兼容表头

This commit is contained in:
andy committed 2026-09-15 18:41:57 +08:00
1 parent af85ea22ec
commit 364ef4ae30
27 files changed
+414 -50

No files matched your search

@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import ExcelJS from 'exceljs';
import JSZip from 'jszip';
import { parseProgramInput } from '../src/program-parser.js';
import {
PassengerRosterWorkbookError,
normalizePassengerRosterWorkbook
@@ -23,6 +24,19 @@ const ATTACHMENT_VARIANT_INDEXES = [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 4
type WorkbookMutation = (workbook: ExcelJS.Workbook) => void | Promise<void>;
const genderTemplate = (ref: string): string => `IF(OR(LEN(${ref})=15,LEN(${ref})=18),IF(MOD(MID(${ref},15,3)*1,2),"男","女"),NA())`;
const birthTemplate = (ref: string): string => `DATE(MID(${ref},7,VLOOKUP(LEN(${ref}),{15,2;18,4},2,0)),MID(${ref},VLOOKUP(LEN(${ref}),{15,9;18,11},2,0),2),MID(${ref},VLOOKUP(LEN(${ref}),{15,11;18,13},2,0),2))`;
const SYNTHETIC_ID = '11111119900102001X';
const normalizeSynthetic = (content: Buffer) => normalizePassengerRosterWorkbook({
content, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream'
});
const identityFormulas: WorkbookMutation = workbook => {
const sheet = workbook.getWorksheet('Sheet1')!;
sheet.getCell('E3').value = SYNTHETIC_ID;
sheet.getCell('D3').value = { formula: genderTemplate('E3'), result: '男' };
sheet.getCell('F3').value = { formula: birthTemplate('E3'), result: new Date(Date.UTC(1990, 0, 2)) };
};
async function syntheticWorkbook(...mutations: WorkbookMutation[]): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Sheet1');
@@ -122,6 +136,158 @@ test('XLSX is deterministically normalized to the canonical 13-column TSV', asyn
].join('\n'));
});
test('a merged title error is skipped, while header and passenger errors stay blocked', async () => {
const baseline = await normalizeSynthetic(await syntheticWorkbook());
const titleError = await normalizeSynthetic(await syntheticWorkbook(workbook => {
const sheet = workbook.getWorksheet('Sheet1')!;
sheet.mergeCells('F1:N1');
sheet.getCell('F1').value = { error: '#VALUE!' };
}));
assert.equal(titleError.canonicalTsv, baseline.canonicalTsv);
for (const [address, code, row, column] of [
['F2', 'roster_workbook_header_not_found', undefined, undefined],
['F3', 'roster_workbook_invalid_cell', 3, 6],
['N4', 'roster_workbook_invalid_cell', 4, 14]
] as const) {
const content = await syntheticWorkbook(workbook => {
workbook.getWorksheet('Sheet1')!.getCell(address).value = { error: '#VALUE!' };
});
await expectRosterError(() => normalizeSynthetic(content), code, { row, column });
}
});
test('same-row identity templates support 18 and 15 digit sources without exporting them', async () => {
for (const legacy of [false, true]) {
const normalized = await normalizeSynthetic(await syntheticWorkbook(identityFormulas, workbook => {
const sheet = workbook.getWorksheet('Sheet1')!;
sheet.getCell('E2').value = '身份证';
if (legacy) {
sheet.getCell('E3').value = 111111900102002;
sheet.getCell('D3').value = { formula: genderTemplate('E3'), result: '女' };
}
}));
const row = normalized.canonicalTsv.split('\n')[1]!.split('\t');
assert.deepEqual(row.slice(3, 5), [legacy ? '女' : '男', '1990-01-02']);
assert.equal(row[6], '护照');
assert.equal(row[7], '123456789');
assert.doesNotMatch(normalized.canonicalTsv, /111111|身份证/);
}
});
test('identity references follow reordered columns, shared formulas and the workbook date system', async () => {
const content = await syntheticWorkbook(identityFormulas, workbook => {
const sheet = workbook.getWorksheet('Sheet1')!;
for (const rowNumber of [2, 3, 4]) {
const row = sheet.getRow(rowNumber);
const values = REORDERED_SOURCE_INDEXES.map(index => row.getCell(index + 1).value);
row.values = values;
}
workbook.properties.date1904 = true;
const birthSerial = (Date.UTC(1990, 0, 2) - Date.UTC(1904, 0, 1)) / 86_400_000;
sheet.getCell('I3').value = { formula: genderTemplate('$F3').toLowerCase(), result: '男' };
sheet.getCell('K3').value = { formula: birthTemplate('F3'), result: birthSerial };
sheet.getCell('J3').value = new Date(Date.UTC(2035, 0, 1));
sheet.getCell('F4').value = SYNTHETIC_ID;
sheet.getCell('I4').value = { sharedFormula: 'I3', result: '男' };
sheet.getCell('K4').value = { sharedFormula: 'K3', result: birthSerial };
});
const result = await normalizeSynthetic(content);
for (const row of result.canonicalTsv.split('\n').slice(1)) {
assert.deepEqual(row.split('\t').slice(3, 5), ['男', '1990-01-02']);
}
});
test('identity formulas reject missing, stale or error results at the formula cell', async () => {
for (const [address, value, code, column] of [
['D3', { formula: genderTemplate('E3') }, 'roster_workbook_formula_result_missing', 4],
['F3', { formula: birthTemplate('E3') }, 'roster_workbook_formula_result_missing', 6],
['D3', { formula: genderTemplate('E3'), result: '女' }, 'roster_workbook_formula_result_mismatch', 4],
['F3', { formula: birthTemplate('E3'), result: '1990-01-03' }, 'roster_workbook_formula_result_mismatch', 6],
['F3', { formula: birthTemplate('E3'), result: { error: '#VALUE!' } }, 'roster_workbook_invalid_cell', 6]
] as const) {
const content = await syntheticWorkbook(identityFormulas, workbook => {
workbook.getWorksheet('Sheet1')!.getCell(address).value = value;
});
await expectRosterError(() => normalizeSynthetic(content), code, { row: 3, column });
}
});
test('identity dependencies must be unambiguous same-row template references', async () => {
const variants: Array<[WorkbookMutation, string, number]> = [
[workbook => { workbook.getWorksheet('Sheet1')!.getCell('E2').value = '普通额外列'; }, 'roster_workbook_formula_not_allowed', 4],
[workbook => { workbook.getWorksheet('Sheet1')!.getCell('O2').value = '身份证'; }, 'roster_workbook_formula_not_allowed', 4],
[workbook => { workbook.getWorksheet('Sheet1')!.getCell('D3').value = { formula: genderTemplate('E4'), result: '男' }; }, 'roster_workbook_formula_not_allowed', 4],
[workbook => { workbook.getWorksheet('Sheet1')!.getCell('D3').value = { formula: '"男"', result: '男' }; }, 'roster_workbook_formula_not_allowed', 4],
[workbook => { workbook.getWorksheet('Sheet1')!.getCell('F3').value = { formula: birthTemplate('E3').replaceAll(';', ','), result: '1990-01-02' }; }, 'roster_workbook_formula_not_allowed', 6],
[workbook => { workbook.getWorksheet('Sheet1')!.getCell('D3').value = { formula: genderTemplate("'[external.xlsx]Sheet1'!E3"), result: '男' }; }, 'roster_workbook_unsafe_content', 4]
];
for (const [mutation, code, column] of variants) {
const content = await syntheticWorkbook(identityFormulas, mutation);
await expectRosterError(() => normalizeSynthetic(content), code, { row: 3, column });
}
});
test('invalid identity sources cannot validate a cached gender or birth date', async () => {
for (const [value, code] of [
['', 'roster_workbook_invalid_identifier'],
['invalid source', 'roster_workbook_invalid_identifier'],
[` ${SYNTHETIC_ID}`, 'roster_workbook_invalid_identifier'],
[`${SYNTHETIC_ID}\n`, 'roster_workbook_invalid_identifier'],
[`111111\t${SYNTHETIC_ID.slice(6)}`, 'roster_workbook_invalid_identifier'],
[111111199001020010, 'roster_workbook_invalid_identifier'],
['11111119900230001X', 'roster_workbook_invalid_date'],
[{ formula: '"11111119900102001X"', result: SYNTHETIC_ID }, 'roster_workbook_formula_not_allowed']
] as const) {
const content = await syntheticWorkbook(identityFormulas, workbook => {
workbook.getWorksheet('Sheet1')!.getCell('E3').value = value;
});
const error = await expectRosterError(() => normalizeSynthetic(content), code, { row: 3, column: 5 });
assert.doesNotMatch(JSON.stringify(error), /111111|invalid source/);
}
});
test('populated identity formula rows are not discarded as empty trailing templates', async () => {
const content = await syntheticWorkbook(identityFormulas, workbook => {
const sheet = workbook.getWorksheet('Sheet1')!;
sheet.getRow(5).values = [3, '', '', { formula: genderTemplate('E5'), result: '男' }, SYNTHETIC_ID,
{ formula: birthTemplate('E5'), result: '1990-01-02' }];
});
const result = await normalizeSynthetic(content);
assert.equal(result.rowCount, 3);
// Retain the partial row for downstream import/supplement validation.
assert.deepEqual(result.canonicalTsv.split('\n')[3]!.split('\t').slice(0, 5), ['3', '', '', '男', '1990-01-02']);
});
test('a 22-person template passes normalization and program parsing with the last row as leader', async () => {
const content = await syntheticWorkbook(identityFormulas, workbook => {
const sheet = workbook.getWorksheet('Sheet1')!;
sheet.mergeCells('F1:N1');
sheet.getCell('F1').value = { error: '#VALUE!' };
const values = sheet.getRow(3).values;
for (let index = 1; index <= 22; index++) {
const rowNumber = index + 2;
const row = sheet.getRow(rowNumber);
row.values = values;
row.getCell('A').value = index;
row.getCell('I').value = `P${String(index).padStart(8, '0')}`;
row.getCell('D').value = { formula: genderTemplate(`E${rowNumber}`), result: '男' };
row.getCell('F').value = { formula: birthTemplate(`E${rowNumber}`), result: '1990-01-02' };
row.getCell('K').value = { formula: `EDATE(L${rowNumber},-10*12)+1`, result: '2025-01-02' };
row.getCell('L').value = '2035-01-01';
row.getCell('M').value = index === 22 ? '13800000000' : '';
row.getCell('N').value = index === 22 ? '领队' : '';
}
});
const result = await normalizeSynthetic(content);
assert.equal(result.rowCount, 22);
assert.doesNotMatch(result.canonicalTsv, /111111|身份证|#VALUE!/);
const parsed = await parseProgramInput({ rawText: `导入散拼子单名单\n预订客户:测试客户\n出发日期:2026-09-21\n名单内容:\n${result.canonicalTsv}`,
receivedAt: new Date('2026-09-15T00:00:00Z') });
assert.equal(parsed.status, 'agent_parse_passed');
const data = parsed.operation?.data as { passenger_list?: { leader_contact?: { sequence?: number } } };
assert.equal(data?.passenger_list?.leader_contact?.sequence, 22);
});
test('the ERP field row is auto-detected and source columns may be reordered', async () => {
const valid = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('N1').value = '任意团信息';