/** * 主数据四域 —— 客户 / 项目 / 供应商 / 市场。 * * 这份测试从前断言的是「四域十九对象」,那个四域是 project / customer / intel / supplier, * 装的是**退役占位槽**的声明,里面混着一单一份的流转单据(受理记录、比价表、报价草案)。 * 2026-07-31 重分:单据归各活动自己的域,这个文件只留**主数据**,按常规 CRM / ERP 分四域。 * * 守的是三条,每条都能机检: * · 八个域,多一个就说明有人把单据塞进主数据了; * · 每张表都是 `master` 且**不带单标识** —— 带了就是单据归错类(安装静态审也会拦); * · 主键是**业务实体**,不是单。 */ import { describe, expect, it } from 'vitest'; import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from '../seed-mvp-data.ts'; const tableOf = (domain: string, object: string) => MASTER_OBJECTS.find((o) => o.domain === domain && o.object === object); const seedRowsOf = (domain: string, object: string) => MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === domain && r.address.object === object); describe('主数据四域', () => { it('八个域:客户 / 项目 / 供应商 / 市场 + 料件 / 需求 / 替代 / 验证(SS02 差异评估 2026-08-26 加后四域)', () => { expect([...MASTER_DOMAINS]).toEqual(['customer', 'project', 'supplier', 'market', 'parts', 'demand', 'substitution', 'validation']); expect([...new Set(MASTER_OBJECTS.map((o) => o.domain))].sort()).toEqual([...MASTER_DOMAINS].sort()); }); it('SS02 差异评估要读写的七张主数据表都在基线里,主键照《统一方案》', () => { const pk = (domain: string, object: string) => MASTER_OBJECTS.find((o) => o.domain === domain && o.object === object)?.fields.filter((f) => f.primaryKey).map((f) => f.name); expect(pk('parts', 'profile')).toEqual(['internal_pn']); expect(pk('parts', 'parameter')).toEqual(['internal_pn', 'param_name']); expect(pk('parts', 'document')).toEqual(['internal_pn', 'doc_seq']); expect(pk('demand', 'request')).toEqual(['request_id']); expect(pk('substitution', 'relation')).toEqual(['substitution_relation_id']); expect(pk('substitution', 'difference_item')).toEqual(['substitution_relation_id', 'difference_item_id']); expect(pk('validation', 'record')).toEqual(['validation_id']); }); it('样例料号:原 PN 与同族可调版在库,候选 XL2596S-5.0 刻意不播(数据工单那条路要试得出来)', () => { const parts = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile').map((r) => r.key['internal_pn']); // AMS1117-3.3(LDO)是 W05 重写 Task 1 新播的第三颗 —— 「两颗料类型不一致 → 硬拦」那条路的唯一素材 expect(parts).toEqual(['LM2596S-5.0', 'LM2596S-ADJ', 'AMS1117-3.3']); expect(parts).not.toContain('XL2596S-5.0'); }); it('样例料号的标准化参数集是独立子表 parts.parameter:两颗料各 7 行,主键是 (internal_pn, param_name)', () => { const params = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'parameter'); const byPn = (pn: string) => params.filter((r) => r.key['internal_pn'] === pn); expect(byPn('LM2596S-5.0')).toHaveLength(7); expect(byPn('LM2596S-ADJ')).toHaveLength(7); for (const row of params) { expect(Object.keys(row.key).sort()).toEqual(['internal_pn', 'param_name']); expect(row.values['param_value'], JSON.stringify(row.key)).toBeTruthy(); // 约束类型已搬到 parts.type_param 字典上(W05 重写 Task 1)—— 这张子表不再带这一列, // 留着旧断言会重新允许「同一个参数在两颗料上各标一次约束」,正是这次改造要消灭的东西。 expect(row.values['constraint_type']).toBeUndefined(); } // parts.profile 不再声明这一列的值 —— 两行样例主记录里它应为空(物理列留在基线,不再写) const profiles = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile'); for (const row of profiles) expect(row.values['normalized_parameter_set']).toBeUndefined(); }); it('四个域各自都有表 —— 空占位也得立得住', () => { for (const d of MASTER_DOMAINS) { expect(MASTER_OBJECTS.filter((o) => o.domain === d).length, `${d} 域一张表都没有`).toBeGreaterThan(0); } }); /* * 这一条是分类的要害:主数据按**业务实体**分行,不按单。 * 带上单标识就说明它其实是一单一份的单据 —— 归错类,治理方式全跟着错。 */ it('每张表都是 master 且不带单标识', () => { for (const o of MASTER_OBJECTS) { expect(o.kind, `${o.domain}.${o.object}`).toBe('master'); expect(o.caseKey, `${o.domain}.${o.object} 不该有单标识`).toBeUndefined(); } }); it('每张表都有主键 —— 没主键的台账合并与去重无从谈起', () => { for (const o of MASTER_OBJECTS) { const pk = o.fields.filter((f) => f.primaryKey).map((f) => f.name); expect(pk.length, `${o.domain}.${o.object} 没有主键`).toBeGreaterThan(0); } }); /* 主键得是业务实体键 —— 单标识那几个名字出现在主键里,就是归错了类 */ it('主键里不出现单标识那几个名字', () => { for (const o of MASTER_OBJECTS) { const pk = o.fields.filter((f) => f.primaryKey).map((f) => f.name); for (const caseish of ['rfq_ref', 'quote_no', 'task_id']) { expect(pk, `${o.domain}.${o.object} 的主键里有单标识 ${caseish}`).not.toContain(caseish); } } }); /* 个人信息必须标脱敏 —— 主数据里存联系人,治理面照 `masked` 裁剪 */ it('联系人的邮箱与电话标了脱敏', () => { const contacts = MASTER_OBJECTS.filter((x) => x.object === 'contact'); expect(contacts.length).toBeGreaterThan(0); for (const o of contacts) { for (const name of ['email', 'phone']) { const field = o.fields.find((x) => x.name === name); expect(field?.masked, `${o.domain}.${o.object}.${name} 未标脱敏`).toBe(true); } } }); }); describe('芯片类型字典(W05 重写)', () => { it('两张新表都物化了,主键形状对', () => { const chipType = tableOf('parts', 'chip_type'); expect(chipType?.fields.find((f) => f.name === 'type_id')).toMatchObject({ type: 'integer', primaryKey: true }); expect(chipType?.fields.map((f) => f.name)).toEqual(expect.arrayContaining(['type_id', 'type_name', 'description'])); const typeParam = tableOf('parts', 'type_param'); /* 复合主键:一个类型下多个参数名 —— 主键少一半,同一类型的第二个参数就会覆盖第一个 */ expect(typeParam?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['type_id', 'param_name']); expect(typeParam?.fields.map((f) => f.name)).toEqual( expect.arrayContaining(['type_id', 'param_name', 'param_desc', 'constraint_type', 'param_unit']), ); }); it('P-08 上有 chip_type_id,类型与字典的主键对得上(integer)', () => { expect(tableOf('parts', 'profile')?.fields.find((f) => f.name === 'chip_type_id')).toMatchObject({ type: 'integer' }); }); it('种子:两个类型,DC-DC 七个核心参数字段、LDO 五个', () => { const types = seedRowsOf('parts', 'chip_type'); expect(types.map((r) => r.values['type_name']).sort()).toEqual(['DC-DC 降压稳压器', 'LDO 线性稳压器']); const dcdc = types.find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id']; const params = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === dcdc); expect(params.map((r) => r.values['param_name'])).toEqual([ '输入电压范围', '输出电压', '输出电流', '开关频率', '静态电流', '工作温度', '使能阈值', ]); /* 约束类型搬到字典上了 —— 同一个参数在两颗料上标法不一致这件事从结构上消失 */ expect(params.find((r) => r.values['param_name'] === '静态电流')?.values['constraint_type']).toBe('soft'); expect(params.find((r) => r.values['param_name'] === '输入电压范围')?.values['constraint_type']).toBe('hard'); /* 每个字段都要有说明 —— AI 分析时读的就是它 */ expect(params.every((r) => String(r.values['param_desc'] ?? '') !== '')).toBe(true); const ldo = types.find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id']; expect(seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo)).toHaveLength(5); }); it('三颗既有料都归了 DC-DC 类型,新播一颗 LDO 供「类型不一致」那条用例用', () => { const dcdc = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id']; const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id']; const parts = seedRowsOf('parts', 'profile'); for (const pn of ['LM2596S-5.0', 'LM2596S-ADJ']) { expect(parts.find((r) => r.key['internal_pn'] === pn)?.values['chip_type_id']).toBe(dcdc); } expect(parts.find((r) => r.key['internal_pn'] === 'AMS1117-3.3')?.values['chip_type_id']).toBe(ldo); }); it('AMS1117-3.3 的参数行按 LDO 的字段清单播齐 —— 它要能被真拿来比', () => { const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id']; const names = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo).map((r) => r.values['param_name']); const rows = seedRowsOf('parts', 'parameter').filter((r) => r.key['internal_pn'] === 'AMS1117-3.3'); expect(rows.map((r) => r.key['param_name']).sort()).toEqual([...names].sort()); }); });