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