refactor(workbench): simplify appointment tasks

This commit is contained in:
2026-09-24 12:01:11 +08:00
parent 436d5500f1
commit ddf953b641
28 changed files with 443 additions and 1384 deletions
@@ -29,44 +29,13 @@ describe("createOperationalScheduledConditions", () => {
expect(currentCondition?.durationMinutes).toBe(5);
});
it("labels the active work-order stage as executing", () => {
it("leaves appointment orders to the backend projection", () => {
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00"));
const executingWorkOrder = conditions.find(
(condition) =>
condition.kind === "work_order" &&
condition.subtasks.some((subtask) =>
subtask.stages.some(
(stage) => stage.id === "execution" && stage.status === "current"
)
)
);
expect(
executingWorkOrder?.kind === "work_order"
? executingWorkOrder.subtasks
.flatMap((subtask) => subtask.stages)
.find((stage) => stage.id === "execution" && stage.status === "current")?.title
: undefined
).toBe("执行中");
expect(conditions.every((condition) => condition.kind === "condition")).toBe(true);
});
it("moves completed field work into a waiting-for-reply state", () => {
it("does not synthesize a waiting-for-reply appointment", () => {
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T10:40:00+08:00"));
const awaitingReplyWorkOrder = conditions.find(
(condition) =>
condition.kind === "work_order" &&
condition.subtasks.some((subtask) =>
subtask.stages.some((stage) => stage.id === "reply" && stage.status === "current")
)
);
expect(awaitingReplyWorkOrder?.status).toBe("awaiting_reply");
expect(
awaitingReplyWorkOrder?.kind === "work_order"
? awaitingReplyWorkOrder.subtasks
.flatMap((subtask) => subtask.stages)
.find((stage) => stage.id === "reply" && stage.status === "current")?.title
: undefined
).toBe("待复令");
expect(conditions.some((condition) => condition.kind === "work_order")).toBe(false);
});
});
@@ -5,9 +5,7 @@ import type {
ScheduledConditionReport,
ScheduledConditionRiskLevel,
ScheduledConditionStatus,
ScheduledConditionTaskId,
ScheduledWorkOrderSubtask,
ScheduledWorkOrderStatus
ScheduledConditionTaskId
} from "../types";
export const SCHEDULED_CONDITION_REFRESH_INTERVAL_MS = 5_000;
@@ -18,8 +16,6 @@ const MODEL_NAME = "SupplyFlow 前端演示模拟器";
const GUARANTEED_RUNNING_TASK_ID: ConditionTaskId = "scada-diagnosis";
type ConditionTaskId = ScheduledConditionTaskId;
type WorkOrderPhase = "dispatch" | "execution" | "reply";
type ConditionTaskDefinition = {
id: ConditionTaskId;
title: string;
@@ -37,32 +33,6 @@ type TaskSignal = {
taskId: ConditionTaskId;
};
type ManualWorkOrderDefinition = {
id: string;
title: string;
summary: string;
detail: string;
recommendation: string;
source: string;
location: string;
dispatcher: string;
assignee: string;
priority: "normal" | "urgent";
startMinute: number;
durationMinutes: number;
dispatchLeadMinutes: number;
replyWindowMinutes: number;
riskLevel: ScheduledConditionRiskLevel;
replyRequirements: string[];
};
type MockWorkOrderTemplate = Omit<ManualWorkOrderDefinition, "startMinute" | "durationMinutes" | "dispatchLeadMinutes" | "replyWindowMinutes"> & {
stage: "dispatch" | "execution" | "reply";
durationMinutes: number;
dispatchLeadMinutes: number;
replyWindowMinutes: number;
};
type DispatchInstruction = {
action: string;
target: string;
@@ -101,120 +71,6 @@ const CONDITION_TASKS: ConditionTaskDefinition[] = [
}
];
const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
{
id: "mock-incident-valve-dispatch",
title: "演示工单:华山路阀门边界确认",
summary: "异常事件分析后生成阀门边界确认任务,派发前需调度员复核影响范围。",
detail: "该条目是前端演示回退,用于展示人工确认界面,不代表真实提交或岗位通知。",
recommendation: "确认影响用户、上下游压力和应急隔离边界后,再通知执行岗位到场。",
source: "异常事件分析",
location: "华山路沿线阀门组 VG-11",
dispatcher: "调度中心值班长",
assignee: "调度执行岗",
priority: "urgent",
durationMinutes: 40,
dispatchLeadMinutes: 30,
replyWindowMinutes: 20,
riskLevel: "attention",
stage: "dispatch",
replyRequirements: ["确认阀门当前开度", "记录影响分区和用户范围", "回传派发确认截图"]
},
{
id: "mock-valve-operation-execution",
title: "演示工单:北辰分区阀门开度调整",
summary: "根据管网智能调度方案,现场调整北辰分区边界阀门开度并复核压力。",
detail: "该工单模拟系统派发后的执行阶段,班组正在按方案对阀门要素进行操作。",
recommendation: "执行过程中保持调度语音联络,调整后立即复核末端压力和流量变化。",
source: "调度方案确认",
location: "北辰分区边界阀门 BV-07",
dispatcher: "管网调度岗",
assignee: "北辰抢修二组",
priority: "urgent",
durationMinutes: 55,
dispatchLeadMinutes: 25,
replyWindowMinutes: 20,
riskLevel: "attention",
stage: "execution",
replyRequirements: ["上传阀门操作前后照片", "记录最终开度", "复核北辰末端压力不低于 0.22MPa"]
},
{
id: "mock-pump-boundary-reply",
title: "演示工单:泵站边界复核复令",
summary: "泵站出水边界复核已完成,等待班组复令和调度确认闭环。",
detail: "该工单模拟执行完成后的复令阶段,用于检查处置结果、照片和指标回传。",
recommendation: "核对泵站出水压力、频率曲线和分区边界模拟结果后完成复令。",
source: "模型复核建议",
location: "北辰二级泵站 P-02",
dispatcher: "模型复核岗",
assignee: "泵站运行班组",
priority: "normal",
durationMinutes: 45,
dispatchLeadMinutes: 20,
replyWindowMinutes: 35,
riskLevel: "normal",
stage: "reply",
replyRequirements: ["上传泵站运行截图", "填写出水压力复核结果", "确认模型边界已同步更新"]
}
];
const MANUAL_WORK_ORDERS: ManualWorkOrderDefinition[] = [
{
id: "huashan-valve-well-service",
title: "演示人工工单:华山路阀井现场处置",
summary: "现场班组复核华山路阀井积水与井盖松动问题,完成照片和处置记录回传。",
detail: "该条目模拟调度员根据现场巡检反馈创建人工工单,不代表真实提交或派发。",
recommendation: "到点后确认现场安全措施、处置照片和回填记录,必要时关联资产缺陷单。",
source: "现场巡检反馈",
location: "华山路阀井",
dispatcher: "调度中心值班长",
assignee: "北片区抢修一组",
priority: "urgent",
startMinute: 9 * 60 + 40,
durationMinutes: 35,
dispatchLeadMinutes: 20,
replyWindowMinutes: 15,
riskLevel: "attention",
replyRequirements: ["上传处置前后照片", "记录阀井积水处理结果", "复核井盖密封和周边安全状态"]
},
{
id: "renmin-road-meter-change",
title: "演示人工工单:人民路流量计更换",
summary: "更换人民路 DN300 支线流量计通信模块,并复测回传质量。",
detail: "该条目模拟计量维护计划中的人工工单,执行前需确认旁通和作业窗口。",
recommendation: "作业完成后核对 15 分钟回传稳定性,并记录新模块编号。",
source: "计量维护计划",
location: "人民路 DN300 支线",
dispatcher: "计量调度岗",
assignee: "计量维护二组",
priority: "normal",
startMinute: 10 * 60 + 30,
durationMinutes: 50,
dispatchLeadMinutes: 30,
replyWindowMinutes: 20,
riskLevel: "normal",
replyRequirements: ["登记新通信模块编号", "回传 15 分钟稳定性截图", "确认旁通恢复和计量数据连续"]
},
{
id: "beichen-fire-hydrant-repair",
title: "演示人工工单:北辰消防栓维修",
summary: "处理北辰片区消防栓渗漏缺陷,现场确认关停影响和恢复时间。",
detail: "该条目模拟客服缺陷转派,由调度员排入当日维修窗口,不代表真实派发。",
recommendation: "维修前通知影响点位,完成后复核周边压力和栓体密封状态。",
source: "客服缺陷转派",
location: "北辰片区消防栓",
dispatcher: "客服联动调度",
assignee: "北辰维修班组",
priority: "urgent",
startMinute: 14 * 60,
durationMinutes: 60,
dispatchLeadMinutes: 45,
replyWindowMinutes: 20,
riskLevel: "attention",
replyRequirements: ["回填关停影响范围", "上传维修完成照片", "复测周边压力和消防栓密封状态"]
}
];
export function createOperationalScheduledConditions(now = new Date()): ScheduledConditionItem[] {
const currentMinutesOfDay = getMinutesOfDay(now);
const historyStart = Math.max(0, currentMinutesOfDay - HISTORY_WINDOW_MINUTES);
@@ -226,9 +82,7 @@ export function createOperationalScheduledConditions(now = new Date()): Schedule
historicalConditions.some((condition) => condition.status === "running") || !runningCondition
? historicalConditions
: [runningCondition, ...historicalConditions.filter((condition) => condition.id !== runningCondition.id)];
const futureWorkOrders = createUpcomingWorkOrders(now, currentMinutesOfDay);
return [...operationalConditions, ...futureWorkOrders].sort(
return operationalConditions.sort(
(a, b) => Date.parse(b.scheduledAt) - Date.parse(a.scheduledAt)
);
}
@@ -326,202 +180,6 @@ function createGuaranteedRunningCondition(now: Date, currentMinutesOfDay: number
};
}
function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): ScheduledConditionItem[] {
const workOrders = [...MANUAL_WORK_ORDERS, ...createMockWorkOrders(currentMinutesOfDay)];
return workOrders.filter(
(workOrder) => currentMinutesOfDay <= workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes
)
.map((workOrder) => {
const scheduledAtDate = setMinutesOfDay(now, workOrder.startMinute);
const phase = getWorkOrderPhase(workOrder, currentMinutesOfDay);
return {
id: `manual-work-order-${workOrder.id}-${formatCompactLocalTime(scheduledAtDate)}`,
kind: "work_order" as const,
code: `WO-${formatCompactLocalTime(scheduledAtDate)}-${workOrder.id.toUpperCase().slice(0, 4)}`,
scheduledAt: formatLocalIsoWithOffset(scheduledAtDate),
title: workOrder.title,
summary: workOrder.summary,
status: getWorkOrderStatus(phase),
riskLevel: workOrder.riskLevel,
updatedAt: now.getTime(),
detail: workOrder.detail,
recommendation: workOrder.recommendation,
durationMinutes: workOrder.durationMinutes,
source: workOrder.source,
dispatcher: workOrder.dispatcher,
priority: workOrder.priority,
dataSource: "mock" as const,
subtasks: createWorkOrderSubtasks(workOrder, now, scheduledAtDate, phase)
};
})
.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt));
}
function createMockWorkOrders(currentMinutesOfDay: number): ManualWorkOrderDefinition[] {
return MOCK_WORK_ORDER_TEMPLATES.map((template) => {
const startMinute = getMockWorkOrderStartMinute(template, currentMinutesOfDay);
return {
...template,
startMinute
};
});
}
function getMockWorkOrderStartMinute(template: MockWorkOrderTemplate, currentMinutesOfDay: number) {
if (template.stage === "dispatch") {
return Math.min(currentMinutesOfDay + 30, 24 * 60 - 1);
}
if (template.stage === "execution") {
return Math.max(0, currentMinutesOfDay - Math.min(20, template.durationMinutes - 5));
}
return currentMinutesOfDay - template.durationMinutes - Math.min(10, template.replyWindowMinutes - 5);
}
function getWorkOrderPhase(
workOrder: ManualWorkOrderDefinition,
currentMinutesOfDay: number
): WorkOrderPhase {
if (currentMinutesOfDay < workOrder.startMinute) {
return "dispatch";
}
if (currentMinutesOfDay < workOrder.startMinute + workOrder.durationMinutes) {
return "execution";
}
return "reply";
}
function getWorkOrderStatus(phase: WorkOrderPhase): ScheduledWorkOrderStatus {
if (phase === "dispatch") {
return "pending";
}
if (phase === "execution") {
return "running";
}
return "awaiting_reply";
}
function createWorkOrderSubtasks(
workOrder: ManualWorkOrderDefinition,
now: Date,
scheduledAtDate: Date,
phase: WorkOrderPhase
): ScheduledWorkOrderSubtask[] {
const taskTitles = workOrder.replyRequirements.length > 0
? workOrder.replyRequirements
: [workOrder.title];
const activeIndex = phase === "dispatch"
? 0
: phase === "execution"
? Math.min(1, taskTitles.length - 1)
: taskTitles.length - 1;
const subtaskDuration = Math.max(5, Math.ceil(workOrder.durationMinutes / taskTitles.length));
return taskTitles.map((title, index) => {
const position = index < activeIndex ? "before" : index > activeIndex ? "after" : "active";
const status = position === "before"
? "completed"
: position === "after" || phase === "dispatch"
? "pending"
: "running";
const subtaskScheduledAt = new Date(scheduledAtDate.getTime() + index * subtaskDuration * 60_000);
return {
id: `${workOrder.id}-subtask-${index + 1}`,
title,
summary: `完成“${title}”并保留可复核的阶段记录。`,
status,
scheduledAt: formatLocalIsoWithOffset(subtaskScheduledAt),
location: workOrder.location,
assignee: workOrder.assignee,
durationMinutes: subtaskDuration,
replyWindowMinutes: workOrder.replyWindowMinutes,
stages: createWorkOrderSubtaskStages(
workOrder,
subtaskScheduledAt,
subtaskDuration,
phase,
position
),
replyRequirements: [`回传“${title}”的执行结果`]
};
});
}
function createWorkOrderSubtaskStages(
workOrder: ManualWorkOrderDefinition,
subtaskScheduledAt: Date,
subtaskDurationMinutes: number,
phase: WorkOrderPhase,
position: "before" | "active" | "after"
) {
const dispatchAt = new Date(
subtaskScheduledAt.getTime() - workOrder.dispatchLeadMinutes * 60_000
);
const executionEnd = new Date(
subtaskScheduledAt.getTime() + subtaskDurationMinutes * 60_000
);
const replyEnd = new Date(executionEnd.getTime() + workOrder.replyWindowMinutes * 60_000);
const allDone = position === "before";
const allPending = position === "after";
return [
{
id: "dispatch",
sequence: 1,
title: "派发",
status: allDone
? ("done" as const)
: allPending
? ("pending" as const)
: phase === "dispatch"
? ("current" as const)
: ("done" as const),
owner: workOrder.dispatcher,
timeLabel: formatClock(dispatchAt),
description: `确认${workOrder.location}任务范围、优先级和到场窗口,派发给${workOrder.assignee}。`
},
{
id: "execution",
sequence: 2,
title: "执行中",
status:
allDone
? ("done" as const)
: allPending || phase === "dispatch"
? ("pending" as const)
: phase === "execution"
? ("current" as const)
: ("done" as const),
owner: workOrder.assignee,
timeLabel: formatClockRange(subtaskScheduledAt, subtaskDurationMinutes),
description: workOrder.summary
},
{
id: "reply",
sequence: 3,
title: "待复令",
status: allDone
? ("done" as const)
: allPending
? ("pending" as const)
: phase === "reply"
? ("current" as const)
: ("pending" as const),
owner: `${workOrder.assignee} → 调度中心`,
timeLabel: formatClockRange(executionEnd, workOrder.replyWindowMinutes),
description: `提交处置结果、现场照片和影响复核,最迟 ${formatClock(replyEnd)} 前完成复令。`
}
];
}
function getConditionStatus(
task: ConditionTaskDefinition,
scheduledAt: Date,
@@ -1146,10 +804,6 @@ function formatClock(date: Date) {
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
}
function formatClockRange(start: Date, durationMinutes: number) {
const end = new Date(start.getTime() + durationMinutes * 60_000);
return `${formatClock(start)} - ${formatClock(end)}`;
}
function formatCompactLocalTime(date: Date) {
const yyyy = date.getFullYear().toString();