refactor(workbench): simplify appointment tasks
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user