company-info-sync.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. /**
  2. * 对话带回的企业资料 → 分类接口 → 落到 DMS 两个企业栏目。
  3. *
  4. * 触发:`/api/chat` 的 `result` 事件里 `data.company_info` 非空时,
  5. * 由 `useBusinessAssistantChat` 在回答完成后调 `syncCompanyInfoFromChat`(fire-and-forget)。
  6. *
  7. * 落库规则(详见 `harness/docs/exec-plans/active/company-classify-dms-sync.md`):
  8. * - **1888 企业基础信息**:一行一家企业,幂等键 `c_credit_code`;没有就新增,
  9. * 有就按 `diffEnterpriseRow` 算出的差异更新(无差异则零写入)
  10. * - **1886 企业荣誉信息**:一个资质荣誉标签一行;只增删「本轮应有的标签集」与
  11. * 「我们自己写过的行(`c_source = company_info_sync`)」的差集,
  12. * 迁移数据等其它来源的行**一律不碰**
  13. * - 「资质荣誉」角度失败或错误不可解析时**跳过整个 1886 同步**:失败 ≠ 没有荣誉,
  14. * 拿空集去删会把上一轮写对的标签删光
  15. *
  16. * ⚠️ 全程 fire-and-forget:任何失败只 `console.warn`,不抛异常、不阻塞聊天。
  17. * ⚠️ 同一企业(同一信用代码)的多轮同步串行排队,避免并发反查各写一行。
  18. */
  19. import {
  20. DMS_COLUMN_ENTERPRISE,
  21. DMS_MODEL_ENTERPRISE,
  22. DMS_COLUMN_HONOR,
  23. DMS_MODEL_HONOR,
  24. addDmsContent,
  25. deleteDmsContent,
  26. enqueueDmsWrite,
  27. findDmsRowBy,
  28. searchDmsContents,
  29. updateDmsContent,
  30. type DmsRow,
  31. } from "./client";
  32. import { classifyCompany } from "@/network/api/company-classification";
  33. import { nowDmsTimestamp } from "./chat-sessions-dms";
  34. import {
  35. buildEnterpriseRow,
  36. buildHonorRowContent,
  37. buildHonorTags,
  38. diffEnterpriseRow,
  39. diffHonorRows,
  40. extractFailedAngles,
  41. extractIdentityFromCompanyInfo,
  42. isCompanyInfoUsable,
  43. partitionHonorRows,
  44. resolveSyncedIdentity,
  45. type CompanyClassification,
  46. type CompanyIdentity,
  47. } from "./classification-sync-utils";
  48. /** 1886 一次性拉取的行数上限(标签最多几十个,200 足够) */
  49. const HONOR_PAGE_SIZE = 200;
  50. /**
  51. * 入口:对话拿到 `company_info` 后调用。
  52. * 没有可用资料时**静默返回**(多数轮都没有,属常态,不刷日志)。
  53. */
  54. export const syncCompanyInfoFromChat = (companyInfo: unknown): void => {
  55. if (!isCompanyInfoUsable(companyInfo)) return;
  56. const identity = extractIdentityFromCompanyInfo(companyInfo);
  57. // 串行键优先用信用代码(同企业串行、不同企业可并行)——
  58. // 分类还没跑,此时只能用 company_info 自己带的
  59. const chainKey = identity?.creditCode || "global";
  60. void enqueueDmsWrite(`company:${chainKey}`, () => runCompanyInfoSync(companyInfo, identity));
  61. };
  62. /** 分类 → 落库(内部;异常一律吞掉,只 warn) */
  63. async function runCompanyInfoSync(
  64. companyInfo: unknown,
  65. identity: CompanyIdentity | null
  66. ): Promise<void> {
  67. try {
  68. const classification = await classifyCompany(companyInfo);
  69. if (!classification) return; // classifyCompany 里已经 warn 过
  70. if (classification.status === "failed") {
  71. console.warn("[company-sync] 分类失败(status=failed),跳过同步", classification.errors);
  72. return;
  73. }
  74. await applyCompanyClassification(classification, identity);
  75. } catch (err) {
  76. console.warn("[company-sync] 同步过程异常", err);
  77. }
  78. }
  79. /**
  80. * 拿到分类结果后落库。**单独导出**是为了让验证脚本能注入假的分类响应
  81. * 直接打真实 DMS(分类接口自身的 422 问题见计划文件,不阻塞这一段)。
  82. */
  83. export const applyCompanyClassification = async (
  84. classification: CompanyClassification,
  85. identityHint?: CompanyIdentity | null
  86. ): Promise<void> => {
  87. const identity = resolveSyncedIdentity(classification, identityHint);
  88. const creditCode = identity.creditCode;
  89. if (!creditCode) {
  90. // 1888 的幂等键、1886 的必填字段都是它,没有就什么都写不了
  91. console.warn("[company-sync] 分类结果缺少统一社会信用代码,跳过同步");
  92. return;
  93. }
  94. const { failed, unknown } = extractFailedAngles(classification);
  95. if (unknown) {
  96. console.warn("[company-sync] 分类错误无法对应到角度,降级为保守同步(只写非空角度、跳过荣誉)");
  97. }
  98. await upsertEnterpriseRow(
  99. creditCode,
  100. buildEnterpriseRow(classification, { failed, conservative: unknown, identityHint })
  101. );
  102. const honorTags = buildHonorTags(classification, { failed, conservative: unknown });
  103. if (honorTags === null) {
  104. console.warn("[company-sync] 资质荣誉角度不可信,跳过 1886 同步");
  105. return;
  106. }
  107. await syncHonorRows(creditCode, identity.name, honorTags);
  108. };
  109. /** 1888:没有就新增,有就按差异更新(无差异零写入) */
  110. async function upsertEnterpriseRow(creditCode: string, desired: DmsRow): Promise<void> {
  111. const existing = await findDmsRowBy(DMS_COLUMN_ENTERPRISE, "c_credit_code", creditCode);
  112. if (!existing) {
  113. const content: DmsRow = {
  114. ...desired,
  115. // desired 里可能回退到了前端抽的 identity,这里兜底保证幂等键一定在
  116. c_credit_code: creditCode,
  117. c_created_at: nowDmsTimestamp(),
  118. };
  119. const id = await addDmsContent(DMS_COLUMN_ENTERPRISE, DMS_MODEL_ENTERPRISE, content);
  120. if (!id) console.warn("[company-sync] 企业基础信息新增失败", creditCode);
  121. return;
  122. }
  123. const patch = diffEnterpriseRow(existing, desired);
  124. if (!patch) return; // 无变化:不写
  125. const id = typeof existing.id === "string" ? existing.id : "";
  126. if (!id) {
  127. console.warn("[company-sync] 企业基础信息行缺少 uuid,无法更新", existing);
  128. return;
  129. }
  130. const ok = await updateDmsContent(DMS_COLUMN_ENTERPRISE, DMS_MODEL_ENTERPRISE, id, patch);
  131. if (!ok) console.warn("[company-sync] 企业基础信息更新失败", creditCode, patch);
  132. }
  133. /** 1886:按「应有的标签集」增删我们自己写过的行(先增后删,任一步失败不影响另一步) */
  134. async function syncHonorRows(
  135. creditCode: string,
  136. name: string | null,
  137. tags: string[]
  138. ): Promise<void> {
  139. const rows = await searchDmsContents(DMS_COLUMN_HONOR, {
  140. search: [{ field: "c_credit_code", searchType: 1, content: { value: creditCode } }],
  141. page: 0,
  142. pageSize: HONOR_PAGE_SIZE,
  143. });
  144. const { owned, malformed } = partitionHonorRows(rows);
  145. if (malformed) {
  146. console.warn("[company-sync] 有我方荣誉行缺 id/标签,已跳过(不删也不计入已有)", malformed);
  147. }
  148. const { toAdd, toDelete } = diffHonorRows(owned, new Set(tags));
  149. if (!toAdd.length && !toDelete.length) return; // 集合一致:零写入
  150. const createdAt = nowDmsTimestamp();
  151. for (const tag of toAdd) {
  152. const id = await addDmsContent(
  153. DMS_COLUMN_HONOR,
  154. DMS_MODEL_HONOR,
  155. buildHonorRowContent(creditCode, name, tag, createdAt)
  156. );
  157. if (!id) console.warn("[company-sync] 荣誉行新增失败", creditCode, tag);
  158. }
  159. for (const row of toDelete) {
  160. const ok = await deleteDmsContent(DMS_COLUMN_HONOR, row.id);
  161. if (!ok) console.warn("[company-sync] 荣誉行删除失败", creditCode, row.tag);
  162. }
  163. }