gen-company-info-mapping-doc.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. /**
  2. * 生成「`company_info` ↔ DMS 企业两栏目」的字段对应文档。
  3. *
  4. * 为什么要脚本生成:字段对应关系有两处会变 —— 代码里的映射表(`PROFILE_FIELD_MAP` 等)
  5. * 与 DMS 侧的模型(别名、类型、新增列)。手抄必然会漂移,所以这份文档
  6. * **从代码 + 实时模型定义生成**,改了字段就重跑一次。
  7. *
  8. * 怎么跑(需要 dev server 起着,代理注入 DMS token):
  9. * node harness/tools/gen-company-info-mapping-doc.mjs
  10. * 产物:harness/docs/reference/company-info-dms-mapping.md
  11. */
  12. process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
  13. import { writeFileSync } from 'node:fs';
  14. const dmsBase = process.env.DMS_BASE || 'https://localhost:8083/dms-api';
  15. globalThis.VITE_DMS_API = dmsBase;
  16. // ⚠️ 取的是 modelId(2036/2032),不是 columnId —— 传错会静默拿到别的模型
  17. const {
  18. PROFILE_FIELD_MAP,
  19. ANGLE_TO_FIELD,
  20. HONOR_FIELD_MAP,
  21. HONOR_NAME_FIELD,
  22. HONOR_TAG_FIELD,
  23. DMS_MODEL_ENTERPRISE,
  24. DMS_MODEL_HONOR,
  25. } = await import('./_company-classify.mjs');
  26. const fetchModel = async (modelId, expectAlias) => {
  27. const res = await fetch(`${dmsBase}/model/getModelById`, {
  28. method: 'POST',
  29. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  30. body: new URLSearchParams({ modelId: String(modelId) }).toString(),
  31. });
  32. const json = await res.json();
  33. if (json.code !== 200) throw new Error(`取模型 ${modelId} 失败:${json.code} ${json.message}`);
  34. if (json.content.modelAlias !== expectAlias) {
  35. throw new Error(`模型 ${modelId} 别名是「${json.content.modelAlias}」,期望「${expectAlias}」`);
  36. }
  37. const list = JSON.parse(json.content.fieldList);
  38. return Object.values(list)
  39. .filter((f) => f.name)
  40. .sort((a, b) => a.sequence - b.sequence);
  41. };
  42. const entFields = await fetchModel(DMS_MODEL_ENTERPRISE, '企业基础信息');
  43. const honorFields = await fetchModel(DMS_MODEL_HONOR, '企业荣誉信息');
  44. const entBy = new Map(entFields.map((f) => [f.name, f]));
  45. const honorBy = new Map(honorFields.map((f) => [f.name, f]));
  46. const desc = (map, col) => {
  47. const f = map.get(col);
  48. return f ? `「${f.alias}」\`${f.frontType}\`` : '(不在模型里)';
  49. };
  50. /** 按 DMS 模型里的列顺序输出,保证与 DMS 界面一致 */
  51. const orderOf = (map, col) => {
  52. const f = map.get(col);
  53. return f ? f.sequence : 999;
  54. };
  55. const rows1888 = Object.entries(PROFILE_FIELD_MAP)
  56. .map(([src, col]) => ({ src, col }))
  57. .sort((a, b) => orderOf(entBy, a.col) - orderOf(entBy, b.col));
  58. /**
  59. * `HONOR_FIELD_MAP` 的键是内部归一化名(`level`),而载荷里是企查查原始键(`Level`)——
  60. * 这份文档是给人对着载荷看的,所以显示**原始键**。
  61. */
  62. const RAW_HONOR_KEYS = {
  63. name: 'Name',
  64. level: 'Level',
  65. source: 'Source',
  66. publishOffice: 'PublishOffice',
  67. publishDate: 'PublishDate',
  68. begingDate: 'BegingDate',
  69. deadLine: 'DeadLine',
  70. certificateCode: 'CertificateCode',
  71. };
  72. const rows1886 = Object.entries(HONOR_FIELD_MAP)
  73. .map(([src, col]) => ({ src: `honors.records[].data.${RAW_HONOR_KEYS[src] || src}`, col }))
  74. .sort((a, b) => orderOf(honorBy, a.col) - orderOf(honorBy, b.col));
  75. const table = (head, rows) =>
  76. [head, ...rows].join('\n');
  77. const entWritten = new Set([...Object.values(PROFILE_FIELD_MAP), ...Object.values(ANGLE_TO_FIELD), 'c_created_at']);
  78. const entUnwritten = entFields.filter((f) => !entWritten.has(f.name));
  79. const honorWritten = new Set([
  80. ...Object.values(HONOR_FIELD_MAP),
  81. HONOR_NAME_FIELD,
  82. 'c_credit_code',
  83. 'c_name',
  84. 'c_id',
  85. 'c_created_at',
  86. ]);
  87. const honorUnwritten = honorFields.filter((f) => !honorWritten.has(f.name));
  88. const md = `# company_info ↔ DMS 企业两栏目 · 字段对应关系
  89. > **这份文档回答一个问题**:对话里拿到的 \`company_info\`(企查查原始结构)的每个字段,
  90. > 最终落到 DMS 的哪一列;哪些没落、为什么。
  91. >
  92. > - **面向**:接手的开发、DMS 侧维护者
  93. > - **由脚本生成**:\`node harness/tools/gen-company-info-mapping-doc.mjs\`
  94. > (读代码里的映射表 + 实时拉 DMS 模型定义)——**改了字段请重跑,不要手改本文件**
  95. > - **覆盖**:对话 \`result\` 事件 → \`company_info\` → DMS **1888 企业基础信息** / **1886 企业荣誉信息**
  96. > - 相关:实现见 \`src/network/api/dms/classification-sync-utils.ts\` 与 \`company-info-sync.ts\`;
  97. > 字段覆盖审计见 \`harness/tools/audit-company-info-coverage.mjs\`
  98. ## 1. 总览
  99. | company_info 里的段 | 落到哪 | 粒度 | 匹配/幂等键 |
  100. |---|---|---|---|
  101. | \`profile.data\`(38 个字段) | **1888 企业基础信息** | 一行一家企业 | \`c_credit_code\` |
  102. | 分类接口六角度(不是 company_info,由它调出) | **1888** 的 \`c_tag_*\` | 同上 | 同上 |
  103. | \`honors.records[].data\`(8 个字段) | **1886 企业荣誉信息** | **一条荣誉一行** | 荣誉名 + 级别 + 来源 |
  104. | \`company\` / \`profile.source\` / \`honors.sources\` 等 | **不落库** | — | 见 §5 |
  105. 触发:对话 \`result\` 事件的 \`company_info\` 非空 → 回答完成后后台同步(fire-and-forget,失败只 warn)。
  106. 三类数据(工商信息 / 荣誉 / 分类标签)**互不依赖**:分类接口挂了,前两者照常同步。
  107. ## 2. 1888 企业基础信息(columnId 1888 / modelId ${DMS_MODEL_ENTERPRISE})
  108. ### 2.1 身份与工商信息:\`profile.data\` → 列(${rows1888.length} 项)
  109. | company_info 字段 | DMS 列 | 列的中文别名与控件 |
  110. |---|---|---|
  111. ${rows1888.map((r) => `| \`profile.data.${r.src}\` | \`${r.col}\` | ${desc(entBy, r.col)} |`).join('\n')}
  112. > \`profile.data\` 共 38 个字段,上表覆盖 ${rows1888.length} 个;其余 2 个(\`KeyNo\`、\`TeamEnd\`)
  113. > DMS 侧**没有对应列**,见 §5。
  114. > \`company\` 段里的 \`Name\` / \`CreditCode\` / \`OperName\` 等与 \`profile.data\` 同名字段重复,
  115. > **只作为回退**(profile 缺失时才用),不单独落列。
  116. ### 2.2 分类标签:分类接口六角度 → \`c_tag_*\`
  117. | 分类角度 | DMS 列 | 列的中文别名与控件 | 取值形态 |
  118. |---|---|---|---|
  119. ${Object.entries(ANGLE_TO_FIELD)
  120. .map(([angle, col]) => `| ${angle} | \`${col}\` | ${desc(entBy, col)} | JSON 数组字符串,如 \`["第一产业","现代农业"]\`;空集**不落库** |`)
  121. .join('\n')}
  122. > 数据**不是**来自 \`company_info\`,而是把 \`company_info\` 整个对象发给分类接口
  123. > \`POST /api/company/classify\` 后的返回(契约见 [\`company-classification.md\`](./company-classification.md))。
  124. ### 2.3 系统字段 \`title\`
  125. | 来源 | DMS 字段 | 说明 |
  126. |---|---|---|
  127. | \`profile.data.Name\`(或回退 \`company.Name\`) | \`title\`(**不在模型 fieldList 里**,是 DMS 的列表显示列) | 取企业名称。不写的话 DMS 会落成**字符串 \`"null"\`**——\`title\` 只在创建那一刻初始化,之后改 \`c_*\` 不带动它 |
  128. ### 2.4 其余列(前端同步不写,${entUnwritten.length} 列)
  129. | DMS 列 | 别名与控件 | 为什么不写 |
  130. |---|---|---|
  131. ${entUnwritten
  132. .map((f) => `| \`${f.name}\` | 「${f.alias}」\`${f.frontType}\` | 见 §5:\`company_info\` 里没有对应数据源 |`)
  133. .join('\n')}
  134. ## 3. 1886 企业荣誉信息(columnId 1886 / modelId ${DMS_MODEL_HONOR})
  135. ### 3.1 \`honors.records[].data\` → 列(${rows1886.length} 项,**一条荣誉一行**)
  136. | company_info 字段 | DMS 列 | 列的中文别名与控件 |
  137. |---|---|---|
  138. ${rows1886.map((r) => `| \`${r.src}\` | \`${r.col}\` | ${desc(honorBy, r.col)} |`).join('\n')}
  139. ### 3.2 另外写入的列
  140. | 值 | DMS 列 | 说明 |
  141. |---|---|---|
  142. | \`records[].data.Name\` | \`${HONOR_NAME_FIELD}\` 与系统字段 \`title\` | 荣誉名**两处都写**:自有列 + 列表显示列 |
  143. | 归属企业 | \`c_credit_code\` / \`c_name\` | 来自身份(\`company_info.profile.data\`) |
  144. | — | \`c_id\` | 必填字段,前端传 \`0\`(DMS 自动填) |
  145. | — | \`c_created_at\` | 新增时写 \`YYYY-MM-DD HH:mm:ss\`(该列在 1888 里写了读不回,1886 正常) |
  146. ### 3.3 不写的列(${honorUnwritten.length} 列)
  147. | DMS 列 | 别名与控件 | 说明 |
  148. |---|---|---|
  149. ${honorUnwritten.map((f) => `| \`${f.name}\` | 「${f.alias}」\`${f.frontType}\` | 用户 2026-09-18 确认:不承担语义、也不清理(早期版本曾把荣誉名写在这里) |`).join('\n')}
  150. ## 4. 荣誉的「全量对齐」语义
  151. 1886 不是简单地「有则更新」,而是**与后端这份荣誉列表对齐**:
  152. | 情况 | 动作 |
  153. |---|---|
  154. | 后端有、库里没有 | 新增一行 |
  155. | 两边都有(荣誉名+级别+来源 相同),但某列值不同 | 只更新有差异的列 |
  156. | 库里多出来的 | **删除**(\`updateAudit state=4\`)——**仅当 \`honors.complete === true\`**;不完整时只增改不删 |
  157. | 完全一致 | 零写入 |
  158. ## 5. 不落库的字段
  159. | 字段 | 为什么不落 |
  160. |---|---|
  161. | \`profile.source.*\`(provider / api_code / document_url / fetched_at / page_index / total_records / company_key) | 溯源元信息,1888 没有对应列 |
  162. | \`profile.data.KeyNo\`、\`company.KeyNo\` | 企查查主体键,DMS 侧没有对应列 |
  163. | \`profile.data.TeamEnd\` | 企查查的拼写错误字段(正确拼法是 \`TermEnd\`),**值恒为空串** |
  164. | \`honors.complete\` / \`honors.error\` / \`honors.sources[].*\` | 只用于判断「能不能删」与溯源,不落库 |
  165. | 1888 的 \`c_scope_brief\` / \`c_phone_number\` / \`c_email\` / \`c_industry\` | **DMS 有列,但 \`company_info\` 里没有这些字段**(企查查 410 接口未返回)——要存需后端先补数据源 |
  166. ## 6. 三条影响「对应关系」的写入规则
  167. 1. **空值一律不写**(\`null\` / 空串 / 空数组 / 空对象)——**不清存量**:
  168. 库里已有的值不会被「本次没带」的字段抹掉
  169. 2. **JSON 列按语义比较**(\`c_area\` / \`c_original_name\` / \`c_revoke_info\` /
  170. \`c_designated_representative_list\`):DMS 里是 Python/jsonb 序列化的(带空格、键按字母序),
  171. 与 JS 的 \`JSON.stringify\` 字节不同——语义相同就不写,避免每轮无谓改写
  172. 3. **无差异零写入**:字段级 diff,patch 为空则完全不发请求(幂等,重复同步不改数据)
  173. ## 7. 当前覆盖率
  174. | 栏目 | 在写 | 未写 | 未写的原因 |
  175. |---|---|---|---|
  176. | 1888(${entFields.length} 列) | **${entWritten.size} 列** | ${entUnwritten.length} 列 | 载荷里没有数据源(§5) |
  177. | 1886(${honorFields.length} 列) | **${honorWritten.size} 列** | ${honorUnwritten.length} 列 | 用户确认不用(§3.3) |
  178. > 复核命令:\`node harness/tools/audit-company-info-coverage.mjs <company-info.json>\`
  179. > (实时拉模型 + 从代码派生写入列,输出同样可比对的清单)
  180. `;
  181. const target = 'harness/docs/reference/company-info-dms-mapping.md';
  182. writeFileSync(target, md, 'utf8');
  183. console.log(`已生成 ${target}`);
  184. console.log(`1888:写 ${entWritten.size}/${entFields.length} 列;1886:写 ${honorWritten.size}/${honorFields.length} 列`);