Files
next-tjwater-frontend/src/features/workbench/hooks/use-operational-tasks.ts
T

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";
}