verify-company-classify-sync.mjs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. /**
  2. * 验证企业分类同步的**纯逻辑**(不碰网络、不碰 DMS)。
  3. *
  4. * 验证什么:
  5. * ① 身份抽取的取值顺序与容错(profile.data 优先、回退 company、非字符串一律不算)
  6. * ② 分类响应 → 1888 期望行(失败角度不写、保守模式只写非空角度)
  7. * ③ 1888 diff:无变化 → null(零写入)、`"[]"` 与 `[]` 不产生假差异、期望为空不清存量
  8. * ④ 1886 荣誉对齐:解析 `honors.records`、按「荣誉名+级别+来源」全量对齐
  9. * (补新增 / 更新差异 / 删多余)、`complete !== true` 时**不许删**、
  10. * 荣誉名回退顺序(c_honor → c_tag_name → title,字符串 "null" 不算名字)——
  11. * c_honor 是 2026-09-18 晚用户新建的**荣誉名称**列;c_tag_name 只作读取兼容
  12. * ⑤ partial / errors 不可解析时的降级语义
  13. * ⑥ 分类接口地址拼接(VITE_CHAT_API 的两种写法)
  14. *
  15. * 怎么跑(先按 _entry-company-classify.ts 头部注释重新打包):
  16. * node harness/tools/verify-company-classify-sync.mjs
  17. */
  18. import {
  19. ANGLE_TO_FIELD,
  20. HONOR_NAME_FIELD,
  21. buildEnterpriseRow,
  22. buildHonorKey,
  23. buildHonorRowContent,
  24. decodeAngleTags,
  25. diffEnterpriseRow,
  26. diffHonorRows,
  27. encodeAngleTags,
  28. extractFailedAngles,
  29. extractIdentityFromCompanyInfo,
  30. isCompanyInfoUsable,
  31. normalizeTagSet,
  32. parseHonorRecords,
  33. readHonorsFromCompanyInfo,
  34. resolveClassifyEndpoint,
  35. resolveSyncedIdentity,
  36. sameTagSet,
  37. } from './_company-classify.mjs';
  38. let pass = 0;
  39. let fail = 0;
  40. const check = (name, cond, detail) => {
  41. if (cond) {
  42. pass += 1;
  43. console.log(` ok ${name}`);
  44. } else {
  45. fail += 1;
  46. console.log(` FAIL ${name}${detail === undefined ? '' : ` → ${JSON.stringify(detail)}`}`);
  47. }
  48. };
  49. /* ---------------------------------------------------------------- *
  50. * ① 身份抽取
  51. * ---------------------------------------------------------------- */
  52. console.log('\n【1】身份抽取');
  53. {
  54. const full = {
  55. company: { KeyNo: 'k1', Name: '公司名A', CreditCode: 'CODE-A', OperName: '法人A' },
  56. profile: { data: { KeyNo: 'k1', Name: '公司名B', CreditCode: 'CODE-B', OperName: '法人B' } },
  57. };
  58. const id = extractIdentityFromCompanyInfo(full);
  59. check('profile.data 优先于 company', id.name === '公司名B' && id.creditCode === 'CODE-B' && id.legalRep === '法人B', id);
  60. const companyOnly = { company: { Name: '只有 company', CreditCode: 'CODE-C' } };
  61. const id2 = extractIdentityFromCompanyInfo(companyOnly);
  62. check('profile 缺失时回退 company(逐项回退)', id2.name === '只有 company' && id2.creditCode === 'CODE-C' && id2.legalRep === null, id2);
  63. const partial = { company: { Name: 'A' }, profile: { data: { CreditCode: 'ONLY-CC' } } };
  64. const id3 = extractIdentityFromCompanyInfo(partial);
  65. check('profile 有但缺字段 → 该项回退 company', id3.name === 'A' && id3.creditCode === 'ONLY-CC', id3);
  66. const dirty = { company: { Name: 123, CreditCode: ' CODE-D ', OperName: '' } };
  67. const id4 = extractIdentityFromCompanyInfo(dirty);
  68. check('非字符串/空串不算,字符串去首尾空白', id4.name === null && id4.creditCode === 'CODE-D' && id4.legalRep === null, id4);
  69. check('全缺 → null', extractIdentityFromCompanyInfo({ company: {}, profile: { data: {} } }) === null);
  70. check('null/undefined/{} → null', extractIdentityFromCompanyInfo(null) === null && extractIdentityFromCompanyInfo(undefined) === null && extractIdentityFromCompanyInfo({}) === null);
  71. check('数组/字符串 → null', extractIdentityFromCompanyInfo([]) === null && extractIdentityFromCompanyInfo('x') === null);
  72. check('isCompanyInfoUsable 与之一致', isCompanyInfoUsable({ company: { Name: 'X' } }) === true && isCompanyInfoUsable({}) === false);
  73. check('readHonorsFromCompanyInfo 取 honors 节点', readHonorsFromCompanyInfo({ honors: { records: [] } }) !== null && readHonorsFromCompanyInfo({}) === null);
  74. }
  75. /* ---------------------------------------------------------------- *
  76. * ② 标签编解码
  77. * ---------------------------------------------------------------- */
  78. console.log('\n【2】标签编解码');
  79. {
  80. check('encode 数组 → JSON 字符串', encodeAngleTags(['第一产业', '现代农业']) === '["第一产业","现代农业"]');
  81. check('encode 空数组 → "[]"', encodeAngleTags([]) === '[]');
  82. check('encode null → "[]"', encodeAngleTags(null) === '[]');
  83. check('encode undefined → undefined(表示本轮不写)', encodeAngleTags(undefined) === undefined);
  84. check('normalize 去空、去重、trim、保序', JSON.stringify(normalizeTagSet([' a ', '', 'b', 'a', null, 3])) === '["a","b"]');
  85. check('decode 往返', JSON.stringify(decodeAngleTags(encodeAngleTags(['x', 'y']))) === '["x","y"]');
  86. check('decode 非法/非字符串 → []', decodeAngleTags('not json').length === 0 && decodeAngleTags(null).length === 0 && decodeAngleTags('{"a":1}').length === 0);
  87. check('sameTagSet 与顺序无关', sameTagSet(['a', 'b'], ['b', 'a']) === true && sameTagSet(['a'], ['a', 'b']) === false);
  88. }
  89. /* ---------------------------------------------------------------- *
  90. * ③ 期望行 + diff
  91. * ---------------------------------------------------------------- */
  92. console.log('\n【3】1888 期望行与 diff');
  93. {
  94. const classification = {
  95. company_name: '合成公司',
  96. credit_code: 'CODE-X',
  97. legal_representative: '合成法人',
  98. categories: {
  99. 基本信息: ['小微企业'],
  100. 资质荣誉: ['高新技术企业'],
  101. 产业信息: [],
  102. 经营活动: [],
  103. 行业信息: ['涉农(广义)'],
  104. 许可认证: [],
  105. },
  106. status: 'completed',
  107. };
  108. const row = buildEnterpriseRow(classification, { failed: new Set(), conservative: false });
  109. check('身份三字段落位', row.c_name === '合成公司' && row.c_credit_code === 'CODE-X' && row.c_oper_name === '合成法人', row);
  110. check('六角度各自落位(含空数组 → "[]")', row[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]' && row[ANGLE_TO_FIELD['产业信息']] === '[]' && row[ANGLE_TO_FIELD['许可认证']] === '[]', row);
  111. check('行业信息用 c_tag_industry(不占用既有的 c_industry)', row[ANGLE_TO_FIELD['行业信息']] === '["涉农(广义)"]' && row.c_industry === undefined);
  112. const failedRow = buildEnterpriseRow(classification, { failed: new Set(['产业信息']), conservative: false });
  113. check('失败角度不写该字段', failedRow[ANGLE_TO_FIELD['产业信息']] === undefined && failedRow[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]');
  114. const conservativeRow = buildEnterpriseRow(classification, { failed: new Set(), conservative: true });
  115. check('保守模式只写非空角度', conservativeRow[ANGLE_TO_FIELD['行业信息']] === '["涉农(广义)"]' && conservativeRow[ANGLE_TO_FIELD['产业信息']] === undefined);
  116. const noIdentity = buildEnterpriseRow({ categories: {} }, { failed: new Set(), conservative: false });
  117. check('身份缺失时不写身份字段(而不是写 null)', !('c_name' in noIdentity) && !('c_credit_code' in noIdentity) && !('c_oper_name' in noIdentity), noIdentity);
  118. const hintRow = buildEnterpriseRow({ categories: {} }, { failed: new Set(), conservative: false, identityHint: { name: '回退名', creditCode: 'FALLBACK', legalRep: null } });
  119. check('响应缺身份时回退 identityHint', hintRow.c_name === '回退名' && hintRow.c_credit_code === 'FALLBACK', hintRow);
  120. const synced = resolveSyncedIdentity({ company_name: null, credit_code: 'C1' }, { name: 'N1', creditCode: 'C0', legalRep: 'L1' });
  121. check('resolveSyncedIdentity 逐项回退', synced.name === 'N1' && synced.creditCode === 'C1' && synced.legalRep === 'L1', synced);
  122. // diff
  123. const existing = { id: 'uuid-1', c_name: '合成公司', c_credit_code: 'CODE-X', [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' };
  124. const same = diffEnterpriseRow(existing, { c_name: '合成公司', c_credit_code: 'CODE-X', [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' });
  125. check('全同 → null(零写入)', same === null, same);
  126. const reordered = diffEnterpriseRow(existing, { [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' });
  127. check('集合比较:内容相同不算变化', reordered === null, reordered);
  128. const changed = diffEnterpriseRow(existing, { c_name: '改名了', [ANGLE_TO_FIELD['基本信息']]: '["小微企业","专精特新"]' });
  129. check('变化字段进 patch,未变字段不进', changed.c_name === '改名了' && changed[ANGLE_TO_FIELD['基本信息']] === '["小微企业","专精特新"]' && !('c_credit_code' in changed), changed);
  130. const desiredEmptyTags = diffEnterpriseRow({ [ANGLE_TO_FIELD['基本信息']]: '["旧标签"]' }, { [ANGLE_TO_FIELD['基本信息']]: '[]' });
  131. check('空集是合法目标值(会覆盖旧标签)', desiredEmptyTags !== null && desiredEmptyTags[ANGLE_TO_FIELD['基本信息']] === '[]', desiredEmptyTags);
  132. const noOverwrite = diffEnterpriseRow({ c_name: '存量名' }, {});
  133. check('期望里没有的字段不参与比较、不清存量', noOverwrite === null, noOverwrite);
  134. const fillGap = diffEnterpriseRow({ c_name: '存量名' }, { c_oper_name: '新法人' });
  135. check('存量缺失 + 期望有 → 补齐', fillGap !== null && fillGap.c_oper_name === '新法人', fillGap);
  136. }
  137. /* ---------------------------------------------------------------- *
  138. * ④ 1886 荣誉对齐(数据源 honors.records)
  139. * ---------------------------------------------------------------- */
  140. console.log('\n【4】1886 荣誉对齐');
  141. {
  142. // —— 解析 ——
  143. const honors = {
  144. complete: true,
  145. records: [
  146. { data: { Name: '高新技术企业', Level: '国家级', Source: '某认定公告', PublishOffice: '科技部', PublishDate: '2025-01-02', CertificateCode: 'CERT-1' } },
  147. { data: { Name: '独角兽企业', Level: '省级', Source: '2026 浙江独角兽', PublishOffice: '', PublishDate: '2026-04-23' } },
  148. { data: { Level: '国家级' } }, // 缺 Name → 丢弃
  149. { notData: true }, // 缺 data → 丢弃
  150. ],
  151. };
  152. const parsed = parseHonorRecords(honors);
  153. check('解析出 2 条(缺 Name / 缺 data 的各丢一条)', parsed.records.length === 2 && parsed.malformed === 2, parsed);
  154. check('complete 透传', parsed.complete === true);
  155. check('字段映射正确', parsed.records[0].name === '高新技术企业' && parsed.records[0].level === '国家级' && parsed.records[0].certificateCode === 'CERT-1' && parsed.records[1].publishOffice === null, parsed.records);
  156. check('honors 缺失 → available=false(调用方整块跳过)', parseHonorRecords(null).available === false && parseHonorRecords(undefined).available === false);
  157. check('honors={} → available=true 但 0 条、complete=false', (() => { const p = parseHonorRecords({}); return p.available === true && p.records.length === 0 && p.complete === false; })());
  158. check('complete:false 透传为 false(不许删)', parseHonorRecords({ complete: false, records: [] }).complete === false);
  159. // —— 键 ——
  160. check('buildHonorKey 三字段拼接', buildHonorKey('A', '国家级', 'X') === 'A|国家级|X');
  161. check('buildHonorKey 缺字段留空', buildHonorKey('A', null, undefined) === 'A||');
  162. const records = parsed.records; // 高新技术企业 | 独角兽企业
  163. // —— 全量对齐 ——
  164. /** 灌入行:荣誉名只在系统字段 `title` 里(DMS 侧 09-17 灌的那批就是这样,没有 c_tag_name) */
  165. const seededRow = (id, title, level, source, extra = {}) => ({ id, title, c_level: level, c_source: source, ...extra });
  166. /** 已对齐过的行:title 与 c_tag_name 都有 */
  167. const alignedRow = (id, title, level, source, extra = {}) =>
  168. seededRow(id, title, level, source, { [HONOR_NAME_FIELD]: title, ...extra });
  169. const rows = [
  170. alignedRow('u1', '高新技术企业', '国家级', '某认定公告', { c_publish_office: '科技部', c_publish_date: '2025-01-02', c_certificate_code: 'CERT-1' }),
  171. alignedRow('u2', '独角兽企业', '省级', '2026 浙江独角兽', { c_publish_date: '2026-04-23' }),
  172. seededRow('u3', '过期的荣誉', '市级', '老名单 2019'),
  173. ];
  174. const aligned = diffHonorRows(rows, records, { allowDelete: true });
  175. check('已对齐的行不产生增/改(幂等)', aligned.toAdd.length === 0 && aligned.toUpdate.length === 0, { add: aligned.toAdd.length, upd: aligned.toUpdate.length, patch: aligned.toUpdate[0]?.patch });
  176. check('库里多出来的 → toDelete(带 id 与键)', aligned.toDelete.length === 1 && aligned.toDelete[0].id === 'u3', aligned.toDelete);
  177. check('无 malformed', aligned.malformed === 0, aligned.malformed);
  178. const noDelete = diffHonorRows(rows, records, { allowDelete: false });
  179. check('**complete=false 时绝不删**', noDelete.toDelete.length === 0 && noDelete.toAdd.length === 0, noDelete);
  180. // 灌入行(只有 title、没有 c_tag_name)→ 首次对齐只补那一列
  181. const seedOnly = diffHonorRows(
  182. [seededRow('u1', '高新技术企业', '国家级', '某认定公告', { c_publish_office: '科技部', c_publish_date: '2025-01-02', c_certificate_code: 'CERT-1' })],
  183. [records[0]],
  184. { allowDelete: true }
  185. );
  186. check('灌入行首次对齐:只补 c_honor,不动其它列', seedOnly.toUpdate.length === 1 && JSON.stringify(Object.keys(seedOnly.toUpdate[0].patch)) === JSON.stringify([HONOR_NAME_FIELD]), seedOnly.toUpdate[0]?.patch);
  187. // 差异 → update(只 patch 有差异的列)
  188. const staleRow = [seededRow('u1', '高新技术企业', '国家级', '某认定公告', { c_publish_office: '旧机构' })];
  189. const upd = diffHonorRows(staleRow, [records[0]], { allowDelete: true });
  190. check('同键字段有差异 → toUpdate,只含差异列', upd.toUpdate.length === 1 && upd.toUpdate[0].patch.c_publish_office === '科技部' && upd.toUpdate[0].patch.title === undefined, upd.toUpdate);
  191. check('title 与荣誉名一致时不重复 patch', upd.toUpdate[0].patch.title === undefined);
  192. check('已匹配的行若缺 c_honor 也会补上', upd.toUpdate[0].patch[HONOR_NAME_FIELD] === '高新技术企业', upd.toUpdate[0].patch);
  193. const titleNull = [seededRow('u9', 'null', '国家级', '某认定公告', { c_tag_name: '高新技术企业' })];
  194. const fixTitle = diffHonorRows(titleNull, [records[0]], { allowDelete: true });
  195. check('title 是字符串 "null" → 用 c_tag_name 认名字,并把 title 修正', fixTitle.toUpdate.length === 1 && fixTitle.toUpdate[0].patch.title === '高新技术企业', fixTitle);
  196. check('字符串 "null" 不被当成荣誉名(键能对上)', fixTitle.toAdd.length === 0);
  197. check('早期同步的行(只有 c_tag_name)也会补上 c_honor', fixTitle.toUpdate[0].patch[HONOR_NAME_FIELD] === '高新技术企业', fixTitle.toUpdate[0].patch);
  198. // 新增
  199. const empty = diffHonorRows([], records, { allowDelete: true });
  200. check('库里没有 → 全部 toAdd', empty.toAdd.length === 2 && empty.toDelete.length === 0, empty);
  201. // 载荷内部重复
  202. const dup = parseHonorRecords({ complete: true, records: [{ data: { Name: 'A', Level: '国家级', Source: 'X' } }, { data: { Name: 'A', Level: '国家级', Source: 'X' } }] });
  203. const dupDiff = diffHonorRows([], dup.records, { allowDelete: true });
  204. check('载荷内重复的荣誉只算一次', dupDiff.toAdd.length === 1, dupDiff.toAdd);
  205. // 缺 id 的行
  206. const noId = diffHonorRows([{ title: '高新技术企业', c_level: '国家级', c_source: '某认定公告' }], records, { allowDelete: true });
  207. check('缺 uuid 的行 → malformed,不匹配也不删', noId.malformed === 1 && noId.toAdd.length === 2 && noId.toDelete.length === 0, noId);
  208. // 内容
  209. const content = buildHonorRowContent('CODE-X', '合成公司', records[0], '2026-09-18 18:00:00');
  210. check('荣誉行内容:title 与 c_honor 都写荣誉名', content.title === '高新技术企业' && content[HONOR_NAME_FIELD] === '高新技术企业', content);
  211. 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);
  212. check('荣誉行内容:必填与归属', content.c_credit_code === 'CODE-X' && content.c_name === '合成公司' && content.c_id === 0, content);
  213. check('空字段不写进行(不落空值)', !('c_dead_line' in content) && !('c_beging_date' in content), content);
  214. }
  215. /* ---------------------------------------------------------------- *
  216. * ⑤ partial / errors 语义
  217. * ---------------------------------------------------------------- */
  218. console.log('\n【5】失败角度与保守降级');
  219. {
  220. const partial = { status: 'partial', errors: { 产业信息: 'model_call_failed' }, categories: { 基本信息: ['a'] } };
  221. const r1 = extractFailedAngles(partial);
  222. check('errors 键 = 角度名 → 识别为失败角度', r1.failed.has('产业信息') && r1.unknown === false, [...r1.failed]);
  223. const weird = { status: 'partial', errors: { something_else: 'x' }, categories: {} };
  224. check('errors 键对不上角度 → unknown 置位', extractFailedAngles(weird).unknown === true);
  225. check('partial 但无 errors → unknown(不知道哪些可信)', extractFailedAngles({ status: 'partial', categories: {} }).unknown === true);
  226. check('completed + 空 errors → 不降级', extractFailedAngles({ status: 'completed', errors: {} }).unknown === false);
  227. }
  228. /* ---------------------------------------------------------------- *
  229. * ⑥ 分类接口地址
  230. * ---------------------------------------------------------------- */
  231. console.log('\n【6】分类接口地址拼接');
  232. {
  233. check('服务前缀写法', resolveClassifyEndpoint('http://192.168.2.23:8000') === 'http://192.168.2.23:8000/api/company/classify');
  234. check('dev 代理前缀写法', resolveClassifyEndpoint('/chat-api') === '/chat-api/api/company/classify');
  235. check('完整聊天接口写法(先剥 /api/chat)', resolveClassifyEndpoint('http://x:8000/api/chat') === 'http://x:8000/api/company/classify');
  236. check('末尾斜杠容错', resolveClassifyEndpoint('http://x:8000///') === 'http://x:8000/api/company/classify');
  237. check('空 → 空串(调用方会跳过)', resolveClassifyEndpoint('') === '' && resolveClassifyEndpoint(undefined) === '');
  238. }
  239. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  240. process.exit(fail ? 1 : 0);