From 323eeb353c35463b79df4e48de9eed29912b02f7 Mon Sep 17 00:00:00 2001 From: "z.zhang" Date: Fri, 9 Oct 2026 16:01:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(replay):=20=E6=8E=92=E4=BA=A7=E6=8E=A8?= =?UTF-8?q?=E6=BC=94=E5=8F=AF=E8=A7=81=E9=98=BB=E6=96=AD=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=20+=20=E6=8E=A8=E6=BC=94=E6=97=A5=E5=BF=97=E4=B8=B0=E5=AF=8C?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/flex/gantt 新增 blockedOrders 投影:本版本有冲突但无工单的订单 (如缺料急单)带原因清单输出,不再在回放里悄悄消失 - 推演面板新增「未排入」条带:订单号/急单标/产品/数量/交期,悬停看 全部阻断原因;副标题与发布前校验条同步计入阻断订单 - 日志区升级:阻断订单置顶常驻(含首条原因与条目数);工单事件补 设备名称、数量、加工/换型/移动分钟与起止时刻;新增订单级完工总结 (N 道工序·按期/超期);可视条数 4→8 且可滚动;载入摘要含版本/ 工单/设备/阻断计数 - 验证:锐扬世界 RUSH-0007 阻断原因完整输出;前端构建通过; 柔性视图相关 golden 46 全过 --- apps/web/src/api/types.ts | 14 +++++ apps/web/src/viewport/VirtualLineReplay.css | 10 +++- apps/web/src/viewport/VirtualLineReplay.tsx | 63 +++++++++++++++++---- server/aps_domain/flex.py | 41 ++++++++++++++ 4 files changed, 117 insertions(+), 11 deletions(-) diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index a09d690..162571d 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -320,6 +320,19 @@ export interface FlexGanttWO { dueDate?: string | null; // 交期(检查点投影富化) } +// 阻断订单(本版本有冲突但无工单,如缺料急单;排产推演显式展示) +export interface FlexGanttBlockedOrder { + orderNo: string; // 订单号 + productCode: string; // 产品编码 + productName: string; // 产品名 + quantity: number | null; // 数量 + dueDate: string | null; // 交期 + priority: number | null; // 优先级 + isRush: boolean; // 是否急单 + severity: string; // 最重冲突等级 + reasons: string[]; // 阻断原因清单 +} + // 柔性甘特整体响应 export interface FlexGanttData { versionNo: string | null; // 最新柔性版本号 @@ -327,6 +340,7 @@ export interface FlexGanttData { zones: { code: string | null; name: string | null }[]; // 区域清单(分组) equipment: { id: number; code: string; name: string; zone: string | null }[]; // 设备骨架 workOrders: FlexGanttWO[]; // 最新版本柔性工单 + blockedOrders?: FlexGanttBlockedOrder[]; // 未排入订单(冲突无工单) } // 产能池仪表盘(/api/flex/capacity,EX-10) diff --git a/apps/web/src/viewport/VirtualLineReplay.css b/apps/web/src/viewport/VirtualLineReplay.css index 1a3ba61..5ec39b2 100644 --- a/apps/web/src/viewport/VirtualLineReplay.css +++ b/apps/web/src/viewport/VirtualLineReplay.css @@ -23,7 +23,7 @@ .vlr-order-card strong { display:block; font-size:10px; line-height:12px; }.vlr-order-card span { display:block; font-size:9px; color:#64748b; }.vlr-order-card em { display:inline-block; margin:1px 1px 0; padding:0 3px; border-radius:3px; background:#fee2e2; color:#b91c1c; font-size:8px; font-style:normal; }.vlr-order-card.is-active { transform:translateX(-50%) scale(1.08); border-color:var(--order-color); box-shadow:0 0 0 3px color-mix(in srgb,var(--order-color) 14%,transparent),0 6px 14px rgba(15,23,42,.16); animation:vlr-pulse 1.2s ease-in-out infinite; }.vlr-order-card.is-waiting { opacity:.48; }.vlr-order-card.is-done { opacity:.85; background:#ecfdf5; border-color:#10b981; }.vlr-order-card.is-risk { border-color:#ef4444; background:#fff1f2; }.vlr-order-progress { position:absolute; left:0; bottom:-1px; height:2px; opacity:.65; transition:width .25s linear; } .vlr-load-axis { display:flex; justify-content:space-between; padding:0 5px 5px 94px; color:#64748b; font-size:10px; font-variant-numeric:tabular-nums; }.vlr-load-grid { position:relative; border-top:1px solid #dbe4ee; border-bottom:1px solid #dbe4ee; background:repeating-linear-gradient(90deg,transparent 0,transparent calc(33.333% - 1px),#dbe4ee calc(33.333% - 1px),#dbe4ee 33.333%); }.vlr-load-row { display:grid; grid-template-columns:90px 1fr; min-height:38px; border-bottom:1px solid #e2e8f0; }.vlr-load-row:last-child { border-bottom:0; }.vlr-load-label { display:flex; flex-direction:column; justify-content:center; padding:3px 6px; background:rgba(248,250,252,.92); font-size:10px; }.vlr-load-label strong { line-height:12px; }.vlr-load-label span { color:#64748b; }.vlr-load-track { position:relative; min-width:0; }.vlr-load-bar { position:absolute; top:9px; height:20px; min-width:3px; border-radius:2px; opacity:.95; transition:opacity .2s,transform .2s; }.vlr-load-bar.is-future { opacity:.16; }.vlr-load-bar.is-active { transform:scaleY(1.22); opacity:1; box-shadow:0 0 0 2px rgba(255,255,255,.8),0 0 0 3px rgba(37,99,235,.35); z-index:2; }.vlr-load-bar.is-risk { background:#dc2626 !important; } .vlr-now-line { position:absolute; top:0; bottom:0; width:0; border-left:1px dashed #0f172a; z-index:4; pointer-events:none; transition:left .12s linear; }.vlr-now-line span { position:absolute; top:-18px; left:-29px; width:64px; text-align:center; color:#334155; font-size:9px; font-variant-numeric:tabular-nums; } -.vlr-footer { display:grid; grid-template-columns:1fr 260px; gap:18px; align-items:end; min-height:78px; padding:10px 18px; color:#dbeafe; background:#0f172a; }.vlr-events { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:11px; line-height:18px; }.vlr-event { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.vlr-event time { color:#7dd3fc; }.vlr-event-risk { color:#fdba74; }.vlr-event-finish { color:#86efac; }.vlr-progress-area { color:#e2e8f0; font-size:11px; }.vlr-progress-area strong { display:block; margin-bottom:8px; text-align:right; }.vlr-progress { height:6px; border:1px solid #475569; background:#1e293b; }.vlr-progress span { display:block; height:100%; background:#2563eb; transition:width .12s linear; } +.vlr-footer { display:grid; grid-template-columns:1fr 260px; gap:18px; align-items:end; min-height:78px; padding:10px 18px; color:#dbeafe; background:#0f172a; }.vlr-events { max-height:108px; overflow-y:auto; font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:11px; line-height:18px; }.vlr-event { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.vlr-event time { color:#7dd3fc; }.vlr-event-risk { color:#fdba74; }.vlr-event-finish { color:#86efac; }.vlr-progress-area { color:#e2e8f0; font-size:11px; }.vlr-progress-area strong { display:block; margin-bottom:8px; text-align:right; }.vlr-progress { height:6px; border:1px solid #475569; background:#1e293b; }.vlr-progress span { display:block; height:100%; background:#2563eb; transition:width .12s linear; } @keyframes vlr-pulse { 0%,100%{filter:saturate(1);transform:translateX(-50%) scale(1.06)} 50%{filter:saturate(1.35);transform:translateX(-50%) scale(1.1)} } @media (max-width:1180px){ .vlr { min-width:760px; }.vlr-main { grid-template-columns:1fr; }.vlr-flow-panel { border-right:0; border-bottom:1px solid #dbe4ee; }.vlr-footer { grid-template-columns:1fr 220px; } } .flex-view-switch { display:inline-flex; gap:4px; margin:0 0 8px; padding:3px; border:1px solid #dbe4ee; border-radius:8px; background:#f8fafc; } @@ -43,3 +43,11 @@ .vlr-checkbar { flex-wrap:wrap; } .vlr-checkbar-note { width:100%; margin-left:0; } } + +.vlr-blocked-strip { display:flex; flex-wrap:wrap; align-items:center; gap:6px; margin:0 6px 8px; padding:6px 8px; border:1px dashed #fca5a5; border-radius:8px; background:#fff1f2; } +.vlr-blocked-label { color:#b91c1c; font-size:11px; font-weight:800; white-space:nowrap; } +.vlr-blocked-card { display:inline-flex; align-items:center; gap:3px; max-width:300px; padding:3px 7px; border:1px solid #ef4444; border-radius:6px; background:#fff; color:#7f1d1d; font-size:10px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:help; } +.vlr-blocked-card strong { color:#b91c1c; } +.vlr-blocked-card em { padding:0 3px; border-radius:3px; background:#dc2626; color:#fff; font-size:8px; font-style:normal; } +.vlr-event-blocked { color:#fca5a5; } +.vlr-event-blocked time { color:#f87171; } diff --git a/apps/web/src/viewport/VirtualLineReplay.tsx b/apps/web/src/viewport/VirtualLineReplay.tsx index f29a921..1a44b7a 100644 --- a/apps/web/src/viewport/VirtualLineReplay.tsx +++ b/apps/web/src/viewport/VirtualLineReplay.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import type { FlexGanttData, FlexGanttWO } from '../api/types'; +import type { FlexGanttBlockedOrder, FlexGanttData, FlexGanttWO } from '../api/types'; import './VirtualLineReplay.css'; const COLORS = ['#2563eb', '#f59e0b', '#0ea5e9', '#8b5cf6', '#ef4444', '#10b981', '#ec4899']; @@ -55,6 +55,7 @@ const STATUS_META: Record = { export default function VirtualLineReplay({ data }: { data: FlexGanttData }) { const workOrders = data.workOrders; + const blockedOrders: FlexGanttBlockedOrder[] = data.blockedOrders ?? []; const minTime = Math.min(...workOrders.map(row => toMs(row.start))); const maxTime = Math.max(...workOrders.map(row => toMs(row.end))); const duration = Math.max(maxTime - minTime, 60_000); @@ -95,7 +96,7 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) { }, [duration, maxTime, playing, speed]); const progress = Math.min(Math.max((virtualTime - minTime) / duration, 0), 1); - const hasRisk = workOrders.some(row => row.conflict || row.overdue); + const hasRisk = workOrders.some(row => row.conflict || row.overdue) || blockedOrders.length > 0; const activeRisk = workOrders.some(row => ( (row.conflict || row.overdue) && toMs(row.start) <= virtualTime && toMs(row.end) >= virtualTime )); @@ -153,19 +154,40 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) { const events = useMemo(() => { const rows: { time: number; text: string; kind: 'start' | 'finish' | 'risk' }[] = []; + const byOrder = new Map(); for (const row of workOrders) { + const startMs = toMs(row.start); + const endMs = toMs(row.end); + const durMin = Math.round((endMs - startMs) / 60_000); + const extras = [ + row.changeoverMin ? `换型${row.changeoverMin}分` : '', + row.moveMin ? `移动${row.moveMin}分` : '', + ].filter(Boolean).join(' · '); rows.push({ - time: toMs(row.start), - text: `${row.orderNo} · ${row.operationName} 上机 ${row.equipmentCode}`, + time: startMs, + text: `${row.orderNo} · ${row.operationName} 上机 ${row.equipmentCode}「${row.equipmentName}」×${row.quantity} · 加工${row.runMin}分${extras ? ` · ${extras}` : ''}`, kind: row.conflict || row.overdue ? 'risk' : 'start', }); rows.push({ - time: toMs(row.end), - text: `${row.orderNo} · ${row.operationName} 完成`, + time: endMs, + text: `${row.orderNo} · ${row.operationName} 完成 · ${formatClock(startMs)}~${formatClock(endMs)} · 历时${durMin}分`, kind: row.conflict || row.overdue ? 'risk' : 'finish', }); + byOrder.set(row.orderNo, [...(byOrder.get(row.orderNo) ?? []), row]); } - return rows.filter(row => row.time <= virtualTime).sort((a, b) => b.time - a.time).slice(0, 4); + // 订单级完工总结:整单最后一道工序结束时给一条可读的完结论 + for (const [orderNo, orderWos] of byOrder.entries()) { + const last = [...orderWos].sort((a, b) => toMs(a.end) - toMs(b.end)).at(-1); + if (!last) continue; + const late = orderWos.some(row => row.overdue); + const risk = orderWos.some(row => row.conflict); + rows.push({ + time: toMs(last.end), + text: `✓ ${orderNo} 全部 ${orderWos.length} 道工序完工 · ${last.productName} ×${last.quantity}${late ? ' · 超出交期' : ' · 按期'}`, + kind: late || risk ? 'risk' : 'finish', + }); + } + return rows.filter(row => row.time <= virtualTime).sort((a, b) => b.time - a.time).slice(0, 8); }, [virtualTime, workOrders]); const orderPosition = (order: OrderTrack) => { @@ -201,7 +223,7 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) {
排产推演
- 依据当前排产版本 {data.versionNo ?? '未命名'} · {data.sortMode ?? '默认策略'} · {workOrders.length} 道工序回放,不产生新的排产结果 + 依据当前排产版本 {data.versionNo ?? '未命名'} · {data.sortMode ?? '默认策略'} · {workOrders.length} 道工序回放{blockedOrders.length > 0 ? ` · ${blockedOrders.length} 张订单未排入` : ''},不产生新的排产结果
@@ -226,7 +248,7 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) { {checkState === 'running' ? '推演进行中' : hasRisk ? '存在待处理风险标记' : '未发现已标记风险'} - 冲突标记 {conflictMarkerCount} · 超期工序 {overdueMarkerCount} + 冲突标记 {conflictMarkerCount} · 超期工序 {overdueMarkerCount}{blockedOrders.length > 0 && ` · 阻断订单 ${blockedOrders.length}`} 依据当前工单标记回放,不替代 P2 发布校验。
@@ -243,6 +265,22 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) { ))} + {blockedOrders.length > 0 && ( +
+ 未排入 {blockedOrders.length} 单 + {blockedOrders.map(order => ( + + {shortCode(order.orderNo)} + {order.isRush && 急} + · {order.productName} ×{order.quantity} · 交期 {order.dueDate || '—'} + + ))} +
+ )}
订单入口 {orders.slice(0, 8).map(order => { @@ -306,11 +344,16 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) {