feat(replay): 排产推演可见阻断订单 + 推演日志丰富化

- /api/flex/gantt 新增 blockedOrders 投影:本版本有冲突但无工单的订单
  (如缺料急单)带原因清单输出,不再在回放里悄悄消失
- 推演面板新增「未排入」条带:订单号/急单标/产品/数量/交期,悬停看
  全部阻断原因;副标题与发布前校验条同步计入阻断订单
- 日志区升级:阻断订单置顶常驻(含首条原因与条目数);工单事件补
  设备名称、数量、加工/换型/移动分钟与起止时刻;新增订单级完工总结
  (N 道工序·按期/超期);可视条数 4→8 且可滚动;载入摘要含版本/
  工单/设备/阻断计数
- 验证:锐扬世界 RUSH-0007 阻断原因完整输出;前端构建通过;
  柔性视图相关 golden 46 全过
This commit is contained in:
z.zhang 2026-10-09 16:01:24 +08:00
parent 5f983fb2c7
commit 323eeb353c
4 changed files with 117 additions and 11 deletions

View File

@ -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)

View File

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

View File

@ -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<ReplayStatus, { label: string; detail: string }> = {
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<string, FlexGanttWO[]>();
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 }) {
<div>
<div className="vlr-eyebrow">排产推演</div>
<div className="vlr-subtitle">
依据当前排产版本 {data.versionNo ?? '未命名'} · {data.sortMode ?? '默认策略'} · {workOrders.length} 道工序回放,不产生新的排产结果
依据当前排产版本 {data.versionNo ?? '未命名'} · {data.sortMode ?? '默认策略'} · {workOrders.length} 道工序回放{blockedOrders.length > 0 ? ` · ${blockedOrders.length} 张订单未排入` : ''},不产生新的排产结果
</div>
</div>
<div className="vlr-clock-group">
@ -226,7 +248,7 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) {
{checkState === 'running' ? '推演进行中' : hasRisk ? '存在待处理风险标记' : '未发现已标记风险'}
</strong>
<span className="vlr-checkbar-counts">
冲突标记 {conflictMarkerCount} · 超期工序 {overdueMarkerCount}
冲突标记 {conflictMarkerCount} · 超期工序 {overdueMarkerCount}{blockedOrders.length > 0 && ` · 阻断订单 ${blockedOrders.length}`}
</span>
<span className="vlr-checkbar-note">依据当前工单标记回放,不替代 P2 发布校验。</span>
</div>
@ -243,6 +265,22 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) {
</div>
))}
</div>
{blockedOrders.length > 0 && (
<div className="vlr-blocked-strip" aria-label="未排入订单">
<span className="vlr-blocked-label">未排入 {blockedOrders.length} 单</span>
{blockedOrders.map(order => (
<span
key={order.orderNo}
className={`vlr-blocked-card${order.isRush ? ' is-rush' : ''}`}
title={order.reasons.join('\n') || '本版本未排出'}
>
<strong>{shortCode(order.orderNo)}</strong>
{order.isRush && <em>急</em>}
· {order.productName} ×{order.quantity} · 交期 {order.dueDate || '—'}
</span>
))}
</div>
)}
<div className="vlr-order-lanes">
<span className="vlr-entry">订单入口</span>
{orders.slice(0, 8).map(order => {
@ -306,11 +344,16 @@ export default function VirtualLineReplay({ data }: { data: FlexGanttData }) {
<footer className="vlr-footer">
<div className="vlr-events" aria-live="polite">
{blockedOrders.map(order => (
<div key={`blocked-${order.orderNo}`} className="vlr-event vlr-event-blocked" title={order.reasons.join('\n')}>
<time>[阻断]</time> ⚠ {order.orderNo} · {order.productName} ×{order.quantity} · 未排入:{order.reasons[0] ?? '资源/物料不满足'}{order.reasons.length > 1 ? `(共 ${order.reasons.length} 项,悬停查看全部)` : ''}
</div>
))}
{events.length ? events.map(event => (
<div key={`${event.time}-${event.text}`} className={`vlr-event vlr-event-${event.kind}`}>
<time>[{formatClock(event.time)}]</time> {event.text}
</div>
)) : <div className="vlr-event">[{formatClock(minTime)}] 排产方案已载入,等待开始推演</div>}
)) : <div className="vlr-event">[{formatClock(minTime)}] 版本 {data.versionNo ?? '未命名'} 已载入:工单 {workOrders.length} 道 · 设备 {equipmentRows.length} 台{blockedOrders.length ? ` · 阻断 ${blockedOrders.length} 单` : ''},等待开始推演</div>}
</div>
<div className="vlr-progress-area">
<strong>推演进度 {(progress * 100).toFixed(1)}%</strong>

View File

@ -226,9 +226,50 @@ def flex_gantt_view(world: World) -> dict:
"zones": [{"code": z.get("code"), "name": z.get("name")} for z in zones],
"equipment": equipment,
"workOrders": wos_out,
"blockedOrders": _blocked_orders_view(world, wos_out, orders_by_no, products, latest_id),
}
def _blocked_orders_view(world: World, wos_out: list[dict], orders_by_no: dict,
products: dict, latest_id) -> list[dict]:
"""阻断订单投影:本版本有冲突但没有任何工单的订单(如缺料急单)。
供排产推演/甘特显式展示「未排入」及其原因,避免订单在回放里悄悄消失。
"""
scheduled_nos = {w["orderNo"] for w in wos_out}
reasons_by_order: dict[str, list[str]] = {}
severity_by_order: dict[str, str] = {}
for c in world.get("flexConflicts", []):
if latest_id and c.get("versionId") != latest_id:
continue
ono = str(c.get("orderNo") or "")
if not ono or ono in scheduled_nos:
continue
sev = str(c.get("severity") or "")
if severity_by_order.get(ono) != "CRITICAL":
severity_by_order[ono] = sev or severity_by_order.get(ono) or ""
text = str(c.get("description") or c.get("conflictType") or "")
if text and text not in reasons_by_order.setdefault(ono, []):
reasons_by_order[ono].append(text)
blocked: list[dict] = []
for ono, reasons in reasons_by_order.items():
o = orders_by_no.get(ono) or {}
prod = products.get(o.get("productCode"))
blocked.append({
"orderNo": ono,
"productCode": o.get("productCode") or "",
"productName": (prod["name"] if prod else None) or o.get("productName") or o.get("productCode") or ono,
"quantity": o.get("quantity"),
"dueDate": o.get("dueDate"),
"priority": o.get("priority"),
"isRush": bool(o.get("isRush")),
"severity": severity_by_order.get(ono) or "",
"reasons": reasons[:8],
})
blocked.sort(key=lambda b: (b.get("dueDate") or "", b["orderNo"]))
return blocked
def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int] | None = None,
start_date: str | None = None, name: str | None = None,
actor: str = "web", window: str | None = None,