Files
gongxue-base/apps/server/src/imports/imports.preflight.spec.ts
wangziqi ae88372ef8 fix(imports): 修复 AI 导入向导多工作表与表头误判
- AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表
- ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复
- headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配
- suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿
- 预检与预览按工作表逐表解析列映射,兼容异构表头
- 修复预检卡生成向导成功后按钮未复位 loading 的问题
- 补充 mapping/预检/run/ai-chat 多工作表测试
2026-08-06 14:46:33 +08:00

233 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Organization } from '../entities/organization.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { buildPreflightReport } from './imports.preflight';
import type { ImportSheetData } from './imports.workbook';
function sheet(name: string, headers: string[], rows: unknown[][]): ImportSheetData {
return { name, headers, rows: rows as ImportSheetData['rows'] };
}
function dataSourceOf(options: {
students?: Student[];
rooms?: Room[];
organizations?: Organization[];
occupancies?: Occupancy[];
} = {}) {
return {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue(options.students ?? []) };
if (entity === Room) return { find: jest.fn().mockResolvedValue(options.rooms ?? []) };
if (entity === Organization) {
return { find: jest.fn().mockResolvedValue(options.organizations ?? []) };
}
if (entity === Occupancy) {
return { find: jest.fn().mockResolvedValue(options.occupancies ?? []) };
}
return { find: jest.fn().mockResolvedValue([]) };
}),
};
}
describe('buildPreflightReport', () => {
it('全新学生表判定为 ready给出分阶段统计与下一步建议', async () => {
const report = await buildPreflightReport(
dataSourceOf({ organizations: [{ id: 1, name: '主校区' }] }) as never,
[
sheet('学生', ['姓名', '学号', '手机号'], [
['张三', '2024001', '13800138000'],
['李四', '2024002', '13900139000'],
]),
],
);
expect(report.verdict).toBe('ready');
expect(report.questions).toEqual([]);
expect(report.stages).toHaveLength(1);
expect(report.stages[0]).toMatchObject({
stepKey: 'students',
total: 2,
create: 2,
update: 0,
error: 0,
skip: 0,
headers: ['姓名', '学号', '手机号'],
mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
});
expect(report.blocks).toEqual([]);
expect(report.nextSteps.some((step) => step.key === 'students-next')).toBe(true);
});
it('已匹配记录时判定为 needs_input 并提出更新策略问题', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const report = await buildPreflightReport(
dataSourceOf({ students: [existing] }) as never,
[sheet('学生', ['姓名', '学号'], [['张三', '2024001']])],
);
expect(report.verdict).toBe('needs_input');
expect(report.stages[0]).toMatchObject({ total: 1, create: 0, update: 1 });
expect(report.questions.some((question) => question.type === 'update')).toBe(true);
});
it('缺少必填列时判定为 blocked 并归因 missing_columns', async () => {
const report = await buildPreflightReport(
dataSourceOf() as never,
[sheet('宿舍', ['宿舍号', '楼栋'], [['A101', '1号楼']])],
);
expect(report.verdict).toBe('blocked');
expect(report.blocks).toContainEqual(
expect.objectContaining({ code: 'missing_columns', count: 1, stepKeys: ['rooms'] }),
);
expect(report.stages[0].missingRequired).toContain('容量');
expect(report.questions.some((question) => question.type === 'mapping')).toBe(true);
});
it('无法识别任何业务表时判定为 blocked', async () => {
const report = await buildPreflightReport(
dataSourceOf() as never,
[sheet('杂项', ['A', 'B'], [['x', 'y']])],
);
expect(report.verdict).toBe('blocked');
expect(report.blocks).toContainEqual(expect.objectContaining({ code: 'no_stages' }));
expect(report.stages).toEqual([]);
});
it('文件内重复入住归因 duplicate_in_file 并提出重复策略问题', async () => {
const student = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const room = { id: 5, roomNumber: 'A101' } as Room;
const report = await buildPreflightReport(
dataSourceOf({ students: [student], rooms: [room] }) as never,
[
sheet('入住', ['姓名', '学号', '房间号', '入住日期'], [
['张三', '2024001', 'A101', '2026-09-01'],
['张三', '2024001', 'A101', '2026-09-02'],
]),
],
);
expect(report.verdict).toBe('needs_input');
expect(report.stages[0]).toMatchObject({ stepKey: 'checkins', total: 2, create: 1, error: 1 });
expect(report.blocks).toContainEqual(
expect.objectContaining({ code: 'duplicate_in_file', count: 1, stepKeys: ['checkins'] }),
);
expect(report.questions.some((question) => question.type === 'duplicate')).toBe(true);
expect(report.errorSamples).toContainEqual(
expect.objectContaining({
code: 'duplicate_in_file',
stepKey: 'checkins',
sheet: '入住',
rowNumber: 3,
errors: expect.arrayContaining([expect.stringContaining('请勿重复导入')]),
}),
);
});
it('未知校区归因 unknown_organization 并提出校区归属问题', async () => {
const report = await buildPreflightReport(
dataSourceOf({ organizations: [{ id: 1, name: '主校区' }] }) as never,
[sheet('学生', ['姓名', '学号', '校区'], [['张三', '2024001', '东校区']])],
);
expect(report.verdict).toBe('needs_input');
expect(report.blocks).toContainEqual(
expect.objectContaining({ code: 'unknown_organization', count: 1 }),
);
const orgQuestion = report.questions.find((question) => question.type === 'organization');
expect(orgQuestion).toBeDefined();
expect(orgQuestion?.options?.map((option) => option.value)).toContain('主校区');
});
it('入住找不到学生/宿舍归因引用缺失并提出未匹配处理问题', async () => {
const student = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const report = await buildPreflightReport(
dataSourceOf({ students: [student] }) as never,
[
sheet('入住', ['姓名', '学号', '房间号', '入住日期'], [
['张三', '2024001', 'A101', '2026-09-01'],
]),
],
);
expect(report.verdict).toBe('needs_input');
expect(report.blocks).toContainEqual(
expect.objectContaining({ code: 'room_not_found', count: 1, stepKeys: ['checkins'] }),
);
expect(report.questions.some((question) => question.type === 'reference')).toBe(true);
});
it('格式错误归因 format_error', async () => {
const report = await buildPreflightReport(
dataSourceOf() as never,
[sheet('学生', ['姓名', '手机号'], [['张三', '123']])],
);
expect(report.blocks).toContainEqual(
expect.objectContaining({ code: 'format_error', count: 1, stepKeys: ['students'] }),
);
expect(report.verdict).toBe('blocked');
expect(report.errorSamples).toContainEqual(
expect.objectContaining({
code: 'format_error',
stepKey: 'students',
sheet: '学生',
rowNumber: 2,
}),
);
});
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(/^入住日期|日期$/),
});
});
});