Jelajahi Sumber

docs(company): 新增 company_info ↔ DMS 企业两栏目 字段对应关系文档

用户要求一份字段对应关系 md,放在 harness 里。已生成
harness/docs/reference/company-info-dms-mapping.md,并登记进 reference/README.md 索引
与 tools/README.md 脚本清单。

关键做法:文档**由脚本生成**(harness/tools/gen-company-info-mapping-doc.mjs)——
读代码里的映射表(PROFILE_FIELD_MAP / ANGLE_TO_FIELD / HONOR_FIELD_MAP)
+ 实时拉 DMS 模型定义(中文别名、控件类型),改了字段重跑即可,避免手抄漂移。

内容:总览(哪段落到哪个栏目/粒度/匹配键)、1888 的 36 项工商映射 + 六角度标签 +
系统字段 title、1886 荣誉字段映射与「全量对齐」语义、不落库字段及原因、
三条写入规则(空值不写/JSON 列语义比较/无差异零写入)、当前覆盖率与复核命令。

生成时修掉一处:荣誉字段原显示内部归一化名(level),已改为载荷原始键(Level)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 9 jam lalu
induk
melakukan
d30461769b

+ 1 - 0
harness/docs/reference/README.md

@@ -10,6 +10,7 @@
 | `api-chat.md` | ✅ **现行契约** | 改任何聊天相关代码之前 |
 | `api-chat-fields-zh.md` | ✅ **现行契约** | 需要字段中英对照、状态取值释义时 |
 | `company-classification.md` | ⚠️ **现行契约(后端有实测问题)** | 做企业信息分类同步(company_info → 1888/1886)时 |
+| `company-info-dms-mapping.md` | 📄 **字段对应关系**(脚本生成) | 要查「company_info 的某个字段落到 DMS 哪一列 / 哪些没落」时 |
 | `legacy/API.md` | 📦 **交接快照**(非规格、不更新) | 只在需要翻"当初调了什么接口"时读 |
 | `DMS_API.md` | 📄 **外部服务契约**(含实测结论) | 要调 DMS(数据管理服务)时 |
 | `DMS_COLUMNS.md` | 📄 **栏目现状** | 查 DMS 五个栏目的 columnId / 字段时 |

+ 169 - 0
harness/docs/reference/company-info-dms-mapping.md

@@ -0,0 +1,169 @@
+# 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 2036)
+
+### 2.1 身份与工商信息:`profile.data` → 列(36 项)
+
+| company_info 字段 | DMS 列 | 列的中文别名与控件 |
+|---|---|---|
+| `profile.data.CreditCode` | `c_credit_code` | 「统一社会信用代码」`varchar` |
+| `profile.data.Name` | `c_name` | 「企业名称」`varchar` |
+| `profile.data.BelongOrg` | `c_belong_org` | 「所属机构」`varchar` |
+| `profile.data.OperId` | `c_oper_id` | 「经营者ID」`varchar` |
+| `profile.data.OperName` | `c_oper_name` | 「经营者名称」`varchar` |
+| `profile.data.DesignatedRepresentativeList` | `c_designated_representative_list` | 「法定代表人列表」`text` |
+| `profile.data.StartDate` | `c_start_date` | 「成立日期」`varchar` |
+| `profile.data.EndDate` | `c_end_date` | 「结束日期」`varchar` |
+| `profile.data.Status` | `c_status` | 「经营状态」`varchar` |
+| `profile.data.Province` | `c_province` | 「省份」`varchar` |
+| `profile.data.UpdatedDate` | `c_updated_date` | 「更新日期」`varchar` |
+| `profile.data.RegistCapi` | `c_regist_capi` | 「注册资本」`varchar` |
+| `profile.data.RegisteredCapital` | `c_registered_capital` | 「注册资本数值」`varchar` |
+| `profile.data.RegisteredCapitalUnit` | `c_registered_capital_unit` | 「注册资本单位」`varchar` |
+| `profile.data.RegisteredCapitalCCY` | `c_registered_capital_ccy` | 「注册资本币种」`varchar` |
+| `profile.data.EconKind` | `c_econ_kind` | 「经济类型」`varchar` |
+| `profile.data.Address` | `c_address` | 「注册地址」`varchar` |
+| `profile.data.Scope` | `c_scope` | 「经营范围」`text` |
+| `profile.data.TermStart` | `c_term_start` | 「营业期限起」`varchar` |
+| `profile.data.TermEnd` | `c_term_end` | 「营业期限止」`varchar` |
+| `profile.data.CheckDate` | `c_check_date` | 「核准日期」`varchar` |
+| `profile.data.OrgNo` | `c_org_no` | 「组织机构代码」`varchar` |
+| `profile.data.IsOnStock` | `c_is_on_stock` | 「是否上市」`varchar` |
+| `profile.data.StockNumber` | `c_stock_number` | 「股票代码」`varchar` |
+| `profile.data.StockType` | `c_stock_type` | 「股票类型」`varchar` |
+| `profile.data.OriginalName` | `c_original_name` | 「曾用名」`text` |
+| `profile.data.ImageUrl` | `c_image_url` | 「图片地址」`varchar` |
+| `profile.data.EntType` | `c_ent_type` | 「企业类型」`varchar` |
+| `profile.data.RecCap` | `c_rec_cap` | 「实收资本」`varchar` |
+| `profile.data.PaidUpCapital` | `c_paid_up_capital` | 「实缴资本」`varchar` |
+| `profile.data.PaidUpCapitalUnit` | `c_paid_up_capital_unit` | 「实缴资本单位」`varchar` |
+| `profile.data.PaidUpCapitalCCY` | `c_paid_up_capital_ccy` | 「实缴资本币种」`varchar` |
+| `profile.data.RevokeInfo` | `c_revoke_info` | 「撤销信息」`text` |
+| `profile.data.Area` | `c_area` | 「行政区划」`text` |
+| `profile.data.AreaCode` | `c_area_code` | 「行政区划代码」`varchar` |
+| `profile.data.No` | `c_no` | 「编号」`varchar` |
+
+> `profile.data` 共 38 个字段,上表覆盖 36 个;其余 2 个(`KeyNo`、`TeamEnd`)
+> DMS 侧**没有对应列**,见 §5。
+> `company` 段里的 `Name` / `CreditCode` / `OperName` 等与 `profile.data` 同名字段重复,
+> **只作为回退**(profile 缺失时才用),不单独落列。
+
+### 2.2 分类标签:分类接口六角度 → `c_tag_*`
+
+| 分类角度 | DMS 列 | 列的中文别名与控件 | 取值形态 |
+|---|---|---|---|
+| 基本信息 | `c_tag_basic` | 「基本信息标签」`text` | JSON 数组字符串,如 `["第一产业","现代农业"]`;空集**不落库** |
+| 资质荣誉 | `c_tag_honor` | 「资质荣誉标签」`content` | JSON 数组字符串,如 `["第一产业","现代农业"]`;空集**不落库** |
+| 产业信息 | `c_tag_sector` | 「产业信息标签」`text` | JSON 数组字符串,如 `["第一产业","现代农业"]`;空集**不落库** |
+| 经营活动 | `c_tag_operation` | 「经营活动标签」`text` | JSON 数组字符串,如 `["第一产业","现代农业"]`;空集**不落库** |
+| 行业信息 | `c_tag_industry` | 「行业信息标签」`text` | JSON 数组字符串,如 `["第一产业","现代农业"]`;空集**不落库** |
+| 许可认证 | `c_tag_license` | 「许可认证标签」`text` | JSON 数组字符串,如 `["第一产业","现代农业"]`;空集**不落库** |
+
+> 数据**不是**来自 `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 其余列(前端同步不写,4 列)
+
+| DMS 列 | 别名与控件 | 为什么不写 |
+|---|---|---|
+| `c_scope_brief` | 「经营范围简述」`varchar` | 见 §5:`company_info` 里没有对应数据源 |
+| `c_phone_number` | 「联系电话」`varchar` | 见 §5:`company_info` 里没有对应数据源 |
+| `c_email` | 「邮箱」`varchar` | 见 §5:`company_info` 里没有对应数据源 |
+| `c_industry` | 「行业」`text` | 见 §5:`company_info` 里没有对应数据源 |
+
+## 3. 1886 企业荣誉信息(columnId 1886 / modelId 2032)
+
+### 3.1 `honors.records[].data` → 列(7 项,**一条荣誉一行**)
+
+| company_info 字段 | DMS 列 | 列的中文别名与控件 |
+|---|---|---|
+| `honors.records[].data.Level` | `c_level` | 「级别」`varchar` |
+| `honors.records[].data.Source` | `c_source` | 「来源」`varchar` |
+| `honors.records[].data.PublishOffice` | `c_publish_office` | 「发布机构」`varchar` |
+| `honors.records[].data.PublishDate` | `c_publish_date` | 「发布日期」`varchar` |
+| `honors.records[].data.BegingDate` | `c_beging_date` | 「起始日期」`varchar` |
+| `honors.records[].data.DeadLine` | `c_dead_line` | 「截止日期」`varchar` |
+| `honors.records[].data.CertificateCode` | `c_certificate_code` | 「证书编号」`varchar` |
+
+### 3.2 另外写入的列
+
+| 值 | DMS 列 | 说明 |
+|---|---|---|
+| `records[].data.Name` | `c_honor` 与系统字段 `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 不写的列(1 列)
+
+| DMS 列 | 别名与控件 | 说明 |
+|---|---|---|
+| `c_tag_name` | 「荣誉标签」`text` | 用户 2026-09-18 确认:不承担语义、也不清理(早期版本曾把荣誉名写在这里) |
+
+## 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(47 列) | **43 列** | 4 列 | 载荷里没有数据源(§5) |
+| 1886(13 列) | **12 列** | 1 列 | 用户确认不用(§3.3) |
+
+> 复核命令:`node harness/tools/audit-company-info-coverage.mjs <company-info.json>`
+> (实时拉模型 + 从代码派生写入列,输出同样可比对的清单)

+ 17 - 0
harness/progress.md

@@ -2820,3 +2820,20 @@
     也就是说这个坑**只在「前端新建企业行」时才会触发**,本轮补上后不会再产生
 - **下一步最佳动作**:无(本轮已闭环);若要继续,见 Session 031 的候选优先级
 
+### 续:字段对应关系文档(用户要求)
+
+用户要一份「`company_info` 字段 ↔ DMS 企业两栏目字段」的对应关系 md 文档。
+已放 **`harness/docs/reference/company-info-dms-mapping.md`**,并登记进
+`reference/README.md` 的索引表与 `tools/README.md` 的脚本清单。
+
+**关键做法:文档由脚本生成,不手写** —— `harness/tools/gen-company-info-mapping-doc.mjs`:
+读代码里的映射表(`PROFILE_FIELD_MAP` / `ANGLE_TO_FIELD` / `HONOR_FIELD_MAP`)
++ **实时拉 DMS 模型定义**(拿中文别名与控件类型),改了字段重跑即可,避免手抄漂移。
+
+文档含:总览(哪段落到哪个栏目/粒度/匹配键)、1888 的 36 项工商映射 + 六角度标签 + 系统字段 title、
+1886 的荣誉字段映射与「全量对齐」语义、不落库的字段及原因、三条影响对应关系的写入规则、
+当前覆盖率(1888 写 43/47、1886 写 12/13)与复核命令。
+
+生成时踩了一处并修掉:荣誉字段显示的是**内部归一化名**(`level`)而非**载荷原始键**(`Level`)——
+文档是给人对着载荷看的,已改为显示原始键(`RAW_HONOR_KEYS` 映射)。
+

+ 7 - 0
harness/tools/README.md

@@ -65,6 +65,13 @@ npx esbuild harness/tools/_entry-company-classify.ts --bundle --format=esm \
 | `_company-classify.mjs` | `verify-company-classify-e2e.mjs` | ✅ 真实分类接口 + 真实 DMS(要一份**真实形态**的 company_info JSON,见脚本头注释) |
 | `_coordinator.mjs` | `verify-company-info-passthrough.mjs` | ❌ 打桩 fetch 喂假 SSE 流 |
 
+文档生成与审计(不是断言脚本,但也打真实 DMS 读模型定义):
+
+| 脚本 | 干什么 | 产物 |
+|---|---|---|
+| `gen-company-info-mapping-doc.mjs` | 生成「company_info ↔ DMS 企业两栏目」字段对应文档 | `docs/reference/company-info-dms-mapping.md` |
+| `audit-company-info-coverage.mjs` | 审计字段覆盖率(载荷 / 模型 / 代码写入列三边对齐) | 控制台清单 |
+
 > `--define:import.meta.env='{}'`:验证脚本跑在 node 里没有 `import.meta.env`,
 > 需要喂一个空对象,否则读取环境变量的模块会直接抛错。
 

+ 230 - 0
harness/tools/gen-company-info-mapping-doc.mjs

@@ -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} 列`);