/** * **件上就地改** —— 人手改的那几格,是不是落得下去、按不按得下提交。 * * 两件事在这儿测,都是用户实测逼出来的(2026-08-02,同一单): * * ① **按地址收,不按「当下那张卡的第几块」收**。 * 载荷从前是 `{workOrderId, values:{'table:0': …}}`:列名由那张工单的 `blocks[0]` * 给出,于是「这份件能不能改」被绑在了「此刻开着的那张卡长什么样」上。 * 实测:一单在放行门连拒五拍熔断,引擎发的是一张**没有 blocks 的熔断卡**; * 屏上摆着的正是受理记录那份件,闸在、编辑进得去、保存按得下 —— * 前端找不到匹配的块,**一格都没往下送**。而人要补的恰恰是把这一单 * 卡在那儿的那个必填格。 * * ② **必填还空着就按不下提交**。同一单里,受理需求那张卡上一条料缺年用量(必填), * 人照样交了回来;这一单于是一路走到放行门被 L1 拦住,模型连拒五拍熔断。 * 同一件事在提交那一刻拦下便宜得多 —— 那时缺的是哪一格就摆在他眼前。 * * 测的是生产那两段本身(`writesOf` / `gateOf`),不在测里抄一份实现: * 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。 */ import { describe, expect, it } from 'vitest'; import { gateAddressesOf, gateOf, 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([]); }); }); describe('提交闸:必填没填就交不了', () => { const gate = (rows: Array>) => 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>) => 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('老清单只给 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, count: number, names: string[]) => { const prefix = others.map((k) => given[k] ?? '').filter((v) => v !== '').join('-'); const out: Record = {}; 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' }); }); });