forked from wangziqi/gongxue-base
- AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表 - ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复 - headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配 - suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿 - 预检与预览按工作表逐表解析列映射,兼容异构表头 - 修复预检卡生成向导成功后按钮未复位 loading 的问题 - 补充 mapping/预检/run/ai-chat 多工作表测试
233 lines
8.4 KiB
TypeScript
233 lines
8.4 KiB
TypeScript
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(/^入住日期|日期$/),
|
||
});
|
||
});
|
||
});
|