|
|
@@ -0,0 +1,230 @@
|
|
|
+/**
|
|
|
+ * 生成「`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} 列`);
|