feat: accept ERP-semantic roster workbook headers

This commit is contained in:
inman committed 2026-09-02 12:05:25 +08:00
1 parent ffa3340899
commit e68fcc1ce3
22 files changed
+569 -89

No files matched your search

@@ -12,7 +12,14 @@ const SOURCE_HEADERS = [
'护照号码', '签发地', '签发日期', '有效期', '电话', '备注'
] as const;
const ERP_HEADERS = [
'序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码',
'签发地', '签发日', '有效期', '电话', '备注'
] as const;
const CANONICAL_HEADER = '序号\t姓名\tNAME\t性别\t出生日期\t出生地\t证件类型\t证件号码\t签发地\t签发日\t有效期\t电话\t备注';
const REORDERED_SOURCE_INDEXES = [8, 1, 13, 0, 10, 4, 6, 12, 3, 11, 5, 9, 2, 7];
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>;
@@ -57,6 +64,29 @@ async function syntheticWorkbook(...mutations: WorkbookMutation[]): Promise<Buff
return Buffer.from(await workbook.xlsx.writeBuffer());
}
async function erpHeaderWorkbook(...mutations: WorkbookMutation[]): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('ERP名单');
sheet.addRow([...ERP_HEADERS]);
sheet.addRow([
1,
'测试甲',
'ALPHA,TEST',
'男',
'1990/1/2',
'测试地一',
'护照',
123456789,
'测试签发地',
new Date(Date.UTC(2025, 0, 2)),
new Date(Date.UTC(2035, 0, 1)),
13800000000,
'领队\t备注'
]);
for (const mutation of mutations) await mutation(workbook);
return Buffer.from(await workbook.xlsx.writeBuffer());
}
async function expectRosterError(
work: () => Promise<unknown>,
code: string,
@@ -92,7 +122,7 @@ test('XLSX is deterministically normalized to the canonical 13-column TSV', asyn
].join('\n'));
});
test('row 1 is ignored as group metadata and the exact roster header must be on row 2', async () => {
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 = '任意团信息';
});
@@ -104,17 +134,142 @@ test('row 1 is ignored as group metadata and the exact roster header must be on
assert.equal(normalized.headerRow, 2);
assert.equal(normalized.rowCount, 2);
const reordered = await syntheticWorkbook((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;
}
sheet.getCell('G3').value = {
formula: 'DATEDIF(K3,TODAY(),"Y")',
result: 36
};
sheet.getCell('J3').value = {
formula: 'EDATE(E3,10*12)-1',
result: new Date(Date.UTC(2035, 0, 1))
};
sheet.getCell('G4').value = {
formula: 'DATEDIF(K4,TODAY(),"Y")',
result: 26
};
sheet.getCell('J4').value = {
formula: 'EDATE(E4,10*12)-1',
result: 49310
};
});
const reorderedResult = await normalizePassengerRosterWorkbook({
content: reordered,
fileName: 'reordered-roster.xlsx',
contentType: 'application/octet-stream'
});
assert.equal(reorderedResult.headerRow, 2);
assert.equal(reorderedResult.canonicalTsv, [
CANONICAL_HEADER,
'1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注',
'2\t测试乙\tBETA,TEST\t女\t2000-01-02\t测试地二\t护照\tP00000002\t测试签发地\t2025-01-02\t2035-01-01\t\t'
].join('\n'));
const shifted = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.insertRow(2, ['额外说明']);
const sheet = workbook.getWorksheet('Sheet1')!;
sheet.insertRow(2, ['额外说明']);
sheet.getCell('G4').value = {
formula: 'DATEDIF(F4,TODAY(),"Y")',
result: 36
};
sheet.getCell('L4').value = {
formula: 'EDATE(K4,10*12)-1',
result: new Date(Date.UTC(2035, 0, 1))
};
sheet.getCell('G5').value = {
formula: 'DATEDIF(F5,TODAY(),"Y")',
result: 26
};
sheet.getCell('L5').value = {
formula: 'EDATE(K5,10*12)-1',
result: 49310
};
});
const shiftedResult = await normalizePassengerRosterWorkbook({
content: shifted,
fileName: 'synthetic.xlsx',
contentType: 'application/octet-stream'
});
assert.equal(shiftedResult.headerRow, 3);
assert.equal(shiftedResult.rowCount, 2);
const erpHeaderOnFirstRow = await normalizePassengerRosterWorkbook({
content: await erpHeaderWorkbook(),
fileName: 'erp-roster.xlsx',
contentType: 'application/octet-stream'
});
assert.equal(erpHeaderOnFirstRow.headerRow, 1);
assert.equal(erpHeaderOnFirstRow.canonicalTsv, [
CANONICAL_HEADER,
'1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注'
].join('\n'));
});
test('the approved attachment aliases and reverse issue-date formula are normalized', async () => {
const attachmentVariant = await syntheticWorkbook((workbook) => {
const sheet = workbook.getWorksheet('Sheet1')!;
for (const rowNumber of [2, 3, 4]) {
const row = sheet.getRow(rowNumber);
const values = ATTACHMENT_VARIANT_INDEXES.map((index) => row.getCell(index + 1).value);
row.values = values;
}
sheet.getCell('N2').value = '身份证';
sheet.getCell('F3').value = 36;
sheet.getCell('J3').value = {
formula: 'EDATE(K3,-10*12)+1',
result: new Date(Date.UTC(2025, 0, 2))
};
sheet.getCell('K3').value = new Date(Date.UTC(2035, 0, 1));
sheet.getCell('F4').value = 26;
sheet.getCell('J4').value = {
formula: 'EDATE(K4,-10*12)+1',
result: 45659
};
sheet.getCell('K4').value = 49310;
});
const result = await normalizePassengerRosterWorkbook({
content: attachmentVariant,
fileName: 'attachment-variant.xlsx',
contentType: 'application/octet-stream'
});
assert.equal(result.headerRow, 2);
assert.equal(result.canonicalTsv, [
CANONICAL_HEADER,
'1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注',
'2\t测试乙\tBETA,TEST\t女\t2000-01-02\t测试地二\t护照\tP00000002\t测试签发地\t2025-01-02\t2035-01-01\t\t'
].join('\n'));
});
test('source header order may vary, but every required label must appear exactly once', async () => {
const missingHeader = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('N2').value = '备注说明';
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({
content: shifted,
fileName: 'synthetic.xlsx',
contentType: 'application/octet-stream'
}),
'roster_workbook_header_row_invalid',
{ row: 3 }
() => normalizePassengerRosterWorkbook({ content: missingHeader, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_header_not_found'
);
const duplicateHeader = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('N2').value = '姓名';
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: duplicateHeader, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_header_not_found'
);
const unheadedExtraData = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('O3').value = '未识别数据';
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: unheadedExtraData, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_unexpected_column',
{ row: 3, column: 15 }
);
});
@@ -164,6 +319,21 @@ test('non-empty identity cards fail closed without leaking cell values', async (
assert.doesNotMatch(JSON.stringify(error), new RegExp(secret));
});
test('canonical ERP document type remains passport-only', async () => {
const unsupportedType = await erpHeaderWorkbook((workbook) => {
workbook.getWorksheet('ERP名单')!.getCell('G2').value = '身份证';
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({
content: unsupportedType,
fileName: 'erp-roster.xlsx',
contentType: 'application/octet-stream'
}),
'roster_workbook_document_type_not_supported',
{ row: 2, column: 7 }
);
});
test('sequence numbers must be positive and unique', async () => {
const duplicate = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('A4').value = 1;
@@ -193,7 +363,7 @@ test('sequence numbers must be positive and unique', async () => {
);
});
test('formulas are limited to the row-local age and expiry template formulas', async () => {
test('formulas are limited to the row-local age, issue-date and expiry template formulas', async () => {
const networkFormula = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('G3').value = {
formula: 'WEBSERVICE("https://invalid.example")',
@@ -240,6 +410,19 @@ test('formulas are limited to the row-local age and expiry template formulas', a
'roster_workbook_formula_result_missing',
{ row: 3, column: 12 }
);
const issueDateWithoutResult = await syntheticWorkbook((workbook) => {
const sheet = workbook.getWorksheet('Sheet1')!;
sheet.getCell('K3').value = {
formula: 'EDATE(L3,-10*12)+1'
};
sheet.getCell('L3').value = new Date(Date.UTC(2035, 0, 1));
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: issueDateWithoutResult, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_formula_result_missing',
{ row: 3, column: 11 }
);
});
test('macros, external relationships and ambiguous sheets fail closed', async () => {