/** * 生成「`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 \` > (实时拉模型 + 从代码派生写入列,输出同样可比对的清单) `; 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} 列`);