forked from ChaiHao/BPOS-MVP
手改是**兜 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
278 lines
9.5 KiB
TypeScript
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({});
|
|
});
|
|
});
|