295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
|
import {
|
|
acknowledgeOperationalTaskNotification,
|
|
createOperationalRequestId,
|
|
createOperationalTask,
|
|
decideOperationalTaskAction,
|
|
executeOperationalTaskAction,
|
|
fetchOperationalTask,
|
|
fetchOperationalTaskEvidenceSnapshot,
|
|
fetchOperationalTaskNotifications,
|
|
reconcileOperationalTaskAction,
|
|
type OperationalTask,
|
|
type OperationalTaskDecision,
|
|
type OperationalTaskNotification
|
|
} from "../api/operational-task-client";
|
|
import type { ScheduledConditionRecord, ScheduledConditionRiskLevel, ScheduledConditionStatus } from "../types";
|
|
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
|
|
|
|
|
export type ManualInvestigationKind = "water_quality_complaint" | "burst_report";
|
|
export type CreateInvestigationInput = {
|
|
kind: ManualInvestigationKind;
|
|
title: string;
|
|
summary: string;
|
|
detail: string;
|
|
occurredAt: string;
|
|
priority?: OperationalTask["priority"];
|
|
source?: OperationalTask["source"];
|
|
triggerKind?: "event" | "manual";
|
|
};
|
|
|
|
export function useOperationalTasks(
|
|
getAccessToken: AccessTokenProvider | undefined,
|
|
sync: WorkbenchSyncState
|
|
) {
|
|
const syncEvents = sync.events;
|
|
const acknowledgeEventsThrough = sync.acknowledgeEventsThrough;
|
|
const [tasks, setTasks] = useState<OperationalTask[]>([]);
|
|
const [notifications, setNotifications] = useState<OperationalTaskNotification[]>([]);
|
|
const [available, setAvailable] = useState(false);
|
|
const activatedRef = useRef(false);
|
|
const tasksRef = useRef<OperationalTask[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
|
const forceRefreshRef = useRef(false);
|
|
|
|
const refresh = useCallback(() => {
|
|
forceRefreshRef.current = true;
|
|
setRefreshGeneration((current) => current + 1);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
const load = async () => {
|
|
const summaries = sync.snapshot?.tasks ?? [];
|
|
try {
|
|
const current = new Map(tasksRef.current.map((task) => [task.task_id, task]));
|
|
const changed = forceRefreshRef.current
|
|
? summaries
|
|
: summaries.filter((summary) => current.get(summary.task_id)?.revision !== summary.revision);
|
|
const details = await Promise.all(changed.map((task) =>
|
|
fetchOperationalTask(task.task_id, getAccessToken, controller.signal)
|
|
));
|
|
if (controller.signal.aborted) return;
|
|
const detailMap = new Map(details.map((task) => [task.task_id, task]));
|
|
const nextTasks = summaries.flatMap((summary) => {
|
|
const detail = detailMap.get(summary.task_id) ?? current.get(summary.task_id);
|
|
return detail ? [detail] : [];
|
|
});
|
|
tasksRef.current = nextTasks;
|
|
forceRefreshRef.current = false;
|
|
setTasks(nextTasks);
|
|
setNotifications((sync.snapshot?.notifications ?? []).filter((item) => item.status === "unread"));
|
|
setAvailable(true);
|
|
activatedRef.current = true;
|
|
setError(null);
|
|
setLastSyncedAt(new Date().toISOString());
|
|
} catch (nextError) {
|
|
if (controller.signal.aborted) return;
|
|
if (!activatedRef.current) setAvailable(false);
|
|
setError(nextError instanceof Error ? nextError.message : "工况任务服务暂时不可用。");
|
|
} finally {
|
|
if (!controller.signal.aborted) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
if (sync.snapshot) void load();
|
|
return () => {
|
|
controller.abort();
|
|
};
|
|
}, [getAccessToken, refreshGeneration, sync.snapshot]);
|
|
|
|
useEffect(() => {
|
|
const events = syncEvents;
|
|
if (events.length === 0) return;
|
|
const controller = new AbortController();
|
|
const lastSequence = events.at(-1)!.event_seq;
|
|
const taskIds = new Set<string>();
|
|
let notificationsChanged = false;
|
|
for (const event of events) {
|
|
if (event.topic === "operational.notification.changed") notificationsChanged = true;
|
|
if (!event.topic.startsWith("operational.") || event.topic === "operational.notification.changed") continue;
|
|
const taskId = typeof event.metadata.task_id === "string"
|
|
? event.metadata.task_id
|
|
: event.aggregate_type === "operational_task" ? event.aggregate_id : null;
|
|
if (taskId) taskIds.add(taskId);
|
|
}
|
|
void Promise.all([
|
|
notificationsChanged
|
|
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
|
|
: Promise.resolve(null),
|
|
Promise.all([...taskIds].map((taskId) =>
|
|
fetchOperationalTask(taskId, getAccessToken, controller.signal)
|
|
))
|
|
]).then(([nextNotifications, changedTasks]) => {
|
|
if (controller.signal.aborted) return;
|
|
if (nextNotifications) setNotifications(nextNotifications);
|
|
if (changedTasks.length > 0) {
|
|
const changed = new Map(changedTasks.map((task) => [task.task_id, task]));
|
|
tasksRef.current = [
|
|
...changedTasks,
|
|
...tasksRef.current.filter((task) => !changed.has(task.task_id))
|
|
];
|
|
setTasks(tasksRef.current);
|
|
setLastSyncedAt(new Date().toISOString());
|
|
}
|
|
acknowledgeEventsThrough(lastSequence);
|
|
}).catch((nextError) => {
|
|
if (!controller.signal.aborted) {
|
|
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
|
|
}
|
|
});
|
|
return () => controller.abort();
|
|
}, [acknowledgeEventsThrough, getAccessToken, syncEvents]);
|
|
|
|
const conditions = useMemo(() => tasks.map(toOperationalTaskCondition), [tasks]);
|
|
|
|
const createInvestigation = useCallback(async (input: CreateInvestigationInput) => {
|
|
const task = await createOperationalTask({
|
|
request_id: createOperationalRequestId(),
|
|
task_type: "event_investigation",
|
|
workflow: input.kind,
|
|
title: input.title,
|
|
summary: input.summary,
|
|
priority: input.priority ?? (input.kind === "burst_report" ? "urgent" : "normal"),
|
|
source: input.source ?? { kind: "operator", external_id: null, external_revision: null, event_kind: input.kind },
|
|
trigger: {
|
|
kind: input.triggerKind ?? "manual",
|
|
occurred_at: input.occurredAt,
|
|
detail: input.detail
|
|
}
|
|
}, getAccessToken);
|
|
tasksRef.current = [task, ...tasksRef.current.filter((item) => item.task_id !== task.task_id)];
|
|
setTasks(tasksRef.current);
|
|
activatedRef.current = true;
|
|
setAvailable(true);
|
|
setLoading(false);
|
|
setError(null);
|
|
setLastSyncedAt(new Date().toISOString());
|
|
return task;
|
|
}, [getAccessToken]);
|
|
|
|
const decideAction = useCallback(async (
|
|
taskId: string,
|
|
actionId: string,
|
|
expectedVersion: number,
|
|
decision: OperationalTaskDecision,
|
|
comment?: string
|
|
) => {
|
|
const action = await decideOperationalTaskAction(taskId, actionId, {
|
|
request_id: createOperationalRequestId(),
|
|
expected_version: expectedVersion,
|
|
decision,
|
|
comment
|
|
}, getAccessToken);
|
|
setTasks((current) => current.map((item) => item.task_id === taskId ? {
|
|
...item,
|
|
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
|
|
} : item));
|
|
return action;
|
|
}, [getAccessToken]);
|
|
|
|
const executeAction = useCallback(async (
|
|
taskId: string,
|
|
actionId: string,
|
|
expectedVersion: number
|
|
) => {
|
|
const action = await executeOperationalTaskAction(taskId, actionId, {
|
|
request_id: createOperationalRequestId(),
|
|
expected_version: expectedVersion
|
|
}, getAccessToken);
|
|
setTasks((current) => current.map((item) => item.task_id === taskId ? {
|
|
...item,
|
|
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
|
|
} : item));
|
|
return action;
|
|
}, [getAccessToken]);
|
|
|
|
const reconcileAction = useCallback(async (taskId: string, actionId: string, expectedVersion: number) => {
|
|
const action = await reconcileOperationalTaskAction(taskId, actionId, {
|
|
request_id: createOperationalRequestId(),
|
|
expected_version: expectedVersion
|
|
}, getAccessToken);
|
|
setTasks((current) => current.map((item) => item.task_id === taskId ? {
|
|
...item,
|
|
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
|
|
} : item));
|
|
return action;
|
|
}, [getAccessToken]);
|
|
|
|
const acknowledgeNotification = useCallback(async (notificationId: string, version: number) => {
|
|
await acknowledgeOperationalTaskNotification(
|
|
notificationId,
|
|
version,
|
|
createOperationalRequestId(),
|
|
getAccessToken
|
|
);
|
|
setNotifications((current) => current.filter((item) => item.notification_id !== notificationId));
|
|
}, [getAccessToken]);
|
|
|
|
const loadEvidenceSnapshot = useCallback((taskId: string, evidenceId: string) =>
|
|
fetchOperationalTaskEvidenceSnapshot(taskId, evidenceId, getAccessToken), [getAccessToken]);
|
|
|
|
return {
|
|
available,
|
|
error: error ?? sync.error,
|
|
syncStatus: sync.status,
|
|
lastSyncedAt,
|
|
loading,
|
|
tasks,
|
|
conditions,
|
|
notifications,
|
|
refresh,
|
|
createInvestigation,
|
|
decideAction,
|
|
executeAction,
|
|
reconcileAction,
|
|
acknowledgeNotification,
|
|
loadEvidenceSnapshot
|
|
};
|
|
}
|
|
|
|
export function toOperationalTaskCondition(task: OperationalTask): ScheduledConditionRecord {
|
|
return {
|
|
id: operationalTaskConditionId(task.task_id),
|
|
kind: "condition",
|
|
taskId: "operational-task",
|
|
dataSource: "operational_tasks",
|
|
sessionId: task.active_run?.agent_session_id ?? task.agent_session_id ?? "",
|
|
title: task.title,
|
|
summary: task.summary,
|
|
status: toConditionStatus(task),
|
|
riskLevel: toRiskLevel(task),
|
|
scheduledAt: task.scheduled_for ?? task.trigger.occurred_at,
|
|
updatedAt: Date.parse(task.updated_at),
|
|
detail: task.task_type === "external_work_order"
|
|
? task.work_order_snapshot?.summary ?? task.summary
|
|
: task.trigger.detail,
|
|
recommendation: task.latest_report?.report.current_conclusion || undefined,
|
|
evidence: task.evidence.map((item) => item.summary),
|
|
modelName: "TJWater operational task",
|
|
operationalTask: task
|
|
};
|
|
}
|
|
|
|
export function operationalTaskConditionId(taskId: string) {
|
|
return `operational-task:${taskId}`;
|
|
}
|
|
|
|
function toConditionStatus(task: OperationalTask): ScheduledConditionStatus {
|
|
const status = task.lifecycle_status;
|
|
if (status === "completed") return "completed";
|
|
if (status === "failed") return "error";
|
|
if (status === "cancelled" || status === "waiting_human") return "warning";
|
|
if (task.task_type === "external_work_order") {
|
|
const externalStatus = task.work_order_snapshot?.status;
|
|
if (externalStatus === "completed") return "completed";
|
|
if (externalStatus === "cancelled" || externalStatus === "awaiting_reply") return "warning";
|
|
if (externalStatus === "in_progress") return "running";
|
|
return "pending";
|
|
}
|
|
return task.active_run?.status === "queued" ? "pending" : "running";
|
|
}
|
|
|
|
function toRiskLevel(task: OperationalTask): ScheduledConditionRiskLevel {
|
|
const { priority, lifecycle_status: status } = task;
|
|
if (status === "failed" || priority === "urgent") return "critical";
|
|
if (status === "waiting_human") return "attention";
|
|
return "normal";
|
|
}
|