Parcourir la source

feat(company): 对话 company_info → 分类接口 → DMS 企业两栏目同步

用户需求:对话 result 事件里 company_info 非空时,调分类接口
POST /api/company/classify,把结果同步到 DMS 的 1888 企业基础信息 与
1886 企业荣誉信息——没有该企业就新增,有就按差异更新;缺的 DMS 字段我来加或列清单。

用户拍板四条:①DMS 读写复用现有路径(不走 121.43.55.7:2101/proxy_dms)
②六个分类角度各存 1888 一个字段 ③1886 一个标签一行 ④1888 对比范围=身份三字段+6 个标签字段

Step-0 探测(两条关键结论):
- DMS 侧 7 个字段**已经加好了**(今天 17:35-17:36):1888 增 6 个 c_tag_*(41→47 字段),
  1886 增 c_tag_name(11→12 字段);must 分别只有 c_credit_code、c_id+c_credit_code
- 🚨 classify 已部署但**对任何带内容的请求体都返回 422**(文档自己的示例、纯 ASCII、
  各种键名全被拒,只有 {} 返回 200,响应头有 x-request-id)——属后端问题,需后端排查

实现:新增纯逻辑层 classification-sync-utils.ts(身份抽取/编解码/diff/集合同步)、
网络层 company-classification.ts、编排层 company-info-sync.ts;协调器新增捕获
company_info(原先 case 'result' 是空分支、数据被丢弃);composable 在 totalResponse
里 fire-and-forget 调用。1886 用 c_source=company_info_sync 标记所有权,只增删自己的行,
迁移数据不碰;资质荣誉角度失败则跳过整个 1886 同步(失败≠没有荣誉)。
顺带把 findRowBy/enqueueWrite 从 chat-sessions-dms 上移到 client 共用。

验证:build 通过、vue-tsc 无新增错误;纯逻辑 53/53;打真实 DMS 23/23(注入假分类响应,
含富文本字段 c_tag_honor 逐字往返、重跑零新增、空集删光我方行而迁移行不动、清理残留 0);
回归 verify-dms-chat-storage 43/43 与 verify-dms-payload-fields 12/12。
⚠️ 真实分类链路与浏览器端到端未验证(阻塞于后端 422)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao il y a 2 jours
Parent
commit
264ad1c7ad

+ 16 - 0
README.md

@@ -237,6 +237,22 @@
   **PC 保持 32px 不变**;**移动端 22px**(`clamp(17px,5.7vw,22px)`,≥386px 的屏恒为 22px,
   更窄的屏按比例收缩以免标题被顶出屏幕)。过程:一度整体 −4px(PC 32→28、移动端 20→16)
   → 用户反馈移动端「太小了」→ 全部还原 → 用户要求移动端调到 22px
+- **企业信息分类同步**(对话带回 `company_info` → 分类接口 → DMS 企业两栏目):
+  - 对话 `result` 事件里的 `company_info` 非空时,回答完成后自动调
+    `POST /api/company/classify`,把六个角度的分类结果落到 DMS:
+    **1888 企业基础信息**(一行一家企业,幂等键 `c_credit_code`,字段级 diff)与
+    **1886 企业荣誉信息**(一个资质荣誉标签一行)
+  - 1886 用 `c_source = company_info_sync` 标记「这行是前端同步写的」,
+    **只增删自己写过的行**,迁移数据一律不碰;「资质荣誉」角度失败时**跳过整个 1886 同步**
+    (失败 ≠ 没有荣誉,拿空集去删会把上一轮写对的标签删光)
+  - 全程 fire-and-forget:任何失败只 warn,不影响聊天;同企业并发走串行链
+  - 适配层新增捕获 `company_info`(原先 `result` 分支是空的、数据被丢弃)
+  - 回归脚本:`verify-company-classify-sync.mjs`(53 项纯逻辑)、
+    `verify-company-classify-dms.mjs`(23 项打真实 DMS)
+  - ⚠️ **分类接口目前对任何带内容的请求体返回 422**(连文档自己的示例也被拒,只有空对象
+    200)——属**后端问题**,已反馈;因此真实分类链路尚未端到端验证,
+    DMS 侧用注入假响应验证通过
+  - DMS 侧字段已就绪(1888 扩到 47 字段、1886 扩到 12 字段,含 `c_tag_*` 分类标签字段)
 - 移除移动端 `@media (max-width: 360px)` 里那条 `.heading { font-size: 28px }`:
   它最初与基础值同值(空操作),09-18 基础值改小后**变成窄屏把标题放大回 28px** 的覆盖,
   而标题是 `white-space: nowrap` —— 360px 屏下标题宽 356px、可用仅 272px,**会溢出屏幕 84px**;

+ 81 - 0
harness/docs/exec-plans/active/company-classify-dms-sync.md

@@ -0,0 +1,81 @@
+# 企业信息分类同步(company_info → classify → DMS 1888/1886)
+
+## 目标
+
+对话返回的 `result` 事件里带 `company_info`(非空)时,自动把该企业的分类结果同步到
+DMS 的企业基础信息(1888)与企业荣誉信息(1886)两个栏目:没有该企业就新增,
+已有就按字段差异更新。全程 fire-and-forget,失败不影响聊天。
+
+依据接口文档:[`../../reference/company-classification.md`](../../reference/company-classification.md)(F062)。
+
+## 现状(2026-09-18 探测所得)
+
+- **classify 接口已部署**(`POST /api/company/classify`,与 `/api/chat` 同一后端)。
+  但 ⚠️ **实测任何带内容的请求体都返回 `422 invalid_request`**——连文档自己的
+  合成示例也被拒;只有空对象 `{}` 返回 200(`company_info_empty`)。
+  详见本文件「Step-0 实测」。
+- **DMS 侧的字段已经加好了**(2026-09-18 17:35–17:36 由 DMS 侧更新模型):
+  - 1888(modelId 2036,**47 字段**)新增 6 个 `c_tag_*`,别名 = 六个角度名;must 只有 `c_credit_code`
+  - 1886(modelId 2032,**12 字段**)新增 `c_tag_name`(别名「荣誉」);must = `c_id` + `c_credit_code`
+  - 所以**不需要再请 DMS 侧加字段**(原计划里那份清单已作废)
+- 前端目前**拿不到 company_info**:`api-chat-coordinator.ts` 的 `case 'result'` 是空分支。
+- `api-chat.md` 里**还没有 company_info 的契约章节**(classify 文档的引用超前);
+  分类文档给出的结构是 `{company, profile:{data, source}, honors}`。
+
+## 已确认的决策(用户 2026-09-18)
+
+1. **DMS 读写复用现有路径**:`/dms-api` 代理 + `src/network/api/dms/client.ts`
+   (「原来怎么用现在就怎么用」),不用 `121.43.55.7:2101/proxy_dms`。
+2. 六个角度各存 1888 一个字段(JSON 数组字符串)。
+3. 1886 一个标签一行,`c_source='company_info_sync'` 作所有权标记。
+4. 1888 对比范围 = 身份三字段(企业名/信用代码/法人)+ 6 个标签字段。
+
+## Step-0 实测(2026-09-18)
+
+| 探测 | 结果 |
+|---|---|
+| classify 空体 `{}` | **200**,返回六角度全空 + `input_warnings:["company_info_empty"]`,不调用 LLM |
+| classify 任何带键的体(文档示例 / ASCII / 中文 / `company:{}` / `profile:{}` / `honors:null` / `company_info:{}` / `Company:{}`) | **全部 422 `invalid_request`**(响应头有 `x-request-id`,可据此找后端日志) |
+| `POST /dms/model/getModelById`(form `modelId=2036`/`2032`) | **200**,拿到完整 fieldList:1888 47 字段 / 1886 12 字段,`c_tag_*` 已存在 |
+| 1888 must | 仅 `c_credit_code` |
+| 1886 must | `c_id` + `c_credit_code` |
+
+> ⚠️ classify 的 422 是**后端问题**(前端已排除编码/引号/键名因素),需后端排查;
+> 在它修好之前,「真实分类响应」这条链路只能靠**注入假响应**验证。
+
+## 步骤
+
+1. 纯函数层 `src/network/api/dms/classification-sync-utils.ts`(身份抽取、编解码、diff、集合同步)
+2. 网络层 `src/network/api/company-classification.ts`(`classifyCompany`)
+3. 编排层 `src/network/api/dms/company-info-sync.ts`(查→分类→写/改,串行链)
+4. 协调器捕获 company_info(3 处)+ composable 挂钩(1 处)
+5. `client.ts` 加 1888/2036、1886/2032 常量;把 `findRowBy`/`enqueueWrite` 从
+   `chat-sessions-dms.ts` 提到 `client.ts` 共用(避免两处 `orderBy` 坑各踩一遍)
+
+## 数据流
+
+```
+result 事件 data.company_info → 协调器暂存 → done → totalResponse 带出
+ → useBusinessAssistantChat 监听器(saveTurnToDms 之后、finishTask 之前)
+ → void syncCompanyInfoFromChat()  → classify → 1888 upsert + 1886 集合同步
+```
+
+## 验证
+
+- 纯函数:`harness/tools/verify-company-classify-sync.mjs`(打包 `_entry-company-classify.ts`)
+- 真实 DMS:`harness/tools/verify-company-classify-dms.mjs`(注入假分类响应走编排层,
+  首写 → 二次改 → 三次清空 → 清理测试数据)
+- 回归:`npm run build`、`vue-tsc --noEmit`、`validate-harness`、既有 tools 脚本
+- 真实 classify:**等后端修好 422 后**补测,届时把真实载荷回填 `api-chat.md`
+
+## 风险 / 未决
+
+- **classify 422 阻塞真实验证**(后端侧);前端实现不受阻
+- `c_tag_honor`(1888)的 `frontType=content`(富文本),其余是 `text`/`varchar` ——
+  写 JSON 数组字符串时**富文本字段可能被转义或清洗**,真实链路验证时要专门核对这一列;
+  若确实被改写,请 DMS 侧把它改成 `text`
+- `company_info` 的真实载荷未知 → 全可选 + `profile.data` 优先回退 `company` 的容错读取
+- 1886 的 `c_id` 必填(整型,源库主键);前端新写行拿不到源库 id,先按 1889 的经验
+  传 `0`(实测自动填),若被 214 拒绝再与 DMS 侧确认取值
+- completed + 空数组 = 覆盖清空(会删光我方 1886 行);若业务要保留历史荣誉,
+  改成「只增不删」即可(一行)

+ 26 - 1
harness/docs/reference/DMS_COLUMNS.md

@@ -81,6 +81,13 @@
 | `c_dead_line` | 截止日期 | **text** | varchar | |
 | `c_certificate_code` | 证书编号 | text | varchar | |
 | `c_created_at` | 创建时间 | **timestamp** | date_time | |
+| `c_tag_name` | 荣誉 | text | text | |
+
+> `c_tag_name` 由**前端的企业信息分类同步**写入(2026-09-18 DMS 侧新增,12 字段):
+> 一行 = 一个资质荣誉标签,同时写 `c_credit_code` / `c_name`,
+> 并用 `c_source = company_info_sync` 标记「这行是前端同步写的」——
+> 同步只增删带这个标记的行,迁移数据一律不碰。
+> 见 [`../exec-plans/active/company-classify-dms-sync.md`](../exec-plans/active/company-classify-dms-sync.md)。
 
 ### 2.3 助手会话 `columnId=1887`
 
@@ -96,7 +103,25 @@
 
 ### 2.4 企业基础信息 `columnId=1888`
 
-41 字段。**主键 `c_credit_code`**(注意:没有 `c_id`)。
+47 字段(原 41 + 2026-09-18 新增的 6 个 `c_tag_*`)。
+**主键 `c_credit_code`**(注意:没有 `c_id`);**must 字段只有 `c_credit_code`**(实测自 `getModelById`)。
+
+**分类标签字段**(前端的企业信息分类同步写入;值是 JSON 数组字符串,空集存 `"[]"`)
+
+| 字段 | 别名 | 类型 | 前端控件 | 必填 |
+|------|------|------|----------|------|
+| `c_tag_basic` | 基本信息 | text | text | |
+| `c_tag_honor` | 资质荣誉 | text | **content(富文本)** | |
+| `c_tag_sector` | 产业信息 | text | text | |
+| `c_tag_operation` | 经营活动 | text | text | |
+| `c_tag_industry` | 行业信息 | text | text | |
+| `c_tag_license` | 许可认证 | text | text | |
+
+> 六个字段与分类接口的六个角度一一对应(字段名映射见
+> `src/network/api/dms/classification-sync-utils.ts` 的 `ANGLE_TO_FIELD`)。
+> ⚠️ **行业信息用 `c_tag_industry`**,别和原有的 `c_industry`(源库行业分类 JSON)混了。
+> ⚠️ `c_tag_honor` 的控件类型是 `content`(富文本),其余是 text——2026-09-18 实测
+> JSON 字符串**逐字往返正常**(见 `verify-company-classify-dms.mjs` 的专项断言)。
 
 **主体字段**
 

