feat: 新增通用导入中心

This commit is contained in:
2026-08-05 17:11:39 +08:00
parent e9c8a1085d
commit 0c94ca54df
20 changed files with 3355 additions and 0 deletions

View File

@@ -0,0 +1,606 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import * as ExcelJS from 'exceljs';
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 { ImportRun } from './entities/import-run.entity';
import { ImportStep } from './entities/import-step.entity';
import { ImportRow } from './entities/import-row.entity';
import { ImportsService } from './imports.service';
import type { ParsedImportFile } from './imports.types';
function makeRowsRepo() {
let nextId = 1;
return {
create: jest.fn((value: unknown) => value),
save: jest.fn(async (rows: unknown[]) => {
const list = Array.isArray(rows) ? rows : [rows];
for (const row of list) {
const record = row as { id?: number };
if (record.id === undefined) record.id = nextId++;
}
return list;
}),
delete: jest.fn().mockResolvedValue({ affected: 0 }),
find: jest.fn().mockResolvedValue([]),
};
}
function makeStepsRepo(step: ImportStep) {
return {
create: jest.fn((value: unknown) => value),
save: jest.fn(async (value: unknown) => value),
findOne: jest.fn().mockResolvedValue(step),
find: jest.fn().mockResolvedValue([]),
};
}
function makeRunsRepo(run: ImportRun) {
return {
create: jest.fn((value: unknown) => value),
save: jest.fn(async (value: unknown) => value),
findOne: jest.fn().mockResolvedValue(run),
};
}
function studentSheet() {
return {
name: '学生',
headers: ['姓名', '学号', '手机号'],
rows: [['张三', '2024001', '13800138000']],
};
}
async function xlsxBuffer(sheet: {
name: string;
headers: string[];
rows: unknown[][];
}): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet(sheet.name);
ws.addRow(sheet.headers);
for (const row of sheet.rows) ws.addRow(row);
return (await workbook.xlsx.writeBuffer()) as Buffer;
}
function fileOf(name: string, buffer: Buffer): ParsedImportFile {
return {
originalName: name,
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: buffer.length,
buffer,
};
}
const principal = {
id: 7,
permissions: ['student:import', 'room:create', 'occupancy:checkin'],
isSuperAdmin: false,
};
describe('ImportsService', () => {
it('拒绝 .xls 文件', async () => {
const service = new ImportsService(
makeRunsRepo({} as ImportRun) as never,
makeStepsRepo({} as ImportStep) as never,
makeRowsRepo() as never,
{} as never,
);
await expect(
service.createRun(principal, 'manual', fileOf('a.xls', Buffer.from('not excel'))),
).rejects.toThrow('暂不支持 .xls');
});
it('上传学生表时自动分配 students 阶段并返回运行详情', async () => {
const buffer = await xlsxBuffer(studentSheet());
const run = {
id: 'run-1',
userId: 7,
conversationId: null,
source: 'manual',
fileName: 'students.xlsx',
sheetsJson: JSON.stringify([studentSheet()]),
status: 'ready',
currentStepKey: 'students',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const steps = [
{
id: 1,
runId: 'run-1',
stepKey: 'students',
sheetsJson: '["学生"]',
mappingJson: '{"name":"姓名","studentNo":"学号","phone":"手机号"}',
status: 'pending',
},
{
id: 2,
runId: 'run-1',
stepKey: 'rooms',
sheetsJson: '[]',
mappingJson: null,
status: 'skipped',
},
{
id: 3,
runId: 'run-1',
stepKey: 'checkins',
sheetsJson: '[]',
mappingJson: null,
status: 'skipped',
},
{
id: 4,
runId: 'run-1',
stepKey: 'transfers',
sheetsJson: '[]',
mappingJson: null,
status: 'skipped',
},
] as unknown as ImportStep[];
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(steps),
};
const service = new ImportsService(
runsRepo as never,
stepsRepo as never,
makeRowsRepo() as never,
{} as never,
);
const detail = await service.createRun(principal, 'manual', fileOf('students.xlsx', buffer));
expect(detail.currentStepKey).toBe('students');
expect(detail.steps.find((step) => step.stepKey === 'students')?.sheets).toEqual(['学生']);
expect(detail.steps.find((step) => step.stepKey === 'students')?.mapping).toEqual({
name: '姓名',
studentNo: '学号',
phone: '手机号',
});
});
it('预览学生阶段:已有学号判为更新,并保留目标记录 ID', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const run = {
id: 'run-1',
userId: 7,
source: 'manual',
fileName: 'students.xlsx',
sheetsJson: JSON.stringify([studentSheet()]),
status: 'ready',
currentStepKey: 'students',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 1,
runId: 'run-1',
stepKey: 'students',
sheetsJson: '["学生"]',
mappingJson: null,
status: 'pending',
} as ImportStep;
const rowsRepo = makeRowsRepo();
const dataSource = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
const result = await service.previewStep(principal, 'run-1', 'students', {
sheets: ['学生'],
mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
});
expect(result.summary).toMatchObject({ total: 1, valid: 1, create: 0, update: 1 });
expect(result.rows[0].action).toBe('update');
expect(result.rows[0].status).toBe('valid');
expect(result.rows[0].id).toBeDefined();
});
it('预览入住阶段:宿舍不存在时按依赖错误提示', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const run = {
id: 'run-2',
userId: 7,
source: 'manual',
fileName: 'checkins.xlsx',
sheetsJson: JSON.stringify([
{
name: '入住',
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
rows: [['张三', '13800138000', 'A101', '2026-09-01']],
},
]),
status: 'ready',
currentStepKey: 'checkins',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 3,
runId: 'run-2',
stepKey: 'checkins',
sheetsJson: '["入住"]',
mappingJson: null,
status: 'pending',
} as ImportStep;
const rowsRepo = makeRowsRepo();
const dataSource = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
const result = await service.previewStep(principal, 'run-2', 'checkins', {
sheets: ['入住'],
mapping: {
name: '姓名',
phone: '手机号',
roomNumber: '宿舍号',
checkInDate: '入住日期',
},
});
expect(result.summary).toMatchObject({ total: 1, valid: 0, error: 1 });
expect(result.rows[0].status).toBe('error');
expect(result.rows[0].errors.join('')).toContain('未找到宿舍');
});
it('提交阶段需要对应权限;已完成的任务幂等返回回执', async () => {
const run = {
id: 'run-1',
userId: 7,
source: 'manual',
fileName: 'students.xlsx',
sheetsJson: '[]',
status: 'ready',
currentStepKey: 'transfers',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 4,
runId: 'run-1',
stepKey: 'transfers',
sheetsJson: '["换宿"]',
mappingJson: '{}',
status: 'ready',
} as ImportStep;
const rowsRepo = makeRowsRepo();
rowsRepo.find.mockResolvedValue([]);
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
{} as never,
);
await expect(
service.commitStep(
{ id: 7, permissions: ['student:import'], isSuperAdmin: false },
'run-1',
'transfers',
[],
),
).rejects.toBeInstanceOf(ForbiddenException);
const committedRun = { ...run, status: 'committed', currentStepKey: null } as ImportRun;
const committedStep = {
id: 1,
runId: 'run-1',
stepKey: 'students',
sheetsJson: '["学生"]',
mappingJson: '{}',
status: 'committed',
summaryJson: JSON.stringify({ total: 1, valid: 1, error: 0, create: 1, update: 0, skip: 0 }),
} as ImportStep;
const committedService = new ImportsService(
makeRunsRepo(committedRun) as never,
makeStepsRepo(committedStep) as never,
makeRowsRepo() as never,
{} as never,
);
const receipt = await committedService.commitStep(principal, 'run-1', 'students', []);
expect(receipt.status).toBe('already_committed');
expect(receipt.created).toBe(1);
});
it('提交阶段拒绝与预览分类矛盾的决策', async () => {
const run = {
id: 'run-1',
userId: 7,
source: 'manual',
fileName: 'students.xlsx',
sheetsJson: '[]',
status: 'ready',
currentStepKey: 'students',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 1,
runId: 'run-1',
stepKey: 'students',
sheetsJson: '["学生"]',
mappingJson: '{}',
status: 'ready',
} as ImportStep;
const row = {
id: 11,
runId: 'run-1',
stepId: 1,
sheetName: '学生',
rowNumber: 3,
rawJson: '{}',
normalizedJson: JSON.stringify({ name: '张三', studentNo: '2024001', phone: '13800138000' }),
matchKey: '2024001',
action: 'update',
status: 'valid',
errorsJson: null,
targetId: 88,
} as ImportRow;
const rowsRepo = makeRowsRepo();
rowsRepo.find.mockResolvedValue([row]);
const dataSource = {
transaction: jest.fn(),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
await expect(
service.commitStep(principal, 'run-1', 'students', [{ rowId: 11, action: 'create' }]),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.commitStep(principal, 'run-1', 'students', [{ rowId: 11, action: 'create' }]),
).rejects.toThrow('预览判定为「更新」');
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('预览学生阶段:学号未命中时回退到手机号匹配', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const run = {
id: 'run-1',
userId: 7,
source: 'manual',
fileName: 'students.xlsx',
sheetsJson: JSON.stringify([
{
name: '学生',
headers: ['姓名', '学号', '手机号'],
rows: [['张三', '2024999', '13800138000']],
},
]),
status: 'ready',
currentStepKey: 'students',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 1,
runId: 'run-1',
stepKey: 'students',
sheetsJson: '["学生"]',
mappingJson: null,
status: 'pending',
} as ImportStep;
const rowsRepo = makeRowsRepo();
const dataSource = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
const result = await service.previewStep(principal, 'run-1', 'students', {
sheets: ['学生'],
mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
});
expect(result.summary).toMatchObject({ total: 1, valid: 1, create: 0, update: 1 });
expect(result.rows[0].action).toBe('update');
expect(result.rows[0].status).toBe('valid');
expect(result.rows[0].id).toBeDefined();
});
it('预览入住阶段:同一文件内重复入住标记为错误', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const room = { id: 5, roomNumber: 'A101' } as Room;
const run = {
id: 'run-2',
userId: 7,
source: 'manual',
fileName: 'checkins.xlsx',
sheetsJson: JSON.stringify([
{
name: '入住',
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
rows: [
['张三', '13800138000', 'A101', '2026-09-01'],
['张三', '13800138000', 'A101', '2026-09-02'],
],
},
]),
status: 'ready',
currentStepKey: 'checkins',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 3,
runId: 'run-2',
stepKey: 'checkins',
sheetsJson: '["入住"]',
mappingJson: null,
status: 'pending',
} as ImportStep;
const rowsRepo = makeRowsRepo();
const dataSource = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
if (entity === Room) return { find: jest.fn().mockResolvedValue([room]) };
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
const result = await service.previewStep(principal, 'run-2', 'checkins', {
sheets: ['入住'],
mapping: {
name: '姓名',
phone: '手机号',
roomNumber: '宿舍号',
checkInDate: '入住日期',
},
});
expect(result.summary).toMatchObject({ total: 2, valid: 1, error: 1 });
expect(result.rows[0].status).toBe('valid');
expect(result.rows[1].status).toBe('error');
expect(result.rows[1].errors.join('')).toContain('请勿重复导入');
});
it('预览换宿阶段:同一文件内重复换宿标记为错误', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const oldRoom = { id: 5, roomNumber: 'A101' } as Room;
const newRoom = { id: 6, roomNumber: 'B202' } as Room;
const run = {
id: 'run-3',
userId: 7,
source: 'manual',
fileName: 'transfers.xlsx',
sheetsJson: JSON.stringify([
{
name: '换宿',
headers: ['姓名', '手机号', '原宿舍', '新宿舍', '换宿日期'],
rows: [
['张三', '13800138000', 'A101', 'B202', '2026-09-10'],
['张三', '13800138000', 'A101', 'B202', '2026-09-11'],
],
},
]),
status: 'ready',
currentStepKey: 'transfers',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 4,
runId: 'run-3',
stepKey: 'transfers',
sheetsJson: '["换宿"]',
mappingJson: null,
status: 'pending',
} as ImportStep;
const rowsRepo = makeRowsRepo();
const dataSource = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
if (entity === Room) return { find: jest.fn().mockResolvedValue([oldRoom, newRoom]) };
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Occupancy) {
return {
find: jest
.fn()
.mockResolvedValue([{ id: 77, studentId: 88, roomId: 5, status: 'active' }]),
};
}
return { find: jest.fn().mockResolvedValue([]) };
}),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
const result = await service.previewStep(principal, 'run-3', 'transfers', {
sheets: ['换宿'],
mapping: {
name: '姓名',
phone: '手机号',
oldRoom: '原宿舍',
newRoom: '新宿舍',
transferDate: '换宿日期',
},
});
expect(result.summary).toMatchObject({ total: 2, valid: 1, error: 1 });
expect(result.rows[0].status).toBe('valid');
expect(result.rows[1].status).toBe('error');
expect(result.rows[1].errors.join('')).toContain('请勿重复换宿');
});
});