verify-company-classify-dms.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. /**
  2. * 打**真实 DMS**验证企业信息同步的落库行为(栏目 1888 企业基础信息 / 1886 企业荣誉信息)。
  3. *
  4. * 用**注入的假数据**(假的分类结果 + 假的荣誉列表)跑完整落库链路,因此:
  5. * - 不消耗分类接口的 LLM 调用,可以反复跑
  6. * - 覆盖边界(改标签、删荣誉、complete=false 不许删、缺信用代码跳过…)
  7. *
  8. * 验证什么:
  9. * ① 1888 首写:一行,六个标签字段 = 对应角度的 JSON 数组
  10. * ② 1886 首写:**一条荣誉一行**,title 与 c_honor 都是荣誉名,级别/来源/机构/日期/证书号各就各位
  11. * ③ 幂等:同样数据再跑一次 → 零新增、行 uuid 不变
  12. * ④ 改:1888 标签变化 → 仍是同一行且已更新;1886 少一条/多一条/改字段 →
  13. * 删多余、补新增、更新差异,行数与后端列表一致
  14. * ⑤ `honors.complete = false` → **只增改不删除**(记录集不完整时不许删)
  15. * ⑥ 缺信用代码 → 整条跳过,不写任何行
  16. * ⑦ 清理:按测试信用代码删掉本次产生的全部行,复查残留 0
  17. *
  18. * 怎么跑(先按 _entry-company-classify.ts 头部注释重新打包):
  19. * 1) npm run dev # 代理负责注入 DMS token
  20. * 2) node harness/tools/verify-company-classify-dms.mjs https://localhost:8083/dms-api
  21. *
  22. * ⚠️ 脚本**不持有 token**(和浏览器一样只打代理地址);测试数据用可识别的信用代码前缀,
  23. * 结束时自行清理。
  24. */
  25. process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
  26. const base = process.argv[2] || 'https://localhost:8083/dms-api';
  27. globalThis.VITE_DMS_API = base;
  28. const {
  29. applyCompanyClassification,
  30. applyCompanyHonors,
  31. applyCompanyProfile,
  32. HONOR_NAME_FIELD,
  33. ANGLE_TO_FIELD,
  34. DMS_COLUMN_ENTERPRISE,
  35. DMS_COLUMN_HONOR,
  36. DMS_MODEL_HONOR,
  37. DMS_MODEL_ENTERPRISE,
  38. addDmsContent,
  39. deleteDmsContent,
  40. searchDmsContents,
  41. updateDmsContent,
  42. } = await import('./_company-classify.mjs');
  43. let pass = 0;
  44. let fail = 0;
  45. const check = (name, ok, extra = '') => {
  46. if (ok) {
  47. pass++;
  48. console.log(` ok ${name}`);
  49. } else {
  50. fail++;
  51. console.log(` FAIL ${name}${extra === '' ? '' : ` → ${JSON.stringify(extra)}`}`);
  52. }
  53. };
  54. const TEST_CREDIT_CODE = `TEST_CC_${Date.now()}`;
  55. const TEST_NAME = '分类同步测试公司';
  56. const IDENTITY = { name: TEST_NAME, creditCode: TEST_CREDIT_CODE, legalRep: '测试法人' };
  57. /** 造一条企查查形态的荣誉记录(`records[].data`) */
  58. const rec = (Name, Level, Source, extra = {}) => ({
  59. data: { Name, Level, Source, ...extra },
  60. });
  61. const honorsNode = (list, complete = true) => ({ complete, records: list, sources: [], error: null });
  62. const findEnterprise = () =>
  63. searchDmsContents(DMS_COLUMN_ENTERPRISE, {
  64. search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT_CODE } }],
  65. page: 0,
  66. pageSize: 10,
  67. });
  68. const findHonors = () =>
  69. searchDmsContents(DMS_COLUMN_HONOR, {
  70. search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT_CODE } }],
  71. page: 0,
  72. pageSize: 200,
  73. });
  74. const honorNames = (rows) =>
  75. rows
  76. .map((r) => (typeof r[HONOR_NAME_FIELD] === 'string' && r[HONOR_NAME_FIELD] ? r[HONOR_NAME_FIELD] : r.title))
  77. .sort();
  78. const classifyWith = (categories) => ({
  79. company_name: TEST_NAME,
  80. credit_code: TEST_CREDIT_CODE,
  81. legal_representative: '测试法人',
  82. categories,
  83. status: 'completed',
  84. errors: {},
  85. });
  86. console.log(`测试信用代码:${TEST_CREDIT_CODE}\n`);
  87. try {
  88. /* ---------------------------------------------------------------- *
  89. * ① 首写
  90. * ---------------------------------------------------------------- */
  91. console.log('【1】首写:1888 一行 + 1886 一条荣誉一行');
  92. await applyCompanyClassification(
  93. classifyWith({
  94. 基本信息: ['小微企业'],
  95. 资质荣誉: ['高新技术企业'],
  96. 产业信息: ['第一产业', '现代农业'],
  97. 经营活动: ['对外投资'],
  98. 行业信息: ['涉农(广义)'],
  99. 许可认证: ['食品生产许可'],
  100. }),
  101. IDENTITY
  102. );
  103. await applyCompanyHonors(
  104. honorsNode([
  105. rec('高新技术企业', '国家级', '某认定公告', {
  106. PublishOffice: '科技部',
  107. PublishDate: '2025-01-02',
  108. CertificateCode: 'CERT-1',
  109. }),
  110. rec('独角兽企业', '省级', '2026 浙江独角兽', { PublishDate: '2026-04-23' }),
  111. ]),
  112. IDENTITY
  113. );
  114. const rows1 = await findEnterprise();
  115. check('1888 恰好一行', rows1.length === 1, rows1.length);
  116. const row1 = rows1[0] || {};
  117. check('1888 身份字段正确', row1.c_name === TEST_NAME && row1.c_oper_name === '测试法人', row1);
  118. check('1888 标签字段正确(非空角度)', row1[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]' && row1[ANGLE_TO_FIELD['产业信息']] === '["第一产业","现代农业"]', row1);
  119. const honors1 = await findHonors();
  120. check('1886 行数 = 荣誉记录数(2)', honors1.length === 2, honors1.length);
  121. const h1 = honors1.find((r) => r[HONOR_NAME_FIELD] === '高新技术企业') || {};
  122. check('1886 title 与 c_honor 都写荣誉名', h1.title === '高新技术企业' && h1[HONOR_NAME_FIELD] === '高新技术企业', { title: h1.title, tag: h1[HONOR_NAME_FIELD] });
  123. 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);
  124. check('1886 归属正确', honors1.every((r) => r.c_credit_code === TEST_CREDIT_CODE && r.c_name === TEST_NAME));
  125. /* ---------------------------------------------------------------- *
  126. * ② 幂等
  127. * ---------------------------------------------------------------- */
  128. console.log('\n【2】同数据重跑 → 零新增');
  129. await applyCompanyClassification(
  130. classifyWith({ 基本信息: ['小微企业'], 资质荣誉: ['高新技术企业'], 产业信息: ['第一产业', '现代农业'], 经营活动: ['对外投资'], 行业信息: ['涉农(广义)'], 许可认证: ['食品生产许可'] }),
  131. IDENTITY
  132. );
  133. await applyCompanyHonors(
  134. honorsNode([
  135. rec('高新技术企业', '国家级', '某认定公告', { PublishOffice: '科技部', PublishDate: '2025-01-02', CertificateCode: 'CERT-1' }),
  136. rec('独角兽企业', '省级', '2026 浙江独角兽', { PublishDate: '2026-04-23' }),
  137. ]),
  138. IDENTITY
  139. );
  140. const rows2 = await findEnterprise();
  141. check('1888 仍是一行、uuid 未变', rows2.length === 1 && rows2[0]?.id === row1.id, rows2.length);
  142. check('1886 仍 2 行', (await findHonors()).length === 2);
  143. /* ---------------------------------------------------------------- *
  144. * ③ 改:1888 标签 + 1886 增删改
  145. * ---------------------------------------------------------------- */
  146. console.log('\n【3】改:1888 更新、1886 增删改对齐');
  147. await applyCompanyClassification(
  148. classifyWith({ 基本信息: ['小微企业', '专精特新'], 资质荣誉: ['高新技术企业'], 产业信息: ['第一产业', '现代农业'], 经营活动: ['对外投资'], 行业信息: ['涉农(广义)'], 许可认证: ['食品生产许可'] }),
  149. IDENTITY
  150. );
  151. await applyCompanyHonors(
  152. honorsNode([
  153. // 同键但发布机构变了 → 更新
  154. rec('高新技术企业', '国家级', '某认定公告', { PublishOffice: '工信部', PublishDate: '2025-01-02', CertificateCode: 'CERT-1' }),
  155. // 新增一条
  156. rec('科技小巨人', '市级', '2026 市科委名单'),
  157. // 「独角兽企业」本条不出现 → 应被删除
  158. ]),
  159. IDENTITY
  160. );
  161. const rows3 = await findEnterprise();
  162. check('1888 仍是一行', rows3.length === 1, rows3.length);
  163. check('1888 基本信息已更新', rows3[0]?.[ANGLE_TO_FIELD['基本信息']] === '["小微企业","专精特新"]', rows3[0]?.[ANGLE_TO_FIELD['基本信息']]);
  164. const honors3 = await findHonors();
  165. check('1886 行数 = 新列表(2)', honors3.length === 2, honors3.length);
  166. check('1886 名字对齐(新增进、多余的删掉)', JSON.stringify(honorNames(honors3)) === JSON.stringify(['科技小巨人', '高新技术企业']), honorNames(honors3));
  167. const h3 = honors3.find((r) => r[HONOR_NAME_FIELD] === '高新技术企业') || {};
  168. check('1886 同键行的字段差异已更新', h3.c_publish_office === '工信部', h3.c_publish_office);
  169. /* ---------------------------------------------------------------- *
  170. * ④ complete=false:只增改、不删除
  171. * ---------------------------------------------------------------- */
  172. console.log('\n【4】honors.complete=false → 只增改不删除');
  173. await applyCompanyHonors(honorsNode([rec('新荣誉', '区级', '某区名单')], false), IDENTITY);
  174. const honors4 = await findHonors();
  175. check('新增的进来了', honorNames(honors4).includes('新荣誉'), honorNames(honors4));
  176. check('**原有的没被删**(3 行:高新技术、科技小巨人、新荣誉)', honors4.length === 3, honorNames(honors4));
  177. /* ---------------------------------------------------------------- *
  178. * ⑤ 迁移行也会被对齐(不再是「只碰自己写的行」)
  179. * ---------------------------------------------------------------- */
  180. console.log('\n【5】全量对齐:非本同步写入的同企业荣誉行同样参与');
  181. await addDmsContent(DMS_COLUMN_HONOR, DMS_MODEL_HONOR, {
  182. c_id: 0,
  183. c_credit_code: TEST_CREDIT_CODE,
  184. c_name: TEST_NAME,
  185. c_source: 'migrate_test', // 模拟迁移数据
  186. title: '迁移来的荣誉',
  187. c_level: '国家级',
  188. c_created_at: '2026-09-18 12:00:00',
  189. });
  190. check('预置 1 条迁移行', (await findHonors()).length === 4, (await findHonors()).length);
  191. await applyCompanyHonors(honorsNode([rec('迁移来的荣誉', '国家级', 'migrate_test')]), IDENTITY);
  192. const honors5 = await findHonors();
  193. check('后端列表只有它 → 其余全删,只剩 1 行', honors5.length === 1, honorNames(honors5));
  194. check('留下的正是迁移行那一条(按「荣誉名+级别+来源」匹配到同一行)', honors5[0]?.title === '迁移来的荣誉', honors5[0]?.title);
  195. /* ---------------------------------------------------------------- *
  196. * ⑥ 缺信用代码 → 跳过
  197. * ---------------------------------------------------------------- */
  198. console.log('\n【6】缺信用代码 → 整条跳过');
  199. await applyCompanyHonors(honorsNode([rec('不该出现的荣誉', '国家级', 'X')]), { name: TEST_NAME, creditCode: null, legalRep: null });
  200. check('1886 行数未变', (await findHonors()).length === 1);
  201. await applyCompanyHonors(null, IDENTITY);
  202. check('honors 缺失/null → 跳过(不清库)', (await findHonors()).length === 1);
  203. /* ---------------------------------------------------------------- *
  204. * ⑦ 工商信息(profile.data → 1888)
  205. * ---------------------------------------------------------------- */
  206. console.log('\n【7】工商信息同步(不依赖分类接口)');
  207. const PROFILE = {
  208. Name: TEST_NAME,
  209. CreditCode: TEST_CREDIT_CODE,
  210. OperName: '测试法人',
  211. No: '310118000000001',
  212. BelongOrg: '青浦区市场监督管理局',
  213. Status: '存续(在营、开业、在册)',
  214. Province: 'SH',
  215. AreaCode: '310118',
  216. Address: '上海市青浦区测试路 1 号',
  217. RegistCapi: '1000万元',
  218. RegisteredCapital: '1000',
  219. IsOnStock: '0',
  220. Noop: '映射表里没有的字段',
  221. EndDate: '',
  222. RevokeInfo: null,
  223. DesignatedRepresentativeList: [],
  224. OriginalName: [{ Name: '测试曾用名', ChangeDate: '2024-07-22' }],
  225. Area: { Province: '上海市', City: '上海市', County: '青浦区' },
  226. };
  227. await applyCompanyProfile(PROFILE, IDENTITY);
  228. const p1 = (await findEnterprise())[0] || {};
  229. check('标量字段落位', p1.c_status === '存续(在营、开业、在册)' && p1.c_regist_capi === '1000万元' && p1.c_no === '310118000000001' && p1.c_area_code === '310118', p1);
  230. 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 });
  231. check('空值不写(clearing 风险)', p1.c_end_date === undefined || p1.c_end_date === '', p1.c_end_date);
  232. check('映射表外的字段不写', p1.c_noop === undefined && p1.Noop === undefined);
  233. // ⚠️ 灌入行的 JSON 是 Python/jsonb 序列化的(带空格、键按字母序)——语义相同就不该重写
  234. const PY_STYLE_AREA = '{ "City": "上海市", "County": "青浦区", "Province": "上海市" }';
  235. const rowBefore = (await findEnterprise())[0];
  236. await updateDmsContent(DMS_COLUMN_ENTERPRISE, DMS_MODEL_ENTERPRISE, rowBefore.id, { c_area: PY_STYLE_AREA, c_status: '旧状态' });
  237. await applyCompanyProfile({ ...PROFILE, Area: { Province: '上海市', City: '上海市', County: '青浦区' }, Status: '存续(在营、开业、在册)' }, IDENTITY);
  238. const p2 = (await findEnterprise())[0] || {};
  239. check('**语义相同的 JSON 不被重写**(灌入形态原样保留)', p2.c_area === PY_STYLE_AREA, p2.c_area);
  240. check('真变化的文本列照常更新', p2.c_status === '存续(在营、开业、在册)', p2.c_status);
  241. await applyCompanyProfile(PROFILE, IDENTITY);
  242. check('再同步一次:JSON 列仍是灌入形态(不反复写)', (await findEnterprise())[0]?.c_area === PY_STYLE_AREA);
  243. } finally {
  244. /* ---------------------------------------------------------------- *
  245. * 清理
  246. * ---------------------------------------------------------------- */
  247. console.log('\n【清理】');
  248. const cleanup = async (columnId, label) => {
  249. const rows = columnId === DMS_COLUMN_ENTERPRISE ? await findEnterprise() : await findHonors();
  250. for (const row of rows) {
  251. if (typeof row.id === 'string') await deleteDmsContent(columnId, row.id);
  252. }
  253. console.log(` ${label} 清理 ${rows.length} 行`);
  254. };
  255. await cleanup(DMS_COLUMN_ENTERPRISE, '1888');
  256. await cleanup(DMS_COLUMN_HONOR, '1886');
  257. check('残留 0 行(1888)', (await findEnterprise()).length === 0);
  258. check('残留 0 行(1886)', (await findHonors()).length === 0);
  259. }
  260. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  261. process.exit(fail ? 1 : 0);