/** * 打**真实 DMS**验证企业信息同步的落库行为(栏目 1888 企业基础信息 / 1886 企业荣誉信息)。 * * 用**注入的假数据**(假的分类结果 + 假的荣誉列表)跑完整落库链路,因此: * - 不消耗分类接口的 LLM 调用,可以反复跑 * - 覆盖边界(改标签、删荣誉、complete=false 不许删、缺信用代码跳过…) * * 验证什么: * ① 1888 首写:一行,六个标签字段 = 对应角度的 JSON 数组 * ② 1886 首写:**一条荣誉一行**,title 与 c_honor 都是荣誉名,级别/来源/机构/日期/证书号各就各位 * ③ 幂等:同样数据再跑一次 → 零新增、行 uuid 不变 * ④ 改:1888 标签变化 → 仍是同一行且已更新;1886 少一条/多一条/改字段 → * 删多余、补新增、更新差异,行数与后端列表一致 * ⑤ `honors.complete = false` → **只增改不删除**(记录集不完整时不许删) * ⑥ 缺信用代码 → 整条跳过,不写任何行 * ⑦ 清理:按测试信用代码删掉本次产生的全部行,复查残留 0 * * 怎么跑(先按 _entry-company-classify.ts 头部注释重新打包): * 1) npm run dev # 代理负责注入 DMS token * 2) node harness/tools/verify-company-classify-dms.mjs https://localhost:8083/dms-api * * ⚠️ 脚本**不持有 token**(和浏览器一样只打代理地址);测试数据用可识别的信用代码前缀, * 结束时自行清理。 */ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; const base = process.argv[2] || 'https://localhost:8083/dms-api'; globalThis.VITE_DMS_API = base; const { applyCompanyClassification, applyCompanyHonors, applyCompanyProfile, HONOR_NAME_FIELD, ANGLE_TO_FIELD, DMS_COLUMN_ENTERPRISE, DMS_COLUMN_HONOR, DMS_MODEL_HONOR, DMS_MODEL_ENTERPRISE, addDmsContent, deleteDmsContent, searchDmsContents, updateDmsContent, } = await import('./_company-classify.mjs'); let pass = 0; let fail = 0; const check = (name, ok, extra = '') => { if (ok) { pass++; console.log(` ok ${name}`); } else { fail++; console.log(` FAIL ${name}${extra === '' ? '' : ` → ${JSON.stringify(extra)}`}`); } }; const TEST_CREDIT_CODE = `TEST_CC_${Date.now()}`; const TEST_NAME = '分类同步测试公司'; const IDENTITY = { name: TEST_NAME, creditCode: TEST_CREDIT_CODE, legalRep: '测试法人' }; /** 造一条企查查形态的荣誉记录(`records[].data`) */ const rec = (Name, Level, Source, extra = {}) => ({ data: { Name, Level, Source, ...extra }, }); const honorsNode = (list, complete = true) => ({ complete, records: list, sources: [], error: null }); const findEnterprise = () => searchDmsContents(DMS_COLUMN_ENTERPRISE, { search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT_CODE } }], page: 0, pageSize: 10, }); const findHonors = () => searchDmsContents(DMS_COLUMN_HONOR, { search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT_CODE } }], page: 0, pageSize: 200, }); const honorNames = (rows) => rows .map((r) => (typeof r[HONOR_NAME_FIELD] === 'string' && r[HONOR_NAME_FIELD] ? r[HONOR_NAME_FIELD] : r.title)) .sort(); const classifyWith = (categories) => ({ company_name: TEST_NAME, credit_code: TEST_CREDIT_CODE, legal_representative: '测试法人', categories, status: 'completed', errors: {}, }); console.log(`测试信用代码:${TEST_CREDIT_CODE}\n`); try { /* ---------------------------------------------------------------- * * ① 首写 * ---------------------------------------------------------------- */ console.log('【1】首写:1888 一行 + 1886 一条荣誉一行'); await applyCompanyClassification( classifyWith({ 基本信息: ['小微企业'], 资质荣誉: ['高新技术企业'], 产业信息: ['第一产业', '现代农业'], 经营活动: ['对外投资'], 行业信息: ['涉农(广义)'], 许可认证: ['食品生产许可'], }), IDENTITY ); await applyCompanyHonors( honorsNode([ rec('高新技术企业', '国家级', '某认定公告', { PublishOffice: '科技部', PublishDate: '2025-01-02', CertificateCode: 'CERT-1', }), rec('独角兽企业', '省级', '2026 浙江独角兽', { PublishDate: '2026-04-23' }), ]), IDENTITY ); const rows1 = await findEnterprise(); check('1888 恰好一行', rows1.length === 1, rows1.length); const row1 = rows1[0] || {}; check('1888 身份字段正确', row1.c_name === TEST_NAME && row1.c_oper_name === '测试法人', row1); check('1888 标签字段正确(非空角度)', row1[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]' && row1[ANGLE_TO_FIELD['产业信息']] === '["第一产业","现代农业"]', row1); const honors1 = await findHonors(); check('1886 行数 = 荣誉记录数(2)', honors1.length === 2, honors1.length); const h1 = honors1.find((r) => r[HONOR_NAME_FIELD] === '高新技术企业') || {}; check('1886 title 与 c_honor 都写荣誉名', h1.title === '高新技术企业' && h1[HONOR_NAME_FIELD] === '高新技术企业', { title: h1.title, tag: h1[HONOR_NAME_FIELD] }); check('1886 级别/来源/机构/日期/证书号落位', h1.c_level === '国家级' && h1.c_source === '某认定公告' && h1.c_publish_office === '科技部' && h1.c_publish_date === '2025-01-02' && h1.c_certificate_code === 'CERT-1', h1); check('1886 归属正确', honors1.every((r) => r.c_credit_code === TEST_CREDIT_CODE && r.c_name === TEST_NAME)); /* ---------------------------------------------------------------- * * ② 幂等 * ---------------------------------------------------------------- */ console.log('\n【2】同数据重跑 → 零新增'); await applyCompanyClassification( classifyWith({ 基本信息: ['小微企业'], 资质荣誉: ['高新技术企业'], 产业信息: ['第一产业', '现代农业'], 经营活动: ['对外投资'], 行业信息: ['涉农(广义)'], 许可认证: ['食品生产许可'] }), IDENTITY ); await applyCompanyHonors( honorsNode([ rec('高新技术企业', '国家级', '某认定公告', { PublishOffice: '科技部', PublishDate: '2025-01-02', CertificateCode: 'CERT-1' }), rec('独角兽企业', '省级', '2026 浙江独角兽', { PublishDate: '2026-04-23' }), ]), IDENTITY ); const rows2 = await findEnterprise(); check('1888 仍是一行、uuid 未变', rows2.length === 1 && rows2[0]?.id === row1.id, rows2.length); check('1886 仍 2 行', (await findHonors()).length === 2); /* ---------------------------------------------------------------- * * ③ 改:1888 标签 + 1886 增删改 * ---------------------------------------------------------------- */ console.log('\n【3】改:1888 更新、1886 增删改对齐'); await applyCompanyClassification( classifyWith({ 基本信息: ['小微企业', '专精特新'], 资质荣誉: ['高新技术企业'], 产业信息: ['第一产业', '现代农业'], 经营活动: ['对外投资'], 行业信息: ['涉农(广义)'], 许可认证: ['食品生产许可'] }), IDENTITY ); await applyCompanyHonors( honorsNode([ // 同键但发布机构变了 → 更新 rec('高新技术企业', '国家级', '某认定公告', { PublishOffice: '工信部', PublishDate: '2025-01-02', CertificateCode: 'CERT-1' }), // 新增一条 rec('科技小巨人', '市级', '2026 市科委名单'), // 「独角兽企业」本条不出现 → 应被删除 ]), IDENTITY ); const rows3 = await findEnterprise(); check('1888 仍是一行', rows3.length === 1, rows3.length); check('1888 基本信息已更新', rows3[0]?.[ANGLE_TO_FIELD['基本信息']] === '["小微企业","专精特新"]', rows3[0]?.[ANGLE_TO_FIELD['基本信息']]); const honors3 = await findHonors(); check('1886 行数 = 新列表(2)', honors3.length === 2, honors3.length); check('1886 名字对齐(新增进、多余的删掉)', JSON.stringify(honorNames(honors3)) === JSON.stringify(['科技小巨人', '高新技术企业']), honorNames(honors3)); const h3 = honors3.find((r) => r[HONOR_NAME_FIELD] === '高新技术企业') || {}; check('1886 同键行的字段差异已更新', h3.c_publish_office === '工信部', h3.c_publish_office); /* ---------------------------------------------------------------- * * ④ complete=false:只增改、不删除 * ---------------------------------------------------------------- */ console.log('\n【4】honors.complete=false → 只增改不删除'); await applyCompanyHonors(honorsNode([rec('新荣誉', '区级', '某区名单')], false), IDENTITY); const honors4 = await findHonors(); check('新增的进来了', honorNames(honors4).includes('新荣誉'), honorNames(honors4)); check('**原有的没被删**(3 行:高新技术、科技小巨人、新荣誉)', honors4.length === 3, honorNames(honors4)); /* ---------------------------------------------------------------- * * ⑤ 迁移行也会被对齐(不再是「只碰自己写的行」) * ---------------------------------------------------------------- */ console.log('\n【5】全量对齐:非本同步写入的同企业荣誉行同样参与'); await addDmsContent(DMS_COLUMN_HONOR, DMS_MODEL_HONOR, { c_id: 0, c_credit_code: TEST_CREDIT_CODE, c_name: TEST_NAME, c_source: 'migrate_test', // 模拟迁移数据 title: '迁移来的荣誉', c_level: '国家级', c_created_at: '2026-09-18 12:00:00', }); check('预置 1 条迁移行', (await findHonors()).length === 4, (await findHonors()).length); await applyCompanyHonors(honorsNode([rec('迁移来的荣誉', '国家级', 'migrate_test')]), IDENTITY); const honors5 = await findHonors(); check('后端列表只有它 → 其余全删,只剩 1 行', honors5.length === 1, honorNames(honors5)); check('留下的正是迁移行那一条(按「荣誉名+级别+来源」匹配到同一行)', honors5[0]?.title === '迁移来的荣誉', honors5[0]?.title); /* ---------------------------------------------------------------- * * ⑥ 缺信用代码 → 跳过 * ---------------------------------------------------------------- */ console.log('\n【6】缺信用代码 → 整条跳过'); await applyCompanyHonors(honorsNode([rec('不该出现的荣誉', '国家级', 'X')]), { name: TEST_NAME, creditCode: null, legalRep: null }); check('1886 行数未变', (await findHonors()).length === 1); await applyCompanyHonors(null, IDENTITY); check('honors 缺失/null → 跳过(不清库)', (await findHonors()).length === 1); /* ---------------------------------------------------------------- * * ⑦ 工商信息(profile.data → 1888) * ---------------------------------------------------------------- */ console.log('\n【7】工商信息同步(不依赖分类接口)'); const PROFILE = { Name: TEST_NAME, CreditCode: TEST_CREDIT_CODE, OperName: '测试法人', No: '310118000000001', BelongOrg: '青浦区市场监督管理局', Status: '存续(在营、开业、在册)', Province: 'SH', AreaCode: '310118', Address: '上海市青浦区测试路 1 号', RegistCapi: '1000万元', RegisteredCapital: '1000', IsOnStock: '0', Noop: '映射表里没有的字段', EndDate: '', RevokeInfo: null, DesignatedRepresentativeList: [], OriginalName: [{ Name: '测试曾用名', ChangeDate: '2024-07-22' }], Area: { Province: '上海市', City: '上海市', County: '青浦区' }, }; await applyCompanyProfile(PROFILE, IDENTITY); const p1 = (await findEnterprise())[0] || {}; check('标量字段落位', p1.c_status === '存续(在营、开业、在册)' && p1.c_regist_capi === '1000万元' && p1.c_no === '310118000000001' && p1.c_area_code === '310118', p1); check('数组/对象落成 JSON 字符串', JSON.parse(p1.c_original_name || '[]')[0]?.Name === '测试曾用名' && JSON.parse(p1.c_area || '{}').County === '青浦区', { orig: p1.c_original_name, area: p1.c_area }); check('空值不写(clearing 风险)', p1.c_end_date === undefined || p1.c_end_date === '', p1.c_end_date); check('映射表外的字段不写', p1.c_noop === undefined && p1.Noop === undefined); // ⚠️ 灌入行的 JSON 是 Python/jsonb 序列化的(带空格、键按字母序)——语义相同就不该重写 const PY_STYLE_AREA = '{ "City": "上海市", "County": "青浦区", "Province": "上海市" }'; const rowBefore = (await findEnterprise())[0]; await updateDmsContent(DMS_COLUMN_ENTERPRISE, DMS_MODEL_ENTERPRISE, rowBefore.id, { c_area: PY_STYLE_AREA, c_status: '旧状态' }); await applyCompanyProfile({ ...PROFILE, Area: { Province: '上海市', City: '上海市', County: '青浦区' }, Status: '存续(在营、开业、在册)' }, IDENTITY); const p2 = (await findEnterprise())[0] || {}; check('**语义相同的 JSON 不被重写**(灌入形态原样保留)', p2.c_area === PY_STYLE_AREA, p2.c_area); check('真变化的文本列照常更新', p2.c_status === '存续(在营、开业、在册)', p2.c_status); await applyCompanyProfile(PROFILE, IDENTITY); check('再同步一次:JSON 列仍是灌入形态(不反复写)', (await findEnterprise())[0]?.c_area === PY_STYLE_AREA); } finally { /* ---------------------------------------------------------------- * * 清理 * ---------------------------------------------------------------- */ console.log('\n【清理】'); const cleanup = async (columnId, label) => { const rows = columnId === DMS_COLUMN_ENTERPRISE ? await findEnterprise() : await findHonors(); for (const row of rows) { if (typeof row.id === 'string') await deleteDmsContent(columnId, row.id); } console.log(` ${label} 清理 ${rows.length} 行`); }; await cleanup(DMS_COLUMN_ENTERPRISE, '1888'); await cleanup(DMS_COLUMN_HONOR, '1886'); check('残留 0 行(1888)', (await findEnterprise()).length === 0); check('残留 0 行(1886)', (await findHonors()).length === 0); } console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);