Forráskód Böngészése

change(company): 1886 荣誉改用 honors.records 全量对齐(用户实测宇树科技后修正)

用户实测宇树科技股份有限公司,1886 只出来 2 条荣誉,质疑「应该不止两条」。查证结论:
**同步没写错,是数据源选错了**——
- 分类接口的「资质荣誉」是 165 个标签里挑的粗标签(宇树只有 2 个)
- company_info.honors.records 才是逐条真实荣誉(同一家 31 条,含级别/来源/发布机构/日期/证书编号)
- 库里 1886 已灌入的 28 行就是这份数据的旧快照,字段一一对应(差集:载荷多 4 条、库里多 1 条)

用户拍板:①1886 改存 honors.records,与库里的行按「荣誉名+级别+来源」全量对齐
(补新增/更新差异/删多余,仅 complete=true 时才删)②荣誉名同时写 title 与 c_tag_name。

改动:
- classification-sync-utils:删掉按 c_source 认所有权的旧方案,改为 parseHonorRecords /
  buildHonorKey / readHonorName(c_tag_name → title 回退,字符串 "null" 不算名字)/ diffHonorRows
- company-info-sync:拆成互不依赖的两个入口 applyCompanyClassification(1888←分类接口)与
  applyCompanyHonors(1886←company_info.honors),分类失败不再牵连荣誉同步
- 修一个被 e2e 抓出的真 bug:已匹配的行不会补写 c_tag_name(字段映射漏了荣誉名那列)

验证:纯逻辑 65/65、真实 DMS 23/23、端到端 20/20(1886 = 31 行)、适配层直通 10/10;
回归全绿(含 DMS 43/43)。真实数据:宇树的 1886 已从「28+2」对齐为与后端一致的 31 条。
⚠️ 修正后尚未再走一遍浏览器。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 2 napja
szülő
commit
b515552905

+ 8 - 7
README.md

@@ -238,13 +238,14 @@
   更窄的屏按比例收缩以免标题被顶出屏幕)。过程:一度整体 −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 同步**
-    (失败 ≠ 没有荣誉,拿空集去删会把上一轮写对的标签删光)
+  - 对话 `result` 事件里的 `company_info` 非空时,回答完成后自动同步到 DMS 两栏目:
+    **1888 企业基础信息** ← 分类接口(`POST /api/company/classify`)的六个角度标签,
+    一行一家企业(幂等键 `c_credit_code`,字段级 diff,无差异零写入);
+    **1886 企业荣誉信息** ← **`company_info.honors.records`**(逐条真实荣誉),一条荣誉一行
+  - 1886 与库里该企业的行按「荣誉名+级别+来源」**全量对齐**(补新增、更新差异、删多余),
+    **仅当 `honors.complete === true` 才删**;荣誉名同时写 `c_tag_name` 与系统 `title`。
+    —— 数据源曾用分类接口的「资质荣誉」标签,实测宇树科技只给 2 个标签而真实荣誉有 31 条,
+    按用户要求改为以 `honors.records` 为准(分类标签仍写进 1888 的 `c_tag_honor`)
   - 全程 fire-and-forget:任何失败只 warn,不影响聊天;同企业并发走串行链
   - 适配层新增捕获 `company_info`(原先 `result` 分支是空的、数据被丢弃)
   - 回归脚本:`verify-company-classify-sync.mjs`(53 项纯逻辑)、

+ 20 - 1
harness/docs/exec-plans/active/company-classify-dms-sync.md

@@ -27,9 +27,28 @@ DMS 的企业基础信息(1888)与企业荣誉信息(1886)两个栏目
 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'` 作所有权标记。
+3. ~~1886 一个标签一行,`c_source='company_info_sync'` 作所有权标记~~ →
+   **【2026-09-18 当天修正,见下】1886 改为以 `company_info.honors.records` 为准全量对齐**
 4. 1888 对比范围 = 身份三字段(企业名/信用代码/法人)+ 6 个标签字段。
 
+### 📌 修正:1886 的数据源换成 `honors.records`(用户 2026-09-18 拍板)
+
+**起因**:用户用宇树科技股份有限公司实测,1886 只有 2 条,觉得「应该不止两条」。
+查证后确认**同步没写错、是数据源选错了**:
+
+| | 数量 |
+|---|---|
+| 分类接口的「资质荣誉」标签(原实现的数据源) | **2** 个(`中国独角兽企业`、`国家级高新技术企业`)——它是 165 个标签里挑的粗标签 |
+| `company_info.honors.records`(真实荣誉) | **31** 条,每条含 荣誉名/级别/来源/发布机构/发布日期/证书编号 |
+| 库里已灌入的行 | 28 条(**同一份企查查荣誉数据的旧快照**,字段与 `honors.records` 一一对应) |
+
+**新规则**:
+- 1886 一行 = 一条真实荣誉;荣誉名同时写 `c_tag_name` 与系统字段 `title`
+- 与库里该企业的行按「**荣誉名+级别+来源**」**全量对齐**:补新增、更新差异、删多余
+- **仅当 `honors.complete === true` 才删**(记录集不完整时删 = 丢数据)
+- 灌入的行同样参与对齐(它们就是同一份数据的旧快照),首次对齐会补上 `c_tag_name`
+- 「资质荣誉」标签仍写进 1888 的 `c_tag_honor`(标签与明细各归其位,互不冲突)
+
 ## Step-0 实测(2026-09-18)
 
 | 探测 | 结果 |

+ 9 - 3
harness/docs/reference/DMS_COLUMNS.md

@@ -84,9 +84,15 @@
 | `c_tag_name` | 荣誉 | text | text | |
 
 > `c_tag_name` 由**前端的企业信息分类同步**写入(2026-09-18 DMS 侧新增,12 字段):
-> 一行 = 一个资质荣誉标签,同时写 `c_credit_code` / `c_name`,
-> 并用 `c_source = company_info_sync` 标记「这行是前端同步写的」——
-> 同步只增删带这个标记的行,迁移数据一律不碰。
+> **一行 = 一条真实荣誉**(数据源是 `company_info.honors.records`),荣誉名同时写进
+> 这一列与系统字段 `title`(DMS 列表用后者显示);别的列照抄后端记录:
+> `c_level`=Level、`c_source`=Source、`c_publish_office`=PublishOffice、
+> `c_publish_date`=PublishDate、`c_beging_date`=BegingDate、`c_dead_line`=DeadLine、
+> `c_certificate_code`=CertificateCode。
+>
+> 同步按「**荣誉名+级别+来源**」与库里该企业的行全量对齐:补新增、更新有差异的列、
+> 删库里多出来的(**仅当后端 `honors.complete === true`**)。
+> 灌入的行(09-17 那批,只有 `title` 没有 `c_tag_name`)同样参与,首次对齐会补上这一列。
 > 见 [`../exec-plans/active/company-classify-dms-sync.md`](../exec-plans/active/company-classify-dms-sync.md)。
 
 ### 2.3 助手会话 `columnId=1887`

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

@@ -35,8 +35,10 @@
 >   [`company-classification.md`](company-classification.md) 与
 >   [`../exec-plans/active/company-classify-dms-sync.md`](../exec-plans/active/company-classify-dms-sync.md)):
 >   1888 按 `c_credit_code` **更新已存在的行**(只动身份三字段 + 6 个 `c_tag_*`,其余工商字段不碰),
->   库里没有的企业才新增;1886 只增删带 `c_source=company_info_sync` 标记的行,
->   **灌入的荣誉行一律不碰**
+>   库里没有的企业才新增;
+>   1886 以 `company_info.honors.records` 为准,与该企业的行按「荣誉名+级别+来源」**全量对齐**
+>   (补新增、更新差异、删多余;`honors.complete=false` 时只增改不删)——
+>   **灌入的荣誉行同样参与对齐**(它们就是同一份数据的旧快照),对齐后会被补上 `c_tag_name`
 > - **1885 页面浏览**:仍是空的(统计埋点已于 2026-09-18 删除,前端不再写)
 
 ## 现行契约:`POST /api/chat`

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 7 - 8
harness/feature_list.json


+ 33 - 0
harness/progress.md

@@ -2556,3 +2556,36 @@
 **仍未做**:**浏览器端到端**(自动化证据已齐,只差真人在页面上问一个公司类问题)。
 验证脚本清单已更新进 `harness/tools/README.md`。
 
+### 续 2:用户在浏览器实测宇树科技 → 「荣誉应该不止两条」→ 1886 换数据源
+
+用户反馈:测了宇树科技股份有限公司,荣誉出来 2 条,**感觉不止两条**。查证结论:
+**同步没写错,是数据源选错了。**
+
+| | 数量 |
+|---|---|
+| 分类接口的「资质荣誉」标签(原实现的数据源) | **2**(中国独角兽企业、国家级高新技术企业)——165 个标签里挑的粗标签 |
+| `company_info.honors.records`(真实荣誉) | **31** 条,含荣誉名/级别/来源/发布机构/发布日期/证书编号 |
+| 库里 1886 已灌入的行 | 28 条——**同一份企查查荣誉数据的旧快照**,字段与 records 一一对应 |
+
+用脚本算出的差集:**载荷有、库里没有 4 条**;**库里有、载荷没有 1 条**。
+
+**用户拍板**:①1886 改存 `honors.records`,与库里的行**全量对齐**(补 4、删 1、同键行更新差异)
+②荣誉名同时写 `title` 与 `c_tag_name`(顺带修掉我上轮 `title="null"` 的瑕疵)。
+
+**改动**:
+- `classification-sync-utils.ts`:删掉按 `c_source` 认所有权的旧方案,改为
+  `parseHonorRecords` / `buildHonorKey`(荣誉名+级别+来源)/ `readHonorName`(c_tag_name → title 回退,
+  **字符串 "null" 不算名字**)/ `diffHonorRows`(toAdd / toUpdate / toDelete,删只在 complete=true 时做)
+- `company-info-sync.ts`:拆成两个**互不依赖**的入口——`applyCompanyClassification`(1888 ← 分类接口)
+  与 `applyCompanyHonors`(1886 ← `company_info.honors`)。分类失败不再牵连荣誉同步
+- 一个真 bug 在 e2e 里被抓出来并修掉:**已匹配的行不会补写 `c_tag_name`**
+  (字段映射漏了荣誉名那一列,只写了 `title`)——灌入的行本来就只有 title,不补就永远缺这一列
+
+**跑过的验证**:纯逻辑 **65/65**(新增荣誉解析/键/增删改对齐/complete=false 不删/名字回退等断言);
+真实 DMS **23/23**(新脚本覆盖:一条荣誉一行、幂等、改字段、删多余、complete=false 不删、
+迁移行也参与对齐);**e2e 20/20**(真实载荷 → 真实分类 → 真实 DMS,1886 = 31 行);
+回归全绿(33/11/24/8/10/21/30/10/12 + DMS 43/43)。
+
+**顺带修好的真实数据**:宇树科技(91330108MA27YJ5H56)的 1886 已从「28 灌入 + 2 标签」
+对齐为**与后端一致的 31 条**(灌入行补上了 `c_tag_name`,缺的 4 条由 `user_gtx` 写入)。
+

+ 9 - 5
harness/tools/_entry-company-classify.ts

@@ -9,11 +9,11 @@
 export {
   ANGLE_TO_FIELD,
   CLASSIFY_ANGLE_KEYS,
-  HONOR_SYNC_SOURCE,
+  HONOR_FIELD_MAP,
   HONOR_TAG_FIELD,
   buildEnterpriseRow,
+  buildHonorKey,
   buildHonorRowContent,
-  buildHonorTags,
   decodeAngleTags,
   diffEnterpriseRow,
   diffHonorRows,
@@ -22,13 +22,17 @@ export {
   extractIdentityFromCompanyInfo,
   isCompanyInfoUsable,
   normalizeTagSet,
-  partitionHonorRows,
+  parseHonorRecords,
+  readHonorsFromCompanyInfo,
   resolveSyncedIdentity,
   sameTagSet,
 } from '../../src/network/api/dms/classification-sync-utils';
 
-/** 注入分类结果直接落库(验证脚本用它打真实 DMS,绕开后端 classify 的 422 问题) */
-export { applyCompanyClassification } from '../../src/network/api/dms/company-info-sync';
+/** 注入分类结果 / 荣誉列表直接落库(验证脚本用它打真实 DMS,不依赖分类接口) */
+export {
+  applyCompanyClassification,
+  applyCompanyHonors,
+} from '../../src/network/api/dms/company-info-sync';
 
 /** 纯函数:base URL → 分类接口地址(两种 VITE_CHAT_API 写法都要对) */
 export { resolveClassifyEndpoint } from '../../src/network/api/company-classification';

+ 120 - 101
harness/tools/verify-company-classify-dms.mjs

@@ -1,16 +1,19 @@
 /**
  * 打**真实 DMS**验证企业信息同步的落库行为(栏目 1888 企业基础信息 / 1886 企业荣誉信息)。
  *
- * 为什么注入假分类响应:分类接口 `POST /api/company/classify` 目前对**任何带内容的
- * 请求体都返回 422**(2026-09-18 实测,连文档里的合成示例也被拒,属后端问题),
- * 所以这一段用 `applyCompanyClassification(分类结果)` 直接验证「拿到结果之后」的落库链路。
+ * 用**注入的假数据**(假的分类结果 + 假的荣誉列表)跑完整落库链路,因此:
+ *   - 不消耗分类接口的 LLM 调用,可以反复跑
+ *   - 覆盖边界(改标签、删荣誉、complete=false 不许删、缺信用代码跳过…)
  *
  * 验证什么:
- *   ① 首写:1888 出现一行且六个标签字段 = 对应角度的 JSON 数组;1886 按标签各一行
- *   ② 重跑同样的数据:**零新增**(幂等,不产生重复行)
- *   ③ 改标签:1888 仍只有一行、字段已更新;1886 集合按增删收敛(旧行被销毁查不到)
- *   ④ 资质荣誉 = 空集:删光「我方」行,**迁移数据行(c_source 非 company_info_sync)不动**
- *   ⑤ 清理:按测试信用代码删掉本次产生的全部行,复查残留 0
+ *   ① 1888 首写:一行,六个标签字段 = 对应角度的 JSON 数组
+ *   ② 1886 首写:**一条荣誉一行**,title 与 c_tag_name 都是荣誉名,级别/来源/机构/日期/证书号各就各位
+ *   ③ 幂等:同样数据再跑一次 → 零新增、行 uuid 不变
+ *   ④ 改:1888 标签变化 → 仍是同一行且已更新;1886 少一条/多一条/改字段 →
+ *      删多余、补新增、更新差异,行数与后端列表一致
+ *   ⑤ `honors.complete = false` → **只增改不删除**(记录集不完整时不许删)
+ *   ⑥ 缺信用代码 → 整条跳过,不写任何行
+ *   ⑦ 清理:按测试信用代码删掉本次产生的全部行,复查残留 0
  *
  * 怎么跑(先按 _entry-company-classify.ts 头部注释重新打包):
  *   1) npm run dev        # 代理负责注入 DMS token
@@ -27,11 +30,10 @@ globalThis.VITE_DMS_API = base;
 
 const {
   applyCompanyClassification,
-  HONOR_SYNC_SOURCE,
+  applyCompanyHonors,
   HONOR_TAG_FIELD,
   ANGLE_TO_FIELD,
   DMS_COLUMN_ENTERPRISE,
-  DMS_MODEL_ENTERPRISE,
   DMS_COLUMN_HONOR,
   DMS_MODEL_HONOR,
   addDmsContent,
@@ -53,33 +55,38 @@ const check = (name, ok, extra = '') => {
 
 const TEST_CREDIT_CODE = `TEST_CC_${Date.now()}`;
 const TEST_NAME = '分类同步测试公司';
+const IDENTITY = { name: TEST_NAME, creditCode: TEST_CREDIT_CODE, legalRep: '测试法人' };
 
-const findEnterprise = async () => {
-  const rows = await searchDmsContents(DMS_COLUMN_ENTERPRISE, {
+/** 造一条企查查形态的荣誉记录(`records[].data`) */
+const rec = (Name, Level, Source, extra = {}) => ({
+  data: { Name, Level, Source, ...extra },
+});
+const honorsNode = (list, complete = true) => ({ complete, records: list, sources: [], error: null });
+
+const findEnterprise = () =>
+  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, {
+const findHonors = () =>
+  searchDmsContents(DMS_COLUMN_HONOR, {
     search: [{ field: 'c_credit_code', searchType: 1, content: { value: TEST_CREDIT_CODE } }],
     page: 0,
     pageSize: 200,
   });
-  return rows;
-};
+const honorNames = (rows) =>
+  rows
+    .map((r) => (typeof r[HONOR_TAG_FIELD] === 'string' && r[HONOR_TAG_FIELD] ? r[HONOR_TAG_FIELD] : r.title))
+    .sort();
 
-const classifyWith = (categories, extra = {}) => ({
+const classifyWith = (categories) => ({
   company_name: TEST_NAME,
   credit_code: TEST_CREDIT_CODE,
   legal_representative: '测试法人',
   categories,
   status: 'completed',
   errors: {},
-  ...extra,
 });
 
 console.log(`测试信用代码:${TEST_CREDIT_CODE}\n`);
@@ -88,114 +95,128 @@ try {
   /* ---------------------------------------------------------------- *
    * ① 首写
    * ---------------------------------------------------------------- */
-  console.log('【1】首写:1888 一行 + 1886 按标签各一行');
-  const first = classifyWith({
-    基本信息: ['小微企业'],
-    资质荣誉: ['高新技术企业', '专精特新'],
-    产业信息: ['第一产业', '现代农业'],
-    经营活动: ['对外投资'],
-    行业信息: ['涉农(广义)'],
-    许可认证: ['食品生产许可'],
-  });
-  await applyCompanyClassification(first);
+  console.log('【1】首写:1888 一行 + 1886 一条荣誉一行');
+  await applyCompanyClassification(
+    classifyWith({
+      基本信息: ['小微企业'],
+      资质荣誉: ['高新技术企业'],
+      产业信息: ['第一产业', '现代农业'],
+      经营活动: ['对外投资'],
+      行业信息: ['涉农(广义)'],
+      许可认证: ['食品生产许可'],
+    }),
+    IDENTITY
+  );
+  await applyCompanyHonors(
+    honorsNode([
+      rec('高新技术企业', '国家级', '某认定公告', {
+        PublishOffice: '科技部',
+        PublishDate: '2025-01-02',
+        CertificateCode: 'CERT-1',
+      }),
+      rec('独角兽企业', '省级', '2026 浙江独角兽', { PublishDate: '2026-04-23' }),
+    ]),
+    IDENTITY
+  );
 
   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['资质荣誉']]
-  );
+  check('1888 身份字段正确', row1.c_name === TEST_NAME && row1.c_oper_name === '测试法人', row1);
+  check('1888 标签字段正确(非空角度)', row1[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]' && row1[ANGLE_TO_FIELD['产业信息']] === '["第一产业","现代农业"]', row1);
 
   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));
+  check('1886 行数 = 荣誉记录数(2)', honors1.length === 2, honors1.length);
+  const h1 = honors1.find((r) => r[HONOR_TAG_FIELD] === '高新技术企业') || {};
+  check('1886 title 与 c_tag_name 都写荣誉名', h1.title === '高新技术企业' && h1[HONOR_TAG_FIELD] === '高新技术企业', { title: h1.title, tag: h1[HONOR_TAG_FIELD] });
+  check('1886 级别/来源/机构/日期/证书号落位', h1.c_level === '国家级' && h1.c_source === '某认定公告' && h1.c_publish_office === '科技部' && h1.c_publish_date === '2025-01-02' && h1.c_certificate_code === 'CERT-1', h1);
+  check('1886 归属正确', honors1.every((r) => r.c_credit_code === TEST_CREDIT_CODE && r.c_name === TEST_NAME));
 
   /* ---------------------------------------------------------------- *
-   * ② 幂等:同数据重跑
+   * ② 幂等
    * ---------------------------------------------------------------- */
   console.log('\n【2】同数据重跑 → 零新增');
-  await applyCompanyClassification(first);
+  await applyCompanyClassification(
+    classifyWith({ 基本信息: ['小微企业'], 资质荣誉: ['高新技术企业'], 产业信息: ['第一产业', '现代农业'], 经营活动: ['对外投资'], 行业信息: ['涉农(广义)'], 许可认证: ['食品生产许可'] }),
+    IDENTITY
+  );
+  await applyCompanyHonors(
+    honorsNode([
+      rec('高新技术企业', '国家级', '某认定公告', { PublishOffice: '科技部', PublishDate: '2025-01-02', CertificateCode: 'CERT-1' }),
+      rec('独角兽企业', '省级', '2026 浙江独角兽', { PublishDate: '2026-04-23' }),
+    ]),
+    IDENTITY
+  );
   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);
+  check('1888 仍是一行、uuid 未变', rows2.length === 1 && rows2[0]?.id === row1.id, rows2.length);
+  check('1886 仍 2 行', (await findHonors()).length === 2);
 
   /* ---------------------------------------------------------------- *
-   * ③ 改标签
+   * ③ 改:1888 标签 + 1886 增删改
    * ---------------------------------------------------------------- */
-  console.log('\n【3】改标签 → 1888 更新、1886 增删收敛');
-  const second = classifyWith({
-    基本信息: ['小微企业', '专精特新'],
-    资质荣誉: ['高新技术企业', '科技小巨人'], // 去掉「专精特新」,新增「科技小巨人」
-    产业信息: ['第一产业', '现代农业'],
-    经营活动: ['对外投资'],
-    行业信息: ['涉农(广义)'],
-    许可认证: ['食品生产许可'],
-  });
-  await applyCompanyClassification(second);
+  console.log('\n【3】改:1888 更新、1886 增删改对齐');
+  await applyCompanyClassification(
+    classifyWith({ 基本信息: ['小微企业', '专精特新'], 资质荣誉: ['高新技术企业'], 产业信息: ['第一产业', '现代农业'], 经营活动: ['对外投资'], 行业信息: ['涉农(广义)'], 许可认证: ['食品生产许可'] }),
+    IDENTITY
+  );
+  await applyCompanyHonors(
+    honorsNode([
+      // 同键但发布机构变了 → 更新
+      rec('高新技术企业', '国家级', '某认定公告', { PublishOffice: '工信部', PublishDate: '2025-01-02', CertificateCode: 'CERT-1' }),
+      // 新增一条
+      rec('科技小巨人', '市级', '2026 市科委名单'),
+      // 「独角兽企业」本条不出现 → 应被删除
+    ]),
+    IDENTITY
+  );
 
   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);
+  check('1886 行数 = 新列表(2)', honors3.length === 2, honors3.length);
+  check('1886 名字对齐(新增进、多余的删掉)', JSON.stringify(honorNames(honors3)) === JSON.stringify(['科技小巨人', '高新技术企业']), honorNames(honors3));
+  const h3 = honors3.find((r) => r[HONOR_TAG_FIELD] === '高新技术企业') || {};
+  check('1886 同键行的字段差异已更新', h3.c_publish_office === '工信部', h3.c_publish_office);
+
+  /* ---------------------------------------------------------------- *
+   * ④ complete=false:只增改、不删除
+   * ---------------------------------------------------------------- */
+  console.log('\n【4】honors.complete=false → 只增改不删除');
+  await applyCompanyHonors(honorsNode([rec('新荣誉', '区级', '某区名单')], false), IDENTITY);
+  const honors4 = await findHonors();
+  check('新增的进来了', honorNames(honors4).includes('新荣誉'), honorNames(honors4));
+  check('**原有的没被删**(3 行:高新技术、科技小巨人、新荣誉)', honors4.length === 3, honorNames(honors4));
 
   /* ---------------------------------------------------------------- *
-   * ④ 空集 + 迁移行保护
+   * ⑤ 迁移行也会被对齐(不再是「只碰自己写的行」)
    * ---------------------------------------------------------------- */
-  console.log('\n【4】资质荣誉空集 → 删光我方行;迁移数据行不动');
-  const foreignId = await addDmsContent(DMS_COLUMN_HONOR, DMS_MODEL_HONOR, {
+  console.log('\n【5】全量对齐:非本同步写入的同企业荣誉行同样参与');
+  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_source: 'migrate_test', // 模拟迁移数据
+    title: '迁移来的荣誉',
+    c_level: '国家级',
     c_created_at: '2026-09-18 12:00:00',
   });
-  check('预置了 1 条迁移行(用于验证不被误删)', !!foreignId, foreignId);
+  check('预置 1 条迁移行', (await findHonors()).length === 4, (await findHonors()).length);
 
-  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));
+  await applyCompanyHonors(honorsNode([rec('迁移来的荣誉', '国家级', 'migrate_test')]), IDENTITY);
+  const honors5 = await findHonors();
+  check('后端列表只有它 → 其余全删,只剩 1 行', honors5.length === 1, honorNames(honors5));
+  check('留下的正是迁移行那一条(按「荣誉名+级别+来源」匹配到同一行)', honors5[0]?.title === '迁移来的荣誉', honors5[0]?.title);
 
   /* ---------------------------------------------------------------- *
-   * ⑤ 缺失信用代码 → 跳过
+   * ⑥ 缺信用代码 → 跳过
    * ---------------------------------------------------------------- */
-  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);
+  console.log('\n【6】缺信用代码 → 整条跳过');
+  await applyCompanyHonors(honorsNode([rec('不该出现的荣誉', '国家级', 'X')]), { name: TEST_NAME, creditCode: null, legalRep: null });
+  check('1886 行数未变', (await findHonors()).length === 1);
+  await applyCompanyHonors(null, IDENTITY);
+  check('honors 缺失/null → 跳过(不清库)', (await findHonors()).length === 1);
 } finally {
   /* ---------------------------------------------------------------- *
    * 清理
@@ -211,10 +232,8 @@ try {
   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);
+  check('残留 0 行(1888)', (await findEnterprise()).length === 0);
+  check('残留 0 行(1886)', (await findHonors()).length === 0);
 }
 
 console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);

+ 36 - 7
harness/tools/verify-company-classify-e2e.mjs

@@ -39,8 +39,10 @@ if (!payloadPath) {
 const {
   classifyCompany,
   applyCompanyClassification,
+  applyCompanyHonors,
   extractIdentityFromCompanyInfo,
-  HONOR_SYNC_SOURCE,
+  parseHonorRecords,
+  readHonorsFromCompanyInfo,
   HONOR_TAG_FIELD,
   ANGLE_TO_FIELD,
   DMS_COLUMN_ENTERPRISE,
@@ -150,23 +152,50 @@ for (const angle of angles) {
   );
 }
 
+/* ---------------------------------------------------------------- *
+ * ②b 荣誉(数据源 = company_info.honors.records)
+ * ---------------------------------------------------------------- */
+console.log("\n【2b】荣誉对齐到 1886(数据源 = company_info.honors.records)");
+const honorParse = parseHonorRecords(readHonorsFromCompanyInfo(companyInfo));
+console.log(`  载荷荣誉记录 ${honorParse.records.length} 条(complete=${honorParse.complete},丢弃 ${honorParse.malformed} 条)`);
+await applyCompanyHonors(readHonorsFromCompanyInfo(companyInfo), identity);
+
 const honors = await findHonors();
-const owned = honors.filter((r) => r.c_source === HONOR_SYNC_SOURCE);
-check("1886 我方行数 = 资质荣誉标签数", owned.length === (cats["资质荣誉"] || []).length, {
-  owned: owned.length,
-  tags: cats["资质荣誉"],
+check("1886 行数 = 载荷荣誉条数", honors.length === honorParse.records.length, {
+  rows: honors.length,
+  records: honorParse.records.length,
 });
+check(
+  "每行都有荣誉名(title 与 c_tag_name)",
+  honors.every((r) => typeof r[HONOR_TAG_FIELD] === "string" && r[HONOR_TAG_FIELD] && r.title === r[HONOR_TAG_FIELD]),
+  honors.slice(0, 3).map((r) => ({ title: r.title, tag: r[HONOR_TAG_FIELD] }))
+);
+check(
+  "级别/来源已写入",
+  honors.every((r) => typeof r.c_level === "string" && typeof r.c_source === "string"),
+  honors.slice(0, 3).map((r) => ({ level: r.c_level, source: r.c_source }))
+);
+const sampleRecord = honorParse.records[0];
+check(
+  "抽样比对:载荷第一条能在库里按「荣誉名+级别+来源」找到",
+  !sampleRecord ||
+    honors.some((r) => r[HONOR_TAG_FIELD] === sampleRecord.name && r.c_level === sampleRecord.level && r.c_source === sampleRecord.source),
+  sampleRecord
+);
 
 /* ---------------------------------------------------------------- *
  * ③ 幂等:再落一次
  * ---------------------------------------------------------------- */
 console.log("\n【3】同样结果再落一次 → 零新增");
 await applyCompanyClassification(classification, identity);
+await applyCompanyHonors(readHonorsFromCompanyInfo(companyInfo), identity);
 const rows2 = await findEnterprise();
 check("1888 仍是一行、uuid 未变", rows2.length === 1 && rows2[0]?.id === row.id, rows2.length);
+const honors2 = await findHonors();
+check("1886 行数未变", honors2.length === honors.length, { before: honors.length, after: honors2.length });
 check(
-  "1886 行数未变",
-  (await findHonors()).filter((r) => r.c_source === HONOR_SYNC_SOURCE).length === owned.length
+  "1886 行 uuid 集合未变(没有删了又加)",
+  JSON.stringify(honors2.map((r) => r.id).sort()) === JSON.stringify(honors.map((r) => r.id).sort())
 );
 
 /* ---------------------------------------------------------------- *

+ 93 - 48
harness/tools/verify-company-classify-sync.mjs

@@ -5,7 +5,9 @@
  *   ① 身份抽取的取值顺序与容错(profile.data 优先、回退 company、非字符串一律不算)
  *   ② 分类响应 → 1888 期望行(失败角度不写、保守模式只写非空角度)
  *   ③ 1888 diff:无变化 → null(零写入)、`"[]"` 与 `[]` 不产生假差异、期望为空不清存量
- *   ④ 1886 集合同步:增/删/改/空集、以及**迁移数据(foreign 行)绝不出现在结果里**
+ *   ④ 1886 荣誉对齐:解析 `honors.records`、按「荣誉名+级别+来源」全量对齐
+ *      (补新增 / 更新差异 / 删多余)、`complete !== true` 时**不许删**、
+ *      荣誉名回退顺序(c_tag_name → title,字符串 "null" 不算名字)
  *   ⑤ partial / errors 不可解析时的降级语义
  *   ⑥ 分类接口地址拼接(VITE_CHAT_API 的两种写法)
  *
@@ -15,11 +17,10 @@
 
 import {
   ANGLE_TO_FIELD,
-  HONOR_SYNC_SOURCE,
   HONOR_TAG_FIELD,
   buildEnterpriseRow,
+  buildHonorKey,
   buildHonorRowContent,
-  buildHonorTags,
   decodeAngleTags,
   diffEnterpriseRow,
   diffHonorRows,
@@ -28,7 +29,8 @@ import {
   extractIdentityFromCompanyInfo,
   isCompanyInfoUsable,
   normalizeTagSet,
-  partitionHonorRows,
+  parseHonorRecords,
+  readHonorsFromCompanyInfo,
   resolveClassifyEndpoint,
   resolveSyncedIdentity,
   sameTagSet,
@@ -74,6 +76,7 @@ console.log('\n【1】身份抽取');
   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);
+  check('readHonorsFromCompanyInfo 取 honors 节点', readHonorsFromCompanyInfo({ honors: { records: [] } }) !== null && readHonorsFromCompanyInfo({}) === null);
 }
 
 /* ---------------------------------------------------------------- *
@@ -152,43 +155,94 @@ console.log('\n【3】1888 期望行与 diff');
 }
 
 /* ---------------------------------------------------------------- *
- * ④ 1886 集合同步
+ * ④ 1886 荣誉对齐(数据源 honors.records)
  * ---------------------------------------------------------------- */
-console.log('\n【4】1886 集合同步');
+console.log('\n【4】1886 荣誉对齐');
 {
+  // —— 解析 ——
+  const honors = {
+    complete: true,
+    records: [
+      { data: { Name: '高新技术企业', Level: '国家级', Source: '某认定公告', PublishOffice: '科技部', PublishDate: '2025-01-02', CertificateCode: 'CERT-1' } },
+      { data: { Name: '独角兽企业', Level: '省级', Source: '2026 浙江独角兽', PublishOffice: '', PublishDate: '2026-04-23' } },
+      { data: { Level: '国家级' } }, // 缺 Name → 丢弃
+      { notData: true }, // 缺 data → 丢弃
+    ],
+  };
+  const parsed = parseHonorRecords(honors);
+  check('解析出 2 条(缺 Name / 缺 data 的各丢一条)', parsed.records.length === 2 && parsed.malformed === 2, parsed);
+  check('complete 透传', parsed.complete === true);
+  check('字段映射正确', parsed.records[0].name === '高新技术企业' && parsed.records[0].level === '国家级' && parsed.records[0].certificateCode === 'CERT-1' && parsed.records[1].publishOffice === null, parsed.records);
+  check('honors 缺失 → available=false(调用方整块跳过)', parseHonorRecords(null).available === false && parseHonorRecords(undefined).available === false);
+  check('honors={} → available=true 但 0 条、complete=false', (() => { const p = parseHonorRecords({}); return p.available === true && p.records.length === 0 && p.complete === false; })());
+  check('complete:false 透传为 false(不许删)', parseHonorRecords({ complete: false, records: [] }).complete === false);
+
+  // —— 键 ——
+  check('buildHonorKey 三字段拼接', buildHonorKey('A', '国家级', 'X') === 'A|国家级|X');
+  check('buildHonorKey 缺字段留空', buildHonorKey('A', null, undefined) === 'A||');
+
+  const records = parsed.records; // 高新技术企业 | 独角兽企业
+
+  // —— 全量对齐 ——
+  /** 灌入行:荣誉名只在系统字段 `title` 里(DMS 侧 09-17 灌的那批就是这样,没有 c_tag_name) */
+  const seededRow = (id, title, level, source, extra = {}) => ({ id, title, c_level: level, c_source: source, ...extra });
+  /** 已对齐过的行:title 与 c_tag_name 都有 */
+  const alignedRow = (id, title, level, source, extra = {}) =>
+    seededRow(id, title, level, source, { [HONOR_TAG_FIELD]: title, ...extra });
+
   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]: '没有来源标记' },
+    alignedRow('u1', '高新技术企业', '国家级', '某认定公告', { c_publish_office: '科技部', c_publish_date: '2025-01-02', c_certificate_code: 'CERT-1' }),
+    alignedRow('u2', '独角兽企业', '省级', '2026 浙江独角兽', { c_publish_date: '2026-04-23' }),
+    seededRow('u3', '过期的荣誉', '市级', '老名单 2019'),
   ];
-  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);
+  const aligned = diffHonorRows(rows, records, { allowDelete: true });
+  check('已对齐的行不产生增/改(幂等)', aligned.toAdd.length === 0 && aligned.toUpdate.length === 0, { add: aligned.toAdd.length, upd: aligned.toUpdate.length, patch: aligned.toUpdate[0]?.patch });
+  check('库里多出来的 → toDelete(带 id 与键)', aligned.toDelete.length === 1 && aligned.toDelete[0].id === 'u3', aligned.toDelete);
+  check('无 malformed', aligned.malformed === 0, aligned.malformed);
+
+  const noDelete = diffHonorRows(rows, records, { allowDelete: false });
+  check('**complete=false 时绝不删**', noDelete.toDelete.length === 0 && noDelete.toAdd.length === 0, noDelete);
+
+  // 灌入行(只有 title、没有 c_tag_name)→ 首次对齐只补那一列
+  const seedOnly = diffHonorRows(
+    [seededRow('u1', '高新技术企业', '国家级', '某认定公告', { c_publish_office: '科技部', c_publish_date: '2025-01-02', c_certificate_code: 'CERT-1' })],
+    [records[0]],
+    { allowDelete: true }
+  );
+  check('灌入行首次对齐:只补 c_tag_name,不动其它列', seedOnly.toUpdate.length === 1 && JSON.stringify(Object.keys(seedOnly.toUpdate[0].patch)) === JSON.stringify([HONOR_TAG_FIELD]), seedOnly.toUpdate[0]?.patch);
+
+  // 差异 → update(只 patch 有差异的列)
+  const staleRow = [seededRow('u1', '高新技术企业', '国家级', '某认定公告', { c_publish_office: '旧机构' })];
+  const upd = diffHonorRows(staleRow, [records[0]], { allowDelete: true });
+  check('同键字段有差异 → toUpdate,只含差异列', upd.toUpdate.length === 1 && upd.toUpdate[0].patch.c_publish_office === '科技部' && upd.toUpdate[0].patch.title === undefined, upd.toUpdate);
+  check('title 与荣誉名一致时不重复 patch', upd.toUpdate[0].patch.title === undefined);
+  check('已匹配的行若缺 c_tag_name 也会补上', upd.toUpdate[0].patch[HONOR_TAG_FIELD] === '高新技术企业', upd.toUpdate[0].patch);
+
+  const titleNull = [seededRow('u9', 'null', '国家级', '某认定公告', { [HONOR_TAG_FIELD]: '高新技术企业' })];
+  const fixTitle = diffHonorRows(titleNull, [records[0]], { allowDelete: true });
+  check('title 是字符串 "null" → 用 c_tag_name 认名字,并把 title 修正', fixTitle.toUpdate.length === 1 && fixTitle.toUpdate[0].patch.title === '高新技术企业', fixTitle);
+  check('字符串 "null" 不被当成荣誉名(键能对上)', fixTitle.toAdd.length === 0);
+
+  // 新增
+  const empty = diffHonorRows([], records, { allowDelete: true });
+  check('库里没有 → 全部 toAdd', empty.toAdd.length === 2 && empty.toDelete.length === 0, empty);
+
+  // 载荷内部重复
+  const dup = parseHonorRecords({ complete: true, records: [{ data: { Name: 'A', Level: '国家级', Source: 'X' } }, { data: { Name: 'A', Level: '国家级', Source: 'X' } }] });
+  const dupDiff = diffHonorRows([], dup.records, { allowDelete: true });
+  check('载荷内重复的荣誉只算一次', dupDiff.toAdd.length === 1, dupDiff.toAdd);
+
+  // 缺 id 的行
+  const noId = diffHonorRows([{ title: '高新技术企业', c_level: '国家级', c_source: '某认定公告' }], records, { allowDelete: true });
+  check('缺 uuid 的行 → malformed,不匹配也不删', noId.malformed === 1 && noId.toAdd.length === 2 && noId.toDelete.length === 0, noId);
+
+  // 内容
+  const content = buildHonorRowContent('CODE-X', '合成公司', records[0], '2026-09-18 18:00:00');
+  check('荣誉行内容:title 与 c_tag_name 都写荣誉名', content.title === '高新技术企业' && content[HONOR_TAG_FIELD] === '高新技术企业', content);
+  check('荣誉行内容:级别/来源/机构/日期/证书编号各就各位', content.c_level === '国家级' && content.c_source === '某认定公告' && content.c_publish_office === '科技部' && content.c_publish_date === '2025-01-02' && content.c_certificate_code === 'CERT-1', content);
+  check('荣誉行内容:必填与归属', content.c_credit_code === 'CODE-X' && content.c_name === '合成公司' && content.c_id === 0, content);
+  check('空字段不写进行(不落空值)', !('c_dead_line' in content) && !('c_beging_date' in content), content);
 }
 
 /* ---------------------------------------------------------------- *
@@ -201,19 +255,10 @@ console.log('\n【5】失败角度与保守降级');
   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('errors 键对不上角度 → unknown 置位', extractFailedAngles(weird).unknown === true);
 
-  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"]');
+  check('partial 但无 errors → unknown(不知道哪些可信)', extractFailedAngles({ status: 'partial', categories: {} }).unknown === true);
+  check('completed + 空 errors → 不降级', extractFailedAngles({ status: 'completed', errors: {} }).unknown === false);
 }
 
 /* ---------------------------------------------------------------- *

+ 157 - 59
src/network/api/dms/classification-sync-utils.ts

@@ -5,9 +5,10 @@
  * (`POST /api/company/classify`),把结果落到 DMS 两个企业栏目:
  *   - **1888 企业基础信息**:一行一家企业(幂等键 `c_credit_code`);六个分类角度
  *     各存一个字段(`c_tag_*`),值是 JSON 数组字符串(空数组存 `"[]"`)
- *   - **1886 企业荣誉信息**:**一个资质荣誉标签一行**,用
- *     `c_source = company_info_sync` 标记「这行是本同步写的」,只增删这些行,
- *     迁移数据或其它来源的行一律不碰
+ *   - **1886 企业荣誉信息**:**一条真实荣誉一行**,数据源是 `company_info.honors.records`
+ *     (**不是**分类标签——标签太粗,实测一家公司只给 2 个,而真实荣誉有 31 条);
+ *     与库里该企业的行按「荣誉名+级别+来源」**全量对齐**(补新增、更新差异、删多余,
+ *     删只在 `honors.complete === true` 时做)
  *
  * 本文件只放类型与纯函数:**不 import vue、不发网络请求**(只 `import type`),
  * 这样 harness 的 esbuild 脚本能直接打包它做断言。
@@ -42,12 +43,9 @@ export const ANGLE_TO_FIELD: Record<ClassifyAngle, string> = {
   许可认证: "c_tag_license",
 };
 
-/** 1886 里存标签文本的字段(DMS 侧 2026-09-18 新增,别名「荣誉」) */
+/** 1886 里存荣誉名的自有列(DMS 侧 2026-09-18 新增,别名「荣誉」);系统字段 `title` 同时写 */
 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;
@@ -78,6 +76,10 @@ const pickString = (value: unknown): string | null => {
   return s ? s : null;
 };
 
+/** 从 `company_info` 里取 `honors` 节点(荣誉同步用;不是对象就返回 null) */
+export const readHonorsFromCompanyInfo = (info: unknown): unknown =>
+  asRecord(info)?.honors ?? null;
+
 /** 只认普通对象(数组/null 不算) */
 const asRecord = (value: unknown): Record<string, unknown> | null =>
   value && typeof value === "object" && !Array.isArray(value)
@@ -273,76 +275,109 @@ export const diffEnterpriseRow = (
 };
 
 /* ------------------------------------------------------------------ *
- * 1886 集合同步
+ * 1886 荣誉同步(数据源 = `company_info.honors.records`)
  * ------------------------------------------------------------------ */
 
 /**
- * 期望的荣誉标签集
+ * 一条荣誉(字段名沿用企查查原始结构里 `records[].data` 的写法)
  *
- * - 资质荣誉角度失败 → **null**(跳过整个 1886 同步:失败 ≠ 没有荣誉,
- *   拿空集去删会把上一轮写对的标签删光)
- * - `conservative`(errors 不可解析)→ 同样 null
- * - 正常(含 completed 且为空数组)→ 数组;**空数组是合法的「确实没有标签」**,
- *   调用方会据此删光我方旧行
+ * ⚠️ **数据源是 `company_info.honors`,不是分类接口的「资质荣誉」角度**——
+ * 后者是 165 个标签里挑出来的粗标签(实测宇树科技只给 2 个),
+ * 而 `honors.records` 才是逐条的真实荣誉(同一家 31 条,含级别/发布机构/日期/证书编号),
+ * 且与 DMS 里既有的行字段一一对应。2026-09-18 按用户要求改为此源。
  */
-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 CompanyHonorRecord {
+  name: string;
+  level: string | null;
+  source: string | null;
+  publishOffice: string | null;
+  publishDate: string | null;
+  begingDate: string | null;
+  deadLine: string | null;
+  certificateCode: string | null;
+}
+
+/** 匹配键:荣誉名 + 级别 + 来源(三者都来自后端,是同一份数据的两端) */
+export const buildHonorKey = (name: unknown, level: unknown, source: unknown): string =>
+  [pickString(name) ?? "", pickString(level) ?? "", pickString(source) ?? ""].join("|");
+
+/** 荣誉字段 → 1886 列名(`name` 除映射列外,还要写进系统字段 `title`,见下) */
+export const HONOR_FIELD_MAP: Record<keyof Omit<CompanyHonorRecord, "name">, string> = {
+  level: "c_level",
+  source: "c_source",
+  publishOffice: "c_publish_office",
+  publishDate: "c_publish_date",
+  begingDate: "c_beging_date",
+  deadLine: "c_dead_line",
+  certificateCode: "c_certificate_code",
 };
 
-export interface HonorRow {
-  /** DMS 记录 uuid(**不是** `c_id`) */
-  id: string;
-  tag: string;
-}
+/** 读出荣誉行里的荣誉名:优先 `c_tag_name`,回退系统字段 `title`(灌入行写在 title) */
+const readHonorName = (row: DmsRow | null | undefined): string | null => {
+  const tag = pickString(row?.[HONOR_TAG_FIELD]);
+  if (tag) return tag;
+  const title = pickString(row?.title);
+  // 历史遗留:一度没写 title,DMS 落的是字符串 "null",别把它当荣誉名
+  return title && title !== "null" ? title : null;
+};
 
 /**
- * 按所有权拆分荣誉行:只有 `c_source = company_info_sync` 且带 id 与标签的行归我们管。
- * 其余(迁移数据、手工录入)一律不参与增删——**绝不碰别人的数据**。
+ * 解析 `company_info.honors`。
+ *
+ * - `honors` 缺失/null → `available=false`,调用方**整块跳过** 1886 同步
+ * - `complete !== true` → 调用方**只增改、不删除**(记录集不完整时删 = 丢数据)
+ * - 没有 `Name` 的记录直接丢弃(没名字的荣誉行没有意义)
  */
-export const partitionHonorRows = (
-  rows: readonly DmsRow[]
-): { owned: HonorRow[]; foreign: number; malformed: number } => {
-  const owned: HonorRow[] = [];
-  let foreign = 0;
+export const parseHonorRecords = (
+  honors: unknown
+): { records: CompanyHonorRecord[]; complete: boolean; available: boolean; malformed: number } => {
+  const node = asRecord(honors);
+  if (!node) return { records: [], complete: false, available: false, malformed: 0 };
+
+  const list = Array.isArray(node.records) ? node.records : [];
+  const records: CompanyHonorRecord[] = [];
   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
+  for (const item of list) {
+    const data = asRecord(asRecord(item)?.data);
+    const name = pickString(data?.Name);
+    if (!data || !name) {
+      malformed += 1;
       continue;
     }
-    owned.push({ id, tag });
+    records.push({
+      name,
+      level: pickString(data.Level),
+      source: pickString(data.Source),
+      publishOffice: pickString(data.PublishOffice),
+      publishDate: pickString(data.PublishDate),
+      begingDate: pickString(data.BegingDate),
+      deadLine: pickString(data.DeadLine),
+      certificateCode: pickString(data.CertificateCode),
+    });
   }
-  return { owned, foreign, malformed };
+  return { records, complete: node.complete === true, available: true, 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 };
+/** 现有行与一条荣誉的字段差异;无差异返回 null(调用方据此跳过 update) */
+const diffHonorFields = (row: DmsRow, record: CompanyHonorRecord): DmsRow | null => {
+  const patch: DmsRow = {};
+  for (const [key, field] of Object.entries(HONOR_FIELD_MAP)) {
+    const next = record[key as keyof Omit<CompanyHonorRecord, "name">];
+    if (!next) continue; // 载荷没有的字段不写(不清存量)
+    if (pickString(row?.[field]) !== next) patch[field] = next;
+  }
+  // 荣誉名两处都要有:自有列 `c_tag_name` + 系统字段 `title`(DMS 列表靠后者显示)。
+  // 灌入的行只有 title、没有 c_tag_name —— 首次对齐时要把这一列补上。
+  if (pickString(row?.[HONOR_TAG_FIELD]) !== record.name) patch[HONOR_TAG_FIELD] = record.name;
+  if (pickString(row?.title) !== record.name) patch.title = record.name;
+  return Object.keys(patch).length ? patch : null;
 };
 
 /** 新荣誉行的内容(c_credit_code 必填;c_id 见计划文件里的说明) */
 export const buildHonorRowContent = (
   creditCode: string,
-  name: string | null,
-  tag: string,
+  companyName: string | null,
+  record: CompanyHonorRecord,
   createdAt: string
 ): DmsRow => {
   const row: DmsRow = {
@@ -351,10 +386,73 @@ export const buildHonorRowContent = (
     // 若被 214 拒绝,需要 DMS 侧放宽该字段或给一个前端可用的幂等键(见计划文件)。
     c_id: 0,
     c_credit_code: creditCode,
-    c_source: HONOR_SYNC_SOURCE,
-    [HONOR_TAG_FIELD]: tag,
     c_created_at: createdAt,
+    [HONOR_TAG_FIELD]: record.name,
+    title: record.name,
   };
-  if (name) row.c_name = name;
+  for (const [key, field] of Object.entries(HONOR_FIELD_MAP)) {
+    const value = record[key as keyof Omit<CompanyHonorRecord, "name">];
+    if (value) row[field] = value;
+  }
+  if (companyName) row.c_name = companyName;
   return row;
 };
+
+/**
+ * 与库里该企业的荣誉行**全量对齐**:按「荣誉名+级别+来源」匹配。
+ *
+ * - 库里没有的 → `toAdd`
+ * - 同键但字段有差异 → `toUpdate`(只 patch 有差异的列;`title` 也跟荣誉名走)
+ * - 库里多出来的 → `toDelete`(**仅当 `allowDelete`**:载荷 `complete !== true` 时不许删)
+ *
+ * ⚠️ 与上一版的区别:上一版只认「自己写的行」(`c_source=company_info_sync`),
+ * 库里灌入的行一律不碰;那个做法在**库里已有同一份数据**时会让两边的行并存
+ * (宇树:28 条灌入 + 2 条标签)。现在按用户 2026-09-18 的要求改为全量对齐。
+ */
+export const diffHonorRows = (
+  existing: readonly DmsRow[],
+  records: readonly CompanyHonorRecord[],
+  options: { allowDelete: boolean }
+): {
+  toAdd: CompanyHonorRecord[];
+  toUpdate: Array<{ id: string; record: CompanyHonorRecord; patch: DmsRow }>;
+  toDelete: Array<{ id: string; key: string }>;
+  malformed: number;
+} => {
+  const existingByKey = new Map<string, DmsRow>();
+  let malformed = 0;
+  for (const row of existing) {
+    const name = readHonorName(row);
+    const id = typeof row?.id === "string" ? row.id : "";
+    if (!name || !id) {
+      malformed += 1; // 缺 id 或认不出荣誉名的行:不匹配、不更新、也不删(保守)
+      continue;
+    }
+    existingByKey.set(buildHonorKey(name, row.c_level, row.c_source), row);
+  }
+
+  const toAdd: CompanyHonorRecord[] = [];
+  const toUpdate: Array<{ id: string; record: CompanyHonorRecord; patch: DmsRow }> = [];
+  const matched = new Set<string>();
+  for (const record of records) {
+    const key = buildHonorKey(record.name, record.level, record.source);
+    if (matched.has(key)) continue; // 载荷内部自身去重
+    matched.add(key);
+    const hit = existingByKey.get(key);
+    if (!hit) {
+      toAdd.push(record);
+      continue;
+    }
+    const patch = diffHonorFields(hit, record);
+    if (patch) toUpdate.push({ id: hit.id as string, record, patch });
+  }
+
+  const toDelete: Array<{ id: string; key: string }> = [];
+  if (options.allowDelete) {
+    for (const [key, row] of existingByKey) {
+      if (!matched.has(key)) toDelete.push({ id: row.id as string, key });
+    }
+  }
+
+  return { toAdd, toUpdate, toDelete, malformed };
+};

+ 90 - 64
src/network/api/dms/company-info-sync.ts

@@ -1,17 +1,20 @@
 /**
- * 对话带回的企业资料 → 分类接口 → 落到 DMS 两个企业栏目。
+ * 对话带回的企业资料 → 分类接口 + 荣誉列表 → 落到 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 同步**:失败 ≠ 没有荣誉,
- *   拿空集去删会把上一轮写对的标签删光
+ * 两个栏目**数据源不同、互不依赖**(一条失败不影响另一条):
+ *
+ * - **1888 企业基础信息** ← 分类接口(`POST /api/company/classify`)的六个角度标签。
+ *   一行一家企业,幂等键 `c_credit_code`;没有就新增,有就按 `diffEnterpriseRow` 的差异更新。
+ * - **1886 企业荣誉信息** ← **`company_info.honors.records`**(逐条真实荣誉)。
+ *   一条荣誉一行,与库里该企业的行按「荣誉名+级别+来源」**全量对齐**:
+ *   补新增、更新有差异的列、删库里多出来的(**仅在 `honors.complete === true` 时删**)。
+ *
+ * ⚠️ 2026-09-18 用户确认的调整:1886 原先写「分类接口的资质荣誉标签」,
+ * 但那只是 165 个标签里挑出的粗标签(实测宇树科技只有 2 个),而同一家的
+ * `honors.records` 有 31 条完整荣誉 —— 改为以后者为准;分类标签仍写进 1888 的 `c_tag_honor`。
  *
  * ⚠️ 全程 fire-and-forget:任何失败只 `console.warn`,不抛异常、不阻塞聊天。
  * ⚠️ 同一企业(同一信用代码)的多轮同步串行排队,避免并发反查各写一行。
@@ -35,19 +38,19 @@ import { nowDmsTimestamp } from "./chat-sessions-dms";
 import {
   buildEnterpriseRow,
   buildHonorRowContent,
-  buildHonorTags,
   diffEnterpriseRow,
   diffHonorRows,
   extractFailedAngles,
   extractIdentityFromCompanyInfo,
   isCompanyInfoUsable,
-  partitionHonorRows,
+  parseHonorRecords,
+  readHonorsFromCompanyInfo,
   resolveSyncedIdentity,
   type CompanyClassification,
   type CompanyIdentity,
 } from "./classification-sync-utils";
 
-/** 1886 一次性拉取的行数上限(标签最多几十个,200 足够) */
+/** 1886 一次性拉取的行数上限(一家企业的荣誉通常几十条,200 足够) */
 const HONOR_PAGE_SIZE = 200;
 
 /**
@@ -58,35 +61,40 @@ 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> {
+  // ① 荣誉(数据源就是 company_info.honors,不依赖分类接口,先跑——它不需要等 LLM)
+  try {
+    await applyCompanyHonors(readHonorsFromCompanyInfo(companyInfo), identity);
+  } catch (err) {
+    console.warn("[company-sync] 荣誉同步异常", err);
+  }
+
+  // ② 基础信息(需要分类结果)
   try {
     const classification = await classifyCompany(companyInfo);
     if (!classification) return; // classifyCompany 里已经 warn 过
-
     if (classification.status === "failed") {
-      console.warn("[company-sync] 分类失败(status=failed),跳过同步", classification.errors);
+      console.warn("[company-sync] 分类失败(status=failed),跳过 1888 同步", classification.errors);
       return;
     }
-
     await applyCompanyClassification(classification, identity);
   } catch (err) {
-    console.warn("[company-sync] 同步过程异常", err);
+    console.warn("[company-sync] 基础信息同步异常", err);
   }
 }
 
 /**
- * 拿到分类结果后落库。**单独导出**是为了让验证脚本能注入假的分类响应
- * 直接打真实 DMS(分类接口自身的 422 问题见计划文件,不阻塞这一段)。
+ * 1888:拿到分类结果后落库。**单独导出**是为了让验证脚本能注入假的分类响应
+ * 直接打真实 DMS(可反复跑,不消耗分类接口的 LLM 调用)。
  */
 export const applyCompanyClassification = async (
   classification: CompanyClassification,
@@ -95,27 +103,81 @@ export const applyCompanyClassification = async (
   const identity = resolveSyncedIdentity(classification, identityHint);
   const creditCode = identity.creditCode;
   if (!creditCode) {
-    // 1888 的幂等键、1886 的必填字段都是它,没有就什么都写不了
-    console.warn("[company-sync] 分类结果缺少统一社会信用代码,跳过同步");
+    // 1888 的幂等键是它,没有就什么都写不了
+    console.warn("[company-sync] 分类结果缺少统一社会信用代码,跳过 1888 同步");
     return;
   }
 
   const { failed, unknown } = extractFailedAngles(classification);
   if (unknown) {
-    console.warn("[company-sync] 分类错误无法对应到角度,降级为保守同步(只写非空角度、跳过荣誉)");
+    console.warn("[company-sync] 分类错误无法对应到角度,降级为保守同步(只写非空角度)");
   }
 
   await upsertEnterpriseRow(
     creditCode,
     buildEnterpriseRow(classification, { failed, conservative: unknown, identityHint })
   );
+};
+
+/**
+ * 1886:拿 `company_info.honors` 与库里该企业的荣誉行全量对齐。
+ * **单独导出**供验证脚本注入假的 honors 直接打真实 DMS。
+ */
+export const applyCompanyHonors = async (
+  honors: unknown,
+  identityHint?: CompanyIdentity | null
+): Promise<void> => {
+  const { records, complete, available, malformed } = parseHonorRecords(honors);
+  if (!available) return; // honors 缺失/null:常态,静默跳过
+  if (malformed) {
+    console.warn("[company-sync] 有荣誉记录缺 data/Name,已跳过这些条", malformed);
+  }
 
-  const honorTags = buildHonorTags(classification, { failed, conservative: unknown });
-  if (honorTags === null) {
-    console.warn("[company-sync] 资质荣誉角度不可信,跳过 1886 同步");
+  const creditCode = identityHint?.creditCode || null;
+  if (!creditCode) {
+    // 1886 的 c_credit_code 是必填,没有就写不了
+    console.warn("[company-sync] 缺少统一社会信用代码,跳过 1886 同步");
     return;
   }
-  await syncHonorRows(creditCode, identity.name, honorTags);
+  if (!complete) {
+    console.warn("[company-sync] honors.complete=false(记录集不完整):只增改、不删除");
+  }
+
+  const rows = await searchDmsContents(DMS_COLUMN_HONOR, {
+    search: [{ field: "c_credit_code", searchType: 1, content: { value: creditCode } }],
+    page: 0,
+    pageSize: HONOR_PAGE_SIZE,
+  });
+
+  const { toAdd, toUpdate, toDelete, malformed: badRows } = diffHonorRows(rows, records, {
+    allowDelete: complete,
+  });
+  if (badRows) {
+    console.warn("[company-sync] 有我方荣誉行缺 id/荣誉名,已跳过(不匹配、不更新、不删)", badRows);
+  }
+  if (!toAdd.length && !toUpdate.length && !toDelete.length) return; // 完全一致:零写入
+
+  const companyName = identityHint?.name ?? null;
+  const createdAt = nowDmsTimestamp();
+
+  for (const record of toAdd) {
+    const id = await addDmsContent(
+      DMS_COLUMN_HONOR,
+      DMS_MODEL_HONOR,
+      buildHonorRowContent(creditCode, companyName, record, createdAt)
+    );
+    if (!id) console.warn("[company-sync] 荣誉行新增失败", creditCode, record.name);
+  }
+
+  for (const item of toUpdate) {
+    const ok = await updateDmsContent(DMS_COLUMN_HONOR, DMS_MODEL_HONOR, item.id, item.patch);
+    if (!ok) console.warn("[company-sync] 荣誉行更新失败", creditCode, item.record.name, item.patch);
+  }
+
+  for (const item of toDelete) {
+    const ok = await deleteDmsContent(DMS_COLUMN_HONOR, item.id);
+    if (!ok) console.warn("[company-sync] 荣誉行删除失败", creditCode, item.key);
+  }
 };
 
 /** 1888:没有就新增,有就按差异更新(无差异零写入) */
@@ -145,39 +207,3 @@ async function upsertEnterpriseRow(creditCode: string, desired: DmsRow): Promise
   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);
-  }
-}

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott