| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- /**
- * 验证企业分类同步的**纯逻辑**(不碰网络、不碰 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);
|