Bladeren bron

docs(company): 审计 company_info → DMS 的字段覆盖,并更正栏目现状

用户问「company_info 的字段是否都存进 DMS 了,列出已存/未存」。
新增 harness/tools/audit-company-info-coverage.mjs:三边对齐(真实载荷字段 / 实时拉取的
DMS 模型字段 / 代码实际写入的列),改字段后重跑即可。

审计结论:
- 1888(47 列)在写 10 列(身份三字段 + c_created_at + 六个 c_tag_*);
  33 个 profile.data 字段 DMS 有现成列但没写(工商信息),4 列载荷无数据源、
  2 个载荷字段 DMS 无列、7 个 profile.source.* 属元信息
- 1886(13 列)在写 12 列,未写 1 列(c_tag_name,用户已确认不用);荣誉明细无缺口

顺带更正 DMS_COLUMNS.md 两处过期内容:「五栏目全空、数据还没迁」→ 各栏目现状;
速查表字段数 11/7/41 → 13/23/47(被 DMS 侧扩过)。

脚本自身踩坑已修:把 columnId 当 modelId 传,DMS 不报错、返回了另一个模型
(带 c_milvus_chunk_ids 的知识库模型)——改用 DMS_MODEL_* 并加了模型别名校验防呆。

验证:审计脚本实跑通过;validate-harness 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 11 uur geleden
bovenliggende
commit
cf37ed4863
3 gewijzigde bestanden met toevoegingen van 327 en 16 verwijderingen
  1. 49 16
      harness/docs/reference/DMS_COLUMNS.md
  2. 40 0
      harness/progress.md
  3. 238 0
      harness/tools/audit-company-info-coverage.mjs

+ 49 - 16
harness/docs/reference/DMS_COLUMNS.md

@@ -17,20 +17,20 @@
 
 ---
 
-## ⚠️ 先读这段:栏目建好了,但**数据还没迁**
+## ⚠️ 先读这段:栏目现状(**「五个栏目全空」已过期**)
 
-| 栏目 | columnId | 当前内容数 |
-|------|----------|-----------|
-| 助手页面浏览 | 1885 | **0** |
-| 企业荣誉信息 | 1886 | **0** |
-| 助手会话 | 1887 | **0** |
-| 企业基础信息 | 1888 | **0** |
-| 助手问答记录 | 1889 | **0** |
+下面这段原本写的是「栏目建好了、数据还没迁」,**2026-09-17 起已不成立**,特此更正:
 
-实测 `selectContentList` 五个栏目全部返回 `code=202 数据不存在`。
+| 栏目 | columnId | 现状 |
+|------|----------|------|
+| 助手页面浏览 | 1885 | 无写入方(统计埋点已于 2026-09-18 从代码中删除),**没有数据** |
+| 企业荣誉信息 | 1886 | **有数据**:09-17 由 DMS 侧灌入(源表 `qcc_honor` 快照)+ 前端分类同步逐条对齐 |
+| 助手会话 | 1887 | 前端曾写入,**2026-09-18 按用户指示停用写入**(只保留读取与删除清理) |
+| 企业基础信息 | 1888 | **有数据**:09-17 由 DMS 侧灌入(源表 `qcc_enterprise` 快照)+ 前端分类同步补标签 |
+| 助手问答记录 | 1889 | 前端在用(一问一答一条 + 反馈) |
 
-**五张表的结构(栏目 + 字段)已就绪,但业务数据尚未写入。**
-数据迁移(`F4-*`)还没开始。所以现在查任何栏目都只会得到 202。
+> 行数会持续变化,**不在这里写死数字**;要查当前值直接 `selectContentList` 看 `content.count`。
+> 字段覆盖(`company_info` 的哪些字段进了 DMS)见 §2.6
 
 ---
 
@@ -39,10 +39,14 @@
 | 栏目 | columnId | tag(权限名) | 栏目模型 modelId | 字段数 | 源表 | 源行数 |
 |------|----------|---------------|------------------|--------|------|--------|
 | 助手页面浏览 | **1885** | `yszs_page_view` | **2030** | 4 | `assistant_page_view` | 39,723 |
