/** * **端到端**验证:真实 company_info → 真实分类接口 → 真实 DMS 两栏目。 * * 与 `verify-company-classify-dms.mjs` 的分工: * - 那个脚本**注入假分类响应**,验证「拿到结果之后」的落库链路(不需要后端配合,可反复跑) * - 本脚本用**真实载荷**打真实分类接口,验证最后一公里(会真的调用 6 次 LLM、会真的写 DMS) * * 为什么载荷要从文件读、不内置: * 分类接口对**合成/精简的请求体一律返回 422**(2026-09-18 实测:文档里的示例片段、 * 各种键名、`{"company":{}}` 全被拒;换成真实抓下来的 `company_info` 才 200)。 * 所以这里不硬编码夹具,由调用方给一份**真实形态**的 company_info JSON。 * * 怎么跑: * 1) npm run dev * 2) 把 /api/chat 的 result 事件里 data.company_info 对象存成 JSON 文件(整个对象, * 不含外层 company_info 键),例如 /tmp/company-info.json * 3) node harness/tools/verify-company-classify-e2e.mjs [--cleanup] * * ⚠️ **默认不清理**:这份是真实企业数据,落库就是功能的正常行为(幂等,重复跑只更新)。 * 加 `--cleanup` 会在验证后把该企业在本轮写下的行删掉(调试用)。 */ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; import { readFileSync } from "node:fs"; const chatBase = process.env.CHAT_BASE || "https://localhost:8083/chat-api"; const dmsBase = process.env.DMS_BASE || "https://localhost:8083/dms-api"; globalThis.VITE_CHAT_API = chatBase; globalThis.VITE_DMS_API = dmsBase; const payloadPath = process.argv[2]; const cleanup = process.argv.includes("--cleanup"); if (!payloadPath) { console.error("用法:node verify-company-classify-e2e.mjs [--cleanup]"); process.exit(2); } const { classifyCompany, applyCompanyClassification, applyCompanyHonors, applyCompanyProfile, extractIdentityFromCompanyInfo, parseHonorRecords, readHonorsFromCompanyInfo, readProfileDataFromCompanyInfo, buildProfileFields, stableJson, HONOR_NAME_FIELD, ANGLE_TO_FIELD, DMS_COLUMN_ENTERPRISE, DMS_COLUMN_HONOR, deleteDmsContent, searchDmsContents, } = await import("./_company-classify.mjs"); let pass = 0; let fail = 0; const check = (name, ok, extra = "") => { if (ok) { pass++; console.log(` ok ${name}`); } else { fail++; console.log(` FAIL ${name}${extra === "" ? "" : ` → ${JSON.stringify(extra)}`}`); } }; const companyInfo = JSON.parse(readFileSync(payloadPath, "utf8")); const identity = extractIdentityFromCompanyInfo(companyInfo); const creditCode = identity?.creditCode; console.log(`载荷:${payloadPath}`); console.log(`企业:${identity?.name || "?"} / ${creditCode || "?"}\n`); if (!creditCode) { console.error("载荷里没有统一社会信用代码,无法继续"); process.exit(2); } const findEnterprise = () => searchDmsContents(DMS_COLUMN_ENTERPRISE, { search: [{ field: "c_credit_code", searchType: 1, content: { value: creditCode } }], page: 0, pageSize: 10, }); const findHonors = () => searchDmsContents(DMS_COLUMN_HONOR, { search: [{ field: "c_credit_code", searchType: 1, content: { value: creditCode } }], page: 0, pageSize: 200, }); /* ---------------------------------------------------------------- * * ① 真实分类接口 * ---------------------------------------------------------------- */ console.log("【1】真实分类接口(会调用 6 次 LLM,稍慢)"); const before = Date.now(); const classification = await classifyCompany(companyInfo); console.log(` (耗时 ${((Date.now() - before) / 1000).toFixed(1)}s)`); check("接口返回了结果(非 null)", !!classification); check( "返回的是 completed/partial(不是 failed)", classification?.status === "completed" || classification?.status === "partial", classification?.status ); check("credit_code 与载荷一致", classification?.credit_code === creditCode, classification?.credit_code); const cats = classification?.categories || {}; const angles = Object.keys(cats); check("六个角度键齐全", angles.length === 6, angles); check( "每个角度都是数组", angles.every((k) => Array.isArray(cats[k])), angles.map((k) => `${k}:${Array.isArray(cats[k])}`) ); console.log(` 分类结果:${JSON.stringify(cats)}`); /* ---------------------------------------------------------------- * * ② 落库(真实 DMS) * ---------------------------------------------------------------- */ console.log("\n【2】落库到 DMS 1888 / 1886"); await applyCompanyClassification(classification, identity); const rows = await findEnterprise(); check("1888 有且只有一行", rows.length === 1, rows.length); const row = rows[0] || {}; check("企业名/信用代码/法人已写入", row.c_name === identity.name && row.c_credit_code === creditCode && row.c_oper_name === identity.legalRep, { c_name: row.c_name, c_credit_code: row.c_credit_code, c_oper_name: row.c_oper_name, }); /** * 库里的标签字段有两种合法形态: * - 有标签 → JSON 数组字符串(如 `["第三产业","软件信息"]`) * - 无标签 → **字段不存在**(DMS 不落空值;`[]` 也不会被写下去) * 所以断言要按「解码后的集合」比,不能按字符串比。 */ const decodeTags = (value) => { if (typeof value !== "string" || !value.trim()) return []; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch { return []; } }; const sameSet = (a, b) => a.length === b.length && b.every((x) => a.includes(x)); for (const angle of angles) { const field = ANGLE_TO_FIELD[angle]; const expectedTags = cats[angle] || []; const storedTags = decodeTags(row[field]); check( `1888 ${angle} → ${field}${expectedTags.length === 0 ? "(空集:字段不落库,语义等同)" : ""}`, sameSet(storedTags, expectedTags), { stored: row[field], storedTags, expectedTags } ); } /* ---------------------------------------------------------------- * * ②b 荣誉(数据源 = company_info.honors.records) * ---------------------------------------------------------------- */ console.log("\n【2b】荣誉对齐到 1886(数据源 = company_info.honors.records)"); const honorParse = parseHonorRecords(readHonorsFromCompanyInfo(companyInfo)); console.log(` 载荷荣誉记录 ${honorParse.records.length} 条(complete=${honorParse.complete},丢弃 ${honorParse.malformed} 条)`); await applyCompanyHonors(readHonorsFromCompanyInfo(companyInfo), identity); const honors = await findHonors(); check("1886 行数 = 载荷荣誉条数", honors.length === honorParse.records.length, { rows: honors.length, records: honorParse.records.length, }); check( "每行都有荣誉名(title 与 c_honor)", honors.every((r) => typeof r[HONOR_NAME_FIELD] === "string" && r[HONOR_NAME_FIELD] && r.title === r[HONOR_NAME_FIELD]), honors.slice(0, 3).map((r) => ({ title: r.title, tag: r[HONOR_NAME_FIELD] })) ); check( "级别/来源已写入", honors.every((r) => typeof r.c_level === "string" && typeof r.c_source === "string"), honors.slice(0, 3).map((r) => ({ level: r.c_level, source: r.c_source })) ); const sampleRecord = honorParse.records[0]; check( "抽样比对:载荷第一条能在库里按「荣誉名+级别+来源」找到", !sampleRecord || honors.some((r) => r[HONOR_NAME_FIELD] === sampleRecord.name && r.c_level === sampleRecord.level && r.c_source === sampleRecord.source), sampleRecord ); /* ---------------------------------------------------------------- * * ②c 工商信息(profile.data → 1888,不依赖分类接口) * ---------------------------------------------------------------- */ console.log("\n【2c】工商信息同步(数据源 = company_info.profile.data)"); const profileData = readProfileDataFromCompanyInfo(companyInfo); const desiredProfile = buildProfileFields(profileData); // 先记下同步前那几列 JSON 的**原始字符串**:语义相同就不该被重写 const JSON_COLS = ["c_area", "c_original_name", "c_designated_representative_list", "c_revoke_info"]; const beforeRow = (await findEnterprise())[0] || {}; const beforeJson = Object.fromEntries(JSON_COLS.map((c) => [c, beforeRow[c]])); await applyCompanyProfile(profileData, identity); const afterRow = (await findEnterprise())[0] || {}; /** * 逐列比对时**不能按字符串比 JSON 列**:库里那几列是 DMS 侧灌进来的(Python/jsonb 序列化, * 带空格、键按字母序),我们按语义比较、不重写,所以字节不同但值相同 —— 这正是期望行为。 */ const sameColumnValue = (col, stored, desired) => { if (!JSON_COLS.includes(col)) return stored === desired; const parse = (v) => { if (typeof v !== "string" || !v.trim()) return null; try { return JSON.parse(v); } catch { return v; } }; return stableJson(parse(stored)) === stableJson(parse(desired)); }; check( "映射出的字段都进了库(JSON 列按语义比)", Object.entries(desiredProfile).every(([col, value]) => sameColumnValue(col, afterRow[col], value)), Object.entries(desiredProfile) .filter(([col, value]) => !sameColumnValue(col, afterRow[col], value)) .map(([col, value]) => ({ col, desired: String(value).slice(0, 40), stored: String(afterRow[col]).slice(0, 40) })) ); check( "抽样:经营状态 / 注册地址 / 注册资本 / 经营范围 已写入", afterRow.c_status === profileData.Status && afterRow.c_address === profileData.Address && afterRow.c_regist_capi === profileData.RegistCapi && String(afterRow.c_scope || "").startsWith(String(profileData.Scope || "").slice(0, 20)), { c_status: afterRow.c_status, c_address: afterRow.c_address, c_regist_capi: afterRow.c_regist_capi } ); check( "JSON 列语义正确(解析后按键排序比较,与载荷一致)", (() => { try { return stableJson(JSON.parse(afterRow.c_area)) === stableJson(profileData.Area); } catch { return false; } })(), afterRow.c_area ); check( "**语义相同的 JSON 列被原样保留**(没有被我们重写成另一种格式)", JSON_COLS.every((c) => beforeJson[c] === undefined || afterRow[c] === beforeJson[c]), JSON_COLS.filter((c) => beforeJson[c] !== undefined && afterRow[c] !== beforeJson[c]).map((c) => ({ col: c, before: String(beforeJson[c]).slice(0, 60), after: String(afterRow[c]).slice(0, 60) })) ); console.log(` 映射表覆盖 ${Object.keys(desiredProfile).length} 列(载荷 profile.data 共 ${Object.keys(profileData || {}).length} 个字段)`); /* ---------------------------------------------------------------- * * ③ 幂等:再落一次 * ---------------------------------------------------------------- */ console.log("\n【3】同样结果再落一次 → 零新增"); await applyCompanyClassification(classification, identity); await applyCompanyHonors(readHonorsFromCompanyInfo(companyInfo), identity); await applyCompanyProfile(profileData, identity); const rows2 = await findEnterprise(); check("1888 仍是一行、uuid 未变", rows2.length === 1 && rows2[0]?.id === row.id, rows2.length); const honors2 = await findHonors(); check("1886 行数未变", honors2.length === honors.length, { before: honors.length, after: honors2.length }); check( "1886 行 uuid 集合未变(没有删了又加)", JSON.stringify(honors2.map((r) => r.id).sort()) === JSON.stringify(honors.map((r) => r.id).sort()) ); /* ---------------------------------------------------------------- * * ④ 可选清理 * ---------------------------------------------------------------- */ if (cleanup) { console.log("\n【4】--cleanup:删除本轮写入的行"); for (const r of await findEnterprise()) if (typeof r.id === "string") await deleteDmsContent(DMS_COLUMN_ENTERPRISE, r.id); for (const r of await findHonors()) if (typeof r.id === "string") await deleteDmsContent(DMS_COLUMN_HONOR, r.id); check("1888 残留 0 行", (await findEnterprise()).length === 0); check("1886 残留 0 行", (await findHonors()).length === 0); } else { console.log("\n【4】未加 --cleanup:数据保留(真实企业数据,功能正常行为)"); } console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);