车辆类型:
@@ -172,6 +177,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { reflectionApi, type CarTypeCoderFieldsRow, type ReflectionCreatableType } from '@/api/reflection'
import * as simpleFieldApi from '@/api/simpleField'
+import SetupGuideAlert from '@/components/setup/SetupGuideAlert.vue'
import {
SIMPLE_FIELD_CATEGORIES,
buildCarType,
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 @@
+
+
+
+
+
+
+
WCS 任务模板引擎 · 原型
+
无代码拖拽设计模板 → 试算选位 → 模拟触发运行(假数据,不接真实 MES/AGV)
+
+
+
+ 模板设计
+ 运行与演示
+
+ 重置世界
+ 跑 7 条脚本
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 模拟触发
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.sourceId }} → {{ row.targetId }}
+
+
+
+
+ 开始
+ 完成
+ 取消
+
+
+
+
+
+
+ 时间线
+
+
+ {{ t.status }} · {{ t.note }}
+
+
+ explain
+ {{ JSON.stringify(selectedInstance.explain, null, 2) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ onAffinityInput(row, v)"
+ />
+
+
+
+
+
+
+
+
+
+ onForceChange(row.storageId, v)"
+ />
+
+
+
+ 预占表
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.ok ? '通过' : '失败' }}
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ typeLabel }}
+
+ {{ severityTag }}
+
+
+
{{ data.label }}
+
{{ data.summary }}
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ 触发
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 参数绑定
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 删除此绑定
+
+
+
+
+ 过滤条件 · {{ sideLabel }}
+
+
+
+
+
+
+ 硬性(不可降级放开)
+ 弹性(可降级)
+
+
+
+ 条件行(组内 AND)
+
+
+
+
+
+
+
+
+ 删
+
+ 加条件
+ 删除此过滤组
+
+
+
+ 打分 · {{ sideLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 删除此打分
+
+
+
+
+ 成对预占
+
+
+
+
+
+
+
+
+
+
+
+
+ 任务动作
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 运行策略
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
拖到画布添加积木(过滤/打分可指定源或宿)
+
+ 源侧
+ 宿侧
+
+
+
+
+ {{ group.category }}
+
+
+
+
+
{{ item.label }}
+
{{ item.description }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 左列=源策略 · 右列=宿策略 · 中轴=触发/绑定/预占/动作
+
+
+
+
+
+
+
+
+
+
+ 从 JSON 应用到画布
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
试算台
+
注入样例参数,查看候选库位与淘汰原因(发布前建议必过)
+
+
+
+
试算
+
+
+
+
+
+ 源候选
+
+
+
+
+
+
+ 宿候选
+
+
+
+
+
+
+ 降级
+ {{ JSON.stringify(result.degrade, null, 2) }}
+
+
+
+
配置完流水线后点「试算」,验证选位逻辑
+
+
+
+
+
+
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
+}