-| 企业荣誉信息 | **1886** | `yszs_qcc_honor` | **2032** | 11 | `qcc_honor` | 7,118 |
-| 助手会话 | **1887** | `yszs_chat_session` | **2034** | 7 | `chat_session` | 3,133 |
-| 企业基础信息 | **1888** | `yszs_qcc_enterprise` | **2036** | 41 | `qcc_enterprise` | 81,980 |
-| 助手问答记录 | **1889** | `yszs_chat_record` | **2038** | 13 | `chat_record` | 15,542 |
+| 企业荣誉信息 | **1886** | `yszs_qcc_honor` | **2032** | **13** | `qcc_honor` | 7,118 |
+| 助手会话 | **1887** | `yszs_chat_session` | **2034** | **23** | `chat_session` | 3,133 |
+| 企业基础信息 | **1888** | `yszs_qcc_enterprise` | **2036** | **47** | `qcc_enterprise` | 81,980 |
+| 助手问答记录 | **1889** | `yszs_chat_record` | **2038** | **13** | `chat_record` | 15,542 |
+
+> ⚠️ 「字段数」一列原为建栏目时的值(11 / 7 / 41),**已被 DMS 侧扩过**:
+> 1886 → 13(`c_tag_name`、`c_honor`)、1887 → 23(运营分析字段,见 `chat-sessions-dms.ts` 的说明)、
+> 1888 → 47(六个 `c_tag_*`)。**改字段后请重新核对本表**(`getModelById` 拉 fieldList)。
 
 **调 content 接口时用 `columnId`。** 权限用 `tag`(已同步 OAuth `permissionName`)。
 
@@ -102,7 +106,7 @@
 > 删库里多出来的(**仅当后端 `honors.complete === true`**)。
 > 灌入的行(09-17 那批,只有 `title`)与早期同步的行(名字在 `c_tag_name`)同样参与,
 > 首次对齐会把 `c_honor` 补上。
-> 见 [`../exec-plans/active/company-classify-dms-sync.md`](../exec-plans/active/company-classify-dms-sync.md)。
+> 见 [`../exec-plans/completed/company-classify-dms-sync.md`](../exec-plans/completed/company-classify-dms-sync.md)。
 
 ### 2.3 助手会话 `columnId=1887`
 
@@ -232,6 +236,35 @@
 
 ---
 
+### 2.6 `company_info` → DMS 的字段覆盖现状(2026-09-20 审计)
+
+**工具**:[`../../tools/audit-company-info-coverage.mjs`](../../tools/audit-company-info-coverage.mjs)
+(实时拉模型字段 + 对照真实载荷,改字段后重跑即可;注意它取的是 **modelId** 不是 columnId)
+
+**1888(47 列,前端同步在写 10 列)**
+
+| 分类 | 数量 | 说明 |
+|---|---|---|
+| 前端同步写入 | **10** | `c_credit_code` / `c_name` / `c_oper_name`(身份)+ `c_created_at` + 六个 `c_tag_*`(分类标签) |
+| **`profile.data` 里有、DMS 也有列、但同步没写** | **33** | 工商信息:`Status→c_status`、`Address→c_address`、`Scope→c_scope`、`RegistCapi→c_regist_capi`、`StartDate→c_start_date`、`Area→c_area` 等(**列是现成的,只差映射**) |
+| DMS 有列但载荷里没有对应字段 | 4 | `c_scope_brief`(经营范围简述)/ `c_phone_number` / `c_email` / `c_industry` —— 企查查 410 接口本就没返回,要存得先有数据源 |
+| 载荷里有、DMS 无列 | 2 | `profile.data.KeyNo`(企查查主体键)、`profile.data.TeamEnd`(企查查的拼写错误字段,值恒为空串) |
+| 元信息,不写 | 7 | `profile.source.*`(provider / api_code / document_url / fetched_at / page_index / total_records / company_key),属溯源信息 |
+
+> ⚠️ **「没写」≠「库里没有」**:1888 是从源表 `qcc_enterprise` 迁过来灌好的
+> (09-17 那批,作者 `ams_user`),那些行的工商字段**本来就有值**;前端同步只是补身份与标签。
+> 真正只有 10 列有值的情况只发生在**库里没有、由前端新建**的企业行上。
+
+**1886(13 列,前端同步在写 12 列)**
+
+| 分类 | 数量 | 说明 |
+|---|---|---|
+| 前端同步写入 | **12** | `c_honor`(荣誉名,并写系统 `title`)+ 级别/来源/发布机构/发布日期/起始/截止/证书编号 + `c_credit_code`/`c_name`/`c_id`/`c_created_at` |
+| 不写 | 1 | `c_tag_name`(荣誉标签)——**用户 2026-09-18 确认不用它、也不清理** |
+| 元信息,不写 | — | `honors.complete` / `honors.error` / `honors.sources[].*`(只用于判断能否删除) |
+
+> 荣誉明细侧**没有缺口**:`honors.records[].data` 的 8 个字段全部落库。
+
 ## 3. 查询示例
 
 所有查询共用:

+ 40 - 0
harness/progress.md

@@ -2654,3 +2654,43 @@
 - **下一步最佳动作**:功能清单已无 in_progress;候选方向见 Session 031 的优先级清单
   (其中「企业信息 1888 切 DMS」这一条现在**已经有现成的读写实现可复用**,成本比当初估计的低)
 
+## Session 063
+
+- **日期**:2026-09-20
+- **本轮目标**:用户问「`company_info` 里的字段是否全部存到 DMS 了?分别列出已存的匹配关系与还没存的」
+- **已完成**:
+  - 新增 **`harness/tools/audit-company-info-coverage.mjs`**(可复用):
+    三边对齐——① 真实载荷里实际有哪些字段 ② **实时**拉 DMS 模型字段(`getModelById`)
+    ③ 代码实际写哪些列(脚本里显式列出,与 src 对齐)。改字段后重跑即可
+  - 审计结论写进 `DMS_COLUMNS.md` **§2.6**(含两个栏目的覆盖表)
+  - 顺带更正该文档两处**已过期**的头部信息:①「五栏目全空、数据还没迁」→ 改为各栏目现状
+    ② 速查表的字段数(11/7/41 → **13/23/47**,被 DMS 侧扩过)
+- **审计结论(2026-09-20)**:
+
+  | | 已写 | 未写 |
+  |---|---|---|
+  | **1888**(47 列) | **10** 列:身份三字段 + `c_created_at` + 六个 `c_tag_*` | **33** 个 `profile.data` 字段**DMS 有现成列但没写**(Status/Address/Scope/注册资本/成立日期/经营范围…);另有 4 列载荷里没有数据源(`c_scope_brief`/`c_phone_number`/`c_email`/`c_industry`)、2 个载荷字段 DMS 无列(`KeyNo`/`TeamEnd`)、7 个 `profile.source.*` 属元信息不写 |
+  | **1886**(13 列) | **12** 列(含 `c_honor`) | 1 列:`c_tag_name`(用户已确认不用、不清理)。**荣誉明细侧无缺口**:`records[].data` 的 8 个字段全部落库 |
+
+  ⚠️ 关键区分:**「没写」≠「库里没有」**——1888 的工商字段是 DMS 侧 09-17 灌进去的
+  (作者 `ams_user`),前端同步只补身份与标签;**只有「库里没有、由前端新建」的企业行**
+  才会出现「只有 10 列有值」。
+
+- **运行过的验证**:
+  - 审计脚本实跑(真实载荷 = 宇树科技那份;模型字段经代理实时拉取)
+  - **脚本自身踩了一坑并已修**:把 **columnId(1888/1886)当成 modelId 传了**,
+    DMS 不报错、直接返回了另一个模型(拉回来一个带 `c_milvus_chunk_ids` 的知识库模型)——
+    已改用 `DMS_MODEL_*`(2036/2032),并**加了模型别名校验**(对不上就抛错),防下次再错
+  - 用户提供新 DMS token(已写入 `.env.development.local`,该文件被 git 忽略)
+- **已记录证据**:本文件 Session 063;`DMS_COLUMNS.md` §2.6 与两处更正;
+  `harness/tools/audit-company-info-coverage.mjs`
+- **提交记录**:见下条提交(已按约定提交推送)
+- **更新过的文件或工件**:`harness/tools/audit-company-info-coverage.mjs`(新增)、
+  `harness/docs/reference/DMS_COLUMNS.md`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **那 33 个工商字段要不要同步,是产品决策**(当初拍板的对比范围只有「身份三字段 + 标签」)。
+    若要同步,工作量主要在**字段映射**(列都现成),`diffEnterpriseRow` 的字段级 diff 可直接复用
+  - ⚠️ 1888 的 `c_phone_number`/`c_email`/`c_scope_brief`/`c_industry` **载荷里没有数据源**,
+    要存得先让后端补(前两个企查查 410 接口没返回)
+- **下一步最佳动作**:若要把工商字段也同步进去,说一声即可(映射 + 对比范围两点确认后开工)
+

+ 238 - 0
harness/tools/audit-company-info-coverage.mjs

@@ -0,0 +1,238 @@
+/**
+ * 审计:`company_info` 里的字段,有多少真的落进了 DMS 的企业两栏目。
+ *
+ * 为什么要有这个脚本:`company_info`(企查查原始结构)字段很多,而 1888 的列
+ * 是照着它建的(47 列里大部分是工商信息),**但我们的同步只写其中一小部分**。
+ * 字段一多,靠记忆或靠文档核对必然漏;这个脚本把三边对齐:
+ *   ① 真实载荷里实际有哪些字段(`company / profile.data / profile.source / honors`)
+ *   ② DMS 模型里实际有哪些列(**实时** `getModelById`,不用记录值)
+ *   ③ 我们的代码实际写哪些列(在脚本里显式列出,与 src 对齐)
+ *
+ * 怎么跑(需要 dev server 起着,代理注入 DMS token):
+ *   node harness/tools/audit-company-info-coverage.mjs <company-info.json> [dms 代理地址]
+ *
+ * 载荷怎么来:把 `/api/chat` 的 `result` 事件里 `data.company_info` 对象存成 JSON 文件
+ * (整个对象,不含外层 company_info 键)。注意用 node 写文件,别用 shell 里的 curl -d 传中文。
+ */
+
+process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
+
+import { readFileSync } from 'node:fs';
+
+const payloadPath = process.argv[2];
+const dmsBase = process.argv[3] || process.env.DMS_BASE || 'https://localhost:8083/dms-api';
+if (!payloadPath) {
+  console.error('用法:node audit-company-info-coverage.mjs <company-info.json> [dms 代理地址]');
+  process.exit(2);
+}
+globalThis.VITE_DMS_API = dmsBase;
+
+// ⚠️ 注意取的是 **modelId**(2036/2032),不是 columnId(1888/1886)——
+// 传错会把别的模型拉回来(踩过:1888 当 modelId 用,拉回来一个带 c_milvus_chunk_ids 的知识库模型)
+const {
+  DMS_MODEL_ENTERPRISE,
+  DMS_MODEL_HONOR,
+} = await import('./_company-classify.mjs');
+
+/* ------------------------------------------------------------------ *
+ * 我们的代码实际写入的列(与 src 对齐,改代码时记得同步这里)
+ * 来源:src/network/api/dms/company-info-sync.ts 与 classification-sync-utils.ts
+ * ------------------------------------------------------------------ */
+const WE_WRITE_1888 = [
+  'c_credit_code', // 幂等键,来自身份
+  'c_name', // 身份(分类响应 → 回退 profile.data.Name → company.Name)
+  'c_oper_name', // 法人
+  'c_created_at', // 仅新增时
+  // 六个分类角度标签(JSON 数组字符串)
+  'c_tag_basic',
+  'c_tag_honor',
+  'c_tag_sector',
+  'c_tag_operation',
+  'c_tag_industry',
+  'c_tag_license',
+];
+const WE_WRITE_1886 = [
+  'c_id', // 只传 0(must 字段,DMS 自动填)
+  'c_credit_code',
+  'c_name',
+  'c_honor', // 荣誉名(并写系统字段 title)
+  'c_level',
+  'c_source',
+  'c_publish_office',
+  'c_publish_date',
+  'c_beging_date',
+  'c_dead_line',
+  'c_certificate_code',
+  'c_created_at',
+];
+
+/** company_info 字段 → 1888 列(语义对应;null = DMS 没有这一列) */
+const MAP_PROFILE_DATA = {
+  Name: 'c_name',
+  CreditCode: 'c_credit_code',
+  OperName: 'c_oper_name',
+  BelongOrg: 'c_belong_org',
+  OperId: 'c_oper_id',
+  EntType: 'c_ent_type',
+  EconKind: 'c_econ_kind',
+  Status: 'c_status',
+  Province: 'c_province',
+  AreaCode: 'c_area_code',
+  Address: 'c_address',
+  StartDate: 'c_start_date',
+  EndDate: 'c_end_date',
+  TermStart: 'c_term_start',
+  TermEnd: 'c_term_end',
+  CheckDate: 'c_check_date',
+  UpdatedDate: 'c_updated_date',
+  RegistCapi: 'c_regist_capi',
+  RegisteredCapital: 'c_registered_capital',
+  RegisteredCapitalUnit: 'c_registered_capital_unit',
+  RegisteredCapitalCCY: 'c_registered_capital_ccy',
+  RecCap: 'c_rec_cap',
+  PaidUpCapital: 'c_paid_up_capital',
+  PaidUpCapitalUnit: 'c_paid_up_capital_unit',
+  PaidUpCapitalCCY: 'c_paid_up_capital_ccy',
+  IsOnStock: 'c_is_on_stock',
+  StockNumber: 'c_stock_number',
+  StockType: 'c_stock_type',
+  OrgNo: 'c_org_no',
+  No: 'c_no',
+  ImageUrl: 'c_image_url',
+  Scope: 'c_scope',
+  DesignatedRepresentativeList: 'c_designated_representative_list',
+  OriginalName: 'c_original_name',
+  RevokeInfo: 'c_revoke_info',
+  Area: 'c_area',
+};
+const MAP_COMPANY = {
+  KeyNo: null,
+  Name: 'c_name',
+  CreditCode: 'c_credit_code',
+  StartDate: 'c_start_date',
+  OperName: 'c_oper_name',
+  Status: 'c_status',
+  No: 'c_no',
+  Address: 'c_address',
+};
+const MAP_HONOR_RECORD = {
+  Name: 'c_honor(并写系统 title)',
+  Level: 'c_level',
+  Source: 'c_source',
+  PublishOffice: 'c_publish_office',
+  PublishDate: 'c_publish_date',
+  BegingDate: 'c_beging_date',
+  DeadLine: 'c_dead_line',
+  CertificateCode: 'c_certificate_code',
+};
+
+/* ------------------------------------------------------------------ *
+ * 读输入
+ * ------------------------------------------------------------------ */
+const payload = JSON.parse(readFileSync(payloadPath, 'utf8'));
+
+const fetchModelFields = async (modelId, expectAlias) => {
+  const form = new URLSearchParams({ modelId: String(modelId) }).toString();
+  const res = await fetch(`${dmsBase}/model/getModelById`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: form,
+  });
+  const json = await res.json().catch(() => null);
+  if (!json || json.code !== 200) {
+    throw new Error(`取模型 ${modelId} 失败:${json ? `${json.code} ${json.message}` : res.status}`);
+  }
+  // 防呆:modelId 传错时 DMS 不会报错、只会返回另一个模型,所以校验别名
+  if (json.content.modelAlias !== expectAlias) {
+    throw new Error(
+      `模型 ${modelId} 的别名是「${json.content.modelAlias}」,期望「${expectAlias}」——modelId 传错了吧?`
+    );
+  }
+  const list = JSON.parse(json.content.fieldList);
+  return Object.values(list)
+    .filter((f) => f.name) // 个别条目 name 为空
+    .sort((a, b) => a.sequence - b.sequence);
+};
+
+const fields1888 = await fetchModelFields(DMS_MODEL_ENTERPRISE, '企业基础信息');
+const fields1886 = await fetchModelFields(DMS_MODEL_HONOR, '企业荣誉信息');
+const cols1888 = fields1888.map((f) => f.name);
+const cols1886 = fields1886.map((f) => f.name);
+
+const out = [];
+const line = (s = '') => out.push(s);
+
+line(`载荷:${payloadPath}`);
+line(`DMS 模型:1888 企业基础信息 ${cols1888.length} 字段 / 1886 企业荣誉信息 ${cols1886.length} 字段`);
+line();
+
+line('===== 载荷结构 =====');
+line(`顶层键:${Object.keys(payload).join(', ')}`);
+line(`company:${Object.keys(payload.company || {}).join(', ')}`);
+const profileData = payload.profile?.data || {};
+line(`profile.data(${Object.keys(profileData).length}):${Object.keys(profileData).join(', ')}`);
+line(`profile.source:${Object.keys(payload.profile?.source || {}).join(', ')}`);
+const honorRecordData = payload.honors?.records?.[0]?.data || {};
+line(`honors:${Object.keys(payload.honors || {}).join(', ')}`);
+line(`honors.records[].data:${Object.keys(honorRecordData).join(', ')}`);
+line(`honors.sources[]:${Object.keys(payload.honors?.sources?.[0] || {}).join(', ')}`);
+line();
+
+line('===== ① profile.data → 1888:已写 / 未写 =====');
+const wrote = [];
+const notWrote = [];
+const noColumn = [];
+for (const key of Object.keys(profileData)) {
+  const col = MAP_PROFILE_DATA[key];
+  if (col === undefined) noColumn.push(key);
+  else if (col === null) noColumn.push(key);
+  else if (WE_WRITE_1888.includes(col)) wrote.push([key, col]);
+  else notWrote.push([key, col]);
+}
+line(`【已写入 DMS】${wrote.length} 个:`);
+for (const [k, c] of wrote) line(`   profile.data.${k.padEnd(30)} → ${c}`);
+line(`【未写入(DMS 里已有现成的列,只是同步没写)】${notWrote.length} 个:`);
+for (const [k, c] of notWrote) line(`   profile.data.${k.padEnd(30)} → ${c}`);
+line(`【DMS 没有对应列】${noColumn.length} 个:${noColumn.join(', ') || '(无)'}`);
+line();
+
+line('===== ② company 段 =====');
+for (const key of Object.keys(payload.company || {})) {
+  const col = MAP_COMPANY[key];
+  const state = col === null || col === undefined ? '**DMS 无对应列**' : WE_WRITE_1888.includes(col) ? `已写(${col},走 profile 同名字段)` : `未写(${col})`;
+  line(`   company.${key.padEnd(16)} → ${state}`);
+}
+line();
+
+line('===== ③ honors → 1886 =====');
+line('honors.records[].data:');
+for (const [k, c] of Object.entries(MAP_HONOR_RECORD)) {
+  const ok = c.startsWith('c_honor') ? WE_WRITE_1886.includes('c_honor') : WE_WRITE_1886.includes(c);
+  line(`   ${k.padEnd(16)} → ${c} ${ok ? '【已写】' : '【未写】'}`);
+}
+line(`   honors.complete / error、honors.sources[](${Object.keys(payload.honors?.sources?.[0] || {}).join('/')}):**不写**(只用于判断能否删除)`);
+line();
+
+line('===== ④ profile.source =====');
+line(`   ${Object.keys(payload.profile?.source || {}).join(', ')}`);
+line('   → **全部不写**:1888 没有对应列,属溯源元信息(荣誉那边的 c_source 是"来源通知名",不是这个)');
+line();
+
+line('===== ⑤ 汇总:1888 的 47 列里,我们在写哪些 =====');
+const unused1888 = cols1888.filter((c) => !WE_WRITE_1888.includes(c));
+line(`   在写 ${WE_WRITE_1888.length} 列`);
+line(`   没写 ${unused1888.length} 列:${unused1888.join(', ')}`);
+line();
+
+line('===== ⑥ 汇总:1886 的 13 列 =====');
+const unused1886 = cols1886.filter((c) => !WE_WRITE_1886.includes(c));
+line(`   在写 ${WE_WRITE_1886.length} 列`);
+line(`   没写 ${unused1886.length} 列:${unused1886.join(', ') || '(无)'}`);
+
+const report = out.join('\n');
+console.log(report);
+if (process.env.WRITE_REPORT) {
+  const { writeFileSync } = await import('node:fs');
+  writeFileSync(process.env.WRITE_REPORT, report, 'utf8');
+  console.log(`\n(已写入 ${process.env.WRITE_REPORT})`);
+}