From ae88372ef8bda283756608a45399317d7086c1c8 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 14:46:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(imports):=20=E4=BF=AE=E5=A4=8D=20AI=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=90=91=E5=AF=BC=E5=A4=9A=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E8=A1=A8=E4=B8=8E=E8=A1=A8=E5=A4=B4=E8=AF=AF=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表 - ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复 - headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配 - suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿 - 预检与预览按工作表逐表解析列映射,兼容异构表头 - 修复预检卡生成向导成功后按钮未复位 loading 的问题 - 补充 mapping/预检/run/ai-chat 多工作表测试 --- .../components/AiChat/ImportPreflightCard.tsx | 1 + .../ImportWizard/ImportWizardModal.tsx | 2 +- .../src/components/ImportWizard/types.ts | 3 + .../src/ai-chat/ai-chat.service.spec.ts | 97 ++++++++++++++++++- .../server/src/ai-chat/ai-chat.submissions.ts | 4 +- .../ai-chat/ai-chat.tool-actions.import.ts | 5 +- apps/server/src/imports/imports.helpers.ts | 4 +- .../src/imports/imports.mapping.spec.ts | 72 ++++++++++++++ apps/server/src/imports/imports.mapping.ts | 54 ++++++++++- .../src/imports/imports.preflight.spec.ts | 36 +++++++ apps/server/src/imports/imports.preflight.ts | 17 +++- .../src/imports/imports.preview.service.ts | 7 +- .../server/src/imports/imports.run.service.ts | 22 +++-- .../src/imports/imports.service.spec.ts | 55 +++++++++++ apps/server/src/imports/imports.types.ts | 3 + 15 files changed, 355 insertions(+), 27 deletions(-) create mode 100644 apps/server/src/imports/imports.mapping.spec.ts diff --git a/apps/admin/src/components/AiChat/ImportPreflightCard.tsx b/apps/admin/src/components/AiChat/ImportPreflightCard.tsx index 6ef111a..42bfe23 100644 --- a/apps/admin/src/components/AiChat/ImportPreflightCard.tsx +++ b/apps/admin/src/components/AiChat/ImportPreflightCard.tsx @@ -146,6 +146,7 @@ export const ImportPreflightCard: React.FC = ({ }); } catch (resolveError) { setError(resolveError instanceof Error ? resolveError.message : '导入向导生成失败'); + } finally { setSubmitting(false); } }; diff --git a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx index d0717f0..223c8ac 100644 --- a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx +++ b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx @@ -259,7 +259,7 @@ export const ImportWizardModal: React.FC = ({ 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); diff --git a/apps/admin/src/components/ImportWizard/types.ts b/apps/admin/src/components/ImportWizard/types.ts index a2e2350..4d390d6 100644 --- a/apps/admin/src/components/ImportWizard/types.ts +++ b/apps/admin/src/components/ImportWizard/types.ts @@ -47,7 +47,10 @@ export interface ImportRunDetail { export interface ImportStageRequest { stepKey: ImportStepKey; + /** 兼容旧调用:单个工作表名。 */ sheet?: string; + /** 一个阶段可包含多张工作表;与 sheet 二选一(sheets 优先)。 */ + sheets?: string[]; headerRow?: number; } diff --git a/apps/server/src/ai-chat/ai-chat.service.spec.ts b/apps/server/src/ai-chat/ai-chat.service.spec.ts index b976e61..99c7449 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -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 = { diff --git a/apps/server/src/ai-chat/ai-chat.submissions.ts b/apps/server/src/ai-chat/ai-chat.submissions.ts index 30397e8..94dacc6 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.ts @@ -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> = {}; diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts index 4f27e84..f37665d 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts @@ -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 && diff --git a/apps/server/src/imports/imports.helpers.ts b/apps/server/src/imports/imports.helpers.ts index 0bec486..a4e9fe0 100644 --- a/apps/server/src/imports/imports.helpers.ts +++ b/apps/server/src/imports/imports.helpers.ts @@ -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 { diff --git a/apps/server/src/imports/imports.mapping.spec.ts b/apps/server/src/imports/imports.mapping.spec.ts new file mode 100644 index 0000000..dc73225 --- /dev/null +++ b/apps/server/src/imports/imports.mapping.spec.ts @@ -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')); + }); +}); diff --git a/apps/server/src/imports/imports.mapping.ts b/apps/server/src/imports/imports.mapping.ts index 4c3b9ef..d69a6fd 100644 --- a/apps/server/src/imports/imports.mapping.ts +++ b/apps/server/src/imports/imports.mapping.ts @@ -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('、')}`); diff --git a/apps/server/src/imports/imports.preflight.spec.ts b/apps/server/src/imports/imports.preflight.spec.ts index cd4e218..01f10fc 100644 --- a/apps/server/src/imports/imports.preflight.spec.ts +++ b/apps/server/src/imports/imports.preflight.spec.ts @@ -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(/^入住日期|日期$/), + }); + }); }); diff --git a/apps/server/src/imports/imports.preflight.ts b/apps/server/src/imports/imports.preflight.ts index 6cc586d..dc0c8ff 100644 --- a/apps/server/src/imports/imports.preflight.ts +++ b/apps/server/src/imports/imports.preflight.ts @@ -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 { - 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 = {}; - 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); diff --git a/apps/server/src/imports/imports.preview.service.ts b/apps/server/src/imports/imports.preview.service.ts index 76ad5fe..1b27894 100644 --- a/apps/server/src/imports/imports.preview.service.ts +++ b/apps/server/src/imports/imports.preview.service.ts @@ -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 = {}; - 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); diff --git a/apps/server/src/imports/imports.run.service.ts b/apps/server/src/imports/imports.run.service.ts index 2e0d8ca..d4d04d1 100644 --- a/apps/server/src/imports/imports.run.service.ts +++ b/apps/server/src/imports/imports.run.service.ts @@ -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(); 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) ?? []) { diff --git a/apps/server/src/imports/imports.service.spec.ts b/apps/server/src/imports/imports.service.spec.ts index 3e2ea9a..1551747 100644 --- a/apps/server/src/imports/imports.service.spec.ts +++ b/apps/server/src/imports/imports.service.spec.ts @@ -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, diff --git a/apps/server/src/imports/imports.types.ts b/apps/server/src/imports/imports.types.ts index 9182131..4b62040 100644 --- a/apps/server/src/imports/imports.types.ts +++ b/apps/server/src/imports/imports.types.ts @@ -46,7 +46,10 @@ export type ColumnMapping = Record; // aislop-ignore-next-line: duplicate-type-declaration -- 与前端 ImportWizard 的 API 契约保持一致 export interface ImportStageRequest { stepKey: ImportStepKey; + /** 兼容旧调用:单个工作表名。 */ sheet?: string; + /** 一个阶段可包含多张工作表;与 sheet 二选一(sheets 优先)。 */ + sheets?: string[]; headerRow?: number; }