fix: ignore non-import roster columns
This commit is contained in:
1 parent
8f70cbae4d
commit
9d1a6b4379
27 files changed
+169
-136
No files matched your search
@@ -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.125';
|
||||
export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.126';
|
||||
|
||||
const lookupFields = ({ customerRequired = true } = {}): ProgramFieldDefinition[] => [
|
||||
{ key: 'order_no', label: '单号', aliases: ['团号', '订单号'], kind: 'text' },
|
||||
|
||||
@@ -13,39 +13,29 @@ 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.3.0';
|
||||
|
||||
const SOURCE_FIELDS = [
|
||||
'序号', '姓名', '英文姓名', '性别', '身份证号码', '出生日期', '年龄', '出生地',
|
||||
'证件类型', '护照号码', '签发地', '签发日期', '有效期', '电话', '备注'
|
||||
] as const;
|
||||
export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.4.0';
|
||||
|
||||
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>;
|
||||
type SourceColumnMap = 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.
|
||||
* 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, SourceField]> = [
|
||||
const HEADER_FIELD_ENTRIES: ReadonlyArray<readonly [string, RequiredSourceField]> = [
|
||||
['序号', '序号'],
|
||||
['姓名', '姓名'],
|
||||
['英文姓名', '英文姓名'],
|
||||
['NAME', '英文姓名'],
|
||||
['性别', '性别'],
|
||||
['身份证号码', '身份证号码'],
|
||||
['身份证', '身份证号码'],
|
||||
['出生日期', '出生日期'],
|
||||
['年龄', '年龄'],
|
||||
['出生地', '出生地'],
|
||||
['证件类型', '证件类型'],
|
||||
['护照号码', '护照号码'],
|
||||
['证件号码', '护照号码'],
|
||||
['签发地', '签发地'],
|
||||
@@ -56,7 +46,7 @@ const HEADER_FIELD_ENTRIES: ReadonlyArray<readonly [string, SourceField]> = [
|
||||
['备注', '备注']
|
||||
];
|
||||
|
||||
const SOURCE_FIELD_BY_HEADER = new Map<string, SourceField>(HEADER_FIELD_ENTRIES);
|
||||
const SOURCE_FIELD_BY_HEADER = new Map<string, RequiredSourceField>(HEADER_FIELD_ENTRIES);
|
||||
|
||||
export const CANONICAL_PASSENGER_HEADERS = [
|
||||
'序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码',
|
||||
@@ -79,15 +69,12 @@ export type PassengerRosterWorkbookErrorCode =
|
||||
| 'roster_workbook_ambiguous_header'
|
||||
| 'roster_workbook_formula_not_allowed'
|
||||
| 'roster_workbook_formula_result_missing'
|
||||
| 'roster_workbook_unexpected_column'
|
||||
| 'roster_workbook_non_contiguous_rows'
|
||||
| 'roster_workbook_row_limit'
|
||||
| 'roster_workbook_no_data'
|
||||
| 'roster_workbook_invalid_sequence'
|
||||
| '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';
|
||||
@@ -103,20 +90,17 @@ const ERROR_MESSAGES: Record<PassengerRosterWorkbookErrorCode, string> = {
|
||||
roster_workbook_archive_limit: '名单工作簿解压规模超过限制',
|
||||
roster_workbook_unsafe_content: '名单工作簿包含不允许的宏、外部链接或嵌入内容',
|
||||
roster_workbook_ambiguous_sheet: '名单工作簿必须且只能包含一个工作表',
|
||||
roster_workbook_hidden_data: '名单工作簿包含隐藏的非空数据',
|
||||
roster_workbook_hidden_data: '名单工作簿包含隐藏的必需名单字段数据',
|
||||
roster_workbook_header_not_found: '未找到可映射到 ERP 名单字段的完整表头',
|
||||
roster_workbook_ambiguous_header: '名单工作簿包含多个可识别的名单表头',
|
||||
roster_workbook_formula_not_allowed: '名单工作簿包含不允许的公式',
|
||||
roster_workbook_formula_result_missing: '名单工作簿公式没有可验证的缓存结果',
|
||||
roster_workbook_unexpected_column: '名单表头范围外包含未识别的数据列',
|
||||
roster_workbook_formula_not_allowed: '名单必需字段包含不允许的公式',
|
||||
roster_workbook_formula_result_missing: '名单必需字段公式没有可验证的缓存结果',
|
||||
roster_workbook_non_contiguous_rows: '名单数据行不连续',
|
||||
roster_workbook_row_limit: '名单数据行数超过技术限制',
|
||||
roster_workbook_no_data: '名单工作簿没有数据行',
|
||||
roster_workbook_invalid_sequence: '名单序号必须是正整数',
|
||||
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: '名单单元格值无效'
|
||||
@@ -343,10 +327,6 @@ function formulaAllowed(
|
||||
const column = cellColumn(cell);
|
||||
if (row <= headerRow) return false;
|
||||
const formula = normalizedFormula(cell.formula);
|
||||
const ageColumn = sourceColumns['年龄'];
|
||||
if (ageColumn !== undefined && column === ageColumn) {
|
||||
return formula === `DATEDIF(${excelColumnName(sourceColumns['出生日期'])}${row},TODAY(),"Y")`;
|
||||
}
|
||||
if (column === sourceColumns['签发日期']) {
|
||||
return formula === `EDATE(${excelColumnName(sourceColumns['有效期'])}${row},-10*12)+1`;
|
||||
}
|
||||
@@ -415,11 +395,9 @@ function validateFormulasAndHiddenData(
|
||||
headerRow: number,
|
||||
sourceColumns: SourceColumnMap
|
||||
): void {
|
||||
const recognizedColumns = new Set(
|
||||
Object.values(sourceColumns).filter((column): column is number => column !== undefined)
|
||||
);
|
||||
const selectedColumns = new Set(Object.values(sourceColumns));
|
||||
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||||
let hasData = false;
|
||||
let hasSelectedData = false;
|
||||
row.eachCell({ includeEmpty: false }, (cell, columnNumber) => {
|
||||
if (cell.type === ExcelJS.ValueType.Merge && cell.master.address !== cell.address) return;
|
||||
const formula = formulaText(cell);
|
||||
@@ -427,23 +405,21 @@ function validateFormulasAndHiddenData(
|
||||
if (containsExternalFormulaReference(formula)) {
|
||||
fail('roster_workbook_unsafe_content', { row: rowNumber, column: columnNumber });
|
||||
}
|
||||
if (!formulaAllowed(cell, headerRow, sourceColumns)) {
|
||||
if (selectedColumns.has(columnNumber) && !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 });
|
||||
}
|
||||
const containsData = cellHasData(cell);
|
||||
if (containsData) hasData = true;
|
||||
if (containsData && rowNumber >= headerRow && !recognizedColumns.has(columnNumber)) {
|
||||
fail('roster_workbook_unexpected_column', { row: rowNumber, column: columnNumber });
|
||||
if (selectedColumns.has(columnNumber) && cellHasData(cell)) {
|
||||
hasSelectedData = true;
|
||||
}
|
||||
});
|
||||
if (row.hidden && hasData) fail('roster_workbook_hidden_data', { row: rowNumber });
|
||||
if (row.hidden && hasSelectedData) fail('roster_workbook_hidden_data', { row: rowNumber });
|
||||
});
|
||||
|
||||
for (let columnNumber = 1; columnNumber <= sheet.columnCount; columnNumber += 1) {
|
||||
for (const columnNumber of selectedColumns) {
|
||||
const column = sheet.getColumn(columnNumber);
|
||||
if (!column.hidden) continue;
|
||||
let hasData = false;
|
||||
@@ -455,23 +431,23 @@ function validateFormulasAndHiddenData(
|
||||
}
|
||||
|
||||
function headerColumnsForRow(sheet: ExcelJS.Worksheet, rowNumber: number): SourceColumnMap | null {
|
||||
const columns = new Map<SourceField, number>();
|
||||
const columns = new Map<RequiredSourceField, 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;
|
||||
if (cell.type === ExcelJS.ValueType.Merge && cell.master.address !== cell.address) continue;
|
||||
const value = headerText(cell);
|
||||
if (value === '') continue;
|
||||
const field = SOURCE_FIELD_BY_HEADER.get(value);
|
||||
if (!field || columns.has(field)) return null;
|
||||
if (!field) continue;
|
||||
if (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;
|
||||
const result: Partial<SourceColumnMap> = {};
|
||||
for (const field of REQUIRED_SOURCE_FIELDS) {
|
||||
result[field] = columns.get(field);
|
||||
}
|
||||
return result as SourceColumnMap;
|
||||
}
|
||||
@@ -575,11 +551,10 @@ function dataRows(
|
||||
): ExcelJS.Row[] {
|
||||
const rows: ExcelJS.Row[] = [];
|
||||
let gapAfterData = false;
|
||||
const recognizedColumns = Object.values(sourceColumns)
|
||||
.filter((column): column is number => column !== undefined);
|
||||
const selectedColumns = Object.values(sourceColumns);
|
||||
for (let rowNumber = headerRow + 1; rowNumber <= sheet.rowCount; rowNumber += 1) {
|
||||
const row = sheet.getRow(rowNumber);
|
||||
const hasData = rowHasData(row, recognizedColumns);
|
||||
const hasData = rowHasData(row, selectedColumns);
|
||||
if (!hasData) {
|
||||
if (rows.length > 0) gapAfterData = true;
|
||||
continue;
|
||||
@@ -608,28 +583,6 @@ function canonicalRow(
|
||||
}
|
||||
seenSequences.add(sequence);
|
||||
|
||||
const identityCardColumn = sourceColumns['身份证号码'];
|
||||
if (
|
||||
identityCardColumn !== undefined
|
||||
&& canonicalIdentifier(row.getCell(identityCardColumn)) !== ''
|
||||
) {
|
||||
fail('roster_workbook_identity_card_not_supported', {
|
||||
row: row.number,
|
||||
column: identityCardColumn
|
||||
});
|
||||
}
|
||||
|
||||
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, ',');
|
||||
|
||||
Reference in new issue
Block a user