diff --git a/api-report/packages/contracts/src/api/workbench.d.ts b/api-report/packages/contracts/src/api/workbench.d.ts index 26954f7..ba16655 100644 --- a/api-report/packages/contracts/src/api/workbench.d.ts +++ b/api-report/packages/contracts/src/api/workbench.d.ts @@ -862,6 +862,13 @@ export declare const taskDetailSchema: z.ZodObject<{ required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>>; + portChoice: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>>; passLabel: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; @@ -1836,6 +1843,13 @@ export declare const taskDetailRoute: { required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>>; + portChoice: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>>; passLabel: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; diff --git a/api-report/packages/contracts/src/card.d.ts b/api-report/packages/contracts/src/card.d.ts index 431c0f0..115cca1 100644 --- a/api-report/packages/contracts/src/card.d.ts +++ b/api-report/packages/contracts/src/card.d.ts @@ -936,6 +936,13 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{ required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>>; + portChoice: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>>; passLabel: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; @@ -1271,6 +1278,13 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>>; + portChoice: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>>; passLabel: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; diff --git a/apps/api/src/app/__tests__/seed-mvp-slots.test.ts b/apps/api/src/app/__tests__/seed-mvp-slots.test.ts index 8ec8fe9..56f3430 100644 --- a/apps/api/src/app/__tests__/seed-mvp-slots.test.ts +++ b/apps/api/src/app/__tests__/seed-mvp-slots.test.ts @@ -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> }) - .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('槽位不携实现:入口是抛「未实现」的占位,工具声明只有名', () => { diff --git a/apps/api/src/app/seed-mvp-flow.ts b/apps/api/src/app/seed-mvp-flow.ts index 8b8121f..a931fd0 100644 --- a/apps/api/src/app/seed-mvp-flow.ts +++ b/apps/api/src/app/seed-mvp-flow.ts @@ -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' } }, diff --git a/apps/api/src/app/seed-mvp-slots.ts b/apps/api/src/app/seed-mvp-slots.ts index c5b7066..0234cd4 100644 --- a/apps/api/src/app/seed-mvp-slots.ts +++ b/apps/api/src/app/seed-mvp-slots.ts @@ -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(), diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index ccac828..4fa6308 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -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 }; diff --git a/apps/api/src/modules/human-tool/internal/domain/card-rules.ts b/apps/api/src/modules/human-tool/internal/domain/card-rules.ts index b410e8e..c5100ea 100644 --- a/apps/api/src/modules/human-tool/internal/domain/card-rules.ts +++ b/apps/api/src/modules/human-tool/internal/domain/card-rules.ts @@ -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; } diff --git a/apps/api/src/modules/release/internal/usecase/release-service.ts b/apps/api/src/modules/release/internal/usecase/release-service.ts index 1727d89..bedfa73 100644 --- a/apps/api/src/modules/release/internal/usecase/release-service.ts +++ b/apps/api/src/modules/release/internal/usecase/release-service.ts @@ -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: {} })), diff --git a/apps/web/src/app/styles/app.css b/apps/web/src/app/styles/app.css index c527146..6dbf574 100644 --- a/apps/web/src/app/styles/app.css +++ b/apps/web/src/app/styles/app.css @@ -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); } diff --git a/apps/web/src/features/desk-task/internal/chat-bubbles.tsx b/apps/web/src/features/desk-task/internal/chat-bubbles.tsx index c394dc7..2b1ea10 100644 --- a/apps/web/src/features/desk-task/internal/chat-bubbles.tsx +++ b/apps/web/src/features/desk-task/internal/chat-bubbles.tsx @@ -419,6 +419,10 @@ function AcceptForm({ onLocate: (id: string) => void; }) { const [why, setWhy] = useState(''); + /** 选择组:恰选一口,选口即裁分支 —— 没选之前「通过」不亮 */ + const [port, setPort] = useState(undefined); + const choice = order.portChoice; + const needChoice = choice !== undefined; const passN = order.criteria.filter((c) => c.pass).length; return (
@@ -460,6 +464,24 @@ function AcceptForm({ }))} /> )} + {choice !== undefined && ( +
+

+ {choice.group} 放行前必选 —— 选的这一口就是这单的去向 +

+ {choice.ports.map((p2) => ( + + ))} +
+ )}