classification-sync-utils.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. /**
  2. * 企业分类同步的**纯逻辑层**。
  3. *
  4. * 背景:对话 `result` 事件带回 `company_info` 时,前端调后端分类接口
  5. * (`POST /api/company/classify`),把结果落到 DMS 两个企业栏目:
  6. * - **1888 企业基础信息**:一行一家企业(幂等键 `c_credit_code`);六个分类角度
  7. * 各存一个字段(`c_tag_*`),值是 JSON 数组字符串(空数组存 `"[]"`)
  8. * - **1886 企业荣誉信息**:**一个资质荣誉标签一行**,用
  9. * `c_source = company_info_sync` 标记「这行是本同步写的」,只增删这些行,
  10. * 迁移数据或其它来源的行一律不碰
  11. *
  12. * 本文件只放类型与纯函数:**不 import vue、不发网络请求**(只 `import type`),
  13. * 这样 harness 的 esbuild 脚本能直接打包它做断言。
  14. * 契约见 `harness/docs/reference/company-classification.md` 与 `DMS_COLUMNS.md`。
  15. */
  16. import type { DmsRow } from "./client";
  17. /** 分类接口的六个角度(响应里就是这六个中文键) */
  18. export const CLASSIFY_ANGLE_KEYS = [
  19. "基本信息",
  20. "资质荣誉",
  21. "产业信息",
  22. "经营活动",
  23. "行业信息",
  24. "许可认证",
  25. ] as const;
  26. export type ClassifyAngle = (typeof CLASSIFY_ANGLE_KEYS)[number];
  27. /**
  28. * 角度 → 1888 的字段名。
  29. * ⚠️ 行业信息用 `c_tag_industry`:1888 里**已经有一个 `c_industry`**(源库的行业分类
  30. * JSON),不能占用那个名字。
  31. */
  32. export const ANGLE_TO_FIELD: Record<ClassifyAngle, string> = {
  33. 基本信息: "c_tag_basic",
  34. 资质荣誉: "c_tag_honor",
  35. 产业信息: "c_tag_sector",
  36. 经营活动: "c_tag_operation",
  37. 行业信息: "c_tag_industry",
  38. 许可认证: "c_tag_license",
  39. };
  40. /** 1886 里存标签文本的字段(DMS 侧 2026-09-18 新增,别名「荣誉」) */
  41. export const HONOR_TAG_FIELD = "c_tag_name";
  42. /** 1886 的所有权标记:只有 `c_source` 等于它、且 `c_credit_code` 匹配的行才归本同步管 */
  43. export const HONOR_SYNC_SOURCE = "company_info_sync";
  44. /** 从 company_info 里抽出来的身份三字段(都可能是 null) */
  45. export interface CompanyIdentity {
  46. name: string | null;
  47. creditCode: string | null;
  48. legalRep: string | null;
  49. }
  50. /** 分类接口响应(全字段可选:后端版本可能增减,读取一律容错) */
  51. export interface CompanyClassification {
  52. company_name?: string | null;
  53. credit_code?: string | null;
  54. legal_representative?: string | null;
  55. /** 角度 → 标签数组;缺失或失败的角度按文档是空数组 */
  56. categories?: Record<string, unknown> | null;
  57. status?: string | null;
  58. errors?: unknown;
  59. input_warnings?: unknown;
  60. }
  61. /* ------------------------------------------------------------------ *
  62. * 基础工具
  63. * ------------------------------------------------------------------ */
  64. /** 只认非空字符串(去空白后);其它类型(数字/对象/null)一律当作没有 */
  65. const pickString = (value: unknown): string | null => {
  66. if (typeof value !== "string") return null;
  67. const s = value.trim();
  68. return s ? s : null;
  69. };
  70. /** 只认普通对象(数组/null 不算) */
  71. const asRecord = (value: unknown): Record<string, unknown> | null =>
  72. value && typeof value === "object" && !Array.isArray(value)
  73. ? (value as Record<string, unknown>)
  74. : null;
  75. /* ------------------------------------------------------------------ *
  76. * 身份
  77. * ------------------------------------------------------------------ */
  78. /**
  79. * 从 `company_info` 抽身份三字段。
  80. *
  81. * 取值顺序与后端一致(见 classify 文档):`profile.data.Name/CreditCode/OperName` 优先,
  82. * 逐项回退 `company` 的同名字段;都没有则返回 null(调用方应跳过整条同步——
  83. * 没有统一社会信用代码就既不能查也不能写 1888)。
  84. */
  85. export const extractIdentityFromCompanyInfo = (info: unknown): CompanyIdentity | null => {
  86. const root = asRecord(info);
  87. if (!root) return null;
  88. const company = asRecord(root.company);
  89. const profile = asRecord(root.profile);
  90. const profileData = profile ? asRecord(profile.data) : null;
  91. const pick = (key: string) => pickString(profileData?.[key]) ?? pickString(company?.[key]);
  92. const identity: CompanyIdentity = {
  93. name: pick("Name"),
  94. creditCode: pick("CreditCode"),
  95. legalRep: pick("OperName"),
  96. };
  97. if (!identity.name && !identity.creditCode && !identity.legalRep) return null;
  98. return identity;
  99. };
  100. /** 有任何一个可取字段才值得发分类请求(多数轮没有 company_info,属常态) */
  101. export const isCompanyInfoUsable = (info: unknown): boolean =>
  102. extractIdentityFromCompanyInfo(info) !== null;
  103. /* ------------------------------------------------------------------ *
  104. * 标签编解码
  105. * ------------------------------------------------------------------ */
  106. /** 归一化标签:只留非空字符串、去首尾空白、去重(保持首次出现顺序) */
  107. export const normalizeTagSet = (tags: unknown): string[] => {
  108. if (!Array.isArray(tags)) return [];
  109. const out: string[] = [];
  110. const seen = new Set<string>();
  111. for (const raw of tags) {
  112. const tag = pickString(raw);
  113. if (!tag || seen.has(tag)) continue;
  114. seen.add(tag);
  115. out.push(tag);
  116. }
  117. return out;
  118. };
  119. /**
  120. * 编码成写库用的字符串。
  121. * `undefined` → `undefined`(表示「这个字段本轮不写」);
  122. * 其它(含 null / 非法)→ 归一化后 JSON,空集是 `"[]"`。
  123. */
  124. export const encodeAngleTags = (tags: unknown): string | undefined => {
  125. if (tags === undefined) return undefined;
  126. return JSON.stringify(normalizeTagSet(tags));
  127. };
  128. /** 解码库里的值;非字符串 / 非法 JSON / 非数组 → `[]`(当作没有标签) */
  129. export const decodeAngleTags = (raw: unknown): string[] => {
  130. if (typeof raw !== "string" || !raw.trim()) return [];
  131. try {
  132. return normalizeTagSet(JSON.parse(raw));
  133. } catch {
  134. return [];
  135. }
  136. };
  137. /** 两个标签数组按**集合**比较(顺序与编码差异不算变化,避免每轮无谓 update) */
  138. export const sameTagSet = (a: readonly string[], b: readonly string[]): boolean => {
  139. if (a.length !== b.length) return false;
  140. const set = new Set(b);
  141. return a.every((x) => set.has(x));
  142. };
  143. /* ------------------------------------------------------------------ *
  144. * 分类结果 → 1888 行
  145. * ------------------------------------------------------------------ */
  146. /**
  147. * 哪些角度失败了(partial 时)。
  148. *
  149. * 文档只给了 `errors` 的**键名**含义,没给完整结构,所以这里按键名与六个角度求交集;
  150. * 出现对不上角度的键(或 partial 却一个角度都对不上)时置 `unknown`,
  151. * 由调用方降级为保守策略(只写非空角度、跳过 1886)。
  152. */
  153. export const extractFailedAngles = (
  154. classification: CompanyClassification | null
  155. ): { failed: Set<string>; unknown: boolean } => {
  156. const failed = new Set<string>();
  157. let unknown = false;
  158. const errors = asRecord(classification?.errors);
  159. if (errors) {
  160. for (const key of Object.keys(errors)) {
  161. if ((CLASSIFY_ANGLE_KEYS as readonly string[]).includes(key)) failed.add(key);
  162. else unknown = true;
  163. }
  164. }
  165. // partial 却一个失败角度都识别不出来 → 不知道哪些角度可信,按保守处理
  166. if (classification?.status === "partial" && failed.size === 0) unknown = true;
  167. return { failed, unknown };
  168. };
  169. /**
  170. * 最终采用的身份三字段。
  171. *
  172. * 以**分类响应**为准(后端按 `profile.data` → `company` 的顺序算好了),响应里为空时
  173. * 回退到前端自己从 `company_info` 抽的 `identityHint` —— 同一份数据的两个出口、
  174. * 不是猜。两个都拿不到就是 null(`creditCode` 为 null 时调用方应跳过整条同步:
  175. * 它既是 1888 的幂等键、又是 1886 的必填字段)。
  176. */
  177. export const resolveSyncedIdentity = (
  178. classification: CompanyClassification | null,
  179. identityHint?: CompanyIdentity | null
  180. ): CompanyIdentity => ({
  181. name: pickString(classification?.company_name) ?? identityHint?.name ?? null,
  182. creditCode: pickString(classification?.credit_code) ?? identityHint?.creditCode ?? null,
  183. legalRep: pickString(classification?.legal_representative) ?? identityHint?.legalRep ?? null,
  184. });
  185. /**
  186. * 组装 1888 期望行(只含「本轮要写」的字段,空值不带)。
  187. *
  188. * - 身份三字段:取 `resolveSyncedIdentity` 的结果;为空就不写该字段
  189. * (**绝不用 null 去清存量**)
  190. * - 六个标签字段:失败角度不写;`conservative`(errors 不可解析)时只写非空角度
  191. */
  192. export const buildEnterpriseRow = (
  193. classification: CompanyClassification | null,
  194. options: { failed: Set<string>; conservative: boolean; identityHint?: CompanyIdentity | null }
  195. ): DmsRow => {
  196. const identity = resolveSyncedIdentity(classification, options.identityHint);
  197. const row: DmsRow = {};
  198. if (identity.name) row.c_name = identity.name;
  199. if (identity.creditCode) row.c_credit_code = identity.creditCode;
  200. if (identity.legalRep) row.c_oper_name = identity.legalRep;
  201. const categories = asRecord(classification?.categories) ?? {};
  202. for (const angle of CLASSIFY_ANGLE_KEYS) {
  203. if (options.failed.has(angle)) continue; // 该角度失败:不写、不覆盖存量
  204. const encoded = encodeAngleTags(categories[angle]);
  205. if (encoded === undefined) continue;
  206. if (options.conservative && decodeAngleTags(encoded).length === 0) continue; // 保守:只写非空
  207. row[ANGLE_TO_FIELD[angle]] = encoded;
  208. }
  209. return row;
  210. };
  211. /* ------------------------------------------------------------------ *
  212. * 1888 diff
  213. * ------------------------------------------------------------------ */
  214. /** 标签字段集合(比较方式与身份字段不同:按集合比) */
  215. const TAG_FIELDS = new Set<string>(Object.values(ANGLE_TO_FIELD));
  216. /**
  217. * 期望行 vs 现有行 → 需要写的字段(无变化返回 null,调用方据此跳过 update)。
  218. *
  219. * - 标签字段:decode 后按**集合**比较,防 `"[]"` 与 `[]`、顺序不同这类假差异
  220. * - 身份字段:期望值非空且与现有值逐字不同才写;期望值为空一律不写(不清存量)
  221. */
  222. export const diffEnterpriseRow = (
  223. existing: DmsRow | null,
  224. desired: DmsRow
  225. ): DmsRow | null => {
  226. const patch: DmsRow = {};
  227. for (const [field, value] of Object.entries(desired)) {
  228. if (TAG_FIELDS.has(field)) {
  229. if (!sameTagSet(decodeAngleTags(existing?.[field]), decodeAngleTags(value))) {
  230. patch[field] = value;
  231. }
  232. continue;
  233. }
  234. const next = pickString(value);
  235. if (!next) continue;
  236. const current = pickString(existing?.[field]);
  237. if (current !== next) patch[field] = next;
  238. }
  239. return Object.keys(patch).length ? patch : null;
  240. };
  241. /* ------------------------------------------------------------------ *
  242. * 1886 集合同步
  243. * ------------------------------------------------------------------ */
  244. /**
  245. * 期望的荣誉标签集。
  246. *
  247. * - 资质荣誉角度失败 → **null**(跳过整个 1886 同步:失败 ≠ 没有荣誉,
  248. * 拿空集去删会把上一轮写对的标签删光)
  249. * - `conservative`(errors 不可解析)→ 同样 null
  250. * - 正常(含 completed 且为空数组)→ 数组;**空数组是合法的「确实没有标签」**,
  251. * 调用方会据此删光我方旧行
  252. */
  253. export const buildHonorTags = (
  254. classification: CompanyClassification | null,
  255. options: { failed: Set<string>; conservative: boolean }
  256. ): string[] | null => {
  257. if (options.failed.has("资质荣誉")) return null;
  258. if (options.conservative) return null;
  259. const categories = asRecord(classification?.categories);
  260. return normalizeTagSet(categories?.["资质荣誉"]);
  261. };
  262. export interface HonorRow {
  263. /** DMS 记录 uuid(**不是** `c_id`) */
  264. id: string;
  265. tag: string;
  266. }
  267. /**
  268. * 按所有权拆分荣誉行:只有 `c_source = company_info_sync` 且带 id 与标签的行归我们管。
  269. * 其余(迁移数据、手工录入)一律不参与增删——**绝不碰别人的数据**。
  270. */
  271. export const partitionHonorRows = (
  272. rows: readonly DmsRow[]
  273. ): { owned: HonorRow[]; foreign: number; malformed: number } => {
  274. const owned: HonorRow[] = [];
  275. let foreign = 0;
  276. let malformed = 0;
  277. for (const row of rows) {
  278. if (pickString(row?.c_source) !== HONOR_SYNC_SOURCE) {
  279. foreign += 1;
  280. continue;
  281. }
  282. const id = typeof row?.id === "string" ? row.id : "";
  283. const tag = pickString(row?.[HONOR_TAG_FIELD]);
  284. if (!id || !tag) {
  285. malformed += 1; // 我方行但缺 id/标签:既不删也不当作已存在,交给调用方 warn
  286. continue;
  287. }
  288. owned.push({ id, tag });
  289. }
  290. return { owned, foreign, malformed };
  291. };
  292. /** 集合 diff:要新增的标签、要删除的行(集合比较,与顺序无关) */
  293. export const diffHonorRows = (
  294. owned: readonly HonorRow[],
  295. desired: ReadonlySet<string>
  296. ): { toAdd: string[]; toDelete: HonorRow[] } => {
  297. const toDelete = owned.filter((row) => !desired.has(row.tag));
  298. const existingTags = new Set(owned.map((row) => row.tag));
  299. const toAdd = [...desired].filter((tag) => !existingTags.has(tag));
  300. return { toAdd, toDelete };
  301. };
  302. /** 新荣誉行的内容(c_credit_code 必填;c_id 见计划文件里的说明) */
  303. export const buildHonorRowContent = (
  304. creditCode: string,
  305. name: string | null,
  306. tag: string,
  307. createdAt: string
  308. ): DmsRow => {
  309. const row: DmsRow = {
  310. // ⚠️ 1886 的 c_id 是源库主键且 must=true,前端新写行拿不到源库 id。
  311. // 按 1889 的实测经验(must 只校验字段在不在、c_id 自动填 0)先传 0;
  312. // 若被 214 拒绝,需要 DMS 侧放宽该字段或给一个前端可用的幂等键(见计划文件)。
  313. c_id: 0,
  314. c_credit_code: creditCode,
  315. c_source: HONOR_SYNC_SOURCE,
  316. [HONOR_TAG_FIELD]: tag,
  317. c_created_at: createdAt,
  318. };
  319. if (name) row.c_name = name;
  320. return row;
  321. };