From 95205cbcde258c86c2d27691c951f32d58254c01 Mon Sep 17 00:00:00 2001 From: ArtoriasWu Date: Tue, 25 Aug 2026 11:21:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EWCS=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=BC=95=E6=93=8E=E5=8E=9F=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增“WCS模板原型”入口及自检脚本,支持无代码拖拽流程设计、选位试算、实例运行、预占管理等。实现了DSL类型定义、控件库、画布节点、节点属性面板、试算台等组件。补充了流程编排、参数组装、表达式求值、选位与打分、预占管理、模板校验等核心逻辑。提供假数据、演示脚本及README说明,便于后续对接真实系统。 --- MiGu.Server/Auth/PageCatalog.cs | 1 + .../apps/simple-platform-vue/package.json | 3 +- .../simple-platform-vue/src/config/navMenu.ts | 1 + .../apps/simple-platform-vue/src/mock/rbac.ts | 1 + .../simple-platform-vue/src/router/index.ts | 1 + .../src/views/admin/WcsTemplateProtoView.vue | 297 +++++++++++++ .../src/wcs-proto/README.md | 41 ++ .../src/wcs-proto/designer/WcsFlowNode.vue | 92 ++++ .../wcs-proto/designer/WcsNodeInspector.vue | 401 ++++++++++++++++++ .../src/wcs-proto/designer/WcsPalette.vue | 133 ++++++ .../designer/WcsTemplateDesigner.vue | 351 +++++++++++++++ .../src/wcs-proto/designer/WcsTrialPanel.vue | 109 +++++ .../src/wcs-proto/designer/flowBridge.ts | 331 +++++++++++++++ .../src/wcs-proto/designer/nodeCatalog.ts | 89 ++++ .../src/wcs-proto/engine/context.ts | 118 ++++++ .../src/wcs-proto/engine/expr.ts | 136 ++++++ .../src/wcs-proto/engine/match.ts | 135 ++++++ .../src/wcs-proto/engine/orchestrator.ts | 282 ++++++++++++ .../src/wcs-proto/engine/reservation.ts | 75 ++++ .../src/wcs-proto/engine/validate.ts | 86 ++++ .../src/wcs-proto/mock/seed.ts | 177 ++++++++ .../src/wcs-proto/runtime/world.ts | 203 +++++++++ .../src/wcs-proto/selfcheck.ts | 19 + .../src/wcs-proto/types.ts | 183 ++++++++ 24 files changed, 3264 insertions(+), 1 deletion(-) create mode 100644 frontends/apps/simple-platform-vue/src/views/admin/WcsTemplateProtoView.vue create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/README.md create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsFlowNode.vue create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsNodeInspector.vue create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsPalette.vue create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsTemplateDesigner.vue create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsTrialPanel.vue create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/designer/flowBridge.ts create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/designer/nodeCatalog.ts create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/engine/context.ts create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/engine/expr.ts create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/engine/match.ts create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/engine/orchestrator.ts create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/engine/reservation.ts create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/engine/validate.ts create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/mock/seed.ts create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/runtime/world.ts create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/selfcheck.ts create mode 100644 frontends/apps/simple-platform-vue/src/wcs-proto/types.ts diff --git a/MiGu.Server/Auth/PageCatalog.cs b/MiGu.Server/Auth/PageCatalog.cs index e37191f..1e4d398 100644 --- a/MiGu.Server/Auth/PageCatalog.cs +++ b/MiGu.Server/Auth/PageCatalog.cs @@ -42,6 +42,7 @@ public static class PageCatalog new("admin-processes", "进程管理", "设计与编排", ScopePlatform), new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform), new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform), + new("admin-wcs-template-proto", "WCS模板原型", "设计与编排", ScopePlatform), new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform), // ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ── diff --git a/frontends/apps/simple-platform-vue/package.json b/frontends/apps/simple-platform-vue/package.json index 8b0ceb0..80cc19b 100644 --- a/frontends/apps/simple-platform-vue/package.json +++ b/frontends/apps/simple-platform-vue/package.json @@ -8,7 +8,8 @@ "dev": "vite", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", - "typecheck": "vue-tsc --noEmit" + "typecheck": "vue-tsc --noEmit", + "wcs-proto:selfcheck": "npx --yes tsx src/wcs-proto/selfcheck.ts" }, "dependencies": { "@element-plus/icons-vue": "^2.3.1", diff --git a/frontends/apps/simple-platform-vue/src/config/navMenu.ts b/frontends/apps/simple-platform-vue/src/config/navMenu.ts index 1bfd9ae..4ebb5b6 100644 --- a/frontends/apps/simple-platform-vue/src/config/navMenu.ts +++ b/frontends/apps/simple-platform-vue/src/config/navMenu.ts @@ -56,6 +56,7 @@ export const ADMIN_MENU: NavMenuItem[] = [ { path: '/admin/processes', label: '进程管理', icon: Cpu, key: 'admin-processes', group: '设计与编辑' }, { path: '/admin/scripts', label: '脚本管理', icon: DocumentCopy, key: 'admin-scripts', group: '设计与编辑' }, { path: '/admin/task-templates', label: '任务编排', icon: Operation, key: 'admin-task-templates', group: '设计与编辑' }, + { path: '/admin/wcs-template-proto', label: 'WCS模板原型', icon: SetUp, key: 'admin-wcs-template-proto', group: '设计与编辑' }, { path: '/admin/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编辑' } ] }, diff --git a/frontends/apps/simple-platform-vue/src/mock/rbac.ts b/frontends/apps/simple-platform-vue/src/mock/rbac.ts index 9a6ddf6..4f23ef4 100644 --- a/frontends/apps/simple-platform-vue/src/mock/rbac.ts +++ b/frontends/apps/simple-platform-vue/src/mock/rbac.ts @@ -22,6 +22,7 @@ const PAGES: PageDef[] = [ { key: 'admin-processes', label: '进程管理', group: '设计与编排', scope: 'Platform' }, { key: 'admin-scripts', label: '脚本管理', group: '设计与编排', scope: 'Platform' }, { key: 'admin-task-templates', label: '任务编排', group: '设计与编排', scope: 'Platform' }, + { key: 'admin-wcs-template-proto', label: 'WCS模板原型', group: '设计与编排', scope: 'Platform' }, { key: 'admin-simple-fields', label: '字段管理', group: '设计与编排', scope: 'Platform' }, { key: 'admin-config-strategy', label: '调度策略', group: '平台配置中心', scope: 'Platform' }, { key: 'admin-vehicle-hub', label: '车辆运维', group: '平台配置中心', scope: 'Platform' }, diff --git a/frontends/apps/simple-platform-vue/src/router/index.ts b/frontends/apps/simple-platform-vue/src/router/index.ts index 4521811..6449ac5 100644 --- a/frontends/apps/simple-platform-vue/src/router/index.ts +++ b/frontends/apps/simple-platform-vue/src/router/index.ts @@ -39,6 +39,7 @@ const routes: RouteRecordRaw[] = [ { path: 'processes', name: 'admin-processes', component: () => import('@/views/admin/ProcessPanelView.vue'), meta: { title: '进程管理' } }, { path: 'scripts', name: 'admin-scripts', component: () => import('@/views/admin/ScriptPanelView.vue'), meta: { title: '脚本管理' } }, { path: 'task-templates', name: 'admin-task-templates', component: () => import('@/views/admin/TaskTemplateView.vue'), meta: { title: '任务编排' } }, + { path: 'wcs-template-proto', name: 'admin-wcs-template-proto', component: () => import('@/views/admin/WcsTemplateProtoView.vue'), meta: { title: 'WCS模板引擎原型' } }, { path: 'simple-fields', name: 'admin-simple-fields', component: () => import('@/views/admin/SimpleFieldManagementView.vue'), meta: { title: '字段管理' } }, { path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } }, // ── 平台配置中心:聚合页 + 独立业务页(page key = route.name,对齐后端 PageCatalog)。 ── diff --git a/frontends/apps/simple-platform-vue/src/views/admin/WcsTemplateProtoView.vue b/frontends/apps/simple-platform-vue/src/views/admin/WcsTemplateProtoView.vue new file mode 100644 index 0000000..666e05f --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/admin/WcsTemplateProtoView.vue @@ -0,0 +1,297 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/README.md b/frontends/apps/simple-platform-vue/src/wcs-proto/README.md new file mode 100644 index 0000000..f37e8f2 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/README.md @@ -0,0 +1,41 @@ +# WCS 任务模板引擎 · 原型 + +不接真实 MES / 仓库事务 / AGV,用假数据跑通操作逻辑: + +触发 → 参数绑定 → 过滤 / 打分 / 降级 → 成对预占(失败全体回滚)→ 下发 / 取消善后。 + +## 入口 + +- 页面:管理员菜单 **设计与编辑 → WCS模板原型** +- 路由:`/admin/wcs-template-proto` +- 自检:`pnpm wcs-proto:selfcheck`(在 `simple-platform-vue` 目录) + +## 目录 + +| 路径 | 说明 | +|---|---| +| `types.ts` | DSL 与运行时类型 | +| `mock/seed.ts` | 库位、参数目录、预置模板 | +| `engine/*` | 条件求值、选位、预占、校验、编排 | +| `runtime/world.ts` | 内存世界 + 7 条演示脚本 | +| `selfcheck.ts` | 无 UI 自检 | + +## 页面能力 + +### 模板设计(主界面,对齐设计文档 §4) + +- **左侧**:模板列表 + 可拖拽控件库(触发 / 参数 / 过滤 / 打分 / 预占 / 动作…) +- **中间**:Vue Flow 流水线画布(源策略左列、宿策略右列、主轴为触发→绑定→预占→动作) +- **右侧**:点选节点后用表单编辑(条件行、硬性/弹性、打分函数等,默认不写脚本) +- **底部**:试算台(样例参数 → 候选库位 / 降级说明) +- **高级**:抽屉中可查看/粘贴 DSL JSON + +### 运行与演示 + +1. **实例**:模拟触发后推进开始/完成/取消 +2. **假数据台**:改库位亲和、禁用、强制预占 +3. **演示脚本**:一键跑通 7 条验收用例 + +## 下一步(P0) + +把 Mock 换成真实 ParamProvider、仓库读模型、预占落库与 DispatchAdapter;编排与 DSL 尽量不改。 diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsFlowNode.vue b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsFlowNode.vue new file mode 100644 index 0000000..5208bff --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsFlowNode.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsNodeInspector.vue b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsNodeInspector.vue new file mode 100644 index 0000000..4e85c68 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsNodeInspector.vue @@ -0,0 +1,401 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsPalette.vue b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsPalette.vue new file mode 100644 index 0000000..9280335 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsPalette.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsTemplateDesigner.vue b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsTemplateDesigner.vue new file mode 100644 index 0000000..de2ae20 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsTemplateDesigner.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsTrialPanel.vue b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsTrialPanel.vue new file mode 100644 index 0000000..931e7de --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/WcsTrialPanel.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/designer/flowBridge.ts b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/flowBridge.ts new file mode 100644 index 0000000..00a175e --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/flowBridge.ts @@ -0,0 +1,331 @@ +import type { Edge, Node } from '@vue-flow/core' +import type { + FilterGroup, + ParamBinding, + ScoreRule, + TaskTemplateDsl +} from '../types' +import { WCS_NODE_MAP, type WcsNodeType } from './nodeCatalog' + +export type WcsFlowNodeData = { + type: WcsNodeType + label: string + summary: string + /** 绑定在 DSL 中的定位 */ + ref?: { + kind: 'meta' | 'trigger' | 'binding' | 'filter' | 'score' | 'allocate' | 'action' | 'policy' | 'end' + side?: 'source' | 'target' + id?: string + index?: number + } +} + +export type WcsFlowNode = Node + +const COL = { main: 280, source: 80, target: 480 } +const ROW_H = 90 + +function summaryTrigger(tpl: TaskTemplateDsl): string { + return `${tpl.trigger.type} · ${tpl.trigger.source}` +} + +function summaryBinding(b: ParamBinding): string { + return `${b.as} ← ${b.from}` +} + +function summaryFilter(g: FilterGroup): string { + const n = g.expr.type === 'group' ? g.expr.children.length : 1 + return `${g.severity === 'hard' ? '硬性' : '弹性'} · ${n} 条条件` +} + +function summaryScore(s: ScoreRule): string { + return `${s.function} · w=${s.weight}` +} + +/** 将 DSL 展开为纵向流水线节点(源/宿分两列展示策略节点) */ +export function dslToFlow(tpl: TaskTemplateDsl): { nodes: WcsFlowNode[]; edges: Edge[] } { + const nodes: WcsFlowNode[] = [] + const edges: Edge[] = [] + let y = 40 + + const add = ( + id: string, + type: WcsNodeType, + label: string, + summary: string, + position: { x: number; y: number }, + ref?: WcsFlowNodeData['ref'] + ) => { + nodes.push({ + id, + type: 'wcs', + position, + data: { type, label, summary, ref }, + draggable: true + }) + } + + add('n-trigger', 'trigger', '触发', summaryTrigger(tpl), { x: COL.main, y }, { kind: 'trigger' }) + y += ROW_H + + const bindIds: string[] = [] + tpl.bindings.forEach((b, i) => { + const id = `n-bind-${i}` + bindIds.push(id) + add(id, 'binding', '参数绑定', summaryBinding(b), { x: COL.main, y }, { kind: 'binding', index: i, id: b.as }) + y += ROW_H * 0.75 + }) + if (!tpl.bindings.length) { + add('n-bind-empty', 'binding', '参数绑定', '点击右侧添加绑定', { x: COL.main, y }, { kind: 'binding', index: -1 }) + bindIds.push('n-bind-empty') + y += ROW_H + } + + const srcStartY = y + let sy = srcStartY + let ty = srcStartY + const srcFilterIds: string[] = [] + const tgtFilterIds: string[] = [] + + tpl.locationStrategies.source.filterGroups.forEach((g) => { + const id = `n-filter-source-${g.id}` + srcFilterIds.push(id) + add(id, 'filter', `源过滤 · ${g.id}`, summaryFilter(g), { x: COL.source, y: sy }, { + kind: 'filter', + side: 'source', + id: g.id + }) + sy += ROW_H + }) + tpl.locationStrategies.target.filterGroups.forEach((g) => { + const id = `n-filter-target-${g.id}` + tgtFilterIds.push(id) + add(id, 'filter', `宿过滤 · ${g.id}`, summaryFilter(g), { x: COL.target, y: ty }, { + kind: 'filter', + side: 'target', + id: g.id + }) + ty += ROW_H + }) + + const srcScoreIds: string[] = [] + const tgtScoreIds: string[] = [] + tpl.locationStrategies.source.scores.forEach((s) => { + const id = `n-score-source-${s.id}` + srcScoreIds.push(id) + add(id, 'score', `源打分 · ${s.id}`, summaryScore(s), { x: COL.source, y: sy }, { + kind: 'score', + side: 'source', + id: s.id + }) + sy += ROW_H + }) + tpl.locationStrategies.target.scores.forEach((s) => { + const id = `n-score-target-${s.id}` + tgtScoreIds.push(id) + add(id, 'score', `宿打分 · ${s.id}`, summaryScore(s), { x: COL.target, y: ty }, { + kind: 'score', + side: 'target', + id: s.id + }) + ty += ROW_H + }) + + y = Math.max(sy, ty) + 20 + add( + 'n-allocate', + 'allocate', + '成对预占', + `topN=${tpl.locationStrategies.source.allocate.topN ?? 5} · 失败全体回滚`, + { x: COL.main, y }, + { kind: 'allocate' } + ) + y += ROW_H + add( + 'n-action', + 'action', + '任务动作', + `${tpl.blueprint.taskType} · 自动派车=${tpl.blueprint.options.autoDispatch ? '是' : '否'}`, + { x: COL.main, y }, + { kind: 'action' } + ) + y += ROW_H + add( + 'n-policy', + 'policy', + '运行策略', + `预占${tpl.policy.reservationTtlSec}s · 重试${tpl.policy.allocateMaxAttempts}`, + { x: COL.main, y }, + { kind: 'policy' } + ) + y += ROW_H + add('n-end', 'end', '结束', '流水线完成', { x: COL.main, y }, { kind: 'end' }) + + const link = (a: string, b: string) => { + edges.push({ id: `e-${a}-${b}`, source: a, target: b, type: 'smoothstep' }) + } + + // 主链:trigger → binds → allocate → action → policy → end + let prev = 'n-trigger' + for (const id of bindIds) { + link(prev, id) + prev = id + } + const lastBind = prev + + // 从最后绑定分叉到源/宿过滤 + const firstSrc = srcFilterIds[0] ?? srcScoreIds[0] + const firstTgt = tgtFilterIds[0] ?? tgtScoreIds[0] + if (firstSrc) link(lastBind, firstSrc) + if (firstTgt) link(lastBind, firstTgt) + + const chain = (ids: string[]) => { + for (let i = 0; i < ids.length - 1; i++) link(ids[i], ids[i + 1]) + } + chain(srcFilterIds) + chain(tgtFilterIds) + if (srcFilterIds.length && srcScoreIds.length) link(srcFilterIds[srcFilterIds.length - 1], srcScoreIds[0]) + if (tgtFilterIds.length && tgtScoreIds.length) link(tgtFilterIds[tgtFilterIds.length - 1], tgtScoreIds[0]) + chain(srcScoreIds) + chain(tgtScoreIds) + + const lastSrc = srcScoreIds[srcScoreIds.length - 1] ?? srcFilterIds[srcFilterIds.length - 1] + const lastTgt = tgtScoreIds[tgtScoreIds.length - 1] ?? tgtFilterIds[tgtFilterIds.length - 1] + if (lastSrc) link(lastSrc, 'n-allocate') + if (lastTgt) link(lastTgt, 'n-allocate') + if (!lastSrc && !lastTgt) link(lastBind, 'n-allocate') + + link('n-allocate', 'n-action') + link('n-action', 'n-policy') + link('n-policy', 'n-end') + + return { nodes, edges } +} + +export function defaultNodeLabel(type: WcsNodeType): string { + return WCS_NODE_MAP[type]?.label ?? type +} + +/** 拖入新节点时,往 DSL 追加默认片段并返回新节点 id */ +export function appendDroppedNode( + tpl: TaskTemplateDsl, + type: WcsNodeType, + side: 'source' | 'target' = 'source' +): { tpl: TaskTemplateDsl; focusNodeId: string } { + const copy = JSON.parse(JSON.stringify(tpl)) as TaskTemplateDsl + const uid = () => `g${Date.now().toString(36).slice(-5)}` + + switch (type) { + case 'binding': { + const as = `field_${copy.bindings.length + 1}` + copy.bindings.push({ + as, + from: 'mes.call.materialCode', + required: false, + resolve: 'eventPayload' + }) + return { tpl: copy, focusNodeId: `n-bind-${copy.bindings.length - 1}` } + } + case 'filter': { + const id = uid() + const st = copy.locationStrategies[side] + st.filterGroups.push({ + id, + severity: 'soft', + expr: { + type: 'group', + op: 'and', + children: [ + { + type: 'compare', + left: { ref: 'wcs.disabled' }, + op: 'eq', + right: { const: false } + } + ] + } + }) + // 确保降级链包含新 soft 组的首档 + if (!st.degradeChain.length) { + st.degradeChain = [{ attempt: 0, requireGroups: st.filterGroups.map((g) => g.id) }] + } else { + st.degradeChain[0].requireGroups = Array.from( + new Set([...st.degradeChain[0].requireGroups, id]) + ) + } + return { tpl: copy, focusNodeId: `n-filter-${side}-${id}` } + } + case 'score': { + const id = uid() + copy.locationStrategies[side].scores.push({ + id, + function: 'constant', + weight: 1, + params: { value: 50 } + }) + return { tpl: copy, focusNodeId: `n-score-${side}-${id}` } + } + case 'trigger': + case 'allocate': + case 'action': + case 'policy': + case 'end': + default: + // 单例节点:不重复添加,仅聚焦 + return { + tpl: copy, + focusNodeId: + type === 'trigger' + ? 'n-trigger' + : type === 'allocate' + ? 'n-allocate' + : type === 'action' + ? 'n-action' + : type === 'policy' + ? 'n-policy' + : 'n-end' + } + } +} + +export function refreshNodeSummaries(tpl: TaskTemplateDsl, nodes: WcsFlowNode[]): WcsFlowNode[] { + return nodes.map((n) => { + const data = n.data + if (!data?.ref) return n + const ref = data.ref + let summary = data.summary + let label = data.label + if (ref.kind === 'trigger') summary = summaryTrigger(tpl) + if (ref.kind === 'binding' && ref.index != null && ref.index >= 0 && tpl.bindings[ref.index]) { + summary = summaryBinding(tpl.bindings[ref.index]) + } + if (ref.kind === 'filter' && ref.side && ref.id) { + const g = tpl.locationStrategies[ref.side].filterGroups.find((x) => x.id === ref.id) + if (g) { + label = `${ref.side === 'source' ? '源' : '宿'}过滤 · ${g.id}` + summary = summaryFilter(g) + } + } + if (ref.kind === 'score' && ref.side && ref.id) { + const s = tpl.locationStrategies[ref.side].scores.find((x) => x.id === ref.id) + if (s) { + label = `${ref.side === 'source' ? '源' : '宿'}打分 · ${s.id}` + summary = summaryScore(s) + } + } + if (ref.kind === 'allocate') { + summary = `topN=${tpl.locationStrategies.source.allocate.topN ?? 5} · 失败全体回滚` + } + if (ref.kind === 'action') { + summary = `${tpl.blueprint.taskType} · 自动派车=${tpl.blueprint.options.autoDispatch ? '是' : '否'}` + } + if (ref.kind === 'policy') { + summary = `预占${tpl.policy.reservationTtlSec}s · 重试${tpl.policy.allocateMaxAttempts}` + } + const next: WcsFlowNode = { + ...n, + data: { ...data, label, summary } + } + return next + }) +} diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/designer/nodeCatalog.ts b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/nodeCatalog.ts new file mode 100644 index 0000000..efe5621 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/designer/nodeCatalog.ts @@ -0,0 +1,89 @@ +/** 无代码设计器左侧积木目录(对齐设计文档 §4) */ + +export type WcsNodeType = + | 'trigger' + | 'binding' + | 'filter' + | 'score' + | 'allocate' + | 'action' + | 'policy' + | 'end' + +export interface WcsPaletteItem { + type: WcsNodeType + label: string + category: string + description: string + color: string + /** 拖入后默认侧(过滤/打分) */ + defaultSide?: 'source' | 'target' +} + +export const WCS_CATEGORY_ORDER = ['触发与参数', '库位策略', '任务与策略', '流程'] as const + +export const WCS_NODE_CATALOG: WcsPaletteItem[] = [ + { + type: 'trigger', + label: '触发', + category: '触发与参数', + description: '事件 / 人工触发', + color: '#67c23a' + }, + { + type: 'binding', + label: '参数绑定', + category: '触发与参数', + description: '从 MES/APS/WMS 引入字段', + color: '#409eff' + }, + { + type: 'filter', + label: '过滤条件', + category: '库位策略', + description: '硬性/弹性筛选库位', + color: '#e6a23c', + defaultSide: 'source' + }, + { + type: 'score', + label: '打分偏好', + category: '库位策略', + description: 'FIFO / 距离等排序', + color: '#f56c6c', + defaultSide: 'source' + }, + { + type: 'allocate', + label: '成对预占', + category: '库位策略', + description: '源+宿同时占位', + color: '#9b59b6' + }, + { + type: 'action', + label: '任务动作', + category: '任务与策略', + description: '生成搬运并派车', + color: '#13c2c2' + }, + { + type: 'policy', + label: '运行策略', + category: '任务与策略', + description: '超时、重试、无候选', + color: '#909399' + }, + { + type: 'end', + label: '结束', + category: '流程', + description: '流水线终点', + color: '#606266' + } +] + +export const WCS_NODE_MAP = Object.fromEntries(WCS_NODE_CATALOG.map((n) => [n.type, n])) as Record< + WcsNodeType, + WcsPaletteItem +> diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/engine/context.ts b/frontends/apps/simple-platform-vue/src/wcs-proto/engine/context.ts new file mode 100644 index 0000000..a7a8909 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/engine/context.ts @@ -0,0 +1,118 @@ +import type { ParamBinding, ParamFieldDef } from '../types' +import { LOOKUP_TABLE, PARAM_FIELDS } from '../mock/seed' + +export interface AssembleResult { + ok: boolean + context: Record + suspend?: boolean + error?: string + explain: Array<{ as: string; from: string; value?: unknown; error?: string }> +} + +const cache = new Map() + +export function clearLookupCache() { + cache.clear() +} + +function payloadKey(from: string): string { + // mes.call.materialCode → materialCode (last segment) also try full after module + const parts = from.split('.') + if (parts.length >= 2) return parts[parts.length - 1] + return from +} + +function buildLookupKey(template: string, args: Record, context: Record): string { + return template.replace(/\{(\w+)\}/g, (_, name: string) => { + const path = args[name] + if (!path) return '' + const ctxPath = path.startsWith('context.') ? path.slice(8) : path + return String(context[ctxPath] ?? '') + }) +} + +export function assembleContext( + bindings: ParamBinding[], + eventPayload: Record, + options?: { lookupBroken?: boolean; fields?: ParamFieldDef[] } +): AssembleResult { + const fields = options?.fields ?? PARAM_FIELDS + const context: Record = {} + const explain: AssembleResult['explain'] = [] + const byFrom = new Map(fields.map((f) => [`${f.module}.${f.path}`, f])) + + const ordered = [...bindings].sort((a, b) => { + const ra = a.resolve ?? byFrom.get(a.from)?.resolveMode ?? 'eventPayload' + const rb = b.resolve ?? byFrom.get(b.from)?.resolveMode ?? 'eventPayload' + const rank = (r: string) => (r === 'eventPayload' ? 0 : r === 'session' ? 1 : 2) + return rank(ra) - rank(rb) + }) + + for (const b of ordered) { + const def = byFrom.get(b.from) + const mode = b.resolve ?? def?.resolveMode ?? 'eventPayload' + try { + if (mode === 'eventPayload' || mode === 'session') { + const key = payloadKey(b.from) + let value = eventPayload[key] + if (value === undefined && b.from.includes('.')) { + // also allow nested payload.mes.call.materialCode style + value = eventPayload[b.from] + } + if (value === undefined) value = b.default + if (value === undefined && b.required) { + explain.push({ as: b.as, from: b.from, error: 'required_missing' }) + return { ok: false, context, error: `缺少必填参数 ${b.as}`, explain } + } + context[b.as] = value + explain.push({ as: b.as, from: b.from, value }) + continue + } + + // lookup + if (options?.lookupBroken) { + const onError = b.onError ?? 'fail' + if (onError === 'cached') { + const ck = b.key ? buildLookupKey(b.key.template, b.key.args, context) : b.from + const hit = cache.get(ck) + if (hit && hit.expireAt > Date.now()) { + context[b.as] = hit.value + explain.push({ as: b.as, from: b.from, value: hit.value, error: 'used_cache_after_error' }) + continue + } + } + if (onError === 'default') { + context[b.as] = b.default + explain.push({ as: b.as, from: b.from, value: b.default, error: 'lookup_broken_default' }) + continue + } + if (onError === 'suspend') { + explain.push({ as: b.as, from: b.from, error: 'lookup_suspend' }) + return { ok: false, context, suspend: true, error: 'lookup 暂不可用', explain } + } + explain.push({ as: b.as, from: b.from, error: 'lookup_fail' }) + return { ok: false, context, error: `lookup 失败: ${b.as}`, explain } + } + + if (!b.key) { + explain.push({ as: b.as, from: b.from, error: 'lookup_key_missing' }) + return { ok: false, context, error: `lookup 缺少 key: ${b.as}`, explain } + } + const lk = buildLookupKey(b.key.template, b.key.args, context) + const value = LOOKUP_TABLE[lk] ?? b.default + if (value === undefined && b.required) { + explain.push({ as: b.as, from: b.from, error: 'lookup_miss' }) + return { ok: false, context, error: `lookup 无结果: ${b.as}`, explain } + } + context[b.as] = value + const ttl = (b.cacheTtlSec ?? 60) * 1000 + cache.set(lk, { value, expireAt: Date.now() + ttl }) + explain.push({ as: b.as, from: b.from, value }) + } catch (e) { + explain.push({ as: b.as, from: b.from, error: String(e) }) + return { ok: false, context, error: String(e), explain } + } + } + + return { ok: true, context, explain } +} diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/engine/expr.ts b/frontends/apps/simple-platform-vue/src/wcs-proto/engine/expr.ts new file mode 100644 index 0000000..7919587 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/engine/expr.ts @@ -0,0 +1,136 @@ +import type { CompareOp, ExprNode, StorageLoc, ValueRef } from '../types' + +function readPath(obj: Record, path: string): unknown { + const parts = path.split('.') + let cur: unknown = obj + for (const p of parts) { + if (cur == null || typeof cur !== 'object') return undefined + cur = (cur as Record)[p] + } + return cur +} + +export function locToWcs(loc: StorageLoc): Record { + return { + storageId: loc.storageId, + areaId: loc.areaId, + areaType: loc.areaType, + storageType: loc.storageType, + status: loc.status, + materialAffinity: loc.materialAffinity, + disabled: loc.disabled, + lineId: loc.lineId, + allowInbound: loc.allowInbound, + allowOutbound: loc.allowOutbound, + containerType: loc.containerType, + batchNo: loc.batchNo, + qty: loc.qty, + inboundAt: loc.inboundAt, + x: loc.x, + y: loc.y, + reserved: !!loc.forceReserved + } +} + +function resolveValue( + side: ValueRef | undefined, + wcs: Record, + context: Record +): unknown { + if (!side) return undefined + if ('const' in side) return side.const + const path = side.ref + if (path.startsWith('wcs.')) return readPath({ wcs }, path) ?? readPath(wcs, path.slice(4)) + if (path.startsWith('context.')) return readPath({ context }, path) ?? readPath(context, path.slice(8)) + return undefined +} + +function isEmpty(v: unknown): boolean { + return v === undefined || v === null || v === '' +} + +export interface CompareFail { + reason: string + op: CompareOp +} + +export function evalCompare( + node: Extract, + wcs: Record, + context: Record +): { ok: boolean; fail?: CompareFail } { + const left = resolveValue(node.left, wcs, context) + const right = resolveValue(node.right, wcs, context) + const op = node.op + + if (op === 'exists') return { ok: !isEmpty(left) } + if (op === 'notExists') return { ok: isEmpty(left) } + + if (isEmpty(left) || (node.right && isEmpty(right) && op !== 'eq')) { + // eq with explicit null const still allowed; otherwise null → false + if (!(op === 'eq' && node.right && 'const' in node.right && node.right.const === null)) { + return { ok: false, fail: { reason: 'null_operand', op } } + } + } + + switch (op) { + case 'eq': return { ok: left === right, fail: left === right ? undefined : { reason: `${fmt(left)} ≠ ${fmt(right)}`, op } } + case 'ne': return { ok: left !== right } + case 'in': { + const arr = Array.isArray(right) ? right : [] + const ok = arr.includes(left as never) + return { ok, fail: ok ? undefined : { reason: `${fmt(left)} not in ${fmt(arr)}`, op } } + } + case 'notIn': { + const arr = Array.isArray(right) ? right : [] + return { ok: !arr.includes(left as never) } + } + case 'contains': { + const arr = Array.isArray(left) ? left : [] + const ok = arr.includes(right as never) + return { ok, fail: ok ? undefined : { reason: `${fmt(arr)} 不含 ${fmt(right)}`, op } } + } + case 'notContains': { + const arr = Array.isArray(left) ? left : [] + return { ok: !arr.includes(right as never) } + } + case 'gt': return { ok: Number(left) > Number(right) } + case 'gte': return { ok: Number(left) >= Number(right) } + case 'lt': return { ok: Number(left) < Number(right) } + case 'lte': return { ok: Number(left) <= Number(right) } + case 'between': { + const arr = Array.isArray(right) ? right : [] + const n = Number(left) + return { ok: n >= Number(arr[0]) && n <= Number(arr[1]) } + } + case 'matchesRef': return { ok: left === right } + default: return { ok: false, fail: { reason: `unknown_op:${op}`, op } } + } +} + +function fmt(v: unknown): string { + try { return JSON.stringify(v) } catch { return String(v) } +} + +export function evalExpr( + node: ExprNode, + wcs: Record, + context: Record +): { ok: boolean; failReason?: string } { + if (node.type === 'compare') { + const r = evalCompare(node, wcs, context) + return { ok: r.ok, failReason: r.fail?.reason } + } + if (node.op === 'and') { + for (const c of node.children) { + const r = evalExpr(c, wcs, context) + if (!r.ok) return r + } + return { ok: true } + } + for (const c of node.children) { + const r = evalExpr(c, wcs, context) + if (r.ok) return { ok: true } + } + return { ok: false, failReason: 'or_group_all_failed' } +} diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/engine/match.ts b/frontends/apps/simple-platform-vue/src/wcs-proto/engine/match.ts new file mode 100644 index 0000000..99c4424 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/engine/match.ts @@ -0,0 +1,135 @@ +import type { LocationStrategy, ScoreRule, StorageLoc } from '../types' +import { evalExpr, locToWcs } from './expr' + +export interface ScoredCandidate { + loc: StorageLoc + total: number + scores: Record +} + +export interface StrategyRunResult { + candidates: ScoredCandidate[] + eliminated: Array<{ storageId: string; groupId?: string; reason: string; attempt: number }> + degrade: Array<{ attempt: number; requireGroups: string[]; candidateCount: number }> + pick?: StorageLoc +} + +function scoreOne(loc: StorageLoc, rule: ScoreRule, all: StorageLoc[]): number { + const p = rule.params + switch (rule.function) { + case 'constant': + return Number(p.value ?? 0) + case 'field_match_bonus': { + // not fully wired in proto scores of seed; keep simple + return 0 + } + case 'fifo_age': { + const field = String(p.timeField ?? 'inboundAt') + const t = (loc as unknown as Record)[field] + if (!t || typeof t !== 'string') return 0 + const times = all + .map((x) => (x as unknown as Record)[field]) + .filter((x): x is string => typeof x === 'string') + .map((x) => new Date(x).getTime()) + if (!times.length) return 0 + const oldest = Math.min(...times) + const newest = Math.max(...times) + const cur = new Date(t).getTime() + if (newest === oldest) return 100 + return ((newest - cur) / (newest - oldest)) * 100 + } + case 'nearer_to_ref': { + const refX = Number(p.refX ?? 0) + const refY = Number(p.refY ?? 0) + const dist = Math.hypot(loc.x - refX, loc.y - refY) + const dists = all.map((x) => Math.hypot(x.x - refX, x.y - refY)) + const min = Math.min(...dists) + const max = Math.max(...dists) + if (max === min) return 100 + return (1 - (dist - min) / (max - min)) * 100 + } + default: + return 0 + } +} + +function passGroups( + loc: StorageLoc, + strategy: LocationStrategy, + groupIds: string[], + context: Record +): { ok: boolean; groupId?: string; reason?: string } { + const wcs = locToWcs(loc) + for (const gid of groupIds) { + const g = strategy.filterGroups.find((x) => x.id === gid) + if (!g) continue + const r = evalExpr(g.expr, wcs, context) + if (!r.ok) return { ok: false, groupId: gid, reason: r.failReason ?? 'filter_fail' } + } + return { ok: true } +} + +export function runLocationStrategy( + strategy: LocationStrategy, + universe: StorageLoc[], + context: Record, + reservedIds: Set +): StrategyRunResult { + const eliminated: StrategyRunResult['eliminated'] = [] + const degrade: StrategyRunResult['degrade'] = [] + const chain = strategy.degradeChain.length + ? [...strategy.degradeChain].sort((a, b) => a.attempt - b.attempt) + : [{ attempt: 0, requireGroups: strategy.filterGroups.map((g) => g.id) }] + + let lastCandidates: ScoredCandidate[] = [] + + for (const step of chain) { + // hard safety: never drop hard groups even if misconfigured + const hardIds = strategy.filterGroups.filter((g) => g.severity === 'hard').map((g) => g.id) + const require = Array.from(new Set([...hardIds, ...step.requireGroups])) + + const passed: StorageLoc[] = [] + for (const loc of universe) { + if (reservedIds.has(loc.storageId) || loc.forceReserved) { + eliminated.push({ storageId: loc.storageId, reason: 'already_reserved', attempt: step.attempt }) + continue + } + const r = passGroups(loc, strategy, require, context) + if (!r.ok) { + eliminated.push({ + storageId: loc.storageId, + groupId: r.groupId, + reason: r.reason ?? 'fail', + attempt: step.attempt + }) + continue + } + passed.push(loc) + } + + const scored = passed.map((loc) => { + const scores: Record = {} + let total = 0 + for (const rule of strategy.scores) { + const s = scoreOne(loc, rule, passed) + scores[rule.id] = Math.round(s * 100) / 100 + total += s * rule.weight + } + return { loc, total, scores } + }) + scored.sort((a, b) => b.total - a.total || a.loc.storageId.localeCompare(b.loc.storageId)) + const topN = strategy.allocate.topN ?? 5 + lastCandidates = scored.slice(0, topN) + degrade.push({ attempt: step.attempt, requireGroups: require, candidateCount: lastCandidates.length }) + if (lastCandidates.length) { + return { + candidates: lastCandidates, + eliminated, + degrade, + pick: lastCandidates[0].loc + } + } + } + + return { candidates: lastCandidates, eliminated, degrade } +} diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/engine/orchestrator.ts b/frontends/apps/simple-platform-vue/src/wcs-proto/engine/orchestrator.ts new file mode 100644 index 0000000..dacec19 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/engine/orchestrator.ts @@ -0,0 +1,282 @@ +import type { + ExplainSection, + InstanceStatus, + TaskInstance, + TaskTemplateDsl, + TrialResult, + TriggerEvent +} from '../types' +import { assembleContext } from './context' +import { runLocationStrategy } from './match' +import type { ReservationStore } from './reservation' +import type { StorageLoc } from '../types' + +function now() { + return new Date().toISOString() +} + +function pushTimeline(inst: TaskInstance, status: InstanceStatus, note?: string) { + inst.status = status + inst.updatedAt = now() + inst.timeline.push({ at: inst.updatedAt, status, note }) +} + +export function trialRun( + tpl: TaskTemplateDsl, + payload: Record, + storages: StorageLoc[], + reserved: Set, + lookupBroken?: boolean +): TrialResult { + const assembled = assembleContext(tpl.bindings, payload, { lookupBroken }) + const explain: ExplainSection = { bindings: assembled.explain } + if (!assembled.ok) { + return { + ok: false, + context: assembled.context, + sourceCandidates: [], + targetCandidates: [], + eliminated: [], + degrade: [], + error: assembled.error, + explain + } + } + const src = runLocationStrategy(tpl.locationStrategies.source, storages, assembled.context, reserved) + const tgt = runLocationStrategy(tpl.locationStrategies.target, storages, assembled.context, reserved) + explain.filter = { source: src.eliminated.slice(0, 40), target: tgt.eliminated.slice(0, 40) } + explain.degrade = { source: src.degrade, target: tgt.degrade } + explain.score = { + source: src.candidates.map((c) => ({ id: c.loc.storageId, total: c.total, scores: c.scores })), + target: tgt.candidates.map((c) => ({ id: c.loc.storageId, total: c.total, scores: c.scores })) + } + const eliminated = [ + ...src.eliminated.map((e) => ({ ...e, strategy: 'source' })), + ...tgt.eliminated.map((e) => ({ ...e, strategy: 'target' })) + ] + if (!src.pick || !tgt.pick) { + explain.allocate = { result: 'no_candidate' } + return { + ok: false, + context: assembled.context, + sourceCandidates: src.candidates.map((c) => ({ storageId: c.loc.storageId, total: c.total, scores: c.scores })), + targetCandidates: tgt.candidates.map((c) => ({ storageId: c.loc.storageId, total: c.total, scores: c.scores })), + eliminated, + degrade: [src.degrade, tgt.degrade], + error: '无可用库位候选', + explain + } + } + explain.allocate = { result: 'ok', sourceId: src.pick.storageId, targetId: tgt.pick.storageId } + return { + ok: true, + context: assembled.context, + sourceCandidates: src.candidates.map((c) => ({ storageId: c.loc.storageId, total: c.total, scores: c.scores })), + targetCandidates: tgt.candidates.map((c) => ({ storageId: c.loc.storageId, total: c.total, scores: c.scores })), + eliminated, + degrade: [src.degrade, tgt.degrade], + pick: { sourceId: src.pick.storageId, targetId: tgt.pick.storageId }, + explain + } +} + +export interface RuntimeDeps { + templates: TaskTemplateDsl[] + storages: StorageLoc[] + reservations: ReservationStore + instances: TaskInstance[] + lookupBroken: boolean + audits: Array<{ at: string; type: string; detail: unknown }> +} + +let seq = 1 + +export function handleTrigger(deps: RuntimeDeps, event: TriggerEvent): TaskInstance | null { + const published = deps.templates.filter((t) => t.published && t.trigger.source === event.source) + const sorted = [...published].sort((a, b) => b.meta.priority - a.meta.priority || a.id.localeCompare(b.id)) + + // mutex / single winner + const winners: TaskTemplateDsl[] = [] + const seenGroup = new Set() + for (const t of sorted) { + const mode = t.meta.routeMode ?? 'single_winner' + const g = t.meta.mutexGroup ?? `__solo__${t.id}` + if (mode === 'single_winner') { + if (seenGroup.has(g)) { + deps.audits.push({ + at: now(), + type: 'template_not_selected', + detail: { templateId: t.id, reason: 'mutex_lost', eventId: event.eventId } + }) + continue + } + seenGroup.add(g) + winners.push(t) + // 同组只取一个;无组时每个模板自己的 solo 组 + continue + } + winners.push(t) + } + + // 全局 single_winner:设计默认同一事件只跑优先级最高的一套(跨组也只取第一个 winner) + const chosen = winners[0] + for (const t of winners.slice(1)) { + deps.audits.push({ + at: now(), + type: 'template_not_selected', + detail: { templateId: t.id, reason: 'single_winner', eventId: event.eventId, winner: chosen?.id } + }) + } + + if (!chosen) { + deps.audits.push({ at: now(), type: 'no_template', detail: { eventId: event.eventId } }) + return null + } + + // idempotency + const keyMode = chosen.trigger.idempotency?.keyMode ?? 'eventId' + const idemKey = keyMode === 'eventId' + ? event.eventId + : `${String(event.payload.businessKey ?? event.eventId)}:${chosen.id}` + + const existing = deps.instances.find((i) => i.eventId === idemKey || (i.eventId === event.eventId && i.templateId === chosen.id)) + if (existing && !['Completed', 'Failed', 'Cancelled', 'IgnoredDuplicate'].includes(existing.status)) { + const dup: TaskInstance = { + id: `inst-${seq++}`, + templateId: chosen.id, + templateName: chosen.name, + eventId: event.eventId, + status: 'IgnoredDuplicate', + context: {}, + explain: { arbitration: { winner: chosen.id, duplicateOf: existing.id } }, + createdAt: now(), + updatedAt: now(), + timeline: [{ at: now(), status: 'IgnoredDuplicate', note: `重复事件,沿用 ${existing.id}` }] + } + deps.instances.unshift(dup) + return dup + } + if (existing && ['Completed', 'Failed', 'Cancelled'].includes(existing.status)) { + const hit = chosen.trigger.idempotency?.onTerminalHit ?? 'reject' + if (hit === 'reject') { + const dup: TaskInstance = { + id: `inst-${seq++}`, + templateId: chosen.id, + templateName: chosen.name, + eventId: event.eventId, + status: 'IgnoredDuplicate', + context: {}, + explain: { arbitration: { winner: chosen.id, rejectedTerminal: existing.id } }, + createdAt: now(), + updatedAt: now(), + timeline: [{ at: now(), status: 'IgnoredDuplicate', note: '终态后拒绝重复 eventId' }] + } + deps.instances.unshift(dup) + return dup + } + } + + const inst: TaskInstance = { + id: `inst-${seq++}`, + templateId: chosen.id, + templateName: chosen.name, + eventId: event.eventId, + status: 'Pending', + context: {}, + explain: { arbitration: { winner: chosen.id, priority: chosen.meta.priority, candidates: published.map((p) => p.id) } }, + createdAt: now(), + updatedAt: now(), + timeline: [] + } + pushTimeline(inst, 'Pending', '事件入站') + deps.instances.unshift(inst) + + // Assembling + pushTimeline(inst, 'Assembling') + const assembled = assembleContext(chosen.bindings, event.payload, { lookupBroken: deps.lookupBroken }) + inst.explain.bindings = assembled.explain + if (assembled.suspend) { + pushTimeline(inst, 'Suspended', assembled.error) + return inst + } + if (!assembled.ok) { + inst.error = assembled.error + pushTimeline(inst, 'Failed', assembled.error) + return inst + } + inst.context = assembled.context + + // Allocating with retries + pushTimeline(inst, 'Allocating') + const max = chosen.policy.allocateMaxAttempts ?? 5 + let lastAllocate: unknown + for (let attempt = 0; attempt < max; attempt++) { + const reserved = deps.reservations.reservedIds() + const src = runLocationStrategy(chosen.locationStrategies.source, deps.storages, inst.context, reserved) + const tgt = runLocationStrategy(chosen.locationStrategies.target, deps.storages, inst.context, reserved) + inst.explain.filter = { sourceElim: src.eliminated.length, targetElim: tgt.eliminated.length } + inst.explain.degrade = { source: src.degrade, target: tgt.degrade } + inst.explain.score = { + sourceTop: src.candidates.map((c) => c.loc.storageId), + targetTop: tgt.candidates.map((c) => c.loc.storageId) + } + + if (!src.pick || !tgt.pick) { + lastAllocate = { result: 'no_candidate', attempt } + continue + } + + const pair = deps.reservations.tryReservePair(inst.id, src.pick.storageId, tgt.pick.storageId) + if (!pair.ok) { + lastAllocate = { + result: 'pair_partial_rollback', + attempt, + reason: pair.reason, + tried: { sourceId: src.pick.storageId, targetId: tgt.pick.storageId }, + heldThenReleased: pair.heldThenReleased + } + inst.explain.allocate = lastAllocate + continue + } + + inst.sourceId = src.pick.storageId + inst.targetId = tgt.pick.storageId + inst.explain.allocate = { result: 'ok', sourceId: inst.sourceId, targetId: inst.targetId, attempt } + pushTimeline(inst, 'Reserved', `${inst.sourceId} → ${inst.targetId}`) + + if (chosen.blueprint.options.autoDispatch) { + pushTimeline(inst, 'Dispatched', 'Mock 执行器已接单(待确认完成)') + inst.explain.dispatch = { adapter: 'MockDispatcher', state: 'accepted' } + } + return inst + } + + inst.explain.allocate = lastAllocate ?? { result: 'no_candidate' } + inst.error = '选位失败:无候选或预占冲突' + pushTimeline(inst, 'Failed', inst.error) + return inst +} + +export function mockDispatchAck(inst: TaskInstance, action: 'start' | 'complete' | 'reject', deps: RuntimeDeps) { + if (action === 'start' && inst.status === 'Dispatched') { + pushTimeline(inst, 'InTransit', '执行中') + return + } + if (action === 'complete' && (inst.status === 'Dispatched' || inst.status === 'InTransit')) { + deps.reservations.releaseByInstance(inst.id) + pushTimeline(inst, 'Completed', '搬运完成,预占已释放') + return + } + if (action === 'reject') { + pushTimeline(inst, 'Compensating', '派车拒绝,善后中') + const released = deps.reservations.releaseByInstance(inst.id) + pushTimeline(inst, 'Failed', `已释放预占: ${released.join(',') || '无'}`) + } +} + +export function cancelInstance(inst: TaskInstance, deps: RuntimeDeps) { + if (['Completed', 'Failed', 'Cancelled', 'IgnoredDuplicate'].includes(inst.status)) return + pushTimeline(inst, 'Compensating', '取消善后') + const released = deps.reservations.releaseByInstance(inst.id) + pushTimeline(inst, 'Cancelled', `已释放预占: ${released.join(',') || '无'}`) +} diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/engine/reservation.ts b/frontends/apps/simple-platform-vue/src/wcs-proto/engine/reservation.ts new file mode 100644 index 0000000..7717e09 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/engine/reservation.ts @@ -0,0 +1,75 @@ +import type { Reservation } from '../types' + +/** 内存预占:按 storageId 排序加锁,成对失败则全体释放 */ +export class ReservationStore { + private rows = new Map() + + list(): Reservation[] { + return [...this.rows.values()] + } + + isReserved(storageId: string): boolean { + return this.rows.has(storageId) + } + + reservedIds(): Set { + return new Set(this.rows.keys()) + } + + /** 成对预占:任一失败则回滚已占 */ + tryReservePair(instanceId: string, sourceId: string, targetId: string): { + ok: boolean + reason?: 'conflict' | 'same_slot' + heldThenReleased?: string[] + } { + if (sourceId === targetId) return { ok: false, reason: 'same_slot' } + const ordered = [sourceId, targetId].sort((a, b) => a.localeCompare(b)) + const held: string[] = [] + const now = new Date().toISOString() + for (const id of ordered) { + if (this.rows.has(id)) { + for (const h of held) this.rows.delete(h) + return { ok: false, reason: 'conflict', heldThenReleased: held } + } + this.rows.set(id, { storageId: id, instanceId, status: 'Held', createdAt: now }) + held.push(id) + } + for (const id of held) { + const r = this.rows.get(id)! + r.status = 'Committed' + } + return { ok: true } + } + + releaseByInstance(instanceId: string): string[] { + const released: string[] = [] + for (const [id, r] of this.rows) { + if (r.instanceId === instanceId) { + this.rows.delete(id) + released.push(id) + } + } + return released + } + + /** 演示:手动占住库位(无实例) */ + forceHold(storageId: string, tag = 'manual'): boolean { + if (this.rows.has(storageId)) return false + this.rows.set(storageId, { + storageId, + instanceId: `force:${tag}`, + status: 'Committed', + createdAt: new Date().toISOString() + }) + return true + } + + releaseForce(storageId: string) { + const r = this.rows.get(storageId) + if (r?.instanceId.startsWith('force:')) this.rows.delete(storageId) + } + + clear() { + this.rows.clear() + } +} diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/engine/validate.ts b/frontends/apps/simple-platform-vue/src/wcs-proto/engine/validate.ts new file mode 100644 index 0000000..45b73a8 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/engine/validate.ts @@ -0,0 +1,86 @@ +import type { TaskTemplateDsl } from '../types' +import { PARAM_FIELDS } from '../mock/seed' + +export interface ValidationIssue { + code: string + level: 'error' | 'warn' + message: string + path?: string +} + +export function validateTemplate(tpl: TaskTemplateDsl, mode: 'save' | 'publish'): ValidationIssue[] { + const issues: ValidationIssue[] = [] + const err = (code: string, message: string, path?: string) => + issues.push({ code, level: 'error', message, path }) + const warn = (code: string, message: string, path?: string) => + issues.push({ code, level: 'warn', message, path }) + + if (tpl.schemaVersion !== 1) err('S01', 'schemaVersion 必须为 1') + if (!tpl.name?.trim()) err('S02', '模板名不能为空') + if (typeof tpl.meta?.priority !== 'number') err('S02', 'priority 必须为数字', 'meta.priority') + if (!tpl.locationStrategies?.source || !tpl.locationStrategies?.target) { + err('S03', '必须配置 source 与 target 策略') + } + + const asSet = new Set() + for (const b of tpl.bindings ?? []) { + if (asSet.has(b.as)) err('S04', `绑定名重复: ${b.as}`, 'bindings') + asSet.add(b.as) + if (!PARAM_FIELDS.some((f) => `${f.module}.${f.path}` === b.from)) { + err('S05', `未知参数字段: ${b.from}`, `bindings.${b.as}`) + } + if ((b.resolve === 'lookup' || PARAM_FIELDS.find((f) => `${f.module}.${f.path}` === b.from)?.resolveMode === 'lookup') && !b.key) { + err('S06', `lookup 绑定缺少 key: ${b.as}`, `bindings.${b.as}`) + } + } + + for (const side of ['source', 'target'] as const) { + const st = tpl.locationStrategies[side] + if (!st) continue + const ids = new Set() + for (const g of st.filterGroups) { + if (ids.has(g.id)) err('S07', `过滤组 id 重复: ${g.id}`, `${side}.filterGroups`) + ids.add(g.id) + if (g.severity !== 'hard' && g.severity !== 'soft') err('S07', `非法 severity: ${g.severity}`) + } + for (const s of st.scores) { + if (!['nearer_to_ref', 'fifo_age', 'field_match_bonus', 'constant'].includes(s.function)) { + err('S09', `未知打分函数: ${s.function}`, `${side}.scores`) + } + if (!(s.weight > 0)) err('S09', `weight 必须 > 0: ${s.id}`, `${side}.scores`) + } + const topN = st.allocate?.topN ?? 5 + if (topN < 1 || topN > 100) err('S10', 'topN 应在 1~100', `${side}.allocate.topN`) + + if (mode === 'publish') { + const hard = st.filterGroups.filter((g) => g.severity === 'hard') + if (side === 'source' && !hard.length) err('P04', '源侧至少要有一个 hard 过滤组', `${side}`) + for (const g of st.filterGroups) { + if (!g.expr || g.expr.type !== 'group') err('P03', `过滤组 ${g.id} 根节点必须是 group`, `${side}.${g.id}`) + } + const chain = st.degradeChain ?? [] + for (const step of chain) { + for (const h of hard) { + if (!step.requireGroups.includes(h.id)) { + err('P05', `降级档 ${step.attempt} 未覆盖 hard 组 ${h.id}`, `${side}.degradeChain`) + } + } + for (const d of step.drop ?? []) { + if (hard.some((h) => h.id === d)) err('P05', `不能 drop hard 组 ${d}`, `${side}.degradeChain`) + } + } + if (!st.scores.length) warn('W02', `${side} 未配置打分,将按库位 ID 排序`, side) + } + } + + if (mode === 'publish') { + if (!tpl.trigger?.source) err('P01', 'trigger.source 必填') + if (tpl.blueprint?.slots?.from !== 'source' || tpl.blueprint?.slots?.to !== 'target') { + err('P07', 'blueprint.slots 必须指向 source/target') + } + const ttl = tpl.policy?.reservationTtlSec ?? 0 + if (ttl < 30 || ttl > 3600) err('P08', 'reservationTtlSec 应在 30~3600') + } + + return issues +} diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/mock/seed.ts b/frontends/apps/simple-platform-vue/src/wcs-proto/mock/seed.ts new file mode 100644 index 0000000..2c018a3 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/mock/seed.ts @@ -0,0 +1,177 @@ +import type { ParamFieldDef, StorageLoc, TaskTemplateDsl } from '../types' + +export const SEED_AREAS = [ + { areaId: 'A-STOR', name: '原材料存储区', areaType: 'Storage', lineId: undefined as string | undefined }, + { areaId: 'A-LINE1', name: '一线线边', areaType: 'LineSide', lineId: 'L1' }, + { areaId: 'A-LINE2', name: '二线线边', areaType: 'LineSide', lineId: 'L2' }, + { areaId: 'A-BUF', name: '空箱缓冲', areaType: 'Buffer', lineId: undefined } +] + +export function createSeedStorages(): StorageLoc[] { + return [ + { storageId: 'S-01', areaId: 'A-STOR', areaType: 'Storage', storageType: 'Storage', status: 'FullContainer', materialAffinity: ['M001', 'M002'], disabled: false, allowInbound: true, allowOutbound: true, inboundAt: '2026-08-01T08:00:00Z', x: 10, y: 10, qty: 10 }, + { storageId: 'S-02', areaId: 'A-STOR', areaType: 'Storage', storageType: 'Storage', status: 'FullContainer', materialAffinity: ['M001'], disabled: false, allowInbound: true, allowOutbound: true, inboundAt: '2026-08-03T08:00:00Z', x: 12, y: 10, qty: 8 }, + { storageId: 'S-03', areaId: 'A-STOR', areaType: 'Storage', storageType: 'Storage', status: 'FullContainer', materialAffinity: ['M003'], disabled: false, allowInbound: true, allowOutbound: true, inboundAt: '2026-08-02T08:00:00Z', x: 20, y: 10, qty: 5 }, + { storageId: 'S-04', areaId: 'A-STOR', areaType: 'Storage', storageType: 'Storage', status: 'EmptyContainer', materialAffinity: ['M001'], disabled: false, allowInbound: true, allowOutbound: true, x: 11, y: 12, qty: 0 }, + { storageId: 'S-05', areaId: 'A-STOR', areaType: 'Storage', storageType: 'Storage', status: 'FullContainer', materialAffinity: ['M001'], disabled: true, allowInbound: false, allowOutbound: false, inboundAt: '2026-08-01T09:00:00Z', x: 15, y: 10, qty: 3 }, + { storageId: 'S-06', areaId: 'A-STOR', areaType: 'Storage', storageType: 'Storage', status: 'FullContainer', materialAffinity: ['M001'], disabled: false, allowInbound: true, allowOutbound: true, inboundAt: '2026-07-20T08:00:00Z', x: 30, y: 30, qty: 12 }, + { storageId: 'T-L1-01', areaId: 'A-LINE1', areaType: 'LineSide', storageType: 'LineSide', status: 'EmptyContainer', materialAffinity: [], disabled: false, lineId: 'L1', allowInbound: true, allowOutbound: true, x: 50, y: 10 }, + { storageId: 'T-L1-02', areaId: 'A-LINE1', areaType: 'LineSide', storageType: 'LineSide', status: 'EmptyContainer', materialAffinity: [], disabled: false, lineId: 'L1', allowInbound: true, allowOutbound: true, x: 52, y: 10 }, + { storageId: 'T-L1-03', areaId: 'A-LINE1', areaType: 'LineSide', storageType: 'LineSide', status: 'FullContainer', materialAffinity: ['M001'], disabled: false, lineId: 'L1', allowInbound: false, allowOutbound: true, x: 54, y: 10 }, + { storageId: 'T-L2-01', areaId: 'A-LINE2', areaType: 'LineSide', storageType: 'LineSide', status: 'EmptyContainer', materialAffinity: [], disabled: false, lineId: 'L2', allowInbound: true, allowOutbound: true, x: 50, y: 40 }, + { storageId: 'B-01', areaId: 'A-BUF', areaType: 'Buffer', storageType: 'Buffer', status: 'Empty', materialAffinity: [], disabled: false, allowInbound: true, allowOutbound: true, x: 5, y: 5 } + ] +} + +export const PARAM_FIELDS: ParamFieldDef[] = [ + { module: 'mes', path: 'call.materialCode', valueType: 'string', resolveMode: 'eventPayload', description: '叫料物料号' }, + { module: 'mes', path: 'call.qty', valueType: 'number', resolveMode: 'eventPayload', description: '叫料数量' }, + { module: 'aps', path: 'wo.lineId', valueType: 'string', resolveMode: 'eventPayload', description: '工单产线' }, + { module: 'wms', path: 'policy.lineSideZone', valueType: 'string', resolveMode: 'lookup', description: '产线对应线边库区' } +] + +export const LOOKUP_TABLE: Record = { + 'line:L1': 'A-LINE1', + 'line:L2': 'A-LINE2' +} + +function andGroup(...compares: Array<{ left: string; op: string; right?: unknown; rightRef?: string }>) { + return { + type: 'group' as const, + op: 'and' as const, + children: compares.map((c) => ({ + type: 'compare' as const, + left: { ref: c.left }, + op: c.op as 'eq', + right: c.rightRef ? { ref: c.rightRef } : { const: c.right as never } + })) + } +} + +export function createPresetTemplates(): TaskTemplateDsl[] { + const sourceHard = { + id: 'src-hard', + severity: 'hard' as const, + expr: andGroup( + { left: 'wcs.disabled', op: 'eq', right: false }, + { left: 'wcs.storageType', op: 'eq', right: 'Storage' }, + { left: 'wcs.materialAffinity', op: 'contains', rightRef: 'context.materialCode' }, + { left: 'wcs.status', op: 'in', right: ['FullContainer'] }, + { left: 'wcs.allowOutbound', op: 'eq', right: true } + ) + } + const sourceSoftNear = { + id: 'src-soft-near', + severity: 'soft' as const, + expr: andGroup( + { left: 'wcs.x', op: 'lt', right: 25 } + ) + } + const targetHard = { + id: 'tgt-hard', + severity: 'hard' as const, + expr: andGroup( + { left: 'wcs.disabled', op: 'eq', right: false }, + { left: 'wcs.storageType', op: 'eq', right: 'LineSide' }, + { left: 'wcs.lineId', op: 'eq', rightRef: 'context.lineId' }, + { left: 'wcs.status', op: 'eq', right: 'EmptyContainer' }, + { left: 'wcs.allowInbound', op: 'eq', right: true } + ) + } + const targetSoftZone = { + id: 'tgt-soft-zone', + severity: 'soft' as const, + expr: andGroup( + { left: 'wcs.areaId', op: 'eq', rightRef: 'context.preferZone' } + ) + } + + const base = (id: string, name: string, priority: number): TaskTemplateDsl => ({ + schemaVersion: 1, + id, + name, + published: true, + meta: { priority, mutexGroup: 'line-side-replenish', routeMode: 'single_winner' }, + trigger: { + type: 'event', + source: 'mes.materialCall', + idempotency: { keyMode: 'eventId', onTerminalHit: 'reject' }, + merge: { windowMs: 0, keyFrom: [] } + }, + bindings: [ + { as: 'materialCode', from: 'mes.call.materialCode', required: true, resolve: 'eventPayload' }, + { as: 'qty', from: 'mes.call.qty', required: false, resolve: 'eventPayload', default: 1 }, + { as: 'lineId', from: 'aps.wo.lineId', required: true, resolve: 'eventPayload' }, + { + as: 'preferZone', + from: 'wms.policy.lineSideZone', + required: false, + resolve: 'lookup', + key: { template: 'line:{lineId}', args: { lineId: 'context.lineId' } }, + onError: 'default', + default: 'A-LINE1', + cacheTtlSec: 60 + } + ], + locationStrategies: { + source: { + filterGroups: [sourceHard, sourceSoftNear], + degradeChain: [ + { attempt: 0, requireGroups: ['src-hard', 'src-soft-near'] }, + { attempt: 1, requireGroups: ['src-hard'], drop: ['src-soft-near'] } + ], + scores: [ + { id: 's-fifo', function: 'fifo_age', weight: 1, params: { timeField: 'inboundAt' } }, + { id: 's-near', function: 'nearer_to_ref', weight: 2, params: { refX: 50, refY: 10, metric: 'euclid' } } + ], + allocate: { mode: 'first', topN: 5 } + }, + target: { + filterGroups: [targetHard, targetSoftZone], + degradeChain: [ + { attempt: 0, requireGroups: ['tgt-hard', 'tgt-soft-zone'] }, + { attempt: 1, requireGroups: ['tgt-hard'], drop: ['tgt-soft-zone'] } + ], + scores: [ + { id: 't-near', function: 'nearer_to_ref', weight: 1, params: { refX: 50, refY: 10, metric: 'euclid' } } + ], + allocate: { mode: 'first', topN: 5 } + } + }, + blueprint: { + taskType: 'transport', + slots: { from: 'source', to: 'target' }, + options: { autoDispatch: true, priority: 50 } + }, + policy: { + reservationTtlSec: 120, + allocateMaxAttempts: 5, + allocateBackoffMs: [0, 0, 0, 0, 0], + onNoCandidate: 'raise_alert', + onRealityDrift: 'fail', + onDispatchReject: 'compensate' + } + }) + + const a = base('tpl-line-replenish-A', '线边补料-A(高优先)', 200) + const b = base('tpl-line-replenish-B', '线边补料-B(低优先)', 100) + // B 更宽:源不要求 FullContainer,仅 soft 更松 + b.locationStrategies.source.filterGroups = [ + { + id: 'src-hard', + severity: 'hard', + expr: andGroup( + { left: 'wcs.disabled', op: 'eq', right: false }, + { left: 'wcs.materialAffinity', op: 'contains', rightRef: 'context.materialCode' } + ) + } + ] + b.locationStrategies.source.degradeChain = [{ attempt: 0, requireGroups: ['src-hard'] }] + return [a, b] +} + +export const DEMO_EVENT = { + eventId: 'evt-demo-001', + source: 'mes.materialCall', + payload: { materialCode: 'M001', qty: 1, lineId: 'L1', priority: 50 } +} diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/runtime/world.ts b/frontends/apps/simple-platform-vue/src/wcs-proto/runtime/world.ts new file mode 100644 index 0000000..8e0e7d4 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/runtime/world.ts @@ -0,0 +1,203 @@ +import { reactive } from 'vue' +import type { StorageLoc, TaskInstance, TaskTemplateDsl, TrialResult, TriggerEvent } from '../types' +import { createPresetTemplates, createSeedStorages, DEMO_EVENT, SEED_AREAS } from '../mock/seed' +import { ReservationStore } from '../engine/reservation' +import { cancelInstance, handleTrigger, mockDispatchAck, trialRun } from '../engine/orchestrator' +import { validateTemplate } from '../engine/validate' +import { clearLookupCache } from '../engine/context' + +export interface DemoScriptResult { + id: string + name: string + ok: boolean + detail: string +} + +function cloneTemplates(): TaskTemplateDsl[] { + return JSON.parse(JSON.stringify(createPresetTemplates())) as TaskTemplateDsl[] +} + +export function createWorld() { + const state = reactive({ + areas: SEED_AREAS, + storages: createSeedStorages() as StorageLoc[], + templates: cloneTemplates(), + instances: [] as TaskInstance[], + audits: [] as Array<{ at: string; type: string; detail: unknown }>, + lookupBroken: false, + lastTrial: null as TrialResult | null, + lastTriggerEvent: { ...DEMO_EVENT } as TriggerEvent, + demoResults: [] as DemoScriptResult[] + }) + + const reservations = new ReservationStore() + + function deps() { + return { + templates: state.templates, + storages: state.storages, + reservations, + instances: state.instances, + lookupBroken: state.lookupBroken, + audits: state.audits + } + } + + return { + state, + reservations, + reset() { + state.storages = createSeedStorages() + state.templates = cloneTemplates() + state.instances = [] + state.audits = [] + state.lookupBroken = false + state.lastTrial = null + state.demoResults = [] + reservations.clear() + clearLookupCache() + }, + saveTemplate(tpl: TaskTemplateDsl) { + const issues = validateTemplate(tpl, 'save').filter((i) => i.level === 'error') + if (issues.length) return { ok: false as const, issues } + const idx = state.templates.findIndex((t) => t.id === tpl.id) + const copy = JSON.parse(JSON.stringify(tpl)) as TaskTemplateDsl + if (idx >= 0) state.templates[idx] = copy + else state.templates.push(copy) + return { ok: true as const, issues: validateTemplate(copy, 'save') } + }, + publishTemplate(id: string) { + const tpl = state.templates.find((t) => t.id === id) + if (!tpl) return { ok: false as const, issues: [{ code: 'X', level: 'error' as const, message: '模板不存在' }] } + const issues = validateTemplate(tpl, 'publish') + if (issues.some((i) => i.level === 'error')) return { ok: false as const, issues } + tpl.published = true + return { ok: true as const, issues } + }, + trial(templateId: string, payload: Record) { + const tpl = state.templates.find((t) => t.id === templateId) + if (!tpl) throw new Error('模板不存在') + state.lastTrial = trialRun(tpl, payload, state.storages, reservations.reservedIds(), state.lookupBroken) + return state.lastTrial + }, + trigger(event?: TriggerEvent) { + const ev = event ?? (JSON.parse(JSON.stringify(state.lastTriggerEvent)) as TriggerEvent) + state.lastTriggerEvent = ev + return handleTrigger(deps(), ev) + }, + dispatch(instanceId: string, action: 'start' | 'complete' | 'reject') { + const inst = state.instances.find((i) => i.id === instanceId) + if (!inst) return + mockDispatchAck(inst, action, deps()) + }, + cancel(instanceId: string) { + const inst = state.instances.find((i) => i.id === instanceId) + if (!inst) return + cancelInstance(inst, deps()) + }, + forceReserve(storageId: string) { + const loc = state.storages.find((s) => s.storageId === storageId) + if (loc) loc.forceReserved = true + reservations.forceHold(storageId) + }, + clearForce(storageId: string) { + const loc = state.storages.find((s) => s.storageId === storageId) + if (loc) loc.forceReserved = false + reservations.releaseForce(storageId) + }, + runDemoScripts(): DemoScriptResult[] { + const results: DemoScriptResult[] = [] + const run = (id: string, name: string, fn: () => string | void) => { + this.reset() + try { + const detail = fn() ?? 'ok' + results.push({ id, name, ok: true, detail: String(detail) }) + } catch (e) { + results.push({ id, name, ok: false, detail: String(e) }) + } + } + + run('happy', '快乐路径', () => { + const inst = this.trigger({ ...DEMO_EVENT, eventId: 'evt-happy' })! + if (inst.status !== 'Dispatched' && inst.status !== 'Reserved') throw new Error(`状态=${inst.status}`) + if (!inst.sourceId || !inst.targetId) throw new Error('未选中库位') + this.dispatch(inst.id, 'start') + this.dispatch(inst.id, 'complete') + // 状态会被 dispatch 就地改写;避免 TS 把前面的联合收窄带到此处 + if (String(inst.status) !== 'Completed') throw new Error(`完成失败 ${inst.status}`) + return `${inst.sourceId}→${inst.targetId}` + }) + + run('hard', '硬性不放宽', () => { + for (const s of state.storages) { + s.materialAffinity = s.materialAffinity.filter((m) => m !== 'M001') + } + const inst = this.trigger({ ...DEMO_EVENT, eventId: 'evt-hard' })! + if (inst.status !== 'Failed') throw new Error(`期望 Failed,实际 ${inst.status}`) + return '硬性无候选 → Failed' + }) + + run('degrade', '弹性降级', () => { + // 让 soft x<25 失败:近处存储位去掉 M001,保留远处 S-06 + for (const s of state.storages) { + if (['S-01', 'S-02', 'S-04'].includes(s.storageId)) s.materialAffinity = ['M999'] + } + const trial = this.trial('tpl-line-replenish-A', DEMO_EVENT.payload) + if (!trial.ok || !trial.pick) throw new Error('降级后应仍能选到位') + if (trial.pick.sourceId !== 'S-06') throw new Error(`期望源 S-06,实际 ${trial.pick.sourceId}`) + const deg = trial.explain.degrade as { source: Array<{ attempt: number }> } + if (!deg?.source?.some((d) => d.attempt >= 1)) throw new Error('未见降级 attempt>=1') + return `选中 ${trial.pick.sourceId} → ${trial.pick.targetId}(经降级)` + }) + + run('pair_rollback', '成对回滚', () => { + this.forceReserve('T-L1-01') + this.forceReserve('T-L1-02') + const before = reservations.list().length + const inst = this.trigger({ ...DEMO_EVENT, eventId: 'evt-pair' })! + if (inst.status !== 'Failed') throw new Error(`期望 Failed,实际 ${inst.status}`) + const alloc = inst.explain.allocate as { result?: string } + // 可能 no_candidate(过滤阶段已无终点)或 pair_partial_rollback + if (!alloc || (alloc.result !== 'no_candidate' && alloc.result !== 'pair_partial_rollback')) { + throw new Error(`allocate=${JSON.stringify(alloc)}`) + } + const leaked = reservations.list().filter((r) => r.instanceId === inst.id) + if (leaked.length) throw new Error('存在实例残留预占') + return `无残留预占(手动占位仍 ${before} 条)` + }) + + run('dedupe', '去重', () => { + const a = this.trigger({ ...DEMO_EVENT, eventId: 'evt-dup' })! + const b = this.trigger({ ...DEMO_EVENT, eventId: 'evt-dup' })! + if (b.status !== 'IgnoredDuplicate') throw new Error(`第二次应为 IgnoredDuplicate,实际 ${b.status}`) + const active = state.instances.filter((i) => i.eventId === 'evt-dup' && i.status !== 'IgnoredDuplicate') + if (active.length !== 1) throw new Error('活跃实例应只有 1 条') + return `原单 ${a.id},重复 ${b.id}` + }) + + run('arbitrate', '多模板仲裁', () => { + const inst = this.trigger({ ...DEMO_EVENT, eventId: 'evt-arb' })! + if (inst.templateId !== 'tpl-line-replenish-A') throw new Error(`应选 A,实际 ${inst.templateId}`) + const lost = state.audits.some( + (a) => a.type === 'template_not_selected' && (a.detail as { templateId?: string }).templateId === 'tpl-line-replenish-B' + ) + if (!lost) throw new Error('缺少 B 落选审计') + return `胜出 ${inst.templateId}` + }) + + run('cancel', '取消善后', () => { + const inst = this.trigger({ ...DEMO_EVENT, eventId: 'evt-cancel' })! + if (inst.status !== 'Reserved' && inst.status !== 'Dispatched') throw new Error(inst.status) + this.cancel(inst.id) + if (String(inst.status) !== 'Cancelled') throw new Error(inst.status) + if (reservations.list().some((r) => r.instanceId === inst.id)) throw new Error('预占未释放') + return '已取消并释放预占' + }) + + state.demoResults = results + return results + } + } +} + +export type WcsProtoWorld = ReturnType diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/selfcheck.ts b/frontends/apps/simple-platform-vue/src/wcs-proto/selfcheck.ts new file mode 100644 index 0000000..a348461 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/selfcheck.ts @@ -0,0 +1,19 @@ +/** + * 无 UI 自检入口:可在 Node 下用 tsx 跑通 7 条演示脚本。 + * 用法(在 simple-platform-vue 目录):npx tsx src/wcs-proto/selfcheck.ts + */ +import { createWorld } from './runtime/world' + +const world = createWorld() +const results = world.runDemoScripts() +let failed = 0 +for (const r of results) { + const mark = r.ok ? 'PASS' : 'FAIL' + console.log(`[${mark}] ${r.id} ${r.name}: ${r.detail}`) + if (!r.ok) failed++ +} +if (failed) { + console.error(`\n${failed}/${results.length} failed`) + process.exit(1) +} +console.log(`\nAll ${results.length} demo scripts passed.`) diff --git a/frontends/apps/simple-platform-vue/src/wcs-proto/types.ts b/frontends/apps/simple-platform-vue/src/wcs-proto/types.ts new file mode 100644 index 0000000..f49bff2 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/wcs-proto/types.ts @@ -0,0 +1,183 @@ +/** WCS 任务模板引擎原型 — 类型与 DSL(schemaVersion=1) */ + +export type Severity = 'hard' | 'soft' +export type ValueRef = + | { ref: string } + | { const: string | number | boolean | Array | null } + +export type CompareOp = + | 'eq' | 'ne' | 'in' | 'notIn' | 'contains' | 'notContains' + | 'gt' | 'gte' | 'lt' | 'lte' | 'between' | 'exists' | 'notExists' | 'matchesRef' + +export type ExprNode = + | { type: 'group'; op: 'and' | 'or'; children: ExprNode[] } + | { type: 'compare'; left: ValueRef; op: CompareOp; right?: ValueRef } + +export interface FilterGroup { + id: string + severity: Severity + expr: ExprNode +} + +export interface DegradeStep { + attempt: number + requireGroups: string[] + drop?: string[] +} + +export interface ScoreRule { + id: string + function: 'nearer_to_ref' | 'fifo_age' | 'field_match_bonus' | 'constant' + weight: number + params: Record +} + +export interface LocationStrategy { + filterGroups: FilterGroup[] + degradeChain: DegradeStep[] + scores: ScoreRule[] + allocate: { mode: 'first' | 'topN_split'; topN?: number } +} + +export interface ParamBinding { + as: string + from: string + required?: boolean + resolve?: 'eventPayload' | 'lookup' | 'session' + key?: { template: string; args: Record } + onError?: 'cached' | 'suspend' | 'default' | 'fail' + timeoutMs?: number + cacheTtlSec?: number + default?: unknown +} + +export interface TaskTemplateDsl { + schemaVersion: 1 + id: string + name: string + published: boolean + meta: { + priority: number + mutexGroup?: string + routeMode?: 'single_winner' | 'multi' + } + trigger: { + type: 'event' | 'manual' + source: string + idempotency?: { keyMode: 'eventId' | 'businessKey+templateId'; onTerminalHit?: 'reject' | 'new_if_terminal' } + merge?: { windowMs: number; keyFrom: string[] } + } + bindings: ParamBinding[] + locationStrategies: { + source: LocationStrategy + target: LocationStrategy + } + blueprint: { + taskType: 'transport' + slots: { from: 'source'; to: 'target' } + options: { autoDispatch: boolean; priority?: number } + } + policy: { + reservationTtlSec: number + allocateMaxAttempts: number + allocateBackoffMs: number[] + onNoCandidate: 'raise_alert' | 'fail' + onRealityDrift?: 'fail' | 'reallocate' + onDispatchReject?: 'compensate' + } +} + +export interface StorageLoc { + storageId: string + areaId: string + areaType: string + storageType: string + status: string + materialAffinity: string[] + disabled: boolean + lineId?: string + allowInbound: boolean + allowOutbound: boolean + containerType?: string + batchNo?: string + qty?: number + inboundAt?: string + x: number + y: number + /** 演示用:被手动占住 */ + forceReserved?: boolean +} + +export interface ParamFieldDef { + module: string + path: string + valueType: 'string' | 'number' | 'bool' + resolveMode: 'eventPayload' | 'lookup' | 'session' + description: string +} + +export type InstanceStatus = + | 'Pending' + | 'Assembling' + | 'Suspended' + | 'Allocating' + | 'Reserved' + | 'Dispatched' + | 'InTransit' + | 'Completed' + | 'Compensating' + | 'Failed' + | 'Cancelled' + | 'IgnoredDuplicate' + +export interface ExplainSection { + arbitration?: unknown + bindings?: unknown + filter?: unknown + degrade?: unknown + score?: unknown + allocate?: unknown + dispatch?: unknown + [k: string]: unknown +} + +export interface TaskInstance { + id: string + templateId: string + templateName: string + eventId: string + status: InstanceStatus + context: Record + sourceId?: string + targetId?: string + explain: ExplainSection + createdAt: string + updatedAt: string + error?: string + timeline: Array<{ at: string; status: InstanceStatus; note?: string }> +} + +export interface Reservation { + storageId: string + instanceId: string + status: 'Held' | 'Committed' + createdAt: string +} + +export interface TriggerEvent { + eventId: string + source: string + payload: Record +} + +export interface TrialResult { + ok: boolean + context: Record + sourceCandidates: Array<{ storageId: string; total: number; scores: Record }> + targetCandidates: Array<{ storageId: string; total: number; scores: Record }> + eliminated: Array<{ storageId: string; strategy: string; groupId?: string; reason: string }> + degrade: unknown[] + pick?: { sourceId: string; targetId: string } + error?: string + explain: ExplainSection +}