导入表头修复

This commit is contained in:
andy committed 2026-09-16 17:11:16 +08:00
1 parent 8a0f02c9be
commit a3505f3921
28 files changed
+281 -45

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.134';
export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.135';
const lookupFields = ({ customerRequired = true } = {}): ProgramFieldDefinition[] => [
{ key: 'order_no', label: '单号', aliases: ['团号', '订单号'], kind: 'text' },
+15 -16
View File
@@ -13,22 +13,23 @@ 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.6.0';
export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.7.0';
const REQUIRED_SOURCE_FIELDS = [
'序号', '姓名', '英文姓名', '性别', '出生日期', '出生地', '护照号码',
'签发地', '签发日期', '有效期', '电话', '备注'
'签发地', '签发日期', '有效期', '备注'
] as const;
type RequiredSourceField = typeof REQUIRED_SOURCE_FIELDS[number];
type SourceColumnMap = Record<RequiredSourceField, number>;
type SourceField = RequiredSourceField | '电话';
type SourceColumnMap = Record<RequiredSourceField, number> & { 电话?: number };
/**
* Header recognition is intentionally exact and finite. It accepts the
* required customer-facing labels, canonical ERP labels, and approved aliases.
* Non-import columns are deliberately absent from this map and are ignored.
*/
const HEADER_FIELD_ENTRIES: ReadonlyArray<readonly [string, RequiredSourceField]> = [
const HEADER_FIELD_ENTRIES: ReadonlyArray<readonly [string, SourceField]> = [
['序号', '序号'],
['姓名', '姓名'],
['英文姓名', '英文姓名'],
@@ -48,7 +49,7 @@ const HEADER_FIELD_ENTRIES: ReadonlyArray<readonly [string, RequiredSourceField]
['备注', '备注']
];
const SOURCE_FIELD_BY_HEADER = new Map<string, RequiredSourceField>(HEADER_FIELD_ENTRIES);
const SOURCE_FIELD_BY_HEADER = new Map<string, SourceField>(HEADER_FIELD_ENTRIES);
export const CANONICAL_PASSENGER_HEADERS = [
'序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码',
@@ -402,7 +403,7 @@ function rowHasData(row: ExcelJS.Row, sourceColumns: readonly number[]): boolean
// These two legacy template formulas derive gender/birth date from a deleted
// identity-card column. Recognize the exact broken templates, not arbitrary
// formulas or their cached results, when deciding whether a tail row is unused.
const BROKEN_TEMPLATE_FORMULAS: Partial<Record<RequiredSourceField, string>> = {
const BROKEN_TEMPLATE_FORMULAS: Partial<Record<SourceField, string>> = {
性别: 'IF(OR(LEN(#REF!)=15,LEN(#REF!)=18),IF(MOD(MID(#REF!,15,3)*1,2),"男","女"),NA())',
出生日期: '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))'
};
@@ -434,11 +435,11 @@ function unusedTemplateRow(row: ExcelJS.Row, headerRow: number, sourceColumns: S
if (sequenceCell.type === ExcelJS.ValueType.Formula) return false;
const sequence = normalizedText(scalarValue(sequenceCell));
if (sequence && (!/^[1-9]\d*$/.test(sequence) || Number(sequence) > MAX_PASSENGER_SEQUENCE)) return false;
for (const field of REQUIRED_SOURCE_FIELDS) {
for (const [field, column] of Object.entries(sourceColumns)) {
if (field === '序号') continue;
const cell = row.getCell(sourceColumns[field]);
const cell = row.getCell(column);
if (cell.type === ExcelJS.ValueType.Formula) {
const template = BROKEN_TEMPLATE_FORMULAS[field];
const template = BROKEN_TEMPLATE_FORMULAS[field as SourceField];
if (!formulaAllowed(cell, headerRow, sourceColumns, identityColumn)
&& (!template || normalizedFormula(cell.formula) !== normalizedFormula(template))) return false;
} else if (normalizedText(scalarValue(cell)) !== '') {
@@ -516,7 +517,7 @@ function validateFormulasAndHiddenData(
}
function headerColumnsForRow(sheet: ExcelJS.Worksheet, rowNumber: number): SourceColumnMap | null {
const columns = new Map<RequiredSourceField, number>();
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);
@@ -530,11 +531,9 @@ function headerColumnsForRow(sheet: ExcelJS.Worksheet, rowNumber: number): Sourc
}
if (!REQUIRED_SOURCE_FIELDS.every((field) => columns.has(field))) return null;
const result: Partial<SourceColumnMap> = {};
for (const field of REQUIRED_SOURCE_FIELDS) {
result[field] = columns.get(field);
}
return result as SourceColumnMap;
// Keep absent optional fields out of the map: all downstream row/formula
// checks operate on actual selected columns, never a guessed neighbour.
return Object.fromEntries(columns) as SourceColumnMap;
}
function findHeaderLayout(sheet: ExcelJS.Worksheet): { row: number; columns: SourceColumnMap } {
@@ -739,7 +738,7 @@ function canonicalRow(
canonicalTextCell(cell('签发地')),
canonicalDate(issueDateCell, date1904, issueDateCell.type === ExcelJS.ValueType.Formula),
canonicalDate(expiryCell, date1904, expiryCell.type === ExcelJS.ValueType.Formula),
canonicalIdentifier(cell('电话')),
sourceColumns['电话'] === undefined ? '' : canonicalIdentifier(row.getCell(sourceColumns['电话'])),
canonicalTextCell(cell('备注'))
];
}