兼容表头
This commit is contained in:
1 parent
af85ea22ec
commit
364ef4ae30
27 files changed
+414
-50
No files matched your search
@@ -80,7 +80,7 @@ export function effectiveParserModeForRoute(route: BusinessRouteDefinition, conf
|
||||
}
|
||||
|
||||
const PROGRAM_VERSION = 'ltjt-program-parser-v1.0.12';
|
||||
export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.133';
|
||||
export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.134';
|
||||
|
||||
const lookupFields = ({ customerRequired = true } = {}): ProgramFieldDefinition[] => [
|
||||
{ key: 'order_no', label: '单号', aliases: ['团号', '订单号'], kind: 'text' },
|
||||
|
||||
@@ -13,7 +13,7 @@ const ABSOLUTE_MAX_DATA_ROWS = 5_000;
|
||||
const MAX_PASSENGER_SEQUENCE = 5_000;
|
||||
const MAX_HEADER_ROW = 100;
|
||||
|
||||
export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.5.1';
|
||||
export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.6.0';
|
||||
|
||||
const REQUIRED_SOURCE_FIELDS = [
|
||||
'序号', '姓名', '英文姓名', '性别', '出生日期', '出生地', '护照号码',
|
||||
@@ -71,6 +71,7 @@ export type PassengerRosterWorkbookErrorCode =
|
||||
| 'roster_workbook_ambiguous_header'
|
||||
| 'roster_workbook_formula_not_allowed'
|
||||
| 'roster_workbook_formula_result_missing'
|
||||
| 'roster_workbook_formula_result_mismatch'
|
||||
| 'roster_workbook_non_contiguous_rows'
|
||||
| 'roster_workbook_incomplete_row'
|
||||
| 'roster_workbook_row_limit'
|
||||
@@ -98,6 +99,7 @@ const ERROR_MESSAGES: Record<PassengerRosterWorkbookErrorCode, string> = {
|
||||
roster_workbook_ambiguous_header: '名单工作簿包含多个可识别的名单表头',
|
||||
roster_workbook_formula_not_allowed: '名单必需字段包含不允许的公式',
|
||||
roster_workbook_formula_result_missing: '名单必需字段公式没有可验证的缓存结果',
|
||||
roster_workbook_formula_result_mismatch: '名单模板公式结果与同一行身份证信息不一致',
|
||||
roster_workbook_non_contiguous_rows: '名单数据行不连续',
|
||||
roster_workbook_incomplete_row: '名单中间存在未填写旅客资料的模板行',
|
||||
roster_workbook_row_limit: '名单数据行数超过技术限制',
|
||||
@@ -325,7 +327,8 @@ function excelColumnName(column: number): string {
|
||||
function formulaAllowed(
|
||||
cell: ExcelJS.Cell,
|
||||
headerRow: number,
|
||||
sourceColumns: SourceColumnMap
|
||||
sourceColumns: SourceColumnMap,
|
||||
identityColumn?: number
|
||||
): boolean {
|
||||
const row = cellRow(cell);
|
||||
const column = cellColumn(cell);
|
||||
@@ -337,7 +340,7 @@ function formulaAllowed(
|
||||
if (column === sourceColumns['有效期']) {
|
||||
return formula === `EDATE(${excelColumnName(sourceColumns['签发日期'])}${row},10*12)-1`;
|
||||
}
|
||||
return false;
|
||||
return identityFormulaField(cell, sourceColumns, identityColumn) !== undefined;
|
||||
}
|
||||
|
||||
function scalarValue(cell: ExcelJS.Cell, allowFormulaResult = false): ScalarCellValue {
|
||||
@@ -378,12 +381,14 @@ function normalizedText(value: ScalarCellValue): string {
|
||||
}
|
||||
|
||||
function headerText(cell: ExcelJS.Cell): string {
|
||||
if (cell.type === ExcelJS.ValueType.Formula) return '';
|
||||
// An error in the title is not a header label. Errors in roster cells are
|
||||
// still non-empty data and must pass scalar validation before export.
|
||||
if (cell.type === ExcelJS.ValueType.Formula || cell.type === ExcelJS.ValueType.Error) return '';
|
||||
return normalizedText(scalarValue(cell));
|
||||
}
|
||||
|
||||
function cellHasData(cell: ExcelJS.Cell): boolean {
|
||||
if (cell.type === ExcelJS.ValueType.Formula) return true;
|
||||
if (cell.type === ExcelJS.ValueType.Formula || cell.type === ExcelJS.ValueType.Error) return true;
|
||||
return normalizedText(scalarValue(cell)) !== '';
|
||||
}
|
||||
|
||||
@@ -402,7 +407,29 @@ const BROKEN_TEMPLATE_FORMULAS: Partial<Record<RequiredSourceField, 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))'
|
||||
};
|
||||
|
||||
function unusedTemplateRow(row: ExcelJS.Row, headerRow: number, sourceColumns: SourceColumnMap): boolean {
|
||||
function identityFormulaField(
|
||||
cell: ExcelJS.Cell,
|
||||
sourceColumns: SourceColumnMap,
|
||||
identityColumn?: number
|
||||
): '性别' | '出生日期' | undefined {
|
||||
if (!identityColumn || cell.type !== ExcelJS.ValueType.Formula) return undefined;
|
||||
const reference = `${excelColumnName(identityColumn)}${cellRow(cell)}`;
|
||||
// Preserve array row/column separators: flattening the VLOOKUP table changes
|
||||
// its meaning. This is a finite template match, never a formula evaluator.
|
||||
const normalize = (formula: string): string => formula.replace(/^=/, '').replace(/\$/g, '').replace(/\s+/g, '').toUpperCase();
|
||||
for (const field of ['性别', '出生日期'] as const) {
|
||||
if (cellColumn(cell) === sourceColumns[field]
|
||||
&& normalize(cell.formula) === normalize(BROKEN_TEMPLATE_FORMULAS[field]!.replaceAll('#REF!', reference))) return field;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unusedTemplateRow(row: ExcelJS.Row, headerRow: number, sourceColumns: SourceColumnMap, identityColumn?: number): boolean {
|
||||
// A populated dependency cannot turn an unfinished passenger into an unused
|
||||
// formula-only tail row. Static-field rosters still ignore identity columns.
|
||||
if (identityColumn && (['性别', '出生日期'] as const).some(field =>
|
||||
identityFormulaField(row.getCell(sourceColumns[field]), sourceColumns, identityColumn))
|
||||
&& cellHasData(row.getCell(identityColumn))) return false;
|
||||
const sequenceCell = row.getCell(sourceColumns['序号']);
|
||||
if (sequenceCell.type === ExcelJS.ValueType.Formula) return false;
|
||||
const sequence = normalizedText(scalarValue(sequenceCell));
|
||||
@@ -412,7 +439,7 @@ function unusedTemplateRow(row: ExcelJS.Row, headerRow: number, sourceColumns: S
|
||||
const cell = row.getCell(sourceColumns[field]);
|
||||
if (cell.type === ExcelJS.ValueType.Formula) {
|
||||
const template = BROKEN_TEMPLATE_FORMULAS[field];
|
||||
if (!formulaAllowed(cell, headerRow, sourceColumns)
|
||||
if (!formulaAllowed(cell, headerRow, sourceColumns, identityColumn)
|
||||
&& (!template || normalizedFormula(cell.formula) !== normalizedFormula(template))) return false;
|
||||
} else if (normalizedText(scalarValue(cell)) !== '') {
|
||||
return false;
|
||||
@@ -435,12 +462,12 @@ function mergedTemplateNote(row: ExcelJS.Row, headerRow: number, sourceColumns:
|
||||
return onlyNote && /^(?:备注|说明|注意事项|温馨提示)[::]/.test(headerText(master));
|
||||
}
|
||||
|
||||
function lastRosterRow(sheet: ExcelJS.Worksheet, headerRow: number, sourceColumns: SourceColumnMap): number {
|
||||
function lastRosterRow(sheet: ExcelJS.Worksheet, headerRow: number, sourceColumns: SourceColumnMap, identityColumn?: number): number {
|
||||
let lastRow = sheet.rowCount;
|
||||
while (lastRow > headerRow) {
|
||||
const row = sheet.getRow(lastRow);
|
||||
if (!mergedTemplateNote(row, headerRow, sourceColumns)
|
||||
&& !unusedTemplateRow(row, headerRow, sourceColumns)) break;
|
||||
&& !unusedTemplateRow(row, headerRow, sourceColumns, identityColumn)) break;
|
||||
lastRow--;
|
||||
}
|
||||
return lastRow;
|
||||
@@ -450,7 +477,8 @@ function validateFormulasAndHiddenData(
|
||||
sheet: ExcelJS.Worksheet,
|
||||
headerRow: number,
|
||||
sourceColumns: SourceColumnMap,
|
||||
lastDataRow: number
|
||||
lastDataRow: number,
|
||||
identityColumn?: number
|
||||
): void {
|
||||
const selectedColumns = new Set(Object.values(sourceColumns));
|
||||
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||||
@@ -462,7 +490,7 @@ function validateFormulasAndHiddenData(
|
||||
if (containsExternalFormulaReference(formula)) {
|
||||
fail('roster_workbook_unsafe_content', { row: rowNumber, column: columnNumber });
|
||||
}
|
||||
if (rowNumber <= lastDataRow && selectedColumns.has(columnNumber) && !formulaAllowed(cell, headerRow, sourceColumns)) {
|
||||
if (rowNumber <= lastDataRow && selectedColumns.has(columnNumber) && !formulaAllowed(cell, headerRow, sourceColumns, identityColumn)) {
|
||||
fail('roster_workbook_formula_not_allowed', { row: rowNumber, column: columnNumber });
|
||||
}
|
||||
}
|
||||
@@ -521,6 +549,16 @@ function findHeaderLayout(sheet: ExcelJS.Worksheet): { row: number; columns: Sou
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function findIdentityColumn(sheet: ExcelJS.Worksheet, headerRow: number): number | undefined {
|
||||
const candidates: number[] = [];
|
||||
sheet.getRow(headerRow).eachCell(cell => {
|
||||
if (cell.type === ExcelJS.ValueType.Merge && cell.master.address !== cell.address) return;
|
||||
if (['身份证号码', '身份证'].includes(headerText(cell))) candidates.push(cellColumn(cell));
|
||||
});
|
||||
// An absent or ambiguous dependency permits only static gender/birth cells.
|
||||
return candidates.length === 1 ? candidates[0] : undefined;
|
||||
}
|
||||
|
||||
function excelSerialToDate(value: number, date1904: boolean): Date | undefined {
|
||||
if (!Number.isFinite(value) || value < 0) return undefined;
|
||||
const millisecondsPerDay = 86_400_000;
|
||||
@@ -577,6 +615,29 @@ function canonicalIdentifier(cell: ExcelJS.Cell): string {
|
||||
return normalizedText(value);
|
||||
}
|
||||
|
||||
function identityDerivedValues(cell: ExcelJS.Cell): { gender: string; birthDate: string } {
|
||||
const value = scalarValue(cell);
|
||||
// LEN/MID operate on the original characters. Trimming or removing control
|
||||
// characters here could incorrectly approve a stale formula cache.
|
||||
const identity = typeof value === 'string' ? value
|
||||
: typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : '';
|
||||
const location = { row: cellRow(cell), column: cellColumn(cell) };
|
||||
if (![15, 18].includes(identity.length) || !/^(?:\d{15}|\d{17}[\dXx])$/.test(identity)) {
|
||||
fail('roster_workbook_invalid_identifier', location);
|
||||
}
|
||||
const digits = identity.length === 18 ? identity.slice(6, 14) : `19${identity.slice(6, 12)}`;
|
||||
const year = Number(digits.slice(0, 4));
|
||||
const month = Number(digits.slice(4, 6));
|
||||
const day = Number(digits.slice(6, 8));
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
if (year < 1900 || year > 2200 || date.getUTCFullYear() !== year
|
||||
|| date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) fail('roster_workbook_invalid_date', location);
|
||||
return {
|
||||
gender: Number(identity[identity.length === 18 ? 16 : 14]) % 2 === 1 ? '男' : '女',
|
||||
birthDate: `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalSequence(cell: ExcelJS.Cell): number {
|
||||
const value = scalarValue(cell);
|
||||
let sequence: number;
|
||||
@@ -605,7 +666,8 @@ function dataRows(
|
||||
headerRow: number,
|
||||
sourceColumns: SourceColumnMap,
|
||||
maxDataRows: number,
|
||||
lastDataRow: number
|
||||
lastDataRow: number,
|
||||
identityColumn?: number
|
||||
): ExcelJS.Row[] {
|
||||
const rows: ExcelJS.Row[] = [];
|
||||
let gapAfterData = false;
|
||||
@@ -618,7 +680,7 @@ function dataRows(
|
||||
continue;
|
||||
}
|
||||
if (gapAfterData) fail('roster_workbook_non_contiguous_rows', { row: rowNumber });
|
||||
if (unusedTemplateRow(row, headerRow, sourceColumns)) {
|
||||
if (unusedTemplateRow(row, headerRow, sourceColumns, identityColumn)) {
|
||||
fail('roster_workbook_incomplete_row', { row: rowNumber, column: sourceColumns['姓名'] });
|
||||
}
|
||||
rows.push(row);
|
||||
@@ -632,7 +694,8 @@ function canonicalRow(
|
||||
row: ExcelJS.Row,
|
||||
date1904: boolean,
|
||||
seenSequences: Set<number>,
|
||||
sourceColumns: SourceColumnMap
|
||||
sourceColumns: SourceColumnMap,
|
||||
identityColumn?: number
|
||||
): string[] {
|
||||
const cell = (field: RequiredSourceField): ExcelJS.Cell => row.getCell(sourceColumns[field]);
|
||||
const sequence = canonicalSequence(cell('序号'));
|
||||
@@ -647,12 +710,29 @@ function canonicalRow(
|
||||
const issueDateCell = cell('签发日期');
|
||||
const expiryCell = cell('有效期');
|
||||
const englishName = canonicalTextCell(cell('英文姓名')).replace(/,/g, ',');
|
||||
const genderCell = cell('性别');
|
||||
const birthCell = cell('出生日期');
|
||||
const genderFormula = identityFormulaField(genderCell, sourceColumns, identityColumn);
|
||||
const birthFormula = identityFormulaField(birthCell, sourceColumns, identityColumn);
|
||||
const gender = normalizedText(scalarValue(genderCell, Boolean(genderFormula)));
|
||||
const birthDate = canonicalDate(birthCell, date1904, Boolean(birthFormula));
|
||||
if (genderFormula || birthFormula) {
|
||||
const derived = identityDerivedValues(row.getCell(identityColumn!));
|
||||
for (const [formula, actual, expected, target] of [
|
||||
[genderFormula, gender, derived.gender, genderCell],
|
||||
[birthFormula, birthDate, derived.birthDate, birthCell]
|
||||
] as const) {
|
||||
if (formula && actual !== expected) {
|
||||
fail('roster_workbook_formula_result_mismatch', { row: row.number, column: cellColumn(target) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return [
|
||||
String(sequence),
|
||||
canonicalTextCell(cell('姓名')),
|
||||
englishName,
|
||||
canonicalTextCell(cell('性别')),
|
||||
canonicalDate(cell('出生日期'), date1904),
|
||||
gender,
|
||||
birthDate,
|
||||
canonicalTextCell(cell('出生地')),
|
||||
'护照',
|
||||
canonicalIdentifier(cell('护照号码')),
|
||||
@@ -715,14 +795,15 @@ export async function normalizePassengerRosterWorkbook(
|
||||
if (sheet.state !== 'visible') fail('roster_workbook_hidden_data');
|
||||
const header = findHeaderLayout(sheet);
|
||||
const headerRow = header.row;
|
||||
const lastDataRow = lastRosterRow(sheet, headerRow, header.columns);
|
||||
const identityColumn = findIdentityColumn(sheet, headerRow);
|
||||
const lastDataRow = lastRosterRow(sheet, headerRow, header.columns, identityColumn);
|
||||
// Content safety and hidden-data checks still cover the entire workbook,
|
||||
// including discarded placeholders and footnotes.
|
||||
validateFormulasAndHiddenData(sheet, headerRow, header.columns, lastDataRow);
|
||||
const rows = dataRows(sheet, headerRow, header.columns, maxDataRows, lastDataRow);
|
||||
validateFormulasAndHiddenData(sheet, headerRow, header.columns, lastDataRow, identityColumn);
|
||||
const rows = dataRows(sheet, headerRow, header.columns, maxDataRows, lastDataRow, identityColumn);
|
||||
const date1904 = workbook.properties.date1904 === true;
|
||||
const seenSequences = new Set<number>();
|
||||
const canonicalRows = rows.map((row) => canonicalRow(row, date1904, seenSequences, header.columns));
|
||||
const canonicalRows = rows.map((row) => canonicalRow(row, date1904, seenSequences, header.columns, identityColumn));
|
||||
return {
|
||||
canonicalTsv: [CANONICAL_PASSENGER_HEADERS, ...canonicalRows]
|
||||
.map((row) => row.join('\t'))
|
||||
|
||||
@@ -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 = '任意团信息';
|
||||
|
||||
Reference in new issue
Block a user