+ 2 - 1
harness/docs/reference/DMS_MAPPING.md

@@ -92,7 +92,8 @@
 
 ### 3.4 `qcc_enterprise` → 企业基础信息 (1888)
 
-41 字段。**主键 `credit_code`**(无 `id` 列)。完整字段表由
+41 字段(源表口径;**2026-09-18 起 DMS 模型已扩到 47**——前端分类同步新增了 6 个 `c_tag_*`,
+见 [`DMS_COLUMNS.md`](DMS_COLUMNS.md) §2.4)。**主键 `credit_code`**(无 `id` 列)。完整字段表由
 [`create_dms_columns.py`](F:/yysk/AI_zhaoshang/DMS_Data_Migration/harness/tools/create_dms_columns.py) 的 `ALIAS` 字典定义,摘要:
 
 - 主体:`credit_code` → `c_credit_code`、`name` → `c_name`

+ 11 - 4
harness/docs/reference/README.md

@@ -9,6 +9,7 @@
 |---|---|---|
 | `api-chat.md` | ✅ **现行契约** | 改任何聊天相关代码之前 |
 | `api-chat-fields-zh.md` | ✅ **现行契约** | 需要字段中英对照、状态取值释义时 |
+| `company-classification.md` | ⚠️ **现行契约(后端有实测问题)** | 做企业信息分类同步(company_info → 1888/1886)时 |
 | `legacy/API.md` | 📦 **交接快照**(非规格、不更新) | 只在需要翻"当初调了什么接口"时读 |
 | `DMS_API.md` | 📄 **外部服务契约**(含实测结论) | 要调 DMS(数据管理服务)时 |
 | `DMS_COLUMNS.md` | 📄 **栏目现状** | 查 DMS 五个栏目的 columnId / 字段时 |
@@ -21,11 +22,17 @@
 > 复制进来时没带上这些同伴,**本文档里指向它们的链接已改为绝对路径**。
 > 那边的项目才是 DMS 工作的主场;这里保留它们,是为了让本仓库的问题排查能查到 DMS 契约。
 >
-> **栏目现状**(2026-09-17 更新,原「五个栏目全空」的说法已过期):
-> - **1887 助手会话 / 1889 助手问答记录**:前端已接入(本站的会话、整段对话、反馈都写这里),
+> **栏目现状**(2026-09-18 更新,原「五个栏目全空」的说法已过期):
+> - **1887 助手会话 / 1889 助手问答记录**:前端已接入(会话、整段对话、反馈都写这里),
 >   属于**在用的在线数据源**。见 [`../exec-plans/active/dms-chat-storage.md`](../exec-plans/active/dms-chat-storage.md)
-> - **1885 页面浏览 / 1886 企业荣誉 / 1888 企业基础信息**:仍是空的,
->   F4 数据迁移未开始,查它们只会得到 `code=202 数据不存在`
+>   (注:1887 的写入已于 2026-09-18 按用户指示**停用**,只保留读取与删除清理)
+> - **1886 企业荣誉信息 / 1888 企业基础信息**:**前端已开始写入**(企业信息分类同步,
+>   见 [`company-classification.md`](company-classification.md) 与
+>   [`../exec-plans/active/company-classify-dms-sync.md`](../exec-plans/active/company-classify-dms-sync.md))——
+>   两栏目的模型已分别扩到 12 / 47 个字段(新增的 `c_tag_*` 就是分类标签)。
+>   **F4 数据迁移仍未开始**,所以这两栏目里目前只有前端同步写进去的行,
+>   源库(`qcc_enterprise` 8.1 万行 / `qcc_honor` 7 千行)的行还没迁过来
+> - **1885 页面浏览**:仍是空的(统计埋点已于 2026-09-18 删除,前端不再写)
 
 ## 现行契约:`POST /api/chat`
 

+ 90 - 0
harness/docs/reference/company-classification.md

@@ -0,0 +1,90 @@
+# 公司信息多标签分类接口(F062)
+
+`POST /api/company/classify`,请求和响应均为 `application/json`,返回普通JSON,不使用SSE。
+直接将 `/api/chat` 的 `result` 事件中 `data.company_info` 对象作为整个请求体,不增加外层 `company_info`,不传 `thread_id`。
+
+```javascript
+const response = await fetch('/api/company/classify', {
+  method: 'POST',
+  headers: {'Content-Type': 'application/json'},
+  body: JSON.stringify(chatResult.data.company_info)
+});
+const classification = await response.json();
+```
+
+请求沿用 `company / profile / honors` 全量原始结构,见 [公司信息契约](api-chat.md)。下面仅为合成片段,不限制工商原始字段:
+
+```json
+{
+  "company": {"KeyNo": "synthetic-key", "Name": "合成公司"},
+  "profile": {
+    "data": {"KeyNo": "synthetic-key", "Name": "合成公司", "OperName": "合成法人", "主营": "农产品生产及加工"},
+    "source": {"provider": "synthetic", "fetched_at": "2026-09-18T15:00:00+08:00"}
+  },
+  "honors": {"records": [], "sources": [], "complete": true, "error": null}
+}
+```
+
+返回主要字段示意(`details`、`as_of`、`classification_version` 省略):
+
+```json
+{
+  "company_name": "合成公司",
+  "credit_code": null,
+  "legal_representative": "合成法人",
+  "categories": {
+    "基本信息": [],
+    "资质荣誉": [],
+    "产业信息": ["第一产业", "第二产业", "现代农业"],
+    "经营活动": [],
+    "行业信息": ["涉农(广义)", "农产品生产", "农产品加工"],
+    "许可认证": []
+  },
+  "status": "completed",
+  "errors": {},
+  "input_warnings": []
+}
+```
+
+示意标签不是所有输入的固定输出,实际依据LLM判断。每个角度总是数组,可以多选,信息不足直接为 `[]`,不返回“未知”或“其他”等表外兜底标签。未选中不等于判定“不符合”。
+
+公司名称、统一社会信用代码、法人(法定代表人)分别取 `profile.data.Name/CreditCode/OperName`,缺失才回退 `company` 同名字段,再缺失返回 `null`,不交由LLM生成。`KeyNo/Name/CreditCode` 或来源主体键明确冲突时拒绝,HTTP422;不会合并两家公司。此接口信任调用方提供的资料,不重新查询或认证资料真实性。
+
+允许请求 `{}`(chat未取得公司资料),身份均为null、六角度均为[]、不调用LLM,`input_warnings=["company_info_empty"]`。允许 `honors=null`,提示 `honors_unavailable`。荣誉 `complete=false` 时仍使用已取得记录,提示 `honors_incomplete`,不把查询失败或缺少记录作为否定事实。
+
+`details`按相同六角度返回命中对象数组:`{tag, reason, citations:[{path, quote}]}`。path为请求原始对象的JSON Pointer,quote必须在该字段连续逐字出现;保留reason中的年份和有效期限制。来源可用该路径回溯原请求的profile.source或对应honors.records项的source。程序检查引用真实存在,不能证明LLM语义判断一定正确。
+
+`as_of`为服务器上海日期,供成立年限、上月税务、许可有效期判断;来源获取时间保留在模型输入。`classification_version`覆盖六套提示、公共规则及标签列表的哈希,便于后续评测比较。没有缓存、落库或跨请求画像复用。
+
+| 状态/错误 | 行为 |
+| --- | --- |
+| HTTP200 / completed | 六个角度分析完成,允许全部为空数组 |
+| HTTP200 / partial | 部分角度失败,已成功角度保留;失败角度仍为空数组,必须结合errors判断 |
+| HTTP502 / failed | 全部角度调用或输出校验失败,不能当成没有标签 |
+| errors中的model_call_failed | 对应角度LLM调用失败;不输出异常原文 |
+| errors中的invalid_model_result | 对应角度两次结构/标签/引用验证仍未通过 |
+| HTTP415 / application_json_required | Content-Type错误 |
+| HTTP422 / invalid_request | 请求结构、类型、嵌套深度或公司身份冲突 |
+| HTTP413 / request_too_large | 请求体超过65536字节,不截断资料 |
+| HTTP408 / request_timeout | 请求体读取超过15秒 |
+| HTTP429 / server_busy | 分类工作池已满,Retry-After为2秒 |
+| HTTP504 / classification_timeout | HTTP等待超时,后台尚未退出时继续占用名额 |
+| HTTP503 / service_unavailable | 分类服务不可用 |
+| HTTP502 / classification_failed | 接口内部失败,返回安全错误码 |
+
+六个角度当前依次独立调用LLM,每个角度仅接收对应白名单,其他角度结果不作为事实输入。每角度结构错误最多修正一次,服务错误不自动重试。非空请求正常6次调用,最多12次;空对象0次。按六角度拆分便于以后独立评测、选择模型或有界并行,目前未宣称并行提速或生产吞吐。
+
+分类工作池容量沿用 `API_MAX_CONCURRENT_REQUESTS` 对应的 `settings.max_concurrent`,独立于问答工作池;底层LLM共享现有 `settings.llm_concurrent` 信号量,不扩大同时调用模型上限。HTTP等待沿用 `settings.stream_timeout_seconds`。停止服务时等待分类工作结束再关闭共享运行时。请求正文、模型结果及异常原文不写日志,响应含X-Request-ID。
+
+标签来自 `docs/法人标签表.xlsx`:166行、165个唯一分类标签,基本信息22、资质荣誉83、产业信息26、经营活动11、行业信息10、许可认证13。“产业信息 / 是否落户在综合保税区”重复一次,稳定去重,其余原名保留。运行时使用 `src/company_classification/taxonomy.json` 快照,无新增Excel依赖;测试核对源文件SHA256及全部行,更新xlsx后须同步快照并重新验证。
+
+提示词位于 `src/company_classification/prompts/`,common.md为共享证据与输出规则,另有basic、honors、industry_sector、operations、industry、licenses六份独立判断规则。成立年限区间采用左闭右开,3/6月内和1年内允许重叠;规模、税务、认证等不能仅凭注册资本、注册地址、经营范围推断。
+
+验证:
+
+```powershell
+.\.venv\Scripts\python.exe -m unittest discover -s src/company_classification/tests -v
+.\.venv\Scripts\python.exe -m src.company_classification.smoke_check
+```
+
+第二条会调用已配置的真实模型,仅发送脚本内合成资料,不查询真实公司、不写DMS。启动仍为 `python -m src.step4_api`,需重启加载新接口;整个服务启动仍依赖原有Redis、Embedding等。生产鉴权、真实公司分类质量、大量并发和延迟尚未验收。

Fichier diff supprimé car celui-ci est trop grand
+ 15 - 0
harness/feature_list.json


+ 81 - 0
harness/progress.md

@@ -2432,3 +2432,84 @@
     先收窄两侧留白或允许折行
 - **下一步最佳动作**:无(本轮已定稿);若要继续做别的,见 Session 031 的优先级清单
 
