fix(imports): 修复 AI 导入向导多工作表与表头误判

- AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表
- ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复
- headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配
- suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿
- 预检与预览按工作表逐表解析列映射,兼容异构表头
- 修复预检卡生成向导成功后按钮未复位 loading 的问题
- 补充 mapping/预检/run/ai-chat 多工作表测试
This commit is contained in:
2026-08-06 14:46:33 +08:00
parent 259271f56c
commit ae88372ef8
15 changed files with 355 additions and 27 deletions

View File

@@ -146,6 +146,7 @@ export const ImportPreflightCard: React.FC<ImportPreflightCardProps> = ({
});
} catch (resolveError) {
setError(resolveError instanceof Error ? resolveError.message : '导入向导生成失败');
} finally {
setSubmitting(false);
}
};

View File

@@ -259,7 +259,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
try {
const detail = await createImportRun(file, {
source: 'manual',
stages: [{ stepKey: activeStepKey, sheet: sheetSelection[activeStepKey]?.[0] }],
stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }],
mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} },
});
await loadRun(detail.id);

View File

@@ -47,7 +47,10 @@ export interface ImportRunDetail {
export interface ImportStageRequest {
stepKey: ImportStepKey;
/** 兼容旧调用:单个工作表名。 */
sheet?: string;
/** 一个阶段可包含多张工作表;与 sheet 二选一sheets 优先)。 */
sheets?: string[];
headerRow?: number;
}

View File

@@ -1884,7 +1884,7 @@ describe('AiChatService', () => {
'ai',
expect.objectContaining({ originalName: 'students.xlsx' }),
3,
[{ stepKey: 'students', sheet: '学生', headerRow: 1 }],
[{ stepKey: 'students', sheets: ['学生'], headerRow: 1 }],
{ students: { name: '姓名', studentNo: '学号' } },
{ updateExisting: false },
);
@@ -1906,6 +1906,101 @@ describe('AiChatService', () => {
).toMatchObject({ resolved: true, runId: 'run-9' });
});
it('resolveImportPreflight 多工作表阶段携带全部 sheetNames 生成导入任务', async () => {
const { service } = createService();
const conversations = {
findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }),
};
const message = {
id: 42,
conversationId: 3,
role: 'assistant',
metadata: {
a2uiImportPreflight: {
verdict: 'needs_input',
stages: [
{
stepKey: 'checkins',
label: '入住管理',
sheetNames: ['四人间女', '四人间男'],
headers: ['姓名', '学号', '手机号', '宿舍号', '入住日期'],
mapping: { name: '姓名', roomNumber: '宿舍号' },
missingRequired: [],
total: 2,
create: 2,
update: 0,
error: 0,
skip: 0,
},
],
blocks: [],
questions: [],
nextSteps: [],
errorSamples: [],
attachmentId: 9,
headerRow: 1,
permittedSteps: ['checkins'],
resolved: false,
runId: null,
},
},
};
const messages = {
findOne: jest.fn().mockResolvedValue(message),
save: jest.fn(async (value) => value),
exists: jest.fn().mockResolvedValue(false),
};
const attachmentService = {
requireReadyOwned: jest.fn().mockResolvedValue([
{
id: 9,
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
originalName: 'dorm.xlsx',
size: 10,
},
]),
readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
};
const importsService = {
createRun: jest.fn().mockResolvedValue({
id: 'run-9',
fileName: 'dorm.xlsx',
sheets: [],
steps: [
{
stepKey: 'checkins',
label: '入住管理',
sheets: ['四人间女', '四人间男'],
status: 'pending',
},
],
}),
};
(service as unknown as { conversations: unknown }).conversations = conversations;
(service as unknown as { messages: unknown }).messages = messages;
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
(service as unknown as { importsService: unknown }).importsService = importsService;
await service.resolveImportPreflight(
authenticatedUser,
42,
{ clientRequestId: 'multi-sheet', mapping: {}, settings: {} },
new AbortController().signal,
jest.fn(),
jest.fn(),
);
expect(importsService.createRun).toHaveBeenCalledWith(
{ id: 7, permissions: ['ai:chat:use'], isSuperAdmin: false },
'ai',
expect.objectContaining({ originalName: 'dorm.xlsx' }),
3,
[{ stepKey: 'checkins', sheets: ['四人间女', '四人间男'], headerRow: 1 }],
{},
{},
);
});
it('resolveImportPreflight 已生成向导时幂等重放,不重复建任务', async () => {
const { service } = createService();
const conversations = {

View File

@@ -125,10 +125,10 @@ export async function resolveImportPreflight(
}
const stages: ImportStageRequest[] = preflight.stages.map((stage) => ({
stepKey: stage.stepKey,
sheet: stage.sheetNames[0],
sheets: stage.sheetNames,
headerRow,
}));
if (stages.some((stage) => !stage.sheet || !String(stage.sheet).trim())) {
if (stages.some((stage) => !stage.sheets || stage.sheets.length === 0)) {
throw new BadRequestException('预检报告缺少工作表信息,请重新预检');
}
const allowedHeadersByStep: Partial<Record<ImportStepKey, string[]>> = {};

View File

@@ -5,6 +5,7 @@ import {
type PreflightReport,
} from '../imports/imports.types';
import { permittedStepKeys } from '../imports/imports.access';
import { expandStageSheets } from '../imports/imports.mapping';
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
import type { AgentToolContext } from './ai-chat.tools';
import { AiMessage } from './entities';
@@ -173,8 +174,8 @@ export const executeStartImportWizard = makeImportToolExecutor(
if (!IMPORT_STEP_KEYS.includes(stage.stepKey)) {
throw new Error(`stages 包含未知业务类型:${String(stage.stepKey)}`);
}
if (!stage.sheet || !String(stage.sheet).trim()) {
throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet请指定 Excel 中对应的 sheet `);
if (expandStageSheets(stage).length === 0) {
throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet/sheets,请指定 Excel 中对应的`);
}
if (
stage.headerRow !== undefined &&

View File

@@ -26,7 +26,9 @@ export function headerMatches(header: string, alias: string): boolean {
const h = normalizeHeader(header);
const a = normalizeHeader(alias);
if (!h || !a) return false;
return h === a || h.includes(a) || a.includes(h);
// 只允许“表头包含别名”的单向匹配,避免短表头(如“宿舍号”)
// 被长复合别名(如“原宿舍号/新宿舍号”)反向包含而误判。
return h === a || h.includes(a);
}
export function cellValue(cell: ExcelJS.Cell | undefined): CellValue {

View File

@@ -0,0 +1,72 @@
import { BadRequestException } from '@nestjs/common';
import {
expandStageSheets,
resolveAssignedSheets,
resolveSheetMapping,
suggestMapping,
} from './imports.mapping';
import type { ImportStageRequest } from './imports.types';
describe('expandStageSheets', () => {
it('sheets 优先并去重、去空', () => {
const stage: ImportStageRequest = {
stepKey: 'checkins',
sheet: '旧表',
sheets: ['四人间女', '四人间男', '', '四人间女'],
};
expect(expandStageSheets(stage)).toEqual(['四人间女', '四人间男']);
});
it('无 sheets 时回退 sheet', () => {
expect(expandStageSheets({ stepKey: 'students', sheet: '学生' })).toEqual(['学生']);
});
it('两者都缺时返回空数组', () => {
expect(expandStageSheets({ stepKey: 'students' })).toEqual([]);
});
});
describe('resolveAssignedSheets', () => {
const available = ['学生', '四人间女', '四人间男', '2号楼'];
it('按 sheets 数组收集一个阶段的多张工作表', () => {
const stages: ImportStageRequest[] = [
{ stepKey: 'checkins', sheets: ['四人间女', '四人间男'] },
{ stepKey: 'checkins', sheets: ['2号楼'] },
];
expect(resolveAssignedSheets(stages, 'checkins', available)).toEqual([
'四人间女',
'四人间男',
'2号楼',
]);
});
it('兼容旧 sheet 单表形态', () => {
expect(resolveAssignedSheets([{ stepKey: 'students', sheet: '学生' }], 'students', available)).toEqual([
'学生',
]);
});
it('引用不存在的工作表时报错', () => {
expect(() =>
resolveAssignedSheets([{ stepKey: 'checkins', sheets: ['不存在'] }], 'checkins', available),
).toThrow(BadRequestException);
});
});
describe('resolveSheetMapping', () => {
it('确认映射表头存在时优先使用,否则回退该表建议', () => {
const confirmed = { name: '姓名', roomNumber: '宿舍号' };
const mapping = resolveSheetMapping(confirmed, ['学生姓名', '房号', '入住日期'], 'checkins');
expect(mapping).toEqual({
name: '学生姓名',
roomNumber: '房号',
checkInDate: '入住日期',
});
});
it('确认映射为空时退化为该表的 suggestMapping', () => {
const mapping = resolveSheetMapping(null, ['姓名', '学号', '宿舍号', '入住日期'], 'checkins');
expect(mapping).toEqual(suggestMapping(['姓名', '学号', '宿舍号', '入住日期'], 'checkins'));
});
});

View File

@@ -24,6 +24,40 @@ export function suggestMapping(headers: string[], stepKey: ImportStepKey): Colum
return mapping;
}
/** 归一化阶段的工作表清单sheets 优先,否则回退 sheet 单表,都没有则返回空数组。 */
export function expandStageSheets(stage: ImportStageRequest): string[] {
const names = stage.sheets?.map((name) => name?.trim()).filter(Boolean) ?? [];
if (names.length > 0) return [...new Set(names)];
const single = stage.sheet?.trim();
return single ? [single] : [];
}
/**
* 按工作表解析列映射:字段优先使用确认映射(该表存在对应表头时),
* 否则回退该表自身的表头建议,避免异构表头导致整行取不到值。
*/
export function resolveSheetMapping(
confirmed: ColumnMapping | null | undefined,
headers: string[],
stepKey: ImportStepKey,
): ColumnMapping {
const suggested = suggestMapping(headers, stepKey);
const mapping: ColumnMapping = {};
const fieldNames = new Set([
...Object.keys(confirmed ?? {}),
...Object.keys(suggested),
]);
for (const field of fieldNames) {
const header = confirmed?.[field];
if (header && headers.includes(header)) {
mapping[field] = header;
} else if (suggested[field]) {
mapping[field] = suggested[field];
}
}
return mapping;
}
export function suggestStep(headers: string[]): ImportStageSuggestion | null {
let best: ImportStageSuggestion | null = null;
for (const stepKey of IMPORT_STEP_ORDER) {
@@ -35,7 +69,21 @@ export function suggestStep(headers: string[]): ImportStageSuggestion | null {
const required = IMPORT_STEP_REQUIRED_FIELDS[stepKey].filter(
(field) => mapping[field],
).length;
const score = Object.keys(mapping).length + identity * 3 + required * 2;
// 显式信号优先:入住表出现“入住日期/姓名”时应优先归入入住;
// 换宿表出现“原宿舍/新宿舍/换宿日期”时应优先归入换宿,
// 避免“日期/宿舍号”这类模糊别名把入住表误判成换宿表。
const hasAny = (aliases: string[]) =>
headers.some((header) => aliases.some((alias) => headerMatches(header, alias)));
const signal =
stepKey === 'checkins' &&
(hasAny(['入住日期', '入住时间']) ||
(hasAny(['宿舍号', '房间号', '房号']) && hasAny(['入住日期', '入住时间', '日期'])))
? 6
: stepKey === 'transfers' &&
hasAny(['原宿舍', '原房间', '原宿舍号', '新宿舍', '新房间', '新宿舍号', '换宿日期', '变更日期'])
? 6
: 0;
const score = Object.keys(mapping).length + identity * 3 + required * 2 + signal;
if (!best || score > best.matchedFields) {
best = { stepKey, mapping, matchedFields: score };
}
@@ -58,8 +106,8 @@ export function resolveAssignedSheets(
available: string[],
): string[] {
const names = stages
.filter((stage) => stage.stepKey === stepKey && stage.sheet)
.map((stage) => stage.sheet as string);
.filter((stage) => stage.stepKey === stepKey)
.flatMap(expandStageSheets);
const missing = names.filter((name) => !available.includes(name));
if (missing.length > 0) {
throw new BadRequestException(`工作表不存在:${missing.join('、')}`);

View File

@@ -193,4 +193,40 @@ describe('buildPreflightReport', () => {
}),
);
});
it('同一阶段多张工作表且表头不一致时按表解析列映射', async () => {
const students = [
{ id: 88, name: '张三', studentNo: '2024001', phone: '13800138000' } as Student,
{ id: 89, name: '李四', studentNo: '2024002', phone: '13900139000' } as Student,
];
const room = { id: 5, roomNumber: 'A101' } as Room;
const report = await buildPreflightReport(
dataSourceOf({ students, rooms: [room] }) as never,
[
sheet('四人间女', ['姓名', '学号', '宿舍号', '入住日期'], [
['张三', '2024001', 'A101', '2026-09-01'],
]),
sheet('四人间男', ['学生姓名', '学号', '房号', '日期'], [
['李四', '2024002', 'A101', '2026-09-02'],
]),
],
);
expect(report.verdict).toBe('ready');
const stage = report.stages.find((item) => item.stepKey === 'checkins');
expect(stage).toBeDefined();
expect(stage).toMatchObject({
sheetNames: ['四人间女', '四人间男'],
total: 2,
create: 2,
update: 0,
error: 0,
});
expect(stage?.mapping).toEqual({
name: expect.stringMatching(/^姓名|学生姓名$/),
studentNo: '学号',
roomNumber: expect.stringMatching(/^宿舍号|房号$/),
checkInDate: expect.stringMatching(/^入住日期|日期$/),
});
});
});

View File

@@ -1,7 +1,7 @@
import { DataSource } from 'typeorm';
import { Organization } from '../entities/organization.entity';
import { buildLookups } from './imports.lookups';
import { suggestMapping, suggestStep } from './imports.mapping';
import { resolveSheetMapping, suggestMapping, suggestStep } from './imports.mapping';
import { validateRow, type ImportBatchState } from './imports.rows';
import {
IMPORT_STEP_IDENTITY_FIELDS,
@@ -139,8 +139,14 @@ async function analyzeStage(
stepKey: ImportStepKey,
sheets: ImportSheetData[],
): Promise<StageAnalysis> {
const firstSheet = sheets[0];
const mapping: ColumnMapping = suggestMapping(firstSheet.headers, stepKey);
// 阶段级映射取各表建议的并集,供预检卡预填;实际按表解析在下方逐表进行。
const mapping: ColumnMapping = {};
for (const sheet of sheets) {
const suggested = suggestMapping(sheet.headers, stepKey);
for (const [field, header] of Object.entries(suggested)) {
if (!mapping[field]) mapping[field] = header;
}
}
const required = IMPORT_STEP_REQUIRED_FIELDS[stepKey];
const missingRequired = required
.filter((field) => !mapping[field])
@@ -162,11 +168,12 @@ async function analyzeStage(
};
for (const sheet of sheets) {
const lookups = await buildLookups(dataSource, stepKey, sheet.headers, sheet.rows, mapping);
const sheetMapping = resolveSheetMapping(mapping, sheet.headers, stepKey);
const lookups = await buildLookups(dataSource, stepKey, sheet.headers, sheet.rows, sheetMapping);
for (let i = 0; i < sheet.rows.length; i += 1) {
const rawValues = sheet.rows[i];
const fields: Record<string, CellValue> = {};
for (const [field, header] of Object.entries(mapping)) {
for (const [field, header] of Object.entries(sheetMapping)) {
fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null;
}
const result = validateRow(stepKey, fields, lookups, batchState);

View File

@@ -13,7 +13,7 @@ import type {
StepPreviewSummary,
} from './imports.types';
import { parseJson } from './imports.helpers';
import { assertMapping, suggestMapping } from './imports.mapping';
import { assertMapping, resolveSheetMapping, suggestMapping } from './imports.mapping';
import { buildLookups } from './imports.lookups';
import { validateRow } from './imports.rows';
import type { ImportBatchState } from './imports.rows';
@@ -98,12 +98,13 @@ export class ImportPreviewService {
for (const sheetName of usedSheets) {
const sheet = sheetsData.find((s) => s.name === sheetName);
if (!sheet) continue;
const sheetMapping = resolveSheetMapping(mapping, sheet.headers, stepKey);
const lookups = await buildLookups(
this.dataSource,
stepKey,
sheet.headers,
sheet.rows,
mapping,
sheetMapping,
);
for (let i = 0; i < sheet.rows.length; i += 1) {
const rawValues = sheet.rows[i];
@@ -112,7 +113,7 @@ export class ImportPreviewService {
raw[header] = rawValues[index] ?? null;
});
const fields: Record<string, CellValue> = {};
for (const [field, header] of Object.entries(mapping)) {
for (const [field, header] of Object.entries(sheetMapping)) {
fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null;
}
const result = validateRow(stepKey, fields, lookups, batchState);

View File

@@ -18,7 +18,13 @@ import type {
import { parseJson } from './imports.helpers';
import { parseSheets } from './imports.workbook';
import type { ImportSheetData } from './imports.workbook';
import { autoAssignedSheets, resolveAssignedSheets, suggestMapping, suggestStep } from './imports.mapping';
import {
autoAssignedSheets,
expandStageSheets,
resolveAssignedSheets,
suggestMapping,
suggestStep,
} from './imports.mapping';
import { findOwnedRun } from './imports.access';
import type { ImportPrincipal } from './imports.access';
@@ -56,15 +62,13 @@ export class ImportRunService {
}
const views = new Map<string, ImportSheetData>();
if (stages && stages.length > 0) {
// v1 limitation: a sheet referenced by multiple stages with different
// header rows keeps the view of the last stage that named it.
for (const stage of stages) {
const sheetName = stage.sheet?.trim();
if (!sheetName) continue;
const view = parsedByHeaderRow
.get(stage.headerRow ?? 1)
?.find((sheet) => sheet.name === sheetName);
if (view) views.set(sheetName, view);
for (const sheetName of expandStageSheets(stage)) {
const view = parsedByHeaderRow
.get(stage.headerRow ?? 1)
?.find((sheet) => sheet.name === sheetName);
if (view) views.set(sheetName, view);
}
}
} else {
for (const sheet of parsedByHeaderRow.get(1) ?? []) {

View File

@@ -166,6 +166,61 @@ describe('ImportsService', () => {
});
});
it('显式多工作表阶段时完整分配所有工作表', async () => {
const workbook = new ExcelJS.Workbook();
const girls = workbook.addWorksheet('四人间女');
girls.addRow(['姓名', '学号', '宿舍号', '入住日期']);
girls.addRow(['张三', '2024001', 'A101', '2026-09-01']);
const boys = workbook.addWorksheet('四人间男');
boys.addRow(['姓名', '学号', '宿舍号', '入住日期']);
boys.addRow(['李四', '2024002', 'A101', '2026-09-02']);
const buffer = (await workbook.xlsx.writeBuffer()) as Buffer;
const run = {
id: 'run-2',
userId: 7,
conversationId: null,
source: 'manual',
fileName: 'dorm.xlsx',
sheetsJson: '[]',
status: 'ready',
currentStepKey: 'checkins',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const runsRepo = makeRunsRepo(run);
const stepsRepo = {
create: jest.fn((value: unknown) => value),
save: jest.fn(async (value: unknown) => value),
findOne: jest.fn().mockResolvedValue(null),
find: jest.fn().mockResolvedValue([]),
};
const service = new ImportsService(
runsRepo as never,
stepsRepo as never,
makeRowsRepo() as never,
{} as never,
);
await service.createRun(
principal,
'manual',
fileOf('dorm.xlsx', buffer),
null,
[{ stepKey: 'checkins', sheets: ['四人间女', '四人间男'], headerRow: 1 }],
);
const savedRun = runsRepo.create.mock.calls[0][0] as { sheetsJson: string };
const savedSheets = JSON.parse(savedRun.sheetsJson) as Array<{ name: string }>;
expect(savedSheets.map((sheet) => sheet.name)).toEqual(['四人间女', '四人间男']);
const checkinStep = stepsRepo.create.mock.calls.find(
(call) => (call[0] as { stepKey: string }).stepKey === 'checkins',
)?.[0] as { sheetsJson: string };
expect(JSON.parse(checkinStep.sheetsJson)).toEqual(['四人间女', '四人间男']);
});
it('预览学生阶段:已有学号判为更新,并保留目标记录 ID', async () => {
const existing = {
id: 88,

View File

@@ -46,7 +46,10 @@ export type ColumnMapping = Record<string, string>;
// aislop-ignore-next-line: duplicate-type-declaration -- 与前端 ImportWizard 的 API 契约保持一致
export interface ImportStageRequest {
stepKey: ImportStepKey;
/** 兼容旧调用:单个工作表名。 */
sheet?: string;
/** 一个阶段可包含多张工作表;与 sheet 二选一sheets 优先)。 */
sheets?: string[];
headerRow?: number;
}