/** * 企业分类同步的**纯逻辑层**。 * * 背景:对话 `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 = { 基本信息: "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 | 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 | null => value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : 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(); 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; unknown: boolean } => { const failed = new Set(); 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; 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(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; 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 ): { 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; };