+## Session 061
+
+- **日期**:2026-09-18
+- **本轮目标**:用户给出新接口文档 `reference/company-classification.md` 与业务需求 ——
+  对话返回 `company_info` 非空时调分类接口,把结果同步到 DMS 的 1888(企业基础信息)
+  与 1886(企业荣誉信息):没有就新增、有不一致就更新;DMS 缺的字段我来加或列清单给他
+- **方案**:[`docs/exec-plans/active/company-classify-dms-sync.md`](docs/exec-plans/active/company-classify-dms-sync.md)
+  (计划已落库,含 Step-0 实测与字段清单)
+
+### 用户拍板的四条
+
+①DMS 读写**复用现有路径**(「原来怎么用现在就怎么用」,不走 121.43.55.7:2101/proxy_dms)
+②六个角度各存 1888 一个字段 ③1886 一个标签一行 ④1888 对比范围 = 身份三字段 + 6 个标签字段
+
+### Step-0 探测(本轮最有价值的两条结论)
+
+| 探测 | 结果 |
+|---|---|
+| **DMS 字段** | ✅ **7 个字段已经加好了**(`POST /dms/model/getModelById`,2026-09-18 17:35–17:36 由 DMS 侧更新):1888 增 `c_tag_basic/c_tag_honor/c_tag_sector/c_tag_operation/c_tag_industry/c_tag_license`(模型 41→**47** 字段),1886 增 `c_tag_name`(11→**12** 字段)。**不需要再请人加字段**,原计划那份清单作废 |
+| **模块 must** | 1888 只有 `c_credit_code`;1886 是 `c_id` + `c_credit_code`(都实测自模型定义,不再是猜) |
+| 🚨 **classify 接口** | **已部署,但对任何带内容的请求体返回 422 `invalid_request`** —— 文档自己的合成示例、纯 ASCII 体(排除编码因素)、`company`/`profile`/`honors`/`company_info`/`Company` 各种键名、`{"company":{}}`、`{"company":null}` **全部 422**;只有 `{}` 返回 200(六角度全空 + `company_info_empty`)。响应头有 `x-request-id` 可查后端日志。**属后端问题,需后端排查** |
+
+### 实现(新增 3 个模块 + 6 处改动)
+
+**新增**:
+- `src/network/api/dms/classification-sync-utils.ts` —— **纯逻辑层**(不 import vue、不发网络):
+  身份抽取(profile.data 优先、逐项回退 company)、标签编解码(JSON 数组字符串)、
+  `buildEnterpriseRow`、`diffEnterpriseRow`、`buildHonorTags`、`partitionHonorRows`、`diffHonorRows`、
+  `extractFailedAngles`
+- `src/network/api/company-classification.ts` —— `classifyCompany` + `resolveClassifyEndpoint`
+  (base URL 复用 `getChatApiBaseUrl()`;`VITE_CHAT_API` 的两种写法都处理)
+- `src/network/api/dms/company-info-sync.ts` —— 编排:`syncCompanyInfoFromChat`(入口,fire-and-forget)、
+  `applyCompanyClassification`(**单独导出,供验证脚本注入假分类结果**)、1888 upsert、1886 集合同步
+
+**改**:
+- `api-chat-coordinator.ts` 5 处:`ChatResultPayload`/`ApiChatTotalResponsePayload` 加
+  `company_info`、新增 `pendingCompanyInfo` 实例字段、`case 'result'` 由空分支改为暂存、
+  `resetTurnContent()` 清空、`finishTurn` 的 totalResponse 非空才带出
+- `useBusinessAssistantChat.ts` 1 处:totalResponse 监听器里 `saveTurnToDms` 之后、
+  `finishTask` 之前调 `syncCompanyInfoFromChat`(PC/Mobile 共用,挂一次两端生效)
+- `dms/client.ts`:加 1888/2036、1886/2032 常量;**把 `findRowBy`/`enqueueWrite` 从
+  `chat-sessions-dms.ts` 上移为 `findDmsRowBy`/`enqueueDmsWrite` 共用**(那两处坑不想再踩第二遍)
+
+### 运行过的验证
+
+- `npm run build` 通过;`npx vue-tsc --noEmit` 本次涉及文件**无新增类型错误**
+- **新增 `verify-company-classify-sync.mjs` —— 53/53 通过**(纯逻辑,六节:身份抽取、
+  编解码、期望行与 diff、1886 集合、partial/保守降级、接口地址拼接)
+- **新增 `verify-company-classify-dms.mjs` —— 23/23 通过**(打**真实 DMS**,注入假分类响应):
+  首写两栏目内容正确(**含富文本字段 `c_tag_honor` 的逐字往返断言**——它 frontType 是
+  `content`,我特意单列一条防被清洗)、同数据重跑**零新增**、改标签后 1888 仍一行且已更新、
+  1886 收敛到新集合(旧的删、新的增)、资质荣誉空集**删光我方行而迁移行不动**、
+  缺信用代码整条跳过、清理后两栏目**残留 0 行**
+- **回归**(因上移了两个工具函数):`verify-dms-chat-storage.mjs`(真实 DMS)**43/43**、
+  `verify-dms-payload-fields.mjs` **12/12**,其余 8 个离线脚本全绿
+- `node harness/tools/validate-harness.mjs` 通过(20 项功能,in_progress 1 项)
+
+- **已记录证据**:本文件 Session 061;两个新脚本;`exec-plans/active/company-classify-dms-sync.md`
+  的 Step-0 表;`reference/README.md` 与 `DMS_COLUMNS.md` 的字段更新;`feature_list.json` 的
+  `company-classify-dms-sync`
+- **更新过的文件或工件**:新增 `classification-sync-utils.ts`、`company-classification.ts`、
+  `company-info-sync.ts`、`_entry-company-classify.ts`、两个验证脚本、
+  `exec-plans/active/company-classify-dms-sync.md`;改 `api-chat-coordinator.ts`、
+  `useBusinessAssistantChat.ts`、`dms/client.ts`、`dms/chat-sessions-dms.ts`、
+  `reference/README.md`、`reference/DMS_COLUMNS.md`、`reference/DMS_MAPPING.md`、
+  `tools/README.md`、根 `README.md`、`feature_list.json`、本文件
+  (另:`reference/company-classification.md` 原本 untracked,本次一并入库)
+- **已知风险或未解决问题**(**需后端配合,用户已知悉**):
+  - 🚨 **classify 的 422 阻塞真实链路**:在它修好之前,「真实分类响应」与**浏览器端到端**
+    都无法验证;DMS 侧已用注入假响应验证到位。修好后请重跑:
+    `node harness/tools/verify-company-classify-dms.mjs`,并补浏览器实测
+  - ⚠️ **`company_info` 的真实载荷未知**:`api-chat.md` 里没有它的契约章节(classify 文档的
+    引用超前),前端按「全可选 + profile.data 优先回退 company」容错读取。
+    拿到真实载荷后应把契约回填进 `api-chat.md`
+  - ⚠️ `completed` + 空数组 = 覆盖清空(会删光我方荣誉行):这是「分类结果即最新事实」的默认语义,
+    若业务要保留历史荣誉,改成「只增不删」即可(一行)
+  - ⚠️ 荣誉行超 200 行会截断漏删(低风险)
+  - ⚠️ **未在浏览器实测**:需要在真实对话里走一遍(等后端修 422)
+- **下一步最佳动作**:把 422 反馈给后端;修好后跑一次真实分类 + 浏览器端到端,
+  通过后本条转 `passing`
+

+ 13 - 0
harness/tools/README.md

@@ -48,8 +48,21 @@ npx esbuild harness/tools/_entry-empty-content.ts --bundle --format=esm \
 # 政策库判定与置顶(入口 _entry-policy-library.ts)
 npx esbuild harness/tools/_entry-policy-library.ts --bundle --format=esm \
   --outfile=harness/tools/_policy-library-utils.mjs
+
+# 企业信息分类同步(入口 _entry-company-classify.ts)
+npx esbuild harness/tools/_entry-company-classify.ts --bundle --format=esm \
+  --outfile=harness/tools/_company-classify.mjs --alias:@=./src --define:import.meta.env='{}'
 ```
 
+对应的验证脚本:
+
+| 产物 | 验证脚本 | 打不打真实后端 |
+|---|---|---|
+| `_dms.mjs` | `verify-dms-chat-storage.mjs` | ✅ 真实 DMS(需 dev server 起着) |
+| `_dms.mjs` | `verify-dms-payload-fields.mjs` | ❌ 打桩 fetch 抓请求体 |
+| `_company-classify.mjs` | `verify-company-classify-sync.mjs` | ❌ 纯逻辑 |
+| `_company-classify.mjs` | `verify-company-classify-dms.mjs` | ✅ 真实 DMS(**注入假分类响应**,原因见该脚本头部注释) |
+
 > `--define:import.meta.env='{}'`:验证脚本跑在 node 里没有 `import.meta.env`,
 > 需要喂一个空对象,否则读取环境变量的模块会直接抛错。
 

+ 45 - 0
harness/tools/_entry-company-classify.ts

@@ -0,0 +1,45 @@
+/**
+ * esbuild 入口:企业分类同步的纯逻辑 + 可注入分类结果的落库函数。
+ *
+ * 生成命令(产物不入库):
+ *   npx esbuild harness/tools/_entry-company-classify.ts --bundle --format=esm \
+ *     --outfile=harness/tools/_company-classify.mjs --alias:@=./src \
+ *     --define:import.meta.env='{}'
+ */
+export {
+  ANGLE_TO_FIELD,
+  CLASSIFY_ANGLE_KEYS,
+  HONOR_SYNC_SOURCE,
+  HONOR_TAG_FIELD,
+  buildEnterpriseRow,
+  buildHonorRowContent,
+  buildHonorTags,
+  decodeAngleTags,
+  diffEnterpriseRow,
+  diffHonorRows,
+  encodeAngleTags,
+  extractFailedAngles,
+  extractIdentityFromCompanyInfo,
+  isCompanyInfoUsable,
+  normalizeTagSet,
+  partitionHonorRows,
+  resolveSyncedIdentity,
+  sameTagSet,
+} from '../../src/network/api/dms/classification-sync-utils';
+
+/** 注入分类结果直接落库(验证脚本用它打真实 DMS,绕开后端 classify 的 422 问题) */
+export { applyCompanyClassification } from '../../src/network/api/dms/company-info-sync';
+
+/** 纯函数:base URL → 分类接口地址(两种 VITE_CHAT_API 写法都要对) */
+export { resolveClassifyEndpoint } from '../../src/network/api/company-classification';
+
+/** 打真实 DMS 的验证脚本要用的客户端函数与栏目常量 */
+export {
+  DMS_COLUMN_ENTERPRISE,
+  DMS_COLUMN_HONOR,
+  DMS_MODEL_ENTERPRISE,
+  DMS_MODEL_HONOR,
+  addDmsContent,
+  deleteDmsContent,
+  searchDmsContents,
+} from '../../src/network/api/dms/client';

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

@@ -0,0 +1,221 @@
+/**
+ * 打**真实 DMS**验证企业信息同步的落库行为(栏目 1888 企业基础信息 / 1886 企业荣誉信息)。
+ *
+ * 为什么注入假分类响应:分类接口 `POST /api/company/classify` 目前对**任何带内容的
+ * 请求体都返回 422**(2026-09-18 实测,连文档里的合成示例也被拒,属后端问题),
+ * 所以这一段用 `applyCompanyClassification(分类结果)` 直接验证「拿到结果之后」的落库链路。
+ *
+ * 验证什么:
+ *   ① 首写:1888 出现一行且六个标签字段 = 对应角度的 JSON 数组;1886 按标签各一行
+ *   ② 重跑同样的数据:**零新增**(幂等,不产生重复行)
+ *   ③ 改标签:1888 仍只有一行、字段已更新;1886 集合按增删收敛(旧行被销毁查不到)
+ *   ④ 资质荣誉 = 空集:删光「我方」行,**迁移数据行(c_source 非 company_info_sync)不动**
+ *   ⑤ 清理:按测试信用代码删掉本次产生的全部行,复查残留 0
+ *
+ * 怎么跑(先按 _entry-company-classify.ts 头部注释重新打包):
+ *   1) npm run dev        # 代理负责注入 DMS token
+ *   2) node harness/tools/verify-company-classify-dms.mjs https://localhost:8083/dms-api
+ *
+ * ⚠️ 脚本**不持有 token**(和浏览器一样只打代理地址);测试数据用可识别的信用代码前缀,
+ * 结束时自行清理。
+ */
+
+process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
+
+const base = process.argv[2] || 'https://localhost:8083/dms-api';
+globalThis.VITE_DMS_API = base;
+
+const {
+  applyCompanyClassification,
+  HONOR_SYNC_SOURCE,
+  HONOR_TAG_FIELD,
+  ANGLE_TO_FIELD,
+  DMS_COLUMN_ENTERPRISE,
+  DMS_MODEL_ENTERPRISE,
+  DMS_COLUMN_HONOR,
+  DMS_MODEL_HONOR,
+  addDmsContent,
+  deleteDmsContent,
+  searchDmsContents,
+} = await import('./_company-classify.mjs');
+
+let pass = 0;
+let fail = 0;
+const check = (name, ok, extra = '') => {
+  if (ok) {
+    pass++;
+    console.log(`  ok   ${name}`);
+  } else {
+    fail++;
+    console.log(`  FAIL ${name}${extra === '' ? '' : ` → ${JSON.stringify(extra)}`}`);
+  }
+};
+
+const TEST_CREDIT_CODE = `TEST_CC_${Date.now()}`;
+const TEST_NAME = '分类同步测试公司';
+
+const findEnterprise = async () => {
+  const rows = await searchDmsContents(DMS_COLUMN_ENTERPRISE, {
+    search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT_CODE } }],
+    page: 0,
+    pageSize: 10,
+  });
+  return rows;
+};
+
+const findHonors = async () => {
+  const rows = await searchDmsContents(DMS_COLUMN_HONOR, {
+    search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT_CODE } }],
+    page: 0,
+    pageSize: 200,
+  });
+  return rows;
+};
+
+const classifyWith = (categories, extra = {}) => ({
+  company_name: TEST_NAME,
+  credit_code: TEST_CREDIT_CODE,
+  legal_representative: '测试法人',
+  categories,
+  status: 'completed',
+  errors: {},
+  ...extra,
+});
+
+console.log(`测试信用代码:${TEST_CREDIT_CODE}\n`);
+
+try {
+  /* ---------------------------------------------------------------- *
+   * ① 首写
+   * ---------------------------------------------------------------- */
+  console.log('【1】首写:1888 一行 + 1886 按标签各一行');
+  const first = classifyWith({
+    基本信息: ['小微企业'],
+    资质荣誉: ['高新技术企业', '专精特新'],
+    产业信息: ['第一产业', '现代农业'],
+    经营活动: ['对外投资'],
+    行业信息: ['涉农(广义)'],
+    许可认证: ['食品生产许可'],
+  });
+  await applyCompanyClassification(first);
+
+  const rows1 = await findEnterprise();
+  check('1888 恰好一行', rows1.length === 1, rows1.length);
+  const row1 = rows1[0] || {};
+  check('身份字段正确', row1.c_name === TEST_NAME && row1.c_credit_code === TEST_CREDIT_CODE && row1.c_oper_name === '测试法人', {
+    c_name: row1.c_name,
+    c_credit_code: row1.c_credit_code,
+    c_oper_name: row1.c_oper_name,
+  });
+  check('基本信息标签', row1[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]', row1[ANGLE_TO_FIELD['基本信息']]);
+  check('产业信息标签', row1[ANGLE_TO_FIELD['产业信息']] === '["第一产业","现代农业"]', row1[ANGLE_TO_FIELD['产业信息']]);
+  check('许可认证标签', row1[ANGLE_TO_FIELD['许可认证']] === '["食品生产许可"]', row1[ANGLE_TO_FIELD['许可认证']]);
+  // ⚠️ c_tag_honor 在 DMS 里的 frontType 是 content(富文本),其余是 text/varchar ——
+  // 富文本字段有可能被清洗,所以单独断言它逐字往返
+  check(
+    '资质荣誉标签(富文本字段)逐字往返',
+    row1[ANGLE_TO_FIELD['资质荣誉']] === '["高新技术企业","专精特新"]',
+    row1[ANGLE_TO_FIELD['资质荣誉']]
+  );
+
+  const honors1 = await findHonors();
+  const owned1 = honors1.filter((r) => r.c_source === HONOR_SYNC_SOURCE);
+  const tags1 = owned1.map((r) => r[HONOR_TAG_FIELD]).sort();
+  check('1886 我方行数 = 资质荣誉标签数', owned1.length === 2, owned1.length);
+  check('1886 标签内容正确', JSON.stringify(tags1) === JSON.stringify(['专精特新', '高新技术企业']), tags1);
+  check('1886 行带企业与归属', owned1.every((r) => r.c_credit_code === TEST_CREDIT_CODE && r.c_name === TEST_NAME));
+
+  /* ---------------------------------------------------------------- *
+   * ② 幂等:同数据重跑
+   * ---------------------------------------------------------------- */
+  console.log('\n【2】同数据重跑 → 零新增');
+  await applyCompanyClassification(first);
+  const rows2 = await findEnterprise();
+  const honors2 = await findHonors();
+  check('1888 仍是一行(没有重复)', rows2.length === 1, rows2.length);
+  check('1888 记录 uuid 未变(走的是更新/零写入)', rows2[0]?.id === row1.id, { before: row1.id, after: rows2[0]?.id });
+  check('1886 仍是我方 2 行', honors2.filter((r) => r.c_source === HONOR_SYNC_SOURCE).length === 2, honors2.length);
+
+  /* ---------------------------------------------------------------- *
+   * ③ 改标签
+   * ---------------------------------------------------------------- */
+  console.log('\n【3】改标签 → 1888 更新、1886 增删收敛');
+  const second = classifyWith({
+    基本信息: ['小微企业', '专精特新'],
+    资质荣誉: ['高新技术企业', '科技小巨人'], // 去掉「专精特新」,新增「科技小巨人」
+    产业信息: ['第一产业', '现代农业'],
+    经营活动: ['对外投资'],
+    行业信息: ['涉农(广义)'],
+    许可认证: ['食品生产许可'],
+  });
+  await applyCompanyClassification(second);
+
+  const rows3 = await findEnterprise();
+  check('1888 仍是一行', rows3.length === 1, rows3.length);
+  check('1888 基本信息已更新', rows3[0]?.[ANGLE_TO_FIELD['基本信息']] === '["小微企业","专精特新"]', rows3[0]?.[ANGLE_TO_FIELD['基本信息']]);
+
+  const honors3 = await findHonors();
+  const tags3 = honors3.filter((r) => r.c_source === HONOR_SYNC_SOURCE).map((r) => r[HONOR_TAG_FIELD]).sort();
+  check('1886 收敛到新集合(2 行)', tags3.length === 2, tags3);
+  check('被移除的标签已不在', !tags3.includes('专精特新'), tags3);
+  check('新增的标签已在', tags3.includes('科技小巨人'), tags3);
+
+  /* ---------------------------------------------------------------- *
+   * ④ 空集 + 迁移行保护
+   * ---------------------------------------------------------------- */
+  console.log('\n【4】资质荣誉空集 → 删光我方行;迁移数据行不动');
+  const foreignId = await addDmsContent(DMS_COLUMN_HONOR, DMS_MODEL_HONOR, {
+    c_id: 0,
+    c_credit_code: TEST_CREDIT_CODE,
+    c_name: TEST_NAME,
+    c_source: 'migrate_test', // 模拟迁移数据:不是我们写的
+    [HONOR_TAG_FIELD]: '迁移来的荣誉',
+    c_created_at: '2026-09-18 12:00:00',
+  });
+  check('预置了 1 条迁移行(用于验证不被误删)', !!foreignId, foreignId);
+
+  await applyCompanyClassification(
+    classifyWith({
+      基本信息: ['小微企业', '专精特新'],
+      资质荣誉: [], // completed + 空数组 = 确实没有荣誉
+      产业信息: ['第一产业', '现代农业'],
+      经营活动: ['对外投资'],
+      行业信息: ['涉农(广义)'],
+      许可认证: ['食品生产许可'],
+    })
+  );
+
+  const honors4 = await findHonors();
+  check('我方荣誉行已删光', honors4.filter((r) => r.c_source === HONOR_SYNC_SOURCE).length === 0, honors4.length);
+  check('迁移行仍在(未被误删)', honors4.some((r) => r.id === foreignId && r[HONOR_TAG_FIELD] === '迁移来的荣誉'), honors4.map((r) => r.c_source));
+
+  /* ---------------------------------------------------------------- *
+   * ⑤ 缺失信用代码 → 跳过
+   * ---------------------------------------------------------------- */
+  console.log('\n【5】分类结果缺信用代码 → 整条跳过(不写任何行)');
+  const noCredit = { company_name: '无信用代码的公司', credit_code: null, categories: { 基本信息: ['x'] }, status: 'completed' };
+  await applyCompanyClassification(noCredit);
+  check('1888 行数未变(仍是 1,且没有新建无名行)', (await findEnterprise()).length === 1);
+} finally {
+  /* ---------------------------------------------------------------- *
+   * 清理
+   * ---------------------------------------------------------------- */
+  console.log('\n【清理】');
+  const cleanup = async (columnId, label) => {
+    const rows = columnId === DMS_COLUMN_ENTERPRISE ? await findEnterprise() : await findHonors();
+    for (const row of rows) {
+      if (typeof row.id === 'string') await deleteDmsContent(columnId, row.id);
+    }
+    console.log(`  ${label} 清理 ${rows.length} 行`);
+  };
+  await cleanup(DMS_COLUMN_ENTERPRISE, '1888');
+  await cleanup(DMS_COLUMN_HONOR, '1886');
+
+  const leftE = await findEnterprise();
+  const leftH = await findHonors();
+  check('残留 0 行(1888)', leftE.length === 0, leftE.length);
+  check('残留 0 行(1886)', leftH.length === 0, leftH.length);
+}
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

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

@@ -0,0 +1,232 @@
+/**
+ * 验证企业分类同步的**纯逻辑**(不碰网络、不碰 DMS)。
+ *
+ * 验证什么:
+ *   ① 身份抽取的取值顺序与容错(profile.data 优先、回退 company、非字符串一律不算)
+ *   ② 分类响应 → 1888 期望行(失败角度不写、保守模式只写非空角度)
+ *   ③ 1888 diff:无变化 → null(零写入)、`"[]"` 与 `[]` 不产生假差异、期望为空不清存量
+ *   ④ 1886 集合同步:增/删/改/空集、以及**迁移数据(foreign 行)绝不出现在结果里**
+ *   ⑤ partial / errors 不可解析时的降级语义
+ *   ⑥ 分类接口地址拼接(VITE_CHAT_API 的两种写法)
+ *
+ * 怎么跑(先按 _entry-company-classify.ts 头部注释重新打包):
+ *   node harness/tools/verify-company-classify-sync.mjs
+ */
+
+import {
+  ANGLE_TO_FIELD,
+  HONOR_SYNC_SOURCE,
+  HONOR_TAG_FIELD,
+  buildEnterpriseRow,
+  buildHonorRowContent,
+  buildHonorTags,
+  decodeAngleTags,
+  diffEnterpriseRow,
+  diffHonorRows,
+  encodeAngleTags,
+  extractFailedAngles,
+  extractIdentityFromCompanyInfo,
+  isCompanyInfoUsable,
+  normalizeTagSet,
+  partitionHonorRows,
+  resolveClassifyEndpoint,
+  resolveSyncedIdentity,
+  sameTagSet,
+} from './_company-classify.mjs';
+
+let pass = 0;
+let fail = 0;
+const check = (name, cond, detail) => {
+  if (cond) {
+    pass += 1;
+    console.log(`  ok   ${name}`);
+  } else {
+    fail += 1;
+    console.log(`  FAIL ${name}${detail === undefined ? '' : ` → ${JSON.stringify(detail)}`}`);
+  }
+};
+
+/* ---------------------------------------------------------------- *
+ * ① 身份抽取
+ * ---------------------------------------------------------------- */
+console.log('\n【1】身份抽取');
+{
+  const full = {
+    company: { KeyNo: 'k1', Name: '公司名A', CreditCode: 'CODE-A', OperName: '法人A' },
+    profile: { data: { KeyNo: 'k1', Name: '公司名B', CreditCode: 'CODE-B', OperName: '法人B' } },
+  };
+  const id = extractIdentityFromCompanyInfo(full);
+  check('profile.data 优先于 company', id.name === '公司名B' && id.creditCode === 'CODE-B' && id.legalRep === '法人B', id);
+
+  const companyOnly = { company: { Name: '只有 company', CreditCode: 'CODE-C' } };
+  const id2 = extractIdentityFromCompanyInfo(companyOnly);
+  check('profile 缺失时回退 company(逐项回退)', id2.name === '只有 company' && id2.creditCode === 'CODE-C' && id2.legalRep === null, id2);
+
+  const partial = { company: { Name: 'A' }, profile: { data: { CreditCode: 'ONLY-CC' } } };
+  const id3 = extractIdentityFromCompanyInfo(partial);
+  check('profile 有但缺字段 → 该项回退 company', id3.name === 'A' && id3.creditCode === 'ONLY-CC', id3);
+
+  const dirty = { company: { Name: 123, CreditCode: '  CODE-D  ', OperName: '' } };
+  const id4 = extractIdentityFromCompanyInfo(dirty);
+  check('非字符串/空串不算,字符串去首尾空白', id4.name === null && id4.creditCode === 'CODE-D' && id4.legalRep === null, id4);
+
+  check('全缺 → null', extractIdentityFromCompanyInfo({ company: {}, profile: { data: {} } }) === null);
+  check('null/undefined/{} → null', extractIdentityFromCompanyInfo(null) === null && extractIdentityFromCompanyInfo(undefined) === null && extractIdentityFromCompanyInfo({}) === null);
+  check('数组/字符串 → null', extractIdentityFromCompanyInfo([]) === null && extractIdentityFromCompanyInfo('x') === null);
+  check('isCompanyInfoUsable 与之一致', isCompanyInfoUsable({ company: { Name: 'X' } }) === true && isCompanyInfoUsable({}) === false);
+}
+
+/* ---------------------------------------------------------------- *
+ * ② 标签编解码
+ * ---------------------------------------------------------------- */
+console.log('\n【2】标签编解码');
+{
+  check('encode 数组 → JSON 字符串', encodeAngleTags(['第一产业', '现代农业']) === '["第一产业","现代农业"]');
+  check('encode 空数组 → "[]"', encodeAngleTags([]) === '[]');
+  check('encode null → "[]"', encodeAngleTags(null) === '[]');
+  check('encode undefined → undefined(表示本轮不写)', encodeAngleTags(undefined) === undefined);
+  check('normalize 去空、去重、trim、保序', JSON.stringify(normalizeTagSet([' a ', '', 'b', 'a', null, 3])) === '["a","b"]');
+  check('decode 往返', JSON.stringify(decodeAngleTags(encodeAngleTags(['x', 'y']))) === '["x","y"]');
+  check('decode 非法/非字符串 → []', decodeAngleTags('not json').length === 0 && decodeAngleTags(null).length === 0 && decodeAngleTags('{"a":1}').length === 0);
+  check('sameTagSet 与顺序无关', sameTagSet(['a', 'b'], ['b', 'a']) === true && sameTagSet(['a'], ['a', 'b']) === false);
+}
+
+/* ---------------------------------------------------------------- *
+ * ③ 期望行 + diff
+ * ---------------------------------------------------------------- */
+console.log('\n【3】1888 期望行与 diff');
+{
+  const classification = {
+    company_name: '合成公司',
+    credit_code: 'CODE-X',
+    legal_representative: '合成法人',
+    categories: {
+      基本信息: ['小微企业'],
+      资质荣誉: ['高新技术企业'],
+      产业信息: [],
+      经营活动: [],
+      行业信息: ['涉农(广义)'],
+      许可认证: [],
+    },
+    status: 'completed',
+  };
+  const row = buildEnterpriseRow(classification, { failed: new Set(), conservative: false });
+  check('身份三字段落位', row.c_name === '合成公司' && row.c_credit_code === 'CODE-X' && row.c_oper_name === '合成法人', row);
+  check('六角度各自落位(含空数组 → "[]")', row[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]' && row[ANGLE_TO_FIELD['产业信息']] === '[]' && row[ANGLE_TO_FIELD['许可认证']] === '[]', row);
+  check('行业信息用 c_tag_industry(不占用既有的 c_industry)', row[ANGLE_TO_FIELD['行业信息']] === '["涉农(广义)"]' && row.c_industry === undefined);
+
+  const failedRow = buildEnterpriseRow(classification, { failed: new Set(['产业信息']), conservative: false });
+  check('失败角度不写该字段', failedRow[ANGLE_TO_FIELD['产业信息']] === undefined && failedRow[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]');
+
+  const conservativeRow = buildEnterpriseRow(classification, { failed: new Set(), conservative: true });
+  check('保守模式只写非空角度', conservativeRow[ANGLE_TO_FIELD['行业信息']] === '["涉农(广义)"]' && conservativeRow[ANGLE_TO_FIELD['产业信息']] === undefined);
+
+  const noIdentity = buildEnterpriseRow({ categories: {} }, { failed: new Set(), conservative: false });
+  check('身份缺失时不写身份字段(而不是写 null)', !('c_name' in noIdentity) && !('c_credit_code' in noIdentity) && !('c_oper_name' in noIdentity), noIdentity);
+
+  const hintRow = buildEnterpriseRow({ categories: {} }, { failed: new Set(), conservative: false, identityHint: { name: '回退名', creditCode: 'FALLBACK', legalRep: null } });
+  check('响应缺身份时回退 identityHint', hintRow.c_name === '回退名' && hintRow.c_credit_code === 'FALLBACK', hintRow);
+
+  const synced = resolveSyncedIdentity({ company_name: null, credit_code: 'C1' }, { name: 'N1', creditCode: 'C0', legalRep: 'L1' });
+  check('resolveSyncedIdentity 逐项回退', synced.name === 'N1' && synced.creditCode === 'C1' && synced.legalRep === 'L1', synced);
+
+  // diff
+  const existing = { id: 'uuid-1', c_name: '合成公司', c_credit_code: 'CODE-X', [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' };
+  const same = diffEnterpriseRow(existing, { c_name: '合成公司', c_credit_code: 'CODE-X', [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' });
+  check('全同 → null(零写入)', same === null, same);
+
+  const reordered = diffEnterpriseRow(existing, { [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' });
+  check('集合比较:内容相同不算变化', reordered === null, reordered);
+
+  const changed = diffEnterpriseRow(existing, { c_name: '改名了', [ANGLE_TO_FIELD['基本信息']]: '["小微企业","专精特新"]' });
+  check('变化字段进 patch,未变字段不进', changed.c_name === '改名了' && changed[ANGLE_TO_FIELD['基本信息']] === '["小微企业","专精特新"]' && !('c_credit_code' in changed), changed);
+
+  const desiredEmptyTags = diffEnterpriseRow({ [ANGLE_TO_FIELD['基本信息']]: '["旧标签"]' }, { [ANGLE_TO_FIELD['基本信息']]: '[]' });
+  check('空集是合法目标值(会覆盖旧标签)', desiredEmptyTags !== null && desiredEmptyTags[ANGLE_TO_FIELD['基本信息']] === '[]', desiredEmptyTags);
+
+  const noOverwrite = diffEnterpriseRow({ c_name: '存量名' }, {});
+  check('期望里没有的字段不参与比较、不清存量', noOverwrite === null, noOverwrite);
+
+  const fillGap = diffEnterpriseRow({ c_name: '存量名' }, { c_oper_name: '新法人' });
+  check('存量缺失 + 期望有 → 补齐', fillGap !== null && fillGap.c_oper_name === '新法人', fillGap);
+}
+
+/* ---------------------------------------------------------------- *
+ * ④ 1886 集合同步
+ * ---------------------------------------------------------------- */
+console.log('\n【4】1886 集合同步');
+{
+  const rows = [
+    { id: 'u1', c_source: HONOR_SYNC_SOURCE, [HONOR_TAG_FIELD]: '高新技术企业' },
+    { id: 'u2', c_source: HONOR_SYNC_SOURCE, [HONOR_TAG_FIELD]: '专精特新' },
+    { id: 'm1', c_source: 'migrate', [HONOR_TAG_FIELD]: '迁移来的荣誉' },
+    { id: 'm2', [HONOR_TAG_FIELD]: '没有来源标记' },
+  ];
+  const { owned, foreign, malformed } = partitionHonorRows(rows);
+  check('只认 c_source=company_info_sync 且有 id/标签的行', owned.length === 2 && owned[0].id === 'u1' && owned[1].tag === '专精特新', owned);
+  check('foreign 计数正确(迁移/无标记)', foreign === 2, foreign);
+  check('缺 id 或标签的我方行计入 malformed、不参与增删', (() => {
+    const r = partitionHonorRows([{ id: 'u3', c_source: HONOR_SYNC_SOURCE }]);
+    return r.owned.length === 0 && r.malformed === 1;
+  })());
+
+  const addOnly = diffHonorRows(owned, new Set(['高新技术企业', '专精特新', '新荣誉']));
+  check('纯增', addOnly.toAdd.length === 1 && addOnly.toAdd[0] === '新荣誉' && addOnly.toDelete.length === 0, addOnly);
+
+  const deleteOnly = diffHonorRows(owned, new Set(['高新技术企业']));
+  check('纯删(含待删行的 uuid)', deleteOnly.toAdd.length === 0 && deleteOnly.toDelete.length === 1 && deleteOnly.toDelete[0].id === 'u2', deleteOnly);
+
+  const swapped = diffHonorRows(owned, new Set(['高新技术企业', '新荣誉']));
+  check('改 = 删 + 增', swapped.toAdd.length === 1 && swapped.toAdd[0] === '新荣誉' && swapped.toDelete.length === 1 && swapped.toDelete[0].tag === '专精特新', swapped);
+
+  const noChange = diffHonorRows(owned, new Set(['专精特新', '高新技术企业']));
+  check('集合相同(顺序不同)→ 零写入', noChange.toAdd.length === 0 && noChange.toDelete.length === 0, noChange);
+
+  const clearAll = diffHonorRows(owned, new Set());
+  check('空目标集 → 删光我方行', clearAll.toDelete.length === 2 && clearAll.toAdd.length === 0, clearAll);
+
+  check('foreign 行不会出现在任何结果里', [...clearAll.toDelete].every((r) => r.id !== 'm1' && r.id !== 'm2'));
+
+  const content = buildHonorRowContent('CODE-X', '合成公司', '高新技术企业', '2026-09-18 18:00:00');
+  check('荣誉行内容:必填齐全 + 所有权标记', content.c_credit_code === 'CODE-X' && content[HONOR_TAG_FIELD] === '高新技术企业' && content.c_source === HONOR_SYNC_SOURCE && content.c_name === '合成公司' && content.c_id === 0, content);
+}
+
+/* ---------------------------------------------------------------- *
+ * ⑤ partial / errors 语义
+ * ---------------------------------------------------------------- */
+console.log('\n【5】失败角度与保守降级');
+{
+  const partial = { status: 'partial', errors: { 产业信息: 'model_call_failed' }, categories: { 基本信息: ['a'] } };
+  const r1 = extractFailedAngles(partial);
+  check('errors 键 = 角度名 → 识别为失败角度', r1.failed.has('产业信息') && r1.unknown === false, [...r1.failed]);
+
+  const weird = { status: 'partial', errors: { something_else: 'x' }, categories: {} };
+  const r2 = extractFailedAngles(weird);
+  check('errors 键对不上角度 → unknown 置位', r2.unknown === true, r2);
+
+  const partialNoErrors = { status: 'partial', categories: {} };
+  check('partial 但无 errors → unknown(不知道哪些可信)', extractFailedAngles(partialNoErrors).unknown === true);
+
+  const completed = { status: 'completed', errors: {}, categories: {} };
+  check('completed + 空 errors → 不降级', extractFailedAngles(completed).unknown === false);
+
+  check('资质荣誉失败 → 跳过整个 1886(null)', buildHonorTags({ categories: { 资质荣誉: ['x'] } }, { failed: new Set(['资质荣誉']), conservative: false }) === null);
+  check('保守模式 → 跳过 1886(null)', buildHonorTags({ categories: { 资质荣誉: ['x'] } }, { failed: new Set(), conservative: true }) === null);
+  check('completed + 空数组 → 空集(合法,会删光我方行)', JSON.stringify(buildHonorTags({ categories: { 资质荣誉: [] } }, { failed: new Set(), conservative: false })) === '[]');
+  check('正常 → 标签数组', JSON.stringify(buildHonorTags({ categories: { 资质荣誉: ['a', 'b'] } }, { failed: new Set(), conservative: false })) === '["a","b"]');
+}
+
+/* ---------------------------------------------------------------- *
+ * ⑥ 分类接口地址
+ * ---------------------------------------------------------------- */
+console.log('\n【6】分类接口地址拼接');
+{
+  check('服务前缀写法', resolveClassifyEndpoint('http://192.168.2.23:8000') === 'http://192.168.2.23:8000/api/company/classify');
+  check('dev 代理前缀写法', resolveClassifyEndpoint('/chat-api') === '/chat-api/api/company/classify');
+  check('完整聊天接口写法(先剥 /api/chat)', resolveClassifyEndpoint('http://x:8000/api/chat') === 'http://x:8000/api/company/classify');
+  check('末尾斜杠容错', resolveClassifyEndpoint('http://x:8000///') === 'http://x:8000/api/company/classify');
+  check('空 → 空串(调用方会跳过)', resolveClassifyEndpoint('') === '' && resolveClassifyEndpoint(undefined) === '');
+}
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 15 - 1
src/components/api-chat-coordinator.ts

@@ -155,6 +155,12 @@ export interface ChatResultPayload {
   response?: string;
   recommendation?: unknown;
   errors?: unknown[];
+  /**
+   * 企业资料(company / profile / honors 的原始结构)。
+   * 协调器**不解析**它,只在 done 时原样交给应用侧——拿到后由
+   * `syncCompanyInfoFromChat` 去调分类接口、落 DMS(见 company-info-sync.ts)。
+   */
+  company_info?: unknown;
 }
 
 export interface ChatDonePayload {
@@ -770,6 +776,8 @@ export class SseStreamParser {
 export interface ApiChatTotalResponsePayload {
   question: string;
   answer: string;
+  /** 本轮 `result` 事件带回的企业资料(没有则不带这个键) */
+  company_info?: unknown;
 }
 
 export type ApiChatEventMap = {
@@ -810,6 +818,7 @@ export class ApiChatCoordinator {
   private pendingItems: ChatItemPayload[] = [];
   private pendingSummary: ChatSummaryPayload | null = null;
   private pendingInterrupt: ChatInterruptPayload | null = null;
+  private pendingCompanyInfo: unknown = null;
   /** 正文是否已输出(输出后不再发送 scope 进度卡片) */
   private contentFlushed = false;
   /** 最近一次补问的原始数据,供提交时反查候选用 */
@@ -1003,6 +1012,7 @@ export class ApiChatCoordinator {
     this.pendingItems = [];
     this.pendingSummary = null;
     this.pendingInterrupt = null;
+    this.pendingCompanyInfo = null;
     this.contentFlushed = false;
     this.scopeEmitted = false;
     this.doneReceived = false;
@@ -1163,8 +1173,10 @@ export class ApiChatCoordinator {
         this.lastInterruptPayload = data as ChatInterruptPayload;
         break;
 
-      // result.response 与 answer.text 相同,是最终快照,不再输出
+      // result.response 与 answer.text 相同,是最终快照,不再输出;
+      // 但 company_info 只有这里有,暂存下来等 done 时随 totalResponse 交给应用侧
       case 'result':
+        this.pendingCompanyInfo = (data as ChatResultPayload).company_info ?? null;
         break;
 
       case 'done': {
@@ -1210,6 +1222,8 @@ export class ApiChatCoordinator {
       this._eventbus.emit('totalResponse', {
         question: this.question,
         answer: this.totalResponse,
+        // 只有拿到了才带这个键(用户主动停止走 silent 分支,本来就不同步)
+        ...(this.pendingCompanyInfo != null ? { company_info: this.pendingCompanyInfo } : {}),
       });
     }
     this._eventbus.emit('loadingState', { type: 'loading', state: false });

+ 5 - 0
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -35,6 +35,7 @@ import { getChatHistoryStorageKey, enterpriseInfo } from '@/components/useEnterp
 import { fetchRemoteSessionRecords, deleteSessionInfo, type RemoteSession } from '@/network/api/chat-sessions';
 // [已停用] updateSessionInfo 随 1887 会话写入一并停用
 import { upsertDmsRecord } from '@/network/api/dms/chat-sessions-dms';
+import { syncCompanyInfoFromChat } from '@/network/api/dms/company-info-sync';
 // [已停用] upsertDmsSession 随 1887 会话写入一并停用
 
 interface UseBusinessAssistantChatOptions {
@@ -336,6 +337,10 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       //   syncSessionTitleToServer(session.id, session.title || '');
       // }
 
+      // 本轮若带回了企业资料 → 调分类接口并把结果落 DMS 企业栏目。
+      // fire-and-forget:内部全程 try/catch + 只 warn,不阻塞也不影响聊天
+      if (payload?.company_info) syncCompanyInfoFromChat(payload.company_info);
+
       finishTask(task);
     });
 

+ 79 - 0
src/network/api/company-classification.ts

@@ -0,0 +1,79 @@
+/**
+ * 公司多标签分类接口(F062):`POST /api/company/classify`。
+ *
+ * 契约见 `harness/docs/reference/company-classification.md`:
+ * - 请求体**就是** `/api/chat` 的 `result` 事件里的 `data.company_info` 整个对象,
+ *   不包外层、不传 thread_id
+ * - 返回普通 JSON(不是 SSE);六个角度见 `CLASSIFY_ANGLE_KEYS`
+ * - 它是**聊天后端**的接口(与 `/api/chat` 同一个服务),所以 base URL 用
+ *   `getChatApiBaseUrl()`(dev 经 `/chat-api` 代理;生产与聊天同一条路)
+ *
+ * 与 DMS 客户端同一约定:**不抛异常**,失败 `console.warn` 后返回 null,
+ * 调用方(对话完成后的同步)是 fire-and-forget,分类失败不能影响聊天。
+ */
+
+import { getChatApiBaseUrl } from "@/utils/runtime-config";
+import type { CompanyClassification } from "./dms/classification-sync-utils";
+
+export const CLASSIFY_ENDPOINT_PATH = "/api/company/classify";
+/** 聊天接口自己的路径;base URL 若已经是完整接口地址,要先剥掉它 */
+const CHAT_ENDPOINT_SUFFIX = "/api/chat";
+
+/**
+ * 拼分类接口地址。`VITE_CHAT_API` 允许两种写法(见 `resolveChatEndpoint`):
+ * - 服务前缀:`http://192.168.2.23:8000` 或 `/chat-api` → 追加 `/api/company/classify`
+ * - 完整聊天接口:`.../api/chat` → 先剥掉再追加
+ */
+export const resolveClassifyEndpoint = (baseUrl?: string): string => {
+  const trimmed = String(baseUrl ?? "")
+    .trim()
+    .replace(/\/+$/, "");
+  if (!trimmed) return "";
+  const serviceBase = trimmed.toLowerCase().endsWith(CHAT_ENDPOINT_SUFFIX)
+    ? trimmed.slice(0, -CHAT_ENDPOINT_SUFFIX.length)
+    : trimmed;
+  return `${serviceBase}${CLASSIFY_ENDPOINT_PATH}`;
+};
+
+/**
+ * 调分类接口。成功返回响应对象;任何失败(无 base、非 2xx、超时、非 JSON)返回 null。
+ *
+ * ⚠️ 请求体原样透传 `companyInfo`:**不要**再包一层、也不要塞 thread_id
+ * (后端按「整个请求体就是 company_info」解析)。
+ */
+export const classifyCompany = async (
+  companyInfo: unknown
+): Promise<CompanyClassification | null> => {
+  const endpoint = resolveClassifyEndpoint(getChatApiBaseUrl());
+  if (!endpoint) {
+    console.warn("[classify] 未配置 VITE_CHAT_API,跳过分类请求");
+    return null;
+  }
+
+  try {
+    const response = await fetch(endpoint, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(companyInfo ?? {}),
+    });
+
+    if (!response.ok) {
+      // 后端在错误时可能回 {"error":"..."},带上便于排查(422 的语义见接口文档)
+      const detail = await response.text().catch(() => "");
+      console.warn(
+        `[classify] 分类接口返回 ${response.status}${detail ? `:${detail.slice(0, 200)}` : ""}`
+      );
+      return null;
+    }
+
+    const data = (await response.json()) as CompanyClassification;
+    if (!data || typeof data !== "object") {
+      console.warn("[classify] 分类接口返回的不是对象,已忽略");
+      return null;
+    }
+    return data;
+  } catch (err) {
+    console.warn("[classify] 分类请求失败", err);
+    return null;
+  }
+};

+ 13 - 43
src/network/api/dms/chat-sessions-dms.ts

@@ -27,6 +27,8 @@ import {
   deleteDmsContent,
   searchDmsContents,
   updateDmsContent,
+  enqueueDmsWrite,
+  findDmsRowBy,
   type DmsRow,
 } from "./client";
 import { enterpriseInfo } from "@/components/useEnterpriseAuth";
@@ -127,41 +129,9 @@ export const parseDmsTimestamp = (value: unknown): number => {
   return Date.now();
 };
 
-/**
- * 同一幂等键的写操作串行化。
- * 避免「提交写 question」与「完成补 answer」两次调用并发反查、各自 add 出重复行。
- */
-const writeChains = new Map<string, Promise<unknown>>();
-
-const enqueueWrite = <T>(key: string, job: () => Promise<T>): Promise<T> => {
-  const prev = writeChains.get(key) || Promise.resolve();
-  const next = prev.then(job, job); // 前一个失败也要继续跑
-  writeChains.set(key, next);
-  void next.finally(() => {
-    if (writeChains.get(key) === next) writeChains.delete(key);
-  });
-  return next;
-};
-
-/**
- * 按字段精确查一行。
- *
- * ⚠️ **不要加 orderBy**:两个栏目的字段并不相同(1889 没有 `c_updated_at`),
- * 按不存在的字段排序会让 DMS 直接报错、查询恒空 —— 后果是 upsert 每次都走「新增」,
- * 同一问答被写成多行(踩过,见 verify-dms-chat-storage.mjs 的回归断言)。
- */
-const findRowBy = async (
-  columnId: number,
-  field: string,
-  value: string
-): Promise<DmsRow | null> => {
-  const rows = await searchDmsContents(columnId, {
-    search: [{ field, searchType: 1, content: { value } }],
-    page: 0,
-    pageSize: 10,
-  });
-  return rows.length ? rows[0] : null;
-};
+// `enqueueWrite` / `findRowBy` 两个工具原先只写在本文件里,
+// 2026-09-18 企业信息同步也要用(且「不要加 orderBy」那条坑不想再踩一遍),
+// 已上移到 ./client 共用,名字加 Dms 前缀:`enqueueDmsWrite` / `findDmsRowBy`。
 
 /**
  * 会话 upsert(栏目 1887,幂等键 `c_session_id`)。
@@ -172,7 +142,7 @@ export const upsertDmsSession = (params: {
   title: string;
   creditCode?: string;
 }): Promise<boolean> =>
-  enqueueWrite(`session:${params.sessionId}`, async () => {
+  enqueueDmsWrite(`session:${params.sessionId}`, async () => {
     const content: DmsRow = {
       c_credit_code: params.creditCode || resolveDmsCreditCode(),
       c_session_id: params.sessionId,
@@ -185,7 +155,7 @@ export const upsertDmsSession = (params: {
       c_updated_at: nowDmsTimestamp(),
     };
 
-    const existing = await findRowBy(DMS_COLUMN_SESSION, "c_session_id", params.sessionId);
+    const existing = await findDmsRowBy(DMS_COLUMN_SESSION, "c_session_id", params.sessionId);
     if (!existing) {
       content.c_created_at = nowDmsTimestamp();
       // 必填的运营字段:只在新增时带空值(见 buildRequiredSessionFields 的说明)
@@ -222,7 +192,7 @@ export const upsertDmsRecord = (params: {
   question?: string;
   answer?: string;
 }): Promise<boolean> =>
-  enqueueWrite(`record:${params.recordId}`, async () => {
+  enqueueDmsWrite(`record:${params.recordId}`, async () => {
     const base: DmsRow = {
       c_credit_code: params.creditCode || resolveDmsCreditCode(),
       c_session_id: params.sessionId,
@@ -230,7 +200,7 @@ export const upsertDmsRecord = (params: {
       c_source: resolveSource(),
     };
 
-    const existing = await findRowBy(DMS_COLUMN_RECORD, "c_record_id", params.recordId);
+    const existing = await findDmsRowBy(DMS_COLUMN_RECORD, "c_record_id", params.recordId);
     if (!existing) {
       const content: DmsRow = { ...base };
       if (params.question !== undefined) {
@@ -306,7 +276,7 @@ export const fetchDmsSessionRecords = async (
 
 /** 删除会话:会话行 + 它名下的全部问答记录(DMS 的删除是置 state=4) */
 export const deleteDmsSession = (sessionId: string): Promise<boolean> =>
-  enqueueWrite(`session:${sessionId}`, async () => {
+  enqueueDmsWrite(`session:${sessionId}`, async () => {
     const rows = await searchDmsContents(DMS_COLUMN_RECORD, {
       search: [{ field: "c_session_id", searchType: 1, content: { value: sessionId } }],
       page: 0,
@@ -316,7 +286,7 @@ export const deleteDmsSession = (sessionId: string): Promise<boolean> =>
       if (row.id) await deleteDmsContent(DMS_COLUMN_RECORD, String(row.id));
     }
 
-    const sessionRow = await findRowBy(DMS_COLUMN_SESSION, "c_session_id", sessionId);
+    const sessionRow = await findDmsRowBy(DMS_COLUMN_SESSION, "c_session_id", sessionId);
     if (!sessionRow?.id) return true; // 本就不存在,视为已删
     return deleteDmsContent(DMS_COLUMN_SESSION, String(sessionRow.id));
   });
@@ -333,8 +303,8 @@ export const writeDmsFeedback = (params: {
   option?: string;
   remark?: string;
 }): Promise<boolean> =>
-  enqueueWrite(`record:${params.recordId}`, async () => {
-    const existing = await findRowBy(DMS_COLUMN_RECORD, "c_record_id", params.recordId);
+  enqueueDmsWrite(`record:${params.recordId}`, async () => {
+    const existing = await findDmsRowBy(DMS_COLUMN_RECORD, "c_record_id", params.recordId);
     if (!existing?.id) {
       // 例如从本地历史里点的赞,那一轮还没写进 DMS
       console.warn("[dms] 反馈未落库:DMS 里没有这条问答记录", params.recordId);

+ 360 - 0
src/network/api/dms/classification-sync-utils.ts

@@ -0,0 +1,360 @@
+/**
+ * 企业分类同步的**纯逻辑层**。
+ *
+ * 背景:对话 `result` 事件带回 `company_info` 时,前端调后端分类接口
+ * (`POST /api/company/classify`),把结果落到 DMS 两个企业栏目:
+ *   - **1888 企业基础信息**:一行一家企业(幂等键 `c_credit_code`);六个分类角度
+ *     各存一个字段(`c_tag_*`),值是 JSON 数组字符串(空数组存 `"[]"`)
+ *   - **1886 企业荣誉信息**:**一个资质荣誉标签一行**,用
+ *     `c_source = company_info_sync` 标记「这行是本同步写的」,只增删这些行,
+ *     迁移数据或其它来源的行一律不碰
+ *
+ * 本文件只放类型与纯函数:**不 import vue、不发网络请求**(只 `import type`),
+ * 这样 harness 的 esbuild 脚本能直接打包它做断言。
+ * 契约见 `harness/docs/reference/company-classification.md` 与 `DMS_COLUMNS.md`。
+ */
+
+import type { DmsRow } from "./client";
+
+/** 分类接口的六个角度(响应里就是这六个中文键) */
+export const CLASSIFY_ANGLE_KEYS = [
+  "基本信息",
+  "资质荣誉",
+  "产业信息",
+  "经营活动",
+  "行业信息",
+  "许可认证",
+] as const;
+
+export type ClassifyAngle = (typeof CLASSIFY_ANGLE_KEYS)[number];
+
+/**
+ * 角度 → 1888 的字段名。
+ * ⚠️ 行业信息用 `c_tag_industry`:1888 里**已经有一个 `c_industry`**(源库的行业分类
+ * JSON),不能占用那个名字。
+ */
+export const ANGLE_TO_FIELD: Record<ClassifyAngle, string> = {
+  基本信息: "c_tag_basic",
+  资质荣誉: "c_tag_honor",
+  产业信息: "c_tag_sector",
+  经营活动: "c_tag_operation",
+  行业信息: "c_tag_industry",
+  许可认证: "c_tag_license",
+};
+
+/** 1886 里存标签文本的字段(DMS 侧 2026-09-18 新增,别名「荣誉」) */
+export const HONOR_TAG_FIELD = "c_tag_name";
+
+/** 1886 的所有权标记:只有 `c_source` 等于它、且 `c_credit_code` 匹配的行才归本同步管 */
+export const HONOR_SYNC_SOURCE = "company_info_sync";
+
+/** 从 company_info 里抽出来的身份三字段(都可能是 null) */
+export interface CompanyIdentity {
+  name: string | null;
+  creditCode: string | null;
+  legalRep: string | null;
+}
+
+/** 分类接口响应(全字段可选:后端版本可能增减,读取一律容错) */
+export interface CompanyClassification {
+  company_name?: string | null;
+  credit_code?: string | null;
+  legal_representative?: string | null;
+  /** 角度 → 标签数组;缺失或失败的角度按文档是空数组 */
+  categories?: Record<string, unknown> | null;
+  status?: string | null;
+  errors?: unknown;
+  input_warnings?: unknown;
+}
+
+/* ------------------------------------------------------------------ *
+ * 基础工具
+ * ------------------------------------------------------------------ */
+
+/** 只认非空字符串(去空白后);其它类型(数字/对象/null)一律当作没有 */
+const pickString = (value: unknown): string | null => {
+  if (typeof value !== "string") return null;
+  const s = value.trim();
+  return s ? s : null;
+};
+
+/** 只认普通对象(数组/null 不算) */
+const asRecord = (value: unknown): Record<string, unknown> | null =>
+  value && typeof value === "object" && !Array.isArray(value)
+    ? (value as Record<string, unknown>)
+    : null;
+
+/* ------------------------------------------------------------------ *
+ * 身份
+ * ------------------------------------------------------------------ */
+
+/**
+ * 从 `company_info` 抽身份三字段。
+ *
+ * 取值顺序与后端一致(见 classify 文档):`profile.data.Name/CreditCode/OperName` 优先,
+ * 逐项回退 `company` 的同名字段;都没有则返回 null(调用方应跳过整条同步——
+ * 没有统一社会信用代码就既不能查也不能写 1888)。
+ */
+export const extractIdentityFromCompanyInfo = (info: unknown): CompanyIdentity | null => {
+  const root = asRecord(info);
+  if (!root) return null;
+  const company = asRecord(root.company);
+  const profile = asRecord(root.profile);
+  const profileData = profile ? asRecord(profile.data) : null;
+  const pick = (key: string) => pickString(profileData?.[key]) ?? pickString(company?.[key]);
+
+  const identity: CompanyIdentity = {
+    name: pick("Name"),
+    creditCode: pick("CreditCode"),
+    legalRep: pick("OperName"),
+  };
+  if (!identity.name && !identity.creditCode && !identity.legalRep) return null;
+  return identity;
+};
+
+/** 有任何一个可取字段才值得发分类请求(多数轮没有 company_info,属常态) */
+export const isCompanyInfoUsable = (info: unknown): boolean =>
+  extractIdentityFromCompanyInfo(info) !== null;
+
+/* ------------------------------------------------------------------ *
+ * 标签编解码
+ * ------------------------------------------------------------------ */
+
+/** 归一化标签:只留非空字符串、去首尾空白、去重(保持首次出现顺序) */
+export const normalizeTagSet = (tags: unknown): string[] => {
+  if (!Array.isArray(tags)) return [];
+  const out: string[] = [];
+  const seen = new Set<string>();
+  for (const raw of tags) {
+    const tag = pickString(raw);
+    if (!tag || seen.has(tag)) continue;
+    seen.add(tag);
+    out.push(tag);
+  }
+  return out;
+};
+
+/**
+ * 编码成写库用的字符串。
+ * `undefined` → `undefined`(表示「这个字段本轮不写」);
+ * 其它(含 null / 非法)→ 归一化后 JSON,空集是 `"[]"`。
+ */
+export const encodeAngleTags = (tags: unknown): string | undefined => {
+  if (tags === undefined) return undefined;
+  return JSON.stringify(normalizeTagSet(tags));
+};
+
+/** 解码库里的值;非字符串 / 非法 JSON / 非数组 → `[]`(当作没有标签) */
+export const decodeAngleTags = (raw: unknown): string[] => {
+  if (typeof raw !== "string" || !raw.trim()) return [];
+  try {
+    return normalizeTagSet(JSON.parse(raw));
+  } catch {
+    return [];
+  }
+};
+
+/** 两个标签数组按**集合**比较(顺序与编码差异不算变化,避免每轮无谓 update) */
+export const sameTagSet = (a: readonly string[], b: readonly string[]): boolean => {
+  if (a.length !== b.length) return false;
+  const set = new Set(b);
+  return a.every((x) => set.has(x));
+};
+
+/* ------------------------------------------------------------------ *
+ * 分类结果 → 1888 行
+ * ------------------------------------------------------------------ */
+
+/**
+ * 哪些角度失败了(partial 时)。
+ *
+ * 文档只给了 `errors` 的**键名**含义,没给完整结构,所以这里按键名与六个角度求交集;
+ * 出现对不上角度的键(或 partial 却一个角度都对不上)时置 `unknown`,
+ * 由调用方降级为保守策略(只写非空角度、跳过 1886)。
+ */
+export const extractFailedAngles = (
+  classification: CompanyClassification | null
+): { failed: Set<string>; unknown: boolean } => {
+  const failed = new Set<string>();
+  let unknown = false;
+
+  const errors = asRecord(classification?.errors);
+  if (errors) {
+    for (const key of Object.keys(errors)) {
+      if ((CLASSIFY_ANGLE_KEYS as readonly string[]).includes(key)) failed.add(key);
+      else unknown = true;
+    }
+  }
+
+  // partial 却一个失败角度都识别不出来 → 不知道哪些角度可信,按保守处理
+  if (classification?.status === "partial" && failed.size === 0) unknown = true;
+
+  return { failed, unknown };
+};
+
+/**
+ * 最终采用的身份三字段。
+ *
+ * 以**分类响应**为准(后端按 `profile.data` → `company` 的顺序算好了),响应里为空时
+ * 回退到前端自己从 `company_info` 抽的 `identityHint` —— 同一份数据的两个出口、
+ * 不是猜。两个都拿不到就是 null(`creditCode` 为 null 时调用方应跳过整条同步:
+ * 它既是 1888 的幂等键、又是 1886 的必填字段)。
+ */
+export const resolveSyncedIdentity = (
+  classification: CompanyClassification | null,
+  identityHint?: CompanyIdentity | null
+): CompanyIdentity => ({
+  name: pickString(classification?.company_name) ?? identityHint?.name ?? null,
+  creditCode: pickString(classification?.credit_code) ?? identityHint?.creditCode ?? null,
+  legalRep: pickString(classification?.legal_representative) ?? identityHint?.legalRep ?? null,
+});
+
+/**
+ * 组装 1888 期望行(只含「本轮要写」的字段,空值不带)。
+ *
+ * - 身份三字段:取 `resolveSyncedIdentity` 的结果;为空就不写该字段
+ *   (**绝不用 null 去清存量**)
+ * - 六个标签字段:失败角度不写;`conservative`(errors 不可解析)时只写非空角度
+ */
+export const buildEnterpriseRow = (
+  classification: CompanyClassification | null,
+  options: { failed: Set<string>; conservative: boolean; identityHint?: CompanyIdentity | null }
+): DmsRow => {
+  const identity = resolveSyncedIdentity(classification, options.identityHint);
+  const row: DmsRow = {};
+
+  if (identity.name) row.c_name = identity.name;
+  if (identity.creditCode) row.c_credit_code = identity.creditCode;
+  if (identity.legalRep) row.c_oper_name = identity.legalRep;
+
+  const categories = asRecord(classification?.categories) ?? {};
+  for (const angle of CLASSIFY_ANGLE_KEYS) {
+    if (options.failed.has(angle)) continue; // 该角度失败:不写、不覆盖存量
+    const encoded = encodeAngleTags(categories[angle]);
+    if (encoded === undefined) continue;
+    if (options.conservative && decodeAngleTags(encoded).length === 0) continue; // 保守:只写非空
+    row[ANGLE_TO_FIELD[angle]] = encoded;
+  }
+
+  return row;
+};
+
+/* ------------------------------------------------------------------ *
+ * 1888 diff
+ * ------------------------------------------------------------------ */
+
+/** 标签字段集合(比较方式与身份字段不同:按集合比) */
+const TAG_FIELDS = new Set<string>(Object.values(ANGLE_TO_FIELD));
+
+/**
+ * 期望行 vs 现有行 → 需要写的字段(无变化返回 null,调用方据此跳过 update)。
+ *
+ * - 标签字段:decode 后按**集合**比较,防 `"[]"` 与 `[]`、顺序不同这类假差异
+ * - 身份字段:期望值非空且与现有值逐字不同才写;期望值为空一律不写(不清存量)
+ */
+export const diffEnterpriseRow = (
+  existing: DmsRow | null,
+  desired: DmsRow
+): DmsRow | null => {
+  const patch: DmsRow = {};
+  for (const [field, value] of Object.entries(desired)) {
+    if (TAG_FIELDS.has(field)) {
+      if (!sameTagSet(decodeAngleTags(existing?.[field]), decodeAngleTags(value))) {
+        patch[field] = value;
+      }
+      continue;
+    }
+    const next = pickString(value);
+    if (!next) continue;
+    const current = pickString(existing?.[field]);
+    if (current !== next) patch[field] = next;
+  }
+  return Object.keys(patch).length ? patch : null;
+};
+
+/* ------------------------------------------------------------------ *
+ * 1886 集合同步
+ * ------------------------------------------------------------------ */
+
+/**
+ * 期望的荣誉标签集。
+ *
+ * - 资质荣誉角度失败 → **null**(跳过整个 1886 同步:失败 ≠ 没有荣誉,
+ *   拿空集去删会把上一轮写对的标签删光)
+ * - `conservative`(errors 不可解析)→ 同样 null
+ * - 正常(含 completed 且为空数组)→ 数组;**空数组是合法的「确实没有标签」**,
+ *   调用方会据此删光我方旧行
+ */
+export const buildHonorTags = (
+  classification: CompanyClassification | null,
+  options: { failed: Set<string>; conservative: boolean }
+): string[] | null => {
+  if (options.failed.has("资质荣誉")) return null;
+  if (options.conservative) return null;
+  const categories = asRecord(classification?.categories);
+  return normalizeTagSet(categories?.["资质荣誉"]);
+};
+
+export interface HonorRow {
+  /** DMS 记录 uuid(**不是** `c_id`) */
+  id: string;
+  tag: string;
+}
+
+/**
+ * 按所有权拆分荣誉行:只有 `c_source = company_info_sync` 且带 id 与标签的行归我们管。
+ * 其余(迁移数据、手工录入)一律不参与增删——**绝不碰别人的数据**。
+ */
+export const partitionHonorRows = (
+  rows: readonly DmsRow[]
+): { owned: HonorRow[]; foreign: number; malformed: number } => {
+  const owned: HonorRow[] = [];
+  let foreign = 0;
+  let malformed = 0;
+  for (const row of rows) {
+    if (pickString(row?.c_source) !== HONOR_SYNC_SOURCE) {
+      foreign += 1;
+      continue;
+    }
+    const id = typeof row?.id === "string" ? row.id : "";
+    const tag = pickString(row?.[HONOR_TAG_FIELD]);
+    if (!id || !tag) {
+      malformed += 1; // 我方行但缺 id/标签:既不删也不当作已存在,交给调用方 warn
+      continue;
+    }
+    owned.push({ id, tag });
+  }
+  return { owned, foreign, malformed };
+};
+
+/** 集合 diff:要新增的标签、要删除的行(集合比较,与顺序无关) */
+export const diffHonorRows = (
+  owned: readonly HonorRow[],
+  desired: ReadonlySet<string>
+): { toAdd: string[]; toDelete: HonorRow[] } => {
+  const toDelete = owned.filter((row) => !desired.has(row.tag));
+  const existingTags = new Set(owned.map((row) => row.tag));
+  const toAdd = [...desired].filter((tag) => !existingTags.has(tag));
+  return { toAdd, toDelete };
+};
+
+/** 新荣誉行的内容(c_credit_code 必填;c_id 见计划文件里的说明) */
+export const buildHonorRowContent = (
+  creditCode: string,
+  name: string | null,
+  tag: string,
+  createdAt: string
+): DmsRow => {
+  const row: DmsRow = {
+    // ⚠️ 1886 的 c_id 是源库主键且 must=true,前端新写行拿不到源库 id。
+    // 按 1889 的实测经验(must 只校验字段在不在、c_id 自动填 0)先传 0;
+    // 若被 214 拒绝,需要 DMS 侧放宽该字段或给一个前端可用的幂等键(见计划文件)。
+    c_id: 0,
+    c_credit_code: creditCode,
+    c_source: HONOR_SYNC_SOURCE,
+    [HONOR_TAG_FIELD]: tag,
+    c_created_at: createdAt,
+  };
+  if (name) row.c_name = name;
+  return row;
+};

+ 46 - 0
src/network/api/dms/client.ts

@@ -21,6 +21,12 @@ export const DMS_COLUMN_SESSION = 1887;
 export const DMS_MODEL_SESSION = 2034;
 export const DMS_COLUMN_RECORD = 1889;
 export const DMS_MODEL_RECORD = 2038;
+/** 企业基础信息(一行一家企业,幂等键 `c_credit_code`;modelId 实测自 getModelById) */
+export const DMS_COLUMN_ENTERPRISE = 1888;
+export const DMS_MODEL_ENTERPRISE = 2036;
+/** 企业荣誉信息(本同步里「一个标签一行」,幂等靠我们自己的 c_source 标记) */
+export const DMS_COLUMN_HONOR = 1886;
+export const DMS_MODEL_HONOR = 2032;
 
 /** `state` 取值:0 草稿 / 1 待审 / 2 审完 / 3 发布 / 4 销毁 / 5 退回 */
 export const DMS_STATE_DESTROYED = 4;
@@ -170,3 +176,43 @@ export const deleteDmsContent = async (columnId: number, id: string): Promise<bo
   });
   return !!res;
 };
+
+/* ------------------------------------------------------------------ *
+ * 通用工具(各业务模块共用,原先只写在 chat-sessions-dms.ts 里)
+ * ------------------------------------------------------------------ */
+
+/**
+ * 按字段精确查一行。
+ *
+ * ⚠️ **不要加 orderBy**:各栏目的字段并不相同(1889 没有 `c_updated_at`),
+ * 按不存在的字段排序会让 DMS 直接报错、查询恒空 —— 后果是 upsert 每次都走「新增」,
+ * 同一份数据被写成多行(踩过,见 `verify-dms-chat-storage.mjs` 的回归断言)。
+ */
+export const findDmsRowBy = async (
+  columnId: number,
+  field: string,
+  value: string
+): Promise<DmsRow | null> => {
+  const rows = await searchDmsContents(columnId, {
+    search: [{ field, searchType: 1, content: { value } }],
+    page: 0,
+    pageSize: 10,
+  });
+  return rows.length ? rows[0] : null;
+};
+
+/**
+ * 同一幂等键的写操作串行化。
+ * 避免「提交写 question」与「完成补 answer」两次调用并发反查、各自 add 出重复行。
+ */
+const writeChains = new Map<string, Promise<unknown>>();
+
+export const enqueueDmsWrite = <T>(key: string, job: () => Promise<T>): Promise<T> => {
+  const prev = writeChains.get(key) || Promise.resolve();
+  const next = prev.then(job, job); // 前一个失败也要继续跑
+  writeChains.set(key, next);
+  void next.finally(() => {
+    if (writeChains.get(key) === next) writeChains.delete(key);
+  });
+  return next;
+};

+ 183 - 0
src/network/api/dms/company-info-sync.ts

@@ -0,0 +1,183 @@
+/**
+ * 对话带回的企业资料 → 分类接口 → 落到 DMS 两个企业栏目。
+ *
+ * 触发:`/api/chat` 的 `result` 事件里 `data.company_info` 非空时,
+ * 由 `useBusinessAssistantChat` 在回答完成后调 `syncCompanyInfoFromChat`(fire-and-forget)。
+ *
+ * 落库规则(详见 `harness/docs/exec-plans/active/company-classify-dms-sync.md`):
+ * - **1888 企业基础信息**:一行一家企业,幂等键 `c_credit_code`;没有就新增,
+ *   有就按 `diffEnterpriseRow` 算出的差异更新(无差异则零写入)
+ * - **1886 企业荣誉信息**:一个资质荣誉标签一行;只增删「本轮应有的标签集」与
+ *   「我们自己写过的行(`c_source = company_info_sync`)」的差集,
+ *   迁移数据等其它来源的行**一律不碰**
+ * - 「资质荣誉」角度失败或错误不可解析时**跳过整个 1886 同步**:失败 ≠ 没有荣誉,
+ *   拿空集去删会把上一轮写对的标签删光
+ *
+ * ⚠️ 全程 fire-and-forget:任何失败只 `console.warn`,不抛异常、不阻塞聊天。
+ * ⚠️ 同一企业(同一信用代码)的多轮同步串行排队,避免并发反查各写一行。
+ */
+
+import {
+  DMS_COLUMN_ENTERPRISE,
+  DMS_MODEL_ENTERPRISE,
+  DMS_COLUMN_HONOR,
+  DMS_MODEL_HONOR,
+  addDmsContent,
+  deleteDmsContent,
+  enqueueDmsWrite,
+  findDmsRowBy,
+  searchDmsContents,
+  updateDmsContent,
+  type DmsRow,
+} from "./client";
+import { classifyCompany } from "@/network/api/company-classification";
+import { nowDmsTimestamp } from "./chat-sessions-dms";
+import {
+  buildEnterpriseRow,
+  buildHonorRowContent,
+  buildHonorTags,
+  diffEnterpriseRow,
+  diffHonorRows,
+  extractFailedAngles,
+  extractIdentityFromCompanyInfo,
+  isCompanyInfoUsable,
+  partitionHonorRows,
+  resolveSyncedIdentity,
+  type CompanyClassification,
+  type CompanyIdentity,
+} from "./classification-sync-utils";
+
+/** 1886 一次性拉取的行数上限(标签最多几十个,200 足够) */
+const HONOR_PAGE_SIZE = 200;
+
+/**
+ * 入口:对话拿到 `company_info` 后调用。
+ * 没有可用资料时**静默返回**(多数轮都没有,属常态,不刷日志)。
+ */
+export const syncCompanyInfoFromChat = (companyInfo: unknown): void => {
+  if (!isCompanyInfoUsable(companyInfo)) return;
+
+  const identity = extractIdentityFromCompanyInfo(companyInfo);
+  // 串行键优先用信用代码(同企业串行、不同企业可并行)——
+  // 分类还没跑,此时只能用 company_info 自己带的
+  const chainKey = identity?.creditCode || "global";
+  void enqueueDmsWrite(`company:${chainKey}`, () => runCompanyInfoSync(companyInfo, identity));
+};
+
+/** 分类 → 落库(内部;异常一律吞掉,只 warn) */
+async function runCompanyInfoSync(
+  companyInfo: unknown,
+  identity: CompanyIdentity | null
+): Promise<void> {
+  try {
+    const classification = await classifyCompany(companyInfo);
+    if (!classification) return; // classifyCompany 里已经 warn 过
+
+    if (classification.status === "failed") {
+      console.warn("[company-sync] 分类失败(status=failed),跳过同步", classification.errors);
+      return;
+    }
+
+    await applyCompanyClassification(classification, identity);
+  } catch (err) {
+    console.warn("[company-sync] 同步过程异常", err);
+  }
+}
+
+/**
+ * 拿到分类结果后落库。**单独导出**是为了让验证脚本能注入假的分类响应
+ * 直接打真实 DMS(分类接口自身的 422 问题见计划文件,不阻塞这一段)。
+ */
+export const applyCompanyClassification = async (
+  classification: CompanyClassification,
+  identityHint?: CompanyIdentity | null
+): Promise<void> => {
+  const identity = resolveSyncedIdentity(classification, identityHint);
+  const creditCode = identity.creditCode;
+  if (!creditCode) {
+    // 1888 的幂等键、1886 的必填字段都是它,没有就什么都写不了
+    console.warn("[company-sync] 分类结果缺少统一社会信用代码,跳过同步");
+    return;
+  }
+
+  const { failed, unknown } = extractFailedAngles(classification);
+  if (unknown) {
+    console.warn("[company-sync] 分类错误无法对应到角度,降级为保守同步(只写非空角度、跳过荣誉)");
+  }
+
+  await upsertEnterpriseRow(
+    creditCode,
+    buildEnterpriseRow(classification, { failed, conservative: unknown, identityHint })
+  );
+
+  const honorTags = buildHonorTags(classification, { failed, conservative: unknown });
+  if (honorTags === null) {
+    console.warn("[company-sync] 资质荣誉角度不可信,跳过 1886 同步");
+    return;
+  }
+  await syncHonorRows(creditCode, identity.name, honorTags);
+};
+
+/** 1888:没有就新增,有就按差异更新(无差异零写入) */
+async function upsertEnterpriseRow(creditCode: string, desired: DmsRow): Promise<void> {
+  const existing = await findDmsRowBy(DMS_COLUMN_ENTERPRISE, "c_credit_code", creditCode);
+
+  if (!existing) {
+    const content: DmsRow = {
+      ...desired,
+      // desired 里可能回退到了前端抽的 identity,这里兜底保证幂等键一定在
+      c_credit_code: creditCode,
+      c_created_at: nowDmsTimestamp(),
+    };
+    const id = await addDmsContent(DMS_COLUMN_ENTERPRISE, DMS_MODEL_ENTERPRISE, content);
+    if (!id) console.warn("[company-sync] 企业基础信息新增失败", creditCode);
+    return;
+  }
+
+  const patch = diffEnterpriseRow(existing, desired);
+  if (!patch) return; // 无变化:不写
+
+  const id = typeof existing.id === "string" ? existing.id : "";
+  if (!id) {
+    console.warn("[company-sync] 企业基础信息行缺少 uuid,无法更新", existing);
+    return;
+  }
+  const ok = await updateDmsContent(DMS_COLUMN_ENTERPRISE, DMS_MODEL_ENTERPRISE, id, patch);
+  if (!ok) console.warn("[company-sync] 企业基础信息更新失败", creditCode, patch);
+}
+
+/** 1886:按「应有的标签集」增删我们自己写过的行(先增后删,任一步失败不影响另一步) */
+async function syncHonorRows(
+  creditCode: string,
+  name: string | null,
+  tags: string[]
+): Promise<void> {
+  const rows = await searchDmsContents(DMS_COLUMN_HONOR, {
+    search: [{ field: "c_credit_code", searchType: 1, content: { value: creditCode } }],
+    page: 0,
+    pageSize: HONOR_PAGE_SIZE,
+  });
+
+  const { owned, malformed } = partitionHonorRows(rows);
+  if (malformed) {
+    console.warn("[company-sync] 有我方荣誉行缺 id/标签,已跳过(不删也不计入已有)", malformed);
+  }
+
+  const { toAdd, toDelete } = diffHonorRows(owned, new Set(tags));
+  if (!toAdd.length && !toDelete.length) return; // 集合一致:零写入
+
+  const createdAt = nowDmsTimestamp();
+  for (const tag of toAdd) {
+    const id = await addDmsContent(
+      DMS_COLUMN_HONOR,
+      DMS_MODEL_HONOR,
+      buildHonorRowContent(creditCode, name, tag, createdAt)
+    );
+    if (!id) console.warn("[company-sync] 荣誉行新增失败", creditCode, tag);
+  }
+
+  for (const row of toDelete) {
+    const ok = await deleteDmsContent(DMS_COLUMN_HONOR, row.id);
+    if (!ok) console.warn("[company-sync] 荣誉行删除失败", creditCode, row.tag);
+  }
+}

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff