/** * 对话带回的企业资料 → 分类接口 → 落到 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 { 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 => { 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 { 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 { 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); } }