兼容表头

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

+1 -1
View File
@@ -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' },
+101 -20
View File
@@ -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'))