fix(correctness): 并发/事务/实体/时区/状态一致性修复
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - wallets 原子扣款防 double-spend;refund 条件更新幂等;findTransactions 分页 - financial/imports/occupancies/attendance 事务与 advisory lock;重复生成/提交幂等 - 矛盾校验器、日期区间、实体双映射/DECIMAL/nullable、时区统一(china-time) - rbac-seed 防重激活、exam 权限恢复、状态一致性、路由顺序、N+1/IN 分块等性能项 Reviewed-by: OCR (open-codereview.ai)
This commit is contained in:
@@ -9,12 +9,11 @@ export class AgentToolRegistry {
|
||||
|
||||
/** Register a tool (called once at module init). */
|
||||
register(tool: ToolDef): void {
|
||||
const idx = this.tools.findIndex((t) => t.name === tool.name);
|
||||
if (idx >= 0) {
|
||||
this.tools[idx] = tool;
|
||||
} else {
|
||||
this.tools.push(tool);
|
||||
if (this.tools.some((t) => t.name === tool.name)) {
|
||||
// 重复 key 会在运行时造成工具覆盖/歧义,注册阶段直接失败更明确。
|
||||
throw new Error(`Agent tool 名称重复: ${tool.name}`);
|
||||
}
|
||||
this.tools.push(tool);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,6 +55,44 @@ describe('A2uiSubmissionsService', () => {
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('并发重复提交遇到唯一约束冲突时返回既有记录而不是抛 500', async () => {
|
||||
const dupError = Object.assign(new Error('Duplicate entry'), {
|
||||
code: 'ER_DUP_ENTRY',
|
||||
errno: 1062,
|
||||
});
|
||||
const { service } = createService({
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(submission),
|
||||
save: jest.fn().mockRejectedValue(dupError),
|
||||
});
|
||||
const result = await service.recordSubmission({
|
||||
artifactId: submission.artifactId,
|
||||
clientRequestId: submission.clientRequestId,
|
||||
status: 'created',
|
||||
resultJson: '{"ok":false}',
|
||||
});
|
||||
expect(result.created).toBe(false);
|
||||
expect(result.submission).toEqual(submission);
|
||||
});
|
||||
|
||||
it('非唯一约束错误仍然原样抛给调用方', async () => {
|
||||
const dbError = new Error('db down');
|
||||
const { service } = createService({
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
save: jest.fn().mockRejectedValue(dbError),
|
||||
});
|
||||
await expect(
|
||||
service.recordSubmission({
|
||||
artifactId: submission.artifactId,
|
||||
clientRequestId: submission.clientRequestId,
|
||||
status: 'created',
|
||||
resultJson: submission.resultJson,
|
||||
}),
|
||||
).rejects.toBe(dbError);
|
||||
});
|
||||
|
||||
it('findSubmission 透传查询条件', async () => {
|
||||
const { service, repo } = createService({
|
||||
findOne: jest.fn().mockResolvedValue(submission),
|
||||
|
||||
@@ -10,6 +10,18 @@ export interface RecordSubmissionInput {
|
||||
resultJson?: string | null;
|
||||
}
|
||||
|
||||
/** 判断是否为唯一约束冲突(MySQL ER_DUP_ENTRY / PostgreSQL 23505) */
|
||||
const isDuplicateKeyError = (error: unknown): boolean => {
|
||||
const err = error as {
|
||||
code?: string;
|
||||
errno?: number;
|
||||
driverError?: { code?: string; errno?: number };
|
||||
};
|
||||
const code = err.driverError?.code ?? err.code;
|
||||
const errno = err.driverError?.errno ?? err.errno;
|
||||
return code === 'ER_DUP_ENTRY' || code === '23505' || errno === 1062;
|
||||
};
|
||||
|
||||
/**
|
||||
* A2UI 提交幂等服务。
|
||||
*/
|
||||
@@ -26,15 +38,25 @@ export class A2uiSubmissionsService {
|
||||
}> {
|
||||
const existing = await this.findSubmission(input.artifactId, input.clientRequestId);
|
||||
if (existing) return { created: false, submission: existing };
|
||||
const submission = await this.submissions.save(
|
||||
this.submissions.create({
|
||||
artifactId: input.artifactId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
status: input.status,
|
||||
resultJson: input.resultJson ?? null,
|
||||
}),
|
||||
);
|
||||
return { created: true, submission };
|
||||
try {
|
||||
const submission = await this.submissions.save(
|
||||
this.submissions.create({
|
||||
artifactId: input.artifactId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
status: input.status,
|
||||
resultJson: input.resultJson ?? null,
|
||||
}),
|
||||
);
|
||||
return { created: true, submission };
|
||||
} catch (error) {
|
||||
// 并发下两个请求同时通过 findSubmission 时,唯一索引 uk_ai_a2ui_submissions_artifact_client
|
||||
// 保证只有一个插入成功;另一个捕获唯一约束冲突后返回既有结果,而不是抛 500
|
||||
if (isDuplicateKeyError(error)) {
|
||||
const concurrent = await this.findSubmission(input.artifactId, input.clientRequestId);
|
||||
if (concurrent) return { created: false, submission: concurrent };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findSubmission(
|
||||
|
||||
@@ -165,7 +165,8 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
database: config.get<string>('DB_DATABASE', 'dorm_billing'),
|
||||
entities: allEntities,
|
||||
migrations: allMigrations,
|
||||
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
|
||||
// 生产安全:仅当显式 DB_SYNCHRONIZE=true 时才自动同步表结构
|
||||
synchronize: config.get('DB_SYNCHRONIZE') === 'true',
|
||||
charset: 'utf8mb4',
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { getWeekDayFromDateOnly } from '../common/china-time';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between } from 'typeorm';
|
||||
import { AttendanceRecord, ClassSchedule } from '../entities';
|
||||
@@ -31,8 +32,7 @@ export class AttendanceCalendarService {
|
||||
}
|
||||
|
||||
private getWeekDayForDate(date: string): number {
|
||||
const day = new Date(`${date}T00:00:00+08:00`).getUTCDay();
|
||||
return day === 0 ? 7 : day;
|
||||
return getWeekDayFromDateOnly(date);
|
||||
}
|
||||
|
||||
async getScheduleOptionsForAttendance(classId: number, date: string) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AttendanceGenerationService } from './attendance-generation.service';
|
||||
|
||||
describe('AttendanceGenerationService — 默认周范围', () => {
|
||||
@@ -62,4 +63,94 @@ describe('AttendanceGenerationService — 默认周范围', () => {
|
||||
dateTo: '2026-08-31',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid date formats before generating', async () => {
|
||||
const service = createService();
|
||||
await expect(
|
||||
service.generateAttendanceFromSchedules({
|
||||
classId: 1,
|
||||
dateFrom: '2026/08/01',
|
||||
dateTo: '2026-08-07',
|
||||
} as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects the 9999-12-31 boundary that would overflow date iteration', async () => {
|
||||
const service = createService();
|
||||
await expect(
|
||||
service.generateAttendanceFromSchedules({
|
||||
classId: 1,
|
||||
dateFrom: '2026-08-01',
|
||||
dateTo: '9999-12-31',
|
||||
} as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('default afternoon period is labeled 下午', async () => {
|
||||
const saved: Array<Record<string, unknown>> = [];
|
||||
const periodConfigRepo = {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
save: jest.fn(async (entities: Array<Record<string, unknown>>) => {
|
||||
saved.push(...entities);
|
||||
return entities;
|
||||
}),
|
||||
create: jest.fn((data: Record<string, unknown>) => ({ ...data })),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const service = new AttendanceGenerationService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
periodConfigRepo as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.getAttendancePeriodConfigs();
|
||||
|
||||
expect(saved.find((period) => period.periodKey === 'afternoon')).toEqual(
|
||||
expect.objectContaining({ label: '下午', startTime: '14:00', endTime: '17:00' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps clear + save of period configs in a single transaction', async () => {
|
||||
const manager = {
|
||||
clear: jest.fn().mockResolvedValue(undefined),
|
||||
create: jest.fn((_entity: unknown, value: unknown) => value),
|
||||
save: jest.fn(async (value: unknown) => value),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb(manager)),
|
||||
};
|
||||
const periodConfigRepo = {
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
clear: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const service = new AttendanceGenerationService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
periodConfigRepo as never,
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
await service.saveAttendancePeriodConfigs({
|
||||
periods: [
|
||||
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 1, enabled: true },
|
||||
{ periodKey: 'afternoon', label: '下午', startTime: '14:00', endTime: '17:00', sortOrder: 2, enabled: true },
|
||||
],
|
||||
} as never);
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(manager.clear).toHaveBeenCalledTimes(1);
|
||||
expect(manager.save).toHaveBeenCalledWith(expect.any(Array));
|
||||
// 两段写都在事务内完成,不再直接走 repo 的 clear/save
|
||||
expect(periodConfigRepo.clear).not.toHaveBeenCalled();
|
||||
expect(periodConfigRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,13 +13,14 @@ import {
|
||||
import { toMinutes, isClassStudentActiveOnDate } from './attendance-time';
|
||||
import dayjs from '../common/dayjs';
|
||||
import type { BatchCreateAttendanceDto, GenerateAttendanceFromSchedulesDto, GenerateFromSchedulesDto, SaveAttendancePeriodConfigsDto } from './dto/attendance.dto';
|
||||
import { addDaysToDateOnly, getWeekDayFromDateOnly } from '../common/china-time';
|
||||
|
||||
@Injectable()
|
||||
export class AttendanceGenerationService {
|
||||
private readonly defaultAttendancePeriods = [
|
||||
{ periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1 },
|
||||
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2 },
|
||||
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 },
|
||||
{ periodKey: 'afternoon', label: '下午', startTime: '14:00', endTime: '17:00', sortOrder: 3 },
|
||||
{ periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4 },
|
||||
] as const;
|
||||
|
||||
@@ -62,6 +63,13 @@ export class AttendanceGenerationService {
|
||||
if (dateFrom > dateTo) {
|
||||
throw new BadRequestException('dateFrom must not be later than dateTo');
|
||||
}
|
||||
// 迭代前校验日期格式与范围:非法格式直接拒绝;9999-12-31 继续 +1 天会溢出归一化
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateFrom) || !/^\d{4}-\d{2}-\d{2}$/.test(dateTo)) {
|
||||
throw new BadRequestException('无效日期');
|
||||
}
|
||||
if (dateFrom > '9999-12-30' || dateTo > '9999-12-30') {
|
||||
throw new BadRequestException('日期超出合理范围(最大支持 9999-12-30)');
|
||||
}
|
||||
|
||||
const cls = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!cls) {
|
||||
@@ -94,17 +102,19 @@ export class AttendanceGenerationService {
|
||||
existingRecords.map((r) => `${r.studentId}|${r.attendanceDate}|${r.session}`),
|
||||
);
|
||||
|
||||
// 考勤时段配置只在循环外取一次,避免每 (date, schedule) 都触发 count+find 查询(N+1)
|
||||
const attendancePeriodConfigs = await this.ensureAttendancePeriodConfigs();
|
||||
|
||||
const entities: AttendanceRecord[] = [];
|
||||
const end = new Date(dateTo);
|
||||
for (let d = new Date(dateFrom); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = dayjs(d).utcOffset(8).format('YYYY-MM-DD');
|
||||
const weekDay = d.getDay() === 0 ? 7 : d.getDay();
|
||||
let dateStr = dateFrom;
|
||||
while (dateStr <= dateTo) {
|
||||
const weekDay = getWeekDayFromDateOnly(dateStr);
|
||||
|
||||
for (const sched of schedules) {
|
||||
if (sched.weekDay !== weekDay) continue;
|
||||
if (dateStr < sched.startDate || dateStr > sched.endDate) continue;
|
||||
|
||||
const session = await this.mapScheduleTimeToSession(sched.startTime);
|
||||
const session = await this.mapScheduleTimeToSession(sched.startTime, attendancePeriodConfigs);
|
||||
const classStudentsForDate = classStudents.filter((cs) =>
|
||||
isClassStudentActiveOnDate(cs, dateStr),
|
||||
);
|
||||
@@ -124,6 +134,7 @@ export class AttendanceGenerationService {
|
||||
existingKeys.add(key);
|
||||
}
|
||||
}
|
||||
dateStr = addDaysToDateOnly(dateStr, 1);
|
||||
}
|
||||
|
||||
const saved = await this.attendanceRepo.save(entities);
|
||||
@@ -152,23 +163,6 @@ export class AttendanceGenerationService {
|
||||
});
|
||||
}
|
||||
|
||||
private toMinutes(time: string): number {
|
||||
const [hour, minute] = time.split(':').map(Number);
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
private getCourseClock(date: Date): { date: string; minutes: number } {
|
||||
const c = dayjs(date).utcOffset(8);
|
||||
return {
|
||||
date: c.format('YYYY-MM-DD'),
|
||||
minutes: c.hour() * 60 + c.minute(),
|
||||
};
|
||||
}
|
||||
|
||||
private shiftDate(date: string, days: number): string {
|
||||
return dayjs.utc(`${date}T00:00:00.000Z`).add(days, 'day').format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
private async ensureAttendancePeriodConfigs() {
|
||||
const count = await this.attendancePeriodConfigRepo.count();
|
||||
if (count === 0) {
|
||||
@@ -187,9 +181,8 @@ export class AttendanceGenerationService {
|
||||
}
|
||||
|
||||
async getRefreshableSchedules(date: string, classId?: number, session?: string, accessibleClassIds?: number[]) {
|
||||
const parsedDate = new Date(`${date}T00:00:00`);
|
||||
if (Number.isNaN(parsedDate.getTime())) throw new BadRequestException('无效日期');
|
||||
const weekDay = parsedDate.getDay() === 0 ? 7 : parsedDate.getDay();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new BadRequestException('无效日期');
|
||||
const weekDay = getWeekDayFromDateOnly(date);
|
||||
const qb = this.scheduleRepo
|
||||
.createQueryBuilder('schedule')
|
||||
.where('schedule.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL })
|
||||
@@ -209,9 +202,11 @@ export class AttendanceGenerationService {
|
||||
const schedules = await qb.orderBy('schedule.startTime', 'ASC').getMany();
|
||||
if (!session) return schedules;
|
||||
|
||||
// 配置只取一次并传给每次 mapScheduleTimeToSession,避免循环内重复 count+find 查询
|
||||
const attendancePeriodConfigs = await this.ensureAttendancePeriodConfigs();
|
||||
const matchedSchedules: ClassSchedule[] = [];
|
||||
for (const schedule of schedules) {
|
||||
if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) {
|
||||
if ((await this.mapScheduleTimeToSession(schedule.startTime, attendancePeriodConfigs)) === session) {
|
||||
matchedSchedules.push(schedule);
|
||||
}
|
||||
}
|
||||
@@ -251,30 +246,39 @@ export class AttendanceGenerationService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.attendancePeriodConfigRepo.clear();
|
||||
await this.attendancePeriodConfigRepo.save(
|
||||
normalized.map((period) => this.attendancePeriodConfigRepo.create(period)),
|
||||
);
|
||||
// clear + save 两段写放进同一事务:中途失败整体回滚,避免出现空配置表
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.clear(AttendancePeriodConfig);
|
||||
await manager.save(
|
||||
manager.create(
|
||||
AttendancePeriodConfig,
|
||||
normalized.map((period) => ({ ...period })),
|
||||
),
|
||||
);
|
||||
});
|
||||
return this.getAttendancePeriodConfigs();
|
||||
}
|
||||
|
||||
async resetAttendancePeriodConfigs() {
|
||||
await this.attendancePeriodConfigRepo.clear();
|
||||
return this.ensureAttendancePeriodConfigs();
|
||||
// clear + 写入默认配置放进同一事务:失败整体回滚,避免残留空配置表
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.clear(AttendancePeriodConfig);
|
||||
await manager.save(
|
||||
manager.create(
|
||||
AttendancePeriodConfig,
|
||||
this.defaultAttendancePeriods.map((period) => ({ ...period, enabled: true })),
|
||||
),
|
||||
);
|
||||
});
|
||||
return this.getAttendancePeriodConfigs();
|
||||
}
|
||||
|
||||
private mapLessonScheduleTimeToSession(startTime: string): string {
|
||||
const hour = parseInt(startTime.slice(0, 2), 10);
|
||||
if (hour < 8) return 'morning_reading';
|
||||
if (hour < 12) return 'morning';
|
||||
if (hour < 17) return 'afternoon';
|
||||
if (hour < 20) return 'evening_study';
|
||||
return 'night_check';
|
||||
}
|
||||
|
||||
private async mapScheduleTimeToSession(startTime: string): Promise<string> {
|
||||
private async mapScheduleTimeToSession(
|
||||
startTime: string,
|
||||
configs?: AttendancePeriodConfig[],
|
||||
): Promise<string> {
|
||||
const startMinutes = toMinutes(startTime);
|
||||
const periods = (await this.ensureAttendancePeriodConfigs()).filter((period) => period.enabled);
|
||||
const periods = (configs ?? (await this.ensureAttendancePeriodConfigs())).filter((period) => period.enabled);
|
||||
const matched = periods.find((period) => {
|
||||
const periodStart = toMinutes(period.startTime);
|
||||
const periodEnd = toMinutes(period.endTime);
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('AttendanceImportService', () => {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const studentRepo = { findOne: jest.fn() };
|
||||
const studentRepo = { findOne: jest.fn(), find: jest.fn() };
|
||||
const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() };
|
||||
const dingTalkService = {
|
||||
fetchAttendanceResults: jest.fn(),
|
||||
@@ -17,17 +17,31 @@ describe('AttendanceImportService', () => {
|
||||
const attendanceService = {
|
||||
autoMatchDingRecords: jest.fn(),
|
||||
};
|
||||
let runner: {
|
||||
connect: jest.Mock;
|
||||
query: jest.Mock;
|
||||
release: jest.Mock;
|
||||
};
|
||||
const dataSource = {
|
||||
createQueryRunner: jest.fn(() => runner),
|
||||
};
|
||||
|
||||
let service: AttendanceImportService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
runner = {
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
query: jest.fn().mockResolvedValue([{ acquired: 1 }]),
|
||||
release: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
service = new AttendanceImportService(
|
||||
dingRawRepo as never,
|
||||
studentRepo as never,
|
||||
studentDingMappingRepo as never,
|
||||
dingTalkService as unknown as DingTalkService,
|
||||
attendanceService as unknown as AttendanceService,
|
||||
dataSource as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -118,6 +132,55 @@ describe('AttendanceImportService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('writes checkInTime for OnDuty and checkOutTime for OffDuty only', async () => {
|
||||
const onDuty = await (service as any).mapToEntity({
|
||||
userId: 'ding-1',
|
||||
userName: '张三',
|
||||
workDate: '2026-07-01',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||
checkId: 'check-on',
|
||||
checkType: 'OnDuty',
|
||||
});
|
||||
const offDuty = await (service as any).mapToEntity({
|
||||
userId: 'ding-1',
|
||||
userName: '张三',
|
||||
workDate: '2026-07-01',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-01T18:00:00.000Z',
|
||||
checkId: 'check-off',
|
||||
checkType: 'OffDuty',
|
||||
});
|
||||
|
||||
expect(onDuty.checkInTime).toEqual(new Date('2026-07-01T08:00:00.000Z'));
|
||||
expect(onDuty.checkOutTime).toBeUndefined();
|
||||
expect(offDuty.checkOutTime).toEqual(new Date('2026-07-01T18:00:00.000Z'));
|
||||
expect(offDuty.checkInTime).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not write checkIn/checkOut times for a non-whitelisted checkType', async () => {
|
||||
const entity = await (service as any).mapToEntity({
|
||||
userId: 'ding-1',
|
||||
userName: '张三',
|
||||
workDate: '2026-07-01',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||
checkId: 'check-unknown',
|
||||
checkType: 'OnDuty/OffDuty',
|
||||
});
|
||||
|
||||
// 非法考勤类型不写任何时间,避免污染 checkOutTime/checkInTime;记录本身仍保留
|
||||
expect(entity.attendanceType).toBe('OnDuty/OffDuty');
|
||||
expect(entity.checkInTime).toBeUndefined();
|
||||
expect(entity.checkOutTime).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||
{
|
||||
@@ -133,8 +196,8 @@ describe('AttendanceImportService', () => {
|
||||
},
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
studentDingMappingRepo.findOne.mockResolvedValue({ studentId: 3 });
|
||||
studentRepo.findOne.mockResolvedValue({ id: 3, name: '张三' });
|
||||
studentDingMappingRepo.find.mockResolvedValue([{ dingUserId: 'ding-1', studentId: 3 }]);
|
||||
studentRepo.find.mockResolvedValue([{ id: 3, name: '张三' }]);
|
||||
dingRawRepo.save.mockImplementation(async (entities) => entities);
|
||||
|
||||
await service.importFromDingTalk({
|
||||
@@ -148,6 +211,11 @@ describe('AttendanceImportService', () => {
|
||||
[expect.objectContaining({ userName: '张三' })],
|
||||
{ chunk: 50 },
|
||||
);
|
||||
// 批量预取:只查一次映射 + 一次学生,不再逐条 findOne
|
||||
expect(studentDingMappingRepo.find).toHaveBeenCalledTimes(1);
|
||||
expect(studentRepo.find).toHaveBeenCalledTimes(1);
|
||||
expect(studentDingMappingRepo.findOne).not.toHaveBeenCalled();
|
||||
expect(studentRepo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -305,4 +373,193 @@ describe('AttendanceImportService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('returns success false when a batch save fails', async () => {
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||
{
|
||||
userId: 'ding-1',
|
||||
userName: '张三',
|
||||
workDate: '2026-07-01',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||
checkId: 'check-save-fail',
|
||||
checkType: 'OnDuty',
|
||||
},
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
dingRawRepo.save.mockRejectedValue(new Error('database down'));
|
||||
|
||||
const result = await service.importFromDingTalk({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: ['ding-1'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.imported).toBe(0);
|
||||
expect(result.errors.some((message) => message.includes('Batch save error'))).toBe(true);
|
||||
expect(service.running).toBe(false);
|
||||
});
|
||||
|
||||
it('cleans up isRunning and importingUserId even when runner.release fails', async () => {
|
||||
runner.release.mockRejectedValueOnce(new Error('release boom'));
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([]);
|
||||
|
||||
const result = await service.importFromDingTalk({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: ['ding-1'],
|
||||
userId: 7,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.running).toBe(false);
|
||||
expect((service as any).importingUserId).toBeUndefined();
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases the query runner when connect fails to avoid pool leaks', async () => {
|
||||
runner.connect.mockRejectedValueOnce(new Error('connect boom'));
|
||||
|
||||
await expect(
|
||||
service.importFromDingTalk({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: ['ding-1'],
|
||||
}),
|
||||
).rejects.toThrow('connect boom');
|
||||
|
||||
expect(runner.release).toHaveBeenCalledTimes(1);
|
||||
expect(runner.query).not.toHaveBeenCalled();
|
||||
expect(service.running).toBe(false);
|
||||
});
|
||||
|
||||
it('watchdog only warns and never releases the DB lock while the import is running', async () => {
|
||||
jest.useFakeTimers();
|
||||
const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined);
|
||||
try {
|
||||
let releaseImport: (() => void) | undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseImport = resolve;
|
||||
});
|
||||
dingTalkService.fetchAttendanceResults.mockImplementation(() => gate.then(() => []));
|
||||
|
||||
const importPromise = service.importFromDingTalk({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: ['ding-1'],
|
||||
});
|
||||
|
||||
await jest.advanceTimersByTimeAsync(30 * 60 * 1000);
|
||||
|
||||
// 看门狗只告警,不执行 RELEASE_LOCK(锁仍由导入结束时释放)
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('30 分钟'));
|
||||
expect(
|
||||
runner.query.mock.calls.some(([sql]) => String(sql).includes('RELEASE_LOCK')),
|
||||
).toBe(false);
|
||||
|
||||
releaseImport!();
|
||||
await importPromise;
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('fetches user×date batches concurrently with a bounded concurrency of 4', async () => {
|
||||
const userIds = Array.from({ length: 101 }, (_, index) => `user-${index + 1}`); // 3 user batches
|
||||
// 3 user batches × 2 date ranges = 6 requests
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
dingTalkService.fetchAttendanceResults.mockImplementation(async (params) => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
inFlight -= 1;
|
||||
return [{
|
||||
userId: params.userIds[0],
|
||||
userName: '',
|
||||
workDate: params.startDate,
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '',
|
||||
checkId: `${params.startDate}-${params.userIds[0]}`,
|
||||
checkType: 'OnDuty',
|
||||
}];
|
||||
});
|
||||
|
||||
const results = await (service as any).fetchAllPages({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-10',
|
||||
userIds,
|
||||
});
|
||||
|
||||
expect(dingTalkService.fetchAttendanceResults).toHaveBeenCalledTimes(6);
|
||||
expect(maxInFlight).toBeGreaterThan(1);
|
||||
expect(maxInFlight).toBeLessThanOrEqual(4);
|
||||
expect(results).toHaveLength(6);
|
||||
// 结果顺序仍为(日期范围 × 用户批次)的原始顺序
|
||||
expect(results[0].checkId).toBe('2026-07-01-user-1');
|
||||
expect(results[1].checkId).toBe('2026-07-01-user-51');
|
||||
expect(results[3].checkId).toBe('2026-07-08-user-1');
|
||||
expect(results[4].checkId).toBe('2026-07-08-user-51');
|
||||
expect(results[5].checkId).toBe('2026-07-08-user-101');
|
||||
});
|
||||
|
||||
it('chunks dingId dedup lookups into blocks of at most 1000 and merges results', async () => {
|
||||
const checkIds = Array.from({ length: 1001 }, (_, index) => `check-${index + 1}`);
|
||||
// 输入含重复 id:先去重再分块,返回 Map 仍按 dingId 去重
|
||||
const results = [...checkIds, 'check-1', 'check-500'].map((checkId) => ({ checkId }));
|
||||
const inValues = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) return value as string[];
|
||||
if (value && typeof value === 'object' && '_value' in value) {
|
||||
return (value as { _value: unknown })._value as string[];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
dingRawRepo.find.mockImplementation(async ({ where }: { where: { dingId: unknown } }) =>
|
||||
inValues(where.dingId).map((dingId) => ({ dingId })),
|
||||
);
|
||||
|
||||
const map = await (service as any).getExistingRecordsByDingId(results);
|
||||
|
||||
// 1001 个去重后的 id 被切成 1000 + 1 两块并发查询
|
||||
expect(dingRawRepo.find).toHaveBeenCalledTimes(2);
|
||||
const firstChunk = inValues(
|
||||
(dingRawRepo.find.mock.calls[0]?.[0] as { where: { dingId: unknown } }).where.dingId,
|
||||
);
|
||||
const secondChunk = inValues(
|
||||
(dingRawRepo.find.mock.calls[1]?.[0] as { where: { dingId: unknown } }).where.dingId,
|
||||
);
|
||||
expect(firstChunk).toHaveLength(1000);
|
||||
expect(secondChunk).toHaveLength(1);
|
||||
// 分块结果合并后仍能命中所有 id,且无重复
|
||||
expect(map.size).toBe(1001);
|
||||
expect(map.get('check-1')?.dingId).toBe('check-1');
|
||||
expect(map.get('check-1001')?.dingId).toBe('check-1001');
|
||||
});
|
||||
|
||||
it('keeps a single query at the 1000-id chunk boundary', async () => {
|
||||
const checkIds = Array.from({ length: 1000 }, (_, index) => `check-${index + 1}`);
|
||||
const inValues = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) return value as string[];
|
||||
if (value && typeof value === 'object' && '_value' in value) {
|
||||
return (value as { _value: unknown })._value as string[];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
dingRawRepo.find.mockImplementation(async ({ where }: { where: { dingId: unknown } }) =>
|
||||
inValues(where.dingId).map((dingId) => ({ dingId })),
|
||||
);
|
||||
|
||||
const map = await (service as any).getExistingRecordsByDingId(
|
||||
checkIds.map((checkId) => ({ checkId })),
|
||||
);
|
||||
|
||||
expect(dingRawRepo.find).toHaveBeenCalledTimes(1);
|
||||
expect(map.size).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import { DataSource, Repository, In } from 'typeorm';
|
||||
import { Subject, Observable } from 'rxjs';
|
||||
import dayjs from '../common/dayjs';
|
||||
import {
|
||||
@@ -12,6 +12,19 @@ import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingta
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto';
|
||||
|
||||
/** 考勤导入 DB 互斥锁名(GET_LOCK/RELEASE_LOCK 共用) */
|
||||
const ATTENDANCE_IMPORT_LOCK = 'gongxue:attendance-import';
|
||||
|
||||
/**
|
||||
* 钉钉考勤类型白名单(与现有 attendance_type 取值一致)。
|
||||
* 只有 OnDuty/OffDuty 能区分签到/签退,才允许写入 checkInTime/checkOutTime;
|
||||
* 其他类型(如 OnDuty/OffDuty、NotSigned 等)只保留原始值,不写打卡时间。
|
||||
*/
|
||||
const ATTENDANCE_CHECK_TYPE_WHITELIST = ['OnDuty', 'OffDuty'];
|
||||
|
||||
/** 单次 IN(dingIds) 查询最多携带的 id 数:超过该上限按块并发查询,避免无界 IN 列表。 */
|
||||
const MAX_DING_IDS_PER_QUERY = 1000;
|
||||
|
||||
/**
|
||||
* Service for importing DingTalk attendance data into the system.
|
||||
*
|
||||
@@ -26,7 +39,12 @@ import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dt
|
||||
export class AttendanceImportService {
|
||||
private readonly logger = new Logger(AttendanceImportService.name);
|
||||
|
||||
/** RxJS Subject emitting live progress during import */
|
||||
/**
|
||||
* RxJS Subject emitting live progress during import。
|
||||
* 注意:这是进程内存态——多实例部署时每个实例各持有一份 Subject,
|
||||
* SSE 只会收到触发导入的那个实例发出的事件(多实例下进度可能不准,如需准确
|
||||
* 应改用 Redis pub/sub 等共享通道,本轮不强改)。
|
||||
*/
|
||||
private progressSubject = new Subject<ImportProgressEvent>();
|
||||
private isRunning = false;
|
||||
/** ID of the user who triggered the current import (for SSE scoping) */
|
||||
@@ -40,6 +58,7 @@ export class AttendanceImportService {
|
||||
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
private readonly dingTalkService: DingTalkService,
|
||||
private readonly attendanceService: AttendanceService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -77,24 +96,81 @@ export class AttendanceImportService {
|
||||
throw new Error('An import is already in progress');
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
this.isRunning = true;
|
||||
this.importingUserId = params.userId;
|
||||
|
||||
// Safety timeout: auto-reset isRunning after 30 minutes in case of
|
||||
// an unhandled exception that bypasses the finally block (extremely rare).
|
||||
const SAFETY_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const safetyTimer = setTimeout(() => {
|
||||
if (this.isRunning) {
|
||||
this.logger.error('Import safety timeout triggered — force-resetting isRunning');
|
||||
this.isRunning = false;
|
||||
// DB 层互斥:用 MySQL advisory lock(GET_LOCK)保证同一时刻只有一个导入在跑,
|
||||
// 不再依赖内存计时器(计时器超时可能把仍在运行的导入误判为可再次启动)。
|
||||
// 锁绑定在同一个 query runner 连接上;进程异常退出时连接关闭会自动释放锁。
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
let lockAcquired = false;
|
||||
let watchdog: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
// connect 也放进 try/finally:连接失败时同样 release runner,避免连接池泄漏
|
||||
await runner.connect();
|
||||
const lockRows = (await runner.query(
|
||||
`SELECT GET_LOCK('${ATTENDANCE_IMPORT_LOCK}', 0) AS acquired`,
|
||||
)) as Array<{ acquired: number | string }> | undefined;
|
||||
lockAcquired =
|
||||
Array.isArray(lockRows) &&
|
||||
lockRows.length > 0 &&
|
||||
Number((lockRows[0] as { acquired?: unknown })?.acquired) === 1;
|
||||
if (!lockAcquired) {
|
||||
throw new Error('An import is already in progress');
|
||||
}
|
||||
}, SAFETY_TIMEOUT_MS);
|
||||
this.isRunning = true;
|
||||
this.importingUserId = params.userId;
|
||||
// 兜底看门狗:只告警、不释放锁。若导入仍在运行就主动 RELEASE_LOCK,
|
||||
// 会让另一个实例误以为锁空闲而并发导入,破坏多实例互斥。
|
||||
// 锁只由 finally 中的 RELEASE_LOCK 释放;进程异常退出时连接关闭,MySQL 会自动释放锁。
|
||||
watchdog = setTimeout(() => {
|
||||
this.logger.warn(
|
||||
'考勤导入超过 30 分钟仍未完成,请检查任务是否卡死(锁会在导入结束或进程退出时自动释放)',
|
||||
);
|
||||
}, 30 * 60 * 1000);
|
||||
return await this.runImport(params);
|
||||
} finally {
|
||||
if (watchdog) clearTimeout(watchdog);
|
||||
if (lockAcquired) {
|
||||
try {
|
||||
await runner.query(`SELECT RELEASE_LOCK('${ATTENDANCE_IMPORT_LOCK}')`);
|
||||
} catch (releaseError) {
|
||||
this.logger.warn(
|
||||
`Failed to release attendance import DB lock: ${String(releaseError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// release 失败只告警,不影响 isRunning/importingUserId 的清理
|
||||
try {
|
||||
await runner.release();
|
||||
} catch (releaseError) {
|
||||
this.logger.warn(
|
||||
`Failed to release attendance import query runner: ${String(releaseError)}`,
|
||||
);
|
||||
}
|
||||
// 只有成功抢到锁的调用才需要清理运行态;未抢到锁时这些字段从未被设置
|
||||
if (lockAcquired) {
|
||||
this.isRunning = false;
|
||||
this.importingUserId = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual import pipeline (fetch → parse → deduplicate → save → auto-match).
|
||||
* Runs while the caller holds the DB-level attendance-import lock.
|
||||
*/
|
||||
private async runImport(params: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
userIds?: string[];
|
||||
autoMatch?: boolean;
|
||||
userId?: number;
|
||||
}): Promise<ImportResult> {
|
||||
const startedAt = Date.now();
|
||||
const errors: string[] = [];
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let matched = 0;
|
||||
// 保存阶段失败的批次数:只要存在保存失败,整体结果即为失败
|
||||
let failedSaves = 0;
|
||||
|
||||
try {
|
||||
this.emit('fetching', 0, 0, 'Fetching attendance results from DingTalk...');
|
||||
@@ -117,10 +193,12 @@ export class AttendanceImportService {
|
||||
}
|
||||
|
||||
this.emit('saving', 0, newRecords.length, `Saving ${newRecords.length} records...`);
|
||||
// 批量预取 dingUserId → 学生姓名(映射+学生各查一次),避免逐条 mapToEntity 时 N+1
|
||||
const studentNameByDingUserId = await this.buildStudentNameByDingUserId(newRecords);
|
||||
const batchSize = 100;
|
||||
for (let i = 0; i < newRecords.length; i += batchSize) {
|
||||
const batch = newRecords.slice(i, i + batchSize);
|
||||
const entities = await Promise.all(batch.map((record) => this.mapToEntity(record)));
|
||||
const entities = batch.map((record) => this.mapToEntity(record, studentNameByDingUserId));
|
||||
try {
|
||||
await this.dingRawRepo.save(entities, { chunk: 50 });
|
||||
imported += entities.length;
|
||||
@@ -128,6 +206,7 @@ export class AttendanceImportService {
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`Batch save error at offset ${i}: ${msg}`);
|
||||
failedSaves += 1;
|
||||
this.logger.error(`Batch save error: ${msg}`);
|
||||
}
|
||||
}
|
||||
@@ -140,18 +219,14 @@ export class AttendanceImportService {
|
||||
|
||||
const duration = Date.now() - startedAt;
|
||||
this.emit('complete', imported, rawResults.length, `Import complete: ${imported} new, ${skipped} skipped, ${matched} matched (${duration}ms)`);
|
||||
this.logger.log(`DingTalk attendance import done: ${imported} imported, ${skipped} skipped, ${matched} matched`);
|
||||
return { success: true, imported, skipped, matched, errors, duration };
|
||||
this.logger.log(`DingTalk attendance import done: ${imported} imported, ${skipped} skipped, ${matched} matched, ${failedSaves} failed batches`);
|
||||
return { success: failedSaves === 0, imported, skipped, matched, errors, duration };
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(msg);
|
||||
this.emit('error', imported, 0, `Import failed: ${msg}`, msg);
|
||||
this.logger.error(`DingTalk attendance import failed: ${msg}`);
|
||||
return { success: false, imported, skipped, matched, errors, duration: Date.now() - startedAt };
|
||||
} finally {
|
||||
clearTimeout(safetyTimer);
|
||||
this.isRunning = false;
|
||||
this.importingUserId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,24 +252,34 @@ export class AttendanceImportService {
|
||||
const dateRanges = this.splitDateRanges(params.startDate, params.endDate, 7);
|
||||
const totalRequests = userBatches.length * dateRanges.length;
|
||||
let completedRequests = 0;
|
||||
let fetchedCount = 0;
|
||||
|
||||
for (const range of dateRanges) {
|
||||
for (const users of userBatches) {
|
||||
// 用户×日期批次相互独立,按并发上限 4 批量 Promise.all 拉取;
|
||||
// 结果仍按(日期范围 × 用户批次)的原始顺序收集,保持返回结构不变。
|
||||
const batches = dateRanges.flatMap((range) =>
|
||||
userBatches.map((users) => ({ range, users })),
|
||||
);
|
||||
const results = await this.mapWithConcurrency(
|
||||
batches,
|
||||
4,
|
||||
async ({ range, users }) => {
|
||||
const batch = await this.dingTalkService.fetchAttendanceResults({
|
||||
startDate: range.startDate,
|
||||
endDate: range.endDate,
|
||||
userIds: users,
|
||||
});
|
||||
allResults.push(...batch);
|
||||
completedRequests++;
|
||||
fetchedCount += batch.length;
|
||||
this.emit(
|
||||
'fetching',
|
||||
completedRequests,
|
||||
totalRequests,
|
||||
`已完成 ${completedRequests}/${totalRequests} 批,获取 ${allResults.length} 条记录`,
|
||||
`已完成 ${completedRequests}/${totalRequests} 批,获取 ${fetchedCount} 条记录`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return batch;
|
||||
},
|
||||
);
|
||||
for (const batch of results) allResults.push(...batch);
|
||||
|
||||
return allResults;
|
||||
}
|
||||
@@ -244,17 +329,28 @@ export class AttendanceImportService {
|
||||
|
||||
/**
|
||||
* Query which dingIds already exist to skip duplicates.
|
||||
*
|
||||
* dingIds 去重后按每块 ≤1000 个切成数组,用 Promise.all 并发查询各块再合并,
|
||||
* 避免无界 IN(dingIds) 触发 MySQL 参数上限/包大小错误;返回 Map 仍按 dingId 去重。
|
||||
*/
|
||||
private async getExistingRecordsByDingId(
|
||||
results: DingTalkAttendanceResult[],
|
||||
): Promise<Map<string, DingAttendanceRaw>> {
|
||||
const dingIds = results.map((r) => r.checkId).filter(Boolean);
|
||||
const dingIds = [...new Set(results.map((r) => r.checkId).filter(Boolean))];
|
||||
if (dingIds.length === 0) return new Map();
|
||||
|
||||
const existing = await this.dingRawRepo.find({
|
||||
where: { dingId: In(dingIds) },
|
||||
});
|
||||
return new Map(existing.map((entity) => [entity.dingId, entity]));
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < dingIds.length; i += MAX_DING_IDS_PER_QUERY) {
|
||||
chunks.push(dingIds.slice(i, i + MAX_DING_IDS_PER_QUERY));
|
||||
}
|
||||
const chunkResults = await Promise.all(
|
||||
chunks.map((chunk) =>
|
||||
this.dingRawRepo.find({
|
||||
where: { dingId: In(chunk) },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return new Map(chunkResults.flat().map((entity) => [entity.dingId, entity]));
|
||||
}
|
||||
|
||||
private async refreshDuplicatePunchMetadata(
|
||||
@@ -288,11 +384,15 @@ export class AttendanceImportService {
|
||||
|
||||
/**
|
||||
* Map a DingTalk API result to a DingAttendanceRaw entity.
|
||||
* 学生姓名来自调用前批量预取的 map,避免逐条触发两次查询(N+1)。
|
||||
*/
|
||||
private async mapToEntity(r: DingTalkAttendanceResult): Promise<DingAttendanceRaw> {
|
||||
private mapToEntity(
|
||||
r: DingTalkAttendanceResult,
|
||||
studentNameByDingUserId: Map<string, string>,
|
||||
): DingAttendanceRaw {
|
||||
const entity = new DingAttendanceRaw();
|
||||
entity.dingUserId = r.userId;
|
||||
entity.userName = r.userName || await this.resolveStudentName(r.userId);
|
||||
entity.userName = r.userName || studentNameByDingUserId.get(r.userId) || '';
|
||||
entity.attendanceDate = r.workDate;
|
||||
entity.dingId = r.checkId;
|
||||
entity.attendanceType = r.checkType || 'OnDuty';
|
||||
@@ -302,7 +402,9 @@ export class AttendanceImportService {
|
||||
entity.punchDeviceName = r.deviceName || null;
|
||||
entity.punchDeviceId = r.deviceId || null;
|
||||
|
||||
if (r.actualCheckTime) {
|
||||
// checkType 白名单校验:非法类型无法确定是签到还是签退,不写时间,
|
||||
// 避免污染 checkOutTime/checkInTime(记录本身仍保留并计入导入)。
|
||||
if (r.actualCheckTime && ATTENDANCE_CHECK_TYPE_WHITELIST.includes(r.checkType)) {
|
||||
const dt = new Date(r.actualCheckTime);
|
||||
if (!isNaN(dt.getTime())) {
|
||||
if (r.checkType === 'OnDuty') {
|
||||
@@ -318,13 +420,64 @@ export class AttendanceImportService {
|
||||
return entity;
|
||||
}
|
||||
|
||||
private async resolveStudentName(dingUserId: string): Promise<string> {
|
||||
const mapping = await this.studentDingMappingRepo.findOne({
|
||||
where: { dingUserId },
|
||||
/**
|
||||
* 批量预取 dingUserId → 学生姓名:先一次性查 StudentDingMapping,
|
||||
* 再一次性查 Student,最后组装成 Map。避免 mapToEntity 逐条两次查询(N+1)。
|
||||
*/
|
||||
private async buildStudentNameByDingUserId(
|
||||
results: DingTalkAttendanceResult[],
|
||||
): Promise<Map<string, string>> {
|
||||
const dingUserIds = [
|
||||
...new Set(
|
||||
results
|
||||
.filter((record) => !record.userName)
|
||||
.map((record) => record.userId)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
if (dingUserIds.length === 0) return new Map();
|
||||
|
||||
const mappings = await this.studentDingMappingRepo.find({
|
||||
where: { dingUserId: In(dingUserIds) },
|
||||
});
|
||||
if (!mapping) return '';
|
||||
const student = await this.studentRepo.findOne({ where: { id: mapping.studentId } });
|
||||
return student?.name || '';
|
||||
if (mappings.length === 0) return new Map();
|
||||
|
||||
const studentIds = [...new Set(mappings.map((mapping) => mapping.studentId))];
|
||||
const students = await this.studentRepo.find({
|
||||
where: { id: In(studentIds) },
|
||||
});
|
||||
const studentNameById = new Map(
|
||||
students.map((student) => [student.id, student.name]),
|
||||
);
|
||||
const nameByDingUserId = new Map<string, string>();
|
||||
for (const mapping of mappings) {
|
||||
const name = studentNameById.get(mapping.studentId);
|
||||
if (name) nameByDingUserId.set(mapping.dingUserId, name);
|
||||
}
|
||||
return nameByDingUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 以固定并发上限执行异步 mapper,返回结果保持输入顺序。
|
||||
*/
|
||||
private async mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
mapper: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.min(limit, items.length);
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await mapper(items[index]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
76
apps/server/src/attendance/attendance-report.service.spec.ts
Normal file
76
apps/server/src/attendance/attendance-report.service.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { AttendanceReportService } from './attendance-report.service';
|
||||
|
||||
describe('AttendanceReportService.getAlerts', () => {
|
||||
function createService(records: unknown[]) {
|
||||
const attendanceRepo = {
|
||||
createQueryBuilder: jest.fn(() => {
|
||||
const qb: any = {
|
||||
leftJoinAndSelect: jest.fn(() => qb),
|
||||
where: jest.fn(() => qb),
|
||||
andWhere: jest.fn(() => qb),
|
||||
orderBy: jest.fn(() => qb),
|
||||
addOrderBy: jest.fn(() => qb),
|
||||
getMany: jest.fn().mockResolvedValue(records),
|
||||
};
|
||||
return qb;
|
||||
}),
|
||||
};
|
||||
return new AttendanceReportService(attendanceRepo as never, {} as never);
|
||||
}
|
||||
|
||||
const absent = (studentId: number, date: string) => ({
|
||||
studentId,
|
||||
status: 'absent',
|
||||
attendanceDate: date,
|
||||
student: { name: `S${studentId}` },
|
||||
class: { name: '一班' },
|
||||
});
|
||||
|
||||
it('returns the most recent run when consecutive runs have equal length', async () => {
|
||||
const service = createService([
|
||||
absent(1, '2026-07-01'),
|
||||
absent(1, '2026-07-02'),
|
||||
absent(1, '2026-07-05'),
|
||||
absent(1, '2026-07-06'),
|
||||
]);
|
||||
|
||||
const alerts = await service.getAlerts(30, 2);
|
||||
|
||||
expect(alerts).toHaveLength(1);
|
||||
expect(alerts[0]).toMatchObject({
|
||||
studentId: 1,
|
||||
studentName: 'S1',
|
||||
className: '一班',
|
||||
type: '缺勤',
|
||||
count: 2,
|
||||
lastDate: '2026-07-06',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the latest date of the longest run as lastDate', async () => {
|
||||
const service = createService([
|
||||
absent(2, '2026-07-01'),
|
||||
absent(2, '2026-07-02'),
|
||||
absent(2, '2026-07-03'),
|
||||
absent(2, '2026-07-06'),
|
||||
absent(2, '2026-07-07'),
|
||||
]);
|
||||
|
||||
const alerts = await service.getAlerts(30, 2);
|
||||
|
||||
expect(alerts).toHaveLength(1);
|
||||
expect(alerts[0]).toMatchObject({ count: 3, lastDate: '2026-07-03' });
|
||||
});
|
||||
|
||||
it('does not merge non-consecutive absences into one run', async () => {
|
||||
const service = createService([
|
||||
absent(3, '2026-07-01'),
|
||||
absent(3, '2026-07-03'),
|
||||
absent(3, '2026-07-05'),
|
||||
]);
|
||||
|
||||
const alerts = await service.getAlerts(30, 3);
|
||||
|
||||
expect(alerts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -171,27 +171,72 @@ export class AttendanceReportService {
|
||||
lastDate: string;
|
||||
}> = [];
|
||||
|
||||
let current: (typeof alerts)[0] | null = null;
|
||||
// 按「学生 + 状态」聚合记录日期,再做真实的连续日判断:
|
||||
// 仅按状态/条数统计会把不连续的日子误算成连续缺勤/迟到。
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
className: string;
|
||||
status: string;
|
||||
dates: Set<string>;
|
||||
}
|
||||
>();
|
||||
for (const r of records) {
|
||||
const name = r.student?.name || '';
|
||||
const className = r.class?.name || '';
|
||||
const status = r.status === 'absent' ? '缺勤' : '迟到';
|
||||
if (current && current.studentId === r.studentId && current.type === status) {
|
||||
current.count++;
|
||||
if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate;
|
||||
} else {
|
||||
if (current && current.count >= threshold) alerts.push({ ...current });
|
||||
current = {
|
||||
const key = `${r.studentId}|${r.status}`;
|
||||
let group = grouped.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
studentId: r.studentId,
|
||||
studentName: name,
|
||||
className,
|
||||
type: status,
|
||||
count: 1,
|
||||
lastDate: r.attendanceDate,
|
||||
studentName: r.student?.name || '',
|
||||
className: r.class?.name || '',
|
||||
status: r.status,
|
||||
dates: new Set(),
|
||||
};
|
||||
grouped.set(key, group);
|
||||
}
|
||||
group.dates.add(r.attendanceDate);
|
||||
}
|
||||
|
||||
for (const group of grouped.values()) {
|
||||
// 按日期倒序聚合:长度相同的连续段取「最近一段」(先遇到者胜出),
|
||||
// lastDate 指向该段内最新日期,保持重构前的原语义。
|
||||
const sortedDates = [...group.dates].sort((a, b) => (a < b ? 1 : -1));
|
||||
let bestRun = 0;
|
||||
let bestRunLastDate = '';
|
||||
let run = 1;
|
||||
let runLastDate = sortedDates[0] || '';
|
||||
for (let i = 1; i <= sortedDates.length; i++) {
|
||||
const date = sortedDates[i];
|
||||
if (
|
||||
i < sortedDates.length &&
|
||||
dayjs.utc(sortedDates[i - 1]).diff(dayjs.utc(date), 'day') === 1
|
||||
) {
|
||||
// 倒序连续:runLastDate 保持该段内最早遇到(即最新)的日期
|
||||
run++;
|
||||
} else {
|
||||
if (run > bestRun) {
|
||||
bestRun = run;
|
||||
bestRunLastDate = runLastDate;
|
||||
}
|
||||
if (i < sortedDates.length) {
|
||||
run = 1;
|
||||
runLastDate = date;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestRun >= threshold) {
|
||||
alerts.push({
|
||||
studentId: group.studentId,
|
||||
studentName: group.studentName,
|
||||
className: group.className,
|
||||
type: group.status === 'absent' ? '缺勤' : '迟到',
|
||||
count: bestRun,
|
||||
lastDate: bestRunLastDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (current && current.count >= threshold) alerts.push(current);
|
||||
return alerts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@ describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => {
|
||||
...periodConfigRepoOverrides,
|
||||
};
|
||||
|
||||
// saveAttendancePeriodConfigs/resetAttendancePeriodConfigs 现在在事务内写库:
|
||||
// 事务 manager 的 clear/create/save 委托给同一个 periodConfigRepo mock,
|
||||
// 保证原有断言(如 savedPeriods 捕获)仍然成立。
|
||||
const transactionManager = {
|
||||
clear: jest.fn().mockImplementation(() => periodConfigRepo.clear()),
|
||||
create: jest.fn().mockImplementation((_entity: unknown, data: unknown) =>
|
||||
Array.isArray(data) ? data : periodConfigRepo.create(data),
|
||||
),
|
||||
save: jest.fn().mockImplementation((entities: unknown) => periodConfigRepo.save(entities)),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (cb: (manager: unknown) => Promise<unknown>) =>
|
||||
cb(transactionManager),
|
||||
),
|
||||
};
|
||||
|
||||
return new AttendanceService(
|
||||
{} as never, // attendanceRepo
|
||||
{} as never, // dingRawRepo
|
||||
@@ -38,7 +54,7 @@ describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => {
|
||||
{} as never, // attendanceSessionRepo
|
||||
{} as never, // attendanceDeviceRepo
|
||||
periodConfigRepo as never,
|
||||
{} as never, // dataSource
|
||||
dataSource as never,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
441
apps/server/src/bills/bills-generation.service.spec.ts
Normal file
441
apps/server/src/bills/bills-generation.service.spec.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { BillsGenerationService } from './bills-generation.service';
|
||||
import type { GenerateBillsDto } from './dto/bill.dto';
|
||||
import { Bill, RoomExpense, PersonalExpense, Occupancy } from '../entities';
|
||||
|
||||
function mockQueryBuilder<T>(results: T[]) {
|
||||
return {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue(results),
|
||||
};
|
||||
}
|
||||
|
||||
function createService(existingPeriodBills: Array<Partial<Bill>> = []) {
|
||||
const lockQb = {
|
||||
setLock: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue(existingPeriodBills),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
const manager = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(lockQb),
|
||||
create: jest.fn((_entity: unknown, value: unknown) => value),
|
||||
save: jest.fn(async (value: Record<string, unknown>) => ({ id: 1, ...value })),
|
||||
// advisory lock:默认首请求可拿到锁,RELEASE_LOCK 直接成功
|
||||
query: jest.fn(async (sql: string) =>
|
||||
sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }],
|
||||
),
|
||||
};
|
||||
const dataSource = { transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb(manager)) };
|
||||
const roomExpRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<RoomExpense>([])),
|
||||
};
|
||||
const occRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<Occupancy>([])),
|
||||
};
|
||||
const personalExpRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<PersonalExpense>([])),
|
||||
};
|
||||
const service = new BillsGenerationService(
|
||||
roomExpRepo as never,
|
||||
personalExpRepo as never,
|
||||
occRepo as never,
|
||||
dataSource as never,
|
||||
{ debitBill: jest.fn(async (_manager: unknown, bill: Bill) => bill) } as never,
|
||||
);
|
||||
return { service, dataSource, manager, lockQb, roomExpRepo, occRepo, personalExpRepo };
|
||||
}
|
||||
|
||||
const PERIOD = { periodStart: '2026-06-01', periodEnd: '2026-06-30' } as GenerateBillsDto;
|
||||
|
||||
describe('BillsGenerationService — 周期内重复生成并发防重', () => {
|
||||
it('rejects duplicate generation inside the transaction with a locked re-check', async () => {
|
||||
const { service, dataSource, manager, lockQb } = createService([
|
||||
{ id: 1, periodStart: '2026-06-01', periodEnd: '2026-06-30' },
|
||||
]);
|
||||
|
||||
await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('账单已生成');
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(lockQb.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||
expect(lockQb.where).toHaveBeenCalledWith('b.periodStart = :periodStart', {
|
||||
periodStart: '2026-06-01',
|
||||
});
|
||||
expect(lockQb.andWhere).toHaveBeenCalledWith('b.periodEnd = :periodEnd', {
|
||||
periodEnd: '2026-06-30',
|
||||
});
|
||||
expect(lockQb.getMany).toHaveBeenCalledTimes(1);
|
||||
expect(manager.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still performs the locked re-check and generates bills when the period is free', async () => {
|
||||
const { service, dataSource, manager, lockQb, roomExpRepo, occRepo } = createService();
|
||||
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<RoomExpense>([
|
||||
{
|
||||
id: 1,
|
||||
roomId: 1,
|
||||
expenseType: 'water',
|
||||
amount: 100,
|
||||
periodStart: '2026-06-01',
|
||||
periodEnd: '2026-06-30',
|
||||
} as RoomExpense,
|
||||
]),
|
||||
);
|
||||
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<Occupancy>([
|
||||
{
|
||||
id: 1,
|
||||
roomId: 1,
|
||||
studentId: 10,
|
||||
stayType: 'short',
|
||||
billingStartDate: '2026-06-01',
|
||||
billingEndDate: '2026-06-30',
|
||||
} as Occupancy,
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.generateBillsOnce(PERIOD);
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(lockQb.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||
expect(lockQb.getMany).toHaveBeenCalledTimes(1);
|
||||
expect(manager.save).toHaveBeenCalled();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
message: '成功生成 1 条账单',
|
||||
count: 1,
|
||||
periodStart: '2026-06-01',
|
||||
periodEnd: '2026-06-30',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads occupancies for all rooms in one batched query (no per-room N+1)', async () => {
|
||||
const { service, manager, occRepo, roomExpRepo } = createService();
|
||||
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<RoomExpense>([
|
||||
{
|
||||
id: 1,
|
||||
roomId: 1,
|
||||
expenseType: 'water',
|
||||
amount: 100,
|
||||
periodStart: '2026-06-01',
|
||||
periodEnd: '2026-06-30',
|
||||
} as RoomExpense,
|
||||
{
|
||||
id: 2,
|
||||
roomId: 2,
|
||||
expenseType: 'water',
|
||||
amount: 200,
|
||||
periodStart: '2026-06-01',
|
||||
periodEnd: '2026-06-30',
|
||||
} as RoomExpense,
|
||||
]),
|
||||
);
|
||||
const batchQb = mockQueryBuilder<Occupancy>([
|
||||
{
|
||||
id: 1,
|
||||
roomId: 1,
|
||||
studentId: 10,
|
||||
stayType: 'short',
|
||||
billingStartDate: '2026-06-01',
|
||||
billingEndDate: '2026-06-30',
|
||||
} as Occupancy,
|
||||
{
|
||||
id: 2,
|
||||
roomId: 2,
|
||||
studentId: 11,
|
||||
stayType: 'short',
|
||||
billingStartDate: '2026-06-01',
|
||||
billingEndDate: '2026-06-30',
|
||||
} as Occupancy,
|
||||
]);
|
||||
// 第 1 次:roomIds 聚合查询(长租 active 入住);第 2 次:全房间批量入住查询
|
||||
(occRepo.createQueryBuilder as jest.Mock)
|
||||
.mockReturnValueOnce(mockQueryBuilder<Occupancy>([]))
|
||||
.mockReturnValueOnce(batchQb);
|
||||
|
||||
const result = await service.generateBillsOnce(PERIOD);
|
||||
|
||||
// 入住查询只发两次:roomIds 聚合 1 次 + roomIds 批量查询 1 次(不再按房间循环 N+1)
|
||||
expect(occRepo.createQueryBuilder).toHaveBeenCalledTimes(2);
|
||||
expect(batchQb.where).toHaveBeenCalledWith('o.roomId IN (:...roomIds)', {
|
||||
roomIds: [1, 2],
|
||||
});
|
||||
expect(batchQb.andWhere).toHaveBeenCalledWith('o.billingStartDate <= :periodEnd', {
|
||||
periodEnd: '2026-06-30',
|
||||
});
|
||||
expect(batchQb.andWhere).toHaveBeenCalledWith(
|
||||
'(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)',
|
||||
{ periodStart: '2026-06-01' },
|
||||
);
|
||||
// 内存分组后仍按原逻辑为每个房间的学生生成账单
|
||||
expect(manager.save).toHaveBeenCalled();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
message: '成功生成 2 条账单',
|
||||
count: 2,
|
||||
periodStart: '2026-06-01',
|
||||
periodEnd: '2026-06-30',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('only one of two concurrent requests succeeds; the second sees committed bills and rejects', async () => {
|
||||
const committed: Array<Partial<Bill>> = [];
|
||||
const lockQb = {
|
||||
setLock: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockImplementation(async () => committed),
|
||||
};
|
||||
const manager = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(lockQb),
|
||||
create: jest.fn((_entity: unknown, value: unknown) => value),
|
||||
save: jest.fn(async (value: Record<string, unknown>) => {
|
||||
if ('studentId' in value && 'periodStart' in value) {
|
||||
const saved = { id: committed.length + 1, ...value };
|
||||
committed.push(saved);
|
||||
return saved;
|
||||
}
|
||||
return { id: 1, ...value };
|
||||
}),
|
||||
query: jest.fn(async (sql: string) =>
|
||||
sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }],
|
||||
),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb(manager)),
|
||||
};
|
||||
const roomExpRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(
|
||||
mockQueryBuilder<RoomExpense>([
|
||||
{
|
||||
id: 1,
|
||||
roomId: 1,
|
||||
expenseType: 'water',
|
||||
amount: 100,
|
||||
periodStart: '2026-06-01',
|
||||
periodEnd: '2026-06-30',
|
||||
} as RoomExpense,
|
||||
]),
|
||||
),
|
||||
};
|
||||
const occRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(
|
||||
mockQueryBuilder<Occupancy>([
|
||||
{
|
||||
id: 1,
|
||||
roomId: 1,
|
||||
studentId: 10,
|
||||
stayType: 'short',
|
||||
billingStartDate: '2026-06-01',
|
||||
billingEndDate: '2026-06-30',
|
||||
} as Occupancy,
|
||||
]),
|
||||
),
|
||||
};
|
||||
const personalExpRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<PersonalExpense>([])),
|
||||
};
|
||||
const service = new BillsGenerationService(
|
||||
roomExpRepo as never,
|
||||
personalExpRepo as never,
|
||||
occRepo as never,
|
||||
dataSource as never,
|
||||
{ debitBill: jest.fn(async (_manager: unknown, bill: Bill) => bill) } as never,
|
||||
);
|
||||
|
||||
const first = await service.generateBillsOnce(PERIOD);
|
||||
expect(first.count).toBe(1);
|
||||
expect(committed).toHaveLength(1);
|
||||
|
||||
// 第二个并发请求在锁释放后能看到第一个请求已提交的账单,直接抛「账单已生成」
|
||||
await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('账单已生成');
|
||||
expect(committed).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws ConflictException when the advisory lock cannot be acquired in time', async () => {
|
||||
const { service, manager } = createService();
|
||||
(manager.query as jest.Mock).mockResolvedValue([{ acquired: 0 }]);
|
||||
|
||||
await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('正在生成');
|
||||
expect(manager.query).toHaveBeenCalledWith(
|
||||
"SELECT GET_LOCK('gongxue:bills-gen:2026-06-01-2026-06-30', 5) AS acquired",
|
||||
);
|
||||
// 未拿到锁:不进入重复检查与写入
|
||||
expect(manager.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only one of two truly concurrent first-time requests succeeds (advisory lock)', async () => {
|
||||
const committed: Array<Partial<Bill>> = [];
|
||||
let lockHeld = false;
|
||||
let markSecondArrived!: () => void;
|
||||
const secondArrived = new Promise<void>((resolve) => {
|
||||
markSecondArrived = resolve;
|
||||
});
|
||||
let releaseFirstSave!: () => void;
|
||||
|
||||
const lockQb = {
|
||||
setLock: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockImplementation(async () => committed),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
const manager = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(lockQb),
|
||||
create: jest.fn((_entity: unknown, value: unknown) => value),
|
||||
save: jest.fn(async (value: Record<string, unknown>) => {
|
||||
if ('studentId' in value && 'periodStart' in value) {
|
||||
// 第一个请求已持有锁并开始写账单,此时放第二个并发请求进来抢同一把锁
|
||||
markSecondArrived();
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirstSave = resolve;
|
||||
});
|
||||
const saved = { id: committed.length + 1, ...value };
|
||||
committed.push(saved);
|
||||
return saved;
|
||||
}
|
||||
return { id: 1, ...value };
|
||||
}),
|
||||
query: jest.fn(async (sql: string) => {
|
||||
if (sql.includes('GET_LOCK')) {
|
||||
if (lockHeld) return [{ acquired: 0 }];
|
||||
lockHeld = true;
|
||||
return [{ acquired: 1 }];
|
||||
}
|
||||
if (sql.includes('RELEASE_LOCK')) {
|
||||
lockHeld = false;
|
||||
return [{ released: 1 }];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb(manager)),
|
||||
};
|
||||
const roomExpRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(
|
||||
mockQueryBuilder<RoomExpense>([
|
||||
{
|
||||
id: 1,
|
||||
roomId: 1,
|
||||
expenseType: 'water',
|
||||
amount: 100,
|
||||
periodStart: '2026-06-01',
|
||||
periodEnd: '2026-06-30',
|
||||
} as RoomExpense,
|
||||
]),
|
||||
),
|
||||
};
|
||||
const occRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(
|
||||
mockQueryBuilder<Occupancy>([
|
||||
{
|
||||
id: 1,
|
||||
roomId: 1,
|
||||
studentId: 10,
|
||||
stayType: 'short',
|
||||
billingStartDate: '2026-06-01',
|
||||
billingEndDate: '2026-06-30',
|
||||
} as Occupancy,
|
||||
]),
|
||||
),
|
||||
};
|
||||
const personalExpRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<PersonalExpense>([])),
|
||||
};
|
||||
const service = new BillsGenerationService(
|
||||
roomExpRepo as never,
|
||||
personalExpRepo as never,
|
||||
occRepo as never,
|
||||
dataSource as never,
|
||||
{ debitBill: jest.fn(async (_manager: unknown, bill: Bill) => bill) } as never,
|
||||
);
|
||||
|
||||
const firstPromise = service.generateBillsOnce(PERIOD);
|
||||
await secondArrived;
|
||||
|
||||
// 第二个并发请求在第一个请求持有锁期间尝试获取同一把锁 → 超时/被占用 → ConflictException
|
||||
await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('正在生成');
|
||||
expect(committed).toHaveLength(0);
|
||||
|
||||
releaseFirstSave();
|
||||
const first = await firstPromise;
|
||||
expect(first.count).toBe(1);
|
||||
expect(committed).toHaveLength(1);
|
||||
expect(lockHeld).toBe(false);
|
||||
});
|
||||
|
||||
it('saves all BillItems of a bill in one batched save', async () => {
|
||||
const { service, manager, personalExpRepo } = createService();
|
||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<PersonalExpense>([
|
||||
{
|
||||
id: 9,
|
||||
studentId: 10,
|
||||
roomId: 1,
|
||||
expenseType: 'meal',
|
||||
description: '三餐',
|
||||
amount: 50,
|
||||
expenseDate: '2026-06-05',
|
||||
status: 'active',
|
||||
} as PersonalExpense,
|
||||
]),
|
||||
);
|
||||
|
||||
await service.generateBillsOnce(PERIOD);
|
||||
|
||||
const arraySaves = (manager.save as jest.Mock).mock.calls.filter(
|
||||
([value]) => Array.isArray(value),
|
||||
);
|
||||
expect(arraySaves).toHaveLength(1);
|
||||
expect(arraySaves[0][0]).toHaveLength(1);
|
||||
expect(arraySaves[0][0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
billId: 1,
|
||||
personalExpenseId: 9,
|
||||
expenseType: 'meal',
|
||||
studentAmount: 50,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rounds personalAmount to two decimals before saving', async () => {
|
||||
const { service, manager, personalExpRepo } = createService();
|
||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<PersonalExpense>([
|
||||
{
|
||||
id: 10,
|
||||
studentId: 10,
|
||||
roomId: 1,
|
||||
expenseType: 'meal',
|
||||
description: '三餐',
|
||||
amount: 0.30000000000000004,
|
||||
expenseDate: '2026-06-05',
|
||||
status: 'active',
|
||||
} as PersonalExpense,
|
||||
]),
|
||||
);
|
||||
|
||||
await service.generateBillsOnce(PERIOD);
|
||||
|
||||
const billSave = (manager.save as jest.Mock).mock.calls.find(
|
||||
([value]) => !Array.isArray(value) && value?.studentId === 10,
|
||||
);
|
||||
expect(billSave).toBeDefined();
|
||||
expect(billSave![0]).toEqual(
|
||||
expect.objectContaining({
|
||||
personalAmount: 0.3,
|
||||
totalAmount: 0.3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,44 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy, Room } from '../entities';
|
||||
import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy } from '../entities';
|
||||
import { WalletsService } from '../wallets/wallets.service';
|
||||
import type { GenerateBillsDto } from './dto/bill.dto';
|
||||
import dayjs from '../common/dayjs';
|
||||
|
||||
/** 把 TypeORM 可能 hydrate 成 Date 的 date-only 字段归一化为 YYYY-MM-DD 字符串。 */
|
||||
function toDateOnly(value: Date | string | null | undefined): string {
|
||||
if (value == null) return '';
|
||||
if (value instanceof Date) {
|
||||
const y = value.getFullYear();
|
||||
const m = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(value.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD 字符串(字典序即时间序)取较大/较小者。 */
|
||||
function maxDateOnly(a: string, b: string): string {
|
||||
return a > b ? a : b;
|
||||
}
|
||||
function minDateOnly(a: string, b: string): string {
|
||||
return a < b ? a : b;
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD 字符串相差的天数(a 晚于 b 返回负值)。 */
|
||||
function daysBetweenDateOnly(a: string, b: string): number {
|
||||
const [ay, am, ad] = a.split('-').map(Number);
|
||||
const [by, bm, bd] = b.split('-').map(Number);
|
||||
return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86_400_000);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BillsGenerationService {
|
||||
constructor(
|
||||
@InjectRepository(Bill) private billRepo: Repository<Bill>,
|
||||
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
|
||||
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
||||
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
|
||||
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
|
||||
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
||||
private dataSource: DataSource,
|
||||
private walletsService: WalletsService,
|
||||
) {}
|
||||
@@ -26,14 +50,6 @@ export class BillsGenerationService {
|
||||
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
|
||||
throw new BadRequestException('账单周期无效,结束日期不能早于开始日期');
|
||||
}
|
||||
const pStart = new Date(`${periodStart}T00:00:00Z`);
|
||||
const pEnd = new Date(`${periodEnd}T00:00:00Z`);
|
||||
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
|
||||
if (existingBills.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`,
|
||||
);
|
||||
}
|
||||
const roomExpenses = await this.roomExpRepo
|
||||
.createQueryBuilder('e')
|
||||
.where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', {
|
||||
@@ -42,7 +58,16 @@ export class BillsGenerationService {
|
||||
})
|
||||
.andWhere('e.status = :status', { status: 'active' })
|
||||
.getMany();
|
||||
const longTermOccupancies: Occupancy[] = [];
|
||||
// 长租入住:即使本周期没有费用记录,也按长租计费纳入开票范围
|
||||
const longTermOccupancies: Occupancy[] = await this.occRepo
|
||||
.createQueryBuilder('o')
|
||||
.leftJoinAndSelect('o.student', 'student')
|
||||
.leftJoinAndSelect('o.room', 'room')
|
||||
.where('o.stayType = :stayType', { stayType: 'long' })
|
||||
.andWhere('o.status = :status', { status: 'active' })
|
||||
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
|
||||
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
|
||||
.getMany();
|
||||
const roomExpMap = new Map<number, RoomExpense[]>();
|
||||
for (const expense of roomExpenses) {
|
||||
const expenses = roomExpMap.get(expense.roomId) || [];
|
||||
@@ -60,16 +85,36 @@ export class BillsGenerationService {
|
||||
{ shared: number; items: Array<Record<string, unknown>> }
|
||||
>();
|
||||
|
||||
// 一次性按 roomIds In(...) 查询本周期内全部在住/曾住的入住记录,内存按 roomId 分组,
|
||||
// 避免对每个房间循环 createQueryBuilder(...).getMany()(N+1)。
|
||||
// 有意不按 o.status 过滤:这里要查这些房间在本周期内全部在住/曾住的入住记录来分摊费用,
|
||||
// 退宿后归档的入住记录只要 billingStartDate/billingEndDate 覆盖本周期仍应参与分摊
|
||||
// (与 occupancy-operations.getRoomOccupanciesInPeriod 的计费语义一致)。
|
||||
// status='active' 过滤只用于上面 roomIds 聚合(决定哪些房间进入开票范围),不用于分摊查询。
|
||||
const roomIdList = [...roomIds];
|
||||
const allOccupancies: Occupancy[] =
|
||||
roomIdList.length > 0
|
||||
? await this.occRepo
|
||||
.createQueryBuilder('o')
|
||||
.leftJoinAndSelect('o.student', 'student')
|
||||
.leftJoinAndSelect('o.room', 'room')
|
||||
.where('o.roomId IN (:...roomIds)', { roomIds: roomIdList })
|
||||
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
|
||||
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', {
|
||||
periodStart,
|
||||
})
|
||||
.getMany()
|
||||
: [];
|
||||
const occupanciesByRoom = new Map<number, Occupancy[]>();
|
||||
for (const occupancy of allOccupancies) {
|
||||
const list = occupanciesByRoom.get(occupancy.roomId) || [];
|
||||
list.push(occupancy);
|
||||
occupanciesByRoom.set(occupancy.roomId, list);
|
||||
}
|
||||
|
||||
for (const roomId of roomIds) {
|
||||
const expenses = roomExpMap.get(roomId) || [];
|
||||
const occupancies = await this.occRepo
|
||||
.createQueryBuilder('o')
|
||||
.leftJoinAndSelect('o.student', 'student')
|
||||
.leftJoinAndSelect('o.room', 'room')
|
||||
.where('o.roomId = :roomId', { roomId })
|
||||
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
|
||||
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
|
||||
.getMany();
|
||||
const occupancies = occupanciesByRoom.get(roomId) || [];
|
||||
const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long');
|
||||
const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long');
|
||||
|
||||
@@ -95,21 +140,21 @@ export class BillsGenerationService {
|
||||
studentBillData.set(occupancy.studentId, data);
|
||||
}
|
||||
|
||||
// 日期统一用 YYYY-MM-DD 字符串比较/运算,避免 TypeORM 把 date 列 hydrate 成 Date 后比较不可靠
|
||||
const studentDays = shortTermOccs.map((occupancy) => {
|
||||
const start = new Date(
|
||||
Math.max(new Date(occupancy.billingStartDate).getTime(), pStart.getTime()),
|
||||
);
|
||||
const end = occupancy.billingEndDate
|
||||
? new Date(Math.min(new Date(occupancy.billingEndDate).getTime(), pEnd.getTime()))
|
||||
: pEnd;
|
||||
const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1);
|
||||
const startStr = toDateOnly(occupancy.billingStartDate);
|
||||
const endStr = toDateOnly(occupancy.billingEndDate) || periodEnd;
|
||||
if (!startStr) return { studentId: occupancy.studentId, days: 0 };
|
||||
const start = maxDateOnly(startStr, periodStart);
|
||||
const end = minDateOnly(endStr, periodEnd);
|
||||
const days = end < start ? 0 : daysBetweenDateOnly(start, end) + 1;
|
||||
return { studentId: occupancy.studentId, days };
|
||||
});
|
||||
const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0);
|
||||
if (totalDays === 0) continue;
|
||||
|
||||
const eligibleDays = studentDays.filter((entry) => entry.days > 0);
|
||||
for (const expense of expenses) {
|
||||
const eligibleDays = studentDays.filter((entry) => entry.days > 0);
|
||||
const expenseTotal = Number(Number(expense.amount).toFixed(2));
|
||||
let allocated = 0;
|
||||
for (const [index, entry] of eligibleDays.entries()) {
|
||||
@@ -167,44 +212,81 @@ export class BillsGenerationService {
|
||||
|
||||
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
|
||||
const bills = await this.dataSource.transaction(async (manager) => {
|
||||
const generated: Bill[] = [];
|
||||
for (const studentId of allStudentIds) {
|
||||
const shared = studentBillData.get(studentId)?.shared || 0;
|
||||
const personal = personalMap.get(studentId) || 0;
|
||||
const total = Number((shared + personal).toFixed(2));
|
||||
let bill = await manager.save(
|
||||
manager.create(Bill, {
|
||||
studentId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
sharedAmount: Number(shared.toFixed(2)),
|
||||
personalAmount: personal,
|
||||
totalAmount: total,
|
||||
source: 'batch',
|
||||
paidAmount: 0,
|
||||
outstandingAmount: total,
|
||||
status: 'unpaid',
|
||||
}),
|
||||
// 首次生成的并发防护:FOR UPDATE 对不存在的行不加锁,两个并发请求都能通过检查;
|
||||
// 这里在事务内先拿按周期 key 的 MySQL advisory lock(GET_LOCK 对不存在的 key 同样生效),
|
||||
// 失败/超时直接抛 ConflictException,重复检查放进锁内。financialOperations.run 只覆盖
|
||||
// 带 operationId 的幂等场景,无 operationId 的并发首次生成靠这把锁兜底。
|
||||
const lockName = `gongxue:bills-gen:${periodStart}-${periodEnd}`;
|
||||
const lockRows = (await manager.query(
|
||||
`SELECT GET_LOCK('${lockName}', 5) AS acquired`,
|
||||
)) as unknown as Array<{ acquired?: unknown }> | undefined;
|
||||
if (Number(lockRows?.[0]?.acquired ?? 0) !== 1) {
|
||||
throw new ConflictException(
|
||||
`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单正在生成中,请勿重复提交`,
|
||||
);
|
||||
const items = [
|
||||
...(studentBillData.get(studentId)?.items || []),
|
||||
...(personalItems.get(studentId) || []),
|
||||
];
|
||||
for (const item of items)
|
||||
await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));
|
||||
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
|
||||
if (includedPersonal.length) {
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.update(PersonalExpense)
|
||||
.set({ billId: bill.id })
|
||||
.where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) })
|
||||
.execute();
|
||||
}
|
||||
bill = await this.walletsService.debitBill(manager, bill);
|
||||
generated.push(bill);
|
||||
}
|
||||
return generated;
|
||||
try {
|
||||
// 周期内重复生成检查移进事务内,并对同一 (periodStart, periodEnd) 加锁复查:
|
||||
// 并发请求会在锁释放后看到已提交的账单并抛「账单已生成」,保证只有一个请求成功。
|
||||
const existingBills = await manager
|
||||
.createQueryBuilder(Bill, 'b')
|
||||
.setLock('pessimistic_write')
|
||||
.where('b.periodStart = :periodStart', { periodStart })
|
||||
.andWhere('b.periodEnd = :periodEnd', { periodEnd })
|
||||
.getMany();
|
||||
if (existingBills.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`,
|
||||
);
|
||||
}
|
||||
const generated: Bill[] = [];
|
||||
for (const studentId of allStudentIds) {
|
||||
const shared = studentBillData.get(studentId)?.shared || 0;
|
||||
const personal = personalMap.get(studentId) || 0;
|
||||
const total = Number((shared + personal).toFixed(2));
|
||||
let bill = await manager.save(
|
||||
manager.create(Bill, {
|
||||
studentId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
sharedAmount: Number(shared.toFixed(2)),
|
||||
personalAmount: Number(personal.toFixed(2)),
|
||||
totalAmount: total,
|
||||
source: 'batch',
|
||||
paidAmount: 0,
|
||||
outstandingAmount: total,
|
||||
status: 'unpaid',
|
||||
}),
|
||||
);
|
||||
const items = [
|
||||
...(studentBillData.get(studentId)?.items || []),
|
||||
...(personalItems.get(studentId) || []),
|
||||
];
|
||||
// 逐条 save 改为批量一次保存,减少往返
|
||||
if (items.length > 0) {
|
||||
await manager.save(
|
||||
manager.create(
|
||||
BillItem,
|
||||
items.map((item) => ({ ...item, billId: bill.id })),
|
||||
),
|
||||
);
|
||||
}
|
||||
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
|
||||
if (includedPersonal.length) {
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.update(PersonalExpense)
|
||||
.set({ billId: bill.id })
|
||||
.where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) })
|
||||
.execute();
|
||||
}
|
||||
bill = await this.walletsService.debitBill(manager, bill);
|
||||
generated.push(bill);
|
||||
}
|
||||
return generated;
|
||||
} finally {
|
||||
await manager.query(`SELECT RELEASE_LOCK('${lockName}')`);
|
||||
}
|
||||
});
|
||||
return {
|
||||
message: `成功生成 ${bills.length} 条账单`,
|
||||
@@ -222,12 +304,10 @@ export class BillsGenerationService {
|
||||
periodEnd: string,
|
||||
monthlyRate: number,
|
||||
) {
|
||||
const activeStart =
|
||||
occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart;
|
||||
const activeEnd =
|
||||
occupancy.billingEndDate && occupancy.billingEndDate < periodEnd
|
||||
? occupancy.billingEndDate
|
||||
: periodEnd;
|
||||
const startStr = toDateOnly(occupancy.billingStartDate);
|
||||
const endStr = toDateOnly(occupancy.billingEndDate) || periodEnd;
|
||||
const activeStart = startStr > periodStart ? startStr : periodStart;
|
||||
const activeEnd = endStr && endStr < periodEnd ? endStr : periodEnd;
|
||||
if (activeEnd < activeStart || monthlyRate <= 0) return 0;
|
||||
const [startYear, startMonth] = activeStart.split('-').map(Number);
|
||||
const [endYear, endMonth] = activeEnd.split('-').map(Number);
|
||||
|
||||
196
apps/server/src/bills/bills.controller.spec.ts
Normal file
196
apps/server/src/bills/bills.controller.spec.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { BillsController } from './bills.controller';
|
||||
import { NotificationType } from '../entities/notification.entity';
|
||||
|
||||
describe('BillsController — 通知批量预取学生', () => {
|
||||
function createController(options: {
|
||||
bills?: Array<Record<string, unknown>>;
|
||||
students?: Array<Record<string, unknown>>;
|
||||
} = {}) {
|
||||
const { bills = [], students = [] } = options;
|
||||
const service = {
|
||||
generateBills: jest.fn().mockResolvedValue({
|
||||
periodStart: '2026-06-01',
|
||||
periodEnd: '2026-06-30',
|
||||
count: bills.length,
|
||||
bills,
|
||||
}),
|
||||
batchUpdateStatus: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
const studentRepo = {
|
||||
find: jest.fn().mockResolvedValue(students),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const billRepo = { findBy: jest.fn().mockResolvedValue(bills) };
|
||||
const notificationsService = {
|
||||
create: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const logService = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const controller = new BillsController(
|
||||
service as never,
|
||||
{} as never,
|
||||
logService as never,
|
||||
notificationsService as never,
|
||||
studentRepo as never,
|
||||
billRepo as never,
|
||||
);
|
||||
return { controller, service, studentRepo, billRepo, notificationsService };
|
||||
}
|
||||
|
||||
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
|
||||
|
||||
it('batches student lookup before sending bill_generated notifications', async () => {
|
||||
const { controller, studentRepo, notificationsService } = createController({
|
||||
bills: [
|
||||
{ id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' },
|
||||
{ id: 2, studentId: 10, totalAmount: 80, status: 'unpaid' },
|
||||
{ id: 3, studentId: 11, totalAmount: 50, status: 'unpaid' },
|
||||
],
|
||||
students: [
|
||||
{ id: 10, userId: 100 },
|
||||
{ id: 11, userId: null },
|
||||
],
|
||||
});
|
||||
|
||||
await controller.generateBills({} as never, req as never);
|
||||
|
||||
expect(studentRepo.find).toHaveBeenCalledTimes(1);
|
||||
const where = (studentRepo.find as jest.Mock).mock.calls[0][0].where;
|
||||
expect(where.id.value).toEqual([10, 11]);
|
||||
expect(studentRepo.findOne).not.toHaveBeenCalled();
|
||||
expect(notificationsService.create).toHaveBeenCalledTimes(2);
|
||||
expect(notificationsService.create).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
recipientIds: [100],
|
||||
type: NotificationType.BILL_GENERATED,
|
||||
title: '新账单',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('notifies only paid bills in batch status update with a single student query', async () => {
|
||||
const { controller, studentRepo, notificationsService, billRepo } = createController({
|
||||
bills: [
|
||||
{ id: 1, studentId: 10, totalAmount: 100, status: 'paid' },
|
||||
{ id: 2, studentId: 11, totalAmount: 50, status: 'unpaid' },
|
||||
{ id: 3, studentId: 10, totalAmount: 80, status: 'paid' },
|
||||
],
|
||||
students: [{ id: 10, userId: 100 }],
|
||||
});
|
||||
// 更新前状态:1/2/3 都未付款;更新后 1/3 变为 paid → 只通知 1/3(幂等重确认不重复通知)
|
||||
billRepo.findBy.mockResolvedValueOnce([
|
||||
{ id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' },
|
||||
{ id: 2, studentId: 11, totalAmount: 50, status: 'unpaid' },
|
||||
{ id: 3, studentId: 10, totalAmount: 80, status: 'unpaid' },
|
||||
]);
|
||||
|
||||
await controller.batchUpdateStatus({ ids: [1, 2, 3], status: 'paid' }, req as never);
|
||||
|
||||
expect(studentRepo.find).toHaveBeenCalledTimes(1);
|
||||
expect(notificationsService.create).toHaveBeenCalledTimes(2);
|
||||
expect(notificationsService.create).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
recipientIds: [100],
|
||||
type: NotificationType.BILL_PAID,
|
||||
title: '账单已确认',
|
||||
content: expect.stringContaining('#1'),
|
||||
}),
|
||||
);
|
||||
expect(notificationsService.create).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ content: expect.stringContaining('#3') }),
|
||||
);
|
||||
});
|
||||
|
||||
it('a notification failure does not stop notifications for other bills', async () => {
|
||||
let call = 0;
|
||||
const notificationsService = {
|
||||
create: jest.fn().mockImplementation(() => {
|
||||
call += 1;
|
||||
if (call === 1) return Promise.reject(new Error('notify down'));
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
};
|
||||
const studentRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 10, userId: 100 }]),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const billRepo = {
|
||||
findBy: jest.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' },
|
||||
{ id: 2, studentId: 10, totalAmount: 50, status: 'unpaid' },
|
||||
])
|
||||
.mockResolvedValue([
|
||||
{ id: 1, studentId: 10, totalAmount: 100, status: 'paid' },
|
||||
{ id: 2, studentId: 10, totalAmount: 50, status: 'paid' },
|
||||
]),
|
||||
};
|
||||
const service = {
|
||||
batchUpdateStatus: jest.fn().mockResolvedValue({}),
|
||||
generateBills: jest.fn(),
|
||||
};
|
||||
const controller = new BillsController(
|
||||
service as never,
|
||||
{} as never,
|
||||
{ log: jest.fn().mockResolvedValue(undefined) } as never,
|
||||
notificationsService as never,
|
||||
studentRepo as never,
|
||||
billRepo as never,
|
||||
);
|
||||
|
||||
await controller.batchUpdateStatus({ ids: [1, 2], status: 'paid' }, req as never);
|
||||
|
||||
expect(notificationsService.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('logs notification failures instead of swallowing them silently', async () => {
|
||||
let call = 0;
|
||||
const notificationsService = {
|
||||
create: jest.fn().mockImplementation(() => {
|
||||
call += 1;
|
||||
if (call === 1) return Promise.reject(new Error('notify down'));
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
};
|
||||
const studentRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 10, userId: 100 }]),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const billRepo = {
|
||||
findBy: jest.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' },
|
||||
{ id: 2, studentId: 10, totalAmount: 50, status: 'unpaid' },
|
||||
])
|
||||
.mockResolvedValue([
|
||||
{ id: 1, studentId: 10, totalAmount: 100, status: 'paid' },
|
||||
{ id: 2, studentId: 10, totalAmount: 50, status: 'paid' },
|
||||
]),
|
||||
};
|
||||
const service = {
|
||||
batchUpdateStatus: jest.fn().mockResolvedValue({}),
|
||||
generateBills: jest.fn(),
|
||||
};
|
||||
const controller = new BillsController(
|
||||
service as never,
|
||||
{} as never,
|
||||
{ log: jest.fn().mockResolvedValue(undefined) } as never,
|
||||
notificationsService as never,
|
||||
studentRepo as never,
|
||||
billRepo as never,
|
||||
);
|
||||
const warnSpy = jest.spyOn((controller as any).logger, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await controller.batchUpdateStatus({ ids: [1, 2], status: 'paid' }, req as never);
|
||||
|
||||
// Promise.allSettled:失败通知不阻断其他通知,且失败被记录到日志
|
||||
expect(notificationsService.create).toHaveBeenCalledTimes(2);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('notify down'));
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Res,
|
||||
Req,
|
||||
ParseIntPipe,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
@@ -21,7 +22,13 @@ import { NotificationType } from '../entities/notification.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { BillsExportService } from './bills-export.service';
|
||||
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||||
import {
|
||||
BatchIdsBodyDto,
|
||||
CancelBillDto,
|
||||
GenerateBillsDto,
|
||||
UpdateBillStatusDto,
|
||||
} from './dto/bill.dto';
|
||||
import { BatchIdsDto } from '../common/batch-ids.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { logAudit } from '../common/with-audit-log';
|
||||
@@ -38,6 +45,8 @@ interface AuthenticatedRequest {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('bills')
|
||||
export class BillsController {
|
||||
private readonly logger = new Logger(BillsController.name);
|
||||
|
||||
constructor(
|
||||
private service: BillsService,
|
||||
private exportService: BillsExportService,
|
||||
@@ -56,18 +65,20 @@ export class BillsController {
|
||||
});
|
||||
// Send bill_generated notifications
|
||||
try {
|
||||
for (const bill of result.bills) {
|
||||
const student = await this.studentRepo.findOne({ where: { id: bill.studentId } });
|
||||
if (student?.userId) {
|
||||
void this.notificationsService.create({
|
||||
recipientIds: [student.userId],
|
||||
type: NotificationType.BILL_GENERATED,
|
||||
title: '新账单',
|
||||
content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${result.periodStart}~${result.periodEnd}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_) { /* don't block response */ }
|
||||
await this.sendPaidNotifications(
|
||||
this.studentRepo,
|
||||
this.notificationsService,
|
||||
result.bills,
|
||||
(bill) => ({
|
||||
type: NotificationType.BILL_GENERATED,
|
||||
title: '新账单',
|
||||
content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${result.periodStart}~${result.periodEnd}`,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
// 预取/通知失败不影响主流程,但记录日志避免静默吞错
|
||||
this.logger.warn(`账单生成通知发送失败: ${String(err)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -87,113 +98,6 @@ export class BillsController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('bill:view')
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@RequirePermission('bill:confirm')
|
||||
async updateStatus(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateBillStatusDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const result = await this.service.updateStatus(id, dto);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '确认账单', targetId: id, targetType: 'bill',
|
||||
});
|
||||
// Send bill_paid notification
|
||||
try {
|
||||
const student = await this.studentRepo.findOne({ where: { id: result.studentId } });
|
||||
if (student?.userId) {
|
||||
void this.notificationsService.create({
|
||||
recipientIds: [student.userId],
|
||||
type: NotificationType.BILL_PAID,
|
||||
title: '账单已确认',
|
||||
content: `账单 #${result.id} 已确认收款,金额: ¥${result.totalAmount}`,
|
||||
});
|
||||
}
|
||||
} catch (_) { /* don't block response */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put('batch/status')
|
||||
@RequirePermission('bill:confirm')
|
||||
async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.batchUpdateStatus(body.ids, body.status);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '确认账单', detail: `IDs: ${body.ids.join(',')}`,
|
||||
});
|
||||
// Send bill_paid notifications (batch)
|
||||
try {
|
||||
const bills = await this.billRepo.findBy({ id: In(body.ids) });
|
||||
for (const bill of bills) {
|
||||
const student = await this.studentRepo.findOne({ where: { id: bill.studentId } });
|
||||
if (student?.userId) {
|
||||
void this.notificationsService.create({
|
||||
recipientIds: [student.userId],
|
||||
type: NotificationType.BILL_PAID,
|
||||
title: '账单已确认',
|
||||
content: `账单 #${bill.id} 已确认收款,金额: ¥${bill.totalAmount}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_) { /* don't block response */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@RequirePermission('bill:delete')
|
||||
async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.cancel(id, dto, req.user?.id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', detail: dto.reason,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('bill:delete')
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.remove(id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id/permanent')
|
||||
@RequirePermission('bill:purge')
|
||||
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.purge(id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('batch-permanent-delete')
|
||||
@RequirePermission('bill:purge')
|
||||
async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.batchPurge(body.ids || []);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('batch/delete')
|
||||
@RequirePermission('bill:delete')
|
||||
async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.batchRemove(body.ids);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '批量归档账单', detail: `IDs: ${body.ids.join(',')}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('export/excel')
|
||||
@RequirePermission('bill:export-excel')
|
||||
async exportExcel(
|
||||
@@ -226,4 +130,157 @@ export class BillsController {
|
||||
});
|
||||
return this.exportService.exportStudentPdf(id, res);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('bill:view')
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Put('batch/status')
|
||||
@RequirePermission('bill:confirm')
|
||||
async batchUpdateStatus(@Body() body: BatchIdsBodyDto, @Request() req: AuthenticatedRequest) {
|
||||
const ids = body.ids || [];
|
||||
// 更新前先取当前状态:只对「本次从非 paid 变为 paid」的账单发通知,幂等重确认不重复打扰
|
||||
const before = await this.billRepo.findBy({ id: In(ids) });
|
||||
const paidBefore = new Set(
|
||||
before.filter((bill) => bill.status === 'paid').map((bill) => bill.id),
|
||||
);
|
||||
const result = await this.service.batchUpdateStatus(ids, body.status);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '确认账单', detail: `IDs: ${ids.join(',')}`,
|
||||
});
|
||||
// Send bill_paid notifications (batch):DB 查询失败正常抛出,只有通知失败被吞(记日志)
|
||||
const bills = await this.billRepo.findBy({ id: In(ids) });
|
||||
const paidBills = bills.filter((bill) => bill.status === 'paid' && !paidBefore.has(bill.id));
|
||||
try {
|
||||
await this.sendPaidNotifications(this.studentRepo, this.notificationsService, paidBills);
|
||||
} catch (err) {
|
||||
this.logger.warn(`账单确认通知发送失败: ${String(err)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@RequirePermission('bill:confirm')
|
||||
async updateStatus(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateBillStatusDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const prior = await this.billRepo.findOne({ where: { id } });
|
||||
const result = await this.service.updateStatus(id, dto);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '确认账单', targetId: id, targetType: 'bill',
|
||||
});
|
||||
// 仅当本次从非 paid 变为 paid 才发通知(幂等),复用批量通知 helper
|
||||
if (result.status === 'paid' && prior?.status !== 'paid') {
|
||||
try {
|
||||
await this.sendPaidNotifications(this.studentRepo, this.notificationsService, [result]);
|
||||
} catch (err) {
|
||||
this.logger.warn(`账单已确认通知发送失败: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@RequirePermission('bill:delete')
|
||||
async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.cancel(id, dto, req.user?.id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill',
|
||||
detail: Array.from(dto.reason ?? '')
|
||||
.map((ch) => (ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127 ? ' ' : ch))
|
||||
.join('')
|
||||
.trim(),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('bill:delete')
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.remove(id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id/permanent')
|
||||
@RequirePermission('bill:purge')
|
||||
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.purge(id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('batch-permanent-delete')
|
||||
@RequirePermission('bill:purge')
|
||||
async batchPurge(@Body() body: BatchIdsDto, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.batchPurge(body.ids || []);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('batch/delete')
|
||||
@RequirePermission('bill:delete')
|
||||
async batchRemove(@Body() body: BatchIdsDto, @Request() req: AuthenticatedRequest) {
|
||||
const ids = body.ids || [];
|
||||
const result = await this.service.batchRemove(ids);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '账单管理', action: '批量归档账单', detail: `IDs: ${ids.join(',')}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量预取学生并并发发送账单通知(generateBills/batchUpdateStatus 共用)。
|
||||
* 内部用 Promise.allSettled 并发发送:单个通知失败只记录日志、不影响其他通知与主流程。
|
||||
* notificationFor 缺省时发送「已确认收款」通知(batchUpdateStatus 场景)。
|
||||
*/
|
||||
private async sendPaidNotifications(
|
||||
studentRepo: Repository<Student>,
|
||||
notificationsService: NotificationsService,
|
||||
bills: Bill[],
|
||||
notificationFor: (
|
||||
bill: Bill,
|
||||
) => { type: NotificationType; title: string; content: string } = (bill) => ({
|
||||
type: NotificationType.BILL_PAID,
|
||||
title: '账单已确认',
|
||||
content: `账单 #${bill.id} 已确认收款,金额: ¥${bill.totalAmount}`,
|
||||
}),
|
||||
): Promise<void> {
|
||||
if (bills.length === 0) return;
|
||||
// 批量预取学生(一次 In 查询),避免每张账单 studentRepo.findOne(N+1)
|
||||
const studentIds = [...new Set(bills.map((bill) => bill.studentId))];
|
||||
const students = await studentRepo.find({ where: { id: In(studentIds) } });
|
||||
const userByStudentId = new Map(
|
||||
students.filter((student) => student.userId).map((student) => [student.id, student.userId]),
|
||||
);
|
||||
const results = await Promise.allSettled(
|
||||
bills.map((bill) => {
|
||||
const userId = userByStudentId.get(bill.studentId);
|
||||
if (!userId) return Promise.resolve(undefined);
|
||||
const notification = notificationFor(bill);
|
||||
return notificationsService.create({
|
||||
recipientIds: [userId],
|
||||
type: notification.type,
|
||||
title: notification.title,
|
||||
content: notification.content,
|
||||
});
|
||||
}),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
// 不吞错:失败记录到日志,通知失败不影响主流程
|
||||
this.logger.warn(`账单通知发送失败: ${String(result.reason)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,11 @@ describe('BillsService — generateBills', () => {
|
||||
await (itemRepo.save as jest.Mock)(value);
|
||||
return { id: value.id || 1, ...value };
|
||||
}),
|
||||
createQueryBuilder: jest.fn(() => ({ update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })),
|
||||
createQueryBuilder: jest.fn(() => ({ setLock: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([]), update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })),
|
||||
// advisory lock:事务内 GET_LOCK/RELEASE_LOCK 直接成功
|
||||
query: jest.fn(async (sql: string) =>
|
||||
sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }],
|
||||
),
|
||||
})),
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
@@ -390,26 +394,27 @@ describe('BillsService — generateBills', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
// Use sequential query builder returns: first call → room 1 occs, second → room 2 occs
|
||||
// Use sequential query builder returns:
|
||||
// call 1 → 本周期长租入住(该测试无长租,返回空)
|
||||
// call 2 → roomIds 批量入住查询(room 1 + room 2 全部在住记录)
|
||||
// NOTE: mock order depends on internal service call sequence; if refactored, update callCount indices
|
||||
let callCount = 0;
|
||||
(occRepo.createQueryBuilder as jest.Mock).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return mockQueryBuilder<Occupancy>([
|
||||
{
|
||||
id: 1, studentId: 10, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-10',
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
{
|
||||
id: 2, studentId: 11, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
]);
|
||||
return mockQueryBuilder<Occupancy>([]);
|
||||
}
|
||||
return mockQueryBuilder<Occupancy>([
|
||||
{
|
||||
id: 1, studentId: 10, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-10',
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
{
|
||||
id: 2, studentId: 11, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
{
|
||||
id: 3, studentId: 12, roomId: 2,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
|
||||
@@ -562,21 +567,25 @@ describe('BillsService — allocation rounding boundary', () => {
|
||||
create: (_entity: unknown, value: any) => value,
|
||||
save: jest.fn(async (value: any) => ({ id: value.id || ++nextBillId, ...value })),
|
||||
createQueryBuilder: jest.fn(() => ({
|
||||
setLock: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
})),
|
||||
// advisory lock:事务内 GET_LOCK/RELEASE_LOCK 直接成功
|
||||
query: jest.fn(async (sql: string) =>
|
||||
sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }],
|
||||
),
|
||||
})),
|
||||
};
|
||||
const walletsService = { debitBill: jest.fn(async (_manager, bill) => bill) } as any;
|
||||
const generation = new BillsGenerationService(
|
||||
billRepo as any,
|
||||
itemRepo as any,
|
||||
roomExpRepo as any,
|
||||
personalExpRepo as any,
|
||||
occRepo as any,
|
||||
roomRepo as any,
|
||||
dataSource as any,
|
||||
walletsService,
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill
|
||||
import { WalletsService } from '../wallets/wallets.service';
|
||||
import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
|
||||
import { BillsGenerationService } from './bills-generation.service';
|
||||
import { escapeLike } from '../common/like-escape';
|
||||
|
||||
interface AgentBillRow {
|
||||
billId: string | number;
|
||||
@@ -143,11 +144,11 @@ export class BillsService {
|
||||
const billId = Number(query.keyword);
|
||||
if (Number.isInteger(billId) && billId > 0) {
|
||||
qb.andWhere('(student.name LIKE :keyword OR bill.id = :billId)', {
|
||||
keyword: `%${query.keyword}%`,
|
||||
keyword: `%${escapeLike(query.keyword)}%`,
|
||||
billId,
|
||||
});
|
||||
} else {
|
||||
qb.andWhere('student.name LIKE :keyword', { keyword: `%${query.keyword}%` });
|
||||
qb.andWhere('student.name LIKE :keyword', { keyword: `%${escapeLike(query.keyword)}%` });
|
||||
}
|
||||
}
|
||||
if (query.periodStart)
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class GenerateBillsDto {
|
||||
@IsOptional()
|
||||
@@ -7,7 +17,7 @@ export class GenerateBillsDto {
|
||||
operationId?: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}$/)
|
||||
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
|
||||
billingMonth: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -36,3 +46,14 @@ export class CancelBillDto {
|
||||
@MaxLength(300)
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** 批量确认账单请求体:ids 校验为整数数组且最多 500 条。 */
|
||||
export class BatchIdsBodyDto {
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@ArrayMaxSize(500)
|
||||
ids: number[];
|
||||
|
||||
@IsIn(['unpaid', 'partially_paid', 'paid'])
|
||||
status: 'unpaid' | 'partially_paid' | 'paid';
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
let scheduleRepo: jest.Mocked<
|
||||
Pick<
|
||||
Repository<ClassSchedule>,
|
||||
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
|
||||
'find' | 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -226,17 +226,30 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
Pick<Repository<Organization>, 'findOne'>
|
||||
>;
|
||||
|
||||
// syncScheduleFromRental 现在把 read+write 放进 scheduleRepo.manager 事务,
|
||||
// 事务内通过 manager.getRepository(ClassSchedule) 拿到绑定事务的同一 mock。
|
||||
const scheduleManager = {
|
||||
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
|
||||
cb({
|
||||
getRepository: jest.fn((entity: unknown) =>
|
||||
entity === ClassSchedule ? scheduleRepo : undefined,
|
||||
),
|
||||
}),
|
||||
),
|
||||
};
|
||||
scheduleRepo = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
manager: scheduleManager,
|
||||
} as jest.Mocked<
|
||||
Pick<
|
||||
Repository<ClassSchedule>,
|
||||
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
|
||||
'find' | 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -265,8 +278,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
const dto: CreateRentalDto = {
|
||||
classroomId: 1,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
startDate: '2026-09-01',
|
||||
endDate: '2026-09-30',
|
||||
};
|
||||
const classroom = { id: 1, status: 'available' } as Classroom;
|
||||
const hostOrganization = {
|
||||
@@ -294,7 +307,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
);
|
||||
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
|
||||
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
|
||||
scheduleRepo.findOne.mockResolvedValue(null);
|
||||
scheduleRepo.find.mockResolvedValue([]);
|
||||
scheduleRepo.create.mockImplementation(
|
||||
(entity) => ({ ...(entity as object) }) as ClassSchedule,
|
||||
);
|
||||
@@ -310,8 +323,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
classroomId: 1,
|
||||
lessorOrganizationId: 1,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
startDate: '2026-09-01',
|
||||
endDate: '2026-09-30',
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
@@ -321,8 +334,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
classId: null,
|
||||
startTime: '00:00',
|
||||
endTime: '23:59',
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
startDate: '2026-09-01',
|
||||
endDate: '2026-09-30',
|
||||
subject: 'Organization A 租赁',
|
||||
teacherId: null,
|
||||
scheduleType: 'RENTAL',
|
||||
@@ -332,6 +345,63 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
);
|
||||
expect(scheduleRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates one RENTAL schedule row per weekday for a multi-day rental', async () => {
|
||||
const dto: CreateRentalDto = {
|
||||
classroomId: 1,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-09-01', // 周二
|
||||
endDate: '2026-09-05', // 周六
|
||||
};
|
||||
classroomRepo.findOne.mockResolvedValue({ id: 1, status: 'available' } as Classroom);
|
||||
organizationRepo.findOne
|
||||
.mockResolvedValueOnce({
|
||||
id: 1,
|
||||
name: 'Host',
|
||||
isHost: true,
|
||||
status: 'active',
|
||||
} as Organization)
|
||||
.mockResolvedValueOnce({
|
||||
id: 2,
|
||||
name: 'Organization A',
|
||||
isHost: false,
|
||||
status: 'active',
|
||||
} as Organization);
|
||||
rentalRepo.create.mockImplementation(
|
||||
(entity) => ({ ...(entity as object) }) as ClassroomRental,
|
||||
);
|
||||
rentalRepo.save.mockImplementation((entity) =>
|
||||
Promise.resolve({ ...(entity as object), id: 1 } as ClassroomRental),
|
||||
);
|
||||
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
|
||||
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
|
||||
scheduleRepo.find.mockResolvedValue([]);
|
||||
scheduleRepo.create.mockImplementation(
|
||||
(entity) => ({ ...(entity as object) }) as ClassSchedule,
|
||||
);
|
||||
scheduleRepo.save.mockImplementation((entity) =>
|
||||
Promise.resolve({ ...(entity as object), id: 100 } as ClassSchedule),
|
||||
);
|
||||
|
||||
await service.create(dto);
|
||||
|
||||
expect(scheduleRepo.create).toHaveBeenCalledTimes(5);
|
||||
const createdWeekDays = (scheduleRepo.create as jest.Mock).mock.calls
|
||||
.map((call) => (call[0] as ClassSchedule).weekDay)
|
||||
.sort((a, b) => a - b);
|
||||
expect(createdWeekDays).toEqual([2, 3, 4, 5, 6]);
|
||||
for (const call of (scheduleRepo.create as jest.Mock).mock.calls) {
|
||||
expect(call[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
rentalId: 1,
|
||||
scheduleType: 'RENTAL',
|
||||
startDate: '2026-09-01',
|
||||
endDate: '2026-09-05',
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('update()', () => {
|
||||
@@ -352,17 +422,20 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2099-04-30',
|
||||
};
|
||||
const existingSchedule = {
|
||||
id: 50,
|
||||
// 多日租赁已按周几展开为 7 行排课(weekDay 1..7)
|
||||
const existingSchedules = [1, 2, 3, 4, 5, 6, 7].map((weekDay) => ({
|
||||
id: 49 + weekDay,
|
||||
rentalId: 1,
|
||||
scheduleType: 'RENTAL',
|
||||
classroomId: 1,
|
||||
} as ClassSchedule;
|
||||
weekDay,
|
||||
status: 'active',
|
||||
})) as ClassSchedule[];
|
||||
|
||||
rentalRepo.findOne.mockResolvedValueOnce(existingRental).mockResolvedValueOnce(updatedRental);
|
||||
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
|
||||
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
|
||||
scheduleRepo.findOne.mockResolvedValue(existingSchedule);
|
||||
scheduleRepo.find.mockResolvedValue(existingSchedules);
|
||||
|
||||
const dto: UpdateRentalDto = { startDate: '2026-08-01', endDate: '2099-04-30' };
|
||||
await service.update(1, dto);
|
||||
@@ -387,6 +460,55 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
expect(scheduleRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels RENTAL schedule rows for weekdays no longer covered after the rental is shortened', async () => {
|
||||
const existingRental = {
|
||||
id: 1,
|
||||
classroomId: 1,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-09-01',
|
||||
endDate: '2026-09-07',
|
||||
status: 'active',
|
||||
notes: '',
|
||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||
classroom: { id: 1 } as Classroom,
|
||||
} as ClassroomRental;
|
||||
const shortenedRental = { ...existingRental, endDate: '2026-09-02' };
|
||||
const existingSchedules = [1, 2, 3, 4, 5, 6, 7].map((weekDay) => ({
|
||||
id: 49 + weekDay,
|
||||
rentalId: 1,
|
||||
scheduleType: 'RENTAL',
|
||||
classroomId: 1,
|
||||
weekDay,
|
||||
status: 'active',
|
||||
})) as ClassSchedule[];
|
||||
|
||||
rentalRepo.findOne.mockResolvedValueOnce(existingRental).mockResolvedValueOnce(shortenedRental);
|
||||
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
|
||||
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
|
||||
scheduleRepo.find.mockResolvedValue(existingSchedules);
|
||||
|
||||
const dto: UpdateRentalDto = { endDate: '2026-09-02' };
|
||||
await service.update(1, dto);
|
||||
|
||||
// 2026-09-01(周二)=2、2026-09-02(周三)=3 保留为 active,其余周几行置为 cancelled
|
||||
expect(scheduleRepo.update).toHaveBeenCalledWith(
|
||||
50,
|
||||
expect.objectContaining({ status: 'cancelled' }),
|
||||
);
|
||||
expect(scheduleRepo.update).toHaveBeenCalledWith(
|
||||
54,
|
||||
expect.objectContaining({ status: 'cancelled' }),
|
||||
);
|
||||
expect(scheduleRepo.update).toHaveBeenCalledWith(
|
||||
51,
|
||||
expect.objectContaining({ status: 'active', weekDay: 2 }),
|
||||
);
|
||||
expect(scheduleRepo.update).toHaveBeenCalledWith(
|
||||
52,
|
||||
expect.objectContaining({ status: 'active', weekDay: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('deactivates the RENTAL schedule row when the rental is cancelled', async () => {
|
||||
const rental = {
|
||||
id: 1,
|
||||
@@ -423,7 +545,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
} as ClassroomRental;
|
||||
const ended = { ...rental, status: 'ended', endDate: '2026-07-13' } as ClassroomRental;
|
||||
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(ended);
|
||||
scheduleRepo.findOne.mockResolvedValue({ id: 50 } as ClassSchedule);
|
||||
scheduleRepo.find.mockResolvedValue([{ id: 50, weekDay: 3, status: 'active' } as ClassSchedule]);
|
||||
|
||||
await service.end(1);
|
||||
|
||||
@@ -431,7 +553,10 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
1,
|
||||
expect.objectContaining({ status: 'ended' }),
|
||||
);
|
||||
expect(scheduleRepo.update).toHaveBeenCalled();
|
||||
expect(scheduleRepo.update).toHaveBeenCalledWith(
|
||||
50,
|
||||
expect.objectContaining({ status: 'cancelled' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects ending a future rental', async () => {
|
||||
@@ -492,12 +617,18 @@ describe('ClassroomRentalsService — organization roles', () => {
|
||||
.mockResolvedValueOnce({ id: 2, name: '合作机构', isHost: false, status: 'active' }),
|
||||
} as any;
|
||||
const scheduleRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
save: jest.fn(async (value) => ({ ...value, id: 100 })),
|
||||
create: jest.fn((value) => value),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassSchedule>([])),
|
||||
manager: {
|
||||
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
|
||||
cb({ getRepository: jest.fn((entity: unknown) => (entity === ClassSchedule ? scheduleRepo : undefined)) }),
|
||||
),
|
||||
},
|
||||
} as any;
|
||||
|
||||
const scheduleService = new RentalScheduleService(rentalRepo, classroomRepo, scheduleRepo);
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { ClassroomRental, Classroom, ClassSchedule, ClassroomStatus } from '../entities';
|
||||
import {
|
||||
ClassroomRental,
|
||||
Classroom,
|
||||
ClassSchedule,
|
||||
ClassroomStatus,
|
||||
ScheduleType,
|
||||
} from '../entities';
|
||||
import { ClassroomRentalStatus } from '../entities/classroom-rental.entity';
|
||||
import dayjs from '../common/dayjs';
|
||||
import { addDaysToDateOnly, getWeekDayFromDateOnly } from '../common/china-time';
|
||||
|
||||
// TODO: matrix 格子整体暂保持 any;统计过滤时用最小显式形状避免 unsafe any 访问
|
||||
interface ScheduleMatrixCell {
|
||||
scheduleType?: string;
|
||||
status?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const COLOR_PALETTE = [
|
||||
"#5B8FF9",
|
||||
@@ -43,8 +57,9 @@ export class RentalScheduleService {
|
||||
this.scheduleRepo.find({
|
||||
where: {
|
||||
classroomId,
|
||||
status: ClassroomRentalStatus.ACTIVE,
|
||||
scheduleType: 'INTERNAL',
|
||||
// ClassSchedule.status 实体类型为 string,保留字面量
|
||||
status: 'active',
|
||||
scheduleType: ScheduleType.INTERNAL,
|
||||
startDate: LessThanOrEqual(monthEnd),
|
||||
endDate: MoreThanOrEqual(monthStart),
|
||||
},
|
||||
@@ -85,8 +100,9 @@ export class RentalScheduleService {
|
||||
const scheduleCandidates = await this.scheduleRepo
|
||||
.createQueryBuilder('cs')
|
||||
.where('cs.classroomId = :cid', { cid: classroomId })
|
||||
// ClassSchedule.status 实体类型为 string,保留字面量
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' })
|
||||
.andWhere('cs.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL })
|
||||
.andWhere('cs.startDate <= :end', { end: endDate })
|
||||
.andWhere('cs.endDate >= :start', { start: startDate })
|
||||
.getMany();
|
||||
@@ -118,25 +134,21 @@ export class RentalScheduleService {
|
||||
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
|
||||
if (overlapStart > overlapEnd) return false;
|
||||
|
||||
const startUtc = this.toUtcDate(overlapStart);
|
||||
const endUtc = this.toUtcDate(overlapEnd);
|
||||
const startWeekDay = startUtc.getUTCDay() || 7;
|
||||
const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7;
|
||||
startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence);
|
||||
return startUtc <= endUtc;
|
||||
}
|
||||
|
||||
private toUtcDate(date: string): Date {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
// YYYY-MM-DD 字典序即时间序;weekDay 取中国日历星期(1=周一 … 7=周日),
|
||||
// 统一走 common/china-time,避免本地时区 getters 造成偏移。
|
||||
const startWeekDay = getWeekDayFromDateOnly(overlapStart);
|
||||
const firstOccurrence = addDaysToDateOnly(
|
||||
overlapStart,
|
||||
(schedule.weekDay - startWeekDay + 7) % 7,
|
||||
);
|
||||
return firstOccurrence <= overlapEnd;
|
||||
}
|
||||
|
||||
private addDateRange(dates: Set<string>, startDate: string, endDate: string) {
|
||||
const current = this.toUtcDate(startDate);
|
||||
const end = this.toUtcDate(endDate);
|
||||
while (current <= end) {
|
||||
dates.add(dayjs(current).utcOffset(8).format('YYYY-MM-DD'));
|
||||
current.setUTCDate(current.getUTCDate() + 1);
|
||||
let current = startDate;
|
||||
while (current <= endDate) {
|
||||
dates.add(current);
|
||||
current = addDaysToDateOnly(current, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,13 +162,11 @@ export class RentalScheduleService {
|
||||
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
|
||||
if (overlapStart > overlapEnd) return;
|
||||
|
||||
const current = this.toUtcDate(overlapStart);
|
||||
const end = this.toUtcDate(overlapEnd);
|
||||
const startWeekDay = current.getUTCDay() || 7;
|
||||
current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7));
|
||||
while (current <= end) {
|
||||
dates.add(dayjs(current).utcOffset(8).format('YYYY-MM-DD'));
|
||||
current.setUTCDate(current.getUTCDate() + 7);
|
||||
const startWeekDay = getWeekDayFromDateOnly(overlapStart);
|
||||
let current = addDaysToDateOnly(overlapStart, (schedule.weekDay - startWeekDay + 7) % 7);
|
||||
while (current <= overlapEnd) {
|
||||
dates.add(current);
|
||||
current = addDaysToDateOnly(current, 7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +190,7 @@ export class RentalScheduleService {
|
||||
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
|
||||
.getMany();
|
||||
|
||||
// TODO: 收敛为显式类型(matrix 格子含 scheduleType/status 等字段,涉及面较大暂保持 any)
|
||||
const organizationMap = new Map<number, any>();
|
||||
const matrix: Record<number, Record<number, any>> = {};
|
||||
const summary: Record<
|
||||
@@ -193,12 +204,9 @@ export class RentalScheduleService {
|
||||
}
|
||||
|
||||
for (const rental of rentals) {
|
||||
const start = new Date(rental.startDate);
|
||||
const end = new Date(rental.endDate);
|
||||
const monthStart = new Date(first);
|
||||
const monthEnd = new Date(last);
|
||||
const effStart = start < monthStart ? monthStart : start;
|
||||
const effEnd = end > monthEnd ? monthEnd : end;
|
||||
// YYYY-MM-DD 字典序即时间序,直接字符串比较,避免本地时区解析偏移
|
||||
const effStart = rental.startDate > first ? rental.startDate : first;
|
||||
const effEnd = rental.endDate < last ? rental.endDate : last;
|
||||
if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) {
|
||||
organizationMap.set(rental.lesseeOrganization.id, {
|
||||
id: rental.lesseeOrganization.id,
|
||||
@@ -208,11 +216,13 @@ export class RentalScheduleService {
|
||||
COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length],
|
||||
});
|
||||
}
|
||||
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
|
||||
const day = d.getDate();
|
||||
if (!matrix[rental.classroomId]) continue;
|
||||
let d = effStart;
|
||||
while (d <= effEnd) {
|
||||
const day = Number(d.slice(8, 10));
|
||||
if (matrix[rental.classroomId]) {
|
||||
matrix[rental.classroomId][day] = {
|
||||
scheduleType: 'RENTAL',
|
||||
scheduleType: ScheduleType.RENTAL,
|
||||
status: rental.status,
|
||||
rentalId: rental.id,
|
||||
organizationId: rental.lesseeOrganizationId,
|
||||
organizationName: rental.lesseeOrganization?.name || '未知',
|
||||
@@ -221,6 +231,8 @@ export class RentalScheduleService {
|
||||
COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length],
|
||||
hasContract: !!rental.contractPath,
|
||||
};
|
||||
}
|
||||
d = addDaysToDateOnly(d, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,26 +241,25 @@ export class RentalScheduleService {
|
||||
.createQueryBuilder('s')
|
||||
.leftJoinAndSelect('s.class', 'class')
|
||||
.leftJoinAndSelect('s.teacher', 'teacher')
|
||||
// ClassSchedule.status 实体类型为 string,保留字面量
|
||||
.where('s.status = :active', { active: 'active' })
|
||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||
.andWhere('s.scheduleType = :type', { type: ScheduleType.INTERNAL })
|
||||
.andWhere('s.startDate <= :last AND s.endDate >= :first', { first, last })
|
||||
.getMany();
|
||||
|
||||
for (const sched of schedules) {
|
||||
if (!sched.classroomId) continue;
|
||||
const schedStart = new Date(
|
||||
Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()),
|
||||
);
|
||||
const schedEnd = new Date(
|
||||
Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()),
|
||||
);
|
||||
for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) {
|
||||
const dow = d.getDay() === 0 ? 7 : d.getDay();
|
||||
if (dow !== sched.weekDay) continue;
|
||||
const day = d.getDate();
|
||||
if (!matrix[sched.classroomId]) continue;
|
||||
const schedStart = sched.startDate > first ? sched.startDate : first;
|
||||
const schedEnd = sched.endDate < last ? sched.endDate : last;
|
||||
let d = schedStart;
|
||||
while (d <= schedEnd) {
|
||||
const dow = getWeekDayFromDateOnly(d);
|
||||
const day = Number(d.slice(8, 10));
|
||||
if (dow === sched.weekDay && matrix[sched.classroomId]) {
|
||||
matrix[sched.classroomId][day] = {
|
||||
scheduleType: 'INTERNAL',
|
||||
scheduleType: ScheduleType.INTERNAL,
|
||||
// ClassSchedule.status 实体类型为 string,保留字面量
|
||||
status: 'active',
|
||||
scheduleId: sched.id,
|
||||
className: (sched.class as { name?: string } | null)?.name || '',
|
||||
subject: sched.subject,
|
||||
@@ -257,11 +268,17 @@ export class RentalScheduleService {
|
||||
endTime: sched.endTime,
|
||||
color: '#52c41a',
|
||||
};
|
||||
}
|
||||
d = addDaysToDateOnly(d, 1);
|
||||
}
|
||||
}
|
||||
// 统计
|
||||
// 统计:只统计 RENTAL 且非 cancelled 的天数——内部排课覆盖格(INTERNAL)与
|
||||
// cancelled 行不计入租赁占用(matrix 每格带 scheduleType/status,按需过滤)。
|
||||
for (const cls of classrooms) {
|
||||
const rented = Object.keys(matrix[cls.id]).length;
|
||||
const rented = Object.values(matrix[cls.id]).filter((cell) => {
|
||||
const typed = cell as ScheduleMatrixCell | undefined;
|
||||
return typed?.scheduleType === ScheduleType.RENTAL && typed?.status !== 'cancelled';
|
||||
}).length;
|
||||
summary[cls.id].rentedDays = rented;
|
||||
summary[cls.id].idleDays = lastDay - rented;
|
||||
summary[cls.id].occupancyRate = lastDay > 0 ? Math.round((rented / lastDay) * 100) / 100 : 0;
|
||||
@@ -299,38 +316,78 @@ export class RentalScheduleService {
|
||||
|
||||
async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) {
|
||||
const name = organizationName || rental.lesseeOrganization?.name || '承租机构';
|
||||
const weekDay = this.dateToWeekDay(rental.startDate);
|
||||
let schedule = await this.scheduleRepo.findOne({
|
||||
where: { rentalId: rental.id, scheduleType: 'RENTAL' },
|
||||
});
|
||||
const data = {
|
||||
classroomId: rental.classroomId,
|
||||
classId: null,
|
||||
weekDay,
|
||||
startTime: '00:00',
|
||||
endTime: '23:59',
|
||||
startDate: rental.startDate,
|
||||
endDate: rental.endDate,
|
||||
subject: `${name} 租赁`,
|
||||
teacherId: null,
|
||||
scheduleType: 'RENTAL',
|
||||
rentalId: rental.id,
|
||||
status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active',
|
||||
notes: rental.notes,
|
||||
};
|
||||
if (schedule) {
|
||||
await this.scheduleRepo.update(schedule.id, data);
|
||||
} else {
|
||||
schedule = this.scheduleRepo.create(data);
|
||||
await this.scheduleRepo.save(schedule);
|
||||
// 已结束的租赁(状态为 ENDED,或 ACTIVE 但 endDate 早于今天)与已取消一致,
|
||||
// 不再写入/更新为 active 排课,统一映射为 cancelled。
|
||||
const today = dayjs().utcOffset(8).format('YYYY-MM-DD');
|
||||
const isInactive =
|
||||
rental.status === ClassroomRentalStatus.CANCELLED ||
|
||||
rental.status === ClassroomRentalStatus.ENDED ||
|
||||
(rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today);
|
||||
|
||||
// 多日租赁:按 startDate..endDate 覆盖的每一天(用 addDaysToDateOnly 迭代)展开成周几,
|
||||
// 每个工作日对应一条 RENTAL 排课,weekDay 取当天星期(1=周一 … 7=周日),
|
||||
// 与 getSchedule/findConflicts 按 weekDay 匹配的语义一致。
|
||||
const coveredWeekDays = new Set<number>();
|
||||
for (
|
||||
let day = rental.startDate;
|
||||
day <= rental.endDate;
|
||||
day = addDaysToDateOnly(day, 1)
|
||||
) {
|
||||
coveredWeekDays.add(this.dateToWeekDay(day));
|
||||
}
|
||||
|
||||
// read-then-multiple-write(cancel 旧行 / update 覆盖行 / insert 新行)放进同一事务,
|
||||
// 任一写入失败则整体回滚,避免残留半同步状态。
|
||||
await this.scheduleRepo.manager.transaction(async (manager) => {
|
||||
// 事务内使用 manager.getRepository 获得绑定到该事务的排课仓库
|
||||
const scheduleRepo = manager.getRepository(ClassSchedule);
|
||||
const existingSchedules = await scheduleRepo.find({
|
||||
where: { rentalId: rental.id, scheduleType: ScheduleType.RENTAL },
|
||||
});
|
||||
const existingByWeekDay = new Map<number, ClassSchedule>();
|
||||
for (const schedule of existingSchedules) {
|
||||
existingByWeekDay.set(schedule.weekDay, schedule);
|
||||
}
|
||||
|
||||
// 租赁缩短/换教室后不再覆盖的周几,旧排课行统一映射为 cancelled,避免残留旧占位
|
||||
// (ClassSchedule.status 实体类型为 string,保留 'cancelled' 字面量)
|
||||
for (const schedule of existingSchedules) {
|
||||
if (!coveredWeekDays.has(schedule.weekDay) && schedule.status !== 'cancelled') {
|
||||
await scheduleRepo.update(schedule.id, { status: 'cancelled' });
|
||||
}
|
||||
}
|
||||
|
||||
for (const weekDay of coveredWeekDays) {
|
||||
const data = {
|
||||
classroomId: rental.classroomId,
|
||||
classId: null,
|
||||
weekDay,
|
||||
startTime: '00:00',
|
||||
endTime: '23:59',
|
||||
startDate: rental.startDate,
|
||||
endDate: rental.endDate,
|
||||
subject: `${name} 租赁`,
|
||||
teacherId: null,
|
||||
scheduleType: ScheduleType.RENTAL,
|
||||
rentalId: rental.id,
|
||||
// ClassSchedule.status 实体类型为 string,保留 'active'/'cancelled' 字面量
|
||||
status: isInactive ? 'cancelled' : 'active',
|
||||
notes: rental.notes,
|
||||
};
|
||||
const schedule = existingByWeekDay.get(weekDay);
|
||||
if (schedule) {
|
||||
await scheduleRepo.update(schedule.id, data);
|
||||
} else {
|
||||
const created = scheduleRepo.create(data);
|
||||
await scheduleRepo.save(created);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
dateToWeekDay(date: string): number {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
return day === 0 ? 7 : day;
|
||||
return getWeekDayFromDateOnly(date);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
38
apps/server/src/common/china-time.spec.ts
Normal file
38
apps/server/src/common/china-time.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
CHINA_UTC_OFFSET,
|
||||
addDaysToDateOnly,
|
||||
getWeekDayFromDateOnly,
|
||||
isConsecutiveDates,
|
||||
parseChinaDateOnly,
|
||||
} from './china-time';
|
||||
|
||||
describe('china-time', () => {
|
||||
it('exports UTC+8 offset', () => {
|
||||
expect(CHINA_UTC_OFFSET).toBe(8);
|
||||
});
|
||||
|
||||
it('addDaysToDateOnly crosses month/year boundaries', () => {
|
||||
expect(addDaysToDateOnly('2026-02-28', 1)).toBe('2026-03-01');
|
||||
expect(addDaysToDateOnly('2026-12-31', 1)).toBe('2027-01-01');
|
||||
expect(addDaysToDateOnly('2026-01-01', -1)).toBe('2025-12-31');
|
||||
expect(addDaysToDateOnly('2026-08-09', 7)).toBe('2026-08-16');
|
||||
});
|
||||
|
||||
it('getWeekDayFromDateOnly is timezone independent', () => {
|
||||
// 2026-08-09 是周日 → 7
|
||||
expect(getWeekDayFromDateOnly('2026-08-09')).toBe(7);
|
||||
// 2026-08-10 是周一 → 1
|
||||
expect(getWeekDayFromDateOnly('2026-08-10')).toBe(1);
|
||||
});
|
||||
|
||||
it('parseChinaDateOnly anchors to UTC+8 midnight', () => {
|
||||
const d = parseChinaDateOnly('2026-08-09');
|
||||
expect(d.toISOString()).toBe('2026-08-08T16:00:00.000Z');
|
||||
});
|
||||
|
||||
it('isConsecutiveDates detects adjacent dates', () => {
|
||||
expect(isConsecutiveDates('2026-08-09', '2026-08-10')).toBe(true);
|
||||
expect(isConsecutiveDates('2026-08-09', '2026-08-11')).toBe(false);
|
||||
expect(isConsecutiveDates('2026-08-09', '2026-08-09')).toBe(false);
|
||||
});
|
||||
});
|
||||
39
apps/server/src/common/china-time.ts
Normal file
39
apps/server/src/common/china-time.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import dayjs from './dayjs';
|
||||
|
||||
/**
|
||||
* 中国时区(Asia/Shanghai)常量与日期工具。
|
||||
* 服务端/生产统一按 UTC+8 处理「业务日期」,避免与服务器本地时区混用导致 off-by-one。
|
||||
*/
|
||||
export const CHINA_UTC_OFFSET = 8;
|
||||
|
||||
/** 当前时刻的中国日期(YYYY-MM-DD)。 */
|
||||
export function getChinaDate(date: Date = new Date()): string {
|
||||
return dayjs(date).utcOffset(CHINA_UTC_OFFSET).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
/** 把 'YYYY-MM-DD' 按 UTC+8 午夜解析为 Date(用于需要绝对时刻的场合)。 */
|
||||
export function parseChinaDateOnly(dateOnly: string): Date {
|
||||
return new Date(`${dateOnly}T00:00:00+08:00`);
|
||||
}
|
||||
|
||||
/** 纯日期字符串加减天数,避免本地时区 getters 造成的偏移。 */
|
||||
export function addDaysToDateOnly(dateOnly: string, days: number): string {
|
||||
const [y, m, d] = dateOnly.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d + days));
|
||||
const yy = dt.getUTCFullYear();
|
||||
const mm = String(dt.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(dt.getUTCDate()).padStart(2, '0');
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/** 返回 dateOnly 在中国日历下的星期(1=周一 … 7=周日),与本地时区无关。 */
|
||||
export function getWeekDayFromDateOnly(dateOnly: string): number {
|
||||
const [y, m, d] = dateOnly.split('-').map(Number);
|
||||
const day = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
|
||||
return day === 0 ? 7 : day;
|
||||
}
|
||||
|
||||
/** 两个 YYYY-MM-DD 是否按中国日历相邻(差 1 天)。 */
|
||||
export function isConsecutiveDates(a: string, b: string): boolean {
|
||||
return addDaysToDateOnly(a, 1) === b || addDaysToDateOnly(b, 1) === a;
|
||||
}
|
||||
@@ -13,7 +13,11 @@ describe('DepositsService.purge', () => {
|
||||
...overrides?.deposit,
|
||||
};
|
||||
const repo = {
|
||||
findOne: jest.fn().mockResolvedValue(deposit),
|
||||
// purge 现在按 id + 已归档状态查询;无状态条件时(fallback)返回记录本体
|
||||
findOne: jest.fn(async ({ where }: { where: { id?: number; status?: string } }) => {
|
||||
if (where?.status === 'archived') return deposit.status === 'archived' ? deposit : null;
|
||||
return deposit;
|
||||
}),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
const installmentRepo = { count: jest.fn().mockResolvedValue(0) };
|
||||
@@ -55,11 +59,18 @@ describe('DepositsService.purge', () => {
|
||||
expect(repo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not delete when the deposit is no longer archived by delete time', async () => {
|
||||
const { service, repo } = createService();
|
||||
repo.delete.mockResolvedValue({ affected: 0 });
|
||||
await expect(service.purge(1)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.delete).toHaveBeenCalledWith({ id: 1, status: 'archived' });
|
||||
});
|
||||
|
||||
it('deletes an archived deposit with no paid history', async () => {
|
||||
const { service, repo } = createService();
|
||||
await expect(service.purge(1)).resolves.toEqual({
|
||||
message: '已永久删除押金(不可恢复)',
|
||||
});
|
||||
expect(repo.delete).toHaveBeenCalledWith(1);
|
||||
expect(repo.delete).toHaveBeenCalledWith({ id: 1, status: 'archived' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
|
||||
import { BatchCreateDepositDto, CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||
import { escapeLike } from '../common/like-escape';
|
||||
|
||||
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
|
||||
|
||||
@@ -195,7 +196,7 @@ export class DepositsService {
|
||||
if (query?.keyword) {
|
||||
qb.andWhere(
|
||||
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
|
||||
{ keyword: `%${query.keyword}%` },
|
||||
{ keyword: `%${escapeLike(query.keyword)}%` },
|
||||
);
|
||||
}
|
||||
if (query?.status && query.status !== 'archived') {
|
||||
@@ -236,7 +237,10 @@ export class DepositsService {
|
||||
}
|
||||
if (amount <= 0) throw new BadRequestException('收取金额必须大于0');
|
||||
|
||||
const existing = await this.repo.findOne({ where: { studentId: dto.studentId } });
|
||||
// 只匹配未归档押金:避免给学生新建押金时错误地复活/修改已归档记录
|
||||
const existing = await this.repo.findOne({
|
||||
where: { studentId: dto.studentId, status: Not('archived') },
|
||||
});
|
||||
if (existing) {
|
||||
existing.amount = money(Number(existing.amount || 0) + amount);
|
||||
existing.paidDate = dto.paidDate;
|
||||
@@ -326,10 +330,12 @@ export class DepositsService {
|
||||
}
|
||||
|
||||
async purge(id: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
if (deposit.status !== 'archived') {
|
||||
throw new BadRequestException('仅已归档押金可以永久删除,请先归档');
|
||||
// 明确按已归档状态查询:避免 findOne 匹配到被 create() 复活/修改的新押金记录
|
||||
const deposit = await this.repo.findOne({ where: { id, status: 'archived' } });
|
||||
if (!deposit) {
|
||||
const existing = await this.repo.findOne({ where: { id } });
|
||||
if (existing) throw new BadRequestException('仅已归档押金可以永久删除,请先归档');
|
||||
throw new NotFoundException('押金记录不存在');
|
||||
}
|
||||
if (Number(deposit.refundAmount || 0) > 0) {
|
||||
throw new BadRequestException('该押金已有退款金额,无法永久删除');
|
||||
@@ -343,7 +349,11 @@ export class DepositsService {
|
||||
if (paidInstallments > 0) {
|
||||
throw new BadRequestException('该押金存在已支付分期,无法永久删除');
|
||||
}
|
||||
await this.repo.delete(id);
|
||||
// 条件删除:即使读取后状态被并发修改(例如被 create() 复活),也只删除仍处于已归档的记录
|
||||
const result = await this.repo.delete({ id, status: 'archived' });
|
||||
if (!result.affected) {
|
||||
throw new BadRequestException('押金状态已变化,请刷新后重试');
|
||||
}
|
||||
return { message: '已永久删除押金(不可恢复)' };
|
||||
}
|
||||
|
||||
|
||||
@@ -24,22 +24,57 @@ export class Bill {
|
||||
@Column({ name: 'period_end', type: 'date' })
|
||||
periodEnd: string;
|
||||
|
||||
@Column({ name: 'shared_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
@Column({
|
||||
name: 'shared_amount',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) },
|
||||
})
|
||||
sharedAmount: number;
|
||||
|
||||
@Column({ name: 'personal_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
@Column({
|
||||
name: 'personal_amount',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) },
|
||||
})
|
||||
personalAmount: number;
|
||||
|
||||
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
@Column({
|
||||
name: 'total_amount',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) },
|
||||
})
|
||||
totalAmount: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 30, default: 'batch' })
|
||||
source: 'batch' | 'student_utility';
|
||||
|
||||
@Column({ name: 'paid_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
@Column({
|
||||
name: 'paid_amount',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) },
|
||||
})
|
||||
paidAmount: number;
|
||||
|
||||
@Column({ name: 'outstanding_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
@Column({
|
||||
name: 'outstanding_amount',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) },
|
||||
})
|
||||
outstandingAmount: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'unpaid' })
|
||||
|
||||
@@ -26,7 +26,7 @@ export class ClassStudent {
|
||||
@Column({ name: 'student_id', type: 'integer' })
|
||||
studentId: number;
|
||||
|
||||
@ManyToOne(() => Student)
|
||||
@ManyToOne(() => Student, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
|
||||
@@ -57,11 +57,11 @@ export class DingAttendanceRaw {
|
||||
matchStatus: string;
|
||||
|
||||
@Column({ name: 'matched_student_id', type: 'integer', nullable: true })
|
||||
matchedStudentId: number;
|
||||
matchedStudentId: number | null;
|
||||
|
||||
@ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'matched_student_id' })
|
||||
matchedStudent: Student;
|
||||
matchedStudent: Student | null;
|
||||
|
||||
@Column({ name: 'raw_data', type: 'text', nullable: true })
|
||||
rawData: string;
|
||||
|
||||
@@ -54,11 +54,11 @@ export class DingLeaveRaw {
|
||||
matchStatus: string;
|
||||
|
||||
@Column({ name: 'matched_student_id', type: 'integer', nullable: true })
|
||||
matchedStudentId: number;
|
||||
matchedStudentId: number | null;
|
||||
|
||||
@ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'matched_student_id' })
|
||||
matchedStudent: Student;
|
||||
matchedStudent: Student | null;
|
||||
|
||||
@Column({ name: 'raw_data', type: 'text', nullable: true })
|
||||
rawData: string;
|
||||
|
||||
@@ -4,10 +4,7 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
|
||||
@Entity('learning_records')
|
||||
export class LearningRecord {
|
||||
@@ -17,10 +14,6 @@ export class LearningRecord {
|
||||
@Column({ name: 'student_id', type: 'integer' })
|
||||
studentId: number;
|
||||
|
||||
@ManyToOne(() => Student, { eager: true })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@Column({ name: 'record_date', type: 'date', nullable: true })
|
||||
recordDate: string;
|
||||
|
||||
|
||||
@@ -4,10 +4,7 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
|
||||
@Entity('student_profiles')
|
||||
export class StudentProfile {
|
||||
@@ -17,10 +14,6 @@ export class StudentProfile {
|
||||
@Column({ name: 'student_id', type: 'integer', unique: true })
|
||||
studentId: number;
|
||||
|
||||
@ManyToOne(() => Student, { eager: true })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@Column({ name: 'target_college', length: 100, nullable: true })
|
||||
targetCollege: string;
|
||||
|
||||
|
||||
@@ -17,7 +17,13 @@ export class StudentWallet {
|
||||
@Column({ name: 'student_id', type: 'integer', unique: true })
|
||||
studentId: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
@Column({
|
||||
type: 'decimal',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) },
|
||||
})
|
||||
balance: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ExamsService } from './exams.service';
|
||||
import { Exam } from '../entities';
|
||||
|
||||
describe('ExamsService.purge', () => {
|
||||
const createService = (overrides?: { exam?: Record<string, unknown> }) => {
|
||||
@@ -10,13 +11,23 @@ describe('ExamsService.purge', () => {
|
||||
find: jest.fn().mockResolvedValue([exam]),
|
||||
};
|
||||
const classTeacherRepo = { findOne: jest.fn().mockResolvedValue({}) };
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (cb: (manager: any) => unknown) =>
|
||||
cb({
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Exam) return examRepo;
|
||||
throw new Error(`Unexpected repository: ${String(entity)}`);
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const service = new ExamsService(
|
||||
examRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
classTeacherRepo as never,
|
||||
{} as never,
|
||||
dataSource as never,
|
||||
);
|
||||
return { service, examRepo, classTeacherRepo };
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, ForbiddenException, NotFoundException, ValidationPipe } from '@nestjs/common';
|
||||
import { ExamScore } from '../entities';
|
||||
import { Exam, ExamScore } from '../entities';
|
||||
import { QueryExamDto } from './dto/exam.dto';
|
||||
import { ExamsService } from './exams.service';
|
||||
|
||||
@@ -36,6 +36,16 @@ function updateQb(affected = 1) {
|
||||
return qb;
|
||||
}
|
||||
|
||||
function runWithExamRepo(examRepo: Record<string, jest.Mock>) {
|
||||
return async (run: (manager: any) => Promise<unknown>) =>
|
||||
run({
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Exam) return examRepo;
|
||||
throw new Error(`Unexpected repository: ${String(entity)}`);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe('ExamsService', () => {
|
||||
it('creates score rows from the active class roster snapshot', async () => {
|
||||
const members = [
|
||||
@@ -260,7 +270,7 @@ describe('ExamsService', () => {
|
||||
createQueryBuilder: jest.fn(() => qb),
|
||||
};
|
||||
const scoreRepo = { update: jest.fn(), save: jest.fn() };
|
||||
const service = createService(async () => undefined, { examRepo, scoreRepo });
|
||||
const service = createService(runWithExamRepo(examRepo), { examRepo, scoreRepo });
|
||||
|
||||
await expect(service.batchArchive([8, 8, 9], 1, true)).resolves.toEqual({
|
||||
message: '已批量归档 1 场考试',
|
||||
@@ -283,7 +293,7 @@ describe('ExamsService', () => {
|
||||
]),
|
||||
createQueryBuilder: jest.fn(() => qb),
|
||||
};
|
||||
const service = createService(async () => undefined, { examRepo });
|
||||
const service = createService(runWithExamRepo(examRepo), { examRepo });
|
||||
|
||||
await expect(service.batchRestore([8, 9], 1, true)).resolves.toEqual({
|
||||
message: '已批量恢复 1 场考试',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In, Like, Repository } from 'typeorm';
|
||||
import { Class, ClassStudent, ClassTeacher, Exam, ExamScore } from '../entities';
|
||||
import { CreateExamDto, QueryExamDto } from './dto/exam.dto';
|
||||
import { escapeLike } from '../common/like-escape';
|
||||
|
||||
@Injectable()
|
||||
export class ExamsService {
|
||||
@@ -31,7 +32,7 @@ export class ExamsService {
|
||||
const where: Record<string, unknown> = {
|
||||
status: query.isArchived ? 'archived' : 'active',
|
||||
};
|
||||
if (query.keyword) where.examName = Like(`%${query.keyword}%`);
|
||||
if (query.keyword) where.examName = Like(`%${escapeLike(query.keyword)}%`);
|
||||
if (query.examType) where.examType = query.examType;
|
||||
if (query.classId) where.classId = query.classId;
|
||||
if (accessibleClassIds) {
|
||||
@@ -205,27 +206,33 @@ export class ExamsService {
|
||||
|
||||
async batchPurge(ids: number[], userId: number, canManageAll: boolean) {
|
||||
const exams = await this.findBatchExams(ids, userId, canManageAll, '永久删除');
|
||||
const deleted: number[] = [];
|
||||
const skipped: string[] = [];
|
||||
for (const exam of exams) {
|
||||
if (exam.status !== 'archived') {
|
||||
skipped.push(`${exam.examName}(未归档)`);
|
||||
continue;
|
||||
// 循环内逐条删除放进同一事务:任一删除失败则整批回滚(全有或全无)。
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const examRepo = manager.getRepository(Exam);
|
||||
const deleted: number[] = [];
|
||||
const skipped: string[] = [];
|
||||
for (const exam of exams) {
|
||||
if (exam.status !== 'archived') {
|
||||
skipped.push(`${exam.examName}(未归档)`);
|
||||
continue;
|
||||
}
|
||||
await examRepo.delete(exam.id);
|
||||
deleted.push(exam.id);
|
||||
}
|
||||
await this.examRepo.delete(exam.id);
|
||||
deleted.push(exam.id);
|
||||
}
|
||||
const message =
|
||||
skipped.length > 0
|
||||
? `已永久删除 ${deleted.length} 场考试;${skipped.length} 场被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})`
|
||||
: `已永久删除 ${deleted.length} 场考试(不可恢复)`;
|
||||
return { message, deleted: deleted.length, skipped: skipped.length };
|
||||
const message =
|
||||
skipped.length > 0
|
||||
? `已永久删除 ${deleted.length} 场考试;${skipped.length} 场被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})`
|
||||
: `已永久删除 ${deleted.length} 场考试(不可恢复)`;
|
||||
return { message, deleted: deleted.length, skipped: skipped.length };
|
||||
});
|
||||
}
|
||||
|
||||
async batchArchive(ids: number[], userId: number, canManageAll: boolean) {
|
||||
const exams = await this.findBatchExams(ids, userId, canManageAll, '归档');
|
||||
const targetIds = exams.filter((exam) => exam.status === 'active').map((exam) => exam.id);
|
||||
const archived = await this.updateBatchStatus(targetIds, 'archived');
|
||||
const archived = await this.dataSource.transaction(async (manager) =>
|
||||
this.updateBatchStatus(manager, targetIds, 'archived'),
|
||||
);
|
||||
return {
|
||||
message: `已批量归档 ${archived} 场考试`,
|
||||
archived,
|
||||
@@ -236,7 +243,9 @@ export class ExamsService {
|
||||
async batchRestore(ids: number[], userId: number, canManageAll: boolean) {
|
||||
const exams = await this.findBatchExams(ids, userId, canManageAll, '恢复');
|
||||
const targetIds = exams.filter((exam) => exam.status === 'archived').map((exam) => exam.id);
|
||||
const restored = await this.updateBatchStatus(targetIds, 'active');
|
||||
const restored = await this.dataSource.transaction(async (manager) =>
|
||||
this.updateBatchStatus(manager, targetIds, 'active'),
|
||||
);
|
||||
return {
|
||||
message: `已批量恢复 ${restored} 场考试`,
|
||||
restored,
|
||||
@@ -263,9 +272,14 @@ export class ExamsService {
|
||||
return exams;
|
||||
}
|
||||
|
||||
private async updateBatchStatus(ids: number[], status: 'active' | 'archived') {
|
||||
private async updateBatchStatus(
|
||||
manager: EntityManager,
|
||||
ids: number[],
|
||||
status: 'active' | 'archived',
|
||||
) {
|
||||
if (ids.length === 0) return 0;
|
||||
const result = await this.examRepo
|
||||
const result = await manager
|
||||
.getRepository(Exam)
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ status })
|
||||
|
||||
@@ -53,7 +53,11 @@ export class ExpenseTypesService {
|
||||
|
||||
async create(dto: CreateExpenseTypeDto): Promise<ExpenseType> {
|
||||
const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() };
|
||||
const exists = await this.repo.findOne({ where: { code: normalized.code } });
|
||||
// 注意:ExpenseType.code 在数据库有唯一索引(全表唯一),唯一性检查必须与之一致,
|
||||
// 否则软删行会让 DB 层抛重复键错误。软删后重建同名类型需另行放开唯一索引(需迁移)。
|
||||
const exists = await this.repo.findOne({
|
||||
where: { code: normalized.code },
|
||||
});
|
||||
if (exists) throw new ConflictException('费用类型代码已存在');
|
||||
return this.repo.save(this.repo.create(normalized));
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export class FinancialOperationsService {
|
||||
const existing = await this.repo.findOne({ where: { operationId } });
|
||||
if (existing) {
|
||||
if (existing.type !== type) throw new ConflictException('operationId 已用于其他操作');
|
||||
if (existing.status === 'completed' && existing.resultJson) return JSON.parse(existing.resultJson) as T;
|
||||
if (existing.status === 'completed' && existing.resultJson) return this.parseResult(existing.resultJson);
|
||||
if (existing.status === 'running') throw new ConflictException('该操作正在处理中,请勿重复提交');
|
||||
}
|
||||
|
||||
@@ -28,21 +28,38 @@ export class FinancialOperationsService {
|
||||
} catch (error) {
|
||||
const concurrent = await this.repo.findOne({ where: { operationId } });
|
||||
if (concurrent?.status === 'completed' && concurrent.resultJson) {
|
||||
return JSON.parse(concurrent.resultJson) as T;
|
||||
return this.parseResult(concurrent.resultJson);
|
||||
}
|
||||
throw new ConflictException('该操作正在处理中,请勿重复提交', { cause: error });
|
||||
}
|
||||
} else {
|
||||
// 失败重试认领:条件状态更新,affected=1 才算认领成功,
|
||||
// 避免并发下多个请求同时把 failed 改成 running 后重复执行 work()
|
||||
const claim = await this.repo
|
||||
.createQueryBuilder()
|
||||
.update(FinancialOperation)
|
||||
.set({ status: 'running', errorMessage: null, resultJson: null })
|
||||
.where('id = :id', { id: operation.id })
|
||||
.andWhere(
|
||||
"(status IN ('failed') OR (status = 'completed' AND resultJson IS NULL) OR (status = 'running' AND updatedAt < DATE_SUB(NOW(), INTERVAL 15 MINUTE)))",
|
||||
)
|
||||
.execute();
|
||||
if (claim.affected !== 1) {
|
||||
const concurrent = await this.repo.findOne({ where: { operationId } });
|
||||
if (concurrent?.status === 'completed' && concurrent.resultJson) {
|
||||
return this.parseResult(concurrent.resultJson);
|
||||
}
|
||||
throw new ConflictException('该操作正在处理中,请勿重复提交');
|
||||
}
|
||||
operation.status = 'running';
|
||||
operation.errorMessage = null;
|
||||
operation.resultJson = null;
|
||||
await this.repo.save(operation);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await work();
|
||||
operation.status = 'completed';
|
||||
operation.resultJson = JSON.stringify(result);
|
||||
operation.resultJson = JSON.stringify(result ?? null);
|
||||
await this.repo.save(operation);
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -52,4 +69,13 @@ export class FinancialOperationsService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析缓存的结果 JSON,损坏数据按失败处理而不是抛 500。 */
|
||||
private parseResult<T>(resultJson: string | null | undefined): T {
|
||||
try {
|
||||
return JSON.parse(resultJson ?? 'null') as T;
|
||||
} catch {
|
||||
throw new ConflictException('该操作的结果数据已损坏,请重试');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,10 +99,28 @@ export class ImportCommitService {
|
||||
}
|
||||
}
|
||||
|
||||
run.status = 'committing';
|
||||
step.status = 'committing';
|
||||
await this.runs.save(run);
|
||||
await this.steps.save(step);
|
||||
// 原子抢占:只有 run/step 仍处于可提交状态时才迁移到 committing,
|
||||
// 避免两个并发请求同时通过校验并重复写入(affected=0 表示已被他人抢占)。
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const runClaim = await manager.update(
|
||||
ImportRun,
|
||||
{ id: run.id, status: In(['ready', 'preparing']) },
|
||||
{ status: 'committing' },
|
||||
);
|
||||
if ((runClaim.affected ?? 0) !== 1) {
|
||||
throw new ConflictException('导入任务正在提交中或状态已变化,请刷新后重试');
|
||||
}
|
||||
const stepClaim = await manager.update(
|
||||
ImportStep,
|
||||
{ id: step.id, status: 'ready' },
|
||||
{ status: 'committing' },
|
||||
);
|
||||
if ((stepClaim.affected ?? 0) !== 1) {
|
||||
throw new ConflictException(
|
||||
`阶段「${IMPORT_STEP_LABELS[stepKey]}」正在提交中或状态已变化,请刷新后重试`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const counts = { created: 0, updated: 0, skipped: 0, failed: 0 };
|
||||
try {
|
||||
|
||||
@@ -80,7 +80,6 @@ export class ImportPreviewService {
|
||||
suggestMapping(sheetsData[0]?.headers ?? [], stepKey));
|
||||
assertMapping(stepKey, mapping, sheetsData, usedSheets);
|
||||
|
||||
await this.rows.delete({ stepId: step.id });
|
||||
const rowEntities: ImportRow[] = [];
|
||||
const summary: StepPreviewSummary = {
|
||||
total: 0,
|
||||
@@ -153,12 +152,17 @@ export class ImportPreviewService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.rows.save(rowEntities);
|
||||
step.sheetsJson = JSON.stringify(usedSheets);
|
||||
step.mappingJson = JSON.stringify(mapping);
|
||||
step.status = 'ready';
|
||||
step.summaryJson = JSON.stringify(summary);
|
||||
await this.steps.save(step);
|
||||
// 同一批次的写入(清空旧预览行、写新预览行、更新阶段状态)放进同一事务,
|
||||
// 避免预览过程中失败留下半成品数据。
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.delete(ImportRow, { stepId: step.id });
|
||||
await manager.save(ImportRow, rowEntities);
|
||||
step.sheetsJson = JSON.stringify(usedSheets);
|
||||
step.mappingJson = JSON.stringify(mapping);
|
||||
step.status = 'ready';
|
||||
step.summaryJson = JSON.stringify(summary);
|
||||
await manager.save(ImportStep, step);
|
||||
});
|
||||
|
||||
const headers = sheetsData.find((s) => s.name === usedSheets[0])?.headers ?? [];
|
||||
const rows = rowEntities.map((entity) => ({
|
||||
|
||||
@@ -256,6 +256,15 @@ describe('ImportsService', () => {
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
|
||||
cb({
|
||||
delete: rowsRepo.delete,
|
||||
save: jest.fn(async (...args: unknown[]) => {
|
||||
const value = args.length >= 2 ? args[1] : args[0];
|
||||
return rowsRepo.save(value as never);
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
@@ -316,6 +325,15 @@ describe('ImportsService', () => {
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
|
||||
cb({
|
||||
delete: rowsRepo.delete,
|
||||
save: jest.fn(async (...args: unknown[]) => {
|
||||
const value = args.length >= 2 ? args[1] : args[0];
|
||||
return rowsRepo.save(value as never);
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
@@ -495,6 +513,15 @@ describe('ImportsService', () => {
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
|
||||
cb({
|
||||
delete: rowsRepo.delete,
|
||||
save: jest.fn(async (...args: unknown[]) => {
|
||||
const value = args.length >= 2 ? args[1] : args[0];
|
||||
return rowsRepo.save(value as never);
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
@@ -559,6 +586,15 @@ describe('ImportsService', () => {
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
|
||||
cb({
|
||||
delete: rowsRepo.delete,
|
||||
save: jest.fn(async (...args: unknown[]) => {
|
||||
const value = args.length >= 2 ? args[1] : args[0];
|
||||
return rowsRepo.save(value as never);
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
@@ -635,6 +671,15 @@ describe('ImportsService', () => {
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
|
||||
cb({
|
||||
delete: rowsRepo.delete,
|
||||
save: jest.fn(async (...args: unknown[]) => {
|
||||
const value = args.length >= 2 ? args[1] : args[0];
|
||||
return rowsRepo.save(value as never);
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
@@ -696,6 +741,15 @@ describe('ImportsService', () => {
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
|
||||
cb({
|
||||
delete: rowsRepo.delete,
|
||||
save: jest.fn(async (...args: unknown[]) => {
|
||||
const value = args.length >= 2 ? args[1] : args[0];
|
||||
return rowsRepo.save(value as never);
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
@@ -767,6 +821,15 @@ describe('ImportsService', () => {
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
|
||||
cb({
|
||||
delete: rowsRepo.delete,
|
||||
save: jest.fn(async (...args: unknown[]) => {
|
||||
const value = args.length >= 2 ? args[1] : args[0];
|
||||
return rowsRepo.save(value as never);
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
@@ -838,6 +901,15 @@ describe('ImportsService', () => {
|
||||
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
transaction: jest.fn(async (cb: (manager: Record<string, unknown>) => unknown) =>
|
||||
cb({
|
||||
delete: rowsRepo.delete,
|
||||
save: jest.fn(async (...args: unknown[]) => {
|
||||
const value = args.length >= 2 ? args[1] : args[0];
|
||||
return rowsRepo.save(value as never);
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const service = new ImportsService(
|
||||
makeRunsRepo(run) as never,
|
||||
|
||||
@@ -54,6 +54,7 @@ function createCheckInManager(options?: {
|
||||
update: jest.fn(),
|
||||
};
|
||||
const queryResults = [
|
||||
options?.student ?? { id: 3, organizationId: 7 },
|
||||
options?.existingOccupancy ?? null,
|
||||
options?.room ?? { id: 2, capacity: 4, status: 'available' },
|
||||
...(options?.bed !== undefined ? [options.bed] : []),
|
||||
@@ -99,6 +100,7 @@ function createCheckOutManager(occupancy: Occupancy | null) {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(createQueryBuilderMock(occupancy)),
|
||||
save: jest.fn(async (value) => value),
|
||||
update: jest.fn(),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
return manager;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,16 @@ export class OccupanciesService {
|
||||
async checkIn(dto: CheckInDto, userId?: number) {
|
||||
this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期');
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
// 先锁学生行:对「不存在的入住记录」做 SELECT ... FOR UPDATE 无法防止
|
||||
// 两个并发入住同时通过检查后插入重复记录;锁学生行可让同一学生的
|
||||
// 并发入住串行化,后到者在拿到锁后能看到先到者已提交的入住记录。
|
||||
const student = await this.withPessimisticWriteLock(
|
||||
manager
|
||||
.createQueryBuilder(Student, 'student')
|
||||
.where('student.id = :studentId', { studentId: dto.studentId }),
|
||||
).getOne();
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
const existing = await this.withPessimisticWriteLock(
|
||||
manager
|
||||
.createQueryBuilder(Occupancy, 'occupancy')
|
||||
@@ -91,9 +101,6 @@ export class OccupanciesService {
|
||||
where: { roomId: dto.roomId, checkOutDate: IsNull() },
|
||||
});
|
||||
if (count >= (room.capacity ?? 0)) throw new BadRequestException('宿舍已满');
|
||||
const student = await manager.findOne(Student, { where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
if (dto.bedId) {
|
||||
const bed = await this.withPessimisticWriteLock(
|
||||
manager.createQueryBuilder(Bed, 'bed').where('bed.id = :bedId AND bed.roomId = :roomId', {
|
||||
|
||||
@@ -75,7 +75,14 @@ export class OccupancyOperationsService {
|
||||
await manager.save(occ);
|
||||
if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' });
|
||||
if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' });
|
||||
await manager.update(Room, occ.roomId, { status: 'available' });
|
||||
// 多床位房间不能因为单条退宿就置为 available:
|
||||
// 只有该房间不再有任何在住记录时才更新房间状态。
|
||||
const remainingActive = await manager.count(Occupancy, {
|
||||
where: { roomId: occ.roomId, checkOutDate: IsNull() },
|
||||
});
|
||||
if (remainingActive === 0) {
|
||||
await manager.update(Room, occ.roomId, { status: 'available' });
|
||||
}
|
||||
return occ;
|
||||
});
|
||||
}
|
||||
@@ -113,7 +120,14 @@ export class OccupancyOperationsService {
|
||||
if (oldOcc.lockerId) {
|
||||
await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' });
|
||||
}
|
||||
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
|
||||
// 多床位房间不能因为单次换房就置为 available:
|
||||
// 只有原房间不再有任何在住记录时才更新房间状态。
|
||||
const oldRoomRemaining = await runner.manager.count(Occupancy, {
|
||||
where: { roomId: oldOcc.roomId, checkOutDate: IsNull() },
|
||||
});
|
||||
if (oldRoomRemaining === 0) {
|
||||
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
|
||||
}
|
||||
// 检查新房容量
|
||||
const newRoom = await withPessimisticWriteLock(
|
||||
runner.manager
|
||||
@@ -365,12 +379,18 @@ export class OccupancyOperationsService {
|
||||
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
|
||||
occ.checkOutReason = dto.checkOutReason || '';
|
||||
await runner.manager.save(occ);
|
||||
// 更新房间状态
|
||||
await runner.manager.update(Room, occ.roomId, { status: 'available' });
|
||||
// 释放床位/柜子
|
||||
if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' });
|
||||
if (occ.lockerId)
|
||||
await runner.manager.update(Locker, occ.lockerId, { status: 'available' });
|
||||
// 多床位房间不能因为单条退宿就置为 available:
|
||||
// 只有该房间不再有任何在住记录时才更新房间状态。
|
||||
const remainingActive = await runner.manager.count(Occupancy, {
|
||||
where: { roomId: occ.roomId, checkOutDate: IsNull() },
|
||||
});
|
||||
if (remainingActive === 0) {
|
||||
await runner.manager.update(Room, occ.roomId, { status: 'available' });
|
||||
}
|
||||
success++;
|
||||
}
|
||||
await runner.commitTransaction();
|
||||
|
||||
@@ -12,6 +12,9 @@ export const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: stri
|
||||
{ code: 'student:import', name: '导入学生', group: 'student' },
|
||||
{ code: 'student:export', name: '导出学生', group: 'student' },
|
||||
{ code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' },
|
||||
{ code: 'exam:create', name: '创建考试', group: 'exam' },
|
||||
{ code: 'exam:edit', name: '编辑/归档考试与录入成绩', group: 'exam' },
|
||||
{ code: 'exam:delete', name: '删除考试', group: 'exam' },
|
||||
{ code: 'room:view', name: '查看宿舍', group: 'room' },
|
||||
{ code: 'room:inspect', name: '宿舍查寝', group: 'room' },
|
||||
{ code: 'room:create', name: '新增宿舍', group: 'room' },
|
||||
@@ -104,9 +107,6 @@ export const DEPRECATED_PERMISSION_CODES = [
|
||||
'learning:create',
|
||||
'learning:edit',
|
||||
'learning:delete',
|
||||
'exam:create',
|
||||
'exam:edit',
|
||||
'exam:delete',
|
||||
'department:view',
|
||||
'department:edit',
|
||||
'department:delete',
|
||||
|
||||
@@ -39,17 +39,6 @@ export class RbacService {
|
||||
return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] });
|
||||
}
|
||||
|
||||
private async resolvePermissions(permissionIds: number[]): Promise<Permission[]> {
|
||||
const uniqueIds = [...new Set(permissionIds)];
|
||||
const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : [];
|
||||
if (permissions.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(permissions.map((permission) => permission.id));
|
||||
const missingIds = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new Error(`权限不存在: ${missingIds.join(',')}`);
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
async getTeacherWorkspace(userId: number) {
|
||||
// Find all classes where this user is a teacher
|
||||
const teacherAssignments = await this.classTeacherRepo.find({
|
||||
@@ -139,11 +128,16 @@ export class RbacSeedService {
|
||||
}
|
||||
|
||||
async seedData(): Promise<void> {
|
||||
const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true });
|
||||
if (restoredLegacyUsers.affected) {
|
||||
// 仅当角色表为空(首次初始化)或显式设置 SEED_ROLES=true 时才播种;
|
||||
// 否则跳过,避免每次启动都无条件重激活角色/权限,覆盖管理员的自定义调整。
|
||||
const forceSeed = process.env.SEED_ROLES === 'true';
|
||||
const roleCount = await this.roleRepo.count();
|
||||
if (roleCount > 0 && !forceSeed) {
|
||||
this.logger.log(
|
||||
`已恢复 ${restoredLegacyUsers.affected} 个旧版禁用账号,账号状态现统一由归档管理`,
|
||||
`角色表已有 ${roleCount} 条记录,跳过种子数据重激活(如需强制播种请设置 SEED_ROLES=true)`,
|
||||
);
|
||||
await this.ensureDefaultAdmin();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const p of PRESET_PERMISSIONS) {
|
||||
@@ -273,26 +267,40 @@ export class RbacSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
const count = await this.userRepo.count();
|
||||
if (count === 0) {
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
const hash = await bcrypt.hash(adminPassword, 10);
|
||||
const adminUser = this.userRepo.create({
|
||||
username: 'admin',
|
||||
passwordHash: hash,
|
||||
name: '管理员',
|
||||
});
|
||||
const superAdminRole = allRoles.find((r) => r.code === 'super_admin');
|
||||
if (superAdminRole) {
|
||||
adminUser.roles = [superAdminRole];
|
||||
}
|
||||
await this.userRepo.save(adminUser);
|
||||
this.logger.log(
|
||||
`已创建默认管理员: admin / ${adminPassword === 'admin123' ? 'admin123 (请尽快修改!)' : '******'}`,
|
||||
);
|
||||
}
|
||||
await this.ensureDefaultAdmin();
|
||||
|
||||
this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次初始化时创建默认管理员。
|
||||
* 即使跳过角色/权限播种(角色表已有数据)也会执行,避免出现「有角色但无管理员」的状态。
|
||||
*/
|
||||
private async ensureDefaultAdmin(): Promise<void> {
|
||||
const count = await this.userRepo.count();
|
||||
if (count > 0) return;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
// 生产环境禁止使用默认弱口令
|
||||
if (!adminPassword) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('生产环境必须设置 ADMIN_PASSWORD 后再初始化默认管理员');
|
||||
}
|
||||
this.logger.warn('ADMIN_PASSWORD 未设置,开发环境使用默认密码 admin123');
|
||||
}
|
||||
const password = adminPassword || 'admin123';
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const adminUser = this.userRepo.create({
|
||||
username: 'admin',
|
||||
passwordHash: hash,
|
||||
name: '管理员',
|
||||
});
|
||||
const superAdminRole = await this.roleRepo.findOne({ where: { code: 'super_admin' } });
|
||||
if (superAdminRole) {
|
||||
adminUser.roles = [superAdminRole];
|
||||
}
|
||||
await this.userRepo.save(adminUser);
|
||||
// 不打印密码,避免凭据落入日志
|
||||
this.logger.log('已创建默认管理员: admin(请妥善保管密码)');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('RbacService seedData', () => {
|
||||
remove: jest.fn(async (value: any) => value),
|
||||
};
|
||||
const roleRepo = {
|
||||
count: jest.fn(async () => 0),
|
||||
findOne: jest.fn(async ({ where }: any) =>
|
||||
where.code === 'system_admin' || where.name === '系统管理员' ? systemAdminRole : null,
|
||||
),
|
||||
@@ -99,6 +100,7 @@ describe('RbacService seedData', () => {
|
||||
remove: jest.fn(async (value) => value),
|
||||
};
|
||||
const roleRepo = {
|
||||
count: jest.fn(async () => 0),
|
||||
findOne: jest.fn(async ({ where }: any) =>
|
||||
where.code === 'teacher' || where.name === '老师' ? teacherRole : null,
|
||||
),
|
||||
@@ -187,6 +189,7 @@ describe('RbacService legacy role consolidation', () => {
|
||||
remove: jest.fn(async (value) => value),
|
||||
};
|
||||
const roleRepo = {
|
||||
count: jest.fn(async () => 0),
|
||||
findOne: jest.fn(async () => targetRole),
|
||||
create: jest.fn((value) => ({ ...value, permissions: [] })),
|
||||
save: jest.fn(async (value) => value),
|
||||
@@ -217,3 +220,71 @@ describe('RbacService legacy role consolidation', () => {
|
||||
expect(roleRepo.remove).toHaveBeenCalledWith(legacyRole);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RbacSeedService startup gating', () => {
|
||||
const makeService = (roleCount: number) => {
|
||||
const permRepo = {
|
||||
findOne: jest.fn(async () => null),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(async () => []),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
const roleRepo = {
|
||||
count: jest.fn(async () => roleCount),
|
||||
findOne: jest.fn(async () => null),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(async () => []),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
const userRepo = {
|
||||
update: jest.fn(async () => ({ affected: 0 })),
|
||||
count: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const service = new RbacService(
|
||||
permRepo as never,
|
||||
roleRepo as never,
|
||||
userRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, permRepo, roleRepo, userRepo };
|
||||
};
|
||||
|
||||
it('skips re-seeding (no reactivation) when roles exist and SEED_ROLES is not set', async () => {
|
||||
const original = process.env.SEED_ROLES;
|
||||
delete process.env.SEED_ROLES;
|
||||
try {
|
||||
const { service, permRepo, roleRepo, userRepo } = makeService(3);
|
||||
await service.seedData();
|
||||
expect(roleRepo.count).toHaveBeenCalled();
|
||||
expect(permRepo.findOne).not.toHaveBeenCalled();
|
||||
expect(roleRepo.save).not.toHaveBeenCalled();
|
||||
expect(userRepo.update).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.SEED_ROLES;
|
||||
else process.env.SEED_ROLES = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('seeds when SEED_ROLES=true even if roles already exist', async () => {
|
||||
const original = process.env.SEED_ROLES;
|
||||
process.env.SEED_ROLES = 'true';
|
||||
try {
|
||||
const { service, permRepo, roleRepo } = makeService(3);
|
||||
await service.seedData();
|
||||
expect(permRepo.findOne).toHaveBeenCalled();
|
||||
expect(roleRepo.save).toHaveBeenCalled();
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.SEED_ROLES;
|
||||
else process.env.SEED_ROLES = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,17 +92,22 @@ export class RoomsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateRoomDto) {
|
||||
const parsed = RoomsService.parseRoomNumber(dto.roomNumber);
|
||||
const entity = this.repo.create({
|
||||
...dto,
|
||||
building: dto.building ?? parsed.building,
|
||||
floor: dto.floor ?? parsed.floor,
|
||||
roomType: dto.roomType ?? parsed.roomType,
|
||||
capacity: dto.capacity ?? parsed.capacity,
|
||||
// 宿舍 + 默认床位创建放进同一事务,避免宿舍已建、床位失败留下半成品。
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const roomRepo = manager.getRepository(Room);
|
||||
const bedRepo = manager.getRepository(Bed);
|
||||
const parsed = RoomsService.parseRoomNumber(dto.roomNumber);
|
||||
const entity = roomRepo.create({
|
||||
...dto,
|
||||
building: dto.building ?? parsed.building,
|
||||
floor: dto.floor ?? parsed.floor,
|
||||
roomType: dto.roomType ?? parsed.roomType,
|
||||
capacity: dto.capacity ?? parsed.capacity,
|
||||
});
|
||||
const room = await roomRepo.save(entity);
|
||||
await this.createDefaultBedsWithManager(bedRepo, room.id, room.capacity);
|
||||
return room;
|
||||
});
|
||||
const room = await this.repo.save(entity);
|
||||
await this.createDefaultBeds(room.id, room.capacity);
|
||||
return room;
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateRoomDto) {
|
||||
@@ -311,6 +316,19 @@ export class RoomsService {
|
||||
return this.queries.createDefaultBeds(roomId, capacity);
|
||||
}
|
||||
|
||||
private async createDefaultBedsWithManager(
|
||||
bedRepo: Repository<Bed>,
|
||||
roomId: number,
|
||||
capacity: number,
|
||||
): Promise<void> {
|
||||
const count = Math.max(capacity ?? 0, 0);
|
||||
if (count === 0) return;
|
||||
const beds = Array.from({ length: count }, (_, index) =>
|
||||
bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }),
|
||||
);
|
||||
await bedRepo.save(beds);
|
||||
}
|
||||
|
||||
private getNextBedNumber(beds: Pick<Bed, 'bedNumber'>[]): number {
|
||||
return this.bedOps.getNextBedNumber(beds);
|
||||
}
|
||||
|
||||
558
apps/server/src/students/students.import.service.spec.ts
Normal file
558
apps/server/src/students/students.import.service.spec.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
import { StudentsImportService } from './students.import.service';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentProfile } from '../entities/student-profile.entity';
|
||||
import { StudentEnrollment } from '../entities/student-enrollment.entity';
|
||||
import { ExamScore } from '../entities/exam-score.entity';
|
||||
import { LearningRecord } from '../entities/learning-record.entity';
|
||||
import { ResultArchive } from '../entities/result-archive.entity';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
|
||||
describe('StudentsImportService.batchImport', () => {
|
||||
const makeManager = (options?: {
|
||||
existingByPhone?: Record<string, Partial<Student>>;
|
||||
existingByIdNumber?: Record<string, Partial<Student>>;
|
||||
hostOrganization?: Organization | null;
|
||||
throwOnOrganization?: boolean;
|
||||
}): {
|
||||
manager: EntityManager;
|
||||
studentRepo: Repository<Student>;
|
||||
organizationRepo: Repository<Organization>;
|
||||
archiveRepo: Record<string, jest.Mock>;
|
||||
savedProfiles: any[];
|
||||
savedEnrollments: any[];
|
||||
savedExamScores: any[];
|
||||
savedLearningRecords: any[];
|
||||
} => {
|
||||
const existingByPhone = options?.existingByPhone ?? {};
|
||||
const existingByIdNumber = options?.existingByIdNumber ?? {};
|
||||
const savedProfiles: any[] = [];
|
||||
const savedEnrollments: any[] = [];
|
||||
const savedExamScores: any[] = [];
|
||||
const savedLearningRecords: any[] = [];
|
||||
let nextStudentId = 1;
|
||||
const inValues = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) return value as string[];
|
||||
if (value && typeof value === 'object' && '_value' in value) {
|
||||
return (value as { _value: unknown })._value as string[];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
const studentRepo = {
|
||||
// 批量预取后不再逐行调用 findOne:保留 mock 用于断言已被替代
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
find: jest.fn(async ({ where }: any) => {
|
||||
if (where?.phone) {
|
||||
return inValues(where.phone)
|
||||
.map((phone) => existingByPhone[phone])
|
||||
.filter((student): student is Student => Boolean(student));
|
||||
}
|
||||
if (where?.idNumber) {
|
||||
return inValues(where.idNumber)
|
||||
.map((idNumber) => existingByIdNumber[idNumber])
|
||||
.filter((student): student is Student => Boolean(student));
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
create: jest.fn((value: unknown) => value),
|
||||
save: jest.fn(async (value: any) => {
|
||||
const id = value?.id ?? nextStudentId++;
|
||||
return { ...value, id };
|
||||
}),
|
||||
update: jest.fn(),
|
||||
} as unknown as Repository<Student>;
|
||||
const organizationRepo = {
|
||||
findOne: jest.fn(async () => {
|
||||
if (options?.throwOnOrganization) throw new BadRequestException('尚未配置本机构');
|
||||
return options?.hostOrganization ?? { id: 7, isHost: true, status: 'active' };
|
||||
}),
|
||||
} as unknown as Repository<Organization>;
|
||||
const archiveRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn((value: unknown) => value),
|
||||
save: jest.fn(async (value: any) => {
|
||||
savedProfiles.push(value);
|
||||
return { ...value, id: value?.id ?? 10 };
|
||||
}),
|
||||
};
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Student) return studentRepo;
|
||||
if (entity === Organization) return organizationRepo;
|
||||
if (entity === StudentProfile) return archiveRepo;
|
||||
if (entity === ResultArchive) return archiveRepo;
|
||||
if (entity === StudentEnrollment) {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn((v: unknown) => v),
|
||||
save: jest.fn(async (v: any) => {
|
||||
savedEnrollments.push(v);
|
||||
return { ...v, id: v?.id ?? 20 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (entity === ExamScore) {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn((v: unknown) => v),
|
||||
save: jest.fn(async (v: any) => {
|
||||
savedExamScores.push(v);
|
||||
return { ...v, id: v?.id ?? 30 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (entity === LearningRecord) {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn((v: unknown) => v),
|
||||
save: jest.fn(async (v: any) => {
|
||||
savedLearningRecords.push(v);
|
||||
return { ...v, id: v?.id ?? 40 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected entity ${String(entity)}`);
|
||||
}),
|
||||
} as unknown as EntityManager;
|
||||
return {
|
||||
manager,
|
||||
studentRepo,
|
||||
organizationRepo,
|
||||
archiveRepo,
|
||||
savedProfiles,
|
||||
savedEnrollments,
|
||||
savedExamScores,
|
||||
savedLearningRecords,
|
||||
};
|
||||
};
|
||||
|
||||
const createService = (options?: Parameters<typeof makeManager>[0]) => {
|
||||
const mocks = makeManager(options);
|
||||
const repo = {
|
||||
manager: {
|
||||
transaction: jest.fn(async (cb: (m: EntityManager) => unknown) => cb(mocks.manager)),
|
||||
},
|
||||
} as unknown as Repository<Student>;
|
||||
const service = new StudentsImportService(repo);
|
||||
return { service, repo, ...mocks };
|
||||
};
|
||||
|
||||
it('imports students inside one transaction and returns the existing shape', async () => {
|
||||
const { service, repo } = createService();
|
||||
const result = await service.batchImport([
|
||||
{ name: '张三', phone: '13800138000' },
|
||||
{ name: '李四', phone: '13900139000' },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
message: '成功导入 2 名学生,导入档案相关记录 0 条,跳过 0 条(重复或空行)',
|
||||
imported: 2,
|
||||
archiveImported: 0,
|
||||
skipped: 0,
|
||||
invalidDateSkipped: 0,
|
||||
});
|
||||
expect(repo.manager.transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips duplicate and empty rows without aborting the batch', async () => {
|
||||
const { service } = createService({
|
||||
existingByPhone: { '13800138000': { id: 1, name: '张三', phone: '13800138000' } as Student },
|
||||
});
|
||||
const result = await service.batchImport([
|
||||
{ name: '张三', phone: '13800138000' },
|
||||
{ name: '', phone: '13900139000' },
|
||||
{ name: '王五', phone: '13700137000' },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
message: '成功导入 1 名学生,导入档案相关记录 0 条,跳过 2 条(重复或空行)',
|
||||
imported: 1,
|
||||
archiveImported: 0,
|
||||
skipped: 2,
|
||||
invalidDateSkipped: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps fail-fast semantics: a row failure rejects and rolls the batch back', async () => {
|
||||
const { service, repo, manager } = createService({ throwOnOrganization: true });
|
||||
await expect(
|
||||
service.batchImport([{ name: '张三', phone: '13800138000' }]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
// The transaction callback threw, so nothing was committed.
|
||||
expect(repo.manager.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getRepository).toHaveBeenCalledWith(Student);
|
||||
});
|
||||
|
||||
it('queries the host organization only once across the whole batch', async () => {
|
||||
const { service, organizationRepo } = createService();
|
||||
const result = await service.batchImport([
|
||||
{ name: '张三', phone: '13800138000' },
|
||||
{ name: '李四', phone: '13900139000' },
|
||||
{ name: '王五', phone: '13700137000' },
|
||||
]);
|
||||
|
||||
expect(result.imported).toBe(3);
|
||||
expect(organizationRepo.findOne).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('routes archive rows to the right student by phone', async () => {
|
||||
const { service, savedEnrollments, savedExamScores, savedLearningRecords } = createService();
|
||||
const result = await service.batchImport({
|
||||
students: [
|
||||
{ name: '张三', phone: '13800138000' },
|
||||
{ name: '李四', phone: '13900139000' },
|
||||
],
|
||||
enrollments: [
|
||||
{ phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' },
|
||||
{ phone: '13900139000', courseCategory: 'art', classType: 'small_class', className: '周末班' },
|
||||
],
|
||||
examScores: [
|
||||
{ phone: '13800138000', examType: 'monthly', subject: '语文', score: 90 },
|
||||
{ phone: '13900139000', examType: 'final', subject: '数学', score: 88 },
|
||||
],
|
||||
learningRecords: [
|
||||
{ phone: '13800138000', recordDate: '2024-10-20', recordType: 'study_feedback', content: 'ok' },
|
||||
{ phone: '13900139000', recordDate: '2024-10-21', recordType: 'study_feedback', content: 'good' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.imported).toBe(2);
|
||||
expect(result.archiveImported).toBe(6);
|
||||
expect(savedEnrollments.map((e) => e.studentId)).toEqual([1, 2]);
|
||||
expect(savedExamScores.map((e) => e.studentId)).toEqual([1, 2]);
|
||||
expect(savedLearningRecords.map((e) => e.studentId)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('deduplicates identical enrollment rows within one import (in-memory upsert)', async () => {
|
||||
const { service, savedEnrollments } = createService();
|
||||
await service.batchImport({
|
||||
students: [{ name: '张三', phone: '13800138000' }],
|
||||
enrollments: [
|
||||
{ phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' },
|
||||
{ phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' },
|
||||
],
|
||||
examScores: [],
|
||||
learningRecords: [],
|
||||
});
|
||||
|
||||
// 同一行被命中两次并更新保存(与原有“find 后 save”的 upsert 语义一致),
|
||||
// 但始终落在同一条记录上,不会新建重复行。
|
||||
expect(savedEnrollments).toHaveLength(2);
|
||||
expect(new Set(savedEnrollments.map((e) => e.id)).size).toBe(1);
|
||||
});
|
||||
|
||||
it('skips invalid dates, records the count, and still imports the rest of the row', async () => {
|
||||
const { service, savedProfiles } = createService();
|
||||
const result = await service.batchImport({
|
||||
students: [
|
||||
{ name: '张三', phone: '13800138000', targetCollege: '北大', profileDate: '2024/9/1' },
|
||||
],
|
||||
enrollments: [],
|
||||
examScores: [
|
||||
{ phone: '13800138000', examType: 'monthly', subject: '语文', score: 90, examDate: 'not-a-date' },
|
||||
],
|
||||
learningRecords: [
|
||||
{ phone: '13800138000', recordDate: '2024/10/20', recordType: 'study_feedback', content: 'ok' },
|
||||
],
|
||||
});
|
||||
|
||||
// profile + exam score imported(非法日期字段被跳过);learning record 因必填日期非法整条跳过
|
||||
expect(result.imported).toBe(1);
|
||||
expect(result.archiveImported).toBe(2);
|
||||
expect(result.invalidDateSkipped).toBe(3);
|
||||
expect(savedProfiles[0]).not.toHaveProperty('profileDate');
|
||||
});
|
||||
|
||||
it('treats impossible calendar dates as invalid while accepting real leap days', async () => {
|
||||
const { service, savedProfiles, savedExamScores } = createService();
|
||||
const result = await service.batchImport({
|
||||
students: [
|
||||
{ name: '张三', phone: '13800138000', targetCollege: '北大', profileDate: '2024-02-31' },
|
||||
],
|
||||
enrollments: [],
|
||||
examScores: [
|
||||
{ phone: '13800138000', examType: 'monthly', subject: '语文', score: 90, examDate: '2023-02-29' },
|
||||
{ phone: '13800138000', examType: 'final', subject: '数学', score: 88, examDate: '2024-02-29' },
|
||||
],
|
||||
learningRecords: [],
|
||||
});
|
||||
|
||||
// 2024-02-31 非法(2 月没有 31 号);2023 非闰年 2/29 非法;2024 闰年 2/29 合法
|
||||
expect(result.invalidDateSkipped).toBe(2);
|
||||
expect(savedProfiles[0]).not.toHaveProperty('profileDate');
|
||||
expect(savedExamScores[0].examDate).toBeUndefined();
|
||||
expect(savedExamScores[1].examDate).toBe('2024-02-29');
|
||||
});
|
||||
|
||||
it('writes well-formed dates unchanged', async () => {
|
||||
const { service, savedProfiles } = createService();
|
||||
await service.batchImport({
|
||||
students: [
|
||||
{ name: '张三', phone: '13800138000', targetCollege: '北大', profileDate: '2024-09-01' },
|
||||
],
|
||||
enrollments: [],
|
||||
examScores: [
|
||||
{ phone: '13800138000', examType: 'monthly', subject: '语文', score: 90, examDate: '2024-10-15' },
|
||||
],
|
||||
learningRecords: [
|
||||
{ phone: '13800138000', recordDate: '2024-10-20', recordType: 'study_feedback', content: '状态稳定' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(savedProfiles[0].profileDate).toBe('2024-09-01');
|
||||
});
|
||||
|
||||
it('prefetches existing students with two IN queries instead of per-row findOne', async () => {
|
||||
const { service, studentRepo } = createService({
|
||||
existingByPhone: { '13800138000': { id: 1, name: '张三', phone: '13800138000' } as Student },
|
||||
existingByIdNumber: {
|
||||
'110101199001011234': { id: 2, name: '李四', idNumber: '110101199001011234' } as Student,
|
||||
},
|
||||
});
|
||||
const result = await service.batchImport([
|
||||
{ name: '张三', phone: '13800138000' },
|
||||
{ name: '李四', idNumber: '110101199001011234' },
|
||||
{ name: '王五', phone: '13900139000' },
|
||||
]);
|
||||
|
||||
// 批量预取:phone + idNumber 各一次 IN 查询,不再逐行 findOne
|
||||
expect(studentRepo.find).toHaveBeenCalledTimes(2);
|
||||
expect(studentRepo.findOne).not.toHaveBeenCalled();
|
||||
expect(result.imported).toBe(1);
|
||||
expect(result.skipped).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps in-batch dedup by phone/idNumber after saving a new student', async () => {
|
||||
const { service, studentRepo } = createService();
|
||||
const result = await service.batchImport([
|
||||
{ name: '张三', phone: '13800138000' },
|
||||
{ name: '张三副本', phone: '13800138000' },
|
||||
{ name: '李四', phone: '13900139000', idNumber: '110101199001011234' },
|
||||
{ name: '李四副本', idNumber: '110101199001011234' },
|
||||
]);
|
||||
|
||||
// 新保存学生同步进内存索引:后续相同 phone/idNumber 行视为重复跳过
|
||||
expect(result.imported).toBe(2);
|
||||
expect(result.skipped).toBe(2);
|
||||
expect(studentRepo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StudentsImportService.matchImport', () => {
|
||||
const createService = (options?: {
|
||||
existingByPhone?: Record<string, Partial<Student>>;
|
||||
existingByIdNumber?: Record<string, Partial<Student>>;
|
||||
}) => {
|
||||
const existingByPhone = options?.existingByPhone ?? {};
|
||||
const existingByIdNumber = options?.existingByIdNumber ?? {};
|
||||
const savedProfiles: any[] = [];
|
||||
const savedEnrollments: any[] = [];
|
||||
const savedExamScores: any[] = [];
|
||||
const savedLearningRecords: any[] = [];
|
||||
const inValues = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) return value as string[];
|
||||
if (value && typeof value === 'object' && '_value' in value) {
|
||||
return (value as { _value: unknown })._value as string[];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
const studentRepo = {
|
||||
// 批量预取后不再逐行调用 findOne:保留 mock 用于断言已被替代
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
find: jest.fn(async ({ where }: any) => {
|
||||
if (where?.phone) {
|
||||
return inValues(where.phone)
|
||||
.map((phone) => existingByPhone[phone])
|
||||
.filter((student): student is Student => Boolean(student));
|
||||
}
|
||||
if (where?.idNumber) {
|
||||
return inValues(where.idNumber)
|
||||
.map((idNumber) => existingByIdNumber[idNumber])
|
||||
.filter((student): student is Student => Boolean(student));
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
update: jest.fn(),
|
||||
} as unknown as Repository<Student>;
|
||||
const archiveRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn((value: unknown) => value),
|
||||
save: jest.fn(async (value: any) => {
|
||||
savedProfiles.push(value);
|
||||
return { ...value, id: value?.id ?? 10 };
|
||||
}),
|
||||
};
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Student) return studentRepo;
|
||||
if (entity === StudentProfile) return archiveRepo;
|
||||
if (entity === ResultArchive) return archiveRepo;
|
||||
if (entity === StudentEnrollment) {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn((v: unknown) => v),
|
||||
save: jest.fn(async (v: any) => {
|
||||
savedEnrollments.push(v);
|
||||
return { ...v, id: v?.id ?? 20 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (entity === ExamScore) {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn((v: unknown) => v),
|
||||
save: jest.fn(async (v: any) => {
|
||||
savedExamScores.push(v);
|
||||
return { ...v, id: v?.id ?? 30 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (entity === LearningRecord) {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn((v: unknown) => v),
|
||||
save: jest.fn(async (v: any) => {
|
||||
savedLearningRecords.push(v);
|
||||
return { ...v, id: v?.id ?? 40 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected entity ${String(entity)}`);
|
||||
}),
|
||||
} as unknown as EntityManager;
|
||||
const repo = {
|
||||
manager: {
|
||||
transaction: jest.fn(async (cb: (m: EntityManager) => unknown) => cb(manager)),
|
||||
},
|
||||
} as unknown as Repository<Student>;
|
||||
const service = new StudentsImportService(repo);
|
||||
return { service, studentRepo, savedProfiles, savedEnrollments, savedExamScores, savedLearningRecords };
|
||||
};
|
||||
|
||||
it('updates a matched student with writable fields', async () => {
|
||||
const { service, studentRepo } = createService({
|
||||
existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student },
|
||||
});
|
||||
const result = await service.matchImport([
|
||||
{ phone: '13800138000', name: '张三', gender: '男' },
|
||||
]);
|
||||
|
||||
expect(studentRepo.update).toHaveBeenCalledWith(1, { name: '张三', gender: '男' });
|
||||
expect(result).toEqual({
|
||||
message: '更新已有学生资料 1 人,导入档案相关记录 0 条,跳过 0 条(无匹配/无更新字段/双键冲突)',
|
||||
matched: 1,
|
||||
archiveImported: 0,
|
||||
skipped: 0,
|
||||
skippedNoFields: 0,
|
||||
skippedConflict: 0,
|
||||
invalidDateSkipped: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips matched rows without writable fields instead of calling an empty update', async () => {
|
||||
const { service, studentRepo } = createService({
|
||||
existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student },
|
||||
});
|
||||
const result = await service.matchImport([{ phone: '13800138000' }]);
|
||||
|
||||
expect(studentRepo.update).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
message: '更新已有学生资料 0 人,导入档案相关记录 0 条,跳过 1 条(无匹配/无更新字段/双键冲突)',
|
||||
matched: 0,
|
||||
archiveImported: 0,
|
||||
skipped: 1,
|
||||
skippedNoFields: 1,
|
||||
skippedConflict: 0,
|
||||
invalidDateSkipped: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips rows whose phone and idNumber match different students (conflict)', async () => {
|
||||
const { service, studentRepo } = createService({
|
||||
existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student },
|
||||
existingByIdNumber: { '440111200001010011': { id: 2, idNumber: '440111200001010011' } as Student },
|
||||
});
|
||||
const result = await service.matchImport([
|
||||
{ phone: '13800138000', idNumber: '440111200001010011', name: '张三' },
|
||||
]);
|
||||
|
||||
// 双键命中不同学生:不得更新任何一方,行计入 skippedConflict
|
||||
expect(studentRepo.update).not.toHaveBeenCalled();
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(result.skippedConflict).toBe(1);
|
||||
expect(result.matched).toBe(0);
|
||||
});
|
||||
|
||||
it('matches by idNumber and can still update phone when it differs', async () => {
|
||||
const { service, studentRepo } = createService({
|
||||
existingByIdNumber: { '110101199001011234': { id: 1, idNumber: '110101199001011234', phone: '13800138000' } as Student },
|
||||
});
|
||||
const result = await service.matchImport([
|
||||
{ idNumber: '110101199001011234', phone: '13900139000' },
|
||||
]);
|
||||
|
||||
expect(studentRepo.update).toHaveBeenCalledWith(1, { phone: '13900139000' });
|
||||
expect(result.matched).toBe(1);
|
||||
expect(result.skippedNoFields).toBe(0);
|
||||
});
|
||||
|
||||
it('imports archive rows for a matched student even when the student row has no fields', async () => {
|
||||
const { service, studentRepo, savedEnrollments } = createService({
|
||||
existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student },
|
||||
});
|
||||
const result = await service.matchImport({
|
||||
students: [{ phone: '13800138000' }],
|
||||
enrollments: [
|
||||
{ phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' },
|
||||
],
|
||||
examScores: [],
|
||||
learningRecords: [],
|
||||
});
|
||||
|
||||
expect(studentRepo.update).not.toHaveBeenCalled();
|
||||
expect(savedEnrollments).toHaveLength(1);
|
||||
expect(result.matched).toBe(1);
|
||||
expect(result.archiveImported).toBe(1);
|
||||
expect(result.skipped).toBe(0);
|
||||
expect(result.skippedNoFields).toBe(0);
|
||||
});
|
||||
|
||||
it('prefetches existing students with two IN queries instead of per-row findOne', async () => {
|
||||
const { service, studentRepo } = createService({
|
||||
existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student },
|
||||
existingByIdNumber: {
|
||||
'110101199001011234': { id: 2, idNumber: '110101199001011234' } as Student,
|
||||
},
|
||||
});
|
||||
const result = await service.matchImport([
|
||||
{ phone: '13800138000', name: '张三' },
|
||||
{ idNumber: '110101199001011234', name: '李四' },
|
||||
{ phone: '13900139000', name: '王五' },
|
||||
]);
|
||||
|
||||
// 批量预取:phone + idNumber 各一次 IN 查询,不再逐行 findOne
|
||||
expect(studentRepo.find).toHaveBeenCalledTimes(2);
|
||||
expect(studentRepo.findOne).not.toHaveBeenCalled();
|
||||
expect(result.matched).toBe(2);
|
||||
expect(result.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps matching by updated phone within the same batch', async () => {
|
||||
const { service, studentRepo } = createService({
|
||||
existingByIdNumber: {
|
||||
'110101199001011234': { id: 1, idNumber: '110101199001011234', phone: '13800138000' } as Student,
|
||||
},
|
||||
});
|
||||
const result = await service.matchImport([
|
||||
{ idNumber: '110101199001011234', phone: '13900139000', name: '张三' },
|
||||
{ phone: '13900139000', name: '张三2' },
|
||||
]);
|
||||
|
||||
// 第一行通过 idNumber 命中并把 phone 改为 13900139000,
|
||||
// 第二行按更新后的 phone 仍能命中同一学生(与同一事务内 findOne 语义一致)
|
||||
expect(studentRepo.update).toHaveBeenCalledTimes(2);
|
||||
expect(result.matched).toBe(2);
|
||||
expect(result.skipped).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { EntityManager, In, Repository } from 'typeorm';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentProfile } from '../entities/student-profile.entity';
|
||||
import { StudentEnrollment } from '../entities/student-enrollment.entity';
|
||||
@@ -16,115 +16,296 @@ import type {
|
||||
StudentWorkbookImport,
|
||||
} from './student-import';
|
||||
import { getHostOrganizationId } from './students.organization';
|
||||
import { addDaysToDateOnly } from '../common/china-time';
|
||||
|
||||
/** 学生查重/匹配的内存索引:phone 与 idNumber 各一张 Map(key 为去空白后的值)。 */
|
||||
type StudentLookupIndex = {
|
||||
byPhone: Map<string, Student>;
|
||||
byIdNumber: Map<string, Student>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StudentsImportService {
|
||||
// 档案/报读/成绩等写入统一走 manager.getRepository(见下方 helpers),
|
||||
// 这里只注入 Student repo(查重、更新、开启事务)。
|
||||
constructor(
|
||||
@InjectRepository(Student) private readonly repo: Repository<Student>,
|
||||
@InjectRepository(StudentProfile) private readonly profileRepo: Repository<StudentProfile>,
|
||||
@InjectRepository(StudentEnrollment)
|
||||
private readonly enrollmentRepo: Repository<StudentEnrollment>,
|
||||
@InjectRepository(ExamScore) private readonly examScoreRepo: Repository<ExamScore>,
|
||||
@InjectRepository(LearningRecord)
|
||||
private readonly learningRecordRepo: Repository<LearningRecord>,
|
||||
@InjectRepository(ResultArchive) private readonly resultRepo: Repository<ResultArchive>,
|
||||
@InjectRepository(Organization) private readonly organizationRepo: Repository<Organization>,
|
||||
) {}
|
||||
|
||||
async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
|
||||
const data = this.normalizeImportData(importData);
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let archiveImported = 0;
|
||||
for (const row of data.students) {
|
||||
if (!row.name || !row.name.trim()) {
|
||||
skipped++;
|
||||
continue;
|
||||
// 整批导入放进同一事务:查重、建学生、写档案要么全部成功,要么全部回滚。
|
||||
return this.repo.manager.transaction(async (manager) => {
|
||||
const studentRepo = manager.getRepository(Student);
|
||||
const organizationRepo = manager.getRepository(Organization);
|
||||
// 按手机号预建报读/成绩/回访行分组(手机号是导入行与学生档案之间的关联键,
|
||||
// 等价于按学生分组),避免逐学生循环内对 data.* 全量 filter。
|
||||
const enrollmentsByPhone = this.groupByPhone(data.enrollments);
|
||||
const examScoresByPhone = this.groupByPhone(data.examScores);
|
||||
const learningRecordsByPhone = this.groupByPhone(data.learningRecords);
|
||||
// 循环外按本批所有 phone/idNumber 一次性批量预取已有学生(仅两条 IN 查询),
|
||||
// 内存建索引后逐行匹配,避免逐行 findOne 的 N+1。
|
||||
const existingIndexes = await this.prefetchStudentIndexes(studentRepo, data.students);
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let archiveImported = 0;
|
||||
let invalidDateSkipped = 0;
|
||||
// 本机构 id 循环外懒加载缓存一次,避免每个学生行都查询
|
||||
let hostOrganizationId: number | undefined;
|
||||
for (const row of data.students) {
|
||||
if (!row.name || !row.name.trim()) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// 按 phone/idNumber 查重:先 phone 后 idNumber,命中即视为已存在
|
||||
if (this.matchStudentByIndexes(existingIndexes, row)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!row.organizationId && hostOrganizationId === undefined) {
|
||||
hostOrganizationId = await getHostOrganizationId(organizationRepo);
|
||||
}
|
||||
const student = await studentRepo.save(
|
||||
studentRepo.create({
|
||||
name: row.name.trim(),
|
||||
studentNo: row.studentNo?.trim() || undefined,
|
||||
phone: row.phone?.trim() || undefined,
|
||||
idNumber: row.idNumber?.trim() || undefined,
|
||||
gender: row.gender || undefined,
|
||||
ethnicity: row.ethnicity || undefined,
|
||||
emergencyContact: row.emergencyContact || undefined,
|
||||
emergencyPhone: row.emergencyPhone || undefined,
|
||||
supervisor: row.supervisor || undefined,
|
||||
organizationId: row.organizationId || hostOrganizationId,
|
||||
}),
|
||||
);
|
||||
// 新保存的学生同步进内存索引:本批后续相同 phone/idNumber 行视为重复跳过,
|
||||
// 保持与逐行 findOne 一致的 upsert/去重语义。
|
||||
this.indexStudent(existingIndexes, student);
|
||||
const archive = await this.importArchiveData(manager, student.id, row, {
|
||||
enrollments: enrollmentsByPhone,
|
||||
examScores: examScoresByPhone,
|
||||
learningRecords: learningRecordsByPhone,
|
||||
});
|
||||
archiveImported += archive.imported;
|
||||
invalidDateSkipped += archive.invalidDateSkipped;
|
||||
imported++;
|
||||
}
|
||||
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
|
||||
if (exists) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const student = await this.repo.save(
|
||||
this.repo.create({
|
||||
name: row.name.trim(),
|
||||
studentNo: row.studentNo?.trim() || undefined,
|
||||
phone: row.phone?.trim() || undefined,
|
||||
idNumber: row.idNumber?.trim() || undefined,
|
||||
gender: row.gender || undefined,
|
||||
ethnicity: row.ethnicity || undefined,
|
||||
emergencyContact: row.emergencyContact || undefined,
|
||||
emergencyPhone: row.emergencyPhone || undefined,
|
||||
supervisor: row.supervisor || undefined,
|
||||
organizationId: row.organizationId || (await getHostOrganizationId(this.organizationRepo)),
|
||||
}),
|
||||
);
|
||||
archiveImported += await this.importArchiveData(student.id, row, data);
|
||||
imported++;
|
||||
}
|
||||
return {
|
||||
message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`,
|
||||
imported,
|
||||
archiveImported,
|
||||
skipped,
|
||||
};
|
||||
return {
|
||||
message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`,
|
||||
imported,
|
||||
archiveImported,
|
||||
skipped,
|
||||
invalidDateSkipped,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
|
||||
const data = this.normalizeImportData(importData);
|
||||
let matched = 0;
|
||||
let skipped = 0;
|
||||
let archiveImported = 0;
|
||||
for (const row of data.students) {
|
||||
// Match by phone first, then idNumber
|
||||
let student = row.phone?.trim()
|
||||
? await this.repo.findOne({ where: { phone: row.phone.trim() } })
|
||||
: null;
|
||||
if (!student && row.idNumber?.trim()) {
|
||||
student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } });
|
||||
// 整批匹配写入放进同一事务:更新学生资料与写档案要么全部成功,要么全部回滚。
|
||||
return this.repo.manager.transaction(async (manager) => {
|
||||
const studentRepo = manager.getRepository(Student);
|
||||
// 与 batchImport 一致:按手机号预建分组,避免逐学生循环内全量 filter。
|
||||
const enrollmentsByPhone = this.groupByPhone(data.enrollments);
|
||||
const examScoresByPhone = this.groupByPhone(data.examScores);
|
||||
const learningRecordsByPhone = this.groupByPhone(data.learningRecords);
|
||||
// 循环外按本批所有 phone/idNumber 一次性批量预取已有学生(仅两条 IN 查询),
|
||||
// 内存建索引后逐行匹配,避免逐行 findOne 的 N+1。
|
||||
const existingIndexes = await this.prefetchStudentIndexes(studentRepo, data.students);
|
||||
let matched = 0;
|
||||
let skipped = 0;
|
||||
let skippedNoFields = 0;
|
||||
let skippedConflict = 0;
|
||||
let archiveImported = 0;
|
||||
let invalidDateSkipped = 0;
|
||||
for (const row of data.students) {
|
||||
// 双键命中不同学生:跳过该行,避免把 idNumber 写到错误的 student 上
|
||||
if (this.hasIndexConflict(existingIndexes, row)) {
|
||||
skipped++;
|
||||
skippedConflict++;
|
||||
continue;
|
||||
}
|
||||
// Match by phone first, then idNumber
|
||||
const student = this.matchStudentByIndexes(existingIndexes, row);
|
||||
if (!student) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const updates: Partial<
|
||||
Pick<
|
||||
Student,
|
||||
| 'name'
|
||||
| 'studentNo'
|
||||
| 'phone'
|
||||
| 'idNumber'
|
||||
| 'gender'
|
||||
| 'ethnicity'
|
||||
| 'emergencyContact'
|
||||
| 'emergencyPhone'
|
||||
| 'supervisor'
|
||||
| 'organizationId'
|
||||
>
|
||||
> = {};
|
||||
if (row.name?.trim()) updates.name = row.name.trim();
|
||||
if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim();
|
||||
// phone/idNumber 是匹配键:与命中学生一致时视为无变更(避免空 set 更新),
|
||||
// 不一致时仍允许通过另一个键命中后更新。
|
||||
if (row.phone?.trim() && row.phone.trim() !== student.phone) updates.phone = row.phone.trim();
|
||||
if (row.idNumber?.trim() && row.idNumber.trim() !== student.idNumber) updates.idNumber = row.idNumber.trim();
|
||||
if (row.gender) updates.gender = row.gender;
|
||||
if (row.ethnicity) updates.ethnicity = row.ethnicity;
|
||||
if (row.emergencyContact) updates.emergencyContact = row.emergencyContact;
|
||||
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
|
||||
if (row.supervisor) updates.supervisor = row.supervisor;
|
||||
if (row.organizationId) updates.organizationId = row.organizationId;
|
||||
// 按 phone/idNumber 命中已有学生但本行没有任何可写字段时,
|
||||
// 跳过该学生的更新(避免 TypeORM 对空 set 报错),保留匹配/去重语义。
|
||||
const hasWritableFields = Object.keys(updates).length > 0;
|
||||
const archive = await this.importArchiveData(manager, student.id, row, {
|
||||
enrollments: enrollmentsByPhone,
|
||||
examScores: examScoresByPhone,
|
||||
learningRecords: learningRecordsByPhone,
|
||||
});
|
||||
archiveImported += archive.imported;
|
||||
invalidDateSkipped += archive.invalidDateSkipped;
|
||||
if (!hasWritableFields && archive.imported === 0 && archive.invalidDateSkipped === 0) {
|
||||
// 整行无任何可写字段:计入 skipped(不抛错)
|
||||
skippedNoFields++;
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (hasWritableFields) {
|
||||
await studentRepo.update(student.id, updates);
|
||||
// 更新后的 phone/idNumber 同步进内存索引:后续行按新值仍可命中,
|
||||
// 与逐行 findOne 在同一事务内能看到本批已更新记录的语义一致。
|
||||
this.reindexStudent(existingIndexes, student, updates);
|
||||
}
|
||||
matched++;
|
||||
}
|
||||
if (!student) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const updates: Partial<
|
||||
Pick<
|
||||
Student,
|
||||
| 'name'
|
||||
| 'studentNo'
|
||||
| 'phone'
|
||||
| 'idNumber'
|
||||
| 'gender'
|
||||
| 'ethnicity'
|
||||
| 'emergencyContact'
|
||||
| 'emergencyPhone'
|
||||
| 'supervisor'
|
||||
| 'organizationId'
|
||||
>
|
||||
> = {};
|
||||
if (row.name?.trim()) updates.name = row.name.trim();
|
||||
if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim();
|
||||
if (row.phone?.trim()) updates.phone = row.phone.trim();
|
||||
if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
|
||||
if (row.gender) updates.gender = row.gender;
|
||||
if (row.ethnicity) updates.ethnicity = row.ethnicity;
|
||||
if (row.emergencyContact) updates.emergencyContact = row.emergencyContact;
|
||||
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
|
||||
if (row.supervisor) updates.supervisor = row.supervisor;
|
||||
if (row.organizationId) updates.organizationId = row.organizationId;
|
||||
await this.repo.update(student.id, updates);
|
||||
archiveImported += await this.importArchiveData(student.id, row, data);
|
||||
matched++;
|
||||
}
|
||||
return {
|
||||
message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配/无更新字段/双键冲突)`,
|
||||
matched,
|
||||
archiveImported,
|
||||
skipped,
|
||||
skippedNoFields,
|
||||
skippedConflict,
|
||||
invalidDateSkipped,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量预取本批导入行涉及的所有 phone / idNumber 对应的已有学生:
|
||||
* 只发两条 IN 查询(phone In(...)、idNumber In(...)),内存建索引供逐行匹配,
|
||||
* 替代逐行 findOne(N+1)。某类 key 为空时跳过对应查询。
|
||||
*/
|
||||
private async prefetchStudentIndexes(
|
||||
studentRepo: Repository<Student>,
|
||||
rows: StudentImportRow[],
|
||||
): Promise<StudentLookupIndex> {
|
||||
const phones = [
|
||||
...new Set(
|
||||
rows
|
||||
.map((row) => row.phone?.trim())
|
||||
.filter((phone): phone is string => Boolean(phone)),
|
||||
),
|
||||
];
|
||||
const idNumbers = [
|
||||
...new Set(
|
||||
rows
|
||||
.map((row) => row.idNumber?.trim())
|
||||
.filter((idNumber): idNumber is string => Boolean(idNumber)),
|
||||
),
|
||||
];
|
||||
const [byPhoneList, byIdNumberList] = await Promise.all([
|
||||
phones.length > 0
|
||||
? studentRepo.find({ where: { phone: In(phones) } })
|
||||
: Promise.resolve([] as Student[]),
|
||||
idNumbers.length > 0
|
||||
? studentRepo.find({ where: { idNumber: In(idNumbers) } })
|
||||
: Promise.resolve([] as Student[]),
|
||||
]);
|
||||
return {
|
||||
message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`,
|
||||
matched,
|
||||
archiveImported,
|
||||
skipped,
|
||||
byPhone: this.buildIndex(byPhoneList, (student) => student.phone),
|
||||
byIdNumber: this.buildIndex(byIdNumberList, (student) => student.idNumber),
|
||||
};
|
||||
}
|
||||
|
||||
/** 从查询结果构建 phone / idNumber → Student 的 Map(key 去空白,空值不建索引)。 */
|
||||
private buildIndex(
|
||||
students: Student[],
|
||||
keyOf: (student: Student) => string | null | undefined,
|
||||
): Map<string, Student> {
|
||||
const index = new Map<string, Student>();
|
||||
for (const student of students) {
|
||||
const key = keyOf(student)?.trim();
|
||||
if (key) index.set(key, student);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 双键冲突检测:同一行 phone 与 idNumber 分别命中不同的学生时,不能更新任何一个,
|
||||
* 否则会把一个学生的 idNumber 写到另一个学生身上(数据错乱)。冲突行按 skipped 处理。
|
||||
*/
|
||||
private hasIndexConflict(indexes: StudentLookupIndex, row: StudentImportRow): boolean {
|
||||
const phone = row.phone?.trim();
|
||||
const idNumber = row.idNumber?.trim();
|
||||
if (!phone || !idNumber) return false;
|
||||
const byPhone = indexes.byPhone.get(phone);
|
||||
const byIdNumber = indexes.byIdNumber.get(idNumber);
|
||||
return !!byPhone && !!byIdNumber && byPhone.id !== byIdNumber.id;
|
||||
}
|
||||
|
||||
/** 逐行匹配已有学生:优先 phone,未命中再按 idNumber(与 matchImport 原有语义一致)。 */
|
||||
private matchStudentByIndexes(
|
||||
indexes: StudentLookupIndex,
|
||||
row: StudentImportRow,
|
||||
): Student | undefined {
|
||||
const phone = row.phone?.trim();
|
||||
if (phone) {
|
||||
const byPhone = indexes.byPhone.get(phone);
|
||||
if (byPhone) return byPhone;
|
||||
}
|
||||
const idNumber = row.idNumber?.trim();
|
||||
if (idNumber) {
|
||||
const byIdNumber = indexes.byIdNumber.get(idNumber);
|
||||
if (byIdNumber) return byIdNumber;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 新保存的学生同步进内存索引(batchImport 批次内去重)。 */
|
||||
private indexStudent(indexes: StudentLookupIndex, student: Student): void {
|
||||
if (student.phone) indexes.byPhone.set(student.phone.trim(), student);
|
||||
if (student.idNumber) indexes.byIdNumber.set(student.idNumber.trim(), student);
|
||||
}
|
||||
|
||||
/**
|
||||
* matchImport 更新学生资料后同步内存索引:phone/idNumber 被改写时删除旧 key、
|
||||
* 注册新 key,并让内存实体与已更新值保持一致(后续行按新值仍可命中)。
|
||||
*/
|
||||
private reindexStudent(
|
||||
indexes: StudentLookupIndex,
|
||||
student: Student,
|
||||
updates: Partial<Pick<Student, 'phone' | 'idNumber'>>,
|
||||
): void {
|
||||
const oldPhone = student.phone?.trim();
|
||||
const newPhone = updates.phone?.trim();
|
||||
const oldIdNumber = student.idNumber?.trim();
|
||||
const newIdNumber = updates.idNumber?.trim();
|
||||
Object.assign(student, updates);
|
||||
if (newPhone && newPhone !== oldPhone) {
|
||||
if (oldPhone) indexes.byPhone.delete(oldPhone);
|
||||
indexes.byPhone.set(newPhone, student);
|
||||
}
|
||||
if (newIdNumber && newIdNumber !== oldIdNumber) {
|
||||
if (oldIdNumber) indexes.byIdNumber.delete(oldIdNumber);
|
||||
indexes.byIdNumber.set(newIdNumber, student);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeImportData(
|
||||
importData: StudentWorkbookImport | StudentImportRow[],
|
||||
): StudentWorkbookImport {
|
||||
@@ -142,6 +323,30 @@ export class StudentsImportService {
|
||||
return String(left ?? '').trim() === String(right ?? '').trim();
|
||||
}
|
||||
|
||||
/** 按手机号预建行分组(手机号去空白后作为 key,空手机号的行不参与匹配)。 */
|
||||
private groupByPhone<T extends { phone?: string }>(rows: T[]): Map<string, T[]> {
|
||||
const byPhone = new Map<string, T[]>();
|
||||
for (const row of rows) {
|
||||
const phone = this.normalizePhone(row.phone);
|
||||
if (!phone) continue;
|
||||
const list = byPhone.get(phone);
|
||||
if (list) list.push(row);
|
||||
else byPhone.set(phone, [row]);
|
||||
}
|
||||
return byPhone;
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期字段写入前的真实日历校验:先检查 YYYY-MM-DD 形状,
|
||||
* 再用 common/china-time 的 addDaysToDateOnly 往返判断月/日是否真实存在
|
||||
* (Date.UTC 会把溢出日期归一化,如 2024-02-31 → 2024-03-02,往返不一致即非法)。
|
||||
*/
|
||||
private isValidDateOnly(value: string): boolean {
|
||||
const dateOnly = value.trim();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateOnly)) return false;
|
||||
return addDaysToDateOnly(dateOnly, 0) === dateOnly;
|
||||
}
|
||||
|
||||
private hasProfileData(row: StudentImportRow) {
|
||||
return [
|
||||
row.targetCollege,
|
||||
@@ -166,89 +371,161 @@ export class StudentsImportService {
|
||||
}
|
||||
|
||||
private async importArchiveData(
|
||||
manager: EntityManager,
|
||||
studentId: number,
|
||||
row: StudentImportRow,
|
||||
data: StudentWorkbookImport,
|
||||
) {
|
||||
data: {
|
||||
enrollments: Map<string, StudentEnrollmentImportRow[]>;
|
||||
examScores: Map<string, ExamScoreImportRow[]>;
|
||||
learningRecords: Map<string, LearningRecordImportRow[]>;
|
||||
},
|
||||
): Promise<{ imported: number; invalidDateSkipped: number }> {
|
||||
const phone = this.normalizePhone(row.phone);
|
||||
let imported = 0;
|
||||
let invalidDateSkipped = 0;
|
||||
if (this.hasProfileData(row)) {
|
||||
await this.upsertProfileFromImport(studentId, row);
|
||||
// 非法日期不写入,跳过该字段并计数
|
||||
if (row.profileDate?.trim() && !this.isValidDateOnly(row.profileDate)) {
|
||||
invalidDateSkipped++;
|
||||
}
|
||||
await this.upsertProfileFromImport(manager, studentId, row);
|
||||
imported++;
|
||||
}
|
||||
if (this.hasResultData(row)) {
|
||||
await this.upsertResultFromImport(studentId, row);
|
||||
await this.upsertResultFromImport(manager, studentId, row);
|
||||
imported++;
|
||||
}
|
||||
if (!phone) return imported;
|
||||
if (!phone) return { imported, invalidDateSkipped };
|
||||
|
||||
const enrollmentRows = data.enrollments.get(phone) ?? [];
|
||||
const examRows = data.examScores.get(phone) ?? [];
|
||||
const learningRows = data.learningRecords.get(phone) ?? [];
|
||||
|
||||
// 按 studentId 批量预取既有档案行,内存匹配(保持 upsert 语义),
|
||||
// 避免每个档案行单独 find 查询;新建保存后同步加入内存列表供后续行匹配。
|
||||
let existingEnrollments: StudentEnrollment[] = [];
|
||||
let existingExamScores: ExamScore[] = [];
|
||||
let existingLearningRecords: LearningRecord[] = [];
|
||||
if (enrollmentRows.length > 0) {
|
||||
existingEnrollments = await manager.getRepository(StudentEnrollment).find({
|
||||
where: { studentId },
|
||||
});
|
||||
}
|
||||
if (examRows.length > 0) {
|
||||
existingExamScores = await manager.getRepository(ExamScore).find({
|
||||
where: { studentId },
|
||||
});
|
||||
}
|
||||
if (learningRows.length > 0) {
|
||||
existingLearningRecords = await manager.getRepository(LearningRecord).find({
|
||||
where: { studentId },
|
||||
});
|
||||
}
|
||||
|
||||
const enrollmentByClassName = new Map<string, StudentEnrollment>();
|
||||
for (const enrollmentRow of data.enrollments.filter(
|
||||
(item) => this.normalizePhone(item.phone) === phone,
|
||||
)) {
|
||||
const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow);
|
||||
for (const enrollmentRow of enrollmentRows) {
|
||||
const enrollment = await this.upsertEnrollmentFromImport(
|
||||
manager,
|
||||
studentId,
|
||||
enrollmentRow,
|
||||
existingEnrollments,
|
||||
);
|
||||
if (!enrollment) continue;
|
||||
if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment);
|
||||
imported++;
|
||||
}
|
||||
for (const examRow of data.examScores.filter(
|
||||
(item) => this.normalizePhone(item.phone) === phone,
|
||||
)) {
|
||||
if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) {
|
||||
for (const examRow of examRows) {
|
||||
// 非法日期不写入,跳过该字段并计数
|
||||
if (examRow.examDate?.trim() && !this.isValidDateOnly(examRow.examDate)) {
|
||||
invalidDateSkipped++;
|
||||
}
|
||||
if (
|
||||
await this.upsertExamScoreFromImport(
|
||||
manager,
|
||||
studentId,
|
||||
examRow,
|
||||
enrollmentByClassName,
|
||||
existingExamScores,
|
||||
)
|
||||
) {
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
for (const learningRow of data.learningRecords.filter(
|
||||
(item) => this.normalizePhone(item.phone) === phone,
|
||||
)) {
|
||||
if (await this.upsertLearningRecordFromImport(studentId, learningRow)) {
|
||||
for (const learningRow of learningRows) {
|
||||
// recordDate 必填:非法日期视为缺省,跳过整条并计数
|
||||
if (learningRow.recordDate?.trim() && !this.isValidDateOnly(learningRow.recordDate)) {
|
||||
invalidDateSkipped++;
|
||||
continue;
|
||||
}
|
||||
if (await this.upsertLearningRecordFromImport(manager, studentId, learningRow, existingLearningRecords)) {
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
return imported;
|
||||
return { imported, invalidDateSkipped };
|
||||
}
|
||||
|
||||
private async upsertProfileFromImport(studentId: number, row: StudentImportRow) {
|
||||
private async upsertProfileFromImport(
|
||||
manager: EntityManager,
|
||||
studentId: number,
|
||||
row: StudentImportRow,
|
||||
) {
|
||||
const profileRepo = manager.getRepository(StudentProfile);
|
||||
const entity =
|
||||
(await this.profileRepo.findOne({ where: { studentId } })) ||
|
||||
this.profileRepo.create({ studentId });
|
||||
(await profileRepo.findOne({ where: { studentId } })) ||
|
||||
profileRepo.create({ studentId });
|
||||
if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim();
|
||||
if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim();
|
||||
if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim();
|
||||
if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim();
|
||||
if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim();
|
||||
if (row.grade?.trim()) entity.grade = row.grade.trim();
|
||||
if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim();
|
||||
if (row.profileDate?.trim() && this.isValidDateOnly(row.profileDate)) {
|
||||
entity.profileDate = row.profileDate.trim();
|
||||
}
|
||||
if (row.notes?.trim()) entity.notes = row.notes.trim();
|
||||
await this.profileRepo.save(entity);
|
||||
await profileRepo.save(entity);
|
||||
}
|
||||
|
||||
private async upsertResultFromImport(studentId: number, row: StudentImportRow) {
|
||||
private async upsertResultFromImport(
|
||||
manager: EntityManager,
|
||||
studentId: number,
|
||||
row: StudentImportRow,
|
||||
) {
|
||||
const resultRepo = manager.getRepository(ResultArchive);
|
||||
const entity =
|
||||
(await this.resultRepo.findOne({ where: { studentId } })) ||
|
||||
this.resultRepo.create({ studentId });
|
||||
(await resultRepo.findOne({ where: { studentId } })) ||
|
||||
resultRepo.create({ studentId });
|
||||
if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore;
|
||||
if (row.professionalFinalScore !== undefined)
|
||||
entity.professionalFinalScore = row.professionalFinalScore;
|
||||
if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim();
|
||||
if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim();
|
||||
if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim();
|
||||
await this.resultRepo.save(entity);
|
||||
await resultRepo.save(entity);
|
||||
}
|
||||
|
||||
private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) {
|
||||
private async upsertEnrollmentFromImport(
|
||||
manager: EntityManager,
|
||||
studentId: number,
|
||||
row: StudentEnrollmentImportRow,
|
||||
existing: StudentEnrollment[],
|
||||
) {
|
||||
if (!row.courseCategory?.trim() || !row.classType?.trim()) {
|
||||
return null;
|
||||
}
|
||||
const existing = await this.enrollmentRepo.find({ where: { studentId } });
|
||||
const entity =
|
||||
existing.find(
|
||||
(item) =>
|
||||
this.sameValue(item.courseCategory, row.courseCategory) &&
|
||||
this.sameValue(item.classType, row.classType) &&
|
||||
this.sameValue(item.className, row.className) &&
|
||||
this.sameValue(item.startDate, row.startDate),
|
||||
) || this.enrollmentRepo.create({ studentId });
|
||||
const enrollmentRepo = manager.getRepository(StudentEnrollment);
|
||||
let entity = existing.find(
|
||||
(item) =>
|
||||
this.sameValue(item.courseCategory, row.courseCategory) &&
|
||||
this.sameValue(item.classType, row.classType) &&
|
||||
this.sameValue(item.className, row.className) &&
|
||||
this.sameValue(item.startDate, row.startDate),
|
||||
);
|
||||
if (!entity) {
|
||||
entity = enrollmentRepo.create({ studentId });
|
||||
// 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体
|
||||
existing.push(entity);
|
||||
}
|
||||
entity.courseCategory = row.courseCategory.trim();
|
||||
entity.classType = row.classType.trim();
|
||||
if (row.className?.trim()) entity.className = row.className.trim();
|
||||
@@ -258,57 +535,74 @@ export class StudentsImportService {
|
||||
if (row.endDate?.trim()) entity.endDate = row.endDate.trim();
|
||||
if (row.status?.trim()) entity.status = row.status.trim();
|
||||
else if (!entity.status) entity.status = 'active';
|
||||
return this.enrollmentRepo.save(entity);
|
||||
return enrollmentRepo.save(entity);
|
||||
}
|
||||
|
||||
private async upsertExamScoreFromImport(
|
||||
manager: EntityManager,
|
||||
studentId: number,
|
||||
row: ExamScoreImportRow,
|
||||
enrollmentByClassName: Map<string, StudentEnrollment>,
|
||||
existing: ExamScore[],
|
||||
) {
|
||||
if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false;
|
||||
const existing = await this.examScoreRepo.find({ where: { studentId } });
|
||||
const entity =
|
||||
existing.find(
|
||||
(item) =>
|
||||
this.sameValue(item.examType, row.examType) &&
|
||||
this.sameValue(item.examName, row.examName) &&
|
||||
this.sameValue(item.subject, row.subject) &&
|
||||
this.sameValue(item.examDate, row.examDate),
|
||||
) || this.examScoreRepo.create({ studentId });
|
||||
const examScoreRepo = manager.getRepository(ExamScore);
|
||||
let entity = existing.find(
|
||||
(item) =>
|
||||
this.sameValue(item.examType, row.examType) &&
|
||||
this.sameValue(item.examName, row.examName) &&
|
||||
this.sameValue(item.subject, row.subject) &&
|
||||
this.sameValue(item.examDate, row.examDate),
|
||||
);
|
||||
if (!entity) {
|
||||
entity = examScoreRepo.create({ studentId });
|
||||
// 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体
|
||||
existing.push(entity);
|
||||
}
|
||||
entity.examType = row.examType.trim();
|
||||
entity.subject = row.subject.trim();
|
||||
entity.score = row.score;
|
||||
if (row.examName?.trim()) entity.examName = row.examName.trim();
|
||||
if (row.classAvg !== undefined) entity.classAvg = row.classAvg;
|
||||
if (row.rank !== undefined) entity.rank = row.rank;
|
||||
if (row.examDate?.trim()) entity.examDate = row.examDate.trim();
|
||||
if (row.examDate?.trim() && this.isValidDateOnly(row.examDate)) {
|
||||
entity.examDate = row.examDate.trim();
|
||||
}
|
||||
if (row.enrollmentName?.trim()) {
|
||||
const enrollment = enrollmentByClassName.get(row.enrollmentName.trim());
|
||||
if (enrollment) entity.enrollmentId = enrollment.id;
|
||||
}
|
||||
if (!entity.status) entity.status = 'active';
|
||||
await this.examScoreRepo.save(entity);
|
||||
await examScoreRepo.save(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) {
|
||||
private async upsertLearningRecordFromImport(
|
||||
manager: EntityManager,
|
||||
studentId: number,
|
||||
row: LearningRecordImportRow,
|
||||
existing: LearningRecord[],
|
||||
) {
|
||||
if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false;
|
||||
const existing = await this.learningRecordRepo.find({ where: { studentId } });
|
||||
const entity =
|
||||
existing.find(
|
||||
(item) =>
|
||||
this.sameValue(item.recordDate, row.recordDate) &&
|
||||
this.sameValue(item.recordType, row.recordType) &&
|
||||
this.sameValue(item.content, row.content),
|
||||
) || this.learningRecordRepo.create({ studentId });
|
||||
const learningRecordRepo = manager.getRepository(LearningRecord);
|
||||
let entity = existing.find(
|
||||
(item) =>
|
||||
this.sameValue(item.recordDate, row.recordDate) &&
|
||||
this.sameValue(item.recordType, row.recordType) &&
|
||||
this.sameValue(item.content, row.content),
|
||||
);
|
||||
if (!entity) {
|
||||
entity = learningRecordRepo.create({ studentId });
|
||||
// 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体
|
||||
existing.push(entity);
|
||||
}
|
||||
entity.recordDate = row.recordDate.trim();
|
||||
entity.recordType = row.recordType.trim();
|
||||
entity.content = row.content.trim();
|
||||
if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim();
|
||||
if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim();
|
||||
if (!entity.status) entity.status = 'active';
|
||||
await this.learningRecordRepo.save(entity);
|
||||
await learningRecordRepo.save(entity);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,13 @@ export class ScheduleSyncService {
|
||||
const classDingUsers = await buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds);
|
||||
const classNameMap = await loadClassNames(this.classRepo, classIds);
|
||||
|
||||
// 同名班级会串组:统计本次参与同步的班级名出现次数,
|
||||
// 重名时在考勤组名/组标识中带上 classId 以避免共享同一个考勤组。
|
||||
const classNameCounts = new Map<string, number>();
|
||||
for (const name of classNameMap.values()) {
|
||||
classNameCounts.set(name, (classNameCounts.get(name) || 0) + 1);
|
||||
}
|
||||
|
||||
// ── Step 3: 将每天的多节课合并成一个钉钉班次 ──
|
||||
// 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为
|
||||
// 同一个班次的多个 sections 写入,不能拆成多条 schedule item。
|
||||
@@ -203,8 +210,11 @@ export class ScheduleSyncService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 创建/匹配该班级的考勤组
|
||||
const groupName = `排课_${className}`;
|
||||
// 创建/匹配该班级的考勤组;重名班级时追加 classId 后缀避免串组
|
||||
const groupName =
|
||||
(classNameCounts.get(className) ?? 0) > 1
|
||||
? `排课_${classId}_${className}`
|
||||
: `排课_${className}`;
|
||||
let attendanceGroupId: number;
|
||||
try {
|
||||
const cached = groupByName.get(groupName);
|
||||
|
||||
@@ -41,7 +41,15 @@ export class SyncRunner {
|
||||
this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
await this.releaseLease(platform, runId);
|
||||
// releaseLease 自身兜底:释放租约失败不能掩盖原始的同步结果/错误
|
||||
try {
|
||||
await this.releaseLease(platform, runId);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`${platform} sync lease release failed`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -189,6 +189,7 @@ export class SyncService {
|
||||
|
||||
let matched = 0;
|
||||
let created = 0;
|
||||
let skippedNoFields = 0;
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const orgs = await manager.query<Array<{ id: number }>>(
|
||||
'SELECT id FROM organizations WHERE is_host = 1 AND status = ? LIMIT 1',
|
||||
@@ -221,6 +222,11 @@ export class SyncService {
|
||||
);
|
||||
|
||||
if (decision.action === 'match' && decision.matchStudentId) {
|
||||
// 所有映射字段均为空时跳过更新,避免用空对象覆盖已有学生数据。
|
||||
if (Object.keys(mappedValues).length === 0) {
|
||||
skippedNoFields++;
|
||||
continue;
|
||||
}
|
||||
await manager.update(Student, decision.matchStudentId, mappedValues);
|
||||
matched++;
|
||||
} else if (decision.action === 'create') {
|
||||
@@ -242,7 +248,10 @@ export class SyncService {
|
||||
return {
|
||||
recordsCount: matched + created,
|
||||
status: 'success',
|
||||
message: `匹配 ${matched} 人,新增 ${created} 人`,
|
||||
message:
|
||||
skippedNoFields > 0
|
||||
? `匹配 ${matched} 人,新增 ${created} 人,跳过无更新字段 ${skippedNoFields} 条`
|
||||
: `匹配 ${matched} 人,新增 ${created} 人`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,15 +5,29 @@ import { Bill } from '../entities/bill.entity';
|
||||
const manager = (walletBalance: number) => {
|
||||
const wallet = { id: 1, studentId: 10, balance: walletBalance };
|
||||
const saved: any[] = [];
|
||||
const updateQuery = {
|
||||
update: jest.fn(),
|
||||
set: jest.fn(),
|
||||
setParameter: jest.fn(),
|
||||
where: jest.fn(),
|
||||
andWhere: jest.fn(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
updateQuery.update.mockReturnValue(updateQuery);
|
||||
updateQuery.set.mockReturnValue(updateQuery);
|
||||
updateQuery.setParameter.mockReturnValue(updateQuery);
|
||||
updateQuery.where.mockReturnValue(updateQuery);
|
||||
updateQuery.andWhere.mockReturnValue(updateQuery);
|
||||
return {
|
||||
wallet,
|
||||
saved,
|
||||
updateQuery,
|
||||
value: {
|
||||
findOne: jest.fn(async () => wallet),
|
||||
findOneByOrFail: jest.fn(async () => wallet),
|
||||
save: jest.fn(async (value: any) => { saved.push(value); return value; }),
|
||||
create: jest.fn((_entity: unknown, value: unknown) => value),
|
||||
createQueryBuilder: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => updateQuery),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -91,6 +105,8 @@ describe('WalletsService financial boundaries', () => {
|
||||
|
||||
it('does not issue a second refund for an already cancelled bill', async () => {
|
||||
const ctx = manager(10);
|
||||
// 模拟条件 UPDATE(status <> 'cancelled')未命中:已取消账单不产生任何退款
|
||||
ctx.updateQuery.execute.mockResolvedValueOnce({ affected: 0 });
|
||||
const service = new WalletsService({} as any, {} as any, {} as any, {} as any);
|
||||
const bill = {
|
||||
id: 12,
|
||||
@@ -107,6 +123,25 @@ describe('WalletsService financial boundaries', () => {
|
||||
expect(ctx.saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('throws 余额不足 when a concurrent debit drained the wallet', async () => {
|
||||
const ctx = manager(40);
|
||||
const service = new WalletsService({} as any, {} as any, {} as any, {} as any);
|
||||
const bill = {
|
||||
id: 13,
|
||||
studentId: 10,
|
||||
totalAmount: 100,
|
||||
paidAmount: 0,
|
||||
outstandingAmount: 100,
|
||||
status: 'unpaid',
|
||||
} as Bill;
|
||||
ctx.updateQuery.execute.mockResolvedValue({ affected: 0 });
|
||||
|
||||
await expect(service.debitBill(ctx.value as any, bill, 1)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(ctx.saved.some((row) => row.type === 'bill_payment')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an amount that rounds to zero before opening a transaction', async () => {
|
||||
const dataSource = { transaction: jest.fn() };
|
||||
const service = new WalletsService({} as any, {} as any, {} as any, dataSource as any);
|
||||
@@ -152,3 +187,54 @@ describe('WalletsService wallet locking', () => {
|
||||
expect(ctx.query.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WalletsService transaction history', () => {
|
||||
it('limits transactions to the latest 200 rows', async () => {
|
||||
const transactionRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const service = new WalletsService({} as any, transactionRepo as any, {} as any, {} as any);
|
||||
|
||||
await service.findTransactions(10);
|
||||
|
||||
expect(transactionRepo.find).toHaveBeenCalledWith({
|
||||
where: { studentId: 10 },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('WalletsService batch operation key length', () => {
|
||||
it('caps operation_id at 64 chars while keeping the :studentId suffix', async () => {
|
||||
const wallet = { id: 1, studentId: 10, balance: 2 };
|
||||
const saved: any[] = [];
|
||||
const txManager = {
|
||||
findOne: jest.fn(async () => wallet),
|
||||
findOneByOrFail: jest.fn(async () => wallet),
|
||||
save: jest.fn(async (value: any) => { saved.push(value); return value; }),
|
||||
create: jest.fn((_entity: unknown, value: unknown) => value),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (work: (manager: any) => Promise<any>) => work(txManager)),
|
||||
};
|
||||
const service = new WalletsService(
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ findOne: jest.fn(async () => ({ id: 10 })) } as any,
|
||||
dataSource as any,
|
||||
);
|
||||
const longId = 'x'.repeat(70);
|
||||
|
||||
await service.batchChangeBalance({
|
||||
operationId: longId,
|
||||
studentIds: [10],
|
||||
amount: -1,
|
||||
type: 'adjustment',
|
||||
description: '批量调账',
|
||||
});
|
||||
|
||||
const tx = saved.find((row: any) => row.type === 'adjustment');
|
||||
expect(tx.operationId).toBe(`${longId.slice(0, 50)}:10`);
|
||||
expect(tx.operationId.length).toBeLessThanOrEqual(64);
|
||||
expect(tx.operationId.endsWith(':10')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { WalletTransaction } from '../entities/wallet-transaction.entity';
|
||||
import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto';
|
||||
import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { escapeLike } from '../common/like-escape';
|
||||
|
||||
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
|
||||
|
||||
@@ -30,7 +31,7 @@ export class WalletsService {
|
||||
|
||||
if (query?.keyword) {
|
||||
qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', {
|
||||
keyword: `%${query.keyword}%`,
|
||||
keyword: `%${escapeLike(query.keyword)}%`,
|
||||
});
|
||||
}
|
||||
if (query?.roomType) {
|
||||
@@ -99,7 +100,12 @@ export class WalletsService {
|
||||
}
|
||||
|
||||
async findTransactions(studentId: number) {
|
||||
return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } });
|
||||
// 只返回最近 200 条流水,避免无分页全量返回拖垮接口
|
||||
return this.transactionRepo.find({
|
||||
where: { studentId },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) {
|
||||
@@ -163,6 +169,11 @@ export class WalletsService {
|
||||
const uniqueStudentIds = Array.from(new Set(batch.studentIds));
|
||||
const results: Array<{ wallet: StudentWallet; payments: Bill[] }> = [];
|
||||
for (const studentId of uniqueStudentIds) {
|
||||
// operation_id 为 varchar(64):operationId 最长 64,直接拼 :studentId 会溢出。
|
||||
// 截断 operationId 前缀并整体兜底截到 64,保证不超长且尽量保留 :studentId。
|
||||
const opKey = operationId
|
||||
? `${operationId.slice(0, 50)}:${studentId}`.slice(0, 64)
|
||||
: undefined;
|
||||
results.push(
|
||||
await this.changeBalanceOnce(
|
||||
{
|
||||
@@ -172,7 +183,7 @@ export class WalletsService {
|
||||
description: batch.description,
|
||||
},
|
||||
recordedBy,
|
||||
operationId ? `${operationId}:${studentId}` : undefined,
|
||||
opKey,
|
||||
manager,
|
||||
),
|
||||
);
|
||||
@@ -206,11 +217,24 @@ export class WalletsService {
|
||||
return manager.save(bill);
|
||||
}
|
||||
|
||||
// 原子扣款:由数据库在同一 UPDATE 中扣减并校验余额,避免并发下 check-then-act 导致 double-spend
|
||||
const debit = await manager
|
||||
.createQueryBuilder()
|
||||
.update(StudentWallet)
|
||||
.set({ balance: () => 'balance - :amount' })
|
||||
.setParameter('amount', amount)
|
||||
.where('id = :id', { id: wallet.id })
|
||||
.andWhere('balance >= :amount', { amount })
|
||||
.execute();
|
||||
if (debit.affected !== 1) {
|
||||
throw new BadRequestException('余额不足');
|
||||
}
|
||||
// balanceAfter 为事务内估算值(内存旧值 ± amount):原子 UPDATE 已由数据库完成,
|
||||
// 并发下流水余额可能与最终 DB 余额略有偏差,但金额本身由数据库条件更新保证一致。
|
||||
wallet.balance = money(Number(wallet.balance) - amount);
|
||||
bill.paidAmount = money(paid + amount);
|
||||
bill.outstandingAmount = money(Math.max(0, total - Number(bill.paidAmount)));
|
||||
bill.status = bill.outstandingAmount <= 0 ? 'paid' : 'partially_paid';
|
||||
await manager.save(wallet);
|
||||
await manager.save(bill);
|
||||
await manager.save(
|
||||
manager.create(WalletTransaction, {
|
||||
@@ -227,13 +251,38 @@ export class WalletsService {
|
||||
}
|
||||
|
||||
async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) {
|
||||
if (bill.status === 'cancelled') return bill;
|
||||
// 幂等:只有成功把账单从未取消 → 取消的那一次调用才执行退款。
|
||||
// 条件 UPDATE 与调用方事务内的悲观锁是双保险,并发重复退款不会重复冲正。
|
||||
const claimed = await manager
|
||||
.createQueryBuilder()
|
||||
.update(Bill)
|
||||
.set({ status: 'cancelled', cancelledAt: () => 'NOW()', cancelReason: reason })
|
||||
.where('id = :id', { id: bill.id })
|
||||
.andWhere('status <> :cancelled', { cancelled: 'cancelled' })
|
||||
.execute();
|
||||
if (claimed.affected !== 1) return bill; // 已被其他请求取消,直接返回(幂等)
|
||||
|
||||
bill.status = 'cancelled';
|
||||
bill.cancelledAt = new Date();
|
||||
bill.cancelReason = reason;
|
||||
|
||||
const paid = Math.max(0, Math.min(money(bill.paidAmount), money(bill.totalAmount)));
|
||||
if (paid > 0) {
|
||||
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
|
||||
// 原子退款:余额累加由数据库完成,避免并发覆盖
|
||||
const credit = await manager
|
||||
.createQueryBuilder()
|
||||
.update(StudentWallet)
|
||||
.set({ balance: () => 'balance + :amount' })
|
||||
.setParameter('amount', paid)
|
||||
.where('id = :id', { id: wallet.id })
|
||||
.execute();
|
||||
if (credit.affected !== 1) {
|
||||
throw new BadRequestException('学生钱包不存在,退款失败');
|
||||
}
|
||||
// balanceAfter 为事务内估算值(内存旧值 + paid):原子 UPDATE 已由数据库完成,
|
||||
// 并发下流水余额可能与最终 DB 余额略有偏差,但冲正金额本身由数据库累加保证一致。
|
||||
wallet.balance = money(Number(wallet.balance) + paid);
|
||||
await manager.save(wallet);
|
||||
await manager.save(
|
||||
manager.create(WalletTransaction, {
|
||||
studentId: bill.studentId,
|
||||
@@ -246,11 +295,8 @@ export class WalletsService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
bill.status = 'cancelled';
|
||||
bill.paidAmount = 0;
|
||||
bill.outstandingAmount = 0;
|
||||
bill.cancelledAt = new Date();
|
||||
bill.cancelReason = reason;
|
||||
return manager.save(bill);
|
||||
}
|
||||
|
||||
@@ -269,9 +315,14 @@ export class WalletsService {
|
||||
.getMany();
|
||||
const settled: Bill[] = [];
|
||||
for (const bill of bills) {
|
||||
const wallet = await manager.findOne(StudentWallet, { where: { studentId } });
|
||||
if (!wallet || money(wallet.balance) <= 0) break;
|
||||
settled.push(await this.debitBill(manager, bill, recordedBy));
|
||||
const paidBefore = money(bill.paidAmount);
|
||||
const result = await this.debitBill(manager, bill, recordedBy);
|
||||
if (money(result.paidAmount) > paidBefore) {
|
||||
settled.push(result);
|
||||
} else {
|
||||
// 余额已不足以支付后续账单,停止结算
|
||||
break;
|
||||
}
|
||||
}
|
||||
return settled;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user