Files
JX-BPOS/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts
T
ChaiHaoandClaude Opus 5 259192f736 人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
2026-08-02 13:41:44 +08:00

278 lines
9.5 KiB
TypeScript

/** 人类工具三规矩的机检 —— domain 测试零 mock(OS 05 / AM 06) */
import { describe, expect, it } from 'vitest';
import type {
AcceptanceWorkOrder,
ChatCard,
DeliverableId,
ExecutionWorkOrder,
WorkOrderId,
WorkOrderReply,
} from '@contracts';
import {
allCriteriaGreen,
arrivalOf,
checkChatCard,
checkChatCardReply,
checkWorkOrder,
checkWorkOrderReply,
producesCompletion,
} from '../internal/domain/card-rules.ts';
const OID = 'wo-1' as WorkOrderId;
const execution = (over: Partial<ExecutionWorkOrder> = {}): ExecutionWorkOrder => ({
id: OID,
at: '2026-07-27T10:00:00.000Z',
mode: 'execution',
title: '补齐 RFQ 关键字段',
basis: [{ k: '缺口', v: 'PN / EAU / 交期' }],
ask: '请向客户追问缺失字段',
fields: [{ key: 'pn', label: 'PN', kind: 'text', required: true }],
...over,
});
const acceptance = (over: Partial<AcceptanceWorkOrder> = {}): AcceptanceWorkOrder => ({
id: OID,
at: '2026-07-27T10:00:00.000Z',
mode: 'acceptance',
title: '验收统一受理记录',
basis: [{ k: '条目数', v: '12' }],
deliverable: 'd-1' as DeliverableId,
criteria: [{ label: '八字段齐', level: 'L1', pass: true }],
fields: [],
...over,
});
describe('调用必附上下文 —— 裸调用基座拒发', () => {
it('basis 为空即拒', () => {
expect(checkWorkOrder(execution({ basis: [] })).map((v) => v.code)).toContain('bare-call');
});
it('决策卡 basis 为空即拒', () => {
const card: ChatCard = {
id: 'c1' as never,
at: '2026-07-27T10:00:00.000Z',
k: 'decision',
ask: '选一个',
basis: [],
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
],
allowCustom: false,
};
expect(checkChatCard(card).map((v) => v.code)).toContain('bare-call');
});
});
describe('工单形态机检', () => {
it('规范的执行工单通过', () => {
expect(checkWorkOrder(execution())).toEqual([]);
});
it('执行工单 = 指导书 + 空表单,表单不得为空', () => {
expect(checkWorkOrder(execution({ fields: [] })).map((v) => v.code)).toContain('fields-missing');
});
it('radio 字段缺选项即拒', () => {
const v = checkWorkOrder(execution({ fields: [{ key: 'x', label: 'X', kind: 'radio' }] }));
expect(v.map((i) => i.code)).toContain('options-missing');
});
it('字段 key 重复即拒', () => {
const v = checkWorkOrder(
execution({
fields: [
{ key: 'x', label: 'X', kind: 'text' },
{ key: 'x', label: 'Y', kind: 'text' },
],
}),
);
expect(v.map((i) => i.code)).toContain('field-key-duplicate');
});
it('验收工单必须列判据', () => {
expect(checkWorkOrder(acceptance({ criteria: [] })).map((v) => v.code)).toContain('criteria-missing');
});
});
describe('到达面两路', () => {
it('负责人的卡进任务对话流', () => {
expect(arrivalOf('s1', 's1')).toBe('task-chat');
});
it('别人的卡走系统消息 —— 跨人卡不是责任转移', () => {
expect(arrivalOf('s2', 's1')).toBe('system-message');
});
});
describe('回传校验', () => {
it('必填未回填即拒', () => {
const reply: WorkOrderReply = { mode: 'execution', workOrderId: OID, values: {} };
expect(checkWorkOrderReply(execution(), reply).map((v) => v.code)).toContain('required-missing');
});
it('必填已填即过', () => {
const reply: WorkOrderReply = { mode: 'execution', workOrderId: OID, values: { pn: 'LM358' } };
expect(checkWorkOrderReply(execution(), reply)).toEqual([]);
});
it('驳回必附意见', () => {
const reply: WorkOrderReply = {
mode: 'acceptance',
workOrderId: OID,
verdict: 'revise',
values: {},
selectedPorts: {},
};
expect(checkWorkOrderReply(acceptance(), reply).map((v) => v.code)).toContain('comment-required');
});
it('放行通过无需意见', () => {
const reply: WorkOrderReply = {
mode: 'acceptance',
workOrderId: OID,
verdict: 'pass',
values: {},
selectedPorts: {},
};
expect(checkWorkOrderReply(acceptance(), reply)).toEqual([]);
});
it('已回传的工单不得再答 —— 只增不改', () => {
const done = acceptance({ done: { at: 'x', by: 's1' as never, result: 'pass' } });
const reply: WorkOrderReply = {
mode: 'acceptance',
workOrderId: OID,
verdict: 'pass',
values: {},
selectedPorts: {},
};
expect(checkWorkOrderReply(done, reply).map((v) => v.code)).toEqual(['already-answered']);
});
it('模式不符即拒', () => {
const reply: WorkOrderReply = { mode: 'execution', workOrderId: OID, values: { pn: 'x' } };
expect(checkWorkOrderReply(acceptance(), reply).map((v) => v.code)).toEqual(['mode-mismatch']);
});
});
describe('对话卡回传', () => {
const decision: ChatCard = {
id: 'c1' as never,
at: '2026-07-27T10:00:00.000Z',
k: 'decision',
ask: '降不降价',
basis: [{ k: '底价', v: '1.80' }],
options: [
{ value: 'hold', label: '守价' },
{ value: 'cut', label: '让价' },
],
allowCustom: false,
};
it('选项内的分支通过', () => {
expect(checkChatCardReply(decision, { k: 'decision', cardId: 'c1' as never, value: 'cut' })).toEqual([]);
});
it('选项外的分支在不允许自定义时即拒', () => {
const v = checkChatCardReply(decision, { k: 'decision', cardId: 'c1' as never, value: 'other' });
expect(v.map((i) => i.code)).toEqual(['unknown-option']);
});
it('自定义收尾项必附理由', () => {
const open = { ...decision, allowCustom: true };
expect(
checkChatCardReply(open, { k: 'decision', cardId: 'c1' as never, value: 'other' }).map((i) => i.code),
).toEqual(['note-required']);
expect(
checkChatCardReply(open, { k: 'decision', cardId: 'c1' as never, value: 'other', note: '客户口头承诺追单' }),
).toEqual([]);
});
it('补全卡两条路必走其一', () => {
const card: ChatCard = {
id: 'c2' as never,
at: '2026-07-27T10:00:00.000Z',
k: 'elicitation',
ask: '补交期',
fields: [{ key: 'lead', label: '交期' }],
};
expect(
checkChatCardReply(card, { k: 'elicitation', cardId: 'c2' as never, values: {} }).map((i) => i.code),
).toEqual(['answer-required']);
expect(checkChatCardReply(card, { k: 'elicitation', cardId: 'c2' as never, values: { lead: '8周' } })).toEqual([]);
expect(
checkChatCardReply(card, { k: 'elicitation', cardId: 'c2' as never, values: {}, reason: '客户未答复' }),
).toEqual([]);
});
});
describe('完成只出自放行通过', () => {
it('放行通过才产生完成', () => {
expect(
producesCompletion({ mode: 'acceptance', workOrderId: OID, verdict: 'pass', values: {}, selectedPorts: {} }),
).toBe(true);
});
it('驳回不产生完成 —— 环内回转,任务继续', () => {
expect(
producesCompletion({
mode: 'acceptance',
workOrderId: OID,
verdict: 'redo',
values: {},
selectedPorts: {},
comment: '重做',
}),
).toBe(false);
});
it('执行工单回传不产生完成', () => {
expect(producesCompletion({ mode: 'execution', workOrderId: OID, values: {} })).toBe(false);
});
it('判据全绿只是可提交,不等于完成', () => {
expect(allCriteriaGreen(acceptance())).toBe(true);
expect(allCriteriaGreen(acceptance({ criteria: [{ label: 'x', level: 'L2', pass: false }] }))).toBe(false);
});
});
/**
* **一张录入卡可以落好几张表**(2026-08-02 用户:「新建客户 联系人和公司都需要确认」)。
*
* 建一位新客户落的是两条主数据:公司档 + 联系人档。从前卡上只有公司那一半,
* 联系人由 AI 自己写 —— 而联系人档的 `contact_code` 是它编出来的,人一眼没看过。
* 值按段隔开(`段号.列名`):两张表都有 `name` 这一列,平铺就会互相盖掉,
* **而盖掉之后没人看得出来**。
*/
describe('录入卡分段:段号.列名', () => {
/** 与 `routes/desk.ts` 那段拆分同一口径 —— 改一处这里要跟着改 */
const split = (values: Record<string, string>, at: number): Record<string, string> => {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(values)) {
const mark = `${at}.`;
if (k.startsWith(mark)) out[k.slice(mark.length)] = v;
else if (at === 0 && !/^\d+\./.test(k)) out[k] = v;
}
return out;
};
const values = {
'0.customer_code': 'CU-9001',
'0.name': '上海精密电子有限公司',
'1.customer_code': 'CU-9001',
'1.contact_code': 'CT-01',
'1.name': '陈刚',
'1.email': 'chen@sh-elec.com',
};
it('**同名列不互相盖** —— 公司叫「上海精密电子」,联系人叫「陈刚」', () => {
expect(split(values, 0)['name']).toBe('上海精密电子有限公司');
expect(split(values, 1)['name']).toBe('陈刚');
});
it('每段只拿自己那几格 —— 联系人编码不该落进公司档', () => {
expect(split(values, 0)['contact_code']).toBeUndefined();
expect(Object.keys(split(values, 1)).sort()).toEqual(['contact_code', 'customer_code', 'email', 'name']);
});
it('**老形状(没段号的平键)仍旧管用** —— 一张卡只有一段时照旧落主段', () => {
expect(split({ customer_code: 'CU-1', name: '老写法' }, 0)).toEqual({ customer_code: 'CU-1', name: '老写法' });
});
it('老形状不喂给后面的段 —— 它说不清自己是哪一张表的', () => {
expect(split({ customer_code: 'CU-1', name: '老写法' }, 1)).toEqual({});
});
});