forked from ChaiHao/BPOS-MVP
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:
@@ -862,6 +862,13 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>>;
|
||||
portChoice: z.ZodOptional<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
ports: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
passLabel: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
@@ -1836,6 +1843,13 @@ export declare const taskDetailRoute: {
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>>;
|
||||
portChoice: z.ZodOptional<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
ports: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
passLabel: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
|
||||
+14
@@ -936,6 +936,13 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>>;
|
||||
portChoice: z.ZodOptional<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
ports: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
passLabel: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
@@ -1271,6 +1278,13 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>>;
|
||||
portChoice: z.ZodOptional<z.ZodObject<{
|
||||
group: z.ZodString;
|
||||
ports: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
label: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
passLabel: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
|
||||
@@ -63,11 +63,21 @@ describe('MVP_SLOTS', () => {
|
||||
expect(Object.keys(p2)).toContain('needSourcing');
|
||||
});
|
||||
|
||||
it('A6 出口带 outcome 三值 —— G4 议价结果的判据', () => {
|
||||
/*
|
||||
* 三个去向是**选择组**,不是必交口里的一个字段:人在放行时恰选一口,
|
||||
* 那一口就是这次运行的 outcome(引擎按选口注入),G4 判的正是它。
|
||||
* 曾写成 formal 口的必填 outcome + selective: [] —— 引擎只在选择组恰发一口时
|
||||
* 才注入 outcome,没有选择组就结构上永远没有判据,G4 三条边全不成立(实测)。
|
||||
*/
|
||||
it('A6 三个去向是选择组 —— 放行恰选一口,选口即 G4 的判据', () => {
|
||||
const a6 = MVP_SLOTS.find((m) => m.identity.activityId === 'quote-negotiation')!;
|
||||
const p6 = (a6.contract.output.mandatory[0]!.payload as { properties?: Record<string, Record<string, unknown>> })
|
||||
.properties ?? {};
|
||||
expect(p6['outcome']?.['enum']).toEqual(['won', 'revise', 'lost']);
|
||||
const groups = a6.contract.output.selective;
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]!.group).toBe('议价结果');
|
||||
expect(groups[0]!.ports.map((p) => p.name)).toEqual(['won', 'revise', 'lost']);
|
||||
// outcome 不再是必交口的必填字段 —— 它由选口给出,不靠活动自己写
|
||||
const formal = a6.contract.output.mandatory[0]!.payload as { required?: string[] };
|
||||
expect(formal.required ?? []).not.toContain('outcome');
|
||||
});
|
||||
|
||||
it('槽位不携实现:入口是抛「未实现」的占位,工具声明只有名', () => {
|
||||
|
||||
@@ -279,6 +279,7 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
label: '拒绝/超时',
|
||||
tone: 'bad',
|
||||
mapping: [
|
||||
// outcome 不是 formal 口声明的字段 —— 它由放行选口注入落地事实,随每个发射口的载荷同行
|
||||
{ target: 'outcome', source: { from: 'output-port', port: 'formal', path: 'outcome' } },
|
||||
{ target: 'quote_no', source: { from: 'output-port', port: 'formal', path: 'quote_no' } },
|
||||
{ target: 'customer_code', source: { from: 'output-port', port: 'formal', path: 'customer_code' } },
|
||||
|
||||
@@ -261,10 +261,11 @@ const identity = (activityId: string, name: string, paradigm: 'flow' | 'loop') =
|
||||
* 0.6.0:占位期骨架改**机器步落台账 + 一张验收单**(用户定:只留验收要人签)。
|
||||
* 六个槽位一律 flow 范式;预算固定 humanCalls: 2。
|
||||
* 0.6.1:修验收步的出参映射 —— 键是**台账列名**(小写蛇形),不是回传字段名。
|
||||
* 0.7.0:A6 的三个去向改**选择组**(契约 0.2.0)—— outcome 由放行选口给出,G4 才判得了。
|
||||
* 契约的入参出口一直没动,故 `contract.version` 始终 0.1.0。
|
||||
* 在途任务按启动时版本跑完,不受影响。
|
||||
*/
|
||||
packageVersion: '0.6.1',
|
||||
packageVersion: '0.7.0',
|
||||
category: '统一报价',
|
||||
paradigm,
|
||||
sdkVersion: '0.1.0-m5',
|
||||
@@ -609,7 +610,7 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
identity: identity('quote-negotiation', '报价议价', 'flow'),
|
||||
contract: {
|
||||
activityId: 'quote-negotiation',
|
||||
version: '0.1.0',
|
||||
version: '0.2.0',
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
@@ -622,12 +623,10 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
mandatory: [
|
||||
{
|
||||
name: 'formal',
|
||||
description: '正式报价单(已送出)+ 跟进提醒 + 议价结果',
|
||||
description: '正式报价单(已送出)+ 跟进提醒',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['outcome'],
|
||||
properties: {
|
||||
outcome: { type: 'string', enum: ['won', 'revise', 'lost'] },
|
||||
quote_no: { type: 'string' },
|
||||
customer_code: { type: 'string' },
|
||||
rows: { type: 'array' },
|
||||
@@ -635,7 +634,22 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
},
|
||||
},
|
||||
],
|
||||
selective: [],
|
||||
/*
|
||||
* 三个去向是**选择组**,不是必交口里的一个字段:人在放行时恰选一口,
|
||||
* 那一口就是这次运行的 outcome(引擎按选口注入),G4 判的正是它。
|
||||
* 从前写成 formal 口的必填 outcome + selective: [] —— 引擎只在选择组
|
||||
* 恰发一口时才注入 outcome,没有选择组就永远没有 outcome,G4 三条边全不成立。
|
||||
*/
|
||||
selective: [
|
||||
{
|
||||
group: '议价结果',
|
||||
ports: [
|
||||
{ name: 'won', description: '成交 —— 转订单履约', payload: { type: 'object' } },
|
||||
{ name: 'revise', description: '有异议 —— 退回重新定价', payload: { type: 'object' } },
|
||||
{ name: 'lost', description: '拒绝或超时 —— 归档闭环', payload: { type: 'object' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
...contractRest,
|
||||
authorization: authorizationFor(),
|
||||
|
||||
@@ -274,12 +274,28 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
: undefined;
|
||||
|
||||
if (input.workOrder === 'acceptance') {
|
||||
/*
|
||||
* 选择组上卡:活动契约声明了 selective 出口的,放行必须**恰选一口**(AM 1.2)——
|
||||
* 那一口就是这次运行的分支结论,下游异或网关判的正是它。
|
||||
* 组不在步的卡面里声明(它是契约的事实),从 manifest 现读。
|
||||
*/
|
||||
const { activityId } = await taskContext.of(input.taskId);
|
||||
const manifest = await deps.registry.manifestOf(activityId);
|
||||
const selective = manifest.contract.output.selective[0];
|
||||
const issued = await deps.humanTool.issueAcceptance(context, {
|
||||
title,
|
||||
basis: basis.length > 0 ? basis : [{ k: '来源', v: '步序声明' }],
|
||||
deliverable: String(input.card['deliverable'] ?? 'deliverable') as never,
|
||||
criteria: [{ label: '成果齐备', level: 'L5', pass: false }],
|
||||
fields: declaredFields ?? [],
|
||||
...(selective === undefined
|
||||
? {}
|
||||
: {
|
||||
portChoice: {
|
||||
group: selective.group,
|
||||
ports: selective.ports.map((port) => ({ name: port.name, label: port.description })),
|
||||
},
|
||||
}),
|
||||
});
|
||||
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
|
||||
return { id: issued.order.id };
|
||||
|
||||
@@ -118,6 +118,23 @@ export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): Ca
|
||||
if (rejectionNeedsComment(reply)) {
|
||||
out.push({ code: 'comment-required', message: '驳回必附意见(OS 05)' });
|
||||
}
|
||||
/*
|
||||
* **恰选一口,随放行一并确认**(AM 1.2)—— 与「驳回必附意见」同族的收单规矩。
|
||||
* 工单投影自己声明了选择组(portChoice),放行回传就必须带组内恰一口:
|
||||
* 选的这一口是这次运行的分支结论,下游网关判的正是它,不能靠默认值溜过去。
|
||||
* 只在**通过**时强制 —— 驳回是打回重做,没有分支结论可言。
|
||||
* 为什么拦在这里而不是落地(land):回传是同步收单、落地在推进器里异步跑,
|
||||
* 拦晚了就是「工单已答完、任务卡死、推进器循环报错」(实测)。
|
||||
*/
|
||||
const choice = (order as AcceptanceWorkOrder).portChoice;
|
||||
if (choice !== undefined && reply.verdict === 'pass') {
|
||||
const picked = reply.selectedPorts[choice.group];
|
||||
if (picked === undefined) {
|
||||
out.push({ code: 'port-required', message: `放行前先选「${choice.group}」—— 选的这一口就是这单的去向` });
|
||||
} else if (!choice.ports.some((p) => p.name === picked)) {
|
||||
out.push({ code: 'port-invalid', message: `「${picked}」不在「${choice.group}」的可选出口里` });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* → 落地收敛输出口才发射 → 终态方成「放行完成」。
|
||||
*/
|
||||
import type { ActionIntent, TaskId } from '@contracts';
|
||||
import { emissionPortNames } from '@contracts';
|
||||
import { emissionPortNames, validateSelection } from '@contracts';
|
||||
import type {
|
||||
LandInput,
|
||||
LandOutcome,
|
||||
@@ -201,6 +201,26 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
|
||||
{},
|
||||
);
|
||||
const ports = await deps.tasks.outputPortsOf(input.taskId);
|
||||
|
||||
/*
|
||||
* **恰选一口是硬约束**,不是前端的善意:选择组未选(或选了组外的口)就不落地。
|
||||
* 前端已强制必选;这里兜底拦 API 直调 —— `validateSelection` 这条不变量
|
||||
* 从 contracts 导出以来一直没人执行过,现在它有了岗位。
|
||||
*/
|
||||
const selectionErrors = validateSelection(
|
||||
{
|
||||
mandatory: ports.mandatory.map((name) => ({ name, payload: {} })),
|
||||
selective: ports.selective.map((g) => ({
|
||||
group: g.group,
|
||||
ports: g.ports.map((name) => ({ name, payload: {} })),
|
||||
})),
|
||||
},
|
||||
input.selectedPorts,
|
||||
);
|
||||
if (selectionErrors.length > 0) {
|
||||
throw new Error(`放行未确认分支结论:${selectionErrors.join('; ')} —— 验收单上恰选一口再通过`);
|
||||
}
|
||||
|
||||
const emitted = emissionPortNames(
|
||||
{
|
||||
mandatory: ports.mandatory.map((name) => ({ name, payload: {} })),
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
>
|
||||
|
||||
@@ -333,6 +333,18 @@ export const acceptanceWorkOrderSchema = z.object({
|
||||
actions: z.array(actionIntentViewSchema).optional(),
|
||||
/** 报告可修订:字段随成果 schema 渲染,AI 拟值已填 */
|
||||
fields: z.array(formFieldSchema).default([]),
|
||||
/**
|
||||
* 选择组(活动契约的 selective 出口):**恰发一口,随放行一并确认**(AM 1.2)。
|
||||
* 人选的那一口就是这次运行的分支结论(如议价的 成交/有异议/拒绝),
|
||||
* 下游异或网关判的正是它 —— 所以放行时必须恰选一个,选口即裁分支。
|
||||
* 没有选择组的活动此字段缺省,验收单照旧只有通过/重做。
|
||||
*/
|
||||
portChoice: z
|
||||
.object({
|
||||
group: z.string(),
|
||||
ports: z.array(z.object({ name: z.string(), label: z.string().optional() })).min(2),
|
||||
})
|
||||
.optional(),
|
||||
passLabel: z.string().optional(),
|
||||
});
|
||||
export type AcceptanceWorkOrder = z.infer<typeof acceptanceWorkOrderSchema>;
|
||||
|
||||
Reference in New Issue
Block a user