672 lines
26 KiB
TypeScript
672 lines
26 KiB
TypeScript
import { extname } from 'node:path';
|
||
import ExcelJS from 'exceljs';
|
||
import { fromBufferPromise, type Entry, type ZipFile } from 'yauzl';
|
||
import type { DocumentConversionErrorCode } from './document-converter.js';
|
||
|
||
const XLSX_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||
const XLS_MAGIC = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
||
const DEFAULT_MAX_INPUT_BYTES = 10 * 1024 * 1024;
|
||
const DEFAULT_MAX_UNCOMPRESSED_BYTES = 64 * 1024 * 1024;
|
||
const DEFAULT_MAX_ARCHIVE_ENTRIES = 2_048;
|
||
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;
|
||
|
||
export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.4.0';
|
||
|
||
const REQUIRED_SOURCE_FIELDS = [
|
||
'序号', '姓名', '英文姓名', '性别', '出生日期', '出生地', '护照号码',
|
||
'签发地', '签发日期', '有效期', '电话', '备注'
|
||
] as const;
|
||
|
||
type RequiredSourceField = typeof REQUIRED_SOURCE_FIELDS[number];
|
||
type SourceColumnMap = Record<RequiredSourceField, 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]> = [
|
||
['序号', '序号'],
|
||
['姓名', '姓名'],
|
||
['英文姓名', '英文姓名'],
|
||
['NAME', '英文姓名'],
|
||
['性别', '性别'],
|
||
['出生日期', '出生日期'],
|
||
['出生地', '出生地'],
|
||
['护照号码', '护照号码'],
|
||
['证件号码', '护照号码'],
|
||
['签发地', '签发地'],
|
||
['签发日期', '签发日期'],
|
||
['签发日', '签发日期'],
|
||
['有效期', '有效期'],
|
||
['电话', '电话'],
|
||
['备注', '备注']
|
||
];
|
||
|
||
const SOURCE_FIELD_BY_HEADER = new Map<string, RequiredSourceField>(HEADER_FIELD_ENTRIES);
|
||
|
||
export const CANONICAL_PASSENGER_HEADERS = [
|
||
'序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码',
|
||
'签发地', '签发日', '有效期', '电话', '备注'
|
||
] as const;
|
||
|
||
export type PassengerRosterWorkbookErrorCode =
|
||
| 'roster_workbook_empty'
|
||
| 'roster_workbook_too_large'
|
||
| 'roster_workbook_unsupported_format'
|
||
| 'roster_workbook_format_mismatch'
|
||
| 'roster_workbook_converter_unavailable'
|
||
| 'roster_workbook_conversion_failed'
|
||
| 'roster_workbook_invalid_xlsx'
|
||
| 'roster_workbook_archive_limit'
|
||
| 'roster_workbook_unsafe_content'
|
||
| 'roster_workbook_ambiguous_sheet'
|
||
| 'roster_workbook_hidden_data'
|
||
| 'roster_workbook_header_not_found'
|
||
| 'roster_workbook_ambiguous_header'
|
||
| '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_invalid_date'
|
||
| 'roster_workbook_invalid_identifier'
|
||
| 'roster_workbook_invalid_cell';
|
||
|
||
const ERROR_MESSAGES: Record<PassengerRosterWorkbookErrorCode, string> = {
|
||
roster_workbook_empty: '名单工作簿为空',
|
||
roster_workbook_too_large: '名单工作簿超过大小限制',
|
||
roster_workbook_unsupported_format: '名单附件格式不受支持',
|
||
roster_workbook_format_mismatch: '名单附件扩展名、类型或文件内容不匹配',
|
||
roster_workbook_converter_unavailable: '旧版名单工作簿转换器不可用',
|
||
roster_workbook_conversion_failed: '旧版名单工作簿转换失败',
|
||
roster_workbook_invalid_xlsx: '名单工作簿不是有效的 XLSX',
|
||
roster_workbook_archive_limit: '名单工作簿解压规模超过限制',
|
||
roster_workbook_unsafe_content: '名单工作簿包含不允许的宏、外部链接或嵌入内容',
|
||
roster_workbook_ambiguous_sheet: '名单工作簿必须且只能包含一个工作表',
|
||
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_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_invalid_date: '名单日期无效',
|
||
roster_workbook_invalid_identifier: '名单证件号或电话无法安全转换为文本',
|
||
roster_workbook_invalid_cell: '名单单元格值无效'
|
||
};
|
||
|
||
export class PassengerRosterWorkbookError extends Error {
|
||
readonly code: PassengerRosterWorkbookErrorCode;
|
||
readonly row?: number;
|
||
readonly column?: number;
|
||
readonly conversionErrorCode?: DocumentConversionErrorCode;
|
||
|
||
constructor(
|
||
code: PassengerRosterWorkbookErrorCode,
|
||
location: { row?: number; column?: number; conversionErrorCode?: DocumentConversionErrorCode } = {}
|
||
) {
|
||
super(ERROR_MESSAGES[code]);
|
||
this.name = 'PassengerRosterWorkbookError';
|
||
this.code = code;
|
||
this.row = location.row;
|
||
this.column = location.column;
|
||
this.conversionErrorCode = location.conversionErrorCode;
|
||
}
|
||
}
|
||
|
||
export interface PassengerRosterWorkbookInput {
|
||
content: Buffer;
|
||
fileName: string;
|
||
contentType: string;
|
||
}
|
||
|
||
/**
|
||
* The caller must run legacy conversion in an isolated, macro-disabled
|
||
* process and must reject converter fallback to the original source bytes.
|
||
* This normalizer independently verifies that the callback returned XLSX.
|
||
*/
|
||
export type LegacyXlsConverter = (input: PassengerRosterWorkbookInput) => Promise<Buffer>;
|
||
|
||
export interface PassengerRosterWorkbookOptions {
|
||
convertLegacyXls?: LegacyXlsConverter;
|
||
maxInputBytes?: number;
|
||
maxUncompressedBytes?: number;
|
||
maxArchiveEntries?: number;
|
||
maxDataRows?: number;
|
||
}
|
||
|
||
export interface NormalizedPassengerRosterWorkbook {
|
||
canonicalTsv: string;
|
||
rowCount: number;
|
||
sourceFormat: 'xls' | 'xlsx';
|
||
worksheetName: string;
|
||
headerRow: number;
|
||
}
|
||
|
||
type ScalarCellValue = string | number | boolean | Date | null;
|
||
|
||
function fail(
|
||
code: PassengerRosterWorkbookErrorCode,
|
||
location: { row?: number; column?: number; conversionErrorCode?: DocumentConversionErrorCode } = {}
|
||
): never {
|
||
throw new PassengerRosterWorkbookError(code, location);
|
||
}
|
||
|
||
function startsWithMagic(content: Buffer, magic: Buffer): boolean {
|
||
return content.byteLength >= magic.byteLength
|
||
&& content.subarray(0, magic.byteLength).equals(magic);
|
||
}
|
||
|
||
function normalizedMimeType(contentType: string): string {
|
||
return contentType.toLowerCase().split(';', 1)[0].trim();
|
||
}
|
||
|
||
function sourceFormat(input: PassengerRosterWorkbookInput): 'xls' | 'xlsx' {
|
||
const extension = extname(input.fileName).toLowerCase();
|
||
const mimeType = normalizedMimeType(input.contentType);
|
||
const xlsxMagic = startsWithMagic(input.content, XLSX_MAGIC);
|
||
const xlsMagic = startsWithMagic(input.content, XLS_MAGIC);
|
||
const genericMime = mimeType === ''
|
||
|| mimeType === 'application/octet-stream'
|
||
|| mimeType === 'application/zip';
|
||
const xlsxMime = mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||
const xlsMime = [
|
||
'application/vnd.ms-excel',
|
||
'application/msexcel',
|
||
'application/x-msexcel',
|
||
'application/xls',
|
||
'application/vnd.ms-office'
|
||
].includes(mimeType);
|
||
|
||
if (extension !== '.xls' && extension !== '.xlsx') {
|
||
fail('roster_workbook_unsupported_format');
|
||
}
|
||
if (extension === '.xlsx') {
|
||
if (!xlsxMagic || xlsMagic || (!genericMime && !xlsxMime)) {
|
||
fail('roster_workbook_format_mismatch');
|
||
}
|
||
return 'xlsx';
|
||
}
|
||
if (!xlsMagic || xlsxMagic || (!genericMime && !xlsMime)) {
|
||
fail('roster_workbook_format_mismatch');
|
||
}
|
||
return 'xls';
|
||
}
|
||
|
||
function legacyWorkbookHasMacroMarkers(content: Buffer): boolean {
|
||
const markers = ['_VBA_PROJECT_CUR', 'PROJECTwm', 'VBA/dir', 'VBA\\dir', 'Macros'];
|
||
return markers.some((marker) => (
|
||
content.indexOf(Buffer.from(marker, 'latin1')) >= 0
|
||
|| content.indexOf(Buffer.from(marker, 'utf16le')) >= 0
|
||
));
|
||
}
|
||
|
||
const UNSAFE_ARCHIVE_PATH = /(?:^|\/)(?:vbaProject\.bin|macrosheets\/|activex\/|embeddings\/|externalLinks\/|connections\.xml$|queryTables\/|webextensions\/|model\/)/i;
|
||
const RELATIONSHIP_OR_CONTENT_TYPE_PATH = /(?:\.rels|\[Content_Types\]\.xml)$/i;
|
||
const EXTERNAL_RELATIONSHIP_PATTERN = /\bTargetMode\s*=\s*["']External["']/i;
|
||
const MACRO_CONTENT_TYPE_PATTERN = /(?:macroEnabled|vbaProject|activeX|oleObject)/i;
|
||
|
||
async function readArchiveEntryText(zipFile: ZipFile, entry: Entry): Promise<string> {
|
||
const stream = await zipFile.openReadStreamPromise(entry);
|
||
const chunks: Buffer[] = [];
|
||
for await (const chunk of stream) {
|
||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||
}
|
||
return Buffer.concat(chunks).toString('utf8');
|
||
}
|
||
|
||
async function inspectXlsxArchive(
|
||
content: Buffer,
|
||
options: Required<Pick<PassengerRosterWorkbookOptions, 'maxUncompressedBytes' | 'maxArchiveEntries'>>
|
||
): Promise<void> {
|
||
let zipFile: ZipFile;
|
||
try {
|
||
zipFile = await fromBufferPromise(content, {
|
||
lazyEntries: true,
|
||
decodeStrings: true,
|
||
validateEntrySizes: true,
|
||
strictFileNames: true
|
||
});
|
||
} catch {
|
||
fail('roster_workbook_invalid_xlsx');
|
||
}
|
||
|
||
let entryCount = 0;
|
||
let uncompressedBytes = 0;
|
||
try {
|
||
for await (const entry of zipFile.eachEntry()) {
|
||
entryCount += 1;
|
||
uncompressedBytes += entry.uncompressedSize;
|
||
if (
|
||
entryCount > options.maxArchiveEntries
|
||
|| entry.uncompressedSize > options.maxUncompressedBytes
|
||
|| uncompressedBytes > options.maxUncompressedBytes
|
||
) {
|
||
fail('roster_workbook_archive_limit');
|
||
}
|
||
if (entry.isEncrypted() || !entry.canDecodeFileData()) {
|
||
fail('roster_workbook_unsafe_content');
|
||
}
|
||
const normalizedPath = entry.fileName.replace(/\\/g, '/');
|
||
if (UNSAFE_ARCHIVE_PATH.test(normalizedPath)) {
|
||
fail('roster_workbook_unsafe_content');
|
||
}
|
||
if (RELATIONSHIP_OR_CONTENT_TYPE_PATH.test(normalizedPath) && entry.uncompressedSize > 0) {
|
||
const source = await readArchiveEntryText(zipFile, entry);
|
||
if (
|
||
EXTERNAL_RELATIONSHIP_PATTERN.test(source)
|
||
|| (normalizedPath.endsWith('[Content_Types].xml') && MACRO_CONTENT_TYPE_PATTERN.test(source))
|
||
) {
|
||
fail('roster_workbook_unsafe_content');
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof PassengerRosterWorkbookError) throw error;
|
||
fail('roster_workbook_invalid_xlsx');
|
||
} finally {
|
||
zipFile.close();
|
||
}
|
||
}
|
||
|
||
function formulaText(cell: ExcelJS.Cell): string | undefined {
|
||
if (cell.type !== ExcelJS.ValueType.Formula) return undefined;
|
||
const formula = cell.formula;
|
||
return typeof formula === 'string' ? formula : undefined;
|
||
}
|
||
|
||
function containsExternalFormulaReference(formula: string): boolean {
|
||
return /(?:\[[^\]]+\]|https?:|ftp:|file:|\\\\|WEBSERVICE\s*\(|HYPERLINK\s*\(|DDE\s*\()/i.test(formula);
|
||
}
|
||
|
||
function normalizedFormula(formula: string): string {
|
||
return formula
|
||
.replace(/^=/, '')
|
||
.replace(/\$/g, '')
|
||
.replace(/;/g, ',')
|
||
.replace(/\s+/g, '')
|
||
.toUpperCase();
|
||
}
|
||
|
||
function cellRow(cell: ExcelJS.Cell): number {
|
||
return cell.fullAddress.row;
|
||
}
|
||
|
||
function cellColumn(cell: ExcelJS.Cell): number {
|
||
return cell.fullAddress.col;
|
||
}
|
||
|
||
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 === sourceColumns['签发日期']) {
|
||
return formula === `EDATE(${excelColumnName(sourceColumns['有效期'])}${row},-10*12)+1`;
|
||
}
|
||
if (column === sourceColumns['有效期']) {
|
||
return formula === `EDATE(${excelColumnName(sourceColumns['签发日期'])}${row},10*12)-1`;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function scalarValue(cell: ExcelJS.Cell, allowFormulaResult = false): ScalarCellValue {
|
||
if (cell.type === ExcelJS.ValueType.Formula) {
|
||
if (!allowFormulaResult) fail('roster_workbook_formula_not_allowed', { row: cellRow(cell), column: cellColumn(cell) });
|
||
const result = cell.result;
|
||
if (result === undefined || result === null) {
|
||
fail('roster_workbook_formula_result_missing', { row: cellRow(cell), column: cellColumn(cell) });
|
||
}
|
||
if (typeof result === 'object' && !(result instanceof Date)) {
|
||
fail('roster_workbook_invalid_cell', { row: cellRow(cell), column: cellColumn(cell) });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
const value = cell.value;
|
||
if (value === undefined || value === null) return null;
|
||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value instanceof Date) {
|
||
return value;
|
||
}
|
||
if ('hyperlink' in value) {
|
||
fail('roster_workbook_unsafe_content', { row: cellRow(cell), column: cellColumn(cell) });
|
||
}
|
||
if ('richText' in value) {
|
||
return value.richText.map((part) => part.text).join('');
|
||
}
|
||
fail('roster_workbook_invalid_cell', { row: cellRow(cell), column: cellColumn(cell) });
|
||
}
|
||
|
||
function normalizedText(value: ScalarCellValue): string {
|
||
if (value === null) return '';
|
||
if (value instanceof Date) return value.toISOString();
|
||
return String(value)
|
||
.replace(/[\t\r\n\u3000]+/g, ' ')
|
||
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function headerText(cell: ExcelJS.Cell): string {
|
||
if (cell.type === ExcelJS.ValueType.Formula) return '';
|
||
return normalizedText(scalarValue(cell));
|
||
}
|
||
|
||
function cellHasData(cell: ExcelJS.Cell): boolean {
|
||
if (cell.type === ExcelJS.ValueType.Formula) return true;
|
||
return normalizedText(scalarValue(cell)) !== '';
|
||
}
|
||
|
||
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,
|
||
sourceColumns: SourceColumnMap
|
||
): void {
|
||
const selectedColumns = new Set(Object.values(sourceColumns));
|
||
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||
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);
|
||
if (formula) {
|
||
if (containsExternalFormulaReference(formula)) {
|
||
fail('roster_workbook_unsafe_content', { row: rowNumber, column: columnNumber });
|
||
}
|
||
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 });
|
||
}
|
||
if (selectedColumns.has(columnNumber) && cellHasData(cell)) {
|
||
hasSelectedData = true;
|
||
}
|
||
});
|
||
if (row.hidden && hasSelectedData) fail('roster_workbook_hidden_data', { row: rowNumber });
|
||
});
|
||
|
||
for (const columnNumber of selectedColumns) {
|
||
const column = sheet.getColumn(columnNumber);
|
||
if (!column.hidden) continue;
|
||
let hasData = false;
|
||
column.eachCell({ includeEmpty: false }, (cell) => {
|
||
if (cellHasData(cell)) hasData = true;
|
||
});
|
||
if (hasData) fail('roster_workbook_hidden_data', { column: columnNumber });
|
||
}
|
||
}
|
||
|
||
function headerColumnsForRow(sheet: ExcelJS.Worksheet, rowNumber: number): SourceColumnMap | null {
|
||
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) continue;
|
||
const value = headerText(cell);
|
||
if (value === '') continue;
|
||
const field = SOURCE_FIELD_BY_HEADER.get(value);
|
||
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<SourceColumnMap> = {};
|
||
for (const field of REQUIRED_SOURCE_FIELDS) {
|
||
result[field] = columns.get(field);
|
||
}
|
||
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 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');
|
||
return candidates[0];
|
||
}
|
||
|
||
function excelSerialToDate(value: number, date1904: boolean): Date | undefined {
|
||
if (!Number.isFinite(value) || value < 0) return undefined;
|
||
const millisecondsPerDay = 86_400_000;
|
||
const epoch = date1904
|
||
? Date.UTC(1904, 0, 1)
|
||
: Date.UTC(1899, 11, 30);
|
||
const result = new Date(epoch + Math.floor(value) * millisecondsPerDay);
|
||
return Number.isNaN(result.getTime()) ? undefined : result;
|
||
}
|
||
|
||
function canonicalDate(cell: ExcelJS.Cell, date1904: boolean, formulaResult = false): string {
|
||
const value = scalarValue(cell, formulaResult);
|
||
if (value === null || value === '') return '';
|
||
let date: Date | undefined;
|
||
if (value instanceof Date) {
|
||
date = value;
|
||
} else if (typeof value === 'number') {
|
||
date = excelSerialToDate(value, date1904);
|
||
} else if (typeof value === 'string') {
|
||
const match = value.trim().match(/^(\d{4})[-/.\u5e74](\d{1,2})[-/.\u6708](\d{1,2})(?:\u65e5)?$/);
|
||
if (match) {
|
||
date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
||
if (
|
||
date.getUTCFullYear() !== Number(match[1])
|
||
|| date.getUTCMonth() !== Number(match[2]) - 1
|
||
|| date.getUTCDate() !== Number(match[3])
|
||
) {
|
||
date = undefined;
|
||
}
|
||
}
|
||
}
|
||
if (!date || Number.isNaN(date.getTime())) {
|
||
fail('roster_workbook_invalid_date', { row: cellRow(cell), column: cellColumn(cell) });
|
||
}
|
||
const year = date.getUTCFullYear();
|
||
if (year < 1900 || year > 2200) {
|
||
fail('roster_workbook_invalid_date', { row: cellRow(cell), column: cellColumn(cell) });
|
||
}
|
||
return `${String(year).padStart(4, '0')}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
function canonicalIdentifier(cell: ExcelJS.Cell): string {
|
||
const value = scalarValue(cell);
|
||
if (value === null || value === '') return '';
|
||
if (typeof value === 'number') {
|
||
if (!Number.isSafeInteger(value) || value < 0) {
|
||
fail('roster_workbook_invalid_identifier', { row: cellRow(cell), column: cellColumn(cell) });
|
||
}
|
||
return String(value);
|
||
}
|
||
if (typeof value !== 'string') {
|
||
fail('roster_workbook_invalid_identifier', { row: cellRow(cell), column: cellColumn(cell) });
|
||
}
|
||
return normalizedText(value);
|
||
}
|
||
|
||
function canonicalSequence(cell: ExcelJS.Cell): number {
|
||
const value = scalarValue(cell);
|
||
let sequence: number;
|
||
if (typeof value === 'number') {
|
||
sequence = value;
|
||
} else if (typeof value === 'string' && /^[1-9]\d*$/.test(value.trim())) {
|
||
sequence = Number(value.trim());
|
||
} else {
|
||
fail('roster_workbook_invalid_sequence', { row: cellRow(cell), column: cellColumn(cell) });
|
||
}
|
||
if (!Number.isSafeInteger(sequence) || sequence <= 0) {
|
||
fail('roster_workbook_invalid_sequence', { row: cellRow(cell), column: cellColumn(cell) });
|
||
}
|
||
if (sequence > MAX_PASSENGER_SEQUENCE) {
|
||
fail('roster_workbook_sequence_limit', { row: cellRow(cell), column: cellColumn(cell) });
|
||
}
|
||
return sequence;
|
||
}
|
||
|
||
function canonicalTextCell(cell: ExcelJS.Cell): string {
|
||
return normalizedText(scalarValue(cell));
|
||
}
|
||
|
||
function dataRows(
|
||
sheet: ExcelJS.Worksheet,
|
||
headerRow: number,
|
||
sourceColumns: SourceColumnMap,
|
||
maxDataRows: number
|
||
): ExcelJS.Row[] {
|
||
const rows: ExcelJS.Row[] = [];
|
||
let gapAfterData = false;
|
||
const selectedColumns = Object.values(sourceColumns);
|
||
for (let rowNumber = headerRow + 1; rowNumber <= sheet.rowCount; rowNumber += 1) {
|
||
const row = sheet.getRow(rowNumber);
|
||
const hasData = rowHasData(row, selectedColumns);
|
||
if (!hasData) {
|
||
if (rows.length > 0) gapAfterData = true;
|
||
continue;
|
||
}
|
||
if (gapAfterData) fail('roster_workbook_non_contiguous_rows', { row: rowNumber });
|
||
rows.push(row);
|
||
if (rows.length > maxDataRows) fail('roster_workbook_row_limit');
|
||
}
|
||
if (rows.length === 0) fail('roster_workbook_no_data');
|
||
return rows;
|
||
}
|
||
|
||
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: sourceColumns['序号']
|
||
});
|
||
}
|
||
seenSequences.add(sequence);
|
||
|
||
const issueDateCell = cell('签发日期');
|
||
const expiryCell = cell('有效期');
|
||
const englishName = canonicalTextCell(cell('英文姓名')).replace(/,/g, ',');
|
||
return [
|
||
String(sequence),
|
||
canonicalTextCell(cell('姓名')),
|
||
englishName,
|
||
canonicalTextCell(cell('性别')),
|
||
canonicalDate(cell('出生日期'), date1904),
|
||
canonicalTextCell(cell('出生地')),
|
||
'护照',
|
||
canonicalIdentifier(cell('护照号码')),
|
||
canonicalTextCell(cell('签发地')),
|
||
canonicalDate(issueDateCell, date1904, issueDateCell.type === ExcelJS.ValueType.Formula),
|
||
canonicalDate(expiryCell, date1904, expiryCell.type === ExcelJS.ValueType.Formula),
|
||
canonicalIdentifier(cell('电话')),
|
||
canonicalTextCell(cell('备注'))
|
||
];
|
||
}
|
||
|
||
export async function normalizePassengerRosterWorkbook(
|
||
input: PassengerRosterWorkbookInput,
|
||
options: PassengerRosterWorkbookOptions = {}
|
||
): Promise<NormalizedPassengerRosterWorkbook> {
|
||
const maxInputBytes = options.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES;
|
||
const maxUncompressedBytes = options.maxUncompressedBytes ?? DEFAULT_MAX_UNCOMPRESSED_BYTES;
|
||
const maxArchiveEntries = options.maxArchiveEntries ?? DEFAULT_MAX_ARCHIVE_ENTRIES;
|
||
const requestedMaxDataRows = options.maxDataRows ?? DEFAULT_MAX_DATA_ROWS;
|
||
const maxDataRows = Math.min(requestedMaxDataRows, ABSOLUTE_MAX_DATA_ROWS);
|
||
|
||
if (!Number.isSafeInteger(maxInputBytes) || maxInputBytes <= 0) fail('roster_workbook_too_large');
|
||
if (!Number.isSafeInteger(maxUncompressedBytes) || maxUncompressedBytes <= 0) fail('roster_workbook_archive_limit');
|
||
if (!Number.isSafeInteger(maxArchiveEntries) || maxArchiveEntries <= 0) fail('roster_workbook_archive_limit');
|
||
if (!Number.isSafeInteger(requestedMaxDataRows) || requestedMaxDataRows <= 0) fail('roster_workbook_row_limit');
|
||
if (input.content.byteLength === 0) fail('roster_workbook_empty');
|
||
if (input.content.byteLength > maxInputBytes) fail('roster_workbook_too_large');
|
||
|
||
const format = sourceFormat(input);
|
||
let xlsxContent = input.content;
|
||
if (format === 'xls') {
|
||
if (legacyWorkbookHasMacroMarkers(input.content)) fail('roster_workbook_unsafe_content');
|
||
if (!options.convertLegacyXls) fail('roster_workbook_converter_unavailable');
|
||
try {
|
||
xlsxContent = await options.convertLegacyXls(input);
|
||
} catch (error) {
|
||
if (error instanceof PassengerRosterWorkbookError) throw error;
|
||
fail('roster_workbook_conversion_failed');
|
||
}
|
||
if (
|
||
!Buffer.isBuffer(xlsxContent)
|
||
|| xlsxContent.byteLength === 0
|
||
|| xlsxContent.byteLength > maxInputBytes
|
||
|| !startsWithMagic(xlsxContent, XLSX_MAGIC)
|
||
) {
|
||
fail('roster_workbook_conversion_failed');
|
||
}
|
||
}
|
||
|
||
await inspectXlsxArchive(xlsxContent, { maxUncompressedBytes, maxArchiveEntries });
|
||
const workbook = new ExcelJS.Workbook();
|
||
try {
|
||
const excelJsBuffer = xlsxContent as unknown as Parameters<typeof workbook.xlsx.load>[0];
|
||
await workbook.xlsx.load(excelJsBuffer);
|
||
} catch {
|
||
fail('roster_workbook_invalid_xlsx');
|
||
}
|
||
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 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, header.columns));
|
||
return {
|
||
canonicalTsv: [CANONICAL_PASSENGER_HEADERS, ...canonicalRows]
|
||
.map((row) => row.join('\t'))
|
||
.join('\n'),
|
||
rowCount: canonicalRows.length,
|
||
sourceFormat: format,
|
||
worksheetName: sheet.name,
|
||
headerRow
|
||
};
|
||
}
|