A6 三个去向改选择组,「恰选一口」全链打通 —— 整条流第一次真正跑完

G4 是整条流最后的断点:三个去向写成了必交口里的 outcome 字段 + selective: [],
而引擎只在选择组恰发一口时才注入 outcome —— 结构上永远没有判据。

## 四层贯通

① 契约:验收工单加 `portChoice`(组名 + 可选出口)。draft 是 Omit 透传,human-tool 零改动。
② A6 声明:outcome 移出必交口,三个去向成选择组(won/revise/lost 各一口带人读说明)。
   契约 0.2.0、包版 0.7.0。G4 三条边一行没改 —— outcome 由放行选口注入落地事实。
③ 引擎:验收发卡时从活动契约现读选择组上卡(组是契约的事实,不在步的卡面里声明)。
④ 前端 AcceptForm:选择组渲染为必选单选,不选「通过」不亮;选中的口随放行回传。

## 「恰选一口」拦在收单,不拦在落地 —— 拦错位置实测翻过车

第一版把 validateSelection 放进 release.land:回传是**同步收单**(200 = 收到裁决),
落地在推进器里**异步**跑 —— 拦晚了就是「工单已答完、任务卡死、推进器循环报错」(实测)。
改到 checkWorkOrderReply(与「驳回必附意见」同族的收单规矩):工单投影自带 portChoice,
放行回传缺口/口不在组内 → 400 同步拒收,工单还活着,人补选就是。
land 里的 validateSelection 留作兜底 —— 这条从 contracts 导出以来从未被执行的不变量,
现在两个岗位都有了。

## 实测四连

  不选口通过 → 400「放行前先选『议价结果』」,工单未被吃掉
  选 won    → end_won released,流 completed —— **整条流第一次真正跑完**
  选 revise → A4#2 重开新实例(A4#1 留痕不动)
  选 lost   → A7 情报归档 → end_arch released,流 completed
  全程 route-dead-end 事件 0 条

typecheck ✓ lint ✓ depcruise ✓ 498 测试 ✓

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 16:37:59 +08:00
co-authored by Claude Opus 5
parent 894769e2ca
commit 545103af1b
11 changed files with 169 additions and 12 deletions
+14
View File
@@ -2978,3 +2978,17 @@
.proto-root .jc-tch-head { flex-wrap: wrap; row-gap: 2px; }
.proto-root .jc-tch-desc { flex: 1 0 100%; text-align: left; white-space: normal; }
}
/* ── 验收单的分支结论单选(补充,非原型)──────────────────────────────
选择组「恰发一口,随放行一并确认」:选的这一口就是这单的去向,下游网关判的正是它。
所以做成必选单选,不选「通过」不亮 —— 分支裁决不能靠默认值溜过去。 */
.proto-root .jc-ports { display: flex; flex-direction: column; gap: 6px; }
.proto-root .jc-ports-t { font-size: 11px; font-weight: 800; color: var(--ink-2); display: flex; align-items: baseline; gap: 8px; }
.proto-root .jc-ports-t em { font-style: normal; font-size: 10px; font-weight: 600; color: var(--ink-4); }
.proto-root .jc-port { display: flex; align-items: center; gap: 8px; padding: 8px 11px; border: 1px solid var(--line-soft); border-radius: 10px; background: #fff; font: inherit; font-size: 12px; color: var(--ink-2); cursor: pointer; text-align: left; transition: border-color var(--t-fast), background var(--t-fast); }
.proto-root .jc-port:hover { border-color: #c9d6e6; }
.proto-root .jc-port.on { border-color: var(--brand); background: var(--brand-wash); }
.proto-root .jc-port-dot { flex: none; width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--line); background: #fff; }
.proto-root .jc-port.on .jc-port-dot { border-color: var(--brand); background: var(--brand); box-shadow: inset 0 0 0 2.5px #fff; }
.proto-root .jc-port b { font-weight: 700; }
.proto-root .jc-port em { margin-left: auto; font-style: normal; font-size: 10px; color: var(--ink-4); }
@@ -419,6 +419,10 @@ function AcceptForm({
onLocate: (id: string) => void;
}) {
const [why, setWhy] = useState('');
/** 选择组:恰选一口,选口即裁分支 —— 没选之前「通过」不亮 */
const [port, setPort] = useState<string | undefined>(undefined);
const choice = order.portChoice;
const needChoice = choice !== undefined;
const passN = order.criteria.filter((c) => c.pass).length;
return (
<div className="jc-fc t-acc">
@@ -460,6 +464,24 @@ function AcceptForm({
}))}
/>
)}
{choice !== undefined && (
<div className="jc-ports">
<p className="jc-ports-t">
{choice.group} <em>放行前必选 —— 选的这一口就是这单的去向</em>
</p>
{choice.ports.map((p2) => (
<button
key={p2.name}
className={'jc-port' + (port === p2.name ? ' on' : '')}
onClick={() => setPort(p2.name)}
>
<span className="jc-port-dot" />
<b>{p2.label ?? p2.name}</b>
<em className="jc-mono">{p2.name}</em>
</button>
))}
</div>
)}
<textarea
className="jc-note"
placeholder="要重做?写明原因(也可以在下面对话里聊)"
@@ -487,13 +509,16 @@ function AcceptForm({
<span className="jc-fc-sp" />
<button
className="jc-btn jc-btn-ok"
disabled={needChoice && port === undefined}
title={needChoice && port === undefined ? `先在上面选一个「${choice.group}」` : undefined}
onClick={() =>
onReplyOrder({
mode: 'acceptance',
workOrderId: order.id,
verdict: 'pass',
values: {},
selectedPorts: {},
// 恰选一口:选中的口随放行一并确认 —— 它就是这次运行的 outcome
selectedPorts: choice === undefined || port === undefined ? {} : { [choice.group]: port },
})
}
>