| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- /**
- * 验证企业分类同步的**纯逻辑**(不碰网络、不碰 DMS)。
- *
- * 验证什么:
- * ① 身份抽取的取值顺序与容错(profile.data 优先、回退 company、非字符串一律不算)
- * ② 分类响应 → 1888 期望行(失败角度不写、保守模式只写非空角度)
- * ③ 1888 diff:无变化 → null(零写入)、`"[]"` 与 `[]` 不产生假差异、期望为空不清存量
- * ④ 1886 荣誉对齐:解析 `honors.records`、按「荣誉名+级别+来源」全量对齐
- * (补新增 / 更新差异 / 删多余)、`complete !== true` 时**不许删**、
- * 荣誉名回退顺序(c_tag_name → title,字符串 "null" 不算名字)
- * ⑤ partial / errors 不可解析时的降级语义
- * ⑥ 分类接口地址拼接(VITE_CHAT_API 的两种写法)
- *
- * 怎么跑(先按 _entry-company-classify.ts 头部注释重新打包):
- * node harness/tools/verify-company-classify-sync.mjs
- */
- import {
- ANGLE_TO_FIELD,
- HONOR_TAG_FIELD,
- buildEnterpriseRow,
- buildHonorKey,
- buildHonorRowContent,
- decodeAngleTags,
- diffEnterpriseRow,
- diffHonorRows,
- encodeAngleTags,
- extractFailedAngles,
- extractIdentityFromCompanyInfo,
- isCompanyInfoUsable,
- normalizeTagSet,
- parseHonorRecords,
- readHonorsFromCompanyInfo,
- resolveClassifyEndpoint,
- resolveSyncedIdentity,
- sameTagSet,
- } from './_company-classify.mjs';
- let pass = 0;
- let fail = 0;
- const check = (name, cond, detail) => {
- if (cond) {
- pass += 1;
- console.log(` ok ${name}`);
- } else {
- fail += 1;
- console.log(` FAIL ${name}${detail === undefined ? '' : ` → ${JSON.stringify(detail)}`}`);
- }
- };
- /* ---------------------------------------------------------------- *
- * ① 身份抽取
- * ---------------------------------------------------------------- */
- console.log('\n【1】身份抽取');
- {
- const full = {
- company: { KeyNo: 'k1', Name: '公司名A', CreditCode: 'CODE-A', OperName: '法人A' },
- profile: { data: { KeyNo: 'k1', Name: '公司名B', CreditCode: 'CODE-B', OperName: '法人B' } },
- };
- const id = extractIdentityFromCompanyInfo(full);
- check('profile.data 优先于 company', id.name === '公司名B' && id.creditCode === 'CODE-B' && id.legalRep === '法人B', id);
- const companyOnly = { company: { Name: '只有 company', CreditCode: 'CODE-C' } };
- const id2 = extractIdentityFromCompanyInfo(companyOnly);
- check('profile 缺失时回退 company(逐项回退)', id2.name === '只有 company' && id2.creditCode === 'CODE-C' && id2.legalRep === null, id2);
- const partial = { company: { Name: 'A' }, profile: { data: { CreditCode: 'ONLY-CC' } } };
- const id3 = extractIdentityFromCompanyInfo(partial);
- check('profile 有但缺字段 → 该项回退 company', id3.name === 'A' && id3.creditCode === 'ONLY-CC', id3);
- const dirty = { company: { Name: 123, CreditCode: ' CODE-D ', OperName: '' } };
- const id4 = extractIdentityFromCompanyInfo(dirty);
- check('非字符串/空串不算,字符串去首尾空白', id4.name === null && id4.creditCode === 'CODE-D' && id4.legalRep === null, id4);
- check('全缺 → null', extractIdentityFromCompanyInfo({ company: {}, profile: { data: {} } }) === null);
- check('null/undefined/{} → null', extractIdentityFromCompanyInfo(null) === null && extractIdentityFromCompanyInfo(undefined) === null && extractIdentityFromCompanyInfo({}) === null);
- check('数组/字符串 → null', extractIdentityFromCompanyInfo([]) === null && extractIdentityFromCompanyInfo('x') === null);
- check('isCompanyInfoUsable 与之一致', isCompanyInfoUsable({ company: { Name: 'X' } }) === true && isCompanyInfoUsable({}) === false);
- check('readHonorsFromCompanyInfo 取 honors 节点', readHonorsFromCompanyInfo({ honors: { records: [] } }) !== null && readHonorsFromCompanyInfo({}) === null);
- }
- /* ---------------------------------------------------------------- *
- * ② 标签编解码
- * ---------------------------------------------------------------- */
- console.log('\n【2】标签编解码');
- {
- check('encode 数组 → JSON 字符串', encodeAngleTags(['第一产业', '现代农业']) === '["第一产业","现代农业"]');
- check('encode 空数组 → "[]"', encodeAngleTags([]) === '[]');
- check('encode null → "[]"', encodeAngleTags(null) === '[]');
- check('encode undefined → undefined(表示本轮不写)', encodeAngleTags(undefined) === undefined);
- check('normalize 去空、去重、trim、保序', JSON.stringify(normalizeTagSet([' a ', '', 'b', 'a', null, 3])) === '["a","b"]');
- check('decode 往返', JSON.stringify(decodeAngleTags(encodeAngleTags(['x', 'y']))) === '["x","y"]');
- check('decode 非法/非字符串 → []', decodeAngleTags('not json').length === 0 && decodeAngleTags(null).length === 0 && decodeAngleTags('{"a":1}').length === 0);
- check('sameTagSet 与顺序无关', sameTagSet(['a', 'b'], ['b', 'a']) === true && sameTagSet(['a'], ['a', 'b']) === false);
- }
- /* ---------------------------------------------------------------- *
- * ③ 期望行 + diff
- * ---------------------------------------------------------------- */
- console.log('\n【3】1888 期望行与 diff');
- {
- const classification = {
- company_name: '合成公司',
- credit_code: 'CODE-X',
- legal_representative: '合成法人',
- categories: {
- 基本信息: ['小微企业'],
- 资质荣誉: ['高新技术企业'],
- 产业信息: [],
- 经营活动: [],
- 行业信息: ['涉农(广义)'],
- 许可认证: [],
- },
- status: 'completed',
- };
- const row = buildEnterpriseRow(classification, { failed: new Set(), conservative: false });
- check('身份三字段落位', row.c_name === '合成公司' && row.c_credit_code === 'CODE-X' && row.c_oper_name === '合成法人', row);
- check('六角度各自落位(含空数组 → "[]")', row[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]' && row[ANGLE_TO_FIELD['产业信息']] === '[]' && row[ANGLE_TO_FIELD['许可认证']] === '[]', row);
- check('行业信息用 c_tag_industry(不占用既有的 c_industry)', row[ANGLE_TO_FIELD['行业信息']] === '["涉农(广义)"]' && row.c_industry === undefined);
- const failedRow = buildEnterpriseRow(classification, { failed: new Set(['产业信息']), conservative: false });
- check('失败角度不写该字段', failedRow[ANGLE_TO_FIELD['产业信息']] === undefined && failedRow[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]');
- const conservativeRow = buildEnterpriseRow(classification, { failed: new Set(), conservative: true });
- check('保守模式只写非空角度', conservativeRow[ANGLE_TO_FIELD['行业信息']] === '["涉农(广义)"]' && conservativeRow[ANGLE_TO_FIELD['产业信息']] === undefined);
- const noIdentity = buildEnterpriseRow({ categories: {} }, { failed: new Set(), conservative: false });
- check('身份缺失时不写身份字段(而不是写 null)', !('c_name' in noIdentity) && !('c_credit_code' in noIdentity) && !('c_oper_name' in noIdentity), noIdentity);
- const hintRow = buildEnterpriseRow({ categories: {} }, { failed: new Set(), conservative: false, identityHint: { name: '回退名', creditCode: 'FALLBACK', legalRep: null } });
- check('响应缺身份时回退 identityHint', hintRow.c_name === '回退名' && hintRow.c_credit_code === 'FALLBACK', hintRow);
- const synced = resolveSyncedIdentity({ company_name: null, credit_code: 'C1' }, { name: 'N1', creditCode: 'C0', legalRep: 'L1' });
- check('resolveSyncedIdentity 逐项回退', synced.name === 'N1' && synced.creditCode === 'C1' && synced.legalRep === 'L1', synced);
- // diff
- const existing = { id: 'uuid-1', c_name: '合成公司', c_credit_code: 'CODE-X', [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' };
- const same = diffEnterpriseRow(existing, { c_name: '合成公司', c_credit_code: 'CODE-X', [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' });
- check('全同 → null(零写入)', same === null, same);
- const reordered = diffEnterpriseRow(existing, { [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' });
- check('集合比较:内容相同不算变化', reordered === null, reordered);
- const changed = diffEnterpriseRow(existing, { c_name: '改名了', [ANGLE_TO_FIELD['基本信息']]: '["小微企业","专精特新"]' });
- check('变化字段进 patch,未变字段不进', changed.c_name === '改名了' && changed[ANGLE_TO_FIELD['基本信息']] === '["小微企业","专精特新"]' && !('c_credit_code' in changed), changed);
- const desiredEmptyTags = diffEnterpriseRow({ [ANGLE_TO_FIELD['基本信息']]: '["旧标签"]' }, { [ANGLE_TO_FIELD['基本信息']]: '[]' });
- check('空集是合法目标值(会覆盖旧标签)', desiredEmptyTags !== null && desiredEmptyTags[ANGLE_TO_FIELD['基本信息']] === '[]', desiredEmptyTags);
- const noOverwrite = diffEnterpriseRow({ c_name: '存量名' }, {});
- check('期望里没有的字段不参与比较、不清存量', noOverwrite === null, noOverwrite);
- const fillGap = diffEnterpriseRow({ c_name: '存量名' }, { c_oper_name: '新法人' });
- check('存量缺失 + 期望有 → 补齐', fillGap !== null && fillGap.c_oper_name === '新法人', fillGap);
- }
- /* ---------------------------------------------------------------- *
- * ④ 1886 荣誉对齐(数据源 honors.records)
- * ---------------------------------------------------------------- */
- console.log('\n【4】1886 荣誉对齐');
- {
- // —— 解析 ——
- const honors = {
- complete: true,
- records: [
- { data: { Name: '高新技术企业', Level: '国家级', Source: '某认定公告', PublishOffice: '科技部', PublishDate: '2025-01-02', CertificateCode: 'CERT-1' } },
- { data: { Name: '独角兽企业', Level: '省级', Source: '2026 浙江独角兽', PublishOffice: '', PublishDate: '2026-04-23' } },
- { data: { Level: '国家级' } }, // 缺 Name → 丢弃
- { notData: true }, // 缺 data → 丢弃
- ],
- };
- const parsed = parseHonorRecords(honors);
- check('解析出 2 条(缺 Name / 缺 data 的各丢一条)', parsed.records.length === 2 && parsed.malformed === 2, parsed);
- check('complete 透传', parsed.complete === true);
- check('字段映射正确', parsed.records[0].name === '高新技术企业' && parsed.records[0].level === '国家级' && parsed.records[0].certificateCode === 'CERT-1' && parsed.records[1].publishOffice === null, parsed.records);
- check('honors 缺失 → available=false(调用方整块跳过)', parseHonorRecords(null).available === false && parseHonorRecords(undefined).available === false);
- check('honors={} → available=true 但 0 条、complete=false', (() => { const p = parseHonorRecords({}); return p.available === true && p.records.length === 0 && p.complete === false; })());
- check('complete:false 透传为 false(不许删)', parseHonorRecords({ complete: false, records: [] }).complete === false);
- // —— 键 ——
- check('buildHonorKey 三字段拼接', buildHonorKey('A', '国家级', 'X') === 'A|国家级|X');
- check('buildHonorKey 缺字段留空', buildHonorKey('A', null, undefined) === 'A||');
- const records = parsed.records; // 高新技术企业 | 独角兽企业
- // —— 全量对齐 ——
- /** 灌入行:荣誉名只在系统字段 `title` 里(DMS 侧 09-17 灌的那批就是这样,没有 c_tag_name) */
- const seededRow = (id, title, level, source, extra = {}) => ({ id, title, c_level: level, c_source: source, ...extra });
- /** 已对齐过的行:title 与 c_tag_name 都有 */
- const alignedRow = (id, title, level, source, extra = {}) =>
- seededRow(id, title, level, source, { [HONOR_TAG_FIELD]: title, ...extra });
- const rows = [
- alignedRow('u1', '高新技术企业', '国家级', '某认定公告', { c_publish_office: '科技部', c_publish_date: '2025-01-02', c_certificate_code: 'CERT-1' }),
- alignedRow('u2', '独角兽企业', '省级', '2026 浙江独角兽', { c_publish_date: '2026-04-23' }),
- seededRow('u3', '过期的荣誉', '市级', '老名单 2019'),
- ];
- const aligned = diffHonorRows(rows, records, { allowDelete: true });
- check('已对齐的行不产生增/改(幂等)', aligned.toAdd.length === 0 && aligned.toUpdate.length === 0, { add: aligned.toAdd.length, upd: aligned.toUpdate.length, patch: aligned.toUpdate[0]?.patch });
- check('库里多出来的 → toDelete(带 id 与键)', aligned.toDelete.length === 1 && aligned.toDelete[0].id === 'u3', aligned.toDelete);
- check('无 malformed', aligned.malformed === 0, aligned.malformed);
- const noDelete = diffHonorRows(rows, records, { allowDelete: false });
- check('**complete=false 时绝不删**', noDelete.toDelete.length === 0 && noDelete.toAdd.length === 0, noDelete);
- // 灌入行(只有 title、没有 c_tag_name)→ 首次对齐只补那一列
- const seedOnly = diffHonorRows(
- [seededRow('u1', '高新技术企业', '国家级', '某认定公告', { c_publish_office: '科技部', c_publish_date: '2025-01-02', c_certificate_code: 'CERT-1' })],
- [records[0]],
- { allowDelete: true }
- );
- check('灌入行首次对齐:只补 c_tag_name,不动其它列', seedOnly.toUpdate.length === 1 && JSON.stringify(Object.keys(seedOnly.toUpdate[0].patch)) === JSON.stringify([HONOR_TAG_FIELD]), seedOnly.toUpdate[0]?.patch);
- // 差异 → update(只 patch 有差异的列)
- const staleRow = [seededRow('u1', '高新技术企业', '国家级', '某认定公告', { c_publish_office: '旧机构' })];
- const upd = diffHonorRows(staleRow, [records[0]], { allowDelete: true });
- check('同键字段有差异 → toUpdate,只含差异列', upd.toUpdate.length === 1 && upd.toUpdate[0].patch.c_publish_office === '科技部' && upd.toUpdate[0].patch.title === undefined, upd.toUpdate);
- check('title 与荣誉名一致时不重复 patch', upd.toUpdate[0].patch.title === undefined);
- check('已匹配的行若缺 c_tag_name 也会补上', upd.toUpdate[0].patch[HONOR_TAG_FIELD] === '高新技术企业', upd.toUpdate[0].patch);
- const titleNull = [seededRow('u9', 'null', '国家级', '某认定公告', { [HONOR_TAG_FIELD]: '高新技术企业' })];
- const fixTitle = diffHonorRows(titleNull, [records[0]], { allowDelete: true });
- check('title 是字符串 "null" → 用 c_tag_name 认名字,并把 title 修正', fixTitle.toUpdate.length === 1 && fixTitle.toUpdate[0].patch.title === '高新技术企业', fixTitle);
- check('字符串 "null" 不被当成荣誉名(键能对上)', fixTitle.toAdd.length === 0);
- // 新增
- const empty = diffHonorRows([], records, { allowDelete: true });
- check('库里没有 → 全部 toAdd', empty.toAdd.length === 2 && empty.toDelete.length === 0, empty);
- // 载荷内部重复
- const dup = parseHonorRecords({ complete: true, records: [{ data: { Name: 'A', Level: '国家级', Source: 'X' } }, { data: { Name: 'A', Level: '国家级', Source: 'X' } }] });
- const dupDiff = diffHonorRows([], dup.records, { allowDelete: true });
- check('载荷内重复的荣誉只算一次', dupDiff.toAdd.length === 1, dupDiff.toAdd);
- // 缺 id 的行
- const noId = diffHonorRows([{ title: '高新技术企业', c_level: '国家级', c_source: '某认定公告' }], records, { allowDelete: true });
- check('缺 uuid 的行 → malformed,不匹配也不删', noId.malformed === 1 && noId.toAdd.length === 2 && noId.toDelete.length === 0, noId);
- // 内容
- const content = buildHonorRowContent('CODE-X', '合成公司', records[0], '2026-09-18 18:00:00');
- check('荣誉行内容:title 与 c_tag_name 都写荣誉名', content.title === '高新技术企业' && content[HONOR_TAG_FIELD] === '高新技术企业', content);
- check('荣誉行内容:级别/来源/机构/日期/证书编号各就各位', content.c_level === '国家级' && content.c_source === '某认定公告' && content.c_publish_office === '科技部' && content.c_publish_date === '2025-01-02' && content.c_certificate_code === 'CERT-1', content);
- check('荣誉行内容:必填与归属', content.c_credit_code === 'CODE-X' && content.c_name === '合成公司' && content.c_id === 0, content);
- check('空字段不写进行(不落空值)', !('c_dead_line' in content) && !('c_beging_date' in content), content);
- }
- /* ---------------------------------------------------------------- *
- * ⑤ partial / errors 语义
- * ---------------------------------------------------------------- */
- console.log('\n【5】失败角度与保守降级');
- {
- const partial = { status: 'partial', errors: { 产业信息: 'model_call_failed' }, categories: { 基本信息: ['a'] } };
- const r1 = extractFailedAngles(partial);
- check('errors 键 = 角度名 → 识别为失败角度', r1.failed.has('产业信息') && r1.unknown === false, [...r1.failed]);
- const weird = { status: 'partial', errors: { something_else: 'x' }, categories: {} };
- check('errors 键对不上角度 → unknown 置位', extractFailedAngles(weird).unknown === true);
- check('partial 但无 errors → unknown(不知道哪些可信)', extractFailedAngles({ status: 'partial', categories: {} }).unknown === true);
- check('completed + 空 errors → 不降级', extractFailedAngles({ status: 'completed', errors: {} }).unknown === false);
- }
- /* ---------------------------------------------------------------- *
- * ⑥ 分类接口地址
- * ---------------------------------------------------------------- */
- console.log('\n【6】分类接口地址拼接');
- {
- check('服务前缀写法', resolveClassifyEndpoint('http://192.168.2.23:8000') === 'http://192.168.2.23:8000/api/company/classify');
- check('dev 代理前缀写法', resolveClassifyEndpoint('/chat-api') === '/chat-api/api/company/classify');
- check('完整聊天接口写法(先剥 /api/chat)', resolveClassifyEndpoint('http://x:8000/api/chat') === 'http://x:8000/api/company/classify');
- check('末尾斜杠容错', resolveClassifyEndpoint('http://x:8000///') === 'http://x:8000/api/company/classify');
- check('空 → 空串(调用方会跳过)', resolveClassifyEndpoint('') === '' && resolveClassifyEndpoint(undefined) === '');
- }
- console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
- process.exit(fail ? 1 : 0);
|