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