feat: accept ERP-semantic roster workbook headers
This commit is contained in:
1 parent
ffa3340899
commit
e68fcc1ce3
22 files changed
+569
-89
No files matched your search
@@ -9,7 +9,7 @@
|
||||
- 登录会话默认跨浏览器重启持续有效,不按空闲时间或绝对时长自动失效;用户退出、修改密码、账号停用或管理员强制撤销时由服务端立即撤销。
|
||||
- 业务页面通过 REST 创建任务;Agent 返回结构化结果后,管理员在选中任务上一次点击“确认并提交到 ERP 插件”,再通过 SSE 或轮询读取服务端状态。
|
||||
- 手工与 AgentBus 的每个新任务都会按同一组织、同一 18 项业务路由固化解析策略及配置 revision;来源不能覆盖模式。其中两项名单 route 固定为 `program_only`,其余 16 项可配置 `ai / shadow / auto / program`。全消息中的唯一已登记指令可以确定 route;没有指令时,只有全部标签都属于唯一 route、至少两个不同标签且业务定位必填项完整的字段签名才可确定 route。未知、冲突或多 route 输入不猜测。后续补充轮次沿用原任务快照,设置变化只影响新任务和新会话。
|
||||
- 名单 route 创建后先进入 `awaiting_attachment`,只接收一份 `.xls/.xlsx` 14 列模板。附件在内存中失败关闭地规范化为 13 列 canonical TSV;原始工作簿不入库,文件名和 canonical TSV 使用字段加密,解析通过或终止后清除 canonical 中间文本。附件到齐前不会领取解析任务,也不会进入 ERP。独立团初始 16 行、散拼子单初始 31 行仅为 ERP 动态扩行基线,5000 为技术上限。
|
||||
- 名单 route 创建后先进入 `awaiting_attachment`,只接收一份 `.xls/.xlsx`;控制面在前 100 行中自动定位唯一的 ERP 名单字段表头,按精确字段语义映射任意表头行、列顺序及受支持别名,并把表头下方连续名单数据在内存中失败关闭地规范化为 13 列 canonical TSV。未知额外列、重复语义字段和多个候选表头继续阻断。原始工作簿不入库,文件名和 canonical TSV 使用字段加密,解析通过或终止后清除 canonical 中间文本。附件到齐前不会领取解析任务,也不会进入 ERP。独立团初始 16 行、散拼子单初始 31 行仅为 ERP 动态扩行基线,5000 为技术上限。
|
||||
- 平台的任务 ID、Agent 会话 ID、确认状态、重要摘要、事件和用户通讯内容只存在于平台任务/会话/结果信封中,不回写到 Agent `operation`,也不成为 ERP 业务字段。
|
||||
- Agent `operation` 只保存解析态业务事实。插件领取已确认任务后先做无需 ERP 查询的前门禁,再在 ERP 内只读唯一解析对象、资源和当前状态,最后对内部 execution operation 执行严格写前门禁。
|
||||
- Chrome 插件仍在管理员已登录的浏览器/ERP 会话中工作;`chrome.storage.local` 只是临时缓存。
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -12,7 +12,14 @@ const SOURCE_HEADERS = [
|
||||
'护照号码', '签发地', '签发日期', '有效期', '电话', '备注'
|
||||
] as const;
|
||||
|
||||
const ERP_HEADERS = [
|
||||
'序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码',
|
||||
'签发地', '签发日', '有效期', '电话', '备注'
|
||||
] as const;
|
||||
|
||||
const CANONICAL_HEADER = '序号\t姓名\tNAME\t性别\t出生日期\t出生地\t证件类型\t证件号码\t签发地\t签发日\t有效期\t电话\t备注';
|
||||
const REORDERED_SOURCE_INDEXES = [8, 1, 13, 0, 10, 4, 6, 12, 3, 11, 5, 9, 2, 7];
|
||||
const ATTACHMENT_VARIANT_INDEXES = [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 4];
|
||||
|
||||
type WorkbookMutation = (workbook: ExcelJS.Workbook) => void | Promise<void>;
|
||||
|
||||
@@ -57,6 +64,29 @@ async function syntheticWorkbook(...mutations: WorkbookMutation[]): Promise<Buff
|
||||
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
}
|
||||
|
||||
async function erpHeaderWorkbook(...mutations: WorkbookMutation[]): Promise<Buffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('ERP名单');
|
||||
sheet.addRow([...ERP_HEADERS]);
|
||||
sheet.addRow([
|
||||
1,
|
||||
'测试甲',
|
||||
'ALPHA,TEST',
|
||||
'男',
|
||||
'1990/1/2',
|
||||
'测试地一',
|
||||
'护照',
|
||||
123456789,
|
||||
'测试签发地',
|
||||
new Date(Date.UTC(2025, 0, 2)),
|
||||
new Date(Date.UTC(2035, 0, 1)),
|
||||
13800000000,
|
||||
'领队\t备注'
|
||||
]);
|
||||
for (const mutation of mutations) await mutation(workbook);
|
||||
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
}
|
||||
|
||||
async function expectRosterError(
|
||||
work: () => Promise<unknown>,
|
||||
code: string,
|
||||
@@ -92,7 +122,7 @@ test('XLSX is deterministically normalized to the canonical 13-column TSV', asyn
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('row 1 is ignored as group metadata and the exact roster header must be on row 2', async () => {
|
||||
test('the ERP field row is auto-detected and source columns may be reordered', async () => {
|
||||
const valid = await syntheticWorkbook((workbook) => {
|
||||
workbook.getWorksheet('Sheet1')!.getCell('N1').value = '任意团信息';
|
||||
});
|
||||
@@ -104,17 +134,142 @@ test('row 1 is ignored as group metadata and the exact roster header must be on
|
||||
assert.equal(normalized.headerRow, 2);
|
||||
assert.equal(normalized.rowCount, 2);
|
||||
|
||||
const reordered = await syntheticWorkbook((workbook) => {
|
||||
const sheet = workbook.getWorksheet('Sheet1')!;
|
||||
for (const rowNumber of [2, 3, 4]) {
|
||||
const row = sheet.getRow(rowNumber);
|
||||
const values = REORDERED_SOURCE_INDEXES.map((index) => row.getCell(index + 1).value);
|
||||
row.values = values;
|
||||
}
|
||||
sheet.getCell('G3').value = {
|
||||
formula: 'DATEDIF(K3,TODAY(),"Y")',
|
||||
result: 36
|
||||
};
|
||||
sheet.getCell('J3').value = {
|
||||
formula: 'EDATE(E3,10*12)-1',
|
||||
result: new Date(Date.UTC(2035, 0, 1))
|
||||
};
|
||||
sheet.getCell('G4').value = {
|
||||
formula: 'DATEDIF(K4,TODAY(),"Y")',
|
||||
result: 26
|
||||
};
|
||||
sheet.getCell('J4').value = {
|
||||
formula: 'EDATE(E4,10*12)-1',
|
||||
result: 49310
|
||||
};
|
||||
});
|
||||
const reorderedResult = await normalizePassengerRosterWorkbook({
|
||||
content: reordered,
|
||||
fileName: 'reordered-roster.xlsx',
|
||||
contentType: 'application/octet-stream'
|
||||
});
|
||||
assert.equal(reorderedResult.headerRow, 2);
|
||||
assert.equal(reorderedResult.canonicalTsv, [
|
||||
CANONICAL_HEADER,
|
||||
'1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注',
|
||||
'2\t测试乙\tBETA,TEST\t女\t2000-01-02\t测试地二\t护照\tP00000002\t测试签发地\t2025-01-02\t2035-01-01\t\t'
|
||||
].join('\n'));
|
||||
|
||||
const shifted = await syntheticWorkbook((workbook) => {
|
||||
workbook.getWorksheet('Sheet1')!.insertRow(2, ['额外说明']);
|
||||
const sheet = workbook.getWorksheet('Sheet1')!;
|
||||
sheet.insertRow(2, ['额外说明']);
|
||||
sheet.getCell('G4').value = {
|
||||
formula: 'DATEDIF(F4,TODAY(),"Y")',
|
||||
result: 36
|
||||
};
|
||||
sheet.getCell('L4').value = {
|
||||
formula: 'EDATE(K4,10*12)-1',
|
||||
result: new Date(Date.UTC(2035, 0, 1))
|
||||
};
|
||||
sheet.getCell('G5').value = {
|
||||
formula: 'DATEDIF(F5,TODAY(),"Y")',
|
||||
result: 26
|
||||
};
|
||||
sheet.getCell('L5').value = {
|
||||
formula: 'EDATE(K5,10*12)-1',
|
||||
result: 49310
|
||||
};
|
||||
});
|
||||
const shiftedResult = await normalizePassengerRosterWorkbook({
|
||||
content: shifted,
|
||||
fileName: 'synthetic.xlsx',
|
||||
contentType: 'application/octet-stream'
|
||||
});
|
||||
assert.equal(shiftedResult.headerRow, 3);
|
||||
assert.equal(shiftedResult.rowCount, 2);
|
||||
|
||||
const erpHeaderOnFirstRow = await normalizePassengerRosterWorkbook({
|
||||
content: await erpHeaderWorkbook(),
|
||||
fileName: 'erp-roster.xlsx',
|
||||
contentType: 'application/octet-stream'
|
||||
});
|
||||
assert.equal(erpHeaderOnFirstRow.headerRow, 1);
|
||||
assert.equal(erpHeaderOnFirstRow.canonicalTsv, [
|
||||
CANONICAL_HEADER,
|
||||
'1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注'
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('the approved attachment aliases and reverse issue-date formula are normalized', async () => {
|
||||
const attachmentVariant = await syntheticWorkbook((workbook) => {
|
||||
const sheet = workbook.getWorksheet('Sheet1')!;
|
||||
for (const rowNumber of [2, 3, 4]) {
|
||||
const row = sheet.getRow(rowNumber);
|
||||
const values = ATTACHMENT_VARIANT_INDEXES.map((index) => row.getCell(index + 1).value);
|
||||
row.values = values;
|
||||
}
|
||||
sheet.getCell('N2').value = '身份证';
|
||||
sheet.getCell('F3').value = 36;
|
||||
sheet.getCell('J3').value = {
|
||||
formula: 'EDATE(K3,-10*12)+1',
|
||||
result: new Date(Date.UTC(2025, 0, 2))
|
||||
};
|
||||
sheet.getCell('K3').value = new Date(Date.UTC(2035, 0, 1));
|
||||
sheet.getCell('F4').value = 26;
|
||||
sheet.getCell('J4').value = {
|
||||
formula: 'EDATE(K4,-10*12)+1',
|
||||
result: 45659
|
||||
};
|
||||
sheet.getCell('K4').value = 49310;
|
||||
});
|
||||
|
||||
const result = await normalizePassengerRosterWorkbook({
|
||||
content: attachmentVariant,
|
||||
fileName: 'attachment-variant.xlsx',
|
||||
contentType: 'application/octet-stream'
|
||||
});
|
||||
assert.equal(result.headerRow, 2);
|
||||
assert.equal(result.canonicalTsv, [
|
||||
CANONICAL_HEADER,
|
||||
'1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注',
|
||||
'2\t测试乙\tBETA,TEST\t女\t2000-01-02\t测试地二\t护照\tP00000002\t测试签发地\t2025-01-02\t2035-01-01\t\t'
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('source header order may vary, but every required label must appear exactly once', async () => {
|
||||
const missingHeader = await syntheticWorkbook((workbook) => {
|
||||
workbook.getWorksheet('Sheet1')!.getCell('N2').value = '备注说明';
|
||||
});
|
||||
await expectRosterError(
|
||||
() => normalizePassengerRosterWorkbook({
|
||||
content: shifted,
|
||||
fileName: 'synthetic.xlsx',
|
||||
contentType: 'application/octet-stream'
|
||||
}),
|
||||
'roster_workbook_header_row_invalid',
|
||||
{ row: 3 }
|
||||
() => normalizePassengerRosterWorkbook({ content: missingHeader, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
|
||||
'roster_workbook_header_not_found'
|
||||
);
|
||||
|
||||
const duplicateHeader = await syntheticWorkbook((workbook) => {
|
||||
workbook.getWorksheet('Sheet1')!.getCell('N2').value = '姓名';
|
||||
});
|
||||
await expectRosterError(
|
||||
() => normalizePassengerRosterWorkbook({ content: duplicateHeader, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
|
||||
'roster_workbook_header_not_found'
|
||||
);
|
||||
|
||||
const unheadedExtraData = await syntheticWorkbook((workbook) => {
|
||||
workbook.getWorksheet('Sheet1')!.getCell('O3').value = '未识别数据';
|
||||
});
|
||||
await expectRosterError(
|
||||
() => normalizePassengerRosterWorkbook({ content: unheadedExtraData, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
|
||||
'roster_workbook_unexpected_column',
|
||||
{ row: 3, column: 15 }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -164,6 +319,21 @@ test('non-empty identity cards fail closed without leaking cell values', async (
|
||||
assert.doesNotMatch(JSON.stringify(error), new RegExp(secret));
|
||||
});
|
||||
|
||||
test('canonical ERP document type remains passport-only', async () => {
|
||||
const unsupportedType = await erpHeaderWorkbook((workbook) => {
|
||||
workbook.getWorksheet('ERP名单')!.getCell('G2').value = '身份证';
|
||||
});
|
||||
await expectRosterError(
|
||||
() => normalizePassengerRosterWorkbook({
|
||||
content: unsupportedType,
|
||||
fileName: 'erp-roster.xlsx',
|
||||
contentType: 'application/octet-stream'
|
||||
}),
|
||||
'roster_workbook_document_type_not_supported',
|
||||
{ row: 2, column: 7 }
|
||||
);
|
||||
});
|
||||
|
||||
test('sequence numbers must be positive and unique', async () => {
|
||||
const duplicate = await syntheticWorkbook((workbook) => {
|
||||
workbook.getWorksheet('Sheet1')!.getCell('A4').value = 1;
|
||||
@@ -193,7 +363,7 @@ test('sequence numbers must be positive and unique', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('formulas are limited to the row-local age and expiry template formulas', async () => {
|
||||
test('formulas are limited to the row-local age, issue-date and expiry template formulas', async () => {
|
||||
const networkFormula = await syntheticWorkbook((workbook) => {
|
||||
workbook.getWorksheet('Sheet1')!.getCell('G3').value = {
|
||||
formula: 'WEBSERVICE("https://invalid.example")',
|
||||
@@ -240,6 +410,19 @@ test('formulas are limited to the row-local age and expiry template formulas', a
|
||||
'roster_workbook_formula_result_missing',
|
||||
{ row: 3, column: 12 }
|
||||
);
|
||||
|
||||
const issueDateWithoutResult = await syntheticWorkbook((workbook) => {
|
||||
const sheet = workbook.getWorksheet('Sheet1')!;
|
||||
sheet.getCell('K3').value = {
|
||||
formula: 'EDATE(L3,-10*12)+1'
|
||||
};
|
||||
sheet.getCell('L3').value = new Date(Date.UTC(2035, 0, 1));
|
||||
});
|
||||
await expectRosterError(
|
||||
() => normalizePassengerRosterWorkbook({ content: issueDateWithoutResult, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
|
||||
'roster_workbook_formula_result_missing',
|
||||
{ row: 3, column: 11 }
|
||||
);
|
||||
});
|
||||
|
||||
test('macros, external relationships and ambiguous sheets fail closed', async () => {
|
||||
|
||||
Reference in new issue
Block a user