feat: accept ERP-semantic roster workbook headers
This commit is contained in:
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 () => {
|
||||
|
||||
Reference in new issue
Block a user