- 全模块类型化:controller 的 req: any → AuthenticatedRequest/RequestUser, 聚合查询 getRawMany 泛型标注、导入行/响应体定义具体 interface、 catch (e: any) → unknown + 收窄、no-base-to-string 用 String() 显式转换 - 第三方无类型库边界(pdfkit/exceljs)文件级或单行 disable 并注明理由 - 顺带修复:get-business-context.tool 两个 require-await error、 bills.controller 参数顺序隐患、main.ts compression 调用 - 运行时逻辑零改动;测试 142 套件 / 1065 用例全部通过
943 lines
29 KiB
TypeScript
943 lines
29 KiB
TypeScript
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('显式多工作表阶段时完整分配所有工作表', 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,
|
||
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('请勿重复换宿');
|
||
});
|
||
|
||
it('预览学生阶段:updateExisting=false 时已匹配行改为跳过', async () => {
|
||
const existing = {
|
||
id: 88,
|
||
name: '张三',
|
||
studentNo: '2024001',
|
||
phone: '13800138000',
|
||
} as Student;
|
||
const run = {
|
||
id: 'run-1',
|
||
userId: 7,
|
||
source: 'ai',
|
||
fileName: 'students.xlsx',
|
||
sheetsJson: JSON.stringify([studentSheet()]),
|
||
settingsJson: JSON.stringify({ updateExisting: false }),
|
||
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: 0,
|
||
skip: 1,
|
||
error: 0,
|
||
});
|
||
expect(result.rows[0]).toMatchObject({ action: 'skip', status: 'valid' });
|
||
expect(result.rows[0].errors.join(';')).toContain('按策略跳过更新');
|
||
});
|
||
|
||
it('预览入住阶段:duplicatePolicy=skip 时文件内重复行跳过并保留提示', 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: 'ai',
|
||
fileName: 'checkins.xlsx',
|
||
sheetsJson: JSON.stringify([
|
||
{
|
||
name: '入住',
|
||
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
|
||
rows: [
|
||
['张三', '13800138000', 'A101', '2026-09-01'],
|
||
['张三', '13800138000', 'A101', '2026-09-02'],
|
||
],
|
||
},
|
||
]),
|
||
settingsJson: JSON.stringify({ duplicatePolicy: 'skip' }),
|
||
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: 2,
|
||
error: 0,
|
||
create: 1,
|
||
skip: 1,
|
||
});
|
||
expect(result.rows[1]).toMatchObject({ action: 'skip', status: 'valid' });
|
||
expect(result.rows[1].errors.join(';')).toContain('已按策略跳过');
|
||
});
|
||
|
||
it('预览入住阶段:skipUnmatched=true 时找不到宿舍的行跳过并保留提示', async () => {
|
||
const existing = {
|
||
id: 88,
|
||
name: '张三',
|
||
studentNo: '2024001',
|
||
phone: '13800138000',
|
||
} as Student;
|
||
const run = {
|
||
id: 'run-2',
|
||
userId: 7,
|
||
source: 'ai',
|
||
fileName: 'checkins.xlsx',
|
||
sheetsJson: JSON.stringify([
|
||
{
|
||
name: '入住',
|
||
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
|
||
rows: [['张三', '13800138000', 'A101', '2026-09-01']],
|
||
},
|
||
]),
|
||
settingsJson: JSON.stringify({ skipUnmatched: true }),
|
||
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: 1,
|
||
error: 0,
|
||
create: 0,
|
||
skip: 1,
|
||
});
|
||
expect(result.rows[0]).toMatchObject({ action: 'skip', status: 'valid' });
|
||
expect(result.rows[0].errors.join(';')).toContain('按策略跳过');
|
||
});
|
||
|
||
|
||
it('createRun 按 stages 的 headerRow 生成对应工作表视图并写入 sheetsJson', async () => {
|
||
const workbook = new ExcelJS.Workbook();
|
||
const worksheet = workbook.addWorksheet('名单');
|
||
worksheet.addRow(['标题行', null]);
|
||
worksheet.addRow(['姓名', '学号']);
|
||
worksheet.addRow(['', '']);
|
||
worksheet.addRow(['张三', '2024001']);
|
||
const buffer = (await workbook.xlsx.writeBuffer()) as Buffer;
|
||
const run = {
|
||
id: 'run-h',
|
||
userId: 7,
|
||
conversationId: null,
|
||
source: 'manual',
|
||
fileName: 'students.xlsx',
|
||
sheetsJson: '[]',
|
||
status: 'ready',
|
||
currentStepKey: null,
|
||
error: null,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
} as ImportRun;
|
||
const runsRepo = makeRunsRepo(run);
|
||
const stepsRepo = makeStepsRepo({} as ImportStep);
|
||
const service = new ImportsService(
|
||
runsRepo as never,
|
||
stepsRepo as never,
|
||
makeRowsRepo() as never,
|
||
{} as never,
|
||
);
|
||
|
||
await service.createRun(
|
||
principal,
|
||
'manual',
|
||
fileOf('students.xlsx', buffer),
|
||
null,
|
||
[{ stepKey: 'students', sheet: '名单', headerRow: 2 }],
|
||
);
|
||
|
||
const created = runsRepo.create.mock.calls[0][0] as { sheetsJson: string };
|
||
const sheets = JSON.parse(created.sheetsJson) as Array<{
|
||
headers: string[];
|
||
rows: unknown[][];
|
||
headerRow: number;
|
||
rowNumbers: number[];
|
||
}>;
|
||
expect(sheets).toHaveLength(1);
|
||
expect(sheets[0].headers).toEqual(['姓名', '学号']);
|
||
expect(sheets[0].rows).toEqual([['张三', '2024001']]);
|
||
expect(sheets[0].headerRow).toBe(2);
|
||
expect(sheets[0].rowNumbers).toEqual([4]);
|
||
});
|
||
|
||
it('createRun 拒绝映射到工作表表头之外的列名', async () => {
|
||
const buffer = await xlsxBuffer(studentSheet());
|
||
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('students.xlsx', buffer),
|
||
null,
|
||
[{ stepKey: 'students', sheet: '学生' }],
|
||
{ students: { name: '姓名', studentNo: '不存在的列' } },
|
||
),
|
||
).rejects.toThrow('不在工作表表头中');
|
||
});
|
||
});
|