feat: accept ERP-semantic roster workbook headers

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

View File

@@ -75,7 +75,7 @@ export function effectiveParserModeForRoute(route: BusinessRouteDefinition, conf
}
const PROGRAM_VERSION = 'ltjt-program-parser-v1.0.6';
export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.123';
export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.125';
const lookupFields = ({ customerRequired = true } = {}): ProgramFieldDefinition[] => [
{ key: 'order_no', label: '单号', aliases: ['团号', '订单号'], kind: 'text' },

View File

@@ -12,16 +12,52 @@ const DEFAULT_MAX_DATA_ROWS = 5_000;
const ABSOLUTE_MAX_DATA_ROWS = 5_000;
const MAX_PASSENGER_SEQUENCE = 5_000;
const MAX_HEADER_ROW = 100;
const EXPECTED_HEADER_ROW = 2;
const SOURCE_COLUMN_COUNT = 14;
export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.1.0';
export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.3.0';
const SOURCE_HEADERS = [
const SOURCE_FIELDS = [
'序号', '姓名', '英文姓名', '性别', '身份证号码', '出生日期', '年龄', '出生地',
'护照号码', '签发地', '签发日期', '有效期', '电话', '备注'
'证件类型', '护照号码', '签发地', '签发日期', '有效期', '电话', '备注'
] as const;
const REQUIRED_SOURCE_FIELDS = [
'序号', '姓名', '英文姓名', '性别', '出生日期', '出生地', '护照号码',
'签发地', '签发日期', '有效期', '电话', '备注'
] as const;
type SourceField = typeof SOURCE_FIELDS[number];
type RequiredSourceField = typeof REQUIRED_SOURCE_FIELDS[number];
type SourceColumnMap = Partial<Record<SourceField, number>> & Record<RequiredSourceField, number>;
/**
* Header recognition is intentionally exact and finite. It accepts the
* customer-facing template labels, the canonical ERP labels, and aliases
* observed in approved roster samples; it does not use fuzzy matching.
*/
const HEADER_FIELD_ENTRIES: ReadonlyArray<readonly [string, SourceField]> = [
['序号', '序号'],
['姓名', '姓名'],
['英文姓名', '英文姓名'],
['NAME', '英文姓名'],
['性别', '性别'],
['身份证号码', '身份证号码'],
['身份证', '身份证号码'],
['出生日期', '出生日期'],
['年龄', '年龄'],
['出生地', '出生地'],
['证件类型', '证件类型'],
['护照号码', '护照号码'],
['证件号码', '护照号码'],
['签发地', '签发地'],
['签发日期', '签发日期'],
['签发日', '签发日期'],
['有效期', '有效期'],
['电话', '电话'],
['备注', '备注']
];
const SOURCE_FIELD_BY_HEADER = new Map<string, SourceField>(HEADER_FIELD_ENTRIES);
export const CANONICAL_PASSENGER_HEADERS = [
'序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码',
'签发地', '签发日', '有效期', '电话', '备注'
@@ -41,7 +77,6 @@ export type PassengerRosterWorkbookErrorCode =
| 'roster_workbook_hidden_data'
| 'roster_workbook_header_not_found'
| 'roster_workbook_ambiguous_header'
| 'roster_workbook_header_row_invalid'
| 'roster_workbook_formula_not_allowed'
| 'roster_workbook_formula_result_missing'
| 'roster_workbook_unexpected_column'
@@ -52,6 +87,7 @@ export type PassengerRosterWorkbookErrorCode =
| 'roster_workbook_sequence_limit'
| 'roster_workbook_duplicate_sequence'
| 'roster_workbook_identity_card_not_supported'
| 'roster_workbook_document_type_not_supported'
| 'roster_workbook_invalid_date'
| 'roster_workbook_invalid_identifier'
| 'roster_workbook_invalid_cell';
@@ -68,12 +104,11 @@ const ERROR_MESSAGES: Record<PassengerRosterWorkbookErrorCode, string> = {
roster_workbook_unsafe_content: '名单工作簿包含不允许的宏、外部链接或嵌入内容',
roster_workbook_ambiguous_sheet: '名单工作簿必须且只能包含一个工作表',
roster_workbook_hidden_data: '名单工作簿包含隐藏的非空数据',
roster_workbook_header_not_found: '未找到完整的 14 列名单表头',
roster_workbook_header_not_found: '未找到可映射到 ERP 名单字段的完整表头',
roster_workbook_ambiguous_header: '名单工作簿包含多个可识别的名单表头',
roster_workbook_header_row_invalid: '名单工作簿第 2 行必须是完整的 14 列名单表头',
roster_workbook_formula_not_allowed: '名单工作簿包含不允许的公式',
roster_workbook_formula_result_missing: '名单工作簿公式没有可验证的缓存结果',
roster_workbook_unexpected_column: '名单数据包含 14 列之外的内容',
roster_workbook_unexpected_column: '名单表头范围外包含未识别的数据列',
roster_workbook_non_contiguous_rows: '名单数据行不连续',
roster_workbook_row_limit: '名单数据行数超过技术限制',
roster_workbook_no_data: '名单工作簿没有数据行',
@@ -81,6 +116,7 @@ const ERROR_MESSAGES: Record<PassengerRosterWorkbookErrorCode, string> = {
roster_workbook_sequence_limit: '名单序号超过技术安全上限',
roster_workbook_duplicate_sequence: '名单序号不能重复',
roster_workbook_identity_card_not_supported: '当前名单导入不支持非空身份证号码',
roster_workbook_document_type_not_supported: '当前名单导入仅支持护照证件类型',
roster_workbook_invalid_date: '名单日期无效',
roster_workbook_invalid_identifier: '名单证件号或电话无法安全转换为文本',
roster_workbook_invalid_cell: '名单单元格值无效'
@@ -287,16 +323,35 @@ function cellColumn(cell: ExcelJS.Cell): number {
return cell.fullAddress.col;
}
function formulaAllowed(cell: ExcelJS.Cell, headerRow: number): boolean {
function excelColumnName(column: number): string {
let value = column;
let result = '';
while (value > 0) {
const remainder = (value - 1) % 26;
result = String.fromCharCode(65 + remainder) + result;
value = Math.floor((value - 1) / 26);
}
return result;
}
function formulaAllowed(
cell: ExcelJS.Cell,
headerRow: number,
sourceColumns: SourceColumnMap
): boolean {
const row = cellRow(cell);
const column = cellColumn(cell);
if (row <= headerRow) return false;
const formula = normalizedFormula(cell.formula);
if (column === 7) {
return formula === `DATEDIF(F${row},TODAY(),"Y")`;
const ageColumn = sourceColumns['年龄'];
if (ageColumn !== undefined && column === ageColumn) {
return formula === `DATEDIF(${excelColumnName(sourceColumns['出生日期'])}${row},TODAY(),"Y")`;
}
if (column === 12) {
return formula === `EDATE(K${row},10*12)-1`;
if (column === sourceColumns['签发日期']) {
return formula === `EDATE(${excelColumnName(sourceColumns['有效期'])}${row},-10*12)+1`;
}
if (column === sourceColumns['有效期']) {
return formula === `EDATE(${excelColumnName(sourceColumns['签发日期'])}${row},10*12)-1`;
}
return false;
}
@@ -348,14 +403,21 @@ function cellHasData(cell: ExcelJS.Cell): boolean {
return normalizedText(scalarValue(cell)) !== '';
}
function rowHasData(row: ExcelJS.Row, maxColumn = SOURCE_COLUMN_COUNT): boolean {
for (let column = 1; column <= maxColumn; column += 1) {
function rowHasData(row: ExcelJS.Row, sourceColumns: readonly number[]): boolean {
for (const column of sourceColumns) {
if (cellHasData(row.getCell(column))) return true;
}
return false;
}
function validateFormulasAndHiddenData(sheet: ExcelJS.Worksheet, headerRow: number): void {
function validateFormulasAndHiddenData(
sheet: ExcelJS.Worksheet,
headerRow: number,
sourceColumns: SourceColumnMap
): void {
const recognizedColumns = new Set(
Object.values(sourceColumns).filter((column): column is number => column !== undefined)
);
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
let hasData = false;
row.eachCell({ includeEmpty: false }, (cell, columnNumber) => {
@@ -365,15 +427,16 @@ function validateFormulasAndHiddenData(sheet: ExcelJS.Worksheet, headerRow: numb
if (containsExternalFormulaReference(formula)) {
fail('roster_workbook_unsafe_content', { row: rowNumber, column: columnNumber });
}
if (!formulaAllowed(cell, headerRow)) {
if (!formulaAllowed(cell, headerRow, sourceColumns)) {
fail('roster_workbook_formula_not_allowed', { row: rowNumber, column: columnNumber });
}
}
if (cell.type === ExcelJS.ValueType.Hyperlink || cell.isHyperlink) {
fail('roster_workbook_unsafe_content', { row: rowNumber, column: columnNumber });
}
if (cellHasData(cell)) hasData = true;
if (columnNumber > SOURCE_COLUMN_COUNT && rowNumber >= headerRow) {
const containsData = cellHasData(cell);
if (containsData) hasData = true;
if (containsData && rowNumber >= headerRow && !recognizedColumns.has(columnNumber)) {
fail('roster_workbook_unexpected_column', { row: rowNumber, column: columnNumber });
}
});
@@ -391,21 +454,37 @@ function validateFormulasAndHiddenData(sheet: ExcelJS.Worksheet, headerRow: numb
}
}
function findHeaderRow(sheet: ExcelJS.Worksheet): number {
const candidates: number[] = [];
function headerColumnsForRow(sheet: ExcelJS.Worksheet, rowNumber: number): SourceColumnMap | null {
const columns = new Map<SourceField, number>();
const row = sheet.getRow(rowNumber);
for (let columnNumber = 1; columnNumber <= sheet.columnCount; columnNumber += 1) {
const cell = row.getCell(columnNumber);
if (cell.type === ExcelJS.ValueType.Merge && cell.master.address !== cell.address) return null;
const value = headerText(cell);
if (value === '') continue;
const field = SOURCE_FIELD_BY_HEADER.get(value);
if (!field || columns.has(field)) return null;
columns.set(field, columnNumber);
}
if (!REQUIRED_SOURCE_FIELDS.every((field) => columns.has(field))) return null;
const result: Partial<Record<SourceField, number>> = {};
for (const field of SOURCE_FIELDS) {
const column = columns.get(field);
if (column !== undefined) result[field] = column;
}
return result as SourceColumnMap;
}
function findHeaderLayout(sheet: ExcelJS.Worksheet): { row: number; columns: SourceColumnMap } {
const candidates: Array<{ row: number; columns: SourceColumnMap }> = [];
const lastCandidate = Math.min(sheet.rowCount, MAX_HEADER_ROW);
for (let rowNumber = 1; rowNumber <= lastCandidate; rowNumber += 1) {
const row = sheet.getRow(rowNumber);
const values = SOURCE_HEADERS.map((_header, index) => headerText(row.getCell(index + 1)));
if (values.every((value, index) => value === SOURCE_HEADERS[index])) {
candidates.push(rowNumber);
}
const columns = headerColumnsForRow(sheet, rowNumber);
if (columns) candidates.push({ row: rowNumber, columns });
}
if (candidates.length === 0) fail('roster_workbook_header_not_found');
if (candidates.length !== 1) fail('roster_workbook_ambiguous_header');
if (candidates[0] !== EXPECTED_HEADER_ROW) {
fail('roster_workbook_header_row_invalid', { row: candidates[0] });
}
return candidates[0];
}
@@ -488,12 +567,19 @@ function canonicalTextCell(cell: ExcelJS.Cell): string {
return normalizedText(scalarValue(cell));
}
function dataRows(sheet: ExcelJS.Worksheet, headerRow: number, maxDataRows: number): ExcelJS.Row[] {
function dataRows(
sheet: ExcelJS.Worksheet,
headerRow: number,
sourceColumns: SourceColumnMap,
maxDataRows: number
): ExcelJS.Row[] {
const rows: ExcelJS.Row[] = [];
let gapAfterData = false;
const recognizedColumns = Object.values(sourceColumns)
.filter((column): column is number => column !== undefined);
for (let rowNumber = headerRow + 1; rowNumber <= sheet.rowCount; rowNumber += 1) {
const row = sheet.getRow(rowNumber);
const hasData = rowHasData(row);
const hasData = rowHasData(row, recognizedColumns);
if (!hasData) {
if (rows.length > 0) gapAfterData = true;
continue;
@@ -506,32 +592,61 @@ function dataRows(sheet: ExcelJS.Worksheet, headerRow: number, maxDataRows: numb
return rows;
}
function canonicalRow(row: ExcelJS.Row, date1904: boolean, seenSequences: Set<number>): string[] {
const sequence = canonicalSequence(row.getCell(1));
function canonicalRow(
row: ExcelJS.Row,
date1904: boolean,
seenSequences: Set<number>,
sourceColumns: SourceColumnMap
): string[] {
const cell = (field: RequiredSourceField): ExcelJS.Cell => row.getCell(sourceColumns[field]);
const sequence = canonicalSequence(cell('序号'));
if (seenSequences.has(sequence)) {
fail('roster_workbook_duplicate_sequence', { row: row.number, column: 1 });
fail('roster_workbook_duplicate_sequence', {
row: row.number,
column: sourceColumns['序号']
});
}
seenSequences.add(sequence);
if (canonicalIdentifier(row.getCell(5)) !== '') {
fail('roster_workbook_identity_card_not_supported', { row: row.number, column: 5 });
const identityCardColumn = sourceColumns['身份证号码'];
if (
identityCardColumn !== undefined
&& canonicalIdentifier(row.getCell(identityCardColumn)) !== ''
) {
fail('roster_workbook_identity_card_not_supported', {
row: row.number,
column: identityCardColumn
});
}
const englishName = canonicalTextCell(row.getCell(3)).replace(/,/g, ',');
const documentTypeColumn = sourceColumns['证件类型'];
if (documentTypeColumn !== undefined) {
const documentType = canonicalTextCell(row.getCell(documentTypeColumn));
if (documentType !== '' && documentType !== '护照') {
fail('roster_workbook_document_type_not_supported', {
row: row.number,
column: documentTypeColumn
});
}
}
const issueDateCell = cell('签发日期');
const expiryCell = cell('有效期');
const englishName = canonicalTextCell(cell('英文姓名')).replace(/,/g, ',');
return [
String(sequence),
canonicalTextCell(row.getCell(2)),
canonicalTextCell(cell('姓名')),
englishName,
canonicalTextCell(row.getCell(4)),
canonicalDate(row.getCell(6), date1904),
canonicalTextCell(row.getCell(8)),
canonicalTextCell(cell('性别')),
canonicalDate(cell('出生日期'), date1904),
canonicalTextCell(cell('出生地')),
'护照',
canonicalIdentifier(row.getCell(9)),
canonicalTextCell(row.getCell(10)),
canonicalDate(row.getCell(11), date1904),
canonicalDate(row.getCell(12), date1904, row.getCell(12).type === ExcelJS.ValueType.Formula),
canonicalIdentifier(row.getCell(13)),
canonicalTextCell(row.getCell(14))
canonicalIdentifier(cell('护照号码')),
canonicalTextCell(cell('签发地')),
canonicalDate(issueDateCell, date1904, issueDateCell.type === ExcelJS.ValueType.Formula),
canonicalDate(expiryCell, date1904, expiryCell.type === ExcelJS.ValueType.Formula),
canonicalIdentifier(cell('电话')),
canonicalTextCell(cell('备注'))
];
}
@@ -584,12 +699,13 @@ export async function normalizePassengerRosterWorkbook(
if (workbook.worksheets.length !== 1) fail('roster_workbook_ambiguous_sheet');
const sheet = workbook.worksheets[0];
if (sheet.state !== 'visible') fail('roster_workbook_hidden_data');
const headerRow = findHeaderRow(sheet);
validateFormulasAndHiddenData(sheet, headerRow);
const rows = dataRows(sheet, headerRow, maxDataRows);
const header = findHeaderLayout(sheet);
const headerRow = header.row;
validateFormulasAndHiddenData(sheet, headerRow, header.columns);
const rows = dataRows(sheet, headerRow, header.columns, maxDataRows);
const date1904 = workbook.properties.date1904 === true;
const seenSequences = new Set<number>();
const canonicalRows = rows.map((row) => canonicalRow(row, date1904, seenSequences));
const canonicalRows = rows.map((row) => canonicalRow(row, date1904, seenSequences, header.columns));
return {
canonicalTsv: [CANONICAL_PASSENGER_HEADERS, ...canonicalRows]
.map((row) => row.join('\t'))