fix(imports): 修复 AI 导入向导多工作表与表头误判
- AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表 - ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复 - headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配 - suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿 - 预检与预览按工作表逐表解析列映射,兼容异构表头 - 修复预检卡生成向导成功后按钮未复位 loading 的问题 - 补充 mapping/预检/run/ai-chat 多工作表测试
This commit is contained in:
@@ -146,6 +146,7 @@ export const ImportPreflightCard: React.FC<ImportPreflightCardProps> = ({
|
|||||||
});
|
});
|
||||||
} catch (resolveError) {
|
} catch (resolveError) {
|
||||||
setError(resolveError instanceof Error ? resolveError.message : '导入向导生成失败');
|
setError(resolveError instanceof Error ? resolveError.message : '导入向导生成失败');
|
||||||
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
|
|||||||
try {
|
try {
|
||||||
const detail = await createImportRun(file, {
|
const detail = await createImportRun(file, {
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
stages: [{ stepKey: activeStepKey, sheet: sheetSelection[activeStepKey]?.[0] }],
|
stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }],
|
||||||
mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} },
|
mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} },
|
||||||
});
|
});
|
||||||
await loadRun(detail.id);
|
await loadRun(detail.id);
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ export interface ImportRunDetail {
|
|||||||
|
|
||||||
export interface ImportStageRequest {
|
export interface ImportStageRequest {
|
||||||
stepKey: ImportStepKey;
|
stepKey: ImportStepKey;
|
||||||
|
/** 兼容旧调用:单个工作表名。 */
|
||||||
sheet?: string;
|
sheet?: string;
|
||||||
|
/** 一个阶段可包含多张工作表;与 sheet 二选一(sheets 优先)。 */
|
||||||
|
sheets?: string[];
|
||||||
headerRow?: number;
|
headerRow?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1884,7 +1884,7 @@ describe('AiChatService', () => {
|
|||||||
'ai',
|
'ai',
|
||||||
expect.objectContaining({ originalName: 'students.xlsx' }),
|
expect.objectContaining({ originalName: 'students.xlsx' }),
|
||||||
3,
|
3,
|
||||||
[{ stepKey: 'students', sheet: '学生', headerRow: 1 }],
|
[{ stepKey: 'students', sheets: ['学生'], headerRow: 1 }],
|
||||||
{ students: { name: '姓名', studentNo: '学号' } },
|
{ students: { name: '姓名', studentNo: '学号' } },
|
||||||
{ updateExisting: false },
|
{ updateExisting: false },
|
||||||
);
|
);
|
||||||
@@ -1906,6 +1906,101 @@ describe('AiChatService', () => {
|
|||||||
).toMatchObject({ resolved: true, runId: 'run-9' });
|
).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 () => {
|
it('resolveImportPreflight 已生成向导时幂等重放,不重复建任务', async () => {
|
||||||
const { service } = createService();
|
const { service } = createService();
|
||||||
const conversations = {
|
const conversations = {
|
||||||
|
|||||||
@@ -125,10 +125,10 @@ export async function resolveImportPreflight(
|
|||||||
}
|
}
|
||||||
const stages: ImportStageRequest[] = preflight.stages.map((stage) => ({
|
const stages: ImportStageRequest[] = preflight.stages.map((stage) => ({
|
||||||
stepKey: stage.stepKey,
|
stepKey: stage.stepKey,
|
||||||
sheet: stage.sheetNames[0],
|
sheets: stage.sheetNames,
|
||||||
headerRow,
|
headerRow,
|
||||||
}));
|
}));
|
||||||
if (stages.some((stage) => !stage.sheet || !String(stage.sheet).trim())) {
|
if (stages.some((stage) => !stage.sheets || stage.sheets.length === 0)) {
|
||||||
throw new BadRequestException('预检报告缺少工作表信息,请重新预检');
|
throw new BadRequestException('预检报告缺少工作表信息,请重新预检');
|
||||||
}
|
}
|
||||||
const allowedHeadersByStep: Partial<Record<ImportStepKey, string[]>> = {};
|
const allowedHeadersByStep: Partial<Record<ImportStepKey, string[]>> = {};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type PreflightReport,
|
type PreflightReport,
|
||||||
} from '../imports/imports.types';
|
} from '../imports/imports.types';
|
||||||
import { permittedStepKeys } from '../imports/imports.access';
|
import { permittedStepKeys } from '../imports/imports.access';
|
||||||
|
import { expandStageSheets } from '../imports/imports.mapping';
|
||||||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||||
import type { AgentToolContext } from './ai-chat.tools';
|
import type { AgentToolContext } from './ai-chat.tools';
|
||||||
import { AiMessage } from './entities';
|
import { AiMessage } from './entities';
|
||||||
@@ -173,8 +174,8 @@ export const executeStartImportWizard = makeImportToolExecutor(
|
|||||||
if (!IMPORT_STEP_KEYS.includes(stage.stepKey)) {
|
if (!IMPORT_STEP_KEYS.includes(stage.stepKey)) {
|
||||||
throw new Error(`stages 包含未知业务类型:${String(stage.stepKey)}`);
|
throw new Error(`stages 包含未知业务类型:${String(stage.stepKey)}`);
|
||||||
}
|
}
|
||||||
if (!stage.sheet || !String(stage.sheet).trim()) {
|
if (expandStageSheets(stage).length === 0) {
|
||||||
throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet,请指定 Excel 中对应的 sheet 名`);
|
throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet/sheets,请指定 Excel 中对应的表名`);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
stage.headerRow !== undefined &&
|
stage.headerRow !== undefined &&
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export function headerMatches(header: string, alias: string): boolean {
|
|||||||
const h = normalizeHeader(header);
|
const h = normalizeHeader(header);
|
||||||
const a = normalizeHeader(alias);
|
const a = normalizeHeader(alias);
|
||||||
if (!h || !a) return false;
|
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 {
|
export function cellValue(cell: ExcelJS.Cell | undefined): CellValue {
|
||||||
|
|||||||
72
apps/server/src/imports/imports.mapping.spec.ts
Normal file
72
apps/server/src/imports/imports.mapping.spec.ts
Normal 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,40 @@ export function suggestMapping(headers: string[], stepKey: ImportStepKey): Colum
|
|||||||
return mapping;
|
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 {
|
export function suggestStep(headers: string[]): ImportStageSuggestion | null {
|
||||||
let best: ImportStageSuggestion | null = null;
|
let best: ImportStageSuggestion | null = null;
|
||||||
for (const stepKey of IMPORT_STEP_ORDER) {
|
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(
|
const required = IMPORT_STEP_REQUIRED_FIELDS[stepKey].filter(
|
||||||
(field) => mapping[field],
|
(field) => mapping[field],
|
||||||
).length;
|
).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) {
|
if (!best || score > best.matchedFields) {
|
||||||
best = { stepKey, mapping, matchedFields: score };
|
best = { stepKey, mapping, matchedFields: score };
|
||||||
}
|
}
|
||||||
@@ -58,8 +106,8 @@ export function resolveAssignedSheets(
|
|||||||
available: string[],
|
available: string[],
|
||||||
): string[] {
|
): string[] {
|
||||||
const names = stages
|
const names = stages
|
||||||
.filter((stage) => stage.stepKey === stepKey && stage.sheet)
|
.filter((stage) => stage.stepKey === stepKey)
|
||||||
.map((stage) => stage.sheet as string);
|
.flatMap(expandStageSheets);
|
||||||
const missing = names.filter((name) => !available.includes(name));
|
const missing = names.filter((name) => !available.includes(name));
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new BadRequestException(`工作表不存在:${missing.join('、')}`);
|
throw new BadRequestException(`工作表不存在:${missing.join('、')}`);
|
||||||
|
|||||||
@@ -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(/^入住日期|日期$/),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { Organization } from '../entities/organization.entity';
|
import { Organization } from '../entities/organization.entity';
|
||||||
import { buildLookups } from './imports.lookups';
|
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 { validateRow, type ImportBatchState } from './imports.rows';
|
||||||
import {
|
import {
|
||||||
IMPORT_STEP_IDENTITY_FIELDS,
|
IMPORT_STEP_IDENTITY_FIELDS,
|
||||||
@@ -139,8 +139,14 @@ async function analyzeStage(
|
|||||||
stepKey: ImportStepKey,
|
stepKey: ImportStepKey,
|
||||||
sheets: ImportSheetData[],
|
sheets: ImportSheetData[],
|
||||||
): Promise<StageAnalysis> {
|
): 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 required = IMPORT_STEP_REQUIRED_FIELDS[stepKey];
|
||||||
const missingRequired = required
|
const missingRequired = required
|
||||||
.filter((field) => !mapping[field])
|
.filter((field) => !mapping[field])
|
||||||
@@ -162,11 +168,12 @@ async function analyzeStage(
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const sheet of sheets) {
|
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) {
|
for (let i = 0; i < sheet.rows.length; i += 1) {
|
||||||
const rawValues = sheet.rows[i];
|
const rawValues = sheet.rows[i];
|
||||||
const fields: Record<string, CellValue> = {};
|
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;
|
fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null;
|
||||||
}
|
}
|
||||||
const result = validateRow(stepKey, fields, lookups, batchState);
|
const result = validateRow(stepKey, fields, lookups, batchState);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
StepPreviewSummary,
|
StepPreviewSummary,
|
||||||
} from './imports.types';
|
} from './imports.types';
|
||||||
import { parseJson } from './imports.helpers';
|
import { parseJson } from './imports.helpers';
|
||||||
import { assertMapping, suggestMapping } from './imports.mapping';
|
import { assertMapping, resolveSheetMapping, suggestMapping } from './imports.mapping';
|
||||||
import { buildLookups } from './imports.lookups';
|
import { buildLookups } from './imports.lookups';
|
||||||
import { validateRow } from './imports.rows';
|
import { validateRow } from './imports.rows';
|
||||||
import type { ImportBatchState } from './imports.rows';
|
import type { ImportBatchState } from './imports.rows';
|
||||||
@@ -98,12 +98,13 @@ export class ImportPreviewService {
|
|||||||
for (const sheetName of usedSheets) {
|
for (const sheetName of usedSheets) {
|
||||||
const sheet = sheetsData.find((s) => s.name === sheetName);
|
const sheet = sheetsData.find((s) => s.name === sheetName);
|
||||||
if (!sheet) continue;
|
if (!sheet) continue;
|
||||||
|
const sheetMapping = resolveSheetMapping(mapping, sheet.headers, stepKey);
|
||||||
const lookups = await buildLookups(
|
const lookups = await buildLookups(
|
||||||
this.dataSource,
|
this.dataSource,
|
||||||
stepKey,
|
stepKey,
|
||||||
sheet.headers,
|
sheet.headers,
|
||||||
sheet.rows,
|
sheet.rows,
|
||||||
mapping,
|
sheetMapping,
|
||||||
);
|
);
|
||||||
for (let i = 0; i < sheet.rows.length; i += 1) {
|
for (let i = 0; i < sheet.rows.length; i += 1) {
|
||||||
const rawValues = sheet.rows[i];
|
const rawValues = sheet.rows[i];
|
||||||
@@ -112,7 +113,7 @@ export class ImportPreviewService {
|
|||||||
raw[header] = rawValues[index] ?? null;
|
raw[header] = rawValues[index] ?? null;
|
||||||
});
|
});
|
||||||
const fields: Record<string, CellValue> = {};
|
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;
|
fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null;
|
||||||
}
|
}
|
||||||
const result = validateRow(stepKey, fields, lookups, batchState);
|
const result = validateRow(stepKey, fields, lookups, batchState);
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ import type {
|
|||||||
import { parseJson } from './imports.helpers';
|
import { parseJson } from './imports.helpers';
|
||||||
import { parseSheets } from './imports.workbook';
|
import { parseSheets } from './imports.workbook';
|
||||||
import type { ImportSheetData } 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 { findOwnedRun } from './imports.access';
|
||||||
import type { ImportPrincipal } from './imports.access';
|
import type { ImportPrincipal } from './imports.access';
|
||||||
|
|
||||||
@@ -56,15 +62,13 @@ export class ImportRunService {
|
|||||||
}
|
}
|
||||||
const views = new Map<string, ImportSheetData>();
|
const views = new Map<string, ImportSheetData>();
|
||||||
if (stages && stages.length > 0) {
|
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) {
|
for (const stage of stages) {
|
||||||
const sheetName = stage.sheet?.trim();
|
for (const sheetName of expandStageSheets(stage)) {
|
||||||
if (!sheetName) continue;
|
const view = parsedByHeaderRow
|
||||||
const view = parsedByHeaderRow
|
.get(stage.headerRow ?? 1)
|
||||||
.get(stage.headerRow ?? 1)
|
?.find((sheet) => sheet.name === sheetName);
|
||||||
?.find((sheet) => sheet.name === sheetName);
|
if (view) views.set(sheetName, view);
|
||||||
if (view) views.set(sheetName, view);
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const sheet of parsedByHeaderRow.get(1) ?? []) {
|
for (const sheet of parsedByHeaderRow.get(1) ?? []) {
|
||||||
|
|||||||
@@ -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 () => {
|
it('预览学生阶段:已有学号判为更新,并保留目标记录 ID', async () => {
|
||||||
const existing = {
|
const existing = {
|
||||||
id: 88,
|
id: 88,
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ export type ColumnMapping = Record<string, string>;
|
|||||||
// aislop-ignore-next-line: duplicate-type-declaration -- 与前端 ImportWizard 的 API 契约保持一致
|
// aislop-ignore-next-line: duplicate-type-declaration -- 与前端 ImportWizard 的 API 契约保持一致
|
||||||
export interface ImportStageRequest {
|
export interface ImportStageRequest {
|
||||||
stepKey: ImportStepKey;
|
stepKey: ImportStepKey;
|
||||||
|
/** 兼容旧调用:单个工作表名。 */
|
||||||
sheet?: string;
|
sheet?: string;
|
||||||
|
/** 一个阶段可包含多张工作表;与 sheet 二选一(sheets 优先)。 */
|
||||||
|
sheets?: string[];
|
||||||
headerRow?: number;
|
headerRow?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user