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) {
|
||||
setError(resolveError instanceof Error ? resolveError.message : '导入向导生成失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -47,7 +47,10 @@ export interface ImportRunDetail {
|
||||
|
||||
export interface ImportStageRequest {
|
||||
stepKey: ImportStepKey;
|
||||
/** 兼容旧调用:单个工作表名。 */
|
||||
sheet?: string;
|
||||
/** 一个阶段可包含多张工作表;与 sheet 二选一(sheets 优先)。 */
|
||||
sheets?: string[];
|
||||
headerRow?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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[]>> = {};
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 归一化阶段的工作表清单: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('、')}`);
|
||||
|
||||
@@ -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 { 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) ?? []) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user