| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- /**
- * 生成「`company_info` ↔ DMS 企业两栏目」的字段对应文档。
- *
- * 为什么要脚本生成:字段对应关系有两处会变 —— 代码里的映射表(`PROFILE_FIELD_MAP` 等)
- * 与 DMS 侧的模型(别名、类型、新增列)。手抄必然会漂移,所以这份文档
- * **从代码 + 实时模型定义生成**,改了字段就重跑一次。
- *
- * 怎么跑(需要 dev server 起着,代理注入 DMS token):
- * node harness/tools/gen-company-info-mapping-doc.mjs
- * 产物:harness/docs/reference/company-info-dms-mapping.md
- */
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
- import { writeFileSync } from 'node:fs';
- const dmsBase = process.env.DMS_BASE || 'https://localhost:8083/dms-api';
- globalThis.VITE_DMS_API = dmsBase;
- // ⚠️ 取的是 modelId(2036/2032),不是 columnId —— 传错会静默拿到别的模型
- const {
- PROFILE_FIELD_MAP,
- ANGLE_TO_FIELD,
- HONOR_FIELD_MAP,
- HONOR_NAME_FIELD,
- HONOR_TAG_FIELD,
- DMS_MODEL_ENTERPRISE,
- DMS_MODEL_HONOR,
- } = await import('./_company-classify.mjs');
- const fetchModel = async (modelId, expectAlias) => {
- const res = await fetch(`${dmsBase}/model/getModelById`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body: new URLSearchParams({ modelId: String(modelId) }).toString(),
- });
- const json = await res.json();
- if (json.code !== 200) throw new Error(`取模型 ${modelId} 失败:${json.code} ${json.message}`);
- if (json.content.modelAlias !== expectAlias) {
- throw new Error(`模型 ${modelId} 别名是「${json.content.modelAlias}」,期望「${expectAlias}」`);
- }
- const list = JSON.parse(json.content.fieldList);
- return Object.values(list)
- .filter((f) => f.name)
- .sort((a, b) => a.sequence - b.sequence);
- };
- const entFields = await fetchModel(DMS_MODEL_ENTERPRISE, '企业基础信息');
- const honorFields = await fetchModel(DMS_MODEL_HONOR, '企业荣誉信息');
- const entBy = new Map(entFields.map((f) => [f.name, f]));
- const honorBy = new Map(honorFields.map((f) => [f.name, f]));
- const desc = (map, col) => {
- const f = map.get(col);
- return f ? `「${f.alias}」\`${f.frontType}\`` : '(不在模型里)';
- };
- /** 按 DMS 模型里的列顺序输出,保证与 DMS 界面一致 */
- const orderOf = (map, col) => {
- const f = map.get(col);
- return f ? f.sequence : 999;
- };
- const rows1888 = Object.entries(PROFILE_FIELD_MAP)
- .map(([src, col]) => ({ src, col }))
- .sort((a, b) => orderOf(entBy, a.col) - orderOf(entBy, b.col));
- /**
- * `HONOR_FIELD_MAP` 的键是内部归一化名(`level`),而载荷里是企查查原始键(`Level`)——
- * 这份文档是给人对着载荷看的,所以显示**原始键**。
- */
- const RAW_HONOR_KEYS = {
- name: 'Name',
- level: 'Level',
- source: 'Source',
- publishOffice: 'PublishOffice',
- publishDate: 'PublishDate',
- begingDate: 'BegingDate',
- deadLine: 'DeadLine',
- certificateCode: 'CertificateCode',
- };
- const rows1886 = Object.entries(HONOR_FIELD_MAP)
- .map(([src, col]) => ({ src: `honors.records[].data.${RAW_HONOR_KEYS[src] || src}`, col }))
- .sort((a, b) => orderOf(honorBy, a.col) - orderOf(honorBy, b.col));
- const table = (head, rows) =>
- [head, ...rows].join('\n');
- const entWritten = new Set([...Object.values(PROFILE_FIELD_MAP), ...Object.values(ANGLE_TO_FIELD), 'c_created_at']);
- const entUnwritten = entFields.filter((f) => !entWritten.has(f.name));
- const honorWritten = new Set([
- ...Object.values(HONOR_FIELD_MAP),
- HONOR_NAME_FIELD,
- 'c_credit_code',
- 'c_name',
- 'c_id',
- 'c_created_at',
- ]);
- const honorUnwritten = honorFields.filter((f) => !honorWritten.has(f.name));
- const md = `# company_info ↔ DMS 企业两栏目 · 字段对应关系
- > **这份文档回答一个问题**:对话里拿到的 \`company_info\`(企查查原始结构)的每个字段,
- > 最终落到 DMS 的哪一列;哪些没落、为什么。
- >
- > - **面向**:接手的开发、DMS 侧维护者
- > - **由脚本生成**:\`node harness/tools/gen-company-info-mapping-doc.mjs\`
- > (读代码里的映射表 + 实时拉 DMS 模型定义)——**改了字段请重跑,不要手改本文件**
- > - **覆盖**:对话 \`result\` 事件 → \`company_info\` → DMS **1888 企业基础信息** / **1886 企业荣誉信息**
- > - 相关:实现见 \`src/network/api/dms/classification-sync-utils.ts\` 与 \`company-info-sync.ts\`;
- > 字段覆盖审计见 \`harness/tools/audit-company-info-coverage.mjs\`
- ## 1. 总览
- | company_info 里的段 | 落到哪 | 粒度 | 匹配/幂等键 |
- |---|---|---|---|
- | \`profile.data\`(38 个字段) | **1888 企业基础信息** | 一行一家企业 | \`c_credit_code\` |
- | 分类接口六角度(不是 company_info,由它调出) | **1888** 的 \`c_tag_*\` | 同上 | 同上 |
- | \`honors.records[].data\`(8 个字段) | **1886 企业荣誉信息** | **一条荣誉一行** | 荣誉名 + 级别 + 来源 |
- | \`company\` / \`profile.source\` / \`honors.sources\` 等 | **不落库** | — | 见 §5 |
- 触发:对话 \`result\` 事件的 \`company_info\` 非空 → 回答完成后后台同步(fire-and-forget,失败只 warn)。
- 三类数据(工商信息 / 荣誉 / 分类标签)**互不依赖**:分类接口挂了,前两者照常同步。
- ## 2. 1888 企业基础信息(columnId 1888 / modelId ${DMS_MODEL_ENTERPRISE})
- ### 2.1 身份与工商信息:\`profile.data\` → 列(${rows1888.length} 项)
- | company_info 字段 | DMS 列 | 列的中文别名与控件 |
- |---|---|---|
- ${rows1888.map((r) => `| \`profile.data.${r.src}\` | \`${r.col}\` | ${desc(entBy, r.col)} |`).join('\n')}
- > \`profile.data\` 共 38 个字段,上表覆盖 ${rows1888.length} 个;其余 2 个(\`KeyNo\`、\`TeamEnd\`)
- > DMS 侧**没有对应列**,见 §5。
- > \`company\` 段里的 \`Name\` / \`CreditCode\` / \`OperName\` 等与 \`profile.data\` 同名字段重复,
- > **只作为回退**(profile 缺失时才用),不单独落列。
- ### 2.2 分类标签:分类接口六角度 → \`c_tag_*\`
- | 分类角度 | DMS 列 | 列的中文别名与控件 | 取值形态 |
- |---|---|---|---|
- ${Object.entries(ANGLE_TO_FIELD)
- .map(([angle, col]) => `| ${angle} | \`${col}\` | ${desc(entBy, col)} | JSON 数组字符串,如 \`["第一产业","现代农业"]\`;空集**不落库** |`)
- .join('\n')}
- > 数据**不是**来自 \`company_info\`,而是把 \`company_info\` 整个对象发给分类接口
- > \`POST /api/company/classify\` 后的返回(契约见 [\`company-classification.md\`](./company-classification.md))。
- ### 2.3 系统字段 \`title\`
- | 来源 | DMS 字段 | 说明 |
- |---|---|---|
- | \`profile.data.Name\`(或回退 \`company.Name\`) | \`title\`(**不在模型 fieldList 里**,是 DMS 的列表显示列) | 取企业名称。不写的话 DMS 会落成**字符串 \`"null"\`**——\`title\` 只在创建那一刻初始化,之后改 \`c_*\` 不带动它 |
- ### 2.4 其余列(前端同步不写,${entUnwritten.length} 列)
- | DMS 列 | 别名与控件 | 为什么不写 |
- |---|---|---|
- ${entUnwritten
- .map((f) => `| \`${f.name}\` | 「${f.alias}」\`${f.frontType}\` | 见 §5:\`company_info\` 里没有对应数据源 |`)
- .join('\n')}
- ## 3. 1886 企业荣誉信息(columnId 1886 / modelId ${DMS_MODEL_HONOR})
- ### 3.1 \`honors.records[].data\` → 列(${rows1886.length} 项,**一条荣誉一行**)
- | company_info 字段 | DMS 列 | 列的中文别名与控件 |
- |---|---|---|
- ${rows1886.map((r) => `| \`${r.src}\` | \`${r.col}\` | ${desc(honorBy, r.col)} |`).join('\n')}
- ### 3.2 另外写入的列
- | 值 | DMS 列 | 说明 |
- |---|---|---|
- | \`records[].data.Name\` | \`${HONOR_NAME_FIELD}\` 与系统字段 \`title\` | 荣誉名**两处都写**:自有列 + 列表显示列 |
- | 归属企业 | \`c_credit_code\` / \`c_name\` | 来自身份(\`company_info.profile.data\`) |
- | — | \`c_id\` | 必填字段,前端传 \`0\`(DMS 自动填) |
- | — | \`c_created_at\` | 新增时写 \`YYYY-MM-DD HH:mm:ss\`(该列在 1888 里写了读不回,1886 正常) |
- ### 3.3 不写的列(${honorUnwritten.length} 列)
- | DMS 列 | 别名与控件 | 说明 |
- |---|---|---|
- ${honorUnwritten.map((f) => `| \`${f.name}\` | 「${f.alias}」\`${f.frontType}\` | 用户 2026-09-18 确认:不承担语义、也不清理(早期版本曾把荣誉名写在这里) |`).join('\n')}
- ## 4. 荣誉的「全量对齐」语义
- 1886 不是简单地「有则更新」,而是**与后端这份荣誉列表对齐**:
- | 情况 | 动作 |
- |---|---|
- | 后端有、库里没有 | 新增一行 |
- | 两边都有(荣誉名+级别+来源 相同),但某列值不同 | 只更新有差异的列 |
- | 库里多出来的 | **删除**(\`updateAudit state=4\`)——**仅当 \`honors.complete === true\`**;不完整时只增改不删 |
- | 完全一致 | 零写入 |
- ## 5. 不落库的字段
- | 字段 | 为什么不落 |
- |---|---|
- | \`profile.source.*\`(provider / api_code / document_url / fetched_at / page_index / total_records / company_key) | 溯源元信息,1888 没有对应列 |
- | \`profile.data.KeyNo\`、\`company.KeyNo\` | 企查查主体键,DMS 侧没有对应列 |
- | \`profile.data.TeamEnd\` | 企查查的拼写错误字段(正确拼法是 \`TermEnd\`),**值恒为空串** |
- | \`honors.complete\` / \`honors.error\` / \`honors.sources[].*\` | 只用于判断「能不能删」与溯源,不落库 |
- | 1888 的 \`c_scope_brief\` / \`c_phone_number\` / \`c_email\` / \`c_industry\` | **DMS 有列,但 \`company_info\` 里没有这些字段**(企查查 410 接口未返回)——要存需后端先补数据源 |
- ## 6. 三条影响「对应关系」的写入规则
- 1. **空值一律不写**(\`null\` / 空串 / 空数组 / 空对象)——**不清存量**:
- 库里已有的值不会被「本次没带」的字段抹掉
- 2. **JSON 列按语义比较**(\`c_area\` / \`c_original_name\` / \`c_revoke_info\` /
- \`c_designated_representative_list\`):DMS 里是 Python/jsonb 序列化的(带空格、键按字母序),
- 与 JS 的 \`JSON.stringify\` 字节不同——语义相同就不写,避免每轮无谓改写
- 3. **无差异零写入**:字段级 diff,patch 为空则完全不发请求(幂等,重复同步不改数据)
- ## 7. 当前覆盖率
- | 栏目 | 在写 | 未写 | 未写的原因 |
- |---|---|---|---|
- | 1888(${entFields.length} 列) | **${entWritten.size} 列** | ${entUnwritten.length} 列 | 载荷里没有数据源(§5) |
- | 1886(${honorFields.length} 列) | **${honorWritten.size} 列** | ${honorUnwritten.length} 列 | 用户确认不用(§3.3) |
- > 复核命令:\`node harness/tools/audit-company-info-coverage.mjs <company-info.json>\`
- > (实时拉模型 + 从代码派生写入列,输出同样可比对的清单)
- `;
- const target = 'harness/docs/reference/company-info-dms-mapping.md';
- writeFileSync(target, md, 'utf8');
- console.log(`已生成 ${target}`);
- console.log(`1888:写 ${entWritten.size}/${entFields.length} 列;1886:写 ${honorWritten.size}/${honorFields.length} 列`);
|