Archived
凭证到位后 `w05` 五案例连跑六轮,逼出八条**浏览器走查逮不到**的缺陷 —— 脚本每张卡都先空手交一次、每个岔路口都按序代答,人手点的时候永远不会去试 「空着交上去会怎样」。末轮:跑到的四例全部 released,三例被拒 0 (第五例停在大模型账户余额不足,不是代码问题)。 平台侧(六条): - L 数据工单的提交闸没牙 —— 卡没绑表就回落到必交付物,而数据工单要人补的是 `parts.*` 主数据,不在交付物名单里:闸盯着一堆无关的表、一格不缺,空手交卷一路放行。 契约新增 `ExecutionWorkOrder.gateLedgers`(这张卡该拦哪几张表,发卡的人说了算), `gateAddressesOf` 变三级取一:绑的表 > 卡自报 > 交付物。 - M `finish` 撞 doneWhen 为假的拒回只谈「行与列」,而缺的是一次**工具调用**; 现在摆出判据原文 + 那几个名字此刻的读数,`tool_*_calls` 读到 0 的单独点名(`unmetWhy`)。 - N 人点了决策卡,模型每拍再发一张,连拒五拍熔断。把「他选的是 approve」写进拒回**没用** —— 下一拍它又从头翻一次会滚走的窗口。改结构:`answeredCardFact` 把 「这张卡已答过 → 他选的是什么」做成每拍都在的事实行。 - O 模型传 `requirement: null`,工具 `null.trim()` 当场崩(边界上一律经 `text()` 归一)。 - P 拒回里的 `count=(没写过)` 是 S-FEEL 函数名被当成台账名 —— 假线索模型会去追。 - Q/S 零行 = 零缺,闸与哨兵一起放过空手交卷的数据工单(`emptyGate`:卡自报的表一行都没有 = 没完成);「早收」头一次只落观察、第二次起才算被拒(与「重发已答过的卡」同一把尺)。 活动侧(1.2.7 → 1.2.9):三张执行工单各自声明 `gateLedgers`;说明书③补「发卡前骨架必须先有行」、 ⑤补这张卡的前提(一颗 absent 都不剩);哨兵 `parts_profile_rows >= created` 逐颗对账; S0_bind 写明发录入卡那一拍别抢写需求行。 e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(服务账号 client_credentials 仍是正路)。 门禁 typecheck / lint / depcruise / test(2091)全绿;api:check 的差异仍只在 org/* 与 api/* (工作树里先前那批鉴权改动),card.d.ts 是本轮的纯新增字段。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
553 lines
26 KiB
TypeScript
553 lines
26 KiB
TypeScript
/**
|
|
* **件上就地改** —— 人手改的那几格,是不是落得下去、按不按得下提交。
|
|
*
|
|
* 两件事在这儿测,都是用户实测逼出来的(2026-08-02,同一单):
|
|
*
|
|
* ① **按地址收,不按「当下那张卡的第几块」收**。
|
|
* 载荷从前是 `{workOrderId, values:{'table:0': …}}`:列名由那张工单的 `blocks[0]`
|
|
* 给出,于是「这份件能不能改」被绑在了「此刻开着的那张卡长什么样」上。
|
|
* 实测:一单在放行门连拒五拍熔断,引擎发的是一张**没有 blocks 的熔断卡**;
|
|
* 屏上摆着的正是受理记录那份件,闸在、编辑进得去、保存按得下 ——
|
|
* 前端找不到匹配的块,**一格都没往下送**。而人要补的恰恰是把这一单
|
|
* 卡在那儿的那个必填格。
|
|
*
|
|
* ② **必填还空着就按不下提交**。同一单里,受理需求那张卡上一条料缺年用量(必填),
|
|
* 人照样交了回来;这一单于是一路走到放行门被 L1 拦住,模型连拒五拍熔断。
|
|
* 同一件事在提交那一刻拦下便宜得多 —— 那时缺的是哪一格就摆在他眼前。
|
|
*
|
|
* 测的是生产那两段本身(`writesOf` / `gateOf`),不在测里抄一份实现:
|
|
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { emptyGate, findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
|
|
import { missingReadings } from '../wiring/engines.ts';
|
|
|
|
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
|
|
const RECORD = {
|
|
domain: 'quote_intake',
|
|
object: 'record',
|
|
fields: [
|
|
{ name: 'rfq_ref', type: 'text', primaryKey: true },
|
|
{ name: 'pn', type: 'text', primaryKey: true, label: '型号 PN', required: true },
|
|
{ name: 'eau', type: 'number', primaryKey: false, label: '年用量 EAU', required: true },
|
|
{ name: 'brand', type: 'text', primaryKey: false, label: '制造商', expect: true },
|
|
],
|
|
};
|
|
const VERDICT = {
|
|
domain: 'quote_intake',
|
|
object: 'verdict',
|
|
fields: [{ name: 'rfq_ref', type: 'text', primaryKey: true }],
|
|
};
|
|
|
|
describe('件上按保存:按地址收', () => {
|
|
it('**地址在声明里就写** —— 当下开着的是哪张卡、带没带块,一概不问', () => {
|
|
const got = writesOf(
|
|
[RECORD, VERDICT],
|
|
{ 'quote_intake.record': [{ pn: 'NE555P', eau: '30000' }] },
|
|
);
|
|
expect(got).toHaveLength(1);
|
|
expect(got[0]?.declared.object).toBe('record');
|
|
expect(got[0]?.lines).toEqual([{ pn: 'NE555P', eau: '30000' }]);
|
|
});
|
|
|
|
it('一次收好几张表 —— 需求表与分类表在同一份件上,各写各的', () => {
|
|
const got = writesOf([RECORD, VERDICT], {
|
|
'quote_intake.record': [{ pn: 'A', eau: '1' }],
|
|
'quote_intake.verdict': [{ rfq_ref: 'R1' }],
|
|
});
|
|
expect(got.map((x) => x.declared.object)).toEqual(['record', 'verdict']);
|
|
});
|
|
|
|
it('**活动没声明过的地址不写** —— 载荷从浏览器来,门得在服务端', () => {
|
|
expect(writesOf([RECORD], { 'customer.profile': [{ name: '改我' }] })).toEqual([]);
|
|
});
|
|
|
|
it('地址不成形的不写 —— 「域.对象」两段,多一段少一段都不认', () => {
|
|
expect(writesOf([RECORD], { record: [{ pn: 'A' }] })).toEqual([]);
|
|
expect(writesOf([RECORD], { 'a.b.c': [{ pn: 'A' }] })).toEqual([]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* **人在件上加的那一行,主键得凑得齐** —— 凑不齐就被静默丢掉,而屏上那一行还在
|
|
* (界面说加上了,台账里没有 —— 最坏的一类:它不报错)。
|
|
*
|
|
* 三类主键各有各的凑法(2026-08-23,审 I01 需求清单时逮到的):
|
|
* · **件上没摆的那一列**(如一单一个的案号):本单现有行全表一致 → 就是它;
|
|
* · **件上摆了的**:人填了就用他填的;
|
|
* · **序号型(整数主键)**:件上摆着,可「加一行」那一下所有格子都被清空
|
|
* (`ViewContainer.addRow` 如此)——各行序号本就互不相同,「全表一致」永远取不出,
|
|
* 于是整行被丢。序号的语义就是「本轮内递增」,该由基座续:现有最大值 + 1。
|
|
* I01 的需求清单主键正是 `round_ref + req_seq`,不补这一手,人加的需求就消失。
|
|
*/
|
|
describe('件上加行:主键凑得齐才落得下', () => {
|
|
const KEYS = ['round_ref', 'req_seq'];
|
|
const TYPES = { round_ref: 'text', req_seq: 'integer' };
|
|
const DRAFTS = [
|
|
{ key: { round_ref: 'CTB-1', req_seq: 1 } },
|
|
{ key: { round_ref: 'CTB-2', req_seq: 2 } },
|
|
];
|
|
const SAME_ROUND = [
|
|
{ key: { round_ref: 'CTB-1', req_seq: 1 } },
|
|
{ key: { round_ref: 'CTB-1', req_seq: 2 } },
|
|
];
|
|
|
|
it('老行:件上摆了序号,案号从全表一致里补', () => {
|
|
expect(resolveRowKey({ keys: KEYS, cells: { req_seq: '2' }, drafts: SAME_ROUND, types: TYPES })).toEqual({
|
|
round_ref: 'CTB-1',
|
|
req_seq: '2',
|
|
});
|
|
});
|
|
|
|
it('**空着的自增列不许从别的行借号** —— 加的新联系人不再覆盖该客户已有的草稿行(2026-08-24)', () => {
|
|
// 人加一行:customer_code 填了、contact_code(auto)在件上但空着;该客户已有一行草稿联系人
|
|
const contactDrafts = [{ key: { customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01' } }];
|
|
const out = resolveRowKey({
|
|
keys: ['customer_code', 'contact_code'],
|
|
cells: { customer_code: 'CU-E2E-ADD', contact_code: '', name: '测试CTO王工' },
|
|
drafts: contactDrafts,
|
|
types: { customer_code: 'text', contact_code: 'text' },
|
|
autos: ['contact_code'],
|
|
});
|
|
// 不许把 -01 借来当自己的钥匙(那是覆盖别人);认不出 → 交给 mint 那条路续号
|
|
expect(out).toBeUndefined();
|
|
// 编码真填了的(编辑既有行)照旧认得出
|
|
expect(
|
|
resolveRowKey({
|
|
keys: ['customer_code', 'contact_code'],
|
|
cells: { customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01', name: '改名' },
|
|
drafts: contactDrafts,
|
|
types: { customer_code: 'text', contact_code: 'text' },
|
|
autos: ['contact_code'],
|
|
}),
|
|
).toEqual({ customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01' });
|
|
});
|
|
|
|
it('**新增行:序号空着 → 基座续号(现有最大 + 1)**,不再静默丢掉', () => {
|
|
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: SAME_ROUND, types: TYPES })).toEqual({
|
|
round_ref: 'CTB-1',
|
|
req_seq: '3',
|
|
});
|
|
});
|
|
|
|
it('空表上加第一行:序号从 1 起 —— 但案号取不出就还是不猜', () => {
|
|
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '公司介绍' }, drafts: [], types: TYPES })).toBeUndefined();
|
|
expect(
|
|
resolveRowKey({ keys: KEYS, cells: { round_ref: 'CTB-9', content_type: '公司介绍' }, drafts: [], types: TYPES }),
|
|
).toEqual({ round_ref: 'CTB-9', req_seq: '1' });
|
|
});
|
|
|
|
/*
|
|
* **只有一行时,那一行的序号不是「全表一致的常量」**(2026-08-23 浏览器实测逮到:
|
|
* 人在只有一行的清单上加一行,新行拿到 req_seq=1,**当场把原来那一行覆盖掉** ——
|
|
* 屏上两行、库里一行,而且内容还成了新的那份)。序号逐行不同是它的定义,
|
|
* 一致只是行数太少的假象。
|
|
*/
|
|
it('**表里只有一行:加的新行续成 2,不覆盖原来那一行**', () => {
|
|
const one = [{ key: { round_ref: 'CTB-1', req_seq: 1 } }];
|
|
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '销售工具包' }, drafts: one, types: TYPES })).toEqual({
|
|
round_ref: 'CTB-1',
|
|
req_seq: '2',
|
|
});
|
|
});
|
|
|
|
it('老行照旧认得出来 —— 件上摆了序号(主键列必挂 data-col,它是行的钥匙)', () => {
|
|
const one = [{ key: { round_ref: 'CTB-1', req_seq: 1 } }];
|
|
expect(resolveRowKey({ keys: KEYS, cells: { req_seq: '1', scene: '展会' }, drafts: one, types: TYPES })).toEqual({
|
|
round_ref: 'CTB-1',
|
|
req_seq: '1',
|
|
});
|
|
});
|
|
|
|
it('案号本身取不出(一单跨了两个号)→ 不猜,如实跳过', () => {
|
|
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: DRAFTS, types: TYPES })).toBeUndefined();
|
|
});
|
|
|
|
it('**文本主键不续号** —— 序号能续是因为它本就是「第几条」,PN 这种续不出来', () => {
|
|
const textKeys = ['rfq_ref', 'pn'];
|
|
const textTypes = { rfq_ref: 'text', pn: 'text' };
|
|
const drafts = [{ key: { rfq_ref: 'R1', pn: 'A' } }, { key: { rfq_ref: 'R1', pn: 'B' } }];
|
|
expect(resolveRowKey({ keys: textKeys, cells: { eau: '100' }, drafts, types: textTypes })).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('提交闸:必填没填就交不了', () => {
|
|
const gate = (rows: Array<Record<string, unknown>>) =>
|
|
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
|
|
|
|
it('一格不缺 → 交得了', () => {
|
|
expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000 }]).ready).toBe(true);
|
|
});
|
|
|
|
it('**缺必填 → 交不了,并说清差在哪一行哪一格**', () => {
|
|
const got = gate([
|
|
{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000 },
|
|
{ rfq_ref: 'R1', pn: 'DAG2400L321504XZB01-R0', eau: null },
|
|
]);
|
|
expect(got.ready).toBe(false);
|
|
expect(got.why).toContain('1 条必填');
|
|
expect(got.blanks).toEqual(['R1/DAG2400L321504XZB01-R0 缺年用量 EAU']);
|
|
});
|
|
|
|
it('**选填缺不拦单** —— 那一问不能省,但它不是交不了的理由', () => {
|
|
expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000, brand: '' }]).ready).toBe(true);
|
|
});
|
|
|
|
it('空串与 null 同等看待 —— 模型用 `""` 表示「原文没给」', () => {
|
|
expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: '' }]).ready).toBe(false);
|
|
});
|
|
|
|
it('**与放行门的 L1 同一个数** —— 两处各算各的,就会出现「卡上说能交、放行门说不行」', () => {
|
|
const rows = [{ rfq_ref: 'R1', pn: 'A', eau: 1 }, { rfq_ref: 'R1', pn: 'B', eau: null }];
|
|
const readings = missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]);
|
|
// L1 判据读的就是这个键:`quote_intake_record_missing = 0`
|
|
expect(readings['quote_intake_record_missing']).toBe(1);
|
|
expect(gateOf(readings, [RECORD]).ready).toBe(false);
|
|
});
|
|
});
|
|
|
|
/*
|
|
* **行级必填 `requiredWhen`:必填钉在该轮到的那一行**(2026-08-02 用户:
|
|
* 「工作区的 HTML 必填项必须填写了才能提交啊,不填写能算任务完成了吗?」)。
|
|
*
|
|
* 逐颗按序派单(A2 的 sel_stage 队列)撞上表级 required 的死结:定案三格整列必填,
|
|
* 第一颗的卡会被第二颗(还没轮到、件上不可见)的空格拦死;整列不标,闸又没牙,
|
|
* 空手就能交卷(用户实测点名)。requiredWhen 只在行上指定列等于指定值时才算必填。
|
|
*/
|
|
describe('行级必填:requiredWhen 只数该轮到的那一行', () => {
|
|
const LIST = {
|
|
domain: 'quote_alternative',
|
|
object: 'list',
|
|
fields: [
|
|
{ name: 'rfq_ref', type: 'text', primaryKey: true },
|
|
{ name: 'pn', type: 'text', primaryKey: true },
|
|
{ name: 'alt_pn', type: 'text', primaryKey: false, label: '替代料 PN', requiredWhen: { column: 'sel_stage', value: 'NOW' } },
|
|
{ name: 'alt_grade', type: 'text', primaryKey: false, label: '替代等级', requiredWhen: { column: 'sel_stage', value: 'NOW' } },
|
|
],
|
|
};
|
|
const read = (rows: Array<Record<string, unknown>>) =>
|
|
missingReadings({ 'mine:quote_alternative.list': rows }, [LIST]);
|
|
|
|
it('**排队(WAIT)的颗不拦当前的卡** —— 只有 NOW 行的空格算缺', () => {
|
|
const got = read([
|
|
{ rfq_ref: 'R1', pn: 'A', sel_stage: 'NOW', alt_pn: '', alt_grade: '' },
|
|
{ rfq_ref: 'R1', pn: 'B', sel_stage: 'WAIT', alt_pn: '', alt_grade: '' },
|
|
]);
|
|
expect(got['quote_alternative_list_missing']).toBe(1);
|
|
expect(got['quote_alternative_list_missing_keys']).toBe('R1/A 缺替代料 PN、替代等级');
|
|
});
|
|
|
|
it('NOW 行定案齐了 → 缺 0,闸开;办结(DONE)的行不再数', () => {
|
|
const got = read([
|
|
{ rfq_ref: 'R1', pn: 'A', sel_stage: 'DONE', alt_pn: 'X', alt_grade: 'P2P' },
|
|
{ rfq_ref: 'R1', pn: 'B', sel_stage: 'NOW', alt_pn: 'Y', alt_grade: 'FUNC' },
|
|
]);
|
|
expect(got['quote_alternative_list_missing']).toBe(0);
|
|
});
|
|
|
|
it('**未果撤行之后 NOW 行不存在 → 缺 0** —— 撤行也是一个答案', () => {
|
|
expect(read([{ rfq_ref: 'R1', pn: 'B', sel_stage: 'WAIT', alt_pn: '', alt_grade: '' }])['quote_alternative_list_missing']).toBe(0);
|
|
});
|
|
|
|
it('不带 requiredWhen 的 required 照旧整列必填 —— 老声明一个字不用改', () => {
|
|
const plain = {
|
|
domain: 'd',
|
|
object: 'o',
|
|
fields: [
|
|
{ name: 'k', type: 'text', primaryKey: true },
|
|
{ name: 'v', type: 'text', primaryKey: false, label: '值', required: true },
|
|
],
|
|
};
|
|
expect(missingReadings({ 'mine:d.o': [{ k: '1', v: '' }] }, [plain])['d_o_missing']).toBe(1);
|
|
});
|
|
});
|
|
|
|
/*
|
|
* **执行卡零输入之后,闸看哪几张表**(2026-08-02 用户定:填写全去工作区)。
|
|
*
|
|
* 卡面不再绑 blocks,闸若只认卡上的表就整个失效 —— 缺着必填照样交得回来,
|
|
* 一路走到放行门才被 L1 拦住,那正是当初立这道闸要治的病。
|
|
* 回落断了页面上看不出来(按钮照常绿着),所以钉在这儿。
|
|
*/
|
|
describe('闸看哪几张表:卡上绑的优先,没绑回落到必交付物声明', () => {
|
|
const table = (ledger: string) => ({ t: 'table' as const, ledger, cols: [], rows: [], fields: [] });
|
|
|
|
it('卡上绑了表 → 看绑的那几张,交付物声明不掺和', () => {
|
|
expect(
|
|
gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b', 'c.d'] }]),
|
|
).toEqual(['quote_intake.record']);
|
|
});
|
|
|
|
it('**卡上没绑表(零输入执行卡)→ 回落到必交付物的 ledgers 全表**', () => {
|
|
expect(
|
|
gateAddressesOf(undefined, [{ ledger: 'quote_intake.record', ledgers: ['quote_intake.record', 'quote_intake.verdict'] }]),
|
|
).toEqual(['quote_intake.record', 'quote_intake.verdict']);
|
|
});
|
|
|
|
it('**卡自己声明了 gateLedgers → 看它,不回落到交付物**(2026-08-27 W05 实测:数据工单要补的是 parts.* 主数据,不在必交付物名单里,闸盯着一堆无关的表、一格不缺,空手交卷一路放行)', () => {
|
|
expect(
|
|
gateAddressesOf(undefined, [{ ledgers: ['sub_eval.input_gap', 'substitution.relation'] }], ['parts.profile', 'parts.parameter']),
|
|
).toEqual(['parts.profile', 'parts.parameter']);
|
|
});
|
|
|
|
it('**卡自报的表一行都没有 = 没完成**(2026-08-27 W05 e2e:模型压拍没写骨架就发了数据工单,件上空空如也,而「缺必填」只数已有行 → 零行零缺、闸照开)', () => {
|
|
const watched = [
|
|
{ domain: 'parts', object: 'profile', label: '芯片主数据' },
|
|
{ domain: 'parts', object: 'parameter', label: '标准化参数集' },
|
|
];
|
|
const declared = ['parts.profile', 'parts.parameter'];
|
|
expect(emptyGate({}, watched, declared)?.ready).toBe(false);
|
|
expect(emptyGate({}, watched, declared)?.why).toContain('芯片主数据、标准化参数集');
|
|
// 两张都有行 → 不归这一段管,交给「缺不缺必填」那一段
|
|
expect(emptyGate({ 'mine:parts.profile': [{}], 'mine:parts.parameter': [{}] }, watched, declared)).toBeUndefined();
|
|
// 一张有一张没有 → 只点没有的那一张
|
|
expect(emptyGate({ 'mine:parts.profile': [{}] }, watched, declared)?.why).toContain('标准化参数集');
|
|
});
|
|
|
|
it('卡没自报(回落到必交付物那条老路)→ 零行不拦人:那条路上零行是常态', () => {
|
|
expect(emptyGate({}, [{ domain: 'a', object: 'b' }], [])).toBeUndefined();
|
|
});
|
|
|
|
it('优先级:绑的表 > 卡自报 > 交付物;空串与空白不算数', () => {
|
|
expect(gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b'] }], ['parts.profile'])).toEqual(['quote_intake.record']);
|
|
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], ['', ' '])).toEqual(['a.b']);
|
|
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], [' parts.profile '])).toEqual(['parts.profile']);
|
|
});
|
|
|
|
it('老清单只给 ledger 单张 → 退它那一张;两边都空才真的没有依据', () => {
|
|
expect(gateAddressesOf([], [{ ledger: 'quote_sourcing.quote_list' }])).toEqual(['quote_sourcing.quote_list']);
|
|
expect(gateAddressesOf([], [{}])).toEqual([]);
|
|
expect(gateAddressesOf(undefined, [])).toEqual([]);
|
|
});
|
|
|
|
it('非表块(文本段)不算绑表 —— 熔断卡带 basis 不带表,照样回落', () => {
|
|
expect(
|
|
gateAddressesOf(
|
|
[{ t: 'p', text: '拒绝理由' } as never],
|
|
[{ ledgers: ['quote_alternative.list', 'quote_alternative.verdict'] }],
|
|
),
|
|
).toEqual(['quote_alternative.list', 'quote_alternative.verdict']);
|
|
});
|
|
});
|
|
|
|
describe('只写真改了的那几行', () => {
|
|
const row = { rfq_ref: 'R1', pn: 'NE555P', eau: 30000, brand: 'TI' };
|
|
|
|
it('**一个字没改就不算一次修订** —— 件上按保存,整张表都回传上来,它不知道人动了哪一格', () => {
|
|
expect(rowChanged(row, { eau: 30000, brand: 'TI' })).toBe(false);
|
|
});
|
|
|
|
it('改了一格就算 —— 只按值比,不按有没有回传比', () => {
|
|
expect(rowChanged(row, { eau: 45000, brand: 'TI' })).toBe(true);
|
|
});
|
|
|
|
it('**数字与它的字符串写法相等** —— 台账里躺的是 30000,人手填回来的是 "30000"', () => {
|
|
expect(rowChanged(row, { eau: '30000' })).toBe(false);
|
|
});
|
|
|
|
it('空与没有相等 —— 空格子回传成 `""`,而台账里那一列压根没写过', () => {
|
|
expect(rowChanged(row, { remark: '' })).toBe(false);
|
|
expect(rowChanged({ ...row, remark: null }, { remark: '' })).toBe(false);
|
|
});
|
|
|
|
it('台账里还没有这一行 → 是新增,当然算改', () => {
|
|
expect(rowChanged(undefined, { eau: 30000 })).toBe(true);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* **增删行**(2026-08-02 用户:「编辑列表应该还能增删行」)。
|
|
*
|
|
* 手改本来就是**兜 AI 的错**的那一手,而错不止「这一格填错了」:
|
|
* 「这一行根本不该在」(客户点名本轮跳过的料被写进了有效清单)与「漏了一行」
|
|
* 是同一类错 —— 只给改格子,前两种就没有出口,那一单会一路走到放行门被判据拦住,
|
|
* 而人明明一眼就看出问题在哪(实测那一单 13 次拒回、234k token 才熔断)。
|
|
*/
|
|
describe('增删行:删要说出来,不靠「没回传就是删了」', () => {
|
|
it('**删走 `drops`,与改走 `tables` 分开** —— 两份都按地址过同一道声明门', () => {
|
|
const drops = writesOf([RECORD], { 'quote_intake.record': [{ pn: 'NE555P', rfq_ref: 'R1' }] });
|
|
expect(drops).toHaveLength(1);
|
|
expect(drops[0]?.lines).toEqual([{ pn: 'NE555P', rfq_ref: 'R1' }]);
|
|
});
|
|
|
|
it('删的地址也得在声明里 —— 载荷从浏览器来,撤行比改格子更该有门', () => {
|
|
expect(writesOf([RECORD], { 'customer.profile': [{ name: '撤我' }] })).toEqual([]);
|
|
});
|
|
|
|
it('**回传里没提到的行不算删** —— 一次不完整的回传不该变成一次删表', () => {
|
|
// 三行的表只回传一行:那是「这次只说了这一行」,不是「另外两行撤掉」
|
|
const got = writesOf([RECORD], { 'quote_intake.record': [{ pn: 'A' }] });
|
|
expect(got[0]?.lines).toHaveLength(1); // 删与不删由 drops 说了算,tables 不承载删的语义
|
|
});
|
|
|
|
it('新增行照样过归型与必填 —— 加一行只填了 PN,闸照旧拦着', () => {
|
|
const readings = missingReadings(
|
|
{ 'mine:quote_intake.record': [{ rfq_ref: 'R1', pn: '老料', eau: 1 }, { rfq_ref: 'R1', pn: '人加的新料' }] },
|
|
[RECORD],
|
|
);
|
|
const got = gateOf(readings, [RECORD]);
|
|
expect(got.ready).toBe(false);
|
|
expect(got.blanks).toEqual(['R1/人加的新料 缺年用量 EAU']);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* **自增主键由基座生成**(2026-08-02 用户:「联系人ID自增啊,不用填」)。
|
|
*
|
|
* 联系人编码不承载业务含义,只是「这家客户下的第几位」。让人填是白占一格
|
|
* 他答不上来的表单(他哪知道已经有几位);让模型编更糟 —— 编重了就是覆盖别人那一行,
|
|
* 而主数据覆盖错了后面每一单都跟着错。
|
|
*/
|
|
describe('自增主键:前缀-两位序号', () => {
|
|
/** 与 `routes/desk.ts` 的 `autoKeys` 同一口径 —— 改一处这里要跟着改 */
|
|
const gen = (others: string[], given: Record<string, string>, count: number, names: string[]) => {
|
|
const prefix = others.map((k) => given[k] ?? '').filter((v) => v !== '').join('-');
|
|
const out: Record<string, string> = {};
|
|
for (const [i, n] of names.entries()) {
|
|
const seq = String(count + 1 + i).padStart(2, '0');
|
|
out[n] = prefix === '' ? seq : `${prefix}-${seq}`;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
it('**照种子里那个写法**:第一位联系人是 `客户编码-01`', () => {
|
|
expect(gen(['customer_code'], { customer_code: 'CU-9001' }, 0, ['contact_code'])).toEqual({
|
|
contact_code: 'CU-9001-01',
|
|
});
|
|
});
|
|
|
|
it('这家客户已经有两位 → 新的是 -03,不跟谁撞', () => {
|
|
expect(gen(['customer_code'], { customer_code: 'CU-0142' }, 2, ['contact_code'])['contact_code']).toBe('CU-0142-03');
|
|
});
|
|
|
|
it('同一单里连加两位 → 各自拿到 -01 / -02(后一位不覆盖前一位)', () => {
|
|
expect(Object.values(gen(['customer_code'], { customer_code: 'CU-1' }, 0, ['a', 'b']))).toEqual([
|
|
'CU-1-01',
|
|
'CU-1-02',
|
|
]);
|
|
});
|
|
|
|
it('没有其余主键(它自己就是唯一那把键)→ 退回纯序号', () => {
|
|
expect(gen([], {}, 4, ['seq'])).toEqual({ seq: '05' });
|
|
});
|
|
});
|
|
|
|
describe('主键摆不全时认现有行:一格都没认出来的碎片不许命中随便第一行', () => {
|
|
const drafts = [
|
|
{ key: { round_ref: 'R1', contact_code: 'CU-0142-01' } },
|
|
{ key: { round_ref: 'R1', contact_code: 'CU-0087-02' } },
|
|
{ key: { round_ref: 'R1', contact_code: 'CU-0087-01' } },
|
|
];
|
|
const keys = ['round_ref', 'contact_code'];
|
|
|
|
it('至少一格对得上 → 命中那一行(正常路径)', () => {
|
|
expect(findKnownRow(drafts, keys, { contact_code: 'CU-0087-02' })?.key).toEqual({
|
|
round_ref: 'R1',
|
|
contact_code: 'CU-0087-02',
|
|
});
|
|
});
|
|
|
|
it('**一格主键都没给的碎片**(如渲染多嵌一层导致 collect() 拆出的空键分组)→ 不命中第一行,回 undefined', () => {
|
|
expect(findKnownRow(drafts, keys, {})).toBeUndefined();
|
|
});
|
|
|
|
it('给了但一个都对不上 → 同样不命中(不是「随便一行」,是「哪行都不是」)', () => {
|
|
expect(findKnownRow(drafts, keys, { contact_code: 'CU-9999-01' })).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* **点开一份资料文件时,那一行是怎么认出来的**(2026-08-23)。
|
|
*
|
|
* 件上传来的 `key` 是一串字符,而"哪一行"是主键说了算。这一段的要害是
|
|
* **只认声明里的主键列** —— 不然一个精心构造的 key 就能把别的行当成"这一份资料"取走。
|
|
*/
|
|
describe('parseRowKey —— 件上传来的主键认哪一行', () => {
|
|
it('单列主键:直接给值', () => {
|
|
expect(parseRowKey('CT-2026-0001', ['content_id'])).toEqual({ content_id: 'CT-2026-0001' });
|
|
});
|
|
|
|
it('多列主键:列=值 以逗号连,顺序无所谓', () => {
|
|
expect(parseRowKey('req_seq=2,round_ref=BR-1', ['round_ref', 'req_seq'])).toEqual({
|
|
round_ref: 'BR-1',
|
|
req_seq: '2',
|
|
});
|
|
});
|
|
|
|
it('**声明里没有的列名一律不认** —— 别让一串 key 把别的行取走', () => {
|
|
expect(parseRowKey('content_id=A,secret=1', ['content_id'])).toBeUndefined();
|
|
});
|
|
|
|
it('主键给不全:不认(宁可打不开,也不打开错的那一份)', () => {
|
|
expect(parseRowKey('round_ref=BR-1', ['round_ref', 'req_seq'])).toBeUndefined();
|
|
expect(parseRowKey('round_ref=BR-1,req_seq=', ['round_ref', 'req_seq'])).toBeUndefined();
|
|
});
|
|
|
|
it('多列主键却只给了个裸值:不认(猜不出它是哪一列)', () => {
|
|
expect(parseRowKey('BR-1', ['round_ref', 'req_seq'])).toBeUndefined();
|
|
});
|
|
|
|
it('空串 / 没有主键列:不认', () => {
|
|
expect(parseRowKey('', ['content_id'])).toBeUndefined();
|
|
expect(parseRowKey('X', [])).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* **保存说成功,就得真的都存下了**(2026-08-24 用户实测:
|
|
* 「用编辑新增了一个客户,推进到下一步直接就把我手动添加的这一行遗漏掉了」)。
|
|
*
|
|
* 主键那几格空着就凑不出钥匙 —— 从前基座一句 `continue` 悄悄跳过,接口照回 `{ok:true}`:
|
|
* 人看到「保存成功」,东西却不在,而提交闸也拦不住(一行根本没写进台账,谈何缺列)。
|
|
* 现在没落下的行要点名说出来,且**只点他填得了的那几列**。
|
|
*/
|
|
describe('落不下的行要点名 —— 不许悄悄丢', () => {
|
|
const keys = ['round_ref', 'customer_code'];
|
|
|
|
it('主键那一格空着 → 认不出这一行(resolveRowKey 交白卷)', () => {
|
|
expect(
|
|
resolveRowKey({
|
|
keys,
|
|
cells: { customer_code: '', customer_name: '忘了填编号的客户' },
|
|
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
|
|
types: { round_ref: 'text', customer_code: 'text' },
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('主键填了 → 案号由「全表一致」补齐,行认得出来', () => {
|
|
expect(
|
|
resolveRowKey({
|
|
keys,
|
|
cells: { customer_code: 'CU-9527', customer_name: '人工加的客户' },
|
|
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
|
|
types: { round_ref: 'text', customer_code: 'text' },
|
|
}),
|
|
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
|
|
});
|
|
|
|
it('**表里只有一行时也不许「继承」老行的编码** —— 那会当场把老行覆盖掉', () => {
|
|
// 件上摆着 customer_code、人却空着:一行的表在「全表一致」眼里像个常量,
|
|
// 从前新行会拿到 CU-0142(屏上两行、库里一行,而且内容还是新的那份)
|
|
expect(
|
|
resolveRowKey({
|
|
keys,
|
|
cells: { customer_code: '', customer_name: '新客户' },
|
|
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
|
|
types: { round_ref: 'text', customer_code: 'text' },
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('件上没摆的那把钥匙(案号)照旧由基座按全表一致补 —— 那是他碰不到的列', () => {
|
|
expect(
|
|
resolveRowKey({
|
|
keys,
|
|
cells: { customer_code: 'CU-9527' },
|
|
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
|
|
types: { round_ref: 'text', customer_code: 'text' },
|
|
}),
|
|
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
|
|
});
|
|
});
|