feat(w05): 数据齐了也叫人一次 —— S1 改派只读的验收工单,件同一份、编辑钮收起

用户:「就算数据是足的也进入这个数据工单,就派发一个验收工单,界面和数据要和现在的
第一个数据工单一致,只不过把芯片卡片上的编辑按钮去掉,只展示不能编辑,然后把标题旁边的
数据工单改成验收工单」。

病根在引擎拍首那道 doneWhen 扫描:输入本来就齐的单,S1 的 doneWhen 第一拍就为真,
配方一笔都还没跑,这一步当场被收掉 —— 卡永远发不出去,人一眼没看过就进了 S2。
治法:CMMN 给 S1 标 manualActivation,那道扫描多一个门槛(worldTouched),次序变成
「配方发卡 → 人答 → 下一拍 doneWhen 为真才收」;doneWhen 仍是真门,只是不抢在人前面。

平台:执行工单第三副面孔 variant:'accept'(件只读、人只确认「确认无误」)——
与 mode:'acceptance'(submit 抬起来的收口验收,带打回重做)分清;三副面孔的判断
抽成纯函数 order-face.ts(有测),chat-bubbles 只摆。
活动:S1 两笔互斥的 card(carded 幂等保证一步只发一张)+ 案卷粘着的旗 order_kind;
一张件两副面孔 —— 题头/标题/页脚三处名字与卡角编辑钮按 v-data / v-accept 由样式表挑
(藏的是容器 .fabs,逐枚去盖打不完)。

测:引擎测钉死那一对(标了人工激活 → 卡发得出去、步不收;没标 → 步当场被收、卡发不出);
auto-runner variant 透传;order-face 4 条;包内 2 条(两笔卡 + 粘旗)、view 1 条;
两处变异验证。e2e w05 5/5 released、被拒 0、模型调用恰 1 次 —— 都在库那例摆的正是验收工单。
2558 测、四门禁绿;2.15.0 装库核过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-31 22:44:00 +08:00
co-authored by Claude Fable 5
parent e47ce92702
commit ce72da77eb
21 changed files with 492 additions and 66 deletions
@@ -0,0 +1,39 @@
/**
* 工单穿哪副面孔(2026-08-31 用户定「数据齐了也叫人一次,派验收工单」逼出的第三副)。
*
* 三副面孔都是**执行工单**的变体,回传即办结;收口验收单(`mode:'acceptance'`)是另一回事,
* 它有自己的裁决表单(AcceptForm)—— `faceOf` 对它返回 undefined,别顶着执行工单的牌子摆出来。
*/
import { describe, expect, it } from 'vitest';
import { ACCEPT_FACE, DATA_FACE, EXEC_FACE, chipOf, faceOf } from '../internal/order-face.ts';
describe('faceOf —— 没办结的执行工单穿哪副', () => {
it('不写变体 = 执行工单;data = 数据工单;accept = 验收工单', () => {
expect(faceOf('execution', undefined)).toBe(EXEC_FACE);
expect(faceOf('execution', 'data')).toBe(DATA_FACE);
expect(faceOf('execution', 'accept')).toBe(ACCEPT_FACE);
});
it('收口验收单不走这儿 —— 它是 submit 抬起来的裁决,不是「这一步要你办的事」', () => {
expect(faceOf('acceptance', undefined)).toBeUndefined();
});
it('三副面孔的四字与按钮字各不相同 —— 人一眼要能认出这张卡要他干什么', () => {
const faces = [EXEC_FACE, DATA_FACE, ACCEPT_FACE];
expect(new Set(faces.map((f) => f.docTitle)).size).toBe(3);
expect(new Set(faces.map((f) => f.submit)).size).toBe(3);
expect(new Set(faces.map((f) => f.klass)).size).toBe(3);
/* 验收工单的件是只读的,按钮说的就得是「确认」而不是「提交/写入」 */
expect(ACCEPT_FACE.submit).toBe('确认无误');
expect(ACCEPT_FACE.docHint).toContain('只读');
});
});
describe('chipOf —— 已办结 / 没有指导书的那张小卡', () => {
it('数据工单摆「数据」,验收工单与收口验收单同摆「验收」,其余「执行」', () => {
expect(chipOf('execution', 'data')).toEqual({ klass: 't-data', ico: 'database', label: '数据' });
expect(chipOf('execution', 'accept')).toEqual({ klass: 't-acc', ico: 'shield-check', label: '验收' });
expect(chipOf('acceptance', undefined)).toEqual({ klass: 't-acc', ico: 'shield-check', label: '验收' });
expect(chipOf('execution', undefined)).toEqual({ klass: 't-exe', ico: 'clipboard-check', label: '执行' });
});
});
@@ -21,6 +21,8 @@ import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { Btn } from '@web-shared/Btn.tsx';
import { approveReply, checklistMissing, redoReply, reviewChoices } from './review-face.ts';
import { ACCEPT_FACE, DATA_FACE, EXEC_FACE, chipOf } from './order-face.ts';
import type { FormFace } from './order-face.ts';
import type { DecisionMessage } from './review-face.ts';
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
import { atClock } from '@web-shared/at.ts';
@@ -359,6 +361,15 @@ function Msg({
onLocate={onLocate}
{...(orderValues === undefined ? {} : { orderValues })}
/>
) : order.mode === 'execution' && order.variant === 'accept' && !settled ? (
/* 验收工单 = 执行工单的变体(契约 `variant`):件只读,人只确认 */
<AcceptCheckForm
order={order}
onReplyOrder={onReplyOrder}
onLocate={onLocate}
onField={onField}
{...(orderValues === undefined ? {} : { orderValues })}
/>
) : order.mode === 'execution' && order.variant === 'data' && !settled ? (
/* 数据工单 = 执行工单的变体(契约 `variant`):同一套办法,另一副面孔 */
<DataForm
@@ -765,13 +776,13 @@ function Kv({ items }: { items: ReadonlyArray<{ k: string; v: string }> }) {
function WoCard({ order, onLocate }: { order: WorkOrder; onLocate: (id: string) => void }) {
const result = order.done?.result;
const bad = result !== undefined && isRejection(result);
const exe = order.mode === 'execution';
const data = order.mode === 'execution' && order.variant === 'data';
/* 三副面孔 + 收口验收单,哪一副由纯函数说了算(`order-face.ts`,有测)—— 这里只摆 */
const chip = chipOf(order.mode, order.mode === 'execution' ? order.variant : undefined);
return (
<div className={'jc-fc ' + (data ? 't-data' : exe ? 't-exe' : 't-acc')}>
<div className={'jc-fc ' + chip.klass}>
<div className="jc-fc-h">
<Ico name={data ? 'database' : exe ? 'file-text' : 'shield-check'} s={13} />
<b>工单 · {data ? '数据' : exe ? '执行' : '验收'}</b>
<Ico name={chip.ico} s={13} />
<b>工单 · {chip.label}</b>
<em className={result === undefined ? '' : bad ? 'bad' : 'ok'}>
{result === undefined ? '待处理' : bad ? '已退回' : '已办结'}
</em>
@@ -962,40 +973,6 @@ function Guide({ steps, standard }: { steps?: readonly string[] | undefined; sta
* 表格那一份值不在这儿收 —— 人是在工作区的件上改的,由外层缓冲带过来
* (与验收裁决同一条路子:编辑与提交分在两个区,缓冲得由壳层持有)。
*/
/** 执行工单与数据工单是同一套办法、两副面孔 —— 面孔在这儿定,办法只写一份 */
type FormFace = {
readonly klass: 't-exe' | 't-data';
readonly headIcon: 'clipboard-check' | 'database';
readonly head: string;
readonly docIcon: 'file-text' | 'table';
readonly docTitle: string;
readonly docHint: string;
readonly submit: string;
};
const EXEC_FACE: FormFace = {
klass: 't-exe',
headIcon: 'clipboard-check',
head: '工单 · 执行',
docIcon: 'file-text',
docTitle: '执行工单',
docHint: '整份清单在工作区,每一格都能就地改;改完回这里提交',
submit: '提交',
};
/**
* 数据工单(2026-08-27 用户:「工单类型新加入了一类数据工单…照着这个大概的样式为数据工单单独做一个组件」):
* 让人**从零建一张主数据表** —— 件上的表就是要建的记录,填齐、保存,回这里确认即入库,活动从「等待数据」唤醒。
* 引擎那一侧与执行工单无异(契约 `ExecutionWorkOrder.variant = 'data'`),所以只换面孔:色、图标、四字、按钮字。
*/
const DATA_FACE: FormFace = {
klass: 't-data',
headIcon: 'database',
head: '工单 · 数据',
docIcon: 'table',
docTitle: '数据工单',
docHint: '件上的表就是要建的记录:每格就地填、保存;填齐后回这里确认,确认即入库',
submit: '确认写入',
};
type ExecFormProps = {
order: Extract<WorkOrder, { mode: 'execution' }>;
onReplyOrder: (reply: WorkOrderReply) => void;
@@ -1009,6 +986,11 @@ function DataForm(props: ExecFormProps) {
return <ExecForm {...props} face={DATA_FACE} />;
}
/** 验收工单卡 —— 执行工单的第三副面孔(见 ACCEPT_FACE) */
function AcceptCheckForm(props: ExecFormProps) {
return <ExecForm {...props} face={ACCEPT_FACE} />;
}
function ExecForm({ order, onReplyOrder, onLocate, orderValues, onField, face = EXEC_FACE }: ExecFormProps & { face?: FormFace }) {
const busy = useReplyBusy();
const val = (f: FormField) => orderValues?.[f.key] ?? f.ai ?? '';
@@ -0,0 +1,82 @@
/**
* 工单穿哪副面孔 —— 纯函数,零 React(与 `review-face.ts` 同一条理由:**判断要能钉测**)。
*
* 执行工单一套办法、**三副面孔**(契约 `ExecutionWorkOrder.variant`):
* · 不写 → 执行工单:作业指导书 + 件上就地办,「提交」;
* · `data` → 数据工单(2026-08-27 用户定):件上的表就是要建的记录,「确认写入」即入库;
* · `accept` → 验收工单(2026-08-31 用户定):件只读,人只核对,「确认无误」。
*
* **`accept` 与 `mode:'acceptance'` 不是一回事**:后者是 `submit` 抬起来的收口验收
* (签下去就落账发口,带打回重做那一路,对话区走 `AcceptForm`),这里的三副面孔都只是
* 「这一步要你办的事」—— 回传即办结,放行不了任何东西。
*/
import type { WorkOrderMode } from '@contracts';
export type FormFace = {
readonly klass: 't-exe' | 't-data' | 't-acc';
readonly headIcon: 'clipboard-check' | 'database' | 'shield-check';
readonly head: string;
readonly docIcon: 'file-text' | 'table';
readonly docTitle: string;
readonly docHint: string;
readonly submit: string;
};
export const EXEC_FACE: FormFace = {
klass: 't-exe',
headIcon: 'clipboard-check',
head: '工单 · 执行',
docIcon: 'file-text',
docTitle: '执行工单',
docHint: '整份清单在工作区,每一格都能就地改;改完回这里提交',
submit: '提交',
};
/**
* 数据工单(2026-08-27 用户:「工单类型新加入了一类数据工单…照着这个大概的样式为数据工单单独做一个组件」):
* 让人**从零建一张主数据表** —— 件上的表就是要建的记录,填齐、保存,回这里确认即入库,活动从「等待数据」唤醒。
*/
export const DATA_FACE: FormFace = {
klass: 't-data',
headIcon: 'database',
head: '工单 · 数据',
docIcon: 'table',
docTitle: '数据工单',
docHint: '件上的表就是要建的记录:每格就地填、保存;填齐后回这里确认,确认即入库',
submit: '确认写入',
};
/**
* 验收工单(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个验收工单…
* 把芯片卡片上的编辑按钮去掉,只展示不能编辑」):这一步的活已经齐了,件只读,人过目、确认。
*/
export const ACCEPT_FACE: FormFace = {
klass: 't-acc',
headIcon: 'shield-check',
head: '工单 · 验收',
docIcon: 'file-text',
docTitle: '验收工单',
docHint: '整份内容在工作区,只读不可改;核对无误后回这里确认',
submit: '确认无误',
};
/** 一张**没办结的执行工单**该穿哪副面孔;收口验收单(mode: 'acceptance')不走这儿 —— 它有自己的裁决表单 */
export function faceOf(mode: WorkOrderMode, variant: 'data' | 'accept' | undefined): FormFace | undefined {
if (mode !== 'execution') return undefined;
if (variant === 'data') return DATA_FACE;
if (variant === 'accept') return ACCEPT_FACE;
return EXEC_FACE;
}
/**
* 已办结的、以及没有作业指导书的那张小卡上的三样东西。
* **验收工单与收口验收单在这张小卡上同一副样子** —— 人读的都是「验收」,
* 至于签下去会不会落账,不是这张小卡要答的问题。
*/
export function chipOf(
mode: WorkOrderMode,
variant: 'data' | 'accept' | undefined,
): { readonly klass: FormFace['klass']; readonly ico: FormFace['headIcon']; readonly label: string } {
const face = mode === 'execution' && variant === 'data' ? DATA_FACE : mode === 'execution' && variant !== 'accept' ? EXEC_FACE : ACCEPT_FACE;
return { klass: face.klass, ico: face.headIcon, label: face.head.replace('工单 · ', '') };
}