导入表头修复
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
- 业务页面通过 REST 创建任务;Agent 返回结构化结果后,任务归属人在自己的任务上一次点击“确认并提交到 ERP 插件”,再通过 SSE 或轮询读取服务端状态。普通用户与组长的任务 API、SSE、附件和插件执行权限只覆盖分配给本人的人工任务与 AgentBus 任务;管理员是纯管理面身份,不能创建、查看、修改、领取、接收事件或提交任何业务任务。
|
||||
- 手工与 AgentBus 的每个新任务都会按同一组织、同一 19 项业务路由固化解析策略及配置 revision;来源不能覆盖模式。其中两项名单 route 与散拼批量子单 route 固定为 `program_only`,其余 16 项可配置 `ai / shadow / auto / program`。全消息中的唯一已登记指令可以确定 route;没有指令时,只有全部标签都属于唯一 route、至少两个不同标签且业务定位必填项完整的字段签名才可确定 route。未知、冲突或多 route 输入不猜测。后续补充轮次沿用原任务快照,设置变化只影响新任务和新会话。
|
||||
- 组长和普通用户使用逐账号白名单,新账号默认没有任何可执行任务类型;管理员自身的任务白名单固定为空,只能在 `/accounts` 为员工逐项授权。已登记但未授权的业务返回 `business_not_authorized`;无法唯一确认 route 的员工输入返回 `business_type_unresolved`。两种拒绝都发生在解析器和 ERP 插件之前,并记录不含明文的授权拒绝审计。权限在补充输入、人工确认、全自动确认和插件领取前再次校验;取消授权后的任务不会进入 ERP 队列。AgentBus 入站使用渠道绑定员工的同一白名单,管理员账号不得成为员工渠道归属人。
|
||||
- 名单 route 创建后先进入 `awaiting_attachment`,只接收一份 `.xls/.xlsx`;控制面在前 100 行中自动定位唯一的 ERP 名单字段表头,按精确字段语义从任意表头行和列顺序中只选择 12 个必需源字段,并把表头下方连续名单数据在内存中规范化为 13 列 canonical TSV。身份证、年龄、源证件类型和其他普通额外列不参与名单行识别、字段校验或输出;必需字段缺失/重复、多个候选表头、必需字段中的非法公式及宏、外链等主动内容仍失败关闭。原始工作簿不入库,文件名和 canonical TSV 使用字段加密,解析通过或终止后清除 canonical 中间文本。附件到齐前不会领取解析任务,也不会进入 ERP。独立团初始 16 行、散拼子单初始 31 行仅为 ERP 动态扩行基线,5000 为技术上限。
|
||||
- 名单 route 创建后先进入 `awaiting_attachment`,只接收一份 `.xls/.xlsx`;控制面在前 100 行中自动定位唯一的 ERP 名单字段表头,按精确字段语义从任意表头行和列顺序中选择 11 个必需源字段及可选的电话列(缺少时输出空值,不推断无表头数字;存在时仍须唯一并接受原有校验),并把表头下方连续名单数据在内存中规范化为 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 会话中工作;每个员工平台账号必须配置唯一 ERP 账号,同一平台账号在 90 秒新鲜期内只允许一台云电脑保持执行 worker。心跳携带扩展对期望 ERP 账号的只读匹配结果;账号不匹配、心跳过期、第二台并发云电脑或任务归属不一致都会在领取和回执两端失败关闭。`chrome.storage.local` 只是临时缓存。
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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('备注'))
|
||||
];
|
||||
}
|
||||
|
||||
@@ -136,6 +136,102 @@ test('XLSX is deterministically normalized to the canonical 13-column TSV', asyn
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('an absent phone header produces blank phones without guessing unheaded values', async () => {
|
||||
const baseline = await normalizeSynthetic(await syntheticWorkbook());
|
||||
const expected = baseline.canonicalTsv.split('\n').map((line, index) => {
|
||||
if (index === 0) return line;
|
||||
const fields = line.split('\t');
|
||||
fields[11] = '';
|
||||
return fields.join('\t');
|
||||
}).join('\n');
|
||||
for (const removeColumn of [false, true]) {
|
||||
const result = await normalizeSynthetic(await syntheticWorkbook(workbook => {
|
||||
const sheet = workbook.getWorksheet('Sheet1')!;
|
||||
if (removeColumn) sheet.spliceColumns(13, 1);
|
||||
else sheet.getCell('M2').value = null; // The original number below remains unheaded.
|
||||
}));
|
||||
assert.equal(result.canonicalTsv, expected);
|
||||
assert.equal(result.rowCount, 2);
|
||||
const parsed = await parseProgramInput({
|
||||
rawText: `导入散拼子单名单\n预订客户:测试客户\n出发日期:2026-09-18\n名单内容:\n${result.canonicalTsv}`,
|
||||
receivedAt: new Date('2026-09-16T00:00:00Z')
|
||||
});
|
||||
assert.equal(parsed.status, 'agent_parse_passed');
|
||||
}
|
||||
});
|
||||
|
||||
test('phone-free rosters support reordered alias headers, merged remarks and XLS conversion', async () => {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('无电话模板');
|
||||
sheet.mergeCells('B1:K1');
|
||||
sheet.getCell('B1').value = '脱敏团队信息';
|
||||
sheet.addRow(['序号', '姓名', '英文姓名', '护照号码', '性别', '出生日期', '年龄', '出生地点', '签发地', '签发日期', '有效期至', '备注']);
|
||||
for (let sequence = 1; sequence <= 8; sequence++) {
|
||||
sheet.addRow([sequence, '测试游客', 'TEST', `P0000000${sequence}`, '女', '1990-01-02', 36, '测试地', '测试地', '2025-01-02', '2035-01-01', '普通备注']);
|
||||
}
|
||||
sheet.mergeCells('L3:L10');
|
||||
sheet.getCell('M10').value = 13800000000; // Extra unheaded content must not become a phone.
|
||||
const xlsx = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
const expected = await normalizeSynthetic(xlsx);
|
||||
assert.equal(expected.rowCount, 8);
|
||||
for (const row of expected.canonicalTsv.split('\n').slice(1)) {
|
||||
assert.equal(row.split('\t').length, 13);
|
||||
assert.deepEqual(row.split('\t').slice(11), ['', '普通备注']);
|
||||
}
|
||||
assert.doesNotMatch(expected.canonicalTsv, /13800000000/);
|
||||
const legacy = await normalizePassengerRosterWorkbook({
|
||||
content: Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]),
|
||||
fileName: 'synthetic.xls', contentType: 'application/vnd.ms-excel'
|
||||
}, { convertLegacyXls: async () => xlsx });
|
||||
assert.equal(legacy.canonicalTsv, expected.canonicalTsv);
|
||||
});
|
||||
|
||||
test('phone is the only optional semantic and duplicate phone headers stay blocked', async () => {
|
||||
for (const column of [1, 2, 3, 4, 6, 8, 9, 10, 11, 12, 14]) {
|
||||
const content = await syntheticWorkbook(workbook => {
|
||||
const sheet = workbook.getWorksheet('Sheet1')!;
|
||||
sheet.getCell('M2').value = null;
|
||||
sheet.getCell(2, column).value = null;
|
||||
});
|
||||
await expectRosterError(() => normalizeSynthetic(content), 'roster_workbook_header_not_found');
|
||||
}
|
||||
await expectRosterError(() => syntheticWorkbook(workbook => {
|
||||
workbook.getWorksheet('Sheet1')!.getCell('O2').value = '电话';
|
||||
}).then(normalizeSynthetic), 'roster_workbook_header_not_found');
|
||||
});
|
||||
|
||||
test('present phone columns retain formula, value, hidden-data and row checks', async () => {
|
||||
for (const [value, code] of [
|
||||
[{ formula: '1+1', result: 2 }, 'roster_workbook_formula_not_allowed'],
|
||||
[{ error: '#VALUE!' }, 'roster_workbook_invalid_cell'],
|
||||
[1.5, 'roster_workbook_invalid_identifier']
|
||||
] as const) {
|
||||
await expectRosterError(() => syntheticWorkbook(workbook => {
|
||||
workbook.getWorksheet('Sheet1')!.getCell('M3').value = value;
|
||||
}).then(normalizeSynthetic), code, { row: 3, column: 13 });
|
||||
}
|
||||
await expectRosterError(() => syntheticWorkbook(workbook => {
|
||||
workbook.getWorksheet('Sheet1')!.getColumn(13).hidden = true;
|
||||
}).then(normalizeSynthetic), 'roster_workbook_hidden_data');
|
||||
await expectRosterError(() => syntheticWorkbook(workbook => {
|
||||
workbook.getWorksheet('Sheet1')!.getCell('M5').value = '13800000000';
|
||||
}).then(normalizeSynthetic), 'roster_workbook_invalid_sequence', { row: 5, column: 1 });
|
||||
});
|
||||
|
||||
test('a marked leader still requires a phone when the source phone column is absent', async () => {
|
||||
const result = await normalizeSynthetic(await syntheticWorkbook(workbook => {
|
||||
const sheet = workbook.getWorksheet('Sheet1')!;
|
||||
sheet.getCell('M2').value = null;
|
||||
sheet.getCell('N3').value = '领队';
|
||||
}));
|
||||
const parsed = await parseProgramInput({
|
||||
rawText: `导入散拼子单名单\n预订客户:测试客户\n出发日期:2026-09-18\n名单内容:\n${result.canonicalTsv}`,
|
||||
receivedAt: new Date('2026-09-16T00:00:00Z')
|
||||
});
|
||||
assert.notEqual(parsed.status, 'agent_parse_passed');
|
||||
assert.match(JSON.stringify(parsed), /姓名和电话都必须填写/);
|
||||
});
|
||||
|
||||
test('a merged title error is skipped, while header and passenger errors stay blocked', async () => {
|
||||
const baseline = await normalizeSynthetic(await syntheticWorkbook());
|
||||
const titleError = await normalizeSynthetic(await syntheticWorkbook(workbook => {
|
||||
|
||||
Reference in New Issue
Block a user