/** * 验证企业分类同步的**纯逻辑**(不碰网络、不碰 DMS)。 * * 验证什么: * ① 身份抽取的取值顺序与容错(profile.data 优先、回退 company、非字符串一律不算) * ② 分类响应 → 1888 期望行(失败角度不写、保守模式只写非空角度) * ③ 1888 diff:无变化 → null(零写入)、`"[]"` 与 `[]` 不产生假差异、期望为空不清存量 * ④ 1886 集合同步:增/删/改/空集、以及**迁移数据(foreign 行)绝不出现在结果里** * ⑤ partial / errors 不可解析时的降级语义 * ⑥ 分类接口地址拼接(VITE_CHAT_API 的两种写法) * * 怎么跑(先按 _entry-company-classify.ts 头部注释重新打包): * node harness/tools/verify-company-classify-sync.mjs */ import { ANGLE_TO_FIELD, HONOR_SYNC_SOURCE, HONOR_TAG_FIELD, buildEnterpriseRow, buildHonorRowContent, buildHonorTags, decodeAngleTags, diffEnterpriseRow, diffHonorRows, encodeAngleTags, extractFailedAngles, extractIdentityFromCompanyInfo, isCompanyInfoUsable, normalizeTagSet, partitionHonorRows, resolveClassifyEndpoint, resolveSyncedIdentity, sameTagSet, } from './_company-classify.mjs'; let pass = 0; let fail = 0; const check = (name, cond, detail) => { if (cond) { pass += 1; console.log(` ok ${name}`); } else { fail += 1; console.log(` FAIL ${name}${detail === undefined ? '' : ` → ${JSON.stringify(detail)}`}`); } }; /* ---------------------------------------------------------------- * * ① 身份抽取 * ---------------------------------------------------------------- */ console.log('\n【1】身份抽取'); { const full = { company: { KeyNo: 'k1', Name: '公司名A', CreditCode: 'CODE-A', OperName: '法人A' }, profile: { data: { KeyNo: 'k1', Name: '公司名B', CreditCode: 'CODE-B', OperName: '法人B' } }, }; const id = extractIdentityFromCompanyInfo(full); check('profile.data 优先于 company', id.name === '公司名B' && id.creditCode === 'CODE-B' && id.legalRep === '法人B', id); const companyOnly = { company: { Name: '只有 company', CreditCode: 'CODE-C' } }; const id2 = extractIdentityFromCompanyInfo(companyOnly); check('profile 缺失时回退 company(逐项回退)', id2.name === '只有 company' && id2.creditCode === 'CODE-C' && id2.legalRep === null, id2); const partial = { company: { Name: 'A' }, profile: { data: { CreditCode: 'ONLY-CC' } } }; const id3 = extractIdentityFromCompanyInfo(partial); check('profile 有但缺字段 → 该项回退 company', id3.name === 'A' && id3.creditCode === 'ONLY-CC', id3); const dirty = { company: { Name: 123, CreditCode: ' CODE-D ', OperName: '' } }; const id4 = extractIdentityFromCompanyInfo(dirty); check('非字符串/空串不算,字符串去首尾空白', id4.name === null && id4.creditCode === 'CODE-D' && id4.legalRep === null, id4); check('全缺 → null', extractIdentityFromCompanyInfo({ company: {}, profile: { data: {} } }) === null); check('null/undefined/{} → null', extractIdentityFromCompanyInfo(null) === null && extractIdentityFromCompanyInfo(undefined) === null && extractIdentityFromCompanyInfo({}) === null); check('数组/字符串 → null', extractIdentityFromCompanyInfo([]) === null && extractIdentityFromCompanyInfo('x') === null); check('isCompanyInfoUsable 与之一致', isCompanyInfoUsable({ company: { Name: 'X' } }) === true && isCompanyInfoUsable({}) === false); } /* ---------------------------------------------------------------- * * ② 标签编解码 * ---------------------------------------------------------------- */ console.log('\n【2】标签编解码'); { check('encode 数组 → JSON 字符串', encodeAngleTags(['第一产业', '现代农业']) === '["第一产业","现代农业"]'); check('encode 空数组 → "[]"', encodeAngleTags([]) === '[]'); check('encode null → "[]"', encodeAngleTags(null) === '[]'); check('encode undefined → undefined(表示本轮不写)', encodeAngleTags(undefined) === undefined); check('normalize 去空、去重、trim、保序', JSON.stringify(normalizeTagSet([' a ', '', 'b', 'a', null, 3])) === '["a","b"]'); check('decode 往返', JSON.stringify(decodeAngleTags(encodeAngleTags(['x', 'y']))) === '["x","y"]'); check('decode 非法/非字符串 → []', decodeAngleTags('not json').length === 0 && decodeAngleTags(null).length === 0 && decodeAngleTags('{"a":1}').length === 0); check('sameTagSet 与顺序无关', sameTagSet(['a', 'b'], ['b', 'a']) === true && sameTagSet(['a'], ['a', 'b']) === false); } /* ---------------------------------------------------------------- * * ③ 期望行 + diff * ---------------------------------------------------------------- */ console.log('\n【3】1888 期望行与 diff'); { const classification = { company_name: '合成公司', credit_code: 'CODE-X', legal_representative: '合成法人', categories: { 基本信息: ['小微企业'], 资质荣誉: ['高新技术企业'], 产业信息: [], 经营活动: [], 行业信息: ['涉农(广义)'], 许可认证: [], }, status: 'completed', }; const row = buildEnterpriseRow(classification, { failed: new Set(), conservative: false }); check('身份三字段落位', row.c_name === '合成公司' && row.c_credit_code === 'CODE-X' && row.c_oper_name === '合成法人', row); check('六角度各自落位(含空数组 → "[]")', row[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]' && row[ANGLE_TO_FIELD['产业信息']] === '[]' && row[ANGLE_TO_FIELD['许可认证']] === '[]', row); check('行业信息用 c_tag_industry(不占用既有的 c_industry)', row[ANGLE_TO_FIELD['行业信息']] === '["涉农(广义)"]' && row.c_industry === undefined); const failedRow = buildEnterpriseRow(classification, { failed: new Set(['产业信息']), conservative: false }); check('失败角度不写该字段', failedRow[ANGLE_TO_FIELD['产业信息']] === undefined && failedRow[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]'); const conservativeRow = buildEnterpriseRow(classification, { failed: new Set(), conservative: true }); check('保守模式只写非空角度', conservativeRow[ANGLE_TO_FIELD['行业信息']] === '["涉农(广义)"]' && conservativeRow[ANGLE_TO_FIELD['产业信息']] === undefined); const noIdentity = buildEnterpriseRow({ categories: {} }, { failed: new Set(), conservative: false }); check('身份缺失时不写身份字段(而不是写 null)', !('c_name' in noIdentity) && !('c_credit_code' in noIdentity) && !('c_oper_name' in noIdentity), noIdentity); const hintRow = buildEnterpriseRow({ categories: {} }, { failed: new Set(), conservative: false, identityHint: { name: '回退名', creditCode: 'FALLBACK', legalRep: null } }); check('响应缺身份时回退 identityHint', hintRow.c_name === '回退名' && hintRow.c_credit_code === 'FALLBACK', hintRow); const synced = resolveSyncedIdentity({ company_name: null, credit_code: 'C1' }, { name: 'N1', creditCode: 'C0', legalRep: 'L1' }); check('resolveSyncedIdentity 逐项回退', synced.name === 'N1' && synced.creditCode === 'C1' && synced.legalRep === 'L1', synced); // diff const existing = { id: 'uuid-1', c_name: '合成公司', c_credit_code: 'CODE-X', [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' }; const same = diffEnterpriseRow(existing, { c_name: '合成公司', c_credit_code: 'CODE-X', [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' }); check('全同 → null(零写入)', same === null, same); const reordered = diffEnterpriseRow(existing, { [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' }); check('集合比较:内容相同不算变化', reordered === null, reordered); const changed = diffEnterpriseRow(existing, { c_name: '改名了', [ANGLE_TO_FIELD['基本信息']]: '["小微企业","专精特新"]' }); check('变化字段进 patch,未变字段不进', changed.c_name === '改名了' && changed[ANGLE_TO_FIELD['基本信息']] === '["小微企业","专精特新"]' && !('c_credit_code' in changed), changed); const desiredEmptyTags = diffEnterpriseRow({ [ANGLE_TO_FIELD['基本信息']]: '["旧标签"]' }, { [ANGLE_TO_FIELD['基本信息']]: '[]' }); check('空集是合法目标值(会覆盖旧标签)', desiredEmptyTags !== null && desiredEmptyTags[ANGLE_TO_FIELD['基本信息']] === '[]', desiredEmptyTags); const noOverwrite = diffEnterpriseRow({ c_name: '存量名' }, {}); check('期望里没有的字段不参与比较、不清存量', noOverwrite === null, noOverwrite); const fillGap = diffEnterpriseRow({ c_name: '存量名' }, { c_oper_name: '新法人' }); check('存量缺失 + 期望有 → 补齐', fillGap !== null && fillGap.c_oper_name === '新法人', fillGap); } /* ---------------------------------------------------------------- * * ④ 1886 集合同步 * ---------------------------------------------------------------- */ console.log('\n【4】1886 集合同步'); { const rows = [ { id: 'u1', c_source: HONOR_SYNC_SOURCE, [HONOR_TAG_FIELD]: '高新技术企业' }, { id: 'u2', c_source: HONOR_SYNC_SOURCE, [HONOR_TAG_FIELD]: '专精特新' }, { id: 'm1', c_source: 'migrate', [HONOR_TAG_FIELD]: '迁移来的荣誉' }, { id: 'm2', [HONOR_TAG_FIELD]: '没有来源标记' }, ]; const { owned, foreign, malformed } = partitionHonorRows(rows); check('只认 c_source=company_info_sync 且有 id/标签的行', owned.length === 2 && owned[0].id === 'u1' && owned[1].tag === '专精特新', owned); check('foreign 计数正确(迁移/无标记)', foreign === 2, foreign); check('缺 id 或标签的我方行计入 malformed、不参与增删', (() => { const r = partitionHonorRows([{ id: 'u3', c_source: HONOR_SYNC_SOURCE }]); return r.owned.length === 0 && r.malformed === 1; })()); const addOnly = diffHonorRows(owned, new Set(['高新技术企业', '专精特新', '新荣誉'])); check('纯增', addOnly.toAdd.length === 1 && addOnly.toAdd[0] === '新荣誉' && addOnly.toDelete.length === 0, addOnly); const deleteOnly = diffHonorRows(owned, new Set(['高新技术企业'])); check('纯删(含待删行的 uuid)', deleteOnly.toAdd.length === 0 && deleteOnly.toDelete.length === 1 && deleteOnly.toDelete[0].id === 'u2', deleteOnly); const swapped = diffHonorRows(owned, new Set(['高新技术企业', '新荣誉'])); check('改 = 删 + 增', swapped.toAdd.length === 1 && swapped.toAdd[0] === '新荣誉' && swapped.toDelete.length === 1 && swapped.toDelete[0].tag === '专精特新', swapped); const noChange = diffHonorRows(owned, new Set(['专精特新', '高新技术企业'])); check('集合相同(顺序不同)→ 零写入', noChange.toAdd.length === 0 && noChange.toDelete.length === 0, noChange); const clearAll = diffHonorRows(owned, new Set()); check('空目标集 → 删光我方行', clearAll.toDelete.length === 2 && clearAll.toAdd.length === 0, clearAll); check('foreign 行不会出现在任何结果里', [...clearAll.toDelete].every((r) => r.id !== 'm1' && r.id !== 'm2')); const content = buildHonorRowContent('CODE-X', '合成公司', '高新技术企业', '2026-09-18 18:00:00'); check('荣誉行内容:必填齐全 + 所有权标记', content.c_credit_code === 'CODE-X' && content[HONOR_TAG_FIELD] === '高新技术企业' && content.c_source === HONOR_SYNC_SOURCE && content.c_name === '合成公司' && content.c_id === 0, content); } /* ---------------------------------------------------------------- * * ⑤ partial / errors 语义 * ---------------------------------------------------------------- */ console.log('\n【5】失败角度与保守降级'); { const partial = { status: 'partial', errors: { 产业信息: 'model_call_failed' }, categories: { 基本信息: ['a'] } }; const r1 = extractFailedAngles(partial); check('errors 键 = 角度名 → 识别为失败角度', r1.failed.has('产业信息') && r1.unknown === false, [...r1.failed]); const weird = { status: 'partial', errors: { something_else: 'x' }, categories: {} }; const r2 = extractFailedAngles(weird); check('errors 键对不上角度 → unknown 置位', r2.unknown === true, r2); const partialNoErrors = { status: 'partial', categories: {} }; check('partial 但无 errors → unknown(不知道哪些可信)', extractFailedAngles(partialNoErrors).unknown === true); const completed = { status: 'completed', errors: {}, categories: {} }; check('completed + 空 errors → 不降级', extractFailedAngles(completed).unknown === false); check('资质荣誉失败 → 跳过整个 1886(null)', buildHonorTags({ categories: { 资质荣誉: ['x'] } }, { failed: new Set(['资质荣誉']), conservative: false }) === null); check('保守模式 → 跳过 1886(null)', buildHonorTags({ categories: { 资质荣誉: ['x'] } }, { failed: new Set(), conservative: true }) === null); check('completed + 空数组 → 空集(合法,会删光我方行)', JSON.stringify(buildHonorTags({ categories: { 资质荣誉: [] } }, { failed: new Set(), conservative: false })) === '[]'); check('正常 → 标签数组', JSON.stringify(buildHonorTags({ categories: { 资质荣誉: ['a', 'b'] } }, { failed: new Set(), conservative: false })) === '["a","b"]'); } /* ---------------------------------------------------------------- * * ⑥ 分类接口地址 * ---------------------------------------------------------------- */ console.log('\n【6】分类接口地址拼接'); { check('服务前缀写法', resolveClassifyEndpoint('http://192.168.2.23:8000') === 'http://192.168.2.23:8000/api/company/classify'); check('dev 代理前缀写法', resolveClassifyEndpoint('/chat-api') === '/chat-api/api/company/classify'); check('完整聊天接口写法(先剥 /api/chat)', resolveClassifyEndpoint('http://x:8000/api/chat') === 'http://x:8000/api/company/classify'); check('末尾斜杠容错', resolveClassifyEndpoint('http://x:8000///') === 'http://x:8000/api/company/classify'); check('空 → 空串(调用方会跳过)', resolveClassifyEndpoint('') === '' && resolveClassifyEndpoint(undefined) === ''); } console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);