Ver código fonte

feat(company): 工商信息也同步进 DMS 1888(36 个字段映射)

用户看过字段覆盖审计后指示「同步」——把 profile.data 的工商信息也写进去。

- classification-sync-utils:新增 PROFILE_FIELD_MAP(36 项)、buildProfileFields、
  readProfileDataFromCompanyInfo、stableJson;diffEnterpriseRow 对 JSON 列改按语义比较
- company-info-sync:新增入口 applyCompanyProfile(1888 ← company_info.profile.data),
  不依赖分类接口,与荣誉一样在分类之前跑;三个入口共用同一串行链键

三条关键决定:
① 空值一律不写(null/空串/空数组/空对象)——不清存量
② JSON 列按语义比较:库里是 Python/jsonb 序列化(带空格、键按字母序),与 JS 的
   JSON.stringify 字节不同,直接比会每轮误判成「有差异」而反复写 → 用 stableJson 递归排序键
③ 映射表外的字段(company.KeyNo / profile.data.TeamEnd)DMS 无列,忽略

验证:纯逻辑 79/79、真实 DMS 30/30、端到端 24/24(含「语义相同的 JSON 列不被重写」专项)、
回归 43/43 + 12/12 + 33/33 + 10/10;build 通过。
真实数据:宇树科技的 1888 已补齐 36 个工商字段,JSON 列仍保持灌入原格式。
审计工具改为从代码派生写入列(避免清单漂移):1888 写 43/47、1886 写 12/13。

过程中:分类接口当日偶发 500(重试恢复,已记入 notes);e2e 首跑 2 项 FAIL 是我的断言
写太严(按字节比 JSON 列)已改语义比较;补 import 时重复导出导致 esbuild 报错已修。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 7 horas atrás
pai
commit
fec535b516

+ 32 - 6
harness/docs/reference/DMS_COLUMNS.md

@@ -241,19 +241,45 @@
 **工具**:[`../../tools/audit-company-info-coverage.mjs`](../../tools/audit-company-info-coverage.mjs)
 (实时拉模型字段 + 对照真实载荷,改字段后重跑即可;注意它取的是 **modelId** 不是 columnId)
 
-**1888(47 列,前端同步在写 10 列)**
+**1888(47 列,前端同步在写 43 列)**
 
 | 分类 | 数量 | 说明 |
 |---|---|---|
-| 前端同步写入 | **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` 等(**列是现成的,只差映射**) |
+| 前端同步写入 | **43** | ①**工商信息 36 列**(`profile.data` 逐字段映射,见下)②`c_created_at`③六个 `c_tag_*`(分类标签) |
 | 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 列有值的情况只发生在**库里没有、由前端新建**的企业行上。
+**工商信息映射**(`profile.data` → 1888 列,实现在
+`src/network/api/dms/classification-sync-utils.ts` 的 `PROFILE_FIELD_MAP`,2026-09-20 加):
+
+| profile.data | 列 | profile.data | 列 |
+|---|---|---|---|
+| `Name` | `c_name` | `RegisteredCapitalCCY` | `c_registered_capital_ccy` |
+| `CreditCode` | `c_credit_code` | `RecCap` | `c_rec_cap` |
+| `OperName` | `c_oper_name` | `PaidUpCapital` | `c_paid_up_capital` |
+| `No` | `c_no` | `PaidUpCapitalUnit` | `c_paid_up_capital_unit` |
+| `BelongOrg` | `c_belong_org` | `PaidUpCapitalCCY` | `c_paid_up_capital_ccy` |
+| `OperId` | `c_oper_id` | `IsOnStock` | `c_is_on_stock` |
+| `EntType` | `c_ent_type` | `StockNumber` | `c_stock_number` |
+| `EconKind` | `c_econ_kind` | `StockType` | `c_stock_type` |
+| `Status` | `c_status` | `OrgNo` | `c_org_no` |
+| `Province` | `c_province` | `ImageUrl` | `c_image_url` |
+| `AreaCode` | `c_area_code` | `Scope` | `c_scope` |
+| `Address` | `c_address` | `DesignatedRepresentativeList` | `c_designated_representative_list` |
+| `StartDate` | `c_start_date` | `OriginalName` | `c_original_name` |
+| `EndDate` | `c_end_date` | `RevokeInfo` | `c_revoke_info` |
+| `TermStart` | `c_term_start` | `Area` | `c_area` |
+| `TermEnd` | `c_term_end` | `UpdatedDate` | `c_updated_date` |
+| `CheckDate` | `c_check_date` | `RegistCapi` | `c_regist_capi` |
+| `RegisteredCapital` | `c_registered_capital` | `RegisteredCapitalUnit` | `c_registered_capital_unit` |
+
+> ⚠️ **空值一律不写**(`null` / 空串 / 空数组 / 空对象都跳过)——不清存量:
+> 库里灌进来的值不会被一个「本次没带」的字段抹掉。
+> ⚠️ **JSON 列(`c_area` / `c_original_name` / `c_revoke_info` / `c_designated_representative_list`)
+> 按语义比较**:库里是 Python/jsonb 序列化的(带空格、键按字母序),与 JS 的 `JSON.stringify`
+> 输出不同,直接比字符串会每轮都误判成「有差异」而反复写(实现在 `diffEnterpriseRow` +
+> `stableJson`)。
 
 **1886(13 列,前端同步在写 12 列)**
 

Diferenças do arquivo suprimidas por serem muito extensas
+ 9 - 10
harness/feature_list.json


+ 49 - 0
harness/progress.md

@@ -2694,3 +2694,52 @@
     要存得先让后端补(前两个企查查 410 接口没返回)
 - **下一步最佳动作**:若要把工商字段也同步进去,说一声即可(映射 + 对比范围两点确认后开工)
 
+## Session 064
+
+- **日期**:2026-09-20
+- **本轮目标**:用户在上一轮的审计结论后指示「**同步**」——把 `profile.data` 的工商信息也写进 1888
+- **已完成**:
+  - `classification-sync-utils.ts` 新增:`PROFILE_FIELD_MAP`(36 项映射)、`buildProfileFields`、
+    `readProfileDataFromCompanyInfo`、`stableJson`(递归排序键),并让 `diffEnterpriseRow`
+    对 **JSON 列按语义比较**
+  - `company-info-sync.ts` 新增入口 **`applyCompanyProfile`**(1888 ← `company_info.profile.data`):
+    **不依赖分类接口**,与荣誉一样在分类之前跑;三个入口共用同一串行链键,不会并发写同一行
+  - 验证脚本同步扩充:纯逻辑 63→**79** 项、真实 DMS 23→**30** 项、e2e 20→**24** 项
+  - 审计脚本改为**从代码派生写入列**(`Object.values(PROFILE_FIELD_MAP)`),避免清单与 src 漂移
+  - 文档:`DMS_COLUMNS.md` §2.6 的覆盖表重写(含 36 项映射对照表)
+- **本轮的三条关键实现决定**(都写进注释与文档了):
+  1. **空值一律不写**(`null`/空串/空数组/空对象)——**不清存量**:库里灌进来的值
+     不会被「本次没带」的字段抹掉
+  2. **JSON 列按语义比较**:DMS 里 `c_area` / `c_original_name` 等是 Python/jsonb 序列化的
+     (带空格、键按字母序),与 JS 的 `JSON.stringify` 输出字节不同 —— 直接比字符串会**每轮都误判**
+     成「有差异」而反复写。用 `stableJson`(递归排序键)比,**语义相同就不动它**
+  3. 映射表外的字段(`company.KeyNo`、`profile.data.TeamEnd`)**DMS 没有列**,忽略
+- **运行过的验证**:
+  - `npm run build` 通过
+  - **纯逻辑 79/79**、适配层直通 **10/10**、真实 DMS **30/30**、**端到端 24/24**
+  - 回归:DMS 问答记录 **43/43**、载荷字段 **12/12**、聊天任务 **33/33** 全绿
+  - e2e 专测「**语义相同的 JSON 列不被重写**」:先读原值 → 同步 → 逐字节比对未变 ✓
+  - **真实数据**:宇树科技的 1888 已补齐 36 个工商字段(JSON 列仍保持灌入原格式)
+- **过程中的观察与自我修正**:
+  - ⚠️ **分类接口当日偶发 500(21 秒后失败)**,重试即恢复 → 已如实记入 feature notes;
+    这也印证了「把工商/荣誉与分类拆开」的价值:分类挂了它们照常同步
+  - ⚠️ e2e 首跑 2 项 FAIL 是**我的断言写太严**(按字节比 JSON 列,而"不重写"本就是预期行为)
+    → 已改成语义比较。代码本身没问题
+  - ⚠️ 补 import 时我的脚本把 `DMS_MODEL_ENTERPRISE` 在入口里**重复导出**了一次,
+    esbuild 直接报错 → 已修(这类批量改文件的操作,改完要立刻编译验证)
+- **已记录证据**:本文件 Session 064;四个脚本的通过输出;`DMS_COLUMNS.md` §2.6;
+  `audit-company-info-coverage.mjs` 的 43/47 与 12/13
+- **提交记录**:见下条提交(已按约定提交推送)
+- **更新过的文件或工件**:`src/network/api/dms/classification-sync-utils.ts`、
+  `src/network/api/dms/company-info-sync.ts`、`harness/tools/{_entry-company-classify.ts,
+  verify-company-classify-sync.mjs, verify-company-classify-dms.mjs, verify-company-classify-e2e.mjs,
+  audit-company-info-coverage.mjs}`、`harness/docs/reference/DMS_COLUMNS.md`、
+  `harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **未走浏览器**:本轮是后台同步逻辑的扩充(触发链路与上轮相同),但按惯例仍需真人在页面上点一次
+  - ⚠️ **会写真实企业数据**:工商字段现在会被前端同步覆盖(字段级 diff、无差异不写、空值不写)。
+    已用宇树验证;若担心影响 DMS 侧维护的数据,可把 `applyCompanyProfile` 从
+    `runCompanyInfoSync` 里摘掉(一行)
+  - ⚠️ `c_scope`(经营范围)单值可达 1000+ 字符,DMS text 列可存(1889 实测 1105 字)✓
+- **下一步最佳动作**:浏览器实测一次公司类提问;若要继续,见 Session 031 的优先级清单
+

+ 7 - 1
harness/tools/_entry-company-classify.ts

@@ -12,9 +12,11 @@ export {
   HONOR_FIELD_MAP,
   HONOR_NAME_FIELD,
   HONOR_TAG_FIELD,
+  PROFILE_FIELD_MAP,
   buildEnterpriseRow,
   buildHonorKey,
   buildHonorRowContent,
+  buildProfileFields,
   decodeAngleTags,
   diffEnterpriseRow,
   diffHonorRows,
@@ -25,14 +27,17 @@ export {
   normalizeTagSet,
   parseHonorRecords,
   readHonorsFromCompanyInfo,
+  readProfileDataFromCompanyInfo,
   resolveSyncedIdentity,
   sameTagSet,
+  stableJson,
 } from '../../src/network/api/dms/classification-sync-utils';
 
-/** 注入分类结果 / 荣誉列表直接落库(验证脚本用它打真实 DMS,不依赖分类接口) */
+/** 注入分类结果 / 荣誉列表 / 工商信息直接落库(验证脚本用它打真实 DMS,不依赖分类接口) */
 export {
   applyCompanyClassification,
   applyCompanyHonors,
+  applyCompanyProfile,
 } from '../../src/network/api/dms/company-info-sync';
 
 /** 纯函数:base URL → 分类接口地址(两种 VITE_CHAT_API 写法都要对) */
@@ -49,4 +54,5 @@ export {
   addDmsContent,
   deleteDmsContent,
   searchDmsContents,
+  updateDmsContent,
 } from '../../src/network/api/dms/client';

+ 3 - 3
harness/tools/audit-company-info-coverage.mjs

@@ -32,6 +32,7 @@ globalThis.VITE_DMS_API = dmsBase;
 const {
   DMS_MODEL_ENTERPRISE,
   DMS_MODEL_HONOR,
+  PROFILE_FIELD_MAP,
 } = await import('./_company-classify.mjs');
 
 /* ------------------------------------------------------------------ *
@@ -39,9 +40,8 @@ const {
  * 来源: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', // 法人
+  // 工商信息:**直接从代码的映射表派生**,避免这份清单与 src 漂移
+  ...Object.values(PROFILE_FIELD_MAP),
   'c_created_at', // 仅新增时
   // 六个分类角度标签(JSON 数组字符串)
   'c_tag_basic',

+ 47 - 0
harness/tools/verify-company-classify-dms.mjs

@@ -31,14 +31,17 @@ globalThis.VITE_DMS_API = base;
 const {
   applyCompanyClassification,
   applyCompanyHonors,
+  applyCompanyProfile,
   HONOR_NAME_FIELD,
   ANGLE_TO_FIELD,
   DMS_COLUMN_ENTERPRISE,
   DMS_COLUMN_HONOR,
   DMS_MODEL_HONOR,
+  DMS_MODEL_ENTERPRISE,
   addDmsContent,
   deleteDmsContent,
   searchDmsContents,
+  updateDmsContent,
 } = await import('./_company-classify.mjs');
 
 let pass = 0;
@@ -217,6 +220,50 @@ try {
   check('1886 行数未变', (await findHonors()).length === 1);
   await applyCompanyHonors(null, IDENTITY);
   check('honors 缺失/null → 跳过(不清库)', (await findHonors()).length === 1);
+
+  /* ---------------------------------------------------------------- *
+   * ⑦ 工商信息(profile.data → 1888)
+   * ---------------------------------------------------------------- */
+  console.log('\n【7】工商信息同步(不依赖分类接口)');
+  const PROFILE = {
+    Name: TEST_NAME,
+    CreditCode: TEST_CREDIT_CODE,
+    OperName: '测试法人',
+    No: '310118000000001',
+    BelongOrg: '青浦区市场监督管理局',
+    Status: '存续(在营、开业、在册)',
+    Province: 'SH',
+    AreaCode: '310118',
+    Address: '上海市青浦区测试路 1 号',
+    RegistCapi: '1000万元',
+    RegisteredCapital: '1000',
+    IsOnStock: '0',
+    Noop: '映射表里没有的字段',
+    EndDate: '',
+    RevokeInfo: null,
+    DesignatedRepresentativeList: [],
+    OriginalName: [{ Name: '测试曾用名', ChangeDate: '2024-07-22' }],
+    Area: { Province: '上海市', City: '上海市', County: '青浦区' },
+  };
+  await applyCompanyProfile(PROFILE, IDENTITY);
+
+  const p1 = (await findEnterprise())[0] || {};
+  check('标量字段落位', p1.c_status === '存续(在营、开业、在册)' && p1.c_regist_capi === '1000万元' && p1.c_no === '310118000000001' && p1.c_area_code === '310118', p1);
+  check('数组/对象落成 JSON 字符串', JSON.parse(p1.c_original_name || '[]')[0]?.Name === '测试曾用名' && JSON.parse(p1.c_area || '{}').County === '青浦区', { orig: p1.c_original_name, area: p1.c_area });
+  check('空值不写(clearing 风险)', p1.c_end_date === undefined || p1.c_end_date === '', p1.c_end_date);
+  check('映射表外的字段不写', p1.c_noop === undefined && p1.Noop === undefined);
+
+  // ⚠️ 灌入行的 JSON 是 Python/jsonb 序列化的(带空格、键按字母序)——语义相同就不该重写
+  const PY_STYLE_AREA = '{ "City": "上海市", "County": "青浦区", "Province": "上海市" }';
+  const rowBefore = (await findEnterprise())[0];
+  await updateDmsContent(DMS_COLUMN_ENTERPRISE, DMS_MODEL_ENTERPRISE, rowBefore.id, { c_area: PY_STYLE_AREA, c_status: '旧状态' });
+  await applyCompanyProfile({ ...PROFILE, Area: { Province: '上海市', City: '上海市', County: '青浦区' }, Status: '存续(在营、开业、在册)' }, IDENTITY);
+  const p2 = (await findEnterprise())[0] || {};
+  check('**语义相同的 JSON 不被重写**(灌入形态原样保留)', p2.c_area === PY_STYLE_AREA, p2.c_area);
+  check('真变化的文本列照常更新', p2.c_status === '存续(在营、开业、在册)', p2.c_status);
+
+  await applyCompanyProfile(PROFILE, IDENTITY);
+  check('再同步一次:JSON 列仍是灌入形态(不反复写)', (await findEnterprise())[0]?.c_area === PY_STYLE_AREA);
 } finally {
   /* ---------------------------------------------------------------- *
    * 清理

+ 69 - 0
harness/tools/verify-company-classify-e2e.mjs

@@ -40,9 +40,13 @@ const {
   classifyCompany,
   applyCompanyClassification,
   applyCompanyHonors,
+  applyCompanyProfile,
   extractIdentityFromCompanyInfo,
   parseHonorRecords,
   readHonorsFromCompanyInfo,
+  readProfileDataFromCompanyInfo,
+  buildProfileFields,
+  stableJson,
   HONOR_NAME_FIELD,
   ANGLE_TO_FIELD,
   DMS_COLUMN_ENTERPRISE,
@@ -183,12 +187,77 @@ check(
   sampleRecord
 );
 
+/* ---------------------------------------------------------------- *
+ * ②c 工商信息(profile.data → 1888,不依赖分类接口)
+ * ---------------------------------------------------------------- */
+console.log("\n【2c】工商信息同步(数据源 = company_info.profile.data)");
+const profileData = readProfileDataFromCompanyInfo(companyInfo);
+const desiredProfile = buildProfileFields(profileData);
+// 先记下同步前那几列 JSON 的**原始字符串**:语义相同就不该被重写
+const JSON_COLS = ["c_area", "c_original_name", "c_designated_representative_list", "c_revoke_info"];
+const beforeRow = (await findEnterprise())[0] || {};
+const beforeJson = Object.fromEntries(JSON_COLS.map((c) => [c, beforeRow[c]]));
+
+await applyCompanyProfile(profileData, identity);
+const afterRow = (await findEnterprise())[0] || {};
+
+/**
+ * 逐列比对时**不能按字符串比 JSON 列**:库里那几列是 DMS 侧灌进来的(Python/jsonb 序列化,
+ * 带空格、键按字母序),我们按语义比较、不重写,所以字节不同但值相同 —— 这正是期望行为。
+ */
+const sameColumnValue = (col, stored, desired) => {
+  if (!JSON_COLS.includes(col)) return stored === desired;
+  const parse = (v) => {
+    if (typeof v !== "string" || !v.trim()) return null;
+    try {
+      return JSON.parse(v);
+    } catch {
+      return v;
+    }
+  };
+  return stableJson(parse(stored)) === stableJson(parse(desired));
+};
+
+check(
+  "映射出的字段都进了库(JSON 列按语义比)",
+  Object.entries(desiredProfile).every(([col, value]) => sameColumnValue(col, afterRow[col], value)),
+  Object.entries(desiredProfile)
+    .filter(([col, value]) => !sameColumnValue(col, afterRow[col], value))
+    .map(([col, value]) => ({ col, desired: String(value).slice(0, 40), stored: String(afterRow[col]).slice(0, 40) }))
+);
+check(
+  "抽样:经营状态 / 注册地址 / 注册资本 / 经营范围 已写入",
+  afterRow.c_status === profileData.Status &&
+    afterRow.c_address === profileData.Address &&
+    afterRow.c_regist_capi === profileData.RegistCapi &&
+    String(afterRow.c_scope || "").startsWith(String(profileData.Scope || "").slice(0, 20)),
+  { c_status: afterRow.c_status, c_address: afterRow.c_address, c_regist_capi: afterRow.c_regist_capi }
+);
+check(
+  "JSON 列语义正确(解析后按键排序比较,与载荷一致)",
+  (() => {
+    try {
+      return stableJson(JSON.parse(afterRow.c_area)) === stableJson(profileData.Area);
+    } catch {
+      return false;
+    }
+  })(),
+  afterRow.c_area
+);
+check(
+  "**语义相同的 JSON 列被原样保留**(没有被我们重写成另一种格式)",
+  JSON_COLS.every((c) => beforeJson[c] === undefined || afterRow[c] === beforeJson[c]),
+  JSON_COLS.filter((c) => beforeJson[c] !== undefined && afterRow[c] !== beforeJson[c]).map((c) => ({ col: c, before: String(beforeJson[c]).slice(0, 60), after: String(afterRow[c]).slice(0, 60) }))
+);
+console.log(`  映射表覆盖 ${Object.keys(desiredProfile).length} 列(载荷 profile.data 共 ${Object.keys(profileData || {}).length} 个字段)`);
+
 /* ---------------------------------------------------------------- *
  * ③ 幂等:再落一次
  * ---------------------------------------------------------------- */
 console.log("\n【3】同样结果再落一次 → 零新增");
 await applyCompanyClassification(classification, identity);
 await applyCompanyHonors(readHonorsFromCompanyInfo(companyInfo), identity);
+await applyCompanyProfile(profileData, identity);
 const rows2 = await findEnterprise();
 check("1888 仍是一行、uuid 未变", rows2.length === 1 && rows2[0]?.id === row.id, rows2.length);
 const honors2 = await findHonors();

+ 53 - 0
harness/tools/verify-company-classify-sync.mjs

@@ -19,9 +19,11 @@
 import {
   ANGLE_TO_FIELD,
   HONOR_NAME_FIELD,
+  PROFILE_FIELD_MAP,
   buildEnterpriseRow,
   buildHonorKey,
   buildHonorRowContent,
+  buildProfileFields,
   decodeAngleTags,
   diffEnterpriseRow,
   diffHonorRows,
@@ -32,9 +34,11 @@ import {
   normalizeTagSet,
   parseHonorRecords,
   readHonorsFromCompanyInfo,
+  readProfileDataFromCompanyInfo,
   resolveClassifyEndpoint,
   resolveSyncedIdentity,
   sameTagSet,
+  stableJson,
 } from './_company-classify.mjs';
 
 let pass = 0;
@@ -155,6 +159,55 @@ console.log('\n【3】1888 期望行与 diff');
   check('存量缺失 + 期望有 → 补齐', fillGap !== null && fillGap.c_oper_name === '新法人', fillGap);
 }
 
+/* ---------------------------------------------------------------- *
+ * ③b 1888 工商信息(数据源 profile.data)
+ * ---------------------------------------------------------------- */
+console.log('\n【3b】工商信息字段映射与比较');
+{
+  const profileData = {
+    Name: '合成公司',
+    CreditCode: 'CODE-X',
+    OperName: '合成法人',
+    No: '310118000000001',
+    BelongOrg: '青浦区市场监督管理局',
+    Status: '存续(在营、开业、在册)',
+    RegistCapi: '1000万元',
+    RegisteredCapital: '1000',
+    IsOnStock: '0',
+    Scope: '一般项目:技术服务…',
+    // 以下应当被跳过(空值不写)
+    EndDate: '',
+    RevokeInfo: null,
+    DesignatedRepresentativeList: [],
+    StockNumber: undefined,
+    // 数组/对象 → JSON 字符串
+    OriginalName: [{ Name: '曾用名', ChangeDate: '2024-07-22' }],
+    Area: { Province: '上海市', City: '上海市', County: '青浦区' },
+  };
+  const row = buildProfileFields(profileData);
+  check('标量字段按映射落位', row.c_name === '合成公司' && row.c_status === '存续(在营、开业、在册)' && row.c_regist_capi === '1000万元' && row.c_no === '310118000000001', row);
+  check('数组/对象 → JSON 字符串', row.c_original_name === '[{"Name":"曾用名","ChangeDate":"2024-07-22"}]' && JSON.parse(row.c_area).County === '青浦区', row);
+  check('空字符串/null/undefined/空数组 **不写**(不清存量)', !('c_end_date' in row) && !('c_revoke_info' in row) && !('c_designated_representative_list' in row) && !('c_stock_number' in row), row);
+  check('未在映射里的字段被忽略', !('c_industry' in row) && !('KeyNo' in row));
+  check('字段数量 = 映射里有值的那些', Object.keys(row).length === 12, Object.keys(row).length);
+  check('readProfileDataFromCompanyInfo 取 profile.data', readProfileDataFromCompanyInfo({ profile: { data: { Name: 'X' } } })?.Name === 'X' && readProfileDataFromCompanyInfo({}) === null);
+
+  // ⚠️ 关键:DMS 里灌入的 JSON 是 Python/jsonb 序列化的(带空格、键按字母序),
+  //    与 JS 的 JSON.stringify 输出不同 —— 不能因此判成「有差异」而反复写
+  const seededArea = '{"City": "上海市", "County": "青浦区", "Province": "上海市"}';
+  const jsArea = JSON.stringify({ Province: '上海市', City: '上海市', County: '青浦区' });
+  check('与灌入值**语义相同** → 不写(键序/空白不造成 churn)', diffEnterpriseRow({ c_area: seededArea }, { c_area: jsArea }) === null);
+  check('顶层键顺序不同也不写', diffEnterpriseRow({ c_area: '{"Province":"上海市","City":"上海市","County":"青浦区"}' }, { c_area: jsArea }) === null);
+
+  const seededArr = '[{"Name": "旧名", "ChangeDate": "2020-01-01"}]';
+  check('数组内容真的变了 → 写', diffEnterpriseRow({ c_original_name: seededArr }, { c_original_name: row.c_original_name }) !== null);
+  check('数组内容相同(仅格式不同)→ 不写', diffEnterpriseRow({ c_original_name: '[{"Name": "曾用名", "ChangeDate": "2024-07-22"}]' }, { c_original_name: row.c_original_name }) === null);
+
+  check('存量是空串、本次有值 → 写', diffEnterpriseRow({ c_revoke_info: '' }, { c_revoke_info: '{"A":1}' })?.c_revoke_info === '{"A":1}');
+  check('普通文本列语义同前(不同才写)', diffEnterpriseRow({ c_status: '存续' }, { c_status: '存续(在营、开业、在册)' })?.c_status === '存续(在营、开业、在册)' && diffEnterpriseRow({ c_status: '存续' }, { c_status: '存续' }) === null);
+  check('stableJson 递归排序键', stableJson({ b: 1, a: [{ d: 2, c: 3 }] }) === '{"a":[{"c":3,"d":2}],"b":1}');
+}
+
 /* ---------------------------------------------------------------- *
  * ④ 1886 荣誉对齐(数据源 honors.records)
  * ---------------------------------------------------------------- */

+ 137 - 2
src/network/api/dms/classification-sync-utils.ts

@@ -252,6 +252,119 @@ export const buildEnterpriseRow = (
   return row;
 };
 
+/* ------------------------------------------------------------------ *
+ * 1888 工商信息(数据源 = company_info.profile.data)
+ * ------------------------------------------------------------------ */
+
+/**
+ * `profile.data` → 1888 列。
+ *
+ * ⚠️ 这些列**本来就是照着企查查这套字段建的**(DMS 侧从源表 `qcc_enterprise` 灌数据时用的同一套),
+ * 所以是直接对应,没有派生计算。2026-09-20 用户要求把这些字段也同步进来
+ * (此前只写身份三字段 + 分类标签)。
+ */
+export const PROFILE_FIELD_MAP: Record<string, string> = {
+  Name: "c_name",
+  CreditCode: "c_credit_code",
+  OperName: "c_oper_name",
+  No: "c_no",
+  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",
+  ImageUrl: "c_image_url",
+  Scope: "c_scope",
+  DesignatedRepresentativeList: "c_designated_representative_list",
+  OriginalName: "c_original_name",
+  RevokeInfo: "c_revoke_info",
+  Area: "c_area",
+};
+
+/**
+ * 值是「数组/对象」的列:存 JSON 字符串(与 DMS 侧灌入的形态一致——那边是 jsonb 序列化过来的)。
+ * ⚠️ 比较时**不能按字符串比**:Python 的 `json.dumps` 带空格、jsonb 还会按字母序排键,
+ * 与 JS 的 `JSON.stringify` 输出不同,直接比会每轮都判成「有差异」而反复写。
+ */
+const JSON_COLUMNS = new Set([
+  "c_designated_representative_list",
+  "c_original_name",
+  "c_revoke_info",
+  "c_area",
+  "c_industry",
+]);
+
+/** 递归按键排序后序列化,用于 JSON 列的**语义比较**(与键序、空白无关) */
+export const stableJson = (value: unknown): string => {
+  const walk = (node: unknown): unknown => {
+    if (Array.isArray(node)) return node.map(walk);
+    if (node && typeof node === "object") {
+      const source = node as Record<string, unknown>;
+      return Object.keys(source)
+        .sort()
+        .reduce<Record<string, unknown>>((acc, key) => {
+          acc[key] = walk(source[key]);
+          return acc;
+        }, {});
+    }
+    return node;
+  };
+  try {
+    return JSON.stringify(walk(value));
+  } catch {
+    return String(value);
+  }
+};
+
+/** 单个值的归一化:空值一律返回 null(**不写**,也就不清存量) */
+const normalizeProfileValue = (raw: unknown): string | null => {
+  if (raw === null || raw === undefined) return null;
+  if (typeof raw === "string") return pickString(raw);
+  if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
+  if (Array.isArray(raw)) return raw.length ? JSON.stringify(raw) : null; // 空数组:不写
+  if (typeof raw === "object") {
+    return Object.keys(raw as Record<string, unknown>).length ? JSON.stringify(raw) : null;
+  }
+  return null;
+};
+
+/** 组装 1888 的工商信息字段(只含本轮有值的;空值不写) */
+export const buildProfileFields = (profileData: unknown): DmsRow => {
+  const data = asRecord(profileData);
+  const row: DmsRow = {};
+  if (!data) return row;
+  for (const [source, column] of Object.entries(PROFILE_FIELD_MAP)) {
+    const value = normalizeProfileValue(data[source]);
+    if (value !== null) row[column] = value;
+  }
+  return row;
+};
+
+/** 从 `company_info` 里取 `profile.data`(工商信息的来源;不是对象就返回 null) */
+export const readProfileDataFromCompanyInfo = (info: unknown): unknown =>
+  asRecord(asRecord(asRecord(info)?.profile)?.data) ?? null;
+
 /* ------------------------------------------------------------------ *
  * 1888 diff
  * ------------------------------------------------------------------ */
@@ -259,11 +372,27 @@ export const buildEnterpriseRow = (
 /** 标签字段集合(比较方式与身份字段不同:按集合比) */
 const TAG_FIELDS = new Set<string>(Object.values(ANGLE_TO_FIELD));
 
+/** 字符串形式的 JSON 解析回值;空串当作「没有」,解析不了就当普通字符串 */
+const parseIfJson = (raw: unknown): unknown => {
+  if (typeof raw !== "string") return raw;
+  const trimmed = raw.trim();
+  if (!trimmed) return null;
+  try {
+    return JSON.parse(trimmed);
+  } catch {
+    return trimmed;
+  }
+};
+
 /**
  * 期望行 vs 现有行 → 需要写的字段(无变化返回 null,调用方据此跳过 update)。
  *
- * - 标签字段:decode 后按**集合**比较,防 `"[]"` 与 `[]`、顺序不同这类假差异
- * - 身份字段:期望值非空且与现有值逐字不同才写;期望值为空一律不写(不清存量)
+ * 三类字段的比较方式不同:
+ * - **标签字段**(`c_tag_*`):decode 后按**集合**比较,防 `"[]"` 与 `[]`、顺序不同这类假差异
+ * - **JSON 列**(`c_area` / `c_original_name` 等):先解析再**按键排序**比较——
+ *   DMS 里灌入的值是 Python/jsonb 序列化的(带空格、键按字母序),
+ *   与 JS 的 `JSON.stringify` 输出不同,直接比字符串会每轮都误判成「有差异」
+ * - **普通文本列**:期望值非空且与现有值(去空白后)不同才写;期望为空一律不写(**不清存量**)
  */
 export const diffEnterpriseRow = (
   existing: DmsRow | null,
@@ -277,6 +406,12 @@ export const diffEnterpriseRow = (
       }
       continue;
     }
+    if (JSON_COLUMNS.has(field)) {
+      if (stableJson(parseIfJson(existing?.[field])) !== stableJson(parseIfJson(value))) {
+        patch[field] = value;
+      }
+      continue;
+    }
     const next = pickString(value);
     if (!next) continue;
     const current = pickString(existing?.[field]);

+ 38 - 3
src/network/api/dms/company-info-sync.ts

@@ -38,6 +38,7 @@ import { nowDmsTimestamp } from "./chat-sessions-dms";
 import {
   buildEnterpriseRow,
   buildHonorRowContent,
+  buildProfileFields,
   diffEnterpriseRow,
   diffHonorRows,
   extractFailedAngles,
@@ -45,6 +46,7 @@ import {
   isCompanyInfoUsable,
   parseHonorRecords,
   readHonorsFromCompanyInfo,
+  readProfileDataFromCompanyInfo,
   resolveSyncedIdentity,
   type CompanyClassification,
   type CompanyIdentity,
@@ -71,14 +73,21 @@ async function runCompanyInfoSync(
   companyInfo: unknown,
   identity: CompanyIdentity | null
 ): Promise<void> {
-  // ① 荣誉(数据源就是 company_info.honors,不依赖分类接口,先跑——它不需要等 LLM)
+  // ① 工商信息(数据源 company_info.profile.data,不依赖分类接口,先跑——不用等 LLM)
+  try {
+    await applyCompanyProfile(readProfileDataFromCompanyInfo(companyInfo), identity);
+  } catch (err) {
+    console.warn("[company-sync] 工商信息同步异常", err);
+  }
+
+  // ② 荣誉(数据源就是 company_info.honors,同样不依赖分类接口)
   try {
     await applyCompanyHonors(readHonorsFromCompanyInfo(companyInfo), identity);
   } catch (err) {
     console.warn("[company-sync] 荣誉同步异常", err);
   }
 
-  // ② 基础信息(需要分类结果)
+  // ③ 分类标签(需要分类接口结果,最慢的一步,放最后
   try {
     const classification = await classifyCompany(companyInfo);
     if (!classification) return; // classifyCompany 里已经 warn 过
@@ -93,7 +102,33 @@ async function runCompanyInfoSync(
 }
 
 /**
- * 1888:拿到分类结果后落库。**单独导出**是为了让验证脚本能注入假的分类响应
+ * 1888:把 `company_info.profile.data` 的工商信息同步进去(**不依赖分类接口**)。
+ *
+ * 一行一家企业,幂等键 `c_credit_code`;字段级 diff,无差异零写入。
+ * 与下面「分类标签」那个入口共用同一个串行链键,因此对同一行的两次写入不会并发。
+ *
+ * ⚠️ 空值一律不写(`null`/空串/空数组/空对象都跳过)——**不清存量**:
+ * 库里灌进来的值不会被一个「本次没带」的字段抹掉。
+ */
+export const applyCompanyProfile = async (
+  profileData: unknown,
+  identityHint?: CompanyIdentity | null
+): Promise<void> => {
+  const fields = buildProfileFields(profileData);
+  const creditCode =
+    (typeof fields.c_credit_code === "string" ? fields.c_credit_code : null) ??
+    identityHint?.creditCode ??
+    null;
+  if (!creditCode) {
+    // 1888 的幂等键就是它,没有就什么都写不了
+    console.warn("[company-sync] 工商信息里没有统一社会信用代码,跳过 1888 同步");
+    return;
+  }
+  await upsertEnterpriseRow(creditCode, fields);
+};
+
+/**
+ * 1888:拿到分类结果后写分类标签。**单独导出**是为了让验证脚本能注入假的分类响应
  * 直接打真实 DMS(可反复跑,不消耗分类接口的 LLM 调用)。
  */
 export const applyCompanyClassification = async (

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff