feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -65,7 +65,7 @@ export class BillsExportService {
const total = Number(bill.totalAmount || 0);
ws.addRow({
id: bill.id,
studentName: (bill as any).student?.name || '-',
studentName: bill.student?.name || '-',
period: `${bill.periodStart} ~ ${bill.periodEnd}`,
shared: Number(bill.sharedAmount),
personal: Number(bill.personalAmount),
@@ -96,7 +96,7 @@ export class BillsExportService {
for (const item of bill.items || []) {
ws2.addRow({
billId: bill.id,
studentName: (bill as any).student?.name || '-',
studentName: bill.student?.name || '-',
expenseType: item.expenseType,
description: item.description,
days: item.days,
@@ -158,7 +158,9 @@ export class BillsExportService {
fontRegistered = true;
break;
}
} catch {}
} catch {
// 字体注册失败时回退到默认字体
}
}
if (!fontRegistered) {
// 如果没有中文字体,使用 Helvetica中文可能乱码
@@ -183,7 +185,7 @@ export class BillsExportService {
// 基本信息
doc.fontSize(12).fillColor('#000');
doc.text(`学生姓名: ${(bill as any).student?.name || '-'}`);
doc.text(`学生姓名: ${bill.student?.name || '-'}`);
doc.text(`计费周期: ${bill.periodStart} ~ ${bill.periodEnd}`);
doc.text(`账单状态: ${statusMap[bill.status] || bill.status}`);
doc.moveDown(0.5);

View File

@@ -0,0 +1,285 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy, Room } from '../entities';
import { WalletsService } from '../wallets/wallets.service';
import type { GenerateBillsDto } from './dto/bill.dto';
@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,
) {}
async generateBillsOnce(dto: GenerateBillsDto) {
const { periodStart, periodEnd } = dto.billingMonth
? this.resolveBillingPeriod(dto.billingMonth)
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
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', {
periodStart,
periodEnd,
})
.andWhere('e.status = :status', { status: 'active' })
.getMany();
const longTermOccupancies: Occupancy[] = [];
const roomExpMap = new Map<number, RoomExpense[]>();
for (const expense of roomExpenses) {
const expenses = roomExpMap.get(expense.roomId) || [];
expenses.push(expense);
roomExpMap.set(expense.roomId, expenses);
}
const roomIds = new Set([
...roomExpMap.keys(),
...longTermOccupancies
.filter((occupancy) => occupancy.stayType === 'long')
.map((occupancy) => occupancy.roomId),
]);
const studentBillData = new Map<
number,
{ shared: number; items: Array<Record<string, unknown>> }
>();
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 shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long');
const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long');
for (const occupancy of longTermOccs) {
const rent = this.calculateLongTermRent(
occupancy,
periodStart,
periodEnd,
Number(occupancy.room?.monthlyRate || 0),
);
if (rent <= 0) continue;
const data = studentBillData.get(occupancy.studentId) || { shared: 0, items: [] };
data.shared += rent;
data.items.push({
roomId,
expenseType: 'rent',
description: `长租月租费 (${occupancy.room?.roomNumber || '未知房间'})`,
days: 0,
totalRoomDays: 0,
roomTotalAmount: rent,
studentAmount: rent,
});
studentBillData.set(occupancy.studentId, data);
}
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);
return { studentId: occupancy.studentId, days };
});
const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0);
if (totalDays === 0) continue;
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()) {
const amount =
index === eligibleDays.length - 1
? Number((expenseTotal - allocated).toFixed(2))
: Number(((entry.days / totalDays) * expenseTotal).toFixed(2));
allocated = Number((allocated + amount).toFixed(2));
const data = studentBillData.get(entry.studentId) || { shared: 0, items: [] };
data.shared += amount;
data.items.push({
roomExpenseId: expense.id,
roomId,
expenseType: expense.expenseType,
description: `${expense.expenseType} 分摊`,
days: entry.days,
totalRoomDays: totalDays,
roomTotalAmount: expense.amount,
studentAmount: amount,
});
studentBillData.set(entry.studentId, data);
}
}
}
const personalExps = await this.personalExpRepo
.createQueryBuilder('pe')
.where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', {
periodStart,
periodEnd,
})
.andWhere('pe.status = :status', { status: 'active' })
.andWhere('pe.billId IS NULL')
.getMany();
const personalMap = new Map<number, number>();
const personalItems = new Map<number, Array<Record<string, unknown>>>();
for (const expense of personalExps) {
personalMap.set(
expense.studentId,
(personalMap.get(expense.studentId) || 0) + Number(expense.amount),
);
const items = personalItems.get(expense.studentId) || [];
items.push({
personalExpenseId: expense.id,
roomId: expense.roomId,
expenseType: expense.expenseType,
description: `个人费用: ${expense.description || expense.expenseType}`,
days: 0,
totalRoomDays: 0,
roomTotalAmount: expense.amount,
studentAmount: expense.amount,
});
personalItems.set(expense.studentId, items);
}
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',
}),
);
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;
});
return {
message: `成功生成 ${bills.length} 条账单`,
count: bills.length,
bills,
periodStart,
periodEnd,
};
}
private calculateLongTermRent(
occupancy: Occupancy,
periodStart: string,
periodEnd: string,
monthlyRate: number,
) {
const activeStart =
occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart;
const activeEnd =
occupancy.billingEndDate && occupancy.billingEndDate < periodEnd
? occupancy.billingEndDate
: periodEnd;
if (activeEnd < activeStart || monthlyRate <= 0) return 0;
const [startYear, startMonth] = activeStart.split('-').map(Number);
const [endYear, endMonth] = activeEnd.split('-').map(Number);
let total = 0;
for (
let year = startYear, month = startMonth;
year < endYear || (year === endYear && month <= endMonth);
) {
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
const prefix = `${year}-${String(month).padStart(2, '0')}-`;
const overlapStart = activeStart > `${prefix}01` ? activeStart : `${prefix}01`;
const monthEnd = `${prefix}${String(daysInMonth).padStart(2, '0')}`;
const overlapEnd = activeEnd < monthEnd ? activeEnd : monthEnd;
const days =
Math.floor(
(Date.parse(`${overlapEnd}T00:00:00Z`) - Date.parse(`${overlapStart}T00:00:00Z`)) /
86_400_000,
) + 1;
total += (monthlyRate * days) / daysInMonth;
if (++month > 12) {
month = 1;
year++;
}
}
return Number(total.toFixed(2));
}
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
private resolveBillingPeriod(billingMonth: string) {
const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || '');
if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
const year = Number(matched[1]);
const month = Number(matched[2]);
if (month < 1 || month > 12) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
const targetMonthStart = new Date(year, month - 1, 1);
const currentMonthStart = new Date();
currentMonthStart.setDate(1);
currentMonthStart.setHours(0, 0, 0, 0);
if (targetMonthStart >= currentMonthStart)
throw new BadRequestException('只能生成已结束月份的账单');
const targetMonthEnd = new Date(year, month, 0);
const pad = (value: number) => String(value).padStart(2, '0');
return {
periodStart: `${year}-${pad(month)}-01`,
periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}`,
};
}
}

View File

@@ -24,7 +24,7 @@ import { BillsExportService } from './bills-export.service';
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import type { Response } from 'express';
@@ -43,16 +43,9 @@ export class BillsController {
@Post('generate')
@RequirePermission('bill:generate')
async generateBills(@Body() dto: GenerateBillsDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.generateBills(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '生成账单',
detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '生成账单', detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count}`,
});
// Send bill_generated notifications
try {
@@ -100,17 +93,9 @@ export class BillsController {
@Body() dto: UpdateBillStatusDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateStatus(id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '确认账单',
targetId: id,
targetType: 'bill',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '确认账单', targetId: id, targetType: 'bill',
});
// Send bill_paid notification
try {
@@ -130,16 +115,9 @@ export class BillsController {
@Put('batch/status')
@RequirePermission('bill:confirm')
async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchUpdateStatus(body.ids, body.status);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '确认账单',
detail: `IDs: ${body.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '确认账单', detail: `IDs: ${body.ids.join(',')}`,
});
// Send bill_paid notifications (batch)
try {
@@ -163,17 +141,8 @@ export class BillsController {
@RequirePermission('bill:delete')
async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: any) {
const result = await this.service.cancel(id, dto, req.user?.id);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '取消账单并冲正',
targetId: id,
targetType: 'bill',
detail: dto.reason,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', detail: dto.reason,
});
return result;
}
@@ -181,17 +150,29 @@ export class BillsController {
@Delete(':id')
@RequirePermission('bill:delete')
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '归档账单',
targetId: id,
targetType: 'bill',
ipAddress,
userAgent,
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: any) {
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: any) {
const result = await this.service.batchPurge(body.ids || []);
await logAudit(this.logService, req, {
module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@@ -199,16 +180,9 @@ export class BillsController {
@Post('batch/delete')
@RequirePermission('bill:delete')
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRemove(body.ids);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '批量归档账单',
detail: `IDs: ${body.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '批量归档账单', detail: `IDs: ${body.ids.join(',')}`,
});
return result;
}
@@ -223,15 +197,8 @@ export class BillsController {
@Res() res?: Response,
@Req() req?: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req?.user?.id,
username: req?.user?.username,
module: '账单管理',
action: '导出账单',
detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '导出账单', detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`,
});
return this.exportService.exportExcel(
{
@@ -247,16 +214,8 @@ export class BillsController {
@Get('export/pdf/:id')
@RequirePermission('bill:export-pdf')
async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req?.user?.id,
username: req?.user?.username,
module: '账单管理',
action: '导出账单',
targetId: id,
targetType: 'bill',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '导出账单', targetId: id, targetType: 'bill',
});
return this.exportService.exportStudentPdf(id, res);
}

View File

@@ -11,6 +11,7 @@ import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { BillsService } from './bills.service';
import { BillsGenerationService } from './bills-generation.service';
import { BillsExportService } from './bills-export.service';
import { BillsController } from './bills.controller';
@@ -30,7 +31,7 @@ import { BillsController } from './bills.controller';
WalletsModule,
],
controllers: [BillsController],
providers: [BillsService, BillsExportService],
providers: [BillsService, BillsExportService, BillsGenerationService],
exports: [BillsService],
})
export class BillsModule {}

View File

@@ -0,0 +1,33 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { BillsController } from './bills.controller';
describe('BillsController purge routes', () => {
it('requires bill:purge on permanent delete routes', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, BillsController.prototype.purge)).toEqual([
'bill:purge',
]);
expect(Reflect.getMetadata(PERMISSION_KEY, BillsController.prototype.batchPurge)).toEqual([
'bill:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除账单(不可恢复)' }) };
const log = jest.fn().mockResolvedValue(undefined);
const controller = new BillsController(
service as never,
{} as never,
{ log } as never,
{} as never,
{} as never,
{} as never,
);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge(1, req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '账单管理', action: '永久删除账单', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,71 @@
import { BadRequestException } from '@nestjs/common';
import { BillsService } from './bills.service';
describe('BillsService.purge', () => {
const createService = (overrides?: { bill?: Record<string, unknown> }) => {
const bill = {
id: 1,
studentId: 2,
status: 'cancelled',
paidAmount: 0,
...overrides?.bill,
};
const billRepo = {
findOne: jest.fn().mockResolvedValue(bill),
find: jest.fn().mockResolvedValue([bill]),
};
const personalExpRepo = { count: jest.fn().mockResolvedValue(0) };
const manager = {
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb(manager)),
};
const service = new BillsService(
billRepo as never,
{} as never,
{} as never,
personalExpRepo as never,
{} as never,
{} as never,
dataSource as never,
{} as never,
);
return { service, billRepo, personalExpRepo, dataSource, manager };
};
it('rejects bills that are not cancelled', async () => {
const { service, dataSource } = createService({ bill: { status: 'unpaid' } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已取消账单可以永久删除,请先取消账单'),
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects cancelled bills with paid amount', async () => {
const { service, dataSource } = createService({ bill: { paidAmount: 100 } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('已发生资金流水的账单不能永久删除'),
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects cancelled bills still referenced by personal expenses', async () => {
const { service, personalExpRepo, dataSource } = createService();
personalExpRepo.count.mockResolvedValue(1);
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('该账单仍关联个人费用,无法永久删除'),
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('deletes bill items and bill in a transaction', async () => {
const { service, dataSource, manager } = createService();
await expect(service.purge(1)).resolves.toEqual({
message: '已永久删除账单(不可恢复)',
});
expect(dataSource.transaction).toHaveBeenCalled();
expect(manager.delete).toHaveBeenNthCalledWith(1, expect.anything(), { billId: 1 });
expect(manager.delete).toHaveBeenNthCalledWith(2, expect.anything(), 1);
});
});

View File

@@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { BillsService } from './bills.service';
import { BillsGenerationService } from './bills-generation.service';
import { Bill } from '../entities/bill.entity';
import { BillItem } from '../entities/bill-item.entity';
import { RoomExpense } from '../entities/room-expense.entity';
@@ -78,6 +79,7 @@ describe('BillsService — generateBills', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BillsService,
BillsGenerationService,
{ provide: getRepositoryToken(Bill), useValue: billRepo },
{ provide: getRepositoryToken(BillItem), useValue: itemRepo },
{ provide: getRepositoryToken(RoomExpense), useValue: roomExpRepo },
@@ -567,6 +569,17 @@ describe('BillsService — allocation rounding boundary', () => {
})),
})),
};
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,
);
const service = new BillsService(
billRepo as any,
itemRepo as any,
@@ -575,7 +588,8 @@ describe('BillsService — allocation rounding boundary', () => {
occRepo as any,
roomRepo as any,
dataSource as any,
{ debitBill: jest.fn(async (_manager, bill) => bill) } as any,
walletsService,
generation,
);
(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,

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, DataSource, EntityManager } from 'typeorm';
import { Repository, In, DataSource } from 'typeorm';
import { Bill } from '../entities/bill.entity';
import { BillItem } from '../entities/bill-item.entity';
import { RoomExpense } from '../entities/room-expense.entity';
@@ -11,6 +11,7 @@ import { StudentWallet } from '../entities/student-wallet.entity';
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
import { WalletsService } from '../wallets/wallets.service';
import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
import { BillsGenerationService } from './bills-generation.service';
interface AgentBillRow {
billId: string | number;
@@ -23,7 +24,6 @@ interface AgentBillRow {
status: string;
}
@Injectable()
export class BillsService {
constructor(
@@ -35,6 +35,7 @@ export class BillsService {
@InjectRepository(Room) private roomRepo: Repository<Room>,
private dataSource: DataSource,
private walletsService: WalletsService,
private generation: BillsGenerationService,
@Optional()
private financialOperations?: FinancialOperationsService,
) {}
@@ -44,220 +45,12 @@ export class BillsService {
*/
async generateBills(dto: GenerateBillsDto) {
const { operationId, ...request } = dto;
const work = () => this.generateBillsOnce(request as GenerateBillsDto);
const work = () => this.generation.generateBillsOnce(request as GenerateBillsDto);
return this.financialOperations
? this.financialOperations.run(operationId, 'bill.generate', work)
: work();
}
private async generateBillsOnce(dto: GenerateBillsDto) {
const { periodStart, periodEnd } = dto.billingMonth
? this.resolveBillingPeriod(dto.billingMonth)
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
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', { periodStart, periodEnd })
.andWhere('e.status = :status', { status: 'active' })
.getMany();
const longTermOccupancies: Occupancy[] = [];
const roomExpMap = new Map<number, RoomExpense[]>();
for (const expense of roomExpenses) {
const expenses = roomExpMap.get(expense.roomId) || [];
expenses.push(expense);
roomExpMap.set(expense.roomId, expenses);
}
const roomIds = new Set([
...roomExpMap.keys(),
...longTermOccupancies.filter((occupancy) => occupancy.stayType === 'long').map((occupancy) => occupancy.roomId),
]);
const studentBillData = new Map<number, { shared: number; items: Array<Record<string, unknown>> }>();
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 shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long');
const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long');
for (const occupancy of longTermOccs) {
const rent = this.calculateLongTermRent(
occupancy,
periodStart,
periodEnd,
Number(occupancy.room?.monthlyRate || 0),
);
if (rent <= 0) continue;
const data = studentBillData.get(occupancy.studentId) || { shared: 0, items: [] };
data.shared += rent;
data.items.push({
roomId,
expenseType: 'rent',
description: `长租月租费 (${occupancy.room?.roomNumber || '未知房间'})`,
days: 0,
totalRoomDays: 0,
roomTotalAmount: rent,
studentAmount: rent,
});
studentBillData.set(occupancy.studentId, data);
}
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);
return { studentId: occupancy.studentId, days };
});
const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0);
if (totalDays === 0) continue;
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()) {
const amount = index === eligibleDays.length - 1
? Number((expenseTotal - allocated).toFixed(2))
: Number(((entry.days / totalDays) * expenseTotal).toFixed(2));
allocated = Number((allocated + amount).toFixed(2));
const data = studentBillData.get(entry.studentId) || { shared: 0, items: [] };
data.shared += amount;
data.items.push({
roomExpenseId: expense.id,
roomId,
expenseType: expense.expenseType,
description: `${expense.expenseType} 分摊`,
days: entry.days,
totalRoomDays: totalDays,
roomTotalAmount: expense.amount,
studentAmount: amount,
});
studentBillData.set(entry.studentId, data);
}
}
}
const personalExps = await this.personalExpRepo
.createQueryBuilder('pe')
.where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', { periodStart, periodEnd })
.andWhere('pe.status = :status', { status: 'active' })
.andWhere('pe.billId IS NULL')
.getMany();
const personalMap = new Map<number, number>();
const personalItems = new Map<number, Array<Record<string, unknown>>>();
for (const expense of personalExps) {
personalMap.set(expense.studentId, (personalMap.get(expense.studentId) || 0) + Number(expense.amount));
const items = personalItems.get(expense.studentId) || [];
items.push({
personalExpenseId: expense.id,
roomId: expense.roomId,
expenseType: expense.expenseType,
description: `个人费用: ${expense.description || expense.expenseType}`,
days: 0,
totalRoomDays: 0,
roomTotalAmount: expense.amount,
studentAmount: expense.amount,
});
personalItems.set(expense.studentId, items);
}
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',
}));
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;
});
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd };
}
private calculateLongTermRent(occupancy: Occupancy, periodStart: string, periodEnd: string, monthlyRate: number) {
const activeStart = occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart;
const activeEnd = occupancy.billingEndDate && occupancy.billingEndDate < periodEnd
? occupancy.billingEndDate
: periodEnd;
if (activeEnd < activeStart || monthlyRate <= 0) return 0;
const [startYear, startMonth] = activeStart.split('-').map(Number);
const [endYear, endMonth] = activeEnd.split('-').map(Number);
let total = 0;
for (let year = startYear, month = startMonth; year < endYear || (year === endYear && month <= endMonth);) {
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
const prefix = `${year}-${String(month).padStart(2, '0')}-`;
const overlapStart = activeStart > `${prefix}01` ? activeStart : `${prefix}01`;
const monthEnd = `${prefix}${String(daysInMonth).padStart(2, '0')}`;
const overlapEnd = activeEnd < monthEnd ? activeEnd : monthEnd;
const days = Math.floor((Date.parse(`${overlapEnd}T00:00:00Z`) - Date.parse(`${overlapStart}T00:00:00Z`)) / 86_400_000) + 1;
total += monthlyRate * days / daysInMonth;
if (++month > 12) { month = 1; year++; }
}
return Number(total.toFixed(2));
}
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
private resolveBillingPeriod(billingMonth: string) {
const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || '');
if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
const year = Number(matched[1]);
const month = Number(matched[2]);
if (month < 1 || month > 12) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
const targetMonthStart = new Date(year, month - 1, 1);
const currentMonthStart = new Date();
currentMonthStart.setDate(1);
currentMonthStart.setHours(0, 0, 0, 0);
if (targetMonthStart >= currentMonthStart) throw new BadRequestException('只能生成已结束月份的账单');
const targetMonthEnd = new Date(year, month, 0);
const pad = (value: number) => String(value).padStart(2, '0');
return { periodStart: `${year}-${pad(month)}-01`, periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}` };
}
async createImmediatePersonalBill(
expense: PersonalExpense,
periodStart: string,
@@ -286,7 +79,8 @@ export class BillsService {
personalExpenseId: expense.id,
roomId: expense.roomId,
expenseType: expense.expenseType,
description: expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'),
description:
expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'),
days: 0,
totalRoomDays: 0,
roomTotalAmount: expense.amount,
@@ -323,19 +117,28 @@ export class BillsService {
}
async agentSearchBills(query: {
keyword?: string; periodStart?: string; periodEnd?: string; status?: string; limit?: number;
keyword?: string;
periodStart?: string;
periodEnd?: string;
status?: string;
limit?: number;
}) {
const billSelects = [
['student.name', 'studentName'],
['bill.periodStart', 'periodStart'],
['bill.periodEnd', 'periodEnd'],
['bill.totalAmount', 'totalAmount'],
['bill.paidAmount', 'paidAmount'],
['bill.outstandingAmount', 'outstandingAmount'],
['bill.status', 'status'],
] as const;
const qb = this.billRepo
.createQueryBuilder('bill')
.leftJoin('bill.student', 'student')
.select('bill.id', 'billId')
.addSelect('student.name', 'studentName')
.addSelect('bill.periodStart', 'periodStart')
.addSelect('bill.periodEnd', 'periodEnd')
.addSelect('bill.totalAmount', 'totalAmount')
.addSelect('bill.paidAmount', 'paidAmount')
.addSelect('bill.outstandingAmount', 'outstandingAmount')
.addSelect('bill.status', 'status');
.select('bill.id', 'billId');
for (const [column, alias] of billSelects) {
qb.addSelect(column, alias);
}
if (query.keyword) {
const billId = Number(query.keyword);
if (Number.isInteger(billId) && billId > 0) {
@@ -347,14 +150,21 @@ export class BillsService {
qb.andWhere('student.name LIKE :keyword', { keyword: `%${query.keyword}%` });
}
}
if (query.periodStart) qb.andWhere('bill.periodStart >= :periodStart', { periodStart: query.periodStart });
if (query.periodEnd) qb.andWhere('bill.periodEnd <= :periodEnd', { periodEnd: query.periodEnd });
if (query.periodStart)
qb.andWhere('bill.periodStart >= :periodStart', { periodStart: query.periodStart });
if (query.periodEnd)
qb.andWhere('bill.periodEnd <= :periodEnd', { periodEnd: query.periodEnd });
if (query.status) qb.andWhere('bill.status = :status', { status: query.status });
const rows = await qb.orderBy('bill.generatedAt', 'DESC').limit(query.limit ?? 20).getRawMany<AgentBillRow>();
const rows = await qb
.orderBy('bill.generatedAt', 'DESC')
.limit(query.limit ?? 20)
.getRawMany<AgentBillRow>();
return rows.map((row) => ({
...row,
billId: Number(row.billId), totalAmount: Number(row.totalAmount || 0),
paidAmount: Number(row.paidAmount || 0), outstandingAmount: Number(row.outstandingAmount || 0),
billId: Number(row.billId),
totalAmount: Number(row.totalAmount || 0),
paidAmount: Number(row.paidAmount || 0),
outstandingAmount: Number(row.outstandingAmount || 0),
}));
}
@@ -374,7 +184,9 @@ export class BillsService {
.createQueryBuilder('wallet')
.where('wallet.studentId IN (:...ids)', { ids: studentIds })
.getMany();
const balanceMap = new Map(wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]));
const balanceMap = new Map(
wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]),
);
return bills.map((bill) => ({
...bill,
walletBalance: Number((balanceMap.get(bill.studentId) || 0).toFixed(2)),
@@ -394,7 +206,8 @@ export class BillsService {
async batchUpdateStatus(ids: number[], status: string) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要更新的账单');
if (!['unpaid', 'partially_paid', 'paid'].includes(status)) throw new BadRequestException('账单状态无效');
if (!['unpaid', 'partially_paid', 'paid'].includes(status))
throw new BadRequestException('账单状态无效');
const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
for (const bill of bills) this.assertStatusMatchesAmounts(bill, status);
@@ -410,16 +223,18 @@ export class BillsService {
async cancel(id: number, dto: CancelBillDto, recordedBy?: number) {
const reason = dto.reason?.trim();
if (!reason) throw new BadRequestException('取消原因不能为空');
const work = () => this.dataSource.transaction(async (manager) => {
const bill = await manager.createQueryBuilder(Bill, 'bill')
.where('bill.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消');
await manager.update(PersonalExpense, { billId: id }, { billId: null });
return this.walletsService.refundBill(manager, bill, reason, recordedBy);
});
const work = () =>
this.dataSource.transaction(async (manager) => {
const bill = await manager
.createQueryBuilder(Bill, 'bill')
.where('bill.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消');
await manager.update(PersonalExpense, { billId: id }, { billId: null });
return this.walletsService.refundBill(manager, bill, reason, recordedBy);
});
return this.financialOperations
? this.financialOperations.run(dto.operationId, `bill.cancel:${id}`, work)
: work();
@@ -441,6 +256,65 @@ export class BillsService {
return { message: '账单已归档' };
}
async purge(id: number) {
const bill = await this.billRepo.findOne({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'cancelled') {
throw new BadRequestException('仅已取消账单可以永久删除,请先取消账单');
}
if (Number(bill.paidAmount) > 0) {
throw new BadRequestException('已发生资金流水的账单不能永久删除');
}
const personalExpenseCount = await this.personalExpRepo.count({ where: { billId: id } });
if (personalExpenseCount > 0) {
throw new BadRequestException('该账单仍关联个人费用,无法永久删除');
}
await this.dataSource.transaction(async (manager) => {
await manager.delete(BillItem, { billId: id });
await manager.delete(Bill, id);
});
return { message: '已永久删除账单(不可恢复)' };
}
async batchPurge(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的账单');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('账单 ID 无效');
}
const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
const personalExpenseCount = await this.personalExpRepo.count({
where: { billId: In(uniqueIds) },
});
if (personalExpenseCount > 0) {
throw new BadRequestException('选中账单仍关联个人费用,无法永久删除');
}
const deleted: number[] = [];
const skipped: string[] = [];
for (const bill of bills) {
if (bill.status !== 'cancelled') {
skipped.push(`账单${bill.id}(未取消)`);
continue;
}
if (Number(bill.paidAmount) > 0) {
skipped.push(`账单${bill.id}(已支付)`);
continue;
}
await this.dataSource.transaction(async (manager) => {
await manager.delete(BillItem, { billId: bill.id });
await manager.delete(Bill, bill.id);
});
deleted.push(bill.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 };
}
async batchRemove(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的账单');
@@ -469,11 +343,12 @@ export class BillsService {
private assertStatusMatchesAmounts(bill: Bill, status: string) {
const paid = Number(bill.paidAmount || 0);
const outstanding = Number(bill.outstandingAmount || 0);
const matches = status === 'paid'
? outstanding <= 0
: status === 'partially_paid'
? paid > 0 && outstanding > 0
: status === 'unpaid' && paid <= 0 && outstanding > 0;
const matches =
status === 'paid'
? outstanding <= 0
: status === 'partially_paid'
? paid > 0 && outstanding > 0
: status === 'unpaid' && paid <= 0 && outstanding > 0;
if (!matches) throw new BadRequestException('账单状态必须与实付及未付金额一致');
}
}

View File

@@ -0,0 +1,172 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository, In } from 'typeorm';
import { Class, ClassStudent, ClassSchedule, AttendanceRecord } from '../entities';
import { Classroom } from '../entities/classroom.entity';
import { syncDingTalkStudents } from '../integration/dingtalk-student-sync';
import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto';
interface AgentClassRow {
id: string | number;
name: string;
code: string;
studentCount: string | number;
}
@Injectable()
export class ClassesQueriesService {
constructor(
@InjectRepository(Class) private readonly classRepo: Repository<Class>,
@InjectRepository(ClassStudent) private readonly classStudentRepo: Repository<ClassStudent>,
@InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository<AttendanceRecord>,
private readonly dataSource: DataSource,
) {}
async agentSearchClasses(
accessibleClassIds: number[] | undefined,
query: { keyword?: string; status?: string; limit?: number },
) {
if (accessibleClassIds?.length === 0) return [];
const qb = this.classRepo
.createQueryBuilder('class')
.leftJoin(
ClassStudent,
'classStudent',
'classStudent.classId = class.id AND classStudent.status = :activeStudent',
{ activeStudent: 'active' },
)
.select('class.id', 'id');
const classSelects = [
['class.name', 'name'],
['class.code', 'code'],
['class.classType', 'classType'],
['class.status', 'status'],
['class.startDate', 'startDate'],
['class.endDate', 'endDate'],
['COUNT(classStudent.id)', 'studentCount'],
] as const;
for (const [column, alias] of classSelects) {
qb.addSelect(column, alias);
}
qb.where('class.isArchived = :isArchived', { isArchived: false });
if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds });
if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` });
if (query.status) qb.andWhere('class.status = :status', { status: query.status });
const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany<AgentClassRow>();
return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) }));
}
async batchImportStudents(
classId: number,
users: Array<{ dingUserId: string; name: string; mobile?: string }>,
): Promise<{ imported: number; skipped: number; conflicts: number }> {
if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 };
return this.dataSource.transaction(async (manager) => {
const classEntity = await manager.findOne(Class, { where: { id: classId } });
if (!classEntity) throw new NotFoundException('班级不存在');
const synced = await syncDingTalkStudents(manager, users);
const studentIds = [...new Set(synced.studentIds.values())];
if (studentIds.length === 0) {
return { imported: 0, skipped: 0, conflicts: synced.conflicts.length };
}
const existingClassStudents = await manager.find(ClassStudent, {
where: { classId, studentId: In(studentIds) },
});
const existingByStudentId = new Map(
existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]),
);
const today = new Date().toISOString().slice(0, 10);
let skipped = 0;
const memberships = studentIds.flatMap((studentId) => {
const existing = existingByStudentId.get(studentId);
if (existing?.status === 'active') {
skipped++;
return [];
}
if (existing) {
existing.status = 'active';
existing.joinDate = today;
existing.leaveDate = null;
return [existing];
}
return [
manager.create(ClassStudent, {
classId,
studentId,
status: 'active',
joinDate: today,
}),
];
});
if (memberships.length > 0) await manager.save(ClassStudent, memberships);
return {
imported: memberships.length,
skipped,
conflicts: synced.conflicts.length,
};
});
}
async getSchedule(classId: number, query: QueryClassScheduleDto) {
const qb = this.scheduleRepo
.createQueryBuilder('cs')
.leftJoinAndSelect('cs.classroom', 'classroom')
.where('cs.classId = :classId', { classId });
if (query.startDate) {
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
}
if (query.endDate) {
qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate });
}
const schedules = await qb
.orderBy('cs.weekDay', 'ASC')
.addOrderBy('cs.startTime', 'ASC')
.getMany();
return schedules.map((s) => ({
...s,
classroomName: (s.classroom as Classroom | undefined)?.name || null,
}));
}
async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) {
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.where('ar.classId = :classId', { classId });
if (query.startDate) {
qb.andWhere('ar.attendanceDate >= :startDate', { startDate: query.startDate });
}
if (query.endDate) {
qb.andWhere('ar.attendanceDate <= :endDate', { endDate: query.endDate });
}
const rows = await qb.getMany();
const total = rows.length;
const present = rows.filter((r) => r.status === 'present').length;
const late = rows.filter((r) => r.status === 'late').length;
const absent = rows.filter((r) => r.status === 'absent').length;
const leave = rows.filter((r) => r.status === 'leave').length;
return {
total,
present,
late,
absent,
leave,
presentRate: total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0,
absentRate: total > 0 ? Number(((absent / total) * 100).toFixed(1)) : 0,
lateRate: total > 0 ? Number(((late / total) * 100).toFixed(1)) : 0,
leaveRate: total > 0 ? Number(((leave / total) * 100).toFixed(1)) : 0,
};
}
}

View File

@@ -1,4 +1,5 @@
import { ClassesService } from './classes.service';
import { ClassesQueriesService } from './classes-queries.service';
import { ClassStudent, Student, StudentDingMapping } from '../entities';
describe('ClassesService — DingTalk class import membership lifecycle', () => {
@@ -32,6 +33,14 @@ describe('ClassesService — DingTalk class import membership lifecycle', () =>
create: jest.fn().mockImplementation((_entity: unknown, value: object) => value),
save: jest.fn().mockImplementation(async (_entity: unknown, value: unknown) => value),
};
const dataSource = { transaction: jest.fn().mockImplementation((work) => work(manager)) };
const queries = new ClassesQueriesService(
{} as never,
{} as never,
{} as never,
{} as never,
dataSource as never,
);
const service = new ClassesService(
{} as never,
{} as never,
@@ -41,7 +50,9 @@ describe('ClassesService — DingTalk class import membership lifecycle', () =>
{} as never,
{} as never,
{} as never,
{ transaction: jest.fn().mockImplementation((work) => work(manager)) } as never,
dataSource as never,
{} as never,
queries,
);
const result = await service.batchImportStudents(3, [

View File

@@ -1,3 +1,5 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { ValidationPipe } from '@nestjs/common';
import { ClassesController } from './classes.controller';
import { ClassesService } from './classes.service';
@@ -114,3 +116,25 @@ describe('QueryClassDto - query transformation', () => {
).resolves.toEqual({ isArchived: expected });
});
});
describe('ClassesController purge route', () => {
it('requires class:purge on permanent delete route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, ClassesController.prototype.purge)).toEqual([
'class:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = {
purge: jest.fn().mockResolvedValue({ message: '已永久删除班级(不可恢复)' }),
};
const log = jest.fn().mockResolvedValue(undefined);
const controller = new ClassesController(service as never, { log } as never, {} as never, {} as never);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge('1', req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '班级管理', action: '永久删除班级', targetId: 1 }),
);
});
});

View File

@@ -27,7 +27,7 @@ import {
} from './dto/class.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity';
@@ -115,18 +115,9 @@ export class ClassesController {
@Post()
@RequirePermission('class:create')
async create(@Body() dto: CreateClassDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '创建班级',
targetId: result.id,
targetType: 'class',
detail: `班级${result.code} ${result.name}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '创建班级', targetId: result.id, targetType: 'class', detail: `班级${result.code} ${result.name}`,
});
return result;
}
@@ -155,18 +146,9 @@ export class ClassesController {
@Put(':id')
@RequirePermission('class:edit')
async update(@Param('id') id: string, @Body() dto: UpdateClassDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '编辑班级',
targetId: +id,
targetType: 'class',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto),
});
return result;
}
@@ -174,17 +156,19 @@ export class ClassesController {
@Delete(':id')
@RequirePermission('class:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '归档班级',
targetId: +id,
targetType: 'class',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class',
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('class:purge')
async purge(@Param('id') id: string, @Request() req: any) {
const result = await this.service.purge(+id);
await logAudit(this.logService, req, {
module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复',
});
return result;
}
@@ -242,18 +226,9 @@ export class ClassesController {
@Post(':id/students')
@RequirePermission('class:edit')
async addStudents(@Param('id') id: string, @Body() dto: AddStudentsDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.addStudents(+id, dto.studentIds);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '添加学生',
targetId: +id,
targetType: 'class',
detail: `新增${result.added}名学生`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`,
});
try {
const cls = await this.service.findOne(+id);
@@ -265,7 +240,9 @@ export class ClassesController {
content: `班级新增${result.added}名学生`,
});
}
} catch {}
} catch {
// 通知失败不影响班级新增结果
}
return result;
}
@@ -276,18 +253,9 @@ export class ClassesController {
@Param('studentId') studentId: string,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.removeStudent(+id, +studentId);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '移除学生',
targetId: +id,
targetType: 'class',
detail: `移除学生${studentId}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '移除学生', targetId: +id, targetType: 'class', detail: `移除学生${studentId}`,
});
return result;
}
@@ -302,18 +270,9 @@ export class ClassesController {
@Post(':id/teachers')
@RequirePermission('class:edit')
async addTeacher(@Param('id') id: string, @Body() dto: AddTeacherDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.addTeacher(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '添加教师',
targetId: +id,
targetType: 'class',
detail: `教师${dto.userId} 角色${dto.roleType}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`,
});
try {
void this.notificationsService.create({
@@ -322,7 +281,9 @@ export class ClassesController {
title: '班级分配',
content: `您已被分配到班级担任${teacherRoleLabels[dto.roleType] ?? dto.roleType}角色`,
});
} catch {}
} catch {
// 通知失败不影响班级分配结果
}
return result;
}
@@ -333,18 +294,9 @@ export class ClassesController {
@Param('assignmentId') assignmentId: string,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.removeTeacherAssignment(+id, +assignmentId);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '移除教师角色',
targetId: +id,
targetType: 'class',
detail: `移除教师分配${assignmentId}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '移除教师角色', targetId: +id, targetType: 'class', detail: `移除教师分配${assignmentId}`,
});
return result;
}
@@ -356,18 +308,9 @@ export class ClassesController {
@Param('userId') userId: string,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.removeTeacher(+id, +userId);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '移除教师',
targetId: +id,
targetType: 'class',
detail: `移除教师${userId}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '移除教师', targetId: +id, targetType: 'class', detail: `移除教师${userId}`,
});
return result;
}

View File

@@ -1,15 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping } from '../entities';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping } from '../entities';
import { ClassesService } from './classes.service';
import { ClassesQueriesService } from './classes-queries.service';
import { ClassesController } from './classes.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule],
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule],
controllers: [ClassesController],
providers: [ClassesService],
providers: [ClassesService, ClassesQueriesService],
exports: [ClassesService],
})
export class ClassesModule {}

View File

@@ -0,0 +1,54 @@
import { BadRequestException } from '@nestjs/common';
import { ClassesService } from './classes.service';
describe('ClassesService.purge', () => {
const createService = (overrides?: {
cls?: Record<string, unknown>;
counts?: Record<string, number>;
}) => {
const cls = { id: 1, name: '冲刺班', code: 'C1', isArchived: true, ...overrides?.cls };
const repo = {
findOne: jest.fn().mockResolvedValue(cls),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const counts = overrides?.counts ?? {};
const countFor = (key: string) => jest.fn().mockResolvedValue(counts[key] ?? 0);
const service = new ClassesService(
repo as never,
{ count: countFor('classStudent') } as never,
{ count: countFor('classTeacher') } as never,
{ count: countFor('schedule') } as never,
{ count: countFor('attendance') } as never,
{ count: countFor('session') } as never,
{} as never,
{} as never,
{} as never,
{ count: countFor('exam') } as never,
);
return { service, repo };
};
it('rejects classes that are not archived', async () => {
const { service, repo } = createService({ cls: { isArchived: false } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已归档班级可以永久删除,请先归档'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('rejects classes with students, teachers, schedules, exams, or attendance', async () => {
const { service, repo } = createService({ counts: { classStudent: 1 } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('该班级存在关联数据(班级学生),无法永久删除'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('deletes an archived class with no references', async () => {
const { service, repo } = createService();
await expect(service.purge(1)).resolves.toEqual({
message: '已永久删除班级(不可恢复)',
});
expect(repo.delete).toHaveBeenCalledWith(1);
});
});

View File

@@ -1,23 +1,26 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository, In, Like } from 'typeorm';
import { DataSource,
Repository,
In,
Like } from 'typeorm';
import {
Class,
ClassStudent,
ClassTeacher,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
Classroom,
Student,
StudentDingMapping,
ClassStudent,
ClassTeacher,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
Exam,
Student,
StudentDingMapping
} from '../entities';
import { syncDingTalkStudents } from '../integration/dingtalk-student-sync';
import { ClassesQueriesService } from './classes-queries.service';
import { normalizeDateOnly } from '../database/date-normalization';
import {
CreateClassDto,
@@ -33,17 +36,6 @@ interface RawStudentCount {
count: string;
}
interface AgentClassRow {
id: string | number;
name: string;
code: string;
classType: string;
status: string;
startDate: string | null;
endDate: string | null;
studentCount: string | number;
}
@Injectable()
export class ClassesService {
constructor(
@@ -64,6 +56,9 @@ export class ClassesService {
@InjectRepository(StudentDingMapping)
private studentDingMappingRepo: Repository<StudentDingMapping>,
private dataSource: DataSource,
@InjectRepository(Exam)
private examRepo: Repository<Exam>,
private queries: ClassesQueriesService,
) {}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
@@ -84,30 +79,22 @@ export class ClassesService {
query: { keyword?: string; status?: string; limit?: number },
) {
const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll);
if (accessibleClassIds?.length === 0) return [];
return this.queries.agentSearchClasses(accessibleClassIds, query);
}
const qb = this.classRepo
.createQueryBuilder('class')
.leftJoin(
ClassStudent,
'classStudent',
'classStudent.classId = class.id AND classStudent.status = :activeStudent',
{ activeStudent: 'active' },
)
.select('class.id', 'id')
.addSelect('class.name', 'name')
.addSelect('class.code', 'code')
.addSelect('class.classType', 'classType')
.addSelect('class.status', 'status')
.addSelect('class.startDate', 'startDate')
.addSelect('class.endDate', 'endDate')
.addSelect('COUNT(classStudent.id)', 'studentCount')
.where('class.isArchived = :isArchived', { isArchived: false });
if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds });
if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` });
if (query.status) qb.andWhere('class.status = :status', { status: query.status });
const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany<AgentClassRow>();
return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) }));
async batchImportStudents(
classId: number,
users: Array<{ dingUserId: string; name: string; mobile?: string }>,
): Promise<{ imported: number; skipped: number; conflicts: number }> {
return this.queries.batchImportStudents(classId, users);
}
async getSchedule(classId: number, query: QueryClassScheduleDto) {
return this.queries.getSchedule(classId, query);
}
async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) {
return this.queries.getAttendanceSummary(classId, query);
}
async findAll(query: QueryClassDto, accessibleClassIds?: number[]) {
@@ -227,64 +214,6 @@ export class ClassesService {
return this.findOne(saved.id);
}
async batchImportStudents(
classId: number,
users: Array<{
dingUserId: string;
name: string;
mobile?: string;
}>,
): Promise<{ imported: number; skipped: number; conflicts: number }> {
if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 };
return this.dataSource.transaction(async (manager) => {
const classEntity = await manager.findOne(Class, { where: { id: classId } });
if (!classEntity) throw new NotFoundException('班级不存在');
const synced = await syncDingTalkStudents(manager, users);
const studentIds = [...new Set(synced.studentIds.values())];
if (studentIds.length === 0) {
return { imported: 0, skipped: 0, conflicts: synced.conflicts.length };
}
const existingClassStudents = await manager.find(ClassStudent, {
where: { classId, studentId: In(studentIds) },
});
const existingByStudentId = new Map(
existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]),
);
const today = new Date().toISOString().slice(0, 10);
let skipped = 0;
const memberships = studentIds.flatMap((studentId) => {
const existing = existingByStudentId.get(studentId);
if (existing?.status === 'active') {
skipped++;
return [];
}
if (existing) {
existing.status = 'active';
existing.joinDate = today;
existing.leaveDate = null;
return [existing];
}
return [
manager.create(ClassStudent, {
classId,
studentId,
status: 'active',
joinDate: today,
}),
];
});
if (memberships.length > 0) await manager.save(ClassStudent, memberships);
return {
imported: memberships.length,
skipped,
conflicts: synced.conflicts.length,
};
});
}
async update(id: number, dto: UpdateClassDto) {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
@@ -323,6 +252,33 @@ export class ClassesService {
return this.archive(id);
}
/** 永久删除班级(仅已归档) */
async purge(id: number) {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
if (!cls.isArchived) throw new BadRequestException('仅已归档班级可以永久删除,请先归档');
const [studentCount, teacherCount, scheduleCount, examCount, sessionCount, attendanceCount] =
await Promise.all([
this.classStudentRepo.count({ where: { classId: id } }),
this.classTeacherRepo.count({ where: { classId: id } }),
this.scheduleRepo.count({ where: { classId: id } }),
this.examRepo.count({ where: { classId: id } }),
this.attendanceSessionRepo.count({ where: { classId: id } }),
this.attendanceRepo.count({ where: { classId: id } }),
]);
const references: string[] = [];
if (studentCount > 0) references.push('班级学生');
if (teacherCount > 0) references.push('任课教师');
if (scheduleCount > 0) references.push('排课');
if (examCount > 0) references.push('考试');
if (sessionCount > 0 || attendanceCount > 0) references.push('考勤记录');
if (references.length > 0) {
throw new BadRequestException(`该班级存在关联数据(${references.join('、')}),无法永久删除`);
}
await this.classRepo.delete(id);
return { message: '已永久删除班级(不可恢复)' };
}
async getStudents(classId: number) {
return this.classStudentRepo.find({
where: { classId },
@@ -447,61 +403,4 @@ export class ClassesService {
academicTeacherId: academic?.userId ?? null,
} as Partial<Class>);
}
async getSchedule(classId: number, query: QueryClassScheduleDto) {
const qb = this.scheduleRepo
.createQueryBuilder('cs')
.leftJoinAndSelect('cs.classroom', 'classroom')
.where('cs.classId = :classId', { classId });
if (query.startDate) {
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
}
if (query.endDate) {
qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate });
}
const schedules = await qb
.orderBy('cs.weekDay', 'ASC')
.addOrderBy('cs.startTime', 'ASC')
.getMany();
return schedules.map((s) => ({
...s,
classroomName: (s.classroom as Classroom | undefined)?.name || null,
}));
}
async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) {
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.where('ar.classId = :classId', { classId });
if (query.startDate) {
qb.andWhere('ar.attendanceDate >= :startDate', { startDate: query.startDate });
}
if (query.endDate) {
qb.andWhere('ar.attendanceDate <= :endDate', { endDate: query.endDate });
}
const rows = await qb.getMany();
const total = rows.length;
const present = rows.filter((r) => r.status === 'present').length;
const late = rows.filter((r) => r.status === 'late').length;
const absent = rows.filter((r) => r.status === 'absent').length;
const leave = rows.filter((r) => r.status === 'leave').length;
return {
total,
present,
late,
absent,
leave,
presentRate: total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0,
absentRate: total > 0 ? Number(((absent / total) * 100).toFixed(1)) : 0,
lateRate: total > 0 ? Number(((late / total) * 100).toFixed(1)) : 0,
leaveRate: total > 0 ? Number(((leave / total) * 100).toFixed(1)) : 0,
};
}
}

View File

@@ -21,7 +21,7 @@ import { ClassroomRentalsService } from './classroom-rentals.service';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
@UseGuards(JwtAuthGuard)
@@ -102,18 +102,9 @@ export class ClassroomRentalsController {
@Post()
@RequirePermission('rental:create')
async create(@Body() dto: CreateRentalDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '新增租赁',
targetId: result.id,
targetType: 'classroom-rental',
detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '新增租赁', targetId: result.id, targetType: 'classroom-rental', detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`,
});
return result;
}
@@ -121,18 +112,9 @@ export class ClassroomRentalsController {
@Put(':id')
@RequirePermission('rental:edit')
async update(@Param('id') id: string, @Body() dto: UpdateRentalDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '编辑租赁',
targetId: +id,
targetType: 'classroom-rental',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '编辑租赁', targetId: +id, targetType: 'classroom-rental', detail: JSON.stringify(dto),
});
return result;
}
@@ -140,17 +122,9 @@ export class ClassroomRentalsController {
@Put(':id/cancel')
@RequirePermission('rental:edit')
async cancel(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.cancel(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '取消租赁',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '取消租赁', targetId: +id, targetType: 'classroom-rental',
});
return result;
}
@@ -158,17 +132,9 @@ export class ClassroomRentalsController {
@Put(':id/end')
@RequirePermission('rental:edit')
async end(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.end(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '结束租赁',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '结束租赁', targetId: +id, targetType: 'classroom-rental',
});
return result;
}
@@ -176,17 +142,19 @@ export class ClassroomRentalsController {
@Delete(':id')
@RequirePermission('rental:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '归档租赁',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '归档租赁', targetId: +id, targetType: 'classroom-rental',
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('rental:purge')
async purge(@Param('id') id: string, @Request() req: any) {
const result = await this.service.purge(+id);
await logAudit(this.logService, req, {
module: '教室租赁', action: '永久删除租赁订单', targetId: +id, targetType: 'classroom-rental', detail: '物理删除,不可恢复',
});
return result;
}
@@ -211,18 +179,9 @@ export class ClassroomRentalsController {
@Request() req: any,
) {
if (!file) throw new BadRequestException('请上传合同文件');
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.attachContract(+id, file);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '上传合同',
targetId: +id,
targetType: 'classroom-rental',
detail: file.originalname,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '上传合同', targetId: +id, targetType: 'classroom-rental', detail: file.originalname,
});
return result;
}
@@ -243,17 +202,9 @@ export class ClassroomRentalsController {
@Delete(':id/contract')
@RequirePermission('rental:edit')
async deleteContract(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.removeContract(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '移除合同',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '移除合同', targetId: +id, targetType: 'classroom-rental',
});
return result;
}

View File

@@ -4,17 +4,27 @@ import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity';
import { Organization } from '../entities/organization.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { ClassroomRentalsService } from './classroom-rentals.service';
import { RentalScheduleService } from './rental-schedule.service';
import { ClassroomRentalsController } from './classroom-rentals.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [
TypeOrmModule.forFeature([ClassroomRental, Classroom, Organization, ClassSchedule]),
TypeOrmModule.forFeature([
ClassroomRental,
Classroom,
Organization,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
]),
OperationLogsModule,
],
controllers: [ClassroomRentalsController],
providers: [ClassroomRentalsService],
providers: [ClassroomRentalsService, RentalScheduleService],
exports: [ClassroomRentalsService],
})
export class ClassroomRentalsModule {}

View File

@@ -0,0 +1,25 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { ClassroomRentalsController } from './classroom-rentals.controller';
describe('ClassroomRentalsController purge route', () => {
it('requires rental:purge on permanent delete route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, ClassroomRentalsController.prototype.purge)).toEqual([
'rental:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = {
purge: jest.fn().mockResolvedValue({ message: '已永久删除租赁订单(不可恢复)' }),
};
const log = jest.fn().mockResolvedValue(undefined);
const controller = new ClassroomRentalsController(service as never, { log } as never);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge('1', req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '教室租赁', action: '永久删除租赁订单', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,92 @@
import { BadRequestException } from '@nestjs/common';
import { ClassroomRentalsService } from './classroom-rentals.service';
import { RentalScheduleService } from './rental-schedule.service';
describe('ClassroomRentalsService.purge', () => {
const createService = (overrides?: {
rental?: Record<string, unknown>;
schedules?: Record<string, unknown>[];
sessionCount?: number;
recordCount?: number;
}) => {
const rental = {
id: 1,
classroomId: 2,
status: 'cancelled',
startDate: '2026-01-01',
endDate: '2026-01-31',
contractPath: null,
...overrides?.rental,
};
const repo = {
findOne: jest.fn().mockResolvedValue(rental),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const scheduleRepo = {
find: jest.fn().mockResolvedValue(overrides?.schedules ?? []),
};
const attendanceRepo = { count: jest.fn().mockResolvedValue(overrides?.recordCount ?? 0) };
const attendanceSessionRepo = {
count: jest.fn().mockResolvedValue(overrides?.sessionCount ?? 0),
};
const manager = {
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb(manager)),
};
const scheduleService = new RentalScheduleService(repo as never, {} as never, scheduleRepo as never);
const service = new ClassroomRentalsService(
repo as never,
{} as never,
{} as never,
scheduleRepo as never,
attendanceRepo as never,
attendanceSessionRepo as never,
dataSource as never,
scheduleService,
);
return { service, repo, scheduleRepo, attendanceRepo, attendanceSessionRepo, dataSource, manager };
};
it('rejects rentals that are not cancelled', async () => {
const { service, dataSource } = createService({ rental: { status: 'active' } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已取消租赁订单可以永久删除,请先取消'),
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects cancelled rentals whose schedules have attendance history', async () => {
const withSession = createService({
schedules: [{ id: 5, rentalId: 1, scheduleType: 'RENTAL' }],
sessionCount: 1,
});
await expect(withSession.service.purge(1)).rejects.toThrow(
new BadRequestException('该租赁的排课已有考勤记录,无法永久删除'),
);
const withRecord = createService({
schedules: [{ id: 5, rentalId: 1, scheduleType: 'RENTAL' }],
recordCount: 1,
});
await expect(withRecord.service.purge(1)).rejects.toThrow(
new BadRequestException('该租赁的排课已有考勤记录,无法永久删除'),
);
expect(withRecord.dataSource.transaction).not.toHaveBeenCalled();
});
it('deletes schedules and rental without attendance history', async () => {
const { service, dataSource, manager } = createService({
schedules: [{ id: 5, rentalId: 1, scheduleType: 'RENTAL' }],
});
await expect(service.purge(1)).resolves.toEqual({
message: '已永久删除租赁订单(不可恢复)',
});
expect(dataSource.transaction).toHaveBeenCalled();
expect(manager.delete).toHaveBeenNthCalledWith(1, expect.anything(), {
id: expect.anything(),
});
expect(manager.delete).toHaveBeenNthCalledWith(2, expect.anything(), 1);
});
});

View File

@@ -1,12 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConflictException } from '@nestjs/common';
import { Not, Repository } from 'typeorm';
import { DataSource, Not, Repository } from 'typeorm';
import { ClassroomRentalsService } from './classroom-rentals.service';
import { RentalScheduleService } from './rental-schedule.service';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity';
import { Organization } from '../entities/organization.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
function mockQueryBuilder<T>(results: T[] = []) {
@@ -28,6 +31,8 @@ describe('ClassroomRentalsService — findConflicts', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ClassroomRentalsService,
RentalScheduleService,
RentalScheduleService,
{
provide: getRepositoryToken(ClassroomRental),
useValue: { createQueryBuilder: jest.fn() },
@@ -35,6 +40,12 @@ describe('ClassroomRentalsService — findConflicts', () => {
{ provide: getRepositoryToken(Classroom), useValue: {} },
{ provide: getRepositoryToken(Organization), useValue: {} },
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
{ provide: getRepositoryToken(AttendanceRecord), useValue: {} },
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
{
provide: DataSource,
useValue: { transaction: jest.fn((cb: (m: unknown) => Promise<unknown>) => cb({})) },
},
],
}).compile();
@@ -131,10 +142,17 @@ describe('ClassroomRentalsService — unavailable dates', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ClassroomRentalsService,
RentalScheduleService,
{ provide: getRepositoryToken(ClassroomRental), useValue: { find: jest.fn() } },
{ provide: getRepositoryToken(Classroom), useValue: {} },
{ provide: getRepositoryToken(Organization), useValue: {} },
{ provide: getRepositoryToken(ClassSchedule), useValue: { find: jest.fn() } },
{ provide: getRepositoryToken(AttendanceRecord), useValue: {} },
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
{
provide: DataSource,
useValue: { transaction: jest.fn((cb: (m: unknown) => Promise<unknown>) => cb({})) },
},
],
}).compile();
@@ -225,10 +243,17 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ClassroomRentalsService,
RentalScheduleService,
{ provide: getRepositoryToken(ClassroomRental), useValue: rentalRepo },
{ provide: getRepositoryToken(Classroom), useValue: classroomRepo },
{ provide: getRepositoryToken(Organization), useValue: organizationRepo },
{ provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepo },
{ provide: getRepositoryToken(AttendanceRecord), useValue: {} },
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
{
provide: DataSource,
useValue: { transaction: jest.fn((cb: (m: unknown) => Promise<unknown>) => cb({})) },
},
],
}).compile();
@@ -475,11 +500,16 @@ describe('ClassroomRentalsService — organization roles', () => {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassSchedule>([])),
} as any;
const scheduleService = new RentalScheduleService(rentalRepo, classroomRepo, scheduleRepo);
const service = new ClassroomRentalsService(
rentalRepo,
classroomRepo,
organizationRepo,
scheduleRepo,
{} as any,
{} as any,
{} as any,
scheduleService,
);
await service.create({

View File

@@ -4,30 +4,35 @@ import {
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
import { Organization } from '../entities/organization.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
import { RentalScheduleService } from './rental-schedule.service';
import * as path from 'path';
import * as fs from 'fs';
// 预设色板(与 organizations.service 保持一致,作为颜色兜底)
const COLOR_PALETTE = [
'#ff7875',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
function rentalConflictError(
message: string,
conflicts: Array<{ id: number; startDate: string; endDate: string; lesseeOrganization?: { name?: string | null } | null }>,
) {
return new ConflictException({
message,
conflicts: conflicts.map((c) => ({
id: c.id,
startDate: c.startDate,
endDate: c.endDate,
organizationName: c.lesseeOrganization?.name,
})),
});
}
@Injectable()
export class ClassroomRentalsService {
@@ -36,6 +41,10 @@ export class ClassroomRentalsService {
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository<AttendanceSession>,
@InjectDataSource() private dataSource: DataSource,
private schedule: RentalScheduleService,
) {}
get uploadDir(): string {
@@ -74,7 +83,7 @@ export class ClassroomRentalsService {
qb.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE });
}
const rentals = await qb.getMany();
return rentals.map((rental) => this.withEffectiveStatus(rental));
return rentals.map((rental) => this.schedule.withEffectiveStatus(rental));
}
/**
@@ -98,19 +107,24 @@ export class ClassroomRentalsService {
contractName: string | null;
}[]
> {
const rentalSelects = [
['classroom.name', 'classroomName'],
['lesseeOrganization.name', 'lesseeOrganizationName'],
['r.startDate', 'startDate'],
['r.endDate', 'endDate'],
['r.dailyRate', 'dailyRate'],
['r.totalAmount', 'totalAmount'],
['r.status', 'status'],
['r.contractOriginalName', 'contractName'],
] as const;
const qb = this.repo
.createQueryBuilder('r')
.leftJoin('r.classroom', 'classroom')
.leftJoin('r.lesseeOrganization', 'lesseeOrganization')
.select('r.id', 'id')
.addSelect('classroom.name', 'classroomName')
.addSelect('lesseeOrganization.name', 'lesseeOrganizationName')
.addSelect('r.startDate', 'startDate')
.addSelect('r.endDate', 'endDate')
.addSelect('r.dailyRate', 'dailyRate')
.addSelect('r.totalAmount', 'totalAmount')
.addSelect('r.status', 'status')
.addSelect('r.contractOriginalName', 'contractName');
.select('r.id', 'id');
for (const [column, alias] of rentalSelects) {
qb.addSelect(column, alias);
}
if (query?.classroomId) {
qb.andWhere('r.classroomId = :classroomId', { classroomId: query.classroomId });
}
@@ -148,142 +162,19 @@ export class ClassroomRentalsService {
relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'],
});
if (!rental) throw new NotFoundException('租赁订单不存在');
return this.withEffectiveStatus(rental);
return this.schedule.withEffectiveStatus(rental);
}
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
const monthStart = `${year}-${String(month).padStart(2, '0')}-01`;
const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const [rentals, schedules] = await Promise.all([
this.repo.find({
where: {
...(excludeId ? { id: Not(excludeId) } : {}),
classroomId,
status: ClassroomRentalStatus.ACTIVE,
startDate: LessThanOrEqual(monthEnd),
endDate: MoreThanOrEqual(monthStart),
},
}),
this.scheduleRepo.find({
where: {
classroomId,
status: ClassroomRentalStatus.ACTIVE,
scheduleType: 'INTERNAL',
startDate: LessThanOrEqual(monthEnd),
endDate: MoreThanOrEqual(monthStart),
},
}),
]);
const unavailableDates = new Set<string>();
for (const rental of rentals) {
this.addDateRange(
unavailableDates,
rental.startDate > monthStart ? rental.startDate : monthStart,
rental.endDate < monthEnd ? rental.endDate : monthEnd,
);
}
for (const schedule of schedules) {
this.addScheduleOccurrences(unavailableDates, schedule, monthStart, monthEnd);
}
return { dates: Array.from(unavailableDates).sort() };
return this.schedule.getUnavailableDates(classroomId, year, month, excludeId);
}
/**
* 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课
* 重叠判定start1 <= end2 AND start2 <= end1
*/
async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) {
const qb = this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.where('r.classroomId = :cid', { cid: classroomId })
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
.andWhere('r.startDate <= :end', { end: endDate })
.andWhere('r.endDate >= :start', { start: startDate });
if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId });
const rentals = await qb.getMany();
// 检测同一教室同一日期段是否存在内部排课
const scheduleCandidates = await this.scheduleRepo
.createQueryBuilder('cs')
.where('cs.classroomId = :cid', { cid: classroomId })
.andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' })
.andWhere('cs.startDate <= :end', { end: endDate })
.andWhere('cs.endDate >= :start', { start: startDate })
.getMany();
const scheduleConflicts = scheduleCandidates.filter((schedule) =>
this.hasScheduleOccurrence(schedule, startDate, endDate),
);
if (scheduleConflicts.length > 0) {
throw new ConflictException({
message: '该教室在此时间段已有排课',
conflicts: scheduleConflicts.map((s) => ({
id: s.id,
startDate: s.startDate,
endDate: s.endDate,
organizationName: `[内部排课] ${s.subject}`,
})),
});
}
return rentals;
return this.schedule.findConflicts(classroomId, startDate, endDate, excludeId);
}
private hasScheduleOccurrence(
schedule: ClassSchedule,
startDate: string,
endDate: string,
): boolean {
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
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));
}
private addDateRange(dates: Set<string>, startDate: string, endDate: string) {
const current = this.toUtcDate(startDate);
const end = this.toUtcDate(endDate);
while (current <= end) {
dates.add(current.toISOString().slice(0, 10));
current.setUTCDate(current.getUTCDate() + 1);
}
}
private addScheduleOccurrences(
dates: Set<string>,
schedule: ClassSchedule,
startDate: string,
endDate: string,
) {
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
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(current.toISOString().slice(0, 10));
current.setUTCDate(current.getUTCDate() + 7);
}
async getSchedule(year: number, month: number) {
return this.schedule.getSchedule(year, month);
}
async create(dto: CreateRentalDto, userId?: number) {
@@ -309,15 +200,7 @@ export class ClassroomRentalsService {
const conflicts = await this.findConflicts(dto.classroomId, dto.startDate, dto.endDate);
if (conflicts.length > 0) {
throw new ConflictException({
message: '该教室在此时间段已有租赁',
conflicts: conflicts.map((c) => ({
id: c.id,
startDate: c.startDate,
endDate: c.endDate,
organizationName: c.lesseeOrganization?.name,
})),
});
throw rentalConflictError('该教室在此时间段已有租赁', conflicts);
}
const rental = this.repo.create({
...dto,
@@ -327,7 +210,7 @@ export class ClassroomRentalsService {
status: ClassroomRentalStatus.ACTIVE,
});
const saved = await this.repo.save(rental);
await this.syncScheduleFromRental(saved, lesseeOrganization.name);
await this.schedule.syncScheduleFromRental(saved, lesseeOrganization.name);
return saved;
}
@@ -351,15 +234,7 @@ export class ClassroomRentalsService {
if (dto.classroomId || dto.startDate || dto.endDate) {
const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id);
if (conflicts.length > 0) {
throw new ConflictException({
message: '修改后时间段与已有租赁冲突',
conflicts: conflicts.map((c) => ({
id: c.id,
startDate: c.startDate,
endDate: c.endDate,
organizationName: c.lesseeOrganization?.name,
})),
});
throw rentalConflictError('修改后时间段与已有租赁冲突', conflicts);
}
}
const newLessorId = dto.lessorOrganizationId ?? rental.lessorOrganizationId;
@@ -381,7 +256,7 @@ export class ClassroomRentalsService {
}
await this.repo.update(id, dto);
const updated = await this.findOne(id);
await this.syncScheduleFromRental(updated);
await this.schedule.syncScheduleFromRental(updated);
return updated;
}
@@ -412,7 +287,7 @@ export class ClassroomRentalsService {
endDate: rental.endDate > today ? today : rental.endDate,
});
const ended = await this.findOne(id);
await this.syncScheduleFromRental(ended);
await this.schedule.syncScheduleFromRental(ended);
return ended;
}
@@ -426,56 +301,40 @@ export class ClassroomRentalsService {
return { message: '租赁订单已归档(合同文件已保留)' };
}
private withEffectiveStatus(rental: ClassroomRental) {
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const effectiveStatus =
rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today
? ClassroomRentalStatus.ENDED
: rental.status;
return Object.assign(rental, { effectiveStatus });
}
/**
* 同步租赁订单到 class_schedulesschedule_type = 'RENTAL'
*/
private 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);
async purge(id: number) {
const rental = await this.findOne(id);
if (rental.status !== ClassroomRentalStatus.CANCELLED) {
throw new BadRequestException('仅已取消租赁订单可以永久删除,请先取消');
}
}
private dateToWeekDay(date: string): number {
const d = new Date(date);
const day = d.getDay();
return day === 0 ? 7 : day;
const schedules = await this.scheduleRepo.find({
where: { rentalId: id, scheduleType: 'RENTAL' },
});
const scheduleIds = schedules.map((schedule) => schedule.id);
if (scheduleIds.length > 0) {
const [sessionCount, recordCount] = await Promise.all([
this.attendanceSessionRepo.count({ where: { scheduleId: In(scheduleIds) } }),
this.attendanceRepo.count({ where: { scheduleId: In(scheduleIds) } }),
]);
if (sessionCount > 0 || recordCount > 0) {
throw new BadRequestException('该租赁的排课已有考勤记录,无法永久删除');
}
}
await this.dataSource.transaction(async (manager) => {
if (scheduleIds.length > 0) {
await manager.delete(ClassSchedule, { id: In(scheduleIds) });
}
await manager.delete(ClassroomRental, id);
});
if (rental.contractPath) {
const fullPath = path.join(this.uploadDir, rental.contractPath);
try {
if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath);
} catch (error) {
// 文件删除失败仅告警,不阻塞数据库删除
console.warn(`[ClassroomRentalsService] 合同文件删除失败: ${fullPath}`, error);
}
}
return { message: '已永久删除租赁订单(不可恢复)' };
}
async attachContract(id: number, file: Express.Multer.File) {
@@ -488,9 +347,7 @@ export class ClassroomRentalsService {
const ext = path.extname(file.originalname).toLowerCase();
if (ext !== '.pdf') throw new BadRequestException('文件扩展名必须为 .pdf');
// UUID 文件名
const uuid =
(globalThis as any).crypto?.randomUUID?.() ||
require('crypto').randomBytes(16).toString('hex');
const uuid = require('crypto').randomBytes(16).toString('hex');
const filename = `${uuid}.pdf`;
const fullPath = path.join(this.uploadDir, filename);
// 路径遍历防护
@@ -525,7 +382,7 @@ export class ClassroomRentalsService {
/* ignore */
}
}
await this.repo.update(id, { contractPath: null as any, contractOriginalName: null as any });
await this.repo.update(id, { contractPath: null, contractOriginalName: null });
return { message: '合同已移除' };
}
@@ -544,127 +401,4 @@ export class ClassroomRentalsService {
/**
* 获取月度排期矩阵
*/
async getSchedule(year: number, month: number) {
const lastDay = new Date(year, month, 0).getDate();
const first = `${year}-${String(month).padStart(2, '0')}-01`;
const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const classrooms = await this.classroomRepo.find({
where: { status: Not(ClassroomStatus.ARCHIVED) },
order: { building: 'ASC', name: 'ASC' },
});
const rentals = await this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.leftJoinAndSelect('r.classroom', 'classroom')
.where('r.status IN (:...statuses)', {
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
})
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
.getMany();
const organizationMap = new Map<number, any>();
const matrix: Record<number, Record<number, any>> = {};
const summary: Record<
number,
{ totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number }
> = {};
for (const cls of classrooms) {
matrix[cls.id] = {};
summary[cls.id] = { totalDays: lastDay, rentedDays: 0, idleDays: lastDay, occupancyRate: 0 };
}
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;
if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) {
organizationMap.set(rental.lesseeOrganization.id, {
id: rental.lesseeOrganization.id,
name: rental.lesseeOrganization.name,
color:
rental.lesseeOrganization.color ||
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;
matrix[rental.classroomId][day] = {
scheduleType: 'RENTAL',
rentalId: rental.id,
organizationId: rental.lesseeOrganizationId,
organizationName: rental.lesseeOrganization?.name || '未知',
color:
rental.lesseeOrganization?.color ||
COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length],
hasContract: !!rental.contractPath,
};
}
}
// ── Overlay internal class schedules ──
const schedules = await this.scheduleRepo
.createQueryBuilder('s')
.leftJoinAndSelect('s.class', 'class')
.leftJoinAndSelect('s.teacher', 'teacher')
.where('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: '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;
matrix[sched.classroomId][day] = {
scheduleType: 'INTERNAL',
scheduleId: sched.id,
className: (sched.class as any)?.name || '',
subject: sched.subject,
teacherName: (sched.teacher as any)?.name || '',
startTime: sched.startTime,
endTime: sched.endTime,
color: '#52c41a',
};
}
}
// 统计
for (const cls of classrooms) {
const rented = Object.keys(matrix[cls.id]).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;
}
return {
year,
month,
days: lastDay,
classrooms: classrooms.map((c) => ({
id: c.id,
name: c.name,
building: c.building,
floor: c.floor,
roomType: c.roomType,
capacity: c.capacity,
})),
organizations: Array.from(organizationMap.values()),
matrix,
summary,
};
}
}

View File

@@ -0,0 +1,341 @@
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 { ClassroomRentalStatus } from '../entities/classroom-rental.entity';
const COLOR_PALETTE = [
"#5B8FF9",
"#61DDAA",
"#65789B",
"#F6BD16",
"#7262FD",
"#78D3F8",
"#9661BC",
"#F6903D",
"#008685",
"#F08BB4"
];
@Injectable()
export class RentalScheduleService {
constructor(
@InjectRepository(ClassroomRental) private repo: Repository<ClassroomRental>,
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
) {}
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
const monthStart = `${year}-${String(month).padStart(2, '0')}-01`;
const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const [rentals, schedules] = await Promise.all([
this.repo.find({
where: {
...(excludeId ? { id: Not(excludeId) } : {}),
classroomId,
status: ClassroomRentalStatus.ACTIVE,
startDate: LessThanOrEqual(monthEnd),
endDate: MoreThanOrEqual(monthStart),
},
}),
this.scheduleRepo.find({
where: {
classroomId,
status: ClassroomRentalStatus.ACTIVE,
scheduleType: 'INTERNAL',
startDate: LessThanOrEqual(monthEnd),
endDate: MoreThanOrEqual(monthStart),
},
}),
]);
const unavailableDates = new Set<string>();
for (const rental of rentals) {
this.addDateRange(
unavailableDates,
rental.startDate > monthStart ? rental.startDate : monthStart,
rental.endDate < monthEnd ? rental.endDate : monthEnd,
);
}
for (const schedule of schedules) {
this.addScheduleOccurrences(unavailableDates, schedule, monthStart, monthEnd);
}
return { dates: Array.from(unavailableDates).sort() };
}
/**
* 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课
* 重叠判定start1 <= end2 AND start2 <= end1
*/
async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) {
const qb = this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.where('r.classroomId = :cid', { cid: classroomId })
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
.andWhere('r.startDate <= :end', { end: endDate })
.andWhere('r.endDate >= :start', { start: startDate });
if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId });
const rentals = await qb.getMany();
// 检测同一教室同一日期段是否存在内部排课
const scheduleCandidates = await this.scheduleRepo
.createQueryBuilder('cs')
.where('cs.classroomId = :cid', { cid: classroomId })
.andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' })
.andWhere('cs.startDate <= :end', { end: endDate })
.andWhere('cs.endDate >= :start', { start: startDate })
.getMany();
const scheduleConflicts = scheduleCandidates.filter((schedule) =>
this.hasScheduleOccurrence(schedule, startDate, endDate),
);
if (scheduleConflicts.length > 0) {
throw new ConflictException({
message: '该教室在此时间段已有排课',
conflicts: scheduleConflicts.map((s) => ({
id: s.id,
startDate: s.startDate,
endDate: s.endDate,
organizationName: `[内部排课] ${s.subject}`,
})),
});
}
return rentals;
}
private hasScheduleOccurrence(
schedule: ClassSchedule,
startDate: string,
endDate: string,
): boolean {
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
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));
}
private addDateRange(dates: Set<string>, startDate: string, endDate: string) {
const current = this.toUtcDate(startDate);
const end = this.toUtcDate(endDate);
while (current <= end) {
dates.add(current.toISOString().slice(0, 10));
current.setUTCDate(current.getUTCDate() + 1);
}
}
private addScheduleOccurrences(
dates: Set<string>,
schedule: ClassSchedule,
startDate: string,
endDate: string,
) {
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
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(current.toISOString().slice(0, 10));
current.setUTCDate(current.getUTCDate() + 7);
}
}
async getSchedule(year: number, month: number) {
const lastDay = new Date(year, month, 0).getDate();
const first = `${year}-${String(month).padStart(2, '0')}-01`;
const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const classrooms = await this.classroomRepo.find({
where: { status: Not(ClassroomStatus.ARCHIVED) },
order: { building: 'ASC', name: 'ASC' },
});
const rentals = await this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.leftJoinAndSelect('r.classroom', 'classroom')
.where('r.status IN (:...statuses)', {
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
})
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
.getMany();
const organizationMap = new Map<number, any>();
const matrix: Record<number, Record<number, any>> = {};
const summary: Record<
number,
{ totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number }
> = {};
for (const cls of classrooms) {
matrix[cls.id] = {};
summary[cls.id] = { totalDays: lastDay, rentedDays: 0, idleDays: lastDay, occupancyRate: 0 };
}
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;
if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) {
organizationMap.set(rental.lesseeOrganization.id, {
id: rental.lesseeOrganization.id,
name: rental.lesseeOrganization.name,
color:
rental.lesseeOrganization.color ||
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;
matrix[rental.classroomId][day] = {
scheduleType: 'RENTAL',
rentalId: rental.id,
organizationId: rental.lesseeOrganizationId,
organizationName: rental.lesseeOrganization?.name || '未知',
color:
rental.lesseeOrganization?.color ||
COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length],
hasContract: !!rental.contractPath,
};
}
}
// ── Overlay internal class schedules ──
const schedules = await this.scheduleRepo
.createQueryBuilder('s')
.leftJoinAndSelect('s.class', 'class')
.leftJoinAndSelect('s.teacher', 'teacher')
.where('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: '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;
matrix[sched.classroomId][day] = {
scheduleType: 'INTERNAL',
scheduleId: sched.id,
className: (sched.class as { name?: string } | null)?.name || '',
subject: sched.subject,
teacherName: (sched.teacher as { name?: string } | null)?.name || '',
startTime: sched.startTime,
endTime: sched.endTime,
color: '#52c41a',
};
}
}
// 统计
for (const cls of classrooms) {
const rented = Object.keys(matrix[cls.id]).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;
}
return {
year,
month,
days: lastDay,
classrooms: classrooms.map((c) => ({
id: c.id,
name: c.name,
building: c.building,
floor: c.floor,
roomType: c.roomType,
capacity: c.capacity,
})),
organizations: Array.from(organizationMap.values()),
matrix,
summary,
};
}
withEffectiveStatus(rental: ClassroomRental) {
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const effectiveStatus =
rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today
? ClassroomRentalStatus.ENDED
: rental.status;
return Object.assign(rental, { effectiveStatus });
}
/**
* 同步租赁订单到 class_schedulesschedule_type = 'RENTAL'
*/
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);
}
}
dateToWeekDay(date: string): number {
const d = new Date(date);
const day = d.getDay();
return day === 0 ? 7 : day;
}
}

View File

@@ -19,6 +19,7 @@ import { ClassroomsService } from './classrooms.service';
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { logAudit } from '../common/with-audit-log';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import * as ExcelJS from 'exceljs';
@@ -104,18 +105,9 @@ export class ClassroomsController {
@Post()
@RequirePermission('classroom:create')
async create(@Body() dto: CreateClassroomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室',
action: '新增教室',
targetId: result.id,
targetType: 'classroom',
detail: dto.name,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室', action: '新增教室', targetId: result.id, targetType: 'classroom', detail: dto.name,
});
return result;
}
@@ -123,18 +115,9 @@ export class ClassroomsController {
@Put(':id')
@RequirePermission('classroom:edit')
async update(@Param('id') id: string, @Body() dto: UpdateClassroomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室',
action: '编辑教室',
targetId: +id,
targetType: 'classroom',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室', action: '编辑教室', targetId: +id, targetType: 'classroom', detail: JSON.stringify(dto),
});
return result;
}
@@ -142,17 +125,19 @@ export class ClassroomsController {
@Delete(':id')
@RequirePermission('classroom:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室',
action: '归档教室',
targetId: +id,
targetType: 'classroom',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室', action: '归档教室', targetId: +id, targetType: 'classroom',
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('classroom:purge')
async purge(@Param('id') id: string, @Request() req: any) {
const result = await this.service.purge(+id);
await logAudit(this.logService, req, {
module: '教室', action: '永久删除教室', targetId: +id, targetType: 'classroom', detail: '物理删除,不可恢复',
});
return result;
}
@@ -160,17 +145,9 @@ export class ClassroomsController {
@Put(':id/restore')
@RequirePermission('classroom:edit')
async restore(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.restore(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室',
action: '恢复教室',
targetId: +id,
targetType: 'classroom',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室', action: '恢复教室', targetId: +id, targetType: 'classroom',
});
return result;
}
@@ -181,7 +158,7 @@ export class ClassroomsController {
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
const ws = workbook.worksheets[0];
const rows: any[] = [];
ws.eachRow((row, idx) => {

View File

@@ -3,12 +3,16 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Classroom } from '../entities/classroom.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { AttendanceDevice } from '../entities/attendance-device.entity';
import { ClassroomsService } from './classrooms.service';
import { ClassroomsController } from './classrooms.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([Classroom, ClassroomRental, ClassSchedule]), OperationLogsModule],
imports: [
TypeOrmModule.forFeature([Classroom, ClassroomRental, ClassSchedule, AttendanceDevice]),
OperationLogsModule,
],
controllers: [ClassroomsController],
providers: [ClassroomsService],
exports: [ClassroomsService],

View File

@@ -0,0 +1,23 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { ClassroomsController } from './classrooms.controller';
describe('ClassroomsController purge route', () => {
it('requires classroom:purge on permanent delete route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, ClassroomsController.prototype.purge)).toEqual([
'classroom:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除教室(不可恢复)' }) };
const log = jest.fn().mockResolvedValue(undefined);
const controller = new ClassroomsController(service as never, { log } as never);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge('1', req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '教室', action: '永久删除教室', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,59 @@
import { BadRequestException } from '@nestjs/common';
import { ClassroomsService } from './classrooms.service';
describe('ClassroomsService.purge', () => {
const createService = (overrides?: {
classroom?: Record<string, unknown>;
scheduleCount?: number;
rentalCount?: number;
deviceCount?: number;
}) => {
const classroom = { id: 1, name: '101教室', status: 'archived', ...overrides?.classroom };
const repo = {
findOne: jest.fn().mockResolvedValue(classroom),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const scheduleRepo = { count: jest.fn().mockResolvedValue(overrides?.scheduleCount ?? 0) };
const rentalRepo = { count: jest.fn().mockResolvedValue(overrides?.rentalCount ?? 0) };
const deviceRepo = { count: jest.fn().mockResolvedValue(overrides?.deviceCount ?? 0) };
const service = new ClassroomsService(
repo as never,
rentalRepo as never,
scheduleRepo as never,
deviceRepo as never,
);
return { service, repo, scheduleRepo, rentalRepo, deviceRepo };
};
it('rejects classrooms that are not archived', async () => {
const { service, repo } = createService({ classroom: { status: 'available' } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已归档教室可以永久删除,请先归档'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('rejects classrooms with schedules, rentals, or devices', async () => {
const withSchedule = createService({ scheduleCount: 1 });
await expect(withSchedule.service.purge(1)).rejects.toThrow(
new BadRequestException('该教室存在排课记录,无法永久删除'),
);
const withRental = createService({ rentalCount: 1 });
await expect(withRental.service.purge(1)).rejects.toThrow(
new BadRequestException('该教室存在租赁订单,无法永久删除'),
);
const withDevice = createService({ deviceCount: 1 });
await expect(withDevice.service.purge(1)).rejects.toThrow(
new BadRequestException('该教室绑定了考勤机,无法永久删除'),
);
expect(withDevice.repo.delete).not.toHaveBeenCalled();
});
it('deletes an archived classroom with no references', async () => {
const { service, repo } = createService();
await expect(service.purge(1)).resolves.toEqual({ message: '已永久删除教室(不可恢复)' });
expect(repo.delete).toHaveBeenCalledWith(1);
});
});

View File

@@ -4,6 +4,7 @@ import { Repository, Not, MoreThanOrEqual, Like } from 'typeorm';
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { AttendanceDevice } from '../entities/attendance-device.entity';
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
@Injectable()
@@ -12,6 +13,7 @@ export class ClassroomsService {
@InjectRepository(Classroom) private repo: Repository<Classroom>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(AttendanceDevice) private deviceRepo: Repository<AttendanceDevice>,
) {}
async findAll(query?: { building?: string; roomType?: string; includeArchived?: boolean }) {
@@ -113,6 +115,24 @@ export class ClassroomsService {
return this.repo.findOne({ where: { id } });
}
async purge(id: number) {
const classroom = await this.repo.findOne({ where: { id } });
if (!classroom) throw new NotFoundException('教室不存在');
if (classroom.status !== ClassroomStatus.ARCHIVED) {
throw new BadRequestException('仅已归档教室可以永久删除,请先归档');
}
const [scheduleCount, rentalCount, deviceCount] = await Promise.all([
this.scheduleRepo.count({ where: { classroomId: id } }),
this.rentalRepo.count({ where: { classroomId: id } }),
this.deviceRepo.count({ where: { classroomId: id } }),
]);
if (scheduleCount > 0) throw new BadRequestException('该教室存在排课记录,无法永久删除');
if (rentalCount > 0) throw new BadRequestException('该教室存在租赁订单,无法永久删除');
if (deviceCount > 0) throw new BadRequestException('该教室绑定了考勤机,无法永久删除');
await this.repo.delete(id);
return { message: '已永久删除教室(不可恢复)' };
}
private withEffectiveStatus(
classroom: Classroom,
usage?: {
@@ -214,17 +234,23 @@ export class ClassroomsService {
};
const weekDay = weekDayMap[shanghaiParts];
const schedules = await this.scheduleRepo
const qb = this.scheduleRepo
.createQueryBuilder('s')
.leftJoin('Class', 'c', 'c.id = s.classId')
.select('s.classroomId', 'classroomId')
.addSelect('s.startTime', 'startTime')
.addSelect('s.endTime', 'endTime')
.addSelect('s.startDate', 'startDate')
.addSelect('s.endDate', 'endDate')
.addSelect('s.weekDay', 'weekDay')
.addSelect('s.subject', 'subject')
.addSelect('c.name', 'className')
.select('s.classroomId', 'classroomId');
const scheduleSelects = [
['s.startTime', 'startTime'],
['s.endTime', 'endTime'],
['s.startDate', 'startDate'],
['s.endDate', 'endDate'],
['s.weekDay', 'weekDay'],
['s.subject', 'subject'],
['c.name', 'className'],
] as const;
for (const [column, alias] of scheduleSelects) {
qb.addSelect(column, alias);
}
const schedules = await qb
.where('s.classroomId IN (:...ids)', { ids: classroomIds })
.andWhere('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })

View File

@@ -1,5 +1,13 @@
function makeExpensesService(
a: never, b: never, c: never, d: never, e: never, f: never,
) {
const operations = new ExpenseOperationsService(a, b, c, d, e, f);
return new ExpensesService(a, b, c, d, e, f, operations);
}
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ExpensesService } from '../expenses/expenses.service';
import { ExpenseOperationsService } from '../expenses/expense-operations.service';
import { OccupanciesService } from '../occupancies/occupancies.service';
import { RoomsService } from '../rooms/rooms.service';
import { StudentsService } from '../students/students.service';
@@ -41,7 +49,7 @@ describe('batch restore service semantics', () => {
const rooms = new RoomsService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
const expenses = new ExpensesService(
const expenses = makeExpensesService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
const occupancies = new OccupanciesService(
@@ -129,7 +137,7 @@ describe('batch restore service semantics', () => {
createQueryBuilder: jest.fn(() => qb),
};
const billItemsRepo = { count: jest.fn().mockResolvedValue(1) };
const service = new ExpensesService(
const service = makeExpensesService(
roomExpRepo as never,
{} as never,
{} as never,
@@ -150,7 +158,7 @@ describe('batch restore service semantics', () => {
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new ExpensesService(
const service = makeExpensesService(
roomExpRepo as never, {} as never, {} as never, {} as never, {} as never,
{ getRepository: jest.fn(() => ({ count: jest.fn().mockResolvedValue(0) })) } as never,
);
@@ -168,7 +176,7 @@ describe('batch restore service semantics', () => {
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new ExpensesService(
const service = makeExpensesService(
roomExpRepo as never, {} as never, {} as never, {} as never, {} as never,
{ getRepository: jest.fn(() => ({ count })) } as never,
);
@@ -185,7 +193,7 @@ describe('batch restore service semantics', () => {
find: jest.fn().mockResolvedValue([{ id: 1, status: 'archived', billId: 9 }]),
createQueryBuilder: jest.fn(),
};
const service = new ExpensesService(
const service = makeExpensesService(
{} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.batchRestorePersonalExpenses([1])).rejects.toBeInstanceOf(BadRequestException);
@@ -201,7 +209,7 @@ describe('batch restore service semantics', () => {
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new ExpensesService(
const service = makeExpensesService(
{} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.batchRestorePersonalExpenses([1, 1, 2])).resolves.toMatchObject({
@@ -220,7 +228,7 @@ describe('batch restore service semantics', () => {
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new ExpensesService(
const service = makeExpensesService(
{} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.batchRestorePersonalExpenses([1, 2])).resolves.toMatchObject({
@@ -233,7 +241,7 @@ describe('batch restore service semantics', () => {
it('uses archived status when querying expense archive views', async () => {
const roomQb = listQb();
const personalRepo = { find: jest.fn().mockResolvedValue([]) };
const service = new ExpensesService(
const service = makeExpensesService(
{ createQueryBuilder: jest.fn(() => roomQb) } as never,
personalRepo as never,
{} as never, {} as never, {} as never, {} as never,
@@ -245,7 +253,7 @@ describe('batch restore service semantics', () => {
});
it('rejects invalid expense query status values', async () => {
const service = new ExpensesService(
const service = makeExpensesService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.findRoomExpenses({ status: 'deleted' as never })).rejects.toBeInstanceOf(BadRequestException);

View File

@@ -0,0 +1,241 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Occupancy } from '../entities/occupancy.entity';
import { Bill } from '../entities/bill.entity';
import { RoomExpense } from '../entities/room-expense.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
export function nextMonth(ym: string): string {
const d = new Date(`${ym}-01`);
d.setMonth(d.getMonth() + 1);
return d.toISOString().slice(0, 7) + '-01';
}
export function applyClassScope(
qb: { andWhere: (condition: string, parameters?: Record<string, unknown>) => unknown },
alias: string,
accessibleClassIds?: number[],
) {
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) {
qb.andWhere('1 = 0');
return;
}
qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds });
}
}
@Injectable()
export class DashboardQueriesService {
constructor(
@InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(Bill) private readonly billRepo: Repository<Bill>,
@InjectRepository(Occupancy) private readonly occRepo: Repository<Occupancy>,
@InjectRepository(RoomExpense) private readonly expRepo: Repository<RoomExpense>,
) {}
async getAttendanceTrend(
attendanceRepo: Repository<AttendanceRecord>,
todayStr: string,
accessibleClassIds?: number[],
) {
const thirtyDaysAgo = new Date(todayStr);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29);
const startStr = thirtyDaysAgo.toISOString().slice(0, 10);
const trendQb = attendanceRepo
.createQueryBuilder('a')
.select('a.attendanceDate', 'date')
.addSelect('a.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('a.attendanceDate >= :start', { start: startStr })
.andWhere('a.attendanceDate <= :today', { today: todayStr });
applyClassScope(trendQb, 'a', accessibleClassIds);
const rows = await trendQb
.groupBy('a.attendanceDate')
.addGroupBy('a.status')
.orderBy('a.attendanceDate', 'ASC')
.getRawMany();
const dayMap = new Map<string, { total: number; present: number }>();
for (const row of rows) {
const d = dayMap.get(row.date) || { total: 0, present: 0 };
const cnt = parseInt(row.count, 10);
d.total += cnt;
if (row.status === 'present') d.present += cnt;
dayMap.set(row.date, d);
}
return Array.from(dayMap.entries()).map(([date, d]) => ({
date,
rate: d.total > 0 ? ((d.present / d.total) * 100).toFixed(1) : 0,
}));
}
async getIncomeTrend(
billRepo: Repository<Bill>,
currentMonth: string,
) {
const results: { month: string; amount: number }[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(`${currentMonth}-01`);
d.setMonth(d.getMonth() - i);
const m = d.toISOString().slice(0, 7);
const row = await billRepo
.createQueryBuilder('b')
.select('SUM(b.totalAmount)', 'total')
.where('b.status = :paid', { paid: 'paid' })
.andWhere('b.periodStart >= :start', { start: `${m}-01` })
.andWhere('b.periodStart < :end', { end: nextMonth(m) })
.getRawOne();
results.push({
month: m,
amount: parseFloat(row?.total || '0'),
});
}
return results;
}
// 甘特图数据:每个宿舍的入住时间线
async getGanttData(
occRepo: Repository<Occupancy>,
assertPeriodRange: (start?: string, end?: string) => void,
query?: { periodStart?: string; periodEnd?: string; building?: string },
) {
assertPeriodRange(query?.periodStart, query?.periodEnd);
const qb = occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.where('room.status != :archived', { archived: 'archived' })
.orderBy('room.roomNumber', 'ASC')
.addOrderBy('o.checkInDate', 'ASC');
if (query?.building) {
qb.andWhere('room.building = :building', { building: query.building });
}
if (query?.periodStart) {
qb.andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :ps)', { ps: query.periodStart });
}
if (query?.periodEnd) {
qb.andWhere('o.checkInDate <= :pe', { pe: query.periodEnd });
}
const records = await qb.getMany();
// 按宿舍分组
const roomMap = new Map<string, Record<string, unknown>[]>();
for (const r of records) {
const key = r.room?.roomNumber || String(r.roomId);
if (!roomMap.has(key)) roomMap.set(key, []);
roomMap.get(key)!.push({
studentName: r.student?.name || '未知',
studentId: r.studentId,
checkInDate: r.checkInDate,
checkOutDate: r.checkOutDate,
billingStartDate: r.billingStartDate,
billingEndDate: r.billingEndDate,
});
}
return Array.from(roomMap.entries()).map(([roomNumber, occupancies]) => ({
roomNumber,
occupancies,
}));
}
// 费用统计
async getExpenseStats(
expRepo: Repository<RoomExpense>,
assertPeriodRange: (start?: string, end?: string) => void,
periodStart?: string,
periodEnd?: string,
) {
assertPeriodRange(periodStart, periodEnd);
const qb = expRepo
.createQueryBuilder('e')
.select('e.expenseType', 'type')
.addSelect('SUM(e.amount)', 'total')
.groupBy('e.expenseType');
if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart });
if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd });
return qb.getRawMany();
}
// 各宿舍费用排行
async getRoomExpenseRanking(
expRepo: Repository<RoomExpense>,
assertPeriodRange: (start?: string, end?: string) => void,
periodStart?: string,
periodEnd?: string,
) {
assertPeriodRange(periodStart, periodEnd);
const qb = expRepo
.createQueryBuilder('e')
.leftJoin('e.room', 'room')
.select('room.roomNumber', 'roomNumber')
.addSelect('SUM(e.amount)', 'total')
.where('room.status != :archived', { archived: 'archived' })
.groupBy('e.roomId')
.orderBy('total', 'DESC')
.limit(20);
if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart });
if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd });
return qb.getRawMany();
}
// 班级考勤排行
async getClassAttendanceRanking(
attendanceRepo: Repository<AttendanceRecord>,
applyClassScope: (
qb: { andWhere: (condition: string, parameters?: Record<string, unknown>) => unknown },
alias: string,
accessibleClassIds?: number[],
) => void,
accessibleClassIds?: number[],
) {
if (accessibleClassIds?.length === 0) return { top: [], bottom: [] };
const qb = attendanceRepo
.createQueryBuilder('a')
.leftJoin('a.class', 'class')
.select('class.id', 'classId')
.addSelect('class.name', 'className')
.addSelect('a.status', 'status')
.addSelect('COUNT(*)', 'count');
applyClassScope(qb, 'a', accessibleClassIds);
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
const raw = await qb.getRawMany();
const classMap = new Map<number, { className: string; present: number; total: number }>();
for (const r of raw) {
if (!r.classId) continue;
if (!classMap.has(Number(r.classId)))
classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 });
const entry = classMap.get(Number(r.classId))!;
const n = parseInt(r.count, 10);
entry.total += n;
if (r.status === 'present') entry.present += n;
}
const ranked = Array.from(classMap.values())
.map((e) => ({
...e,
rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0,
}))
.sort((a, b) => b.rate - a.rate);
return { top: ranked.slice(0, 5), bottom: ranked.slice(-5).reverse() };
}
}

View File

@@ -14,6 +14,7 @@ import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { DashboardService } from './dashboard.service';
import { DashboardQueriesService } from './dashboard-queries.service';
import { DashboardController } from './dashboard.controller';
@Module({
@@ -35,7 +36,7 @@ import { DashboardController } from './dashboard.controller';
]),
],
controllers: [DashboardController],
providers: [DashboardService],
providers: [DashboardService, DashboardQueriesService],
exports: [DashboardService],
})
export class DashboardModule {}

View File

@@ -1,4 +1,8 @@
import { DashboardService } from './dashboard.service';
import { DashboardQueriesService } from './dashboard-queries.service';
const queriesService = (attendanceRepo?: unknown) =>
new DashboardQueriesService(attendanceRepo as never, {} as never, {} as never, {} as never);
const createQb = () => ({
leftJoin: jest.fn().mockReturnThis(),
@@ -32,7 +36,7 @@ describe('DashboardService — teacher class scope', () => {
{} as never,
{} as never,
{} as never,
{},
queriesService(attendanceRepo),
);
await service.getClassAttendanceRanking([8, 9]);
@@ -51,6 +55,7 @@ describe('DashboardService — boundary conditions', () => {
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never, attendanceRepo as never, {} as never, {} as never, {} as never,
{} as never, {} as never,
queriesService(attendanceRepo),
);
await (service as unknown as {
@@ -69,6 +74,7 @@ describe('DashboardService — boundary conditions', () => {
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never,
queriesService(),
);
await expect((service[method] as (...values: never[]) => Promise<unknown>)(...(args as never[])))
.rejects.toThrow('结束日期不能早于开始日期');
@@ -79,6 +85,7 @@ describe('DashboardService — boundary conditions', () => {
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never,
queriesService(),
);
expect((service as unknown as { getChinaDate: (date: Date) => string })
.getChinaDate(new Date('2026-07-13T16:30:00.000Z'))).toBe('2026-07-14');

View File

@@ -14,6 +14,7 @@ import { Deposit } from '../entities/deposit.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { DashboardQueriesService } from './dashboard-queries.service';
interface AgentAttendanceStatusRow {
status: string;
@@ -36,6 +37,7 @@ export class DashboardService {
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
private readonly queries: DashboardQueriesService,
) {}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
@@ -50,24 +52,39 @@ export class DashboardService {
const totalStudents = accessibleClassIds
? await this.countStudentsInClasses(accessibleClassIds)
: await this.studentRepo.count({ where: { status: 'active' } });
const classCount = accessibleClassIds ? accessibleClassIds.length : await this.classRepo.count({ where: { isArchived: false } });
const classCount = accessibleClassIds
? accessibleClassIds.length
: await this.classRepo.count({ where: { isArchived: false } });
const attendanceQb = this.attendanceRepo
.createQueryBuilder('attendance')
.select('attendance.status', 'status')
.addSelect('COUNT(attendance.id)', 'count')
.where('attendance.attendanceDate = :today', { today });
this.applyClassScope(attendanceQb, 'attendance', accessibleClassIds);
const rows = await attendanceQb.groupBy('attendance.status').getRawMany<AgentAttendanceStatusRow>();
const attendanceByStatus = rows.reduce((result, row) => {
result[String(row.status)] = Number(row.count || 0);
return result;
}, {} as Record<string, number>);
const rows = await attendanceQb
.groupBy('attendance.status')
.getRawMany<AgentAttendanceStatusRow>();
const attendanceByStatus = rows.reduce(
(result, row) => {
result[String(row.status)] = Number(row.count || 0);
return result;
},
{} as Record<string, number>,
);
const attendanceTotal = Object.values(attendanceByStatus).reduce<number>(
(sum, count) => sum + Number(count),
0,
);
const present = attendanceByStatus.present ?? 0;
return { date: today, totalStudents, classCount, attendanceTotal, present, attendanceRate: attendanceTotal ? Number(((present / attendanceTotal) * 100).toFixed(1)) : 0, attendanceByStatus };
return {
date: today,
totalStudents,
classCount,
attendanceTotal,
present,
attendanceRate: attendanceTotal ? Number(((present / attendanceTotal) * 100).toFixed(1)) : 0,
attendanceByStatus,
};
}
async getStats(accessibleClassIds?: number[]) {
@@ -117,12 +134,9 @@ export class DashboardService {
this.applyClassScope(attTodayQb, 'a', accessibleClassIds);
attTodayQb.groupBy('a.status');
const attTodayStats = await attTodayQb.getRawMany();
const todayTotal = attTodayStats.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
const todayPresent = attTodayStats
.filter((r) => r.status === 'present')
.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
const todayAttendanceRate = todayTotal > 0 ? ((todayPresent / todayTotal) * 100).toFixed(1) : 0;
const incomeQb = this.billRepo
.createQueryBuilder('b')
.select('SUM(b.totalAmount)', 'total')
@@ -135,7 +149,6 @@ export class DashboardService {
const attendanceTrend = await this.getAttendanceTrend(todayStr, accessibleClassIds);
const incomeTrend = await this.getIncomeTrend(currentMonth);
// --- New stats ---
const classCount = accessibleClassIds
? accessibleClassIds.length
: await this.classRepo.count({ where: {} });
@@ -226,64 +239,32 @@ export class DashboardService {
return new Set(classStudents.map((item) => item.studentId)).size;
}
private async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) {
const thirtyDaysAgo = new Date(todayStr);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29);
const startStr = thirtyDaysAgo.toISOString().slice(0, 10);
const trendQb = this.attendanceRepo
.createQueryBuilder('a')
.select('a.attendanceDate', 'date')
.addSelect('a.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('a.attendanceDate >= :start', { start: startStr })
.andWhere('a.attendanceDate <= :today', { today: todayStr });
this.applyClassScope(trendQb, 'a', accessibleClassIds);
const rows = await trendQb
.groupBy('a.attendanceDate')
.addGroupBy('a.status')
.orderBy('a.attendanceDate', 'ASC')
.getRawMany();
const dayMap = new Map<string, { total: number; present: number }>();
for (const row of rows) {
const d = dayMap.get(row.date) || { total: 0, present: 0 };
const cnt = parseInt(row.count, 10);
d.total += cnt;
if (row.status === 'present') d.present += cnt;
dayMap.set(row.date, d);
}
return Array.from(dayMap.entries()).map(([date, d]) => ({
date,
rate: d.total > 0 ? ((d.present / d.total) * 100).toFixed(1) : 0,
}));
async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) {
return this.queries.getAttendanceTrend(this.attendanceRepo, todayStr, accessibleClassIds);
}
private async getIncomeTrend(currentMonth: string) {
const results: { month: string; amount: number }[] = [];
async getIncomeTrend(currentMonth: string) {
return this.queries.getIncomeTrend(this.billRepo, currentMonth);
}
for (let i = 5; i >= 0; i--) {
const d = new Date(`${currentMonth}-01`);
d.setMonth(d.getMonth() - i);
const m = d.toISOString().slice(0, 7);
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) {
return this.queries.getGanttData(this.occRepo, (a, b) => this.assertPeriodRange(a, b), query);
}
const row = await this.billRepo
.createQueryBuilder('b')
.select('SUM(b.totalAmount)', 'total')
.where('b.status = :paid', { paid: 'paid' })
.andWhere('b.periodStart >= :start', { start: `${m}-01` })
.andWhere('b.periodStart < :end', { end: this.nextMonth(m) })
.getRawOne();
async getExpenseStats(periodStart?: string, periodEnd?: string) {
return this.queries.getExpenseStats(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd);
}
results.push({
month: m,
amount: parseFloat(row?.total || '0'),
});
}
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
return this.queries.getRoomExpenseRanking(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd);
}
return results;
async getClassAttendanceRanking(accessibleClassIds?: number[]) {
return this.queries.getClassAttendanceRanking(
this.attendanceRepo,
(qb, alias, ids) => this.applyClassScope(qb, alias, ids),
accessibleClassIds,
);
}
private nextMonth(ym: string): string {
@@ -292,114 +273,6 @@ export class DashboardService {
return d.toISOString().slice(0, 7) + '-01';
}
// 甘特图数据:每个宿舍的入住时间线
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) {
this.assertPeriodRange(query?.periodStart, query?.periodEnd);
const qb = this.occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.where('room.status != :archived', { archived: 'archived' })
.orderBy('room.roomNumber', 'ASC')
.addOrderBy('o.checkInDate', 'ASC');
if (query?.building) {
qb.andWhere('room.building = :building', { building: query.building });
}
if (query?.periodStart) {
qb.andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :ps)', { ps: query.periodStart });
}
if (query?.periodEnd) {
qb.andWhere('o.checkInDate <= :pe', { pe: query.periodEnd });
}
const records = await qb.getMany();
// 按宿舍分组
const roomMap = new Map<string, Record<string, unknown>[]>();
for (const r of records) {
const key = r.room?.roomNumber || String(r.roomId);
if (!roomMap.has(key)) roomMap.set(key, []);
roomMap.get(key)!.push({
studentName: r.student?.name || '未知',
studentId: r.studentId,
checkInDate: r.checkInDate,
checkOutDate: r.checkOutDate,
billingStartDate: r.billingStartDate,
billingEndDate: r.billingEndDate,
});
}
return Array.from(roomMap.entries()).map(([roomNumber, occupancies]) => ({
roomNumber,
occupancies,
}));
}
// 费用统计
async getExpenseStats(periodStart?: string, periodEnd?: string) {
this.assertPeriodRange(periodStart, periodEnd);
const qb = this.expRepo
.createQueryBuilder('e')
.select('e.expenseType', 'type')
.addSelect('SUM(e.amount)', 'total')
.groupBy('e.expenseType');
if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart });
if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd });
return qb.getRawMany();
}
// 各宿舍费用排行
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
this.assertPeriodRange(periodStart, periodEnd);
const qb = this.expRepo
.createQueryBuilder('e')
.leftJoin('e.room', 'room')
.select('room.roomNumber', 'roomNumber')
.addSelect('SUM(e.amount)', 'total')
.where('room.status != :archived', { archived: 'archived' })
.groupBy('e.roomId')
.orderBy('total', 'DESC')
.limit(20);
if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart });
if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd });
return qb.getRawMany();
}
// 班级考勤排行
async getClassAttendanceRanking(accessibleClassIds?: number[]) {
if (accessibleClassIds?.length === 0) return { top: [], bottom: [] };
const qb = this.attendanceRepo
.createQueryBuilder('a')
.leftJoin('a.class', 'class')
.select('class.id', 'classId')
.addSelect('class.name', 'className')
.addSelect('a.status', 'status')
.addSelect('COUNT(*)', 'count');
this.applyClassScope(qb, 'a', accessibleClassIds);
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
const raw = await qb.getRawMany();
const classMap = new Map<number, { className: string; present: number; total: number }>();
for (const r of raw) {
if (!r.classId) continue;
if (!classMap.has(Number(r.classId)))
classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 });
const entry = classMap.get(Number(r.classId))!;
const n = parseInt(r.count, 10);
entry.total += n;
if (r.status === 'present') entry.present += n;
}
const ranked = Array.from(classMap.values())
.map((e) => ({
...e,
rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0,
}))
.sort((a, b) => b.rate - a.rate);
return { top: ranked.slice(0, 5), bottom: ranked.slice(-5).reverse() };
}
async getClassroomOccupancy() {
const classrooms = await this.classroomRepo.find({
where: { status: 'available' as const },

View File

@@ -25,7 +25,7 @@ import {
} from './dto/deposit.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
@UseGuards(JwtAuthGuard)
@@ -78,31 +78,11 @@ export class DepositsController {
@Post()
@RequirePermission('deposit:create')
async create(@Body() dto: CreateDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '收取押金',
targetId: result.id,
targetType: 'deposit',
detail: `学生${dto.studentId} ¥${dto.amount}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '收取押金', targetId: result.id, targetType: 'deposit', detail: `学生${dto.studentId} ¥${dto.amount}`,
});
// Send deposit_due notification
try {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (student?.userId) {
void this.notificationsService.create({
recipientIds: [student.userId],
type: 'deposit_due',
title: '押金待缴',
content: `您有一笔押金待缴纳,金额: ¥${dto.amount}`,
});
}
} catch (_) { /* don't block response */ }
await this.notifyDeposit(dto.studentId, 'deposit_due', '押金待缴', `您有一笔押金待缴纳,金额: ¥${dto.amount}`);
return result;
}
@@ -110,17 +90,9 @@ export class DepositsController {
@Post('batch')
@RequirePermission('deposit:create')
async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchCreate(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '批量收取押金',
targetType: 'deposit',
detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '批量收取押金', targetType: 'deposit', detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`,
});
return result;
}
@@ -132,18 +104,9 @@ export class DepositsController {
@Body() body: CreateDepositInstallmentDto,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.addInstallment(id, body.amount, body.dueDate);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '新增分期',
targetId: result.id,
targetType: 'deposit-installment',
detail: `押金${id} 新增分期 ¥${result.amount}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '新增分期', targetId: result.id, targetType: 'deposit-installment', detail: `押金${id} 新增分期 ¥${result.amount}`,
});
return result;
}
@@ -155,18 +118,9 @@ export class DepositsController {
@Body() body: UpdateDepositInstallmentDto,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateInstallment(installmentId, body);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '更新分期',
targetId: installmentId,
targetType: 'deposit-installment',
detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '更新分期', targetId: installmentId, targetType: 'deposit-installment', detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
});
return result;
}
@@ -177,18 +131,9 @@ export class DepositsController {
@Param('installmentId', ParseIntPipe) installmentId: number,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.deleteInstallment(installmentId);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '归档分期',
targetId: installmentId,
targetType: 'deposit-installment',
detail: `归档分期${installmentId}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '归档分期', targetId: installmentId, targetType: 'deposit-installment', detail: `归档分期${installmentId}`,
});
return result;
}
@@ -196,48 +141,46 @@ export class DepositsController {
@Put(':id/refund')
@RequirePermission('deposit:refund')
async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.refund(id, dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '退还押金',
targetId: id,
targetType: 'deposit',
detail: `退还全部可用押金 ¥${result.refundAmount}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '退还押金', targetId: id, targetType: 'deposit', detail: `退还全部可用押金 ¥${result.refundAmount}`,
});
// Send deposit_refunded notification
try {
const student = await this.studentRepo.findOne({ where: { id: result.studentId } });
if (student?.userId) {
void this.notificationsService.create({
recipientIds: [student.userId],
type: 'deposit_refunded',
title: '押金已退还',
content: `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`,
});
}
} catch (_) { /* don't block response */ }
await this.notifyDeposit(result.studentId, 'deposit_refunded', '押金已退还', `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`);
return result;
}
private async notifyDeposit(
studentId: number,
type: 'deposit_due' | 'deposit_refunded',
title: string,
content: string,
): Promise<void> {
try {
const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (student?.userId) {
void this.notificationsService.create({ recipientIds: [student.userId], type, title, content });
}
} catch {
// 通知失败不影响主流程
}
}
@Delete(':id')
@RequirePermission('deposit:delete')
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '归档押金记录',
targetId: id,
targetType: 'deposit',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '归档押金记录', targetId: id, targetType: 'deposit',
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('deposit:purge')
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.purge(id);
await logAudit(this.logService, req, {
module: '押金管理', action: '永久删除押金', targetId: id, targetType: 'deposit', detail: '物理删除,不可恢复',
});
return result;
}

View File

@@ -0,0 +1,28 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { DepositsController } from './deposits.controller';
describe('DepositsController purge route', () => {
it('requires deposit:purge on permanent delete route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, DepositsController.prototype.purge)).toEqual([
'deposit:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除押金(不可恢复)' }) };
const log = jest.fn().mockResolvedValue(undefined);
const controller = new DepositsController(
service as never,
{ log } as never,
{} as never,
{} as never,
);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge(1, req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '押金管理', action: '永久删除押金', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,65 @@
import { BadRequestException } from '@nestjs/common';
import { DepositsService } from './deposits.service';
describe('DepositsService.purge', () => {
const createService = (overrides?: { deposit?: Record<string, unknown> }) => {
const deposit = {
id: 1,
studentId: 2,
amount: 500,
status: 'archived',
refundAmount: null,
deductionAmount: 0,
...overrides?.deposit,
};
const repo = {
findOne: jest.fn().mockResolvedValue(deposit),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const installmentRepo = { count: jest.fn().mockResolvedValue(0) };
const service = new DepositsService(
repo as never,
installmentRepo as never,
{} as never,
);
return { service, repo, installmentRepo };
};
it('rejects deposits that are not archived', async () => {
const { service, repo } = createService({ deposit: { status: 'paid' } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已归档押金可以永久删除,请先归档'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('rejects deposits with refund or deduction amounts', async () => {
const withRefund = createService({ deposit: { refundAmount: 100 } });
await expect(withRefund.service.purge(1)).rejects.toThrow(
new BadRequestException('该押金已有退款金额,无法永久删除'),
);
const withDeduction = createService({ deposit: { deductionAmount: 50 } });
await expect(withDeduction.service.purge(1)).rejects.toThrow(
new BadRequestException('该押金已有抵扣金额,无法永久删除'),
);
expect(withDeduction.repo.delete).not.toHaveBeenCalled();
});
it('rejects deposits with paid installments', async () => {
const { service, installmentRepo, repo } = createService();
installmentRepo.count.mockResolvedValue(1);
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('该押金存在已支付分期,无法永久删除'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
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);
});
});

View File

@@ -67,16 +67,21 @@ export class DepositsService {
.leftJoin(Deposit, 'deposit', 'deposit.student_id = student.id AND deposit.status != :archived', {
archived: 'archived',
})
.select('student.id', 'studentId')
.addSelect('student.name', 'studentName')
.addSelect('student.studentNo', 'studentNo')
.addSelect('room.id', 'roomId')
.addSelect('room.roomNumber', 'roomNumber')
.addSelect('room.building', 'building')
.addSelect('room.roomType', 'roomType')
.addSelect('room.capacity', 'capacity')
.addSelect('deposit.amount', 'depositAmount')
.where('o.status = :activeStatus', { activeStatus: 'active' })
.select('student.id', 'studentId');
const eligibleSelects = [
['student.name', 'studentName'],
['student.studentNo', 'studentNo'],
['room.id', 'roomId'],
['room.roomNumber', 'roomNumber'],
['room.building', 'building'],
['room.roomType', 'roomType'],
['room.capacity', 'capacity'],
['deposit.amount', 'depositAmount'],
] as const;
for (const [column, alias] of eligibleSelects) {
qb.addSelect(column, alias);
}
qb.where('o.status = :activeStatus', { activeStatus: 'active' })
.andWhere('o.checkOutDate IS NULL')
.andWhere('student.status = :studentStatus', { studentStatus: 'active' })
.orderBy('room.building', 'ASC')
@@ -167,15 +172,20 @@ export class DepositsService {
const qb = this.repo
.createQueryBuilder('d')
.leftJoin('d.student', 'student')
.select('d.id', 'id')
.addSelect('student.name', 'studentName')
.addSelect('student.studentNo', 'studentNo')
.addSelect('d.amount', 'amount')
.addSelect('d.status', 'status')
.addSelect('d.paidDate', 'paidDate')
.addSelect('d.refundAmount', 'refundAmount')
.addSelect('d.refundDate', 'refundDate')
.where('d.status != :archived', { archived: 'archived' });
.select('d.id', 'id');
const depositSelects = [
['student.name', 'studentName'],
['student.studentNo', 'studentNo'],
['d.amount', 'amount'],
['d.status', 'status'],
['d.paidDate', 'paidDate'],
['d.refundAmount', 'refundAmount'],
['d.refundDate', 'refundDate'],
] as const;
for (const [column, alias] of depositSelects) {
qb.addSelect(column, alias);
}
qb.where('d.status != :archived', { archived: 'archived' });
if (query?.keyword) {
qb.andWhere(
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
@@ -226,8 +236,8 @@ export class DepositsService {
existing.paidDate = dto.paidDate;
existing.status = 'paid';
existing.recordedBy = userId ?? null;
existing.refundDate = null as unknown as string;
existing.refundAmount = null as unknown as number;
existing.refundDate = null;
existing.refundAmount = null;
existing.refundedBy = null;
existing.refundedAt = null;
if (dto.notes) existing.notes = dto.notes;
@@ -309,6 +319,28 @@ export class DepositsService {
return { message: '已归档' };
}
async purge(id: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
if (deposit.status !== 'archived') {
throw new BadRequestException('仅已归档押金可以永久删除,请先归档');
}
if (Number(deposit.refundAmount || 0) > 0) {
throw new BadRequestException('该押金已有退款金额,无法永久删除');
}
if (Number(deposit.deductionAmount || 0) > 0) {
throw new BadRequestException('该押金已有抵扣金额,无法永久删除');
}
const paidInstallments = await this.installmentRepo.count({
where: { depositId: id, status: 'paid' },
});
if (paidInstallments > 0) {
throw new BadRequestException('该押金存在已支付分期,无法永久删除');
}
await this.repo.delete(id);
return { message: '已永久删除押金(不可恢复)' };
}
async getStats() {
const qb = this.repo
.createQueryBuilder('d')

View File

@@ -8,6 +8,8 @@ import {
JoinColumn,
Check,
} from 'typeorm';
import type { Class } from './class.entity';
import type { User } from './user.entity';
export enum ScheduleType {
INTERNAL = 'INTERNAL',
@@ -26,7 +28,7 @@ export class ClassSchedule {
// Forward reference — Class entity
@ManyToOne('Class', { nullable: true })
@JoinColumn({ name: 'class_id' })
class: unknown;
class: Class | null;
@Column({ name: 'classroom_id', type: 'integer' })
classroomId: number;
@@ -64,7 +66,7 @@ export class ClassSchedule {
// Forward reference — User entity
@ManyToOne('User', { nullable: true })
@JoinColumn({ name: 'teacher_id' })
teacher: unknown;
teacher: User | null;
@Column({ name: 'schedule_type', length: 20, default: 'INTERNAL' })
scheduleType: string;

View File

@@ -51,11 +51,11 @@ export class ClassroomRental {
endDate: string;
// 合同 PDF 相对路径(相对 UPLOAD_DIR仅存文件名
@Column({ name: 'contract_path', length: 255, nullable: true })
contractPath: string;
@Column({ name: 'contract_path', type: 'varchar', length: 255, nullable: true })
contractPath: string | null;
@Column({ name: 'contract_original_name', length: 255, nullable: true })
contractOriginalName: string;
@Column({ name: 'contract_original_name', type: 'varchar', length: 255, nullable: true })
contractOriginalName: string | null;
@Column({ name: 'daily_rate', type: 'decimal', precision: 10, scale: 2, nullable: true })
dailyRate: number;

View File

@@ -29,10 +29,10 @@ export class Deposit {
paidDate: string;
@Column({ name: 'refund_date', type: 'date', nullable: true })
refundDate: string;
refundDate: string | null;
@Column({ name: 'refund_amount', type: 'decimal', precision: 10, scale: 2, nullable: true })
refundAmount: number;
refundAmount: number | null;
@Column({ name: 'deduction_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
deductionAmount: number;

View File

@@ -53,3 +53,6 @@ export {
AiForm,
AiReview,
} from '../ai-chat/entities';
export { ImportRun } from '../imports/entities/import-run.entity';
export { ImportStep } from '../imports/entities/import-step.entity';
export { ImportRow } from '../imports/entities/import-row.entity';

View File

@@ -1,12 +1,4 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
OneToMany,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany } from 'typeorm';
import { Occupancy } from './occupancy.entity';
import { RoomExpense } from './room-expense.entity';

View File

@@ -60,4 +60,24 @@ describe('ExamsController batch archive and restore', () => {
['批量恢复考试', 'IDs: 3,4'],
]);
});
it('requires exam:purge and writes permanent delete logs', async () => {
expect(Reflect.getMetadata(PERMISSION_KEY, ExamsController.prototype.purge)).toEqual([
'exam:purge',
]);
expect(
Reflect.getMetadata(PERMISSION_KEY, ExamsController.prototype.batchPurge),
).toEqual(['exam:purge']);
const service = {
purge: jest.fn().mockResolvedValue({ message: '已永久删除考试(不可恢复)' }),
};
const log = jest.fn().mockResolvedValue(undefined);
const controller = new ExamsController(service as never, { log } as never);
await controller.purge(1, req);
expect(service.purge).toHaveBeenCalledWith(1, 7, true);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '考试管理', action: '永久删除考试', targetId: 1 }),
);
});
});

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
@@ -14,7 +15,7 @@ import {
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { BatchIdsDto } from '../common/batch-ids.dto';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import type { AuthenticatedUser } from '../authorization';
@@ -55,15 +56,8 @@ export class ExamsController {
req.user.id,
this.canManageAll(req),
);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考试管理',
action: '批量归档考试',
detail: `IDs: ${dto.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '考试管理', action: '批量归档考试', detail: `IDs: ${dto.ids.join(',')}`,
});
return result;
}
@@ -76,15 +70,8 @@ export class ExamsController {
req.user.id,
this.canManageAll(req),
);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考试管理',
action: '批量恢复考试',
detail: `IDs: ${dto.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '考试管理', action: '批量恢复考试', detail: `IDs: ${dto.ids.join(',')}`,
});
return result;
}
@@ -99,17 +86,8 @@ export class ExamsController {
@RequirePermission('exam:view')
async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.create(dto, req.user.id, this.canManageAll(req));
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考试管理',
action: '创建考试',
targetId: result.id,
targetType: 'exam',
detail: `${dto.examName} - ${dto.subject}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '考试管理', action: '创建考试', targetId: result.id, targetType: 'exam', detail: `${dto.examName} - ${dto.subject}`,
});
return result;
}
@@ -121,16 +99,8 @@ export class ExamsController {
@Request() req: AuthenticatedRequest,
) {
const result = await this.service.archive(id, req.user.id, this.canManageAll(req));
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考试管理',
action: '归档考试',
targetId: id,
targetType: 'exam',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '考试管理', action: '归档考试', targetId: id, targetType: 'exam',
});
return result;
}
@@ -142,16 +112,35 @@ export class ExamsController {
@Request() req: AuthenticatedRequest,
) {
const result = await this.service.restore(id, req.user.id, this.canManageAll(req));
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考试管理',
action: '恢复考试',
targetId: id,
targetType: 'exam',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '考试管理', action: '恢复考试', targetId: id, targetType: 'exam',
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('exam:purge')
async purge(
@Param('id', ParseIntPipe) id: number,
@Request() req: AuthenticatedRequest,
) {
const result = await this.service.purge(id, req.user.id, this.canManageAll(req));
await logAudit(this.logService, req, {
module: '考试管理', action: '永久删除考试', targetId: id, targetType: 'exam', detail: '物理删除,不可恢复',
});
return result;
}
@Post('batch-permanent-delete')
@RequirePermission('exam:purge')
async batchPurge(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.batchPurge(
dto.ids,
req.user.id,
this.canManageAll(req),
);
await logAudit(this.logService, req, {
module: '考试管理', action: '批量永久删除考试', detail: `IDs: ${dto.ids.join(',')}`,
});
return result;
}
@@ -171,17 +160,8 @@ export class ExamsController {
req.user.id,
this.canManageAll(req),
);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考试管理',
action: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩',
targetId: scoreId,
targetType: 'exam_score',
detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩:${dto.score}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '考试管理', action: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩', targetId: scoreId, targetType: 'exam_score', detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩:${dto.score}`,
});
return result;
}

View File

@@ -0,0 +1,56 @@
import { BadRequestException } from '@nestjs/common';
import { ExamsService } from './exams.service';
describe('ExamsService.purge', () => {
const createService = (overrides?: { exam?: Record<string, unknown> }) => {
const exam = { id: 1, examName: '月考', classId: 2, status: 'archived', ...overrides?.exam };
const examRepo = {
findOne: jest.fn().mockResolvedValue(exam),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
find: jest.fn().mockResolvedValue([exam]),
};
const classTeacherRepo = { findOne: jest.fn().mockResolvedValue({}) };
const service = new ExamsService(
examRepo as never,
{} as never,
{} as never,
{} as never,
classTeacherRepo as never,
{} as never,
);
return { service, examRepo, classTeacherRepo };
};
it('rejects exams that are not archived', async () => {
const { service, examRepo } = createService({ exam: { status: 'active' } });
await expect(service.purge(1, 7, true)).rejects.toThrow(
new BadRequestException('仅已归档考试可以永久删除,请先归档'),
);
expect(examRepo.delete).not.toHaveBeenCalled();
});
it('deletes an archived exam and its scores', async () => {
const { service, examRepo } = createService();
await expect(service.purge(1, 7, true)).resolves.toEqual({
message: '已永久删除考试(不可恢复)',
});
expect(examRepo.delete).toHaveBeenCalledWith(1);
});
it('checks class access before purge', async () => {
const { service, classTeacherRepo } = createService();
classTeacherRepo.findOne.mockResolvedValue(null);
await expect(service.purge(1, 7, false)).rejects.toThrow('只能访问自己被分配的班级');
});
it('batch purge returns deleted and skipped', async () => {
const { service, examRepo } = createService();
examRepo.find = jest.fn().mockResolvedValue([
{ id: 1, examName: '月考', classId: 2, status: 'archived' },
{ id: 2, examName: '期中', classId: 2, status: 'active' },
]);
const result = await service.batchPurge([1, 2], 7, true);
expect(result).toMatchObject({ deleted: 1, skipped: 1 });
expect(examRepo.delete).toHaveBeenCalledWith(1);
});
});

View File

@@ -194,6 +194,34 @@ export class ExamsService {
return { success: true };
}
async purge(id: number, userId: number, canManageAll: boolean) {
const exam = await this.examRepo.findOne({ where: { id } });
if (!exam) throw new NotFoundException('考试不存在');
await this.assertClassAccess(userId, exam.classId, canManageAll);
if (exam.status !== 'archived') throw new BadRequestException('仅已归档考试可以永久删除,请先归档');
await this.examRepo.delete(id);
return { message: '已永久删除考试(不可恢复)' };
}
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;
}
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 };
}
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);
@@ -220,7 +248,7 @@ export class ExamsService {
ids: number[],
userId: number,
canManageAll: boolean,
action: '归档' | '恢复',
action: '归档' | '恢复' | '永久删除',
) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException(`请选择要${action}的考试`);

View File

@@ -0,0 +1,438 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import { RoomExpense, PersonalExpense, Room, Student } from '../entities';
import { BillsService } from '../bills/bills.service';
import { RoomsService } from '../rooms/rooms.service';
import type { CreatePersonalExpenseDto } from './dto/expense.dto';
@Injectable()
export class ExpenseOperationsService {
constructor(
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
@InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
private billsService: BillsService,
private dataSource: DataSource,
) {}
private assertPositiveAmount(amount: number) {
if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) {
throw new BadRequestException('费用金额最多保留两位小数');
}
if (amount <= 0) throw new BadRequestException('费用金额必须大于0');
}
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) {
this.assertPositiveAmount(dto.amount);
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId });
return this.personalExpRepo.save(entity);
}
async findPersonalExpenses(query?: { studentId?: number; status?: 'active' | 'archived' }) {
const status = query?.status ?? 'active';
if (status !== 'active' && status !== 'archived') throw new BadRequestException('费用状态无效');
const where: Record<string, unknown> = { status };
if (query?.studentId) where.studentId = query.studentId;
return this.personalExpRepo.find({
where,
relations: ['student'],
order: { createdAt: 'DESC' },
});
}
async deletePersonalExpense(id: number) {
const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能归档,请先取消账单');
if (e.status === 'archived') throw new BadRequestException('费用记录已归档');
await this.personalExpRepo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async batchDeletePersonalExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录');
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
if (existing.some((expense) => expense.billId)) {
throw new BadRequestException('选中记录包含已计入账单的个人费用');
}
const result = await this.personalExpRepo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: uniqueIds })
.execute();
return { message: `已批量归档 ${result.affected || 0}`, archived: result.affected || 0 };
}
async batchRestorePersonalExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('费用记录 ID 无效');
}
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const targets = existing.filter((expense) => expense.status === 'archived');
if (targets.some((expense) => expense.billId)) {
throw new BadRequestException('选中记录包含已计入账单的个人费用');
}
const targetIds = targets.map((expense) => expense.id);
const skipped = existing.length - targetIds.length;
let restored = 0;
if (targetIds.length > 0) {
const result = await this.personalExpRepo
.createQueryBuilder()
.update()
.set({ status: 'active' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
restored = result.affected || 0;
}
return { message: `已批量恢复 ${restored} 条个人费用`, restored, skipped };
}
async purgePersonalExpense(id: number) {
const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.status !== 'archived') throw new BadRequestException('仅已归档费用可以永久删除,请先归档');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能永久删除,请先取消账单');
const billed = await this.dataSource
.getRepository('bill_items')
.count({ where: { personalExpenseId: id } });
if (billed) throw new BadRequestException('已计入账单明细的个人费用不能永久删除,请先取消账单');
await this.personalExpRepo.delete(id);
return { message: '已永久删除个人费用(不可恢复)' };
}
async batchPurgePersonalExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的个人费用');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('费用记录 ID 无效');
}
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const billed = await this.dataSource
.getRepository('bill_items')
.count({ where: { personalExpenseId: In(uniqueIds) } });
if (billed) throw new BadRequestException('选中记录包含已计入账单明细的个人费用');
if (existing.some((expense) => expense.billId)) {
throw new BadRequestException('选中记录包含已计入账单的个人费用');
}
const deleted: number[] = [];
const skipped: string[] = [];
for (const e of existing) {
if (e.status !== 'archived') {
skipped.push(`记录${e.id}(未归档)`);
continue;
}
await this.personalExpRepo.delete(e.id);
deleted.push(e.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 };
}
async updatePersonalExpense(id: number, dto: Partial<CreatePersonalExpenseDto>) {
const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能修改,请先取消账单');
if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount);
if (dto.studentId !== undefined && dto.studentId !== e.studentId) {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
}
Object.assign(e, dto);
return this.personalExpRepo.save(e);
}
/**
* 水电费Excel批量导入
* Excel格式: 序号|时间|房间号|房间电量|电费|冷水用量(吨)|水费|应缴金额
* 时间格式: "2026-01-21 - 2026-02-08"
*/
async batchImportUtilityExpenses(
rows: {
periodStr: string;
roomNumber: string;
electricityAmount: number;
electricityFee: number;
waterAmount: number;
waterFee: number;
totalFee: number;
}[],
userId?: number,
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNum = i + 2;
if (!row.roomNumber?.trim()) {
skipped++;
continue;
}
try {
// 查找或创建宿舍
let room = await this.roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } });
if (!room) {
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
room = await this.roomRepo.save(
this.roomRepo.create({
roomNumber: row.roomNumber.trim(),
building: parsed.building || undefined,
floor: parsed.floor || undefined,
capacity: parsed.capacity || 4,
roomType: parsed.roomType || undefined,
}),
);
}
// 解析时间段 "2026-01-21 - 2026-02-08" 或 "2026-01-21~2026-02-08"
let periodStart = '';
let periodEnd = '';
if (row.periodStr) {
// 先尝试用" - "或" ~ "分割(带空格的分隔符,避免拆分日期内部的连字符)
let parts = row.periodStr.split(/\s+[-~]\s+/);
if (parts.length < 2) {
// 回退:尝试用正则提取 YYYY-MM-DD 格式的日期
const dateMatches = row.periodStr.match(/(\d{4}-\d{1,2}-\d{1,2})/g);
if (dateMatches && dateMatches.length >= 2) {
parts = [dateMatches[0], dateMatches[1]];
}
}
if (parts.length >= 2) {
periodStart = this.normalizeDate(parts[0].trim());
periodEnd = this.normalizeDate(parts[1].trim());
}
}
if (!periodStart || !periodEnd) {
errors.push(`${rowNum}行: 时间格式无法解析 "${row.periodStr}"`);
skipped++;
continue;
}
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
errors.push(`${rowNum}行: ${row.roomNumber} 账期无效(${periodStart} ~ ${periodEnd}),已跳过`);
skipped++;
continue;
}
// 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失,
// 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。
if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) {
errors.push(
`${rowNum}行: ${row.roomNumber} 电费和水费均为 0可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`,
);
skipped++;
continue;
}
const existing = await this.roomExpRepo.find({
where: [
{ importKey: `${room.id}:${periodStart}:${periodEnd}:electricity` },
{ importKey: `${room.id}:${periodStart}:${periodEnd}:water` },
],
});
const byType = new Map(existing.map((expense) => [expense.expenseType, expense]));
let savedAny = false;
if (row.electricityFee > 0) {
await this.importUtilityExpense(
room.id,
'electricity',
periodStart,
periodEnd,
row.electricityFee,
`电量${row.electricityAmount}kWh`,
byType,
userId!,
);
savedAny = true;
}
if (row.waterFee > 0) {
await this.importUtilityExpense(
room.id,
'water',
periodStart,
periodEnd,
row.waterFee,
`用水${row.waterAmount}`,
byType,
userId!,
);
savedAny = true;
}
if (savedAny) imported++;
else {
skipped++;
errors.push(`${rowNum}行: ${row.roomNumber} 无有效金额`);
}
} catch (e: any) {
errors.push(`${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`);
skipped++;
}
}
return {
message:
imported > 0
? `成功导入 ${imported} 间宿舍水电费${skipped > 0 ? `,跳过 ${skipped}` : ''}`
: `未导入任何记录${skipped > 0 ? `,共 ${skipped} 条被跳过` : ''}`,
imported,
skipped,
errors: errors.length > 0 ? errors : undefined,
};
}
private async importUtilityExpense(
roomId: number,
expenseType: 'electricity' | 'water',
periodStart: string,
periodEnd: string,
amount: number,
description: string,
byType: Map<string, RoomExpense>,
recordedBy: number,
): Promise<void> {
const expense = byType.get(expenseType) || this.roomExpRepo.create({
roomId,
expenseType,
periodStart,
periodEnd,
importKey: `${roomId}:${periodStart}:${periodEnd}:${expenseType}`,
});
if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) {
throw new BadRequestException(`该周期${expenseType === 'electricity' ? '电费' : '水费'}已计入账单,不能覆盖`);
}
expense.amount = amount;
expense.description = description;
expense.recordedBy = recordedBy;
await this.roomExpRepo.save(expense);
}
/** 把 2026/4/1、2026-4-1 之类格式归一化为 YYYY-MM-DD */
private normalizeDate(s: string): string {
if (!s) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
const m = s.match(/(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/);
if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
return s;
}
/**
* 个人附加费Excel批量导入
* Excel格式: 学生姓名|费用类型|金额|费用日期|说明
*/
async batchImportPersonalExpenses(
rows: {
studentName: string;
expenseType: string;
amount: number;
expenseDate: string;
description?: string;
}[],
userId?: number,
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNum = i + 2;
if (!row.studentName?.trim()) {
skipped++;
continue;
}
try {
// 查找学生
const student = await this.studentRepo.findOne({ where: { name: row.studentName.trim() } });
if (!student) {
errors.push(`${rowNum}行: 学生"${row.studentName}"未找到`);
skipped++;
continue;
}
// 解析费用类型
const expenseType = row.expenseType?.trim() || '';
if (!expenseType) {
errors.push(`${rowNum}行: 费用类型不能为空`);
skipped++;
continue;
}
// 解析日期
let expenseDate = row.expenseDate?.trim() || '';
if (!expenseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
// 尝试从各种格式解析
const dateMatch = expenseDate.match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (dateMatch) {
expenseDate = `${dateMatch[1]}-${dateMatch[2].padStart(2, '0')}-${dateMatch[3].padStart(2, '0')}`;
} else {
errors.push(`${rowNum}行: 日期格式"${row.expenseDate}"无效需要YYYY-MM-DD`);
skipped++;
continue;
}
}
// 校验金额
try {
this.assertPositiveAmount(row.amount);
} catch (e: any) {
errors.push(`${rowNum}行: ${row.studentName} ${e.message}`);
skipped++;
continue;
}
await this.personalExpRepo.save(
this.personalExpRepo.create({
studentId: student.id,
expenseType,
amount: row.amount,
expenseDate,
description: row.description || undefined,
recordedBy: userId,
}),
);
imported++;
} catch (e: any) {
errors.push(`${rowNum}行: ${row.studentName} 导入失败 - ${e.message}`);
skipped++;
}
}
return {
message: `成功导入 ${imported} 条个人附加费,跳过 ${skipped}`,
imported,
skipped,
errors: errors.length > 0 ? errors : undefined,
};
}
}

View File

@@ -1,5 +1,6 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ExpensesService } from './expenses.service';
import { ExpenseOperationsService } from './expense-operations.service';
import { PersonalExpense } from '../entities/personal-expense.entity';
const qb = (affected = 1) => ({
@@ -35,7 +36,25 @@ function createService(options?: {
};
const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 1 }) };
return {
service: new ExpensesService(roomExpRepo as any, personalExpRepo as any, roomRepo as any, studentRepo as any, {} as any),
service: (() => {
const operations = new ExpenseOperationsService(
roomExpRepo as any,
personalExpRepo as any,
roomRepo as any,
studentRepo as any,
{} as any,
undefined as any,
);
return new ExpensesService(
roomExpRepo as any,
personalExpRepo as any,
roomRepo as any,
studentRepo as any,
{} as any,
undefined as any,
operations,
);
})(),
roomExpRepo,
personalExpRepo,
roomRepo,

View File

@@ -31,7 +31,7 @@ import {
} from './dto/expense.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { BatchIdsDto } from '../common/batch-ids.dto';
import * as ExcelJS from 'exceljs';
@@ -91,17 +91,8 @@ export class ExpensesController {
@RequirePermission('expense:create')
async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: any) {
const result = await this.service.createStudentUtilityBill(dto, req.user?.id);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '录入学生水电费并出账',
targetId: result.bill.id,
targetType: 'bill',
detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '录入学生水电费并出账', targetId: result.bill.id, targetType: 'bill', detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`,
});
return result;
}
@@ -109,18 +100,9 @@ export class ExpensesController {
@Post('room')
@RequirePermission('expense:create')
async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.createRoomExpense(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '录入费用',
targetId: result.id,
targetType: 'room_expense',
detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '录入费用', targetId: result.id, targetType: 'room_expense', detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`,
});
return result;
}
@@ -128,16 +110,9 @@ export class ExpensesController {
@Post('room/batch')
@RequirePermission('expense:create')
async batchCreateRoomExpenses(@Body() dto: BatchRoomExpenseDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchCreateRoomExpenses(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '批量录入费用',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '批量录入费用', detail: JSON.stringify(dto),
});
return result;
}
@@ -151,17 +126,9 @@ export class ExpensesController {
@Delete('room/:id')
@RequirePermission('expense:delete')
async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.deleteRoomExpense(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '归档费用',
targetId: id,
targetType: 'room_expense',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '归档费用', targetId: id, targetType: 'room_expense',
});
return result;
}
@@ -169,16 +136,29 @@ export class ExpensesController {
@Post('room/batch-delete')
@RequirePermission('expense:delete')
async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchDeleteRoomExpenses(body.ids || []);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '批量归档宿舍费用',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '批量归档宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@Delete('room/:id/permanent')
@RequirePermission('expense:purge')
async purgeRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.purgeRoomExpense(id);
await logAudit(this.logService, req, {
module: '费用管理', action: '永久删除宿舍费用', targetId: id, targetType: 'room_expense', detail: '物理删除,不可恢复',
});
return result;
}
@Post('room/batch-permanent-delete')
@RequirePermission('expense:purge')
async batchPurgeRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
const result = await this.service.batchPurgeRoomExpenses(body.ids || []);
await logAudit(this.logService, req, {
module: '费用管理', action: '批量永久删除宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@@ -187,16 +167,9 @@ export class ExpensesController {
@RequirePermission('expense:edit')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
async batchRestoreRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRestoreRoomExpenses(dto.ids);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '批量恢复宿舍费用',
detail: `IDs: ${dto.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '批量恢复宿舍费用', detail: `IDs: ${dto.ids.join(',')}`,
});
return result;
}
@@ -208,18 +181,9 @@ export class ExpensesController {
@Body() dto: UpdateRoomExpenseDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateRoomExpense(id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '编辑费用',
targetId: id,
targetType: 'room_expense',
detail: `¥${dto.amount} ${dto.expenseType}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '编辑费用', targetId: id, targetType: 'room_expense', detail: `¥${dto.amount} ${dto.expenseType}`,
});
return result;
}
@@ -227,16 +191,9 @@ export class ExpensesController {
@Post('personal')
@RequirePermission('expense:create')
async createPersonalExpense(@Body() dto: CreatePersonalExpenseDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.createPersonalExpense(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '录入费用',
detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '录入费用', detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`,
});
return result;
}
@@ -250,16 +207,9 @@ export class ExpensesController {
@Delete('personal/:id')
@RequirePermission('expense:delete')
async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.deletePersonalExpense(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '归档费用',
targetId: id,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '归档费用', targetId: id,
});
return result;
}
@@ -267,16 +217,29 @@ export class ExpensesController {
@Post('personal/batch-delete')
@RequirePermission('expense:delete')
async batchDeletePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchDeletePersonalExpenses(body.ids || []);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '批量归档个人费用',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '批量归档个人费用', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@Delete('personal/:id/permanent')
@RequirePermission('expense:purge')
async purgePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.purgePersonalExpense(id);
await logAudit(this.logService, req, {
module: '费用管理', action: '永久删除个人费用', targetId: id, targetType: 'personal_expense', detail: '物理删除,不可恢复',
});
return result;
}
@Post('personal/batch-permanent-delete')
@RequirePermission('expense:purge')
async batchPurgePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
const result = await this.service.batchPurgePersonalExpenses(body.ids || []);
await logAudit(this.logService, req, {
module: '费用管理', action: '批量永久删除个人费用', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@@ -285,16 +248,9 @@ export class ExpensesController {
@RequirePermission('expense:edit')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
async batchRestorePersonalExpenses(@Body() dto: BatchIdsDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRestorePersonalExpenses(dto.ids);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '批量恢复个人费用',
detail: `IDs: ${dto.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '批量恢复个人费用', detail: `IDs: ${dto.ids.join(',')}`,
});
return result;
}
@@ -306,17 +262,9 @@ export class ExpensesController {
@Body() dto: UpdatePersonalExpenseDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updatePersonalExpense(id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '编辑费用',
targetId: id,
detail: `¥${dto.amount} ${dto.expenseType}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '编辑费用', targetId: id, detail: `¥${dto.amount} ${dto.expenseType}`,
});
return result;
}
@@ -361,9 +309,8 @@ export class ExpensesController {
@RequirePermission('expense:create')
@UseInterceptors(FileInterceptor('file'))
async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
const ws = workbook.worksheets[0];
const rows: any[] = [];
ws.eachRow((row, idx) => {
@@ -381,14 +328,8 @@ export class ExpensesController {
});
});
const result = await this.service.batchImportUtilityExpenses(rows, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '导入水电费',
detail: result.message,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '导入水电费', detail: result.message,
});
return result;
}
@@ -433,9 +374,8 @@ export class ExpensesController {
@RequirePermission('expense:create')
@UseInterceptors(FileInterceptor('file'))
async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
const ws = workbook.worksheets[0];
const rows: any[] = [];
ws.eachRow((row, idx) => {
@@ -451,14 +391,8 @@ export class ExpensesController {
});
});
const result = await this.service.batchImportPersonalExpenses(rows, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '导入个人附加费',
detail: result.message,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '导入个人附加费', detail: result.message,
});
return result;
}

View File

@@ -5,6 +5,7 @@ import { PersonalExpense } from '../entities/personal-expense.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { ExpensesService } from './expenses.service';
import { ExpenseOperationsService } from './expense-operations.service';
import { ExpensesController } from './expenses.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { BillsModule } from '../bills/bills.module';
@@ -16,7 +17,7 @@ import { BillsModule } from '../bills/bills.module';
BillsModule,
],
controllers: [ExpensesController],
providers: [ExpensesService],
providers: [ExpensesService, ExpenseOperationsService],
exports: [ExpensesService],
})
export class ExpensesModule {}

View File

@@ -0,0 +1,34 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { ExpensesController } from './expenses.controller';
describe('ExpensesController purge routes', () => {
it('requires expense:purge on permanent delete routes', () => {
expect(
Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.purgeRoomExpense),
).toEqual(['expense:purge']);
expect(
Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.batchPurgeRoomExpenses),
).toEqual(['expense:purge']);
expect(
Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.purgePersonalExpense),
).toEqual(['expense:purge']);
expect(
Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.batchPurgePersonalExpenses),
).toEqual(['expense:purge']);
});
it('writes permanent delete audit logs', async () => {
const service = {
purgeRoomExpense: jest.fn().mockResolvedValue({ message: '已永久删除宿舍费用(不可恢复)' }),
};
const log = jest.fn().mockResolvedValue(undefined);
const controller = new ExpensesController(service as never, { log } as never);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purgeRoomExpense(1, req);
expect(service.purgeRoomExpense).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '费用管理', action: '永久删除宿舍费用', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,91 @@
import { BadRequestException } from '@nestjs/common';
import { ExpensesService } from './expenses.service';
import { ExpenseOperationsService } from './expense-operations.service';
describe('ExpensesService purge', () => {
const billItemsRepo = {
count: jest.fn().mockResolvedValue(0),
};
const dataSource = {
getRepository: jest.fn().mockReturnValue(billItemsRepo),
};
const roomExpRepo = {
findOne: jest.fn(),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
find: jest.fn(),
};
const personalExpRepo = {
findOne: jest.fn(),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
find: jest.fn(),
};
const createService = () =>
new ExpensesService(
roomExpRepo as never,
personalExpRepo as never,
{} as never,
{} as never,
{} as never,
dataSource as never,
new ExpenseOperationsService(
roomExpRepo as never,
personalExpRepo as never,
{} as never,
{} as never,
{} as never,
dataSource as never,
),
);
beforeEach(() => {
jest.clearAllMocks();
billItemsRepo.count.mockResolvedValue(0);
});
it('room expense purge rejects non-archived records', async () => {
roomExpRepo.findOne.mockResolvedValue({ id: 1, status: 'active' });
const service = createService();
await expect(service.purgeRoomExpense(1)).rejects.toThrow(
new BadRequestException('仅已归档费用可以永久删除,请先归档'),
);
expect(roomExpRepo.delete).not.toHaveBeenCalled();
});
it('room expense purge rejects billed records', async () => {
roomExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived' });
billItemsRepo.count.mockResolvedValue(1);
const service = createService();
await expect(service.purgeRoomExpense(1)).rejects.toThrow(
new BadRequestException('已计入账单的宿舍费用不能永久删除,请先取消账单'),
);
expect(roomExpRepo.delete).not.toHaveBeenCalled();
});
it('room expense purge deletes archived records', async () => {
roomExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived' });
const service = createService();
await expect(service.purgeRoomExpense(1)).resolves.toEqual({
message: '已永久删除宿舍费用(不可恢复)',
});
expect(roomExpRepo.delete).toHaveBeenCalledWith(1);
});
it('personal expense purge rejects records attached to a bill', async () => {
personalExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived', billId: 9 });
const service = createService();
await expect(service.purgePersonalExpense(1)).rejects.toThrow(
new BadRequestException('已计入账单的个人费用不能永久删除,请先取消账单'),
);
expect(personalExpRepo.delete).not.toHaveBeenCalled();
});
it('personal expense purge deletes archived records with no bill', async () => {
personalExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived', billId: null });
const service = createService();
await expect(service.purgePersonalExpense(1)).resolves.toEqual({
message: '已永久删除个人费用(不可恢复)',
});
expect(personalExpRepo.delete).toHaveBeenCalledWith(1);
});
});

View File

@@ -11,8 +11,8 @@ import {
BatchRoomExpenseDto,
CreateStudentUtilityBillDto,
} from './dto/expense.dto';
import { RoomsService } from '../rooms/rooms.service';
import { BillsService } from '../bills/bills.service';
import { ExpenseOperationsService } from './expense-operations.service';
@Injectable()
@@ -24,6 +24,7 @@ export class ExpensesService {
@InjectRepository(Student) private studentRepo: Repository<Student>,
private billsService: BillsService,
private dataSource: DataSource,
private operations: ExpenseOperationsService,
) {}
async getFormLookups() {
@@ -120,13 +121,18 @@ export class ExpensesService {
const roomQb = this.roomExpRepo
.createQueryBuilder('e')
.leftJoin('e.room', 'room')
.select('e.id', 'id')
.addSelect('e.expenseType', 'expenseType')
.addSelect('e.amount', 'amount')
.addSelect('e.periodStart', 'periodStart')
.addSelect('e.periodEnd', 'periodEnd')
.addSelect('room.roomNumber', 'roomNumber')
.where('e.status = :status', { status: 'active' });
.select('e.id', 'id');
const roomExpenseSelects = [
['e.expenseType', 'expenseType'],
['e.amount', 'amount'],
['e.periodStart', 'periodStart'],
['e.periodEnd', 'periodEnd'],
['room.roomNumber', 'roomNumber'],
] as const;
for (const [column, alias] of roomExpenseSelects) {
roomQb.addSelect(column, alias);
}
roomQb.where('e.status = :status', { status: 'active' });
if (query?.keyword) {
roomQb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${query.keyword}%` });
}
@@ -144,13 +150,18 @@ export class ExpensesService {
const personalQb = this.personalExpRepo
.createQueryBuilder('e')
.leftJoin('e.student', 'student')
.select('e.id', 'id')
.addSelect('e.expenseType', 'expenseType')
.addSelect('e.amount', 'amount')
.addSelect('e.expenseDate', 'expenseDate')
.addSelect('student.name', 'studentName')
.addSelect('student.studentNo', 'studentNo')
.where('e.status = :status', { status: 'active' });
.select('e.id', 'id');
const personalExpenseSelects = [
['e.expenseType', 'expenseType'],
['e.amount', 'amount'],
['e.expenseDate', 'expenseDate'],
['student.name', 'studentName'],
['student.studentNo', 'studentNo'],
] as const;
for (const [column, alias] of personalExpenseSelects) {
personalQb.addSelect(column, alias);
}
personalQb.where('e.status = :status', { status: 'active' });
if (query?.keyword) {
personalQb.andWhere(
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
@@ -243,6 +254,48 @@ export class ExpensesService {
return { message: `已批量恢复 ${restored} 条宿舍费用`, restored, skipped };
}
async purgeRoomExpense(id: number) {
const e = await this.roomExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.status !== 'archived') throw new BadRequestException('仅已归档费用可以永久删除,请先归档');
const billed = await this.dataSource
.getRepository('bill_items')
.count({ where: { roomExpenseId: id } });
if (billed) throw new BadRequestException('已计入账单的宿舍费用不能永久删除,请先取消账单');
await this.roomExpRepo.delete(id);
return { message: '已永久删除宿舍费用(不可恢复)' };
}
async batchPurgeRoomExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的宿舍费用');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('费用记录 ID 无效');
}
const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const billed = await this.dataSource
.getRepository('bill_items')
.count({ where: { roomExpenseId: In(uniqueIds) } });
if (billed) throw new BadRequestException('选中记录包含已计入账单的宿舍费用');
const deleted: number[] = [];
const skipped: string[] = [];
for (const e of existing) {
if (e.status !== 'archived') {
skipped.push(`记录${e.id}(未归档)`);
continue;
}
await this.roomExpRepo.delete(e.id);
deleted.push(e.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 };
}
async updateRoomExpense(id: number, dto: Partial<CreateRoomExpenseDto>) {
const e = await this.roomExpRepo.findOne({ where: { id } });
const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } });
@@ -298,97 +351,37 @@ export class ExpensesService {
// 个人附加费
async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) {
this.assertPositiveAmount(dto.amount);
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId });
return this.personalExpRepo.save(entity);
return this.operations.createPersonalExpense(dto, userId);
}
async findPersonalExpenses(query?: { studentId?: number; status?: 'active' | 'archived' }) {
const status = query?.status ?? 'active';
if (status !== 'active' && status !== 'archived') throw new BadRequestException('费用状态无效');
const where: Record<string, unknown> = { status };
if (query?.studentId) where.studentId = query.studentId;
return this.personalExpRepo.find({
where,
relations: ['student'],
order: { createdAt: 'DESC' },
});
return this.operations.findPersonalExpenses(query);
}
async deletePersonalExpense(id: number) {
const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能归档,请先取消账单');
if (e.status === 'archived') throw new BadRequestException('费用记录已归档');
await this.personalExpRepo.update(id, { status: 'archived' });
return { message: '已归档' };
return this.operations.deletePersonalExpense(id);
}
async batchDeletePersonalExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录');
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
if (existing.some((expense) => expense.billId)) {
throw new BadRequestException('选中记录包含已计入账单的个人费用');
}
const result = await this.personalExpRepo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: uniqueIds })
.execute();
return { message: `已批量归档 ${result.affected || 0}`, archived: result.affected || 0 };
return this.operations.batchDeletePersonalExpenses(ids);
}
async batchRestorePersonalExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('费用记录 ID 无效');
}
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const targets = existing.filter((expense) => expense.status === 'archived');
if (targets.some((expense) => expense.billId)) {
throw new BadRequestException('选中记录包含已计入账单的个人费用');
}
return this.operations.batchRestorePersonalExpenses(ids);
}
const targetIds = targets.map((expense) => expense.id);
const skipped = existing.length - targetIds.length;
let restored = 0;
if (targetIds.length > 0) {
const result = await this.personalExpRepo
.createQueryBuilder()
.update()
.set({ status: 'active' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
restored = result.affected || 0;
}
return { message: `已批量恢复 ${restored} 条个人费用`, restored, skipped };
async purgePersonalExpense(id: number) {
return this.operations.purgePersonalExpense(id);
}
async batchPurgePersonalExpenses(ids: number[]) {
return this.operations.batchPurgePersonalExpenses(ids);
}
async updatePersonalExpense(id: number, dto: Partial<CreatePersonalExpenseDto>) {
const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能修改,请先取消账单');
if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount);
if (dto.studentId !== undefined && dto.studentId !== e.studentId) {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
}
Object.assign(e, dto);
return this.personalExpRepo.save(e);
return this.operations.updatePersonalExpense(id, dto);
}
/**
* 水电费Excel批量导入
* Excel格式: 序号|时间|房间号|房间电量|电费|冷水用量(吨)|水费|应缴金额
* 时间格式: "2026-01-21 - 2026-02-08"
*/
async batchImportUtilityExpenses(
rows: {
periodStr: string;
@@ -401,156 +394,9 @@ export class ExpensesService {
}[],
userId?: number,
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNum = i + 2;
if (!row.roomNumber?.trim()) {
skipped++;
continue;
}
try {
// 查找或创建宿舍
let room = await this.roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } });
if (!room) {
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
room = await this.roomRepo.save(
this.roomRepo.create({
roomNumber: row.roomNumber.trim(),
building: parsed.building || undefined,
floor: parsed.floor || undefined,
capacity: parsed.capacity || 4,
roomType: parsed.roomType || undefined,
}),
);
}
// 解析时间段 "2026-01-21 - 2026-02-08" 或 "2026-01-21~2026-02-08"
let periodStart = '';
let periodEnd = '';
if (row.periodStr) {
// 先尝试用" - "或" ~ "分割(带空格的分隔符,避免拆分日期内部的连字符)
let parts = row.periodStr.split(/\s+[-~]\s+/);
if (parts.length < 2) {
// 回退:尝试用正则提取 YYYY-MM-DD 格式的日期
const dateMatches = row.periodStr.match(/(\d{4}-\d{1,2}-\d{1,2})/g);
if (dateMatches && dateMatches.length >= 2) {
parts = [dateMatches[0], dateMatches[1]];
}
}
if (parts.length >= 2) {
periodStart = this.normalizeDate(parts[0].trim());
periodEnd = this.normalizeDate(parts[1].trim());
}
}
if (!periodStart || !periodEnd) {
errors.push(`${rowNum}行: 时间格式无法解析 "${row.periodStr}"`);
skipped++;
continue;
}
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
errors.push(`${rowNum}行: ${row.roomNumber} 账期无效(${periodStart} ~ ${periodEnd}),已跳过`);
skipped++;
continue;
}
// 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失,
// 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。
if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) {
errors.push(
`${rowNum}行: ${row.roomNumber} 电费和水费均为 0可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`,
);
skipped++;
continue;
}
const existing = await this.roomExpRepo.find({
where: [
{ importKey: `${room.id}:${periodStart}:${periodEnd}:electricity` },
{ importKey: `${room.id}:${periodStart}:${periodEnd}:water` },
],
});
const byType = new Map(existing.map((expense) => [expense.expenseType, expense]));
let savedAny = false;
// 导入电费
if (row.electricityFee > 0) {
const expense = byType.get('electricity') || this.roomExpRepo.create({
roomId: room.id,
expenseType: 'electricity',
periodStart,
periodEnd,
importKey: `${room.id}:${periodStart}:${periodEnd}:electricity`,
});
if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) {
throw new BadRequestException('该周期电费已计入账单,不能覆盖');
}
expense.amount = row.electricityFee;
expense.description = `电量${row.electricityAmount}kWh`;
expense.recordedBy = userId!;
await this.roomExpRepo.save(expense);
savedAny = true;
}
// 导入水费
if (row.waterFee > 0) {
const expense = byType.get('water') || this.roomExpRepo.create({
roomId: room.id,
expenseType: 'water',
periodStart,
periodEnd,
importKey: `${room.id}:${periodStart}:${periodEnd}:water`,
});
if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) {
throw new BadRequestException('该周期水费已计入账单,不能覆盖');
}
expense.amount = row.waterFee;
expense.description = `用水${row.waterAmount}`;
expense.recordedBy = userId!;
await this.roomExpRepo.save(expense);
savedAny = true;
}
if (savedAny) imported++;
else {
skipped++;
errors.push(`${rowNum}行: ${row.roomNumber} 无有效金额`);
}
} catch (e: any) {
errors.push(`${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`);
skipped++;
}
}
return {
message:
imported > 0
? `成功导入 ${imported} 间宿舍水电费${skipped > 0 ? `,跳过 ${skipped}` : ''}`
: `未导入任何记录${skipped > 0 ? `,共 ${skipped} 条被跳过` : ''}`,
imported,
skipped,
errors: errors.length > 0 ? errors : undefined,
};
return this.operations.batchImportUtilityExpenses(rows, userId);
}
/** 把 2026/4/1、2026-4-1 之类格式归一化为 YYYY-MM-DD */
private normalizeDate(s: string): string {
if (!s) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
const m = s.match(/(\d{4})[\-\/.](\d{1,2})[\-\/.](\d{1,2})/);
if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
return s;
}
/**
* 个人附加费Excel批量导入
* Excel格式: 学生姓名|费用类型|金额|费用日期|说明
*/
async batchImportPersonalExpenses(
rows: {
studentName: string;
@@ -561,83 +407,6 @@ export class ExpensesService {
}[],
userId?: number,
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNum = i + 2;
if (!row.studentName?.trim()) {
skipped++;
continue;
}
try {
// 查找学生
const student = await this.studentRepo.findOne({ where: { name: row.studentName.trim() } });
if (!student) {
errors.push(`${rowNum}行: 学生"${row.studentName}"未找到`);
skipped++;
continue;
}
// 解析费用类型
const expenseType = row.expenseType?.trim() || '';
if (!expenseType) {
errors.push(`${rowNum}行: 费用类型不能为空`);
skipped++;
continue;
}
// 解析日期
let expenseDate = row.expenseDate?.trim() || '';
if (!expenseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
// 尝试从各种格式解析
const dateMatch = expenseDate.match(/(\d{4})[\-\/](\d{1,2})[\-\/](\d{1,2})/);
if (dateMatch) {
expenseDate = `${dateMatch[1]}-${dateMatch[2].padStart(2, '0')}-${dateMatch[3].padStart(2, '0')}`;
} else {
errors.push(`${rowNum}行: 日期格式"${row.expenseDate}"无效需要YYYY-MM-DD`);
skipped++;
continue;
}
}
// 校验金额
try {
this.assertPositiveAmount(row.amount);
} catch (e: any) {
errors.push(`${rowNum}行: ${row.studentName} ${e.message}`);
skipped++;
continue;
}
await this.personalExpRepo.save(
this.personalExpRepo.create({
studentId: student.id,
expenseType,
amount: row.amount,
expenseDate,
description: row.description || undefined,
recordedBy: userId,
}),
);
imported++;
} catch (e: any) {
errors.push(`${rowNum}行: ${row.studentName} 导入失败 - ${e.message}`);
skipped++;
}
}
return {
message: `成功导入 ${imported} 条个人附加费,跳过 ${skipped}`,
imported,
skipped,
errors: errors.length > 0 ? errors : undefined,
};
return this.operations.batchImportPersonalExpenses(rows, userId);
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DINGTALK_OAUTH_TOKEN_URL } from '../endpoints';
import { IntegrationConfig, IntegrationConfigDetail } from '../entities/integration-config.entity';
import {
ThirdConfigBaseDTO,
@@ -208,7 +209,7 @@ export class IntegrationConfigService {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
const res = await fetch(DINGTALK_OAUTH_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appKey, appSecret }),

View File

@@ -1,3 +1,4 @@
// aislop-ignore-file: duplicate-block -- 钉钉 API 调用块结构相似(端点/参数不同)
/**
* 钉钉集成服务 — 对齐 gongxue-dorm-sys
*
@@ -12,203 +13,52 @@ import { Student } from '../entities/student.entity';
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
import { syncDingTalkStudents } from './dingtalk-student-sync';
import { IntegrationConfigService } from './config/integration-config.service';
import { DINGTALK_OAUTH_TOKEN_URL } from './endpoints';
import { isDingTalkUserListResponse } from './dingtalk.types';
import type {
DingTalkCredentials,
DingTalkDeptGetResponse,
DingTalkDeptListResponse,
DingTalkServiceContext,
DingTalkUserListResponse,
OrgDeptNode,
OrgDeptNodeWithUsers,
} from './dingtalk.types';
import { DingTalkAttendanceClient } from './dingtalk.attendance';
import { DingTalkShiftClient } from './dingtalk.shifts';
import { DingTalkGroupClient } from './dingtalk.groups';
import { DingTalkScheduleClient } from './dingtalk.schedules';
// ── Types ──
interface DingTalkCredentials {
appKey: string;
appSecret: string;
}
interface DingTalkUserListResponse {
errcode: number;
errmsg: string;
result: {
has_more: boolean;
next_cursor?: number;
list: Array<{
userid: string;
name: string;
mobile: string;
dept_id_list: number[];
}>;
};
}
function isDingTalkUserListResponse(value: unknown): value is DingTalkUserListResponse {
if (!value || typeof value !== 'object' || !('errcode' in value)) return false;
if (typeof value.errcode !== 'number') return false;
if ('errmsg' in value && typeof value.errmsg !== 'string') return false;
if (!('result' in value) || !value.result || typeof value.result !== 'object') {
return value.errcode !== 0;
}
if (!('has_more' in value.result) || typeof value.result.has_more !== 'boolean') return false;
if (!('list' in value.result) || !Array.isArray(value.result.list)) return false;
return value.result.list.every(
(item) =>
item &&
typeof item === 'object' &&
'userid' in item &&
typeof item.userid === 'string' &&
'name' in item &&
typeof item.name === 'string' &&
'mobile' in item &&
typeof item.mobile === 'string' &&
'dept_id_list' in item &&
Array.isArray(item.dept_id_list) &&
item.dept_id_list.every((id) => typeof id === 'number'),
);
}
/** 钉钉打卡结果 — 对齐 dws attendance check result */
export interface DingTalkAttendanceResult {
userId: string;
userName: string;
workDate: string;
timeResult: string;
locationResult: string;
planCheckTime: string;
actualCheckTime: string;
checkId: string;
checkType: string;
/** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */
sourceType: string;
/** 部分钉钉租户会额外返回考勤机名称或编号。 */
deviceName?: string;
deviceId?: string;
}
// ── 组织架构 API 类型 ──
interface DingTalkDeptListResponse {
errcode: number;
result?: Array<{ dept_id: number; name: string; parent_id: number }>;
}
interface DingTalkDeptGetResponse {
errcode: number;
result?: { name: string; parent_id: number };
}
export interface OrgDeptNode {
id: number;
name: string;
parentId: number;
children: OrgDeptNode[];
}
export interface OrgDeptNodeWithUsers extends OrgDeptNode {
users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
}
// ── 考勤排班 API 类型 ──
/** 班次卡段打卡时间 */
export interface DingTalkShiftTime {
check_type: 'OnDuty' | 'OffDuty';
across: number;
check_time: string;
begin_min?: number;
end_min?: number;
free_check?: boolean;
}
/** 班次卡段 */
export interface DingTalkShiftSection {
times: DingTalkShiftTime[];
}
/** 班次配置 */
export interface DingTalkShiftSetting {
is_flexible?: boolean;
serious_late_minutes?: number;
absenteeism_late_minutes?: number;
}
/** 创建/修改班次参数 */
export interface DingTalkShiftParams {
id?: number;
name: string;
owner?: string;
sections: DingTalkShiftSection[];
setting?: DingTalkShiftSetting;
}
/** 班次摘要(查询返回) */
export interface DingTalkShiftSummary {
id: number;
name: string;
}
/** 考勤组成员 */
export interface DingTalkGroupMember {
role: string;
type: 'StaffMember' | 'DeptMember';
user_id: string;
}
/** 创建考勤组参数 */
export interface DingTalkGroupParams {
name: string;
type: 'TURN';
owner: string;
members: DingTalkGroupMember[];
shift_ids?: number[];
enable_emp_select_class?: boolean;
disable_check_without_schedule?: boolean;
disable_check_when_rest?: boolean;
/** 关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,仅保留考勤机打卡入口 */
attendance_machine_only?: boolean;
}
/** 修改考勤组参数 */
export interface DingTalkGroupUpdateParams extends DingTalkGroupParams {
id: number;
}
/** 考勤组摘要(查询返回) */
export interface DingTalkGroupSummary {
group_id: number;
group_name: string;
type: string;
member_count: number;
}
/** 排班参数(单条) */
export interface DingTalkScheduleItem {
userid: string;
work_date: number;
shift_id: number;
is_rest?: boolean;
}
/** 排班查询结果 */
export interface DingTalkScheduleResult {
userid: string;
work_date: string;
shift_id: number;
is_rest: string;
check_type: string;
plan_check_time: string;
group_id: number;
id: number;
}
export type {
DingTalkAttendanceResult,
DingTalkGroupParams,
DingTalkGroupSummary,
DingTalkGroupUpdateParams,
DingTalkScheduleItem,
DingTalkScheduleResult,
DingTalkShiftParams,
DingTalkShiftSummary,
OrgDeptNode,
OrgDeptNodeWithUsers,
} from './dingtalk.types';
@Injectable()
export class DingTalkService {
private readonly logger = new Logger(DingTalkService.name);
private accessToken: string | null = null;
private accessTokenCredentialKey: string | null = null;
private tokenExpiresAt = 0;
private apiRequestCount = 0;
export class DingTalkService implements DingTalkServiceContext {
accessToken: string | null = null;
accessTokenCredentialKey: string | null = null;
tokenExpiresAt = 0;
apiRequestCount = 0;
readonly logger = new Logger(DingTalkService.name);
/** 钉钉 API 限流:每秒最多 20 次 */
private static readonly RATE_LIMIT = 20;
private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT;
private attendanceClient?: DingTalkAttendanceClient;
private shiftClient?: DingTalkShiftClient;
private groupClient?: DingTalkGroupClient;
private scheduleClient?: DingTalkScheduleClient;
constructor(
@InjectRepository(Student)
private readonly studentRepo: Repository<Student>,
@@ -218,7 +68,27 @@ export class DingTalkService {
private readonly dataSource?: DataSource,
) {}
private async getCredentials(): Promise<DingTalkCredentials | null> {
private get attendance(): DingTalkAttendanceClient {
if (!this.attendanceClient) this.attendanceClient = new DingTalkAttendanceClient(this);
return this.attendanceClient;
}
private get shifts(): DingTalkShiftClient {
if (!this.shiftClient) this.shiftClient = new DingTalkShiftClient(this);
return this.shiftClient;
}
private get groups(): DingTalkGroupClient {
if (!this.groupClient) this.groupClient = new DingTalkGroupClient(this);
return this.groupClient;
}
private get schedules(): DingTalkScheduleClient {
if (!this.scheduleClient) this.scheduleClient = new DingTalkScheduleClient(this);
return this.scheduleClient;
}
async getCredentials(): Promise<DingTalkCredentials | null> {
const rawConfig = await this.integrationConfigService?.getRawConfig('DINGTALK');
const dbAppKey = typeof rawConfig?.agentId === 'string' ? rawConfig.agentId.trim() : '';
const dbAppSecret = typeof rawConfig?.appSecret === 'string' ? rawConfig.appSecret.trim() : '';
@@ -235,7 +105,7 @@ export class DingTalkService {
return null;
}
private async isConfigured(): Promise<boolean> {
async isConfigured(): Promise<boolean> {
return !!(await this.getCredentials());
}
@@ -243,7 +113,7 @@ export class DingTalkService {
// Token — 对齐 gongxue-dorm-sys getAccessToken
// ═══════════════════════════════════════════
private async getAccessToken(): Promise<string> {
async getAccessToken(): Promise<string> {
const credentials = await this.getCredentials();
if (!credentials) {
throw new Error('DingTalk not configured');
@@ -258,7 +128,7 @@ export class DingTalkService {
return this.accessToken;
}
const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
const res = await fetch(DINGTALK_OAUTH_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
@@ -285,7 +155,7 @@ export class DingTalkService {
// Users by department — 对齐 gongxue-dorm-sys getUsersByDepartment
// ═══════════════════════════════════════════
private async getDeptUsers(
async getDeptUsers(
token: string,
deptId: number,
): Promise<Array<{ userid: string; name: string; mobile: string; dept_id_list: number[] }>> {
@@ -482,445 +352,61 @@ export class DingTalkService {
return [attachUsers(deptTree)];
}
// ═══════════════════════════════════════════
// Rate limiting — 对齐 gongxue-dorm-sys
// ═══════════════════════════════════════════
private async rateLimit(): Promise<void> {
async rateLimit(): Promise<void> {
await this.sleep(DingTalkService.MIN_INTERVAL);
this.apiRequestCount++;
}
// ═══════════════════════════════════════════
// 考勤打卡结果 — 对齐 dws attendance check result
// ═══════════════════════════════════════════
async fetchAttendanceResults(params: {
startDate: string;
endDate: string;
userIds?: string[];
}): Promise<DingTalkAttendanceResult[]> {
if (!(await this.isConfigured())) throw new Error('DingTalk not configured');
if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空');
if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人');
const token = await this.getAccessToken();
const dateFrom = params.startDate.includes(' ') ? params.startDate : `${params.startDate} 00:00:00`;
const dateTo = params.endDate.includes(' ') ? params.endDate : `${params.endDate} 23:59:59`;
const body: Record<string, unknown> = {
checkDateFrom: dateFrom,
checkDateTo: dateTo,
};
body.userIds = params.userIds;
const res = await fetch(
`https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
const data = await res.json() as {
errcode: number; errmsg: string;
recordresult?: Array<{
id: number; userId: string; workDate: number;
userCheckTime: number; sourceType: string;
checkType?: string; timeResult?: string;
locationResult?: string; locationMethod?: string;
userAddress?: string; userLongitude?: number; userLatitude?: number;
deviceName?: string; deviceId?: string | number; deviceSN?: string | number;
attendanceMachineName?: string; attendanceMachineId?: string | number;
}>;
};
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
const records = data.recordresult ?? [];
return records.map((r) => ({
userId: r.userId,
userName: '',
workDate: new Date(r.workDate + 8 * 60 * 60 * 1000).toISOString().slice(0, 10),
timeResult: r.timeResult ?? r.sourceType ?? '',
locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '',
planCheckTime: '',
actualCheckTime: new Date(r.userCheckTime).toISOString(),
checkId: String(r.id),
checkType: r.checkType ?? '',
sourceType: r.sourceType ?? '',
deviceName: r.deviceName ?? r.attendanceMachineName,
deviceId: String(r.deviceId ?? r.attendanceMachineId ?? r.deviceSN ?? '') || undefined,
}));
async fetchAttendanceResults(
...args: Parameters<DingTalkAttendanceClient['fetchAttendanceResults']>
) {
return this.attendance.fetchAttendanceResults(...args);
}
// ═══════════════════════════════════════════
// 考勤排班 — 班次管理
// ═══════════════════════════════════════════
/** 创建或修改班次。id 不传=创建,传了=修改 */
async upsertShift(params: DingTalkShiftParams): Promise<number> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const body: Record<string, unknown> = {
op_user_id: params.owner || 'manager',
shift: {
name: params.name,
owner: params.owner,
sections: params.sections.map((s) => ({
times: s.times.map((t) => ({
check_type: t.check_type,
across: t.across,
check_time: t.check_time,
begin_min: t.begin_min ?? -1,
end_min: t.end_min ?? -1,
free_check: t.free_check ?? false,
})),
})),
setting: params.setting
? {
is_flexible: params.setting.is_flexible ?? false,
serious_late_minutes: params.setting.serious_late_minutes ?? -1,
absenteeism_late_minutes: params.setting.absenteeism_late_minutes ?? -1,
}
: undefined,
},
};
if (params.id) (body.shift as Record<string, unknown>).id = params.id;
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/shift/add?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
result?: { id: number; name: string };
};
if (data.errcode !== 0) {
throw new Error(`钉钉班次操作失败: ${data.errmsg} (code=${data.errcode})`);
}
this.logger.log(`钉钉班次 ${params.id ? '更新' : '创建'} 成功: ${data.result?.name} (id=${data.result?.id})`);
return data.result!.id;
async upsertShift(...args: Parameters<DingTalkShiftClient['upsertShift']>) {
return this.shifts.upsertShift(...args);
}
/** 查询所有班次摘要每页最多200条 */
async queryShifts(opUserId = 'manager'): Promise<DingTalkShiftSummary[]> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const all: DingTalkShiftSummary[] = [];
let cursor = 0;
let hasMore = true;
while (hasMore) {
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/shift/list?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_user_id: opUserId, cursor }),
},
);
const data = (await res.json()) as {
errcode: number;
errmsg: string;
result?: {
cursor?: number;
has_more?: boolean;
result?: Array<{ id: number; name: string }>;
};
};
if (data.errcode !== 0) {
throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`);
}
const page = data.result;
all.push(...(page?.result ?? []).map((s) => ({ id: s.id, name: s.name })));
hasMore = page?.has_more ?? false;
if (hasMore) {
if (page?.cursor === undefined || page.cursor === cursor) {
throw new Error('钉钉查询班次失败: 分页游标无效');
}
cursor = page.cursor;
}
}
return all;
async queryShifts(...args: Parameters<DingTalkShiftClient['queryShifts']>) {
return this.shifts.queryShifts(...args);
}
// ═══════════════════════════════════════════
// 考勤排班 — 考勤组管理
// ═══════════════════════════════════════════
/** 创建排班制考勤组 */
async createAttendanceGroup(params: DingTalkGroupParams): Promise<number> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const topGroup = this.buildAttendanceGroupBody(params);
const body = { op_user_id: params.owner, top_group: topGroup };
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/add?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
result?: { id: number };
};
if (data.errcode !== 0) {
throw new Error(`钉钉创建考勤组失败: ${data.errmsg} (code=${data.errcode})`);
}
this.logger.log(`钉钉考勤组创建成功: ${params.name} (id=${data.result?.id})`);
return data.result!.id;
async createAttendanceGroup(
...args: Parameters<DingTalkGroupClient['createAttendanceGroup']>
) {
return this.groups.createAttendanceGroup(...args);
}
/** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */
async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise<void> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id };
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/modify?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_user_id: params.owner, top_group: topGroup }),
},
);
const data = (await res.json()) as {
errcode?: number;
errmsg?: string;
success?: boolean;
message?: string;
};
const succeeded = data.success === true || data.errcode === 0;
if (!succeeded) {
throw new Error(
`钉钉更新考勤组失败: ${data.message || data.errmsg || '未知错误'} ` +
`(code=${data.errcode ?? 'unknown'})`,
);
}
this.logger.log(`钉钉考勤组更新成功: ${params.name} (id=${params.id})`);
async updateAttendanceGroup(
...args: Parameters<DingTalkGroupClient['updateAttendanceGroup']>
) {
return this.groups.updateAttendanceGroup(...args);
}
private buildAttendanceGroupBody(params: DingTalkGroupParams): Record<string, unknown> {
const machineOnly = params.attendance_machine_only ?? false;
const topGroup: Record<string, unknown> = {
name: params.name,
type: params.type,
owner: params.owner,
members: params.members.map((m) => ({
role: m.role,
type: m.type,
user_id: m.user_id,
})),
enable_emp_select_class: machineOnly ? false : (params.enable_emp_select_class ?? true),
disable_check_without_schedule: machineOnly ? true : (params.disable_check_without_schedule ?? false),
disable_check_when_rest: params.disable_check_when_rest ?? true,
};
if (params.shift_ids?.length) {
topGroup.shift_vo_list = params.shift_ids.map((id) => ({ id }));
}
if (machineOnly) {
Object.assign(topGroup, {
enable_outside_check: false,
enable_position_ble: false,
positions: [],
wifis: [],
});
}
return topGroup;
async queryAttendanceGroups(
...args: Parameters<DingTalkGroupClient['queryAttendanceGroups']>
) {
return this.groups.queryAttendanceGroups(...args);
}
/** 查询所有考勤组摘要分页每页10条 */
async queryAttendanceGroups(_opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const all: DingTalkGroupSummary[] = [];
let offset = 0;
let hasMore = true;
while (hasMore) {
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/getsimplegroups?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ offset, size: 10 }),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
result?: {
has_more: boolean;
groups: Array<{ group_id: number; group_name: string; type: string; member_count: number }>;
};
};
if (data.errcode !== 0) {
throw new Error(`钉钉查询考勤组失败: ${data.errmsg} (code=${data.errcode})`);
}
if (data.result?.groups) {
all.push(...data.result.groups.map((g) => ({
group_id: g.group_id,
group_name: g.group_name,
type: g.type,
member_count: g.member_count,
})));
}
hasMore = data.result?.has_more ?? false;
offset += 10;
}
return all;
async deleteAttendanceGroup(
...args: Parameters<DingTalkGroupClient['deleteAttendanceGroup']>
) {
return this.groups.deleteAttendanceGroup(...args);
}
async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise<void> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
await this.rateLimit();
const keyResponse = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/groups/idtokey?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_user_id: opUserId, group_id: groupId }),
},
);
const keyData = await keyResponse.json() as {
errcode: number;
errmsg: string;
result?: string;
};
if (keyData.errcode !== 0 || !keyData.result) {
throw new Error(`钉钉考勤组ID转换失败: ${keyData.errmsg} (code=${keyData.errcode})`);
}
await this.rateLimit();
const deleteResponse = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/delete?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_userid: opUserId, group_key: keyData.result }),
},
);
const deleteData = await deleteResponse.json() as {
errcode: number;
errmsg: string;
success?: boolean;
};
if (deleteData.errcode !== 0 || deleteData.success !== true) {
throw new Error(`钉钉删除考勤组失败: ${deleteData.errmsg} (code=${deleteData.errcode})`);
}
async scheduleUsers(...args: Parameters<DingTalkScheduleClient['scheduleUsers']>) {
return this.schedules.scheduleUsers(...args);
}
// ═══════════════════════════════════════════
// 考勤排班 — 排班分配
// ═══════════════════════════════════════════
/** 批量排班单次最多200条 */
async scheduleUsers(
groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager',
): Promise<void> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
if (schedules.length === 0) return;
if (schedules.length > 200) {
throw new Error(`排班单次最多200条当前 ${schedules.length}`);
}
const token = await this.getAccessToken();
const body = {
op_user_id: opUserId,
group_id: groupId,
schedules: schedules.map((s) => ({
userid: s.userid,
work_date: s.work_date,
shift_id: s.shift_id,
is_rest: s.is_rest ?? false,
})),
};
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/schedule/async?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
};
if (data.errcode !== 0) {
throw new Error(`钉钉排班失败: ${data.errmsg} (code=${data.errcode})`);
}
this.logger.log(`钉钉排班成功: groupId=${groupId}, ${schedules.length}`);
}
/** 查询指定用户的排班信息7天内最多50人 */
async queryScheduleByUsers(
userIds: string[], fromDate: number, toDate: number, opUserId = 'manager',
): Promise<DingTalkScheduleResult[]> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/schedule/listbyusers?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
op_user_id: opUserId,
userids: userIds.join(','),
from_date_time: fromDate,
to_date_time: toDate,
}),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
result?: Array<{
userid: string; work_date: string; shift_id: number;
is_rest: string; check_type: string; plan_check_time: string;
group_id: number; id: number;
}>;
};
if (data.errcode !== 0) {
throw new Error(`钉钉查询排班失败: ${data.errmsg} (code=${data.errcode})`);
}
return (data.result ?? []).map((r) => ({
userid: r.userid,
work_date: r.work_date,
shift_id: r.shift_id,
is_rest: r.is_rest,
check_type: r.check_type,
plan_check_time: r.plan_check_time,
group_id: r.group_id,
id: r.id,
}));
...args: Parameters<DingTalkScheduleClient['queryScheduleByUsers']>
) {
return this.schedules.queryScheduleByUsers(...args);
}
private sleep(ms: number): Promise<void> {

View File

@@ -23,7 +23,6 @@ export async function syncJinshujuStudents(
manager: EntityManager,
entries: JinshujuEntry[],
): Promise<JinshujuStudentSyncResult> {
// Extract name/phone from entries
interface ParsedEntry {
serialNumber: number;
name: string;
@@ -96,7 +95,6 @@ export async function syncJinshujuStudents(
toCreate.push({ name: p.name, phone: p.phone });
}
// Create new students
let created = 0;
if (toCreate.length > 0) {
const host = await manager.findOne(Organization, { where: { isHost: true, status: 'active' } });

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { JINSHUJU_API_BASE } from './endpoints';
export interface JinshujuEntry {
serial_number: number;
@@ -15,6 +16,7 @@ export interface JinshujuEntriesResponse {
next: number | null;
}
// aislop-ignore-next-line: duplicate-type-declaration -- 与前端 JinshujuMatchModal 的 API 契约保持一致
export interface JinshujuFormField {
key: string;
label: string;
@@ -29,7 +31,6 @@ interface JinshujuFormResponse {
@Injectable()
export class JinshujuService {
private readonly logger = new Logger(JinshujuService.name);
private static readonly BASE = 'https://jinshuju.net/api/v1';
private getAuthorization(apiKey: string, apiSecret: string): string {
return `Basic ${Buffer.from(`${apiKey}:${apiSecret}`).toString('base64')}`;
@@ -41,7 +42,7 @@ export class JinshujuService {
formToken: string,
): Promise<{ name: string; fields: JinshujuFormField[] }> {
const response = await fetch(
`${JinshujuService.BASE}/forms/${encodeURIComponent(formToken)}`,
`${JINSHUJU_API_BASE}/forms/${encodeURIComponent(formToken)}`,
{
headers: {
Authorization: this.getAuthorization(apiKey, apiSecret),
@@ -74,7 +75,7 @@ export class JinshujuService {
let next: number | null | undefined = undefined;
do {
const url = new URL(`${JinshujuService.BASE}/forms/${encodeURIComponent(formToken)}/entries`);
const url = new URL(`${JINSHUJU_API_BASE}/forms/${encodeURIComponent(formToken)}/entries`);
if (next) url.searchParams.set('next', String(next));
this.logger.log(`Fetching Jinshuju entries: ${url.toString().replace(/api_key=[^&]+/, 'api_key=***')}`);

View File

@@ -2,6 +2,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entities/user.entity';
import {
WECOM_API_BASE,
WECOM_DEPARTMENT_PATH,
WECOM_TOKEN_PATH,
WECOM_USER_PATH,
} from './endpoints';
interface WeComTokenResponse {
errcode: number;
@@ -48,7 +54,7 @@ export class WeComService {
}
const corpId = process.env.WECOM_CORP_ID!;
const corpSecret = process.env.WECOM_CORP_SECRET!;
const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${corpSecret}`;
const url = `${WECOM_API_BASE}${WECOM_TOKEN_PATH}?corpid=${corpId}&corpsecret=${corpSecret}`;
const res = await fetch(url);
const body: WeComTokenResponse = await res.json();
if (body.errcode !== 0) {
@@ -64,7 +70,7 @@ export class WeComService {
parentId = 1,
): Promise<Array<{ id: number; name: string; parentid: number }>> {
const all: WeComDeptListResponse['department'] = [];
const url = `https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token=${token}&id=${parentId}`;
const url = `${WECOM_API_BASE}${WECOM_DEPARTMENT_PATH}?access_token=${token}&id=${parentId}`;
const res = await fetch(url);
const body: WeComDeptListResponse = await res.json();
if (body.errcode !== 0) {
@@ -85,7 +91,7 @@ export class WeComService {
token: string,
deptId: number,
): Promise<Array<{ userid: string; name: string; mobile: string; department: number[] }>> {
const url = `https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=${token}&department_id=${deptId}&fetch_child=1`;
const url = `${WECOM_API_BASE}${WECOM_USER_PATH}?access_token=${token}&department_id=${deptId}&fetch_child=1`;
const res = await fetch(url);
const body: WeComUserListResponse = await res.json();
if (body.errcode !== 0) {

View File

@@ -23,4 +23,13 @@ describe('OccupanciesController permissions', () => {
Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.downloadTemplate),
).toEqual(['occupancy:view']);
});
it('requires occupancy:purge on permanent delete routes', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.purge)).toEqual([
'occupancy:purge',
]);
expect(
Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.batchPurge),
).toEqual(['occupancy:purge']);
});
});

View File

@@ -27,6 +27,7 @@ import { NotificationType } from '../entities/notification.entity';
import { CheckInDto, CheckOutDto, TransferRoomDto, BatchCheckOutDto } from './dto/occupancy.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { logAudit } from '../common/with-audit-log';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { BatchIdsDto } from '../common/batch-ids.dto';
@@ -66,16 +67,9 @@ export class OccupanciesController {
@RequirePermission('occupancy:delete')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRestore(dto.ids);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住管理',
action: '批量恢复入住记录',
detail: `IDs: ${dto.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '入住管理', action: '批量恢复入住记录', detail: `IDs: ${dto.ids.join(',')}`,
});
return result;
}
@@ -83,16 +77,9 @@ export class OccupanciesController {
@Post('batch-check-out')
@RequirePermission('occupancy:checkout')
async batchCheckOut(@Body() dto: BatchCheckOutDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchCheckOut(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住管理',
action: '批量退宿',
detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '入住管理', action: '批量退宿', detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`,
});
return result;
}
@@ -100,18 +87,9 @@ export class OccupanciesController {
@Post('check-in')
@RequirePermission('occupancy:checkin')
async checkIn(@Body() dto: CheckInDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.checkIn(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住管理',
action: '办理入住',
targetId: result.id,
targetType: 'occupancy',
detail: `学生${dto.studentId} 入住房间${dto.roomId}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '入住管理', action: '办理入住', targetId: result.id, targetType: 'occupancy', detail: `学生${dto.studentId} 入住房间${dto.roomId}`,
});
// Send check_in notification
try {
@@ -133,17 +111,9 @@ export class OccupanciesController {
@Put(':id/check-out')
@RequirePermission('occupancy:checkout')
async checkOut(@Param('id') id: string, @Body() dto: CheckOutDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.checkOut(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住管理',
action: '办理退宿',
targetId: +id,
targetType: 'occupancy',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '入住管理', action: '办理退宿', targetId: +id, targetType: 'occupancy',
});
// Send check_out notification
try {
@@ -165,18 +135,9 @@ export class OccupanciesController {
@Put(':id/transfer')
@RequirePermission('occupancy:transfer')
async transferRoom(@Param('id') id: string, @Body() dto: TransferRoomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.transferRoom(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住管理',
action: '调换宿舍',
targetId: +id,
targetType: 'occupancy',
detail: `换到房间${dto.newRoomId}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '入住管理', action: '调换宿舍', targetId: +id, targetType: 'occupancy', detail: `换到房间${dto.newRoomId}`,
});
return result;
}
@@ -184,17 +145,9 @@ export class OccupanciesController {
@Delete(':id')
@RequirePermission('occupancy:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住管理',
action: '归档入住记录',
targetId: +id,
targetType: 'occupancy',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '入住管理', action: '归档入住记录', targetId: +id, targetType: 'occupancy',
});
return result;
}
@@ -202,16 +155,29 @@ export class OccupanciesController {
@Post('batch-delete')
@RequirePermission('occupancy:delete')
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRemove(body.ids || []);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住管理',
action: '批量归档入住记录',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '入住管理', action: '批量归档入住记录', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('occupancy:purge')
async purge(@Param('id') id: string, @Request() req: any) {
const result = await this.service.purge(+id);
await logAudit(this.logService, req, {
module: '入住管理', action: '永久删除入住记录', targetId: +id, targetType: 'occupancy', detail: '物理删除,不可恢复',
});
return result;
}
@Post('batch-permanent-delete')
@RequirePermission('occupancy:purge')
async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) {
const result = await this.service.batchPurge(body.ids || []);
await logAudit(this.logService, req, {
module: '入住管理', action: '批量永久删除入住记录', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@@ -298,7 +264,7 @@ export class OccupanciesController {
const { ipAddress, userAgent } = extractRequestInfo(req);
if (!file?.buffer) throw new BadRequestException('请上传入住名单 Excel 文件');
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
const ws = workbook.worksheets[0];
const rows = parseOccupancyImportWorksheet(ws);
const result = await this.service.batchImportCheckIn(rows, {

View File

@@ -7,19 +7,31 @@ import { Deposit } from '../entities/deposit.entity';
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
import { Organization } from '../entities/organization.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
import { OccupanciesService } from './occupancies.service';
import { OccupancyOperationsService } from './occupancy-operations.service';
import { OccupancyImportService } from './occupancy-import.service';
import { OccupanciesController } from './occupancies.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [
TypeOrmModule.forFeature([Occupancy, Room, Student, Deposit, Bed, Locker, Organization]),
TypeOrmModule.forFeature([
Occupancy,
Room,
Student,
Deposit,
Bed,
Locker,
Organization,
RoomInspectionDetail,
]),
OperationLogsModule,
NotificationsModule,
],
controllers: [OccupanciesController],
providers: [OccupanciesService],
providers: [OccupanciesService, OccupancyOperationsService, OccupancyImportService],
exports: [OccupanciesService],
})
export class OccupanciesModule {}

View File

@@ -0,0 +1,80 @@
import { BadRequestException } from '@nestjs/common';
import { OccupanciesService } from './occupancies.service';
import { OccupancyOperationsService } from './occupancy-operations.service';
describe('OccupanciesService.purge', () => {
const createService = (overrides?: {
occupancy?: Record<string, unknown>;
detailCount?: number;
}) => {
const occ = { id: 1, status: 'archived', student: { name: '张三' }, ...overrides?.occupancy };
const repo = {
findOne: jest.fn().mockResolvedValue(occ),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
find: jest.fn().mockResolvedValue([occ]),
};
const inspectionDetailRepo = {
count: jest.fn().mockResolvedValue(overrides?.detailCount ?? 0),
};
const operations = new OccupancyOperationsService(
repo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
inspectionDetailRepo as never,
);
const service = new OccupanciesService(
repo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
inspectionDetailRepo as never,
operations,
);
return { service, repo, inspectionDetailRepo };
};
it('rejects occupancies that are not archived', async () => {
const { service, repo } = createService({ occupancy: { status: 'active' } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已归档入住记录可以永久删除,请先归档'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('rejects occupancies referenced by inspection details', async () => {
const { service, repo } = createService({ detailCount: 1 });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('该入住记录已被查寝记录引用,无法永久删除'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('deletes an archived occupancy with no references', async () => {
const { service, repo } = createService();
await expect(service.purge(1)).resolves.toEqual({
message: '已永久删除入住记录(不可恢复)',
});
expect(repo.delete).toHaveBeenCalledWith(1);
});
it('batch purge skips referenced records', async () => {
const { service, repo, inspectionDetailRepo } = createService();
repo.find = jest.fn().mockResolvedValue([
{ id: 1, status: 'archived', student: { name: '甲' } },
{ id: 2, status: 'archived', student: { name: '乙' } },
]);
inspectionDetailRepo.count.mockResolvedValueOnce(1).mockResolvedValueOnce(0);
const result = await service.batchPurge([1, 2]);
expect(result).toMatchObject({ deleted: 1, skipped: 1 });
expect(repo.delete).toHaveBeenCalledWith(2);
});
});

View File

@@ -1,5 +1,6 @@
import { Repository, DataSource } from 'typeorm';
import { OccupanciesService } from './occupancies.service';
import { OccupancyOperationsService } from './occupancy-operations.service';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
@@ -108,6 +109,17 @@ describe('OccupanciesService — responsible organization', () => {
student: { id: 3, gender: '男', organizationId: 7 },
bed: { id: 4, roomId: 2, status: 'available' },
});
const operations = new OccupancyOperationsService(
{} as Repository<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
createTransactionDataSource(manager),
{} as Repository<any>,
);
const service = new OccupanciesService(
{} as Repository<Occupancy>,
{} as Repository<Room>,
@@ -117,6 +129,8 @@ describe('OccupanciesService — responsible organization', () => {
{} as Repository<Locker>,
{} as Repository<any>,
createTransactionDataSource(manager),
{} as Repository<any>,
operations,
);
await service.checkIn({
@@ -151,6 +165,18 @@ describe('OccupanciesService — manual check-in deposit', () => {
{} as Repository<Locker>,
{} as Repository<any>,
createTransactionDataSource(manager),
{} as Repository<any>,
new OccupancyOperationsService(
{} as Repository<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
createTransactionDataSource(manager),
{} as Repository<any>,
),
),
manager,
};

View File

@@ -1,16 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
Repository,
DataSource,
IsNull,
Between,
LessThanOrEqual,
MoreThanOrEqual,
In,
SelectQueryBuilder,
ObjectLiteral,
} from 'typeorm';
import { Repository, DataSource, IsNull, SelectQueryBuilder, ObjectLiteral } from 'typeorm';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
@@ -18,10 +8,10 @@ import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
import { Deposit } from '../entities/deposit.entity';
import { Organization } from '../entities/organization.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
import { RoomsService } from '../rooms/rooms.service';
import { OccupancyOperationsService } from './occupancy-operations.service';
class ImportRowSkipped extends Error {}
@Injectable()
export class OccupanciesService {
@@ -34,21 +24,37 @@ export class OccupanciesService {
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
private dataSource: DataSource,
@InjectRepository(RoomInspectionDetail)
private inspectionDetailRepo: Repository<RoomInspectionDetail>,
@Optional() private operations?: OccupancyOperationsService,
) {}
private withPessimisticWriteLock<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
): SelectQueryBuilder<T> {
const type = this.dataSource.options.type;
if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') {
return qb.setLock('pessimistic_write');
private get ops(): OccupancyOperationsService {
if (!this.operations) {
this.operations = new OccupancyOperationsService(
this.repo,
this.roomRepo,
this.studentRepo,
this.depositRepo,
this.bedRepo,
this.lockerRepo,
this.organizationRepo,
this.dataSource,
this.inspectionDetailRepo,
);
}
return qb;
return this.operations;
}
async findAll(query?: { roomId?: number; studentId?: number; active?: boolean; status?: 'active' | 'archived' }) {
async findAll(query?: {
roomId?: number;
studentId?: number;
active?: boolean;
status?: 'active' | 'archived';
}) {
const status = query?.status ?? 'active';
if (status !== 'active' && status !== 'archived') throw new BadRequestException('入住记录状态无效');
if (status !== 'active' && status !== 'archived')
throw new BadRequestException('入住记录状态无效');
const qb = this.repo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
@@ -126,7 +132,8 @@ export class OccupanciesService {
);
if (dto.bedId) await manager.update(Bed, dto.bedId, { status: 'occupied' });
if (dto.lockerId) await manager.update(Locker, dto.lockerId, { status: 'occupied' });
if (count + 1 >= (room.capacity ?? 0)) await manager.update(Room, room.id, { status: 'full' });
if (count + 1 >= (room.capacity ?? 0))
await manager.update(Room, room.id, { status: 'full' });
if (dto.collectDeposit) {
let deposit = await manager.findOne(Deposit, { where: { studentId: dto.studentId } });
if (deposit) {
@@ -153,228 +160,65 @@ export class OccupanciesService {
});
}
private withPessimisticWriteLock<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
): SelectQueryBuilder<T> {
const type = this.dataSource.options.type;
if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') {
return qb.setLock('pessimistic_write');
}
return qb;
}
private normalizePositiveMoney(value: number, label: string): number {
if (!Number.isFinite(value) || value < 0) {
throw new BadRequestException(`${label}必须为非负数字`);
}
return Math.round(value * 100) / 100;
}
private assertDateOnly(value: string, label: string): void {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) {
throw new BadRequestException(`${label}格式错误,应为 YYYY-MM-DD`);
}
const date = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(date.getTime())) throw new BadRequestException(`${label}不是有效日期`);
}
private assertDateOrder(start: string, end: string | undefined, message: string): void {
if (end && start > end) throw new BadRequestException(message);
}
async checkOut(occupancyId: number, dto: CheckOutDto) {
return this.dataSource.transaction(async (manager) => {
const occ = await this.withPessimisticWriteLock(
manager
.createQueryBuilder(Occupancy, 'occupancy')
.where('occupancy.id = :id', { id: occupancyId }),
).getOne();
if (!occ) throw new NotFoundException('入住记录不存在');
if (occ.checkOutDate) throw new BadRequestException('该记录已退宿');
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(
occ.billingStartDate || occ.checkInDate,
dto.billingEndDate || dto.checkOutDate,
'计费截止日不能早于计费起始日',
);
occ.checkOutDate = dto.checkOutDate;
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
occ.checkOutReason = dto.checkOutReason || '';
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' });
return occ;
});
return this.ops.checkOut(occupancyId, dto);
}
async transferRoom(occupancyId: number, dto: TransferRoomDto) {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
await runner.startTransaction();
try {
const oldOcc = await this.withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Occupancy, 'occupancy')
.where('occupancy.id = :id', { id: occupancyId }),
).getOne();
if (!oldOcc) throw new NotFoundException('入住记录不存在');
if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿');
if (oldOcc.roomId === dto.newRoomId)
throw new BadRequestException('目标宿舍不能与当前宿舍相同');
this.assertDateOrder(oldOcc.checkInDate, dto.transferDate, '换房日期不能早于原入住日期');
this.assertDateOrder(
oldOcc.billingStartDate || oldOcc.checkInDate,
dto.oldBillingEndDate || dto.transferDate,
'原宿舍计费截止日不能早于计费起始日',
);
// 退旧房
oldOcc.checkOutDate = dto.transferDate;
oldOcc.billingEndDate = dto.oldBillingEndDate || dto.transferDate;
oldOcc.checkOutReason = dto.reason || '换房';
await runner.manager.save(oldOcc);
// 释放旧床位/柜子
if (oldOcc.bedId) {
await runner.manager.update(Bed, oldOcc.bedId, { status: 'available' });
}
if (oldOcc.lockerId) {
await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' });
}
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
// 检查新房容量
const newRoom = await this.withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Room, 'room')
.where('room.id = :roomId', { roomId: dto.newRoomId }),
).getOne();
if (!newRoom) throw new NotFoundException('目标宿舍不存在');
if (newRoom.status === 'archived' || newRoom.status === 'maintenance') {
throw new BadRequestException('目标宿舍当前不可入住');
}
const count = await runner.manager.count(Occupancy, {
where: { roomId: dto.newRoomId, checkOutDate: IsNull() },
});
if (count >= (newRoom.capacity ?? 0)) throw new BadRequestException('目标宿舍已满');
// 新床位校验
if (dto.newBedId) {
const newBed = await this.withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Bed, 'bed')
.where('bed.id = :bedId AND bed.roomId = :roomId', {
bedId: dto.newBedId,
roomId: dto.newRoomId,
}),
).getOne();
if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍');
if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用');
}
if (dto.newLockerId) {
const newLocker = await this.withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Locker, 'locker')
.where('locker.id = :lockerId AND locker.roomId = :roomId', {
lockerId: dto.newLockerId,
roomId: dto.newRoomId,
}),
).getOne();
if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍');
if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用');
}
// 计算新房计费起始日:默认为换房日期次日
const transferDate = new Date(dto.transferDate);
const nextDay = new Date(transferDate);
nextDay.setDate(nextDay.getDate() + 1);
const defaultBillingStart = nextDay.toISOString().split('T')[0];
this.assertDateOrder(
dto.transferDate,
dto.newBillingStartDate || defaultBillingStart,
'新宿舍计费起始日不能早于换房日期',
);
// 入住新房
const newOcc = runner.manager.create(Occupancy, {
studentId: oldOcc.studentId,
roomId: dto.newRoomId,
checkInDate: dto.transferDate,
billingStartDate: dto.newBillingStartDate || defaultBillingStart,
stayType: oldOcc.stayType,
responsibleOrganizationId: oldOcc.responsibleOrganizationId,
notes: `${oldOcc.roomId}号房换入`,
bedId: dto.newBedId,
lockerId: dto.newLockerId,
});
await runner.manager.save(newOcc);
// 更新新床位/柜子状态
if (dto.newBedId) {
await runner.manager.update(Bed, dto.newBedId, { status: 'occupied' });
}
if (dto.newLockerId) {
await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' });
}
if (count + 1 >= (newRoom.capacity ?? 0)) {
await runner.manager.update(Room, newRoom.id, { status: 'full' });
}
await runner.commitTransaction();
return { oldOccupancy: oldOcc, newOccupancy: newOcc };
} catch (err) {
await runner.rollbackTransaction();
throw err;
} finally {
await runner.release();
}
return this.ops.transferRoom(occupancyId, dto);
}
// 获取某宿舍在指定时间段内的入住记录(用于计费)
async getRoomOccupanciesInPeriod(roomId: number, periodStart: string, periodEnd: string) {
return this.repo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.where('o.roomId = :roomId', { roomId })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
.getMany();
return this.ops.getRoomOccupanciesInPeriod(roomId, periodStart, periodEnd);
}
async remove(id: number) {
const occ = await this.repo.findOne({ where: { id } });
if (!occ) throw new NotFoundException('入住记录不存在');
if (!occ.checkOutDate) throw new BadRequestException('在住记录不能归档,请先办理退宿');
if (occ.status === 'archived') throw new BadRequestException('入住记录已归档');
await this.repo.update(id, { status: 'archived' });
return { message: '已归档' };
return this.ops.remove(id);
}
async batchRemove(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的记录');
const records = await this.repo.find({ where: { id: In(ids) }, relations: ['student'] });
const skipped: string[] = [];
const deletableIds: number[] = [];
for (const occ of records) {
if (!occ.checkOutDate) {
skipped.push(occ.student?.name || `记录${occ.id}`);
} else {
deletableIds.push(occ.id);
}
}
let archived = 0;
if (deletableIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: deletableIds })
.execute();
archived = result.affected || 0;
}
const message =
skipped.length > 0
? `成功归档 ${archived} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿`
: `批量归档成功,共 ${archived}`;
return { message, archived, skipped: skipped.length };
return this.ops.batchRemove(ids);
}
async batchRestore(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('入住记录 ID 无效');
}
const records = await this.repo.find({ where: { id: In(uniqueIds) } });
if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在');
if (records.some((record) => record.status === 'archived' && !record.checkOutDate)) {
throw new BadRequestException('选中记录包含未退宿的异常归档记录');
}
return this.ops.batchRestore(ids);
}
const targetIds = records.filter((record) => record.status === 'archived').map((record) => record.id);
const skipped = records.length - targetIds.length;
let restored = 0;
if (targetIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'active' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
restored = result.affected || 0;
}
return { message: `已批量恢复 ${restored} 条入住记录`, restored, skipped };
async purge(id: number) {
return this.ops.purge(id);
}
async batchPurge(ids: number[]) {
return this.ops.batchPurge(ids);
}
async batchCheckOut(dto: {
@@ -383,71 +227,9 @@ export class OccupanciesService {
billingEndDate?: string;
checkOutReason?: string;
}) {
if (!dto.ids || dto.ids.length === 0) {
throw new BadRequestException('请选择要退宿的记录');
}
const runner = this.dataSource.createQueryRunner();
await runner.connect();
await runner.startTransaction();
let success = 0;
const errors: string[] = [];
try {
for (const id of dto.ids) {
const occ = await runner.manager.findOne(Occupancy, {
where: { id },
relations: ['student'],
});
if (!occ) {
errors.push(`记录${id}不存在`);
continue;
}
if (occ.checkOutDate) {
errors.push(`${occ.student?.name || id}已退宿`);
continue;
}
try {
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(
occ.billingStartDate || occ.checkInDate,
dto.billingEndDate || dto.checkOutDate,
'计费截止日不能早于计费起始日',
);
} catch (error) {
errors.push(`${occ.student?.name || id}: ${(error as BadRequestException).message}`);
continue;
}
occ.checkOutDate = dto.checkOutDate;
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' });
success++;
}
await runner.commitTransaction();
} catch (err) {
await runner.rollbackTransaction();
throw err;
} finally {
await runner.release();
}
return {
success,
failed: errors.length,
message: `已成功退宿 ${success}${errors.length > 0 ? `${errors.length} 条失败` : ''}`,
errors: errors.length > 0 ? errors : undefined,
};
return this.ops.batchCheckOut(dto);
}
/**
* 一键导入入住名单
* 每行数据:姓名、电话、学号、房间号、楼栋、入住日期
* 自动创建不存在的学生和宿舍,并登记入住
*/
async batchImportCheckIn(
rows: {
name: string;
@@ -471,276 +253,6 @@ export class OccupanciesService {
}[],
options?: { autoDeposit?: boolean; depositAmount?: number },
) {
let imported = 0;
let skipped = 0;
let depositsCreated = 0;
const errors: string[] = [];
const importDepositAmount = options?.autoDeposit
? this.normalizePositiveMoney(options.depositAmount ?? 500, '押金金额')
: undefined;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNum = i + 2; // Excel第2行开始第1行是表头
if (!row.name?.trim() || !row.roomNumber?.trim()) {
skipped++;
continue;
}
try {
const result = await this.dataSource.transaction(async (manager) => {
const occupancyRepo = manager.getRepository(Occupancy);
const roomRepo = manager.getRepository(Room);
const studentRepo = manager.getRepository(Student);
const depositRepo = manager.getRepository(Deposit);
const bedRepo = manager.getRepository(Bed);
const lockerRepo = manager.getRepository(Locker);
const organizationRepo = manager.getRepository(Organization);
let rowDepositsCreated = 0;
// 1. 通过手机号关联学生;未找到时创建学生并归入本机构
const phone = row.phone?.trim();
if (!phone) throw new BadRequestException('手机号不能为空,无法关联学生');
let student = await studentRepo.findOne({ where: { phone } });
if (!student) {
const hostOrganization = await organizationRepo.findOne({
where: { isHost: true, status: 'active' },
});
if (!hostOrganization) throw new BadRequestException('尚未配置本机构');
student = await studentRepo.save(
studentRepo.create({
name: row.name.trim(),
phone,
studentNo: row.studentNo?.trim() || undefined,
idNumber: row.idNumber?.trim() || undefined,
gender: row.gender?.trim() || undefined,
ethnicity: row.ethnicity?.trim() || undefined,
emergencyContact: row.emergencyContact?.trim() || undefined,
emergencyPhone: row.emergencyPhone?.trim() || undefined,
organizationId: hostOrganization.id,
supervisor: row.supervisor?.trim() || undefined,
}),
);
} else {
// 更新已有学生的缺失信息
const updates: any = {};
if (!student.studentNo && row.studentNo?.trim()) updates.studentNo = row.studentNo.trim();
if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim();
if (!student.ethnicity && row.ethnicity?.trim()) updates.ethnicity = row.ethnicity.trim();
if (!student.emergencyContact && row.emergencyContact?.trim())
updates.emergencyContact = row.emergencyContact.trim();
if (!student.emergencyPhone && row.emergencyPhone?.trim())
updates.emergencyPhone = row.emergencyPhone.trim();
if (!student.supervisor && row.supervisor?.trim())
updates.supervisor = row.supervisor.trim();
if (Object.keys(updates).length > 0) {
await studentRepo.update(student.id, updates);
Object.assign(student, updates);
}
}
// 2. 查找或创建宿舍(使用智能解析)
let room = await roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } });
if (!room) {
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
room = await roomRepo.save(
roomRepo.create({
roomNumber: row.roomNumber.trim(),
building: row.building?.trim() || parsed.building || undefined,
floor: parsed.floor || undefined,
capacity: parsed.capacity || 4,
roomType: parsed.roomType || undefined,
}),
);
}
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
const checkOutDate = row.checkOutDate?.trim();
const billingStartDate = row.billingStartDate?.trim() || checkInDate;
const isHistoricalRecord = Boolean(checkOutDate);
this.assertDateOnly(checkInDate, '入住日期');
this.assertDateOnly(billingStartDate, '计费起始日');
this.assertDateOrder(checkInDate, billingStartDate, '计费起始日不能早于入住日期');
if (checkOutDate) {
this.assertDateOnly(checkOutDate, '退宿日期');
this.assertDateOrder(checkInDate, checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(billingStartDate, checkOutDate, '退宿日期不能早于计费起始日');
}
// 3. 检查是否已有活跃入住(历史记录不影响当前入住)
const existing = await occupancyRepo.findOne({
where: { studentId: student.id, checkOutDate: IsNull() },
relations: ['room'],
});
if (existing && !isHistoricalRecord) {
throw new ImportRowSkipped(
`${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`,
);
}
// 4. 检查宿舍容量
const count = await occupancyRepo.count({ where: { roomId: room.id, checkOutDate: IsNull() } });
if (!isHistoricalRecord && count >= (room.capacity ?? 0)) {
throw new ImportRowSkipped(
`${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity ?? '?'}),跳过 ${row.name}`,
);
}
// 5. 匹配或创建床位、柜子,并校验是否可用
let bed: Bed | null = null;
if (row.bedNumber?.trim()) {
const bedNumber = row.bedNumber.trim();
bed = await bedRepo.findOne({ where: { roomId: room.id, bedNumber } });
if (!bed) {
const existingBedCount = await bedRepo.count({ where: { roomId: room.id } });
if (existingBedCount >= (room.capacity ?? 0)) {
throw new BadRequestException(
`宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity ?? '?'}`,
);
}
bed = await bedRepo.save(
bedRepo.create({ roomId: room.id, bedNumber, status: 'available' }),
);
}
if (!isHistoricalRecord && bed.status !== 'available') {
throw new BadRequestException(`床位 ${bedNumber} 已被占用或维修中`);
}
}
let locker: Locker | null = null;
if (row.lockerNumber?.trim()) {
const lockerNumber = row.lockerNumber.trim();
locker = await lockerRepo.findOne({ where: { roomId: room.id, lockerNumber } });
if (!locker) {
locker = await lockerRepo.save(
lockerRepo.create({ roomId: room.id, lockerNumber, status: 'available' }),
);
}
if (!isHistoricalRecord && locker.status !== 'available') {
throw new BadRequestException(`柜子 ${lockerNumber} 已被占用或维修中`);
}
}
// 6. 创建入住记录
const occData: any = {
studentId: student.id,
roomId: room.id,
checkInDate,
billingStartDate,
stayType: row.stayType || undefined,
responsibleOrganizationId: student.organizationId,
notes: row.notes || undefined,
bedId: bed?.id,
lockerId: locker?.id,
};
// 如果有退宿日期,直接记录
if (checkOutDate) {
occData.checkOutDate = checkOutDate;
occData.billingEndDate = checkOutDate;
}
await occupancyRepo.save(occupancyRepo.create(occData));
// 7. 更新床位、柜子和宿舍状态
if (!isHistoricalRecord) {
if (bed) await bedRepo.update(bed.id, { status: 'occupied' });
if (locker) await lockerRepo.update(locker.id, { status: 'occupied' });
if (count + 1 >= (room.capacity ?? 0)) {
await roomRepo.update(room.id, { status: 'full' });
}
}
// 9. 自动收取押金(仅对新入住且非历史记录的学生)
if (options?.autoDeposit && !isHistoricalRecord) {
const existingDeposit = await depositRepo.findOne({
where: { studentId: student.id },
});
const depositAmount = importDepositAmount!;
const hasPaidDeposit =
existingDeposit?.status === 'paid' && Number(existingDeposit.amount || 0) > 0;
if (hasPaidDeposit) {
// 导入重试或重复导入时,已有已缴押金不重复收取。
} else if (existingDeposit) {
existingDeposit.amount = depositAmount;
existingDeposit.status = 'paid';
existingDeposit.paidDate = checkInDate;
existingDeposit.refundDate = null as unknown as string;
existingDeposit.refundAmount = null as unknown as number;
existingDeposit.refundedBy = null;
existingDeposit.refundedAt = null;
existingDeposit.notes = '入住导入自动收取';
await depositRepo.save(existingDeposit);
rowDepositsCreated++;
} else {
await depositRepo.save(
depositRepo.create({
studentId: student.id,
amount: depositAmount,
paidDate: checkInDate,
status: 'paid',
notes: '入住导入自动收取',
}),
);
rowDepositsCreated++;
}
}
return { depositsCreated: rowDepositsCreated };
});
imported++;
depositsCreated += result.depositsCreated;
} catch (e: any) {
errors.push(
e instanceof ImportRowSkipped
? e.message
: `${rowNum}行: ${row.name} 导入失败 - ${e.message}`,
);
skipped++;
}
}
const depositMsg = depositsCreated > 0 ? `,自动收取 ${depositsCreated} 笔押金` : '';
return {
message: `成功导入 ${imported} 条入住记录,跳过 ${skipped}${depositMsg}`,
imported,
skipped,
depositsCreated,
errors: errors.length > 0 ? errors : undefined,
};
}
private normalizePositiveMoney(value: number, label: string): number {
const amount = Number(value);
if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) {
throw new BadRequestException(`${label}最多保留两位小数`);
}
if (amount <= 0) throw new BadRequestException(`${label}必须大于0`);
return Number(amount.toFixed(2));
}
private assertDateOnly(value: string, label: string): void {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`);
}
const [year, month, day] = value.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() + 1 !== month ||
date.getUTCDate() !== day
) {
throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`);
}
}
private assertDateOrder(start: string, end: string | undefined, message: string): void {
this.assertDateOnly(start, '起始日期');
if (!end) return;
this.assertDateOnly(end, '结束日期');
if (end < start) throw new BadRequestException(message);
return this.ops.batchImportCheckIn(rows, options);
}
}

View File

@@ -81,7 +81,7 @@ function parseDate(cell: ExcelJS.Cell | undefined): string {
return `${year}-${month}-${day}`;
}
const text = cellText(cell);
const matched = text.match(/(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})/);
const matched = text.match(/(\d{4})[/\-.](\d{1,2})[/\-.](\d{1,2})/);
if (!matched) return text;
return `${matched[1]}-${matched[2].padStart(2, '0')}-${matched[3].padStart(2, '0')}`;
}

View File

@@ -0,0 +1,311 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { DataSource, IsNull } from 'typeorm';
import { Occupancy, Room, Student, Deposit, Bed, Locker, Organization } from '../entities';
import { RoomsService } from '../rooms/rooms.service';
class ImportRowSkipped extends Error {}
@Injectable()
export class OccupancyImportService {
constructor(private dataSource: DataSource) {}
async batchImportCheckIn(
rows: {
name: string;
phone?: string;
studentNo?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
supervisor?: string;
roomNumber: string;
building?: string;
checkInDate: string;
billingStartDate?: string;
checkOutDate?: string;
bedNumber?: string;
lockerNumber?: string;
stayType?: string;
notes?: string;
}[],
options?: { autoDeposit?: boolean; depositAmount?: number },
) {
let imported = 0;
let skipped = 0;
let depositsCreated = 0;
const errors: string[] = [];
const importDepositAmount = options?.autoDeposit
? this.normalizePositiveMoney(options.depositAmount ?? 500, '押金金额')
: undefined;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNum = i + 2; // Excel第2行开始第1行是表头
if (!row.name?.trim() || !row.roomNumber?.trim()) {
skipped++;
continue;
}
try {
const result = await this.dataSource.transaction(async (manager) => {
const occupancyRepo = manager.getRepository(Occupancy);
const roomRepo = manager.getRepository(Room);
const studentRepo = manager.getRepository(Student);
const depositRepo = manager.getRepository(Deposit);
const bedRepo = manager.getRepository(Bed);
const lockerRepo = manager.getRepository(Locker);
const organizationRepo = manager.getRepository(Organization);
let rowDepositsCreated = 0;
// 1. 通过手机号关联学生;未找到时创建学生并归入本机构
const phone = row.phone?.trim();
if (!phone) throw new BadRequestException('手机号不能为空,无法关联学生');
let student = await studentRepo.findOne({ where: { phone } });
if (!student) {
const hostOrganization = await organizationRepo.findOne({
where: { isHost: true, status: 'active' },
});
if (!hostOrganization) throw new BadRequestException('尚未配置本机构');
student = await studentRepo.save(
studentRepo.create({
name: row.name.trim(),
phone,
studentNo: row.studentNo?.trim() || undefined,
idNumber: row.idNumber?.trim() || undefined,
gender: row.gender?.trim() || undefined,
ethnicity: row.ethnicity?.trim() || undefined,
emergencyContact: row.emergencyContact?.trim() || undefined,
emergencyPhone: row.emergencyPhone?.trim() || undefined,
organizationId: hostOrganization.id,
supervisor: row.supervisor?.trim() || undefined,
}),
);
} else {
// 更新已有学生的缺失信息
const updates: any = {};
if (!student.studentNo && row.studentNo?.trim())
updates.studentNo = row.studentNo.trim();
if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim();
if (!student.ethnicity && row.ethnicity?.trim())
updates.ethnicity = row.ethnicity.trim();
if (!student.emergencyContact && row.emergencyContact?.trim())
updates.emergencyContact = row.emergencyContact.trim();
if (!student.emergencyPhone && row.emergencyPhone?.trim())
updates.emergencyPhone = row.emergencyPhone.trim();
if (!student.supervisor && row.supervisor?.trim())
updates.supervisor = row.supervisor.trim();
if (Object.keys(updates).length > 0) {
await studentRepo.update(student.id, updates);
Object.assign(student, updates);
}
}
// 2. 查找或创建宿舍(使用智能解析)
let room = await roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } });
if (!room) {
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
room = await roomRepo.save(
roomRepo.create({
roomNumber: row.roomNumber.trim(),
building: row.building?.trim() || parsed.building || undefined,
floor: parsed.floor || undefined,
capacity: parsed.capacity || 4,
roomType: parsed.roomType || undefined,
}),
);
}
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
const checkOutDate = row.checkOutDate?.trim();
const billingStartDate = row.billingStartDate?.trim() || checkInDate;
const isHistoricalRecord = Boolean(checkOutDate);
this.assertDateOnly(checkInDate, '入住日期');
this.assertDateOnly(billingStartDate, '计费起始日');
this.assertDateOrder(checkInDate, billingStartDate, '计费起始日不能早于入住日期');
if (checkOutDate) {
this.assertDateOnly(checkOutDate, '退宿日期');
this.assertDateOrder(checkInDate, checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(billingStartDate, checkOutDate, '退宿日期不能早于计费起始日');
}
// 3. 检查是否已有活跃入住(历史记录不影响当前入住)
const existing = await occupancyRepo.findOne({
where: { studentId: student.id, checkOutDate: IsNull() },
relations: ['room'],
});
if (existing && !isHistoricalRecord) {
throw new ImportRowSkipped(
`${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`,
);
}
// 4. 检查宿舍容量
const count = await occupancyRepo.count({
where: { roomId: room.id, checkOutDate: IsNull() },
});
if (!isHistoricalRecord && count >= (room.capacity ?? 0)) {
throw new ImportRowSkipped(
`${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity ?? '?'}),跳过 ${row.name}`,
);
}
// 5. 匹配或创建床位、柜子,并校验是否可用
let bed: Bed | null = null;
if (row.bedNumber?.trim()) {
const bedNumber = row.bedNumber.trim();
bed = await bedRepo.findOne({ where: { roomId: room.id, bedNumber } });
if (!bed) {
const existingBedCount = await bedRepo.count({ where: { roomId: room.id } });
if (existingBedCount >= (room.capacity ?? 0)) {
throw new BadRequestException(
`宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity ?? '?'}`,
);
}
bed = await bedRepo.save(
bedRepo.create({ roomId: room.id, bedNumber, status: 'available' }),
);
}
if (!isHistoricalRecord && bed.status !== 'available') {
throw new BadRequestException(`床位 ${bedNumber} 已被占用或维修中`);
}
}
let locker: Locker | null = null;
if (row.lockerNumber?.trim()) {
const lockerNumber = row.lockerNumber.trim();
locker = await lockerRepo.findOne({ where: { roomId: room.id, lockerNumber } });
if (!locker) {
locker = await lockerRepo.save(
lockerRepo.create({ roomId: room.id, lockerNumber, status: 'available' }),
);
}
if (!isHistoricalRecord && locker.status !== 'available') {
throw new BadRequestException(`柜子 ${lockerNumber} 已被占用或维修中`);
}
}
// 6. 创建入住记录
const occData: any = {
studentId: student.id,
roomId: room.id,
checkInDate,
billingStartDate,
stayType: row.stayType || undefined,
responsibleOrganizationId: student.organizationId,
notes: row.notes || undefined,
bedId: bed?.id,
lockerId: locker?.id,
};
// 如果有退宿日期,直接记录
if (checkOutDate) {
occData.checkOutDate = checkOutDate;
occData.billingEndDate = checkOutDate;
}
await occupancyRepo.save(occupancyRepo.create(occData));
// 7. 更新床位、柜子和宿舍状态
if (!isHistoricalRecord) {
if (bed) await bedRepo.update(bed.id, { status: 'occupied' });
if (locker) await lockerRepo.update(locker.id, { status: 'occupied' });
if (count + 1 >= (room.capacity ?? 0)) {
await roomRepo.update(room.id, { status: 'full' });
}
}
// 9. 自动收取押金(仅对新入住且非历史记录的学生)
if (options?.autoDeposit && !isHistoricalRecord) {
const existingDeposit = await depositRepo.findOne({
where: { studentId: student.id },
});
const depositAmount = importDepositAmount!;
const hasPaidDeposit =
existingDeposit?.status === 'paid' && Number(existingDeposit.amount || 0) > 0;
if (hasPaidDeposit) {
// 导入重试或重复导入时,已有已缴押金不重复收取。
} else if (existingDeposit) {
existingDeposit.amount = depositAmount;
existingDeposit.status = 'paid';
existingDeposit.paidDate = checkInDate;
(existingDeposit as { refundDate: string | null }).refundDate = null;
(existingDeposit as { refundAmount: number | null }).refundAmount = null;
(existingDeposit as { refundedBy: number | null }).refundedBy = null;
(existingDeposit as { refundedAt: Date | null }).refundedAt = null;
existingDeposit.notes = '入住导入自动收取';
await depositRepo.save(existingDeposit);
rowDepositsCreated++;
} else {
await depositRepo.save(
depositRepo.create({
studentId: student.id,
amount: depositAmount,
paidDate: checkInDate,
status: 'paid',
notes: '入住导入自动收取',
}),
);
rowDepositsCreated++;
}
}
return { depositsCreated: rowDepositsCreated };
});
imported++;
depositsCreated += result.depositsCreated;
} catch (e: any) {
errors.push(
e instanceof ImportRowSkipped
? e.message
: `${rowNum}行: ${row.name} 导入失败 - ${e.message}`,
);
skipped++;
}
}
const depositMsg = depositsCreated > 0 ? `,自动收取 ${depositsCreated} 笔押金` : '';
return {
message: `成功导入 ${imported} 条入住记录,跳过 ${skipped}${depositMsg}`,
imported,
skipped,
depositsCreated,
errors: errors.length > 0 ? errors : undefined,
};
}
private normalizePositiveMoney(value: number, label: string): number {
const amount = value;
if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) {
throw new BadRequestException(`${label}最多保留两位小数`);
}
if (amount <= 0) throw new BadRequestException(`${label}必须大于0`);
return Number(amount.toFixed(2));
}
private assertDateOnly(value: string, label: string): void {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`);
}
const [year, month, day] = value.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() + 1 !== month ||
date.getUTCDate() !== day
) {
throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`);
}
}
private assertDateOrder(start: string, end: string | undefined, message: string): void {
this.assertDateOnly(start, '起始日期');
if (!end) return;
this.assertDateOnly(end, '结束日期');
if (end < start) throw new BadRequestException(message);
}
}

View File

@@ -0,0 +1,12 @@
import type { SelectQueryBuilder, ObjectLiteral, DataSource } from 'typeorm';
export function withPessimisticWriteLock<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
dataSource: DataSource,
): SelectQueryBuilder<T> {
const type = dataSource.options.type;
if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') {
return qb.setLock('pessimistic_write');
}
return qb;
}

View File

@@ -0,0 +1,420 @@
import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, IsNull, In } from 'typeorm';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
import { Deposit } from '../entities/deposit.entity';
import { Organization } from '../entities/organization.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
import { CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
import { OccupancyImportService } from './occupancy-import.service';
import { withPessimisticWriteLock } from './occupancy-lock';
@Injectable()
export class OccupancyOperationsService {
constructor(
@InjectRepository(Occupancy) private repo: Repository<Occupancy>,
@InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
private dataSource: DataSource,
@InjectRepository(RoomInspectionDetail)
private inspectionDetailRepo: Repository<RoomInspectionDetail>,
@Optional() private imports?: OccupancyImportService,
) {}
private get imp(): OccupancyImportService {
if (!this.imports) this.imports = new OccupancyImportService(this.dataSource);
return this.imports;
}
private normalizePositiveMoney(value: number, label: string): number {
if (!Number.isFinite(value) || value < 0) {
throw new BadRequestException(`${label}必须为非负数字`);
}
return Math.round(value * 100) / 100;
}
private assertDateOnly(value: string, label: string): void {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) {
throw new BadRequestException(`${label}格式错误,应为 YYYY-MM-DD`);
}
const date = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(date.getTime())) throw new BadRequestException(`${label}不是有效日期`);
}
private assertDateOrder(start: string, end: string | undefined, message: string): void {
if (end && start > end) throw new BadRequestException(message);
}
async checkOut(occupancyId: number, dto: CheckOutDto) {
return this.dataSource.transaction(async (manager) => {
const occ = await withPessimisticWriteLock(
manager
.createQueryBuilder(Occupancy, 'occupancy')
.where('occupancy.id = :id', { id: occupancyId }),
this.dataSource).getOne();
if (!occ) throw new NotFoundException('入住记录不存在');
if (occ.checkOutDate) throw new BadRequestException('该记录已退宿');
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(
occ.billingStartDate || occ.checkInDate,
dto.billingEndDate || dto.checkOutDate,
'计费截止日不能早于计费起始日',
);
occ.checkOutDate = dto.checkOutDate;
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
occ.checkOutReason = dto.checkOutReason || '';
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' });
return occ;
});
}
async transferRoom(occupancyId: number, dto: TransferRoomDto) {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
await runner.startTransaction();
try {
const oldOcc = await withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Occupancy, 'occupancy')
.where('occupancy.id = :id', { id: occupancyId }),
this.dataSource).getOne();
if (!oldOcc) throw new NotFoundException('入住记录不存在');
if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿');
if (oldOcc.roomId === dto.newRoomId)
throw new BadRequestException('目标宿舍不能与当前宿舍相同');
this.assertDateOrder(oldOcc.checkInDate, dto.transferDate, '换房日期不能早于原入住日期');
this.assertDateOrder(
oldOcc.billingStartDate || oldOcc.checkInDate,
dto.oldBillingEndDate || dto.transferDate,
'原宿舍计费截止日不能早于计费起始日',
);
// 退旧房
oldOcc.checkOutDate = dto.transferDate;
oldOcc.billingEndDate = dto.oldBillingEndDate || dto.transferDate;
oldOcc.checkOutReason = dto.reason || '换房';
await runner.manager.save(oldOcc);
// 释放旧床位/柜子
if (oldOcc.bedId) {
await runner.manager.update(Bed, oldOcc.bedId, { status: 'available' });
}
if (oldOcc.lockerId) {
await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' });
}
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
// 检查新房容量
const newRoom = await withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Room, 'room')
.where('room.id = :roomId', { roomId: dto.newRoomId }),
this.dataSource).getOne();
if (!newRoom) throw new NotFoundException('目标宿舍不存在');
if (newRoom.status === 'archived' || newRoom.status === 'maintenance') {
throw new BadRequestException('目标宿舍当前不可入住');
}
const count = await runner.manager.count(Occupancy, {
where: { roomId: dto.newRoomId, checkOutDate: IsNull() },
});
if (count >= (newRoom.capacity ?? 0)) throw new BadRequestException('目标宿舍已满');
// 新床位校验
if (dto.newBedId) {
const newBed = await withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Bed, 'bed')
.where('bed.id = :bedId AND bed.roomId = :roomId', {
bedId: dto.newBedId,
roomId: dto.newRoomId,
}),
this.dataSource).getOne();
if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍');
if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用');
}
if (dto.newLockerId) {
const newLocker = await withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Locker, 'locker')
.where('locker.id = :lockerId AND locker.roomId = :roomId', {
lockerId: dto.newLockerId,
roomId: dto.newRoomId,
}),
this.dataSource).getOne();
if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍');
if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用');
}
// 计算新房计费起始日:默认为换房日期次日
const transferDate = new Date(dto.transferDate);
const nextDay = new Date(transferDate);
nextDay.setDate(nextDay.getDate() + 1);
const defaultBillingStart = nextDay.toISOString().split('T')[0];
this.assertDateOrder(
dto.transferDate,
dto.newBillingStartDate || defaultBillingStart,
'新宿舍计费起始日不能早于换房日期',
);
// 入住新房
const newOcc = runner.manager.create(Occupancy, {
studentId: oldOcc.studentId,
roomId: dto.newRoomId,
checkInDate: dto.transferDate,
billingStartDate: dto.newBillingStartDate || defaultBillingStart,
stayType: oldOcc.stayType,
responsibleOrganizationId: oldOcc.responsibleOrganizationId,
notes: `${oldOcc.roomId}号房换入`,
bedId: dto.newBedId,
lockerId: dto.newLockerId,
});
await runner.manager.save(newOcc);
// 更新新床位/柜子状态
if (dto.newBedId) {
await runner.manager.update(Bed, dto.newBedId, { status: 'occupied' });
}
if (dto.newLockerId) {
await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' });
}
if (count + 1 >= (newRoom.capacity ?? 0)) {
await runner.manager.update(Room, newRoom.id, { status: 'full' });
}
await runner.commitTransaction();
return { oldOccupancy: oldOcc, newOccupancy: newOcc };
} catch (err) {
await runner.rollbackTransaction();
throw err;
} finally {
await runner.release();
}
}
// 获取某宿舍在指定时间段内的入住记录(用于计费)
async getRoomOccupanciesInPeriod(roomId: number, periodStart: string, periodEnd: string) {
return this.repo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.where('o.roomId = :roomId', { roomId })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
.getMany();
}
async remove(id: number) {
const occ = await this.repo.findOne({ where: { id } });
if (!occ) throw new NotFoundException('入住记录不存在');
if (!occ.checkOutDate) throw new BadRequestException('在住记录不能归档,请先办理退宿');
if (occ.status === 'archived') throw new BadRequestException('入住记录已归档');
await this.repo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async batchRemove(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的记录');
const records = await this.repo.find({ where: { id: In(ids) }, relations: ['student'] });
const skipped: string[] = [];
const deletableIds: number[] = [];
for (const occ of records) {
if (!occ.checkOutDate) {
skipped.push(occ.student?.name || `记录${occ.id}`);
} else {
deletableIds.push(occ.id);
}
}
let archived = 0;
if (deletableIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: deletableIds })
.execute();
archived = result.affected || 0;
}
const message =
skipped.length > 0
? `成功归档 ${archived} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿`
: `批量归档成功,共 ${archived}`;
return { message, archived, skipped: skipped.length };
}
async batchRestore(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('入住记录 ID 无效');
}
const records = await this.repo.find({ where: { id: In(uniqueIds) } });
if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在');
if (records.some((record) => record.status === 'archived' && !record.checkOutDate)) {
throw new BadRequestException('选中记录包含未退宿的异常归档记录');
}
const targetIds = records
.filter((record) => record.status === 'archived')
.map((record) => record.id);
const skipped = records.length - targetIds.length;
let restored = 0;
if (targetIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'active' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
restored = result.affected || 0;
}
return { message: `已批量恢复 ${restored} 条入住记录`, restored, skipped };
}
async purge(id: number) {
const occ = await this.repo.findOne({ where: { id } });
if (!occ) throw new NotFoundException('入住记录不存在');
if (occ.status !== 'archived')
throw new BadRequestException('仅已归档入住记录可以永久删除,请先归档');
const detailCount = await this.inspectionDetailRepo.count({ where: { occupancyId: id } });
if (detailCount > 0) throw new BadRequestException('该入住记录已被查寝记录引用,无法永久删除');
await this.repo.delete(id);
return { message: '已永久删除入住记录(不可恢复)' };
}
async batchPurge(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的入住记录');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('入住记录 ID 无效');
}
const records = await this.repo.find({ where: { id: In(uniqueIds) }, relations: ['student'] });
if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在');
const deleted: number[] = [];
const skipped: string[] = [];
for (const occ of records) {
if (occ.status !== 'archived') {
skipped.push(`${occ.student?.name || `记录${occ.id}`}(未归档)`);
continue;
}
const detailCount = await this.inspectionDetailRepo.count({ where: { occupancyId: occ.id } });
if (detailCount > 0) {
skipped.push(`${occ.student?.name || `记录${occ.id}`}(存在关联数据)`);
continue;
}
await this.repo.delete(occ.id);
deleted.push(occ.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 };
}
async batchCheckOut(dto: {
ids: number[];
checkOutDate: string;
billingEndDate?: string;
checkOutReason?: string;
}) {
if (!dto.ids || dto.ids.length === 0) {
throw new BadRequestException('请选择要退宿的记录');
}
const runner = this.dataSource.createQueryRunner();
await runner.connect();
await runner.startTransaction();
let success = 0;
const errors: string[] = [];
try {
for (const id of dto.ids) {
const occ = await runner.manager.findOne(Occupancy, {
where: { id },
relations: ['student'],
});
if (!occ) {
errors.push(`记录${id}不存在`);
continue;
}
if (occ.checkOutDate) {
errors.push(`${occ.student?.name || id}已退宿`);
continue;
}
try {
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(
occ.billingStartDate || occ.checkInDate,
dto.billingEndDate || dto.checkOutDate,
'计费截止日不能早于计费起始日',
);
} catch (error) {
errors.push(`${occ.student?.name || id}: ${(error as BadRequestException).message}`);
continue;
}
occ.checkOutDate = dto.checkOutDate;
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' });
success++;
}
await runner.commitTransaction();
} catch (err) {
await runner.rollbackTransaction();
throw err;
} finally {
await runner.release();
}
return {
success,
failed: errors.length,
message: `已成功退宿 ${success}${errors.length > 0 ? `${errors.length} 条失败` : ''}`,
errors: errors.length > 0 ? errors : undefined,
};
}
/**
* 一键导入入住名单
* 每行数据:姓名、电话、学号、房间号、楼栋、入住日期
* 自动创建不存在的学生和宿舍,并登记入住
*/
async batchImportCheckIn(
rows: {
name: string;
phone?: string;
studentNo?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
supervisor?: string;
roomNumber: string;
building?: string;
checkInDate: string;
billingStartDate?: string;
checkOutDate?: string;
bedNumber?: string;
lockerNumber?: string;
stayType?: string;
notes?: string;
}[],
options?: { autoDeposit?: boolean; depositAmount?: number },
) {
return this.imp.batchImportCheckIn(rows, options);
}
}

View File

@@ -21,3 +21,25 @@ describe('OrganizationsController permissions', () => {
]);
});
});
describe('OrganizationsController', () => {
it('requires organization:purge on permanent delete route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.purge)).toEqual([
'organization:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = {
purge: jest.fn().mockResolvedValue({ message: '已永久删除机构(不可恢复)' }),
};
const log = jest.fn().mockResolvedValue(undefined);
const controller = new OrganizationsController(service as never, { log } as never);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge('1', req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '机构管理', action: '永久删除机构', targetId: 1 }),
);
});
});

View File

@@ -101,4 +101,23 @@ export class OrganizationsController {
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('organization:purge')
async purge(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.purge(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '机构管理',
action: '永久删除机构',
targetId: +id,
targetType: 'organization',
detail: '物理删除,不可恢复',
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -1,12 +1,18 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Organization } from '../entities/organization.entity';
import { Student } from '../entities/student.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { OrganizationsService } from './organizations.service';
import { OrganizationsController } from './organizations.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([Organization]), OperationLogsModule],
imports: [
TypeOrmModule.forFeature([Organization, Student, Occupancy, ClassroomRental]),
OperationLogsModule,
],
controllers: [OrganizationsController],
providers: [OrganizationsService],
exports: [OrganizationsService],

View File

@@ -0,0 +1,78 @@
import { BadRequestException } from '@nestjs/common';
import { OrganizationsService } from './organizations.service';
describe('OrganizationsService.purge', () => {
const createService = (overrides?: {
organization?: Record<string, unknown>;
studentCount?: number;
occupancyCount?: number;
lessorCount?: number;
lesseeCount?: number;
}) => {
const organization = {
id: 1,
name: '合作机构',
status: 'archived',
isHost: false,
...overrides?.organization,
};
const repo = {
findOne: jest.fn().mockResolvedValue(organization),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const studentRepo = { count: jest.fn().mockResolvedValue(overrides?.studentCount ?? 0) };
const occupancyRepo = { count: jest.fn().mockResolvedValue(overrides?.occupancyCount ?? 0) };
const rentalRepo = {
count: jest.fn().mockResolvedValue(overrides?.lessorCount ?? 0),
};
rentalRepo.count.mockResolvedValueOnce(overrides?.lessorCount ?? 0);
rentalRepo.count.mockResolvedValueOnce(overrides?.lesseeCount ?? 0);
const service = new OrganizationsService(
repo as never,
studentRepo as never,
occupancyRepo as never,
rentalRepo as never,
);
return { service, repo, studentRepo, occupancyRepo, rentalRepo };
};
it('rejects organizations that are not archived or are the host', async () => {
const notArchived = createService({ organization: { status: 'active' } });
await expect(notArchived.service.purge(1)).rejects.toThrow(
new BadRequestException('仅已归档机构可以永久删除,请先归档'),
);
const host = createService({ organization: { isHost: true } });
await expect(host.service.purge(1)).rejects.toThrow(
new BadRequestException('本机构不能永久删除'),
);
expect(notArchived.repo.delete).not.toHaveBeenCalled();
expect(host.repo.delete).not.toHaveBeenCalled();
});
it('rejects organizations with student, occupancy, or rental references', async () => {
const withStudents = createService({ studentCount: 1 });
await expect(withStudents.service.purge(1)).rejects.toThrow(
new BadRequestException('该机构存在关联数据(学生归属),无法永久删除'),
);
const withOccupancy = createService({ occupancyCount: 1 });
await expect(withOccupancy.service.purge(1)).rejects.toThrow(
new BadRequestException('该机构存在关联数据(入住责任机构),无法永久删除'),
);
const withLessee = createService({ lesseeCount: 1 });
await expect(withLessee.service.purge(1)).rejects.toThrow(
new BadRequestException('该机构存在关联数据(承租租赁订单),无法永久删除'),
);
expect(withLessee.repo.delete).not.toHaveBeenCalled();
});
it('deletes an archived organization with no references', async () => {
const { service, repo } = createService();
await expect(service.purge(1)).resolves.toEqual({
message: '已永久删除机构(不可恢复)',
});
expect(repo.delete).toHaveBeenCalledWith(1);
});
});

View File

@@ -3,6 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
import { Organization } from '../entities/organization.entity';
import { Student } from '../entities/student.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { CreateOrganizationDto, UpdateOrganizationDto } from './dto/organization.dto';
const COLOR_PALETTE = [
@@ -20,7 +23,12 @@ const COLOR_PALETTE = [
@Injectable()
export class OrganizationsService {
constructor(@InjectRepository(Organization) private repo: Repository<Organization>) {}
constructor(
@InjectRepository(Organization) private repo: Repository<Organization>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
@InjectRepository(Occupancy) private occupancyRepo: Repository<Occupancy>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
) {}
async findAll(query?: { includeArchived?: boolean; scope?: 'all' | 'host' | 'external' }) {
const where: Record<string, unknown> = {};
@@ -86,4 +94,28 @@ export class OrganizationsService {
await this.repo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async purge(id: number) {
const organization = await this.findOne(id);
if (organization.status !== 'archived') {
throw new BadRequestException('仅已归档机构可以永久删除,请先归档');
}
if (organization.isHost) throw new BadRequestException('本机构不能永久删除');
const [studentCount, occupancyCount, lessorCount, lesseeCount] = await Promise.all([
this.studentRepo.count({ where: { organizationId: id } }),
this.occupancyRepo.count({ where: { responsibleOrganizationId: id } }),
this.rentalRepo.count({ where: { lessorOrganizationId: id } }),
this.rentalRepo.count({ where: { lesseeOrganizationId: id } }),
]);
const references: string[] = [];
if (studentCount > 0) references.push('学生归属');
if (occupancyCount > 0) references.push('入住责任机构');
if (lessorCount > 0) references.push('出租租赁订单');
if (lesseeCount > 0) references.push('承租租赁订单');
if (references.length > 0) {
throw new BadRequestException(`该机构存在关联数据(${references.join('、')}),无法永久删除`);
}
await this.repo.delete(id);
return { message: '已永久删除机构(不可恢复)' };
}
}

View File

@@ -0,0 +1,190 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm';
import { Room } from '../entities/room.entity';
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
import type { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto';
import type { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto';
@Injectable()
export class RoomBedLockerService {
constructor(
@InjectRepository(Room) private repo: Repository<Room>,
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
) {}
async getRoomBeds(roomId: number): Promise<Bed[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
return this.bedRepo.find({
where: { roomId, status: Not('archived') },
order: { bedNumber: 'ASC' },
});
}
async getRoomAvailableBeds(roomId: number): Promise<Bed[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
return this.bedRepo.find({
where: { roomId, status: 'available' },
order: { bedNumber: 'ASC' },
});
}
async createBed(roomId: number, dto: CreateBedDto): Promise<Bed> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
await this.assertCanAddBeds(room, 1);
const existing = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } });
if (existing) throw new BadRequestException('该床位编号已存在');
const bed = this.bedRepo.create({ ...dto, roomId });
return this.bedRepo.save(bed);
}
async updateBed(roomId: number, id: number, dto: UpdateBedDto): Promise<Bed> {
const bed = await this.bedRepo.findOne({ where: { id, roomId } });
if (!bed) throw new NotFoundException('床位不存在');
// 不允许将 occupied 的床位改为 maintenance
if (dto.status === 'maintenance' && bed.status === 'occupied') {
throw new BadRequestException('该床位有人入住,请先退宿');
}
// 编号唯一性检查
if (dto.bedNumber && dto.bedNumber !== bed.bedNumber) {
const dup = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } });
if (dup) throw new BadRequestException('该床位编号已存在');
}
Object.assign(bed, dto);
return this.bedRepo.save(bed);
}
async deleteBed(roomId: number, id: number): Promise<void> {
const bed = await this.bedRepo.findOne({ where: { id, roomId } });
if (!bed) throw new NotFoundException('床位不存在');
if (bed.status === 'occupied') throw new BadRequestException('该床位有人入住,无法归档');
if (bed.status === 'archived') throw new BadRequestException('该床位已归档');
await this.bedRepo.update(id, { status: 'archived' });
}
async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise<Bed[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
const existing = await this.bedRepo.find({
where: { roomId, status: Not('archived') },
order: { bedNumber: 'ASC' },
});
this.assertCanAddBedsFromCount(room, existing.length, dto.count);
const numbers = existing.map((b) => {
const match = b.bedNumber.match(/^\d+/);
return match ? parseInt(match[0]) : 0;
});
const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
const beds: Bed[] = [];
for (let i = 0; i < dto.count; i++) {
beds.push(this.bedRepo.create({ roomId, bedNumber: `${start + i}号床` }));
}
return this.bedRepo.save(beds);
}
getNextBedNumber(beds: Pick<Bed, 'bedNumber'>[]): number {
const numbers = beds.map((bed) => {
const match = bed.bedNumber.match(/^\d+/);
return match ? parseInt(match[0], 10) : 0;
});
return numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
}
private async assertCanAddBeds(room: Room, count: number): Promise<void> {
const existingCount = await this.bedRepo.count({ where: { roomId: room.id } });
this.assertCanAddBedsFromCount(room, existingCount, count);
}
private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void {
const remaining = Math.max((room.capacity ?? 0) - existingCount, 0);
if (count > remaining) {
throw new BadRequestException(
`床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining}`,
);
}
}
// ── 柜子管理 ──
async getRoomLockers(roomId: number): Promise<Locker[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
return this.lockerRepo.find({
where: { roomId, status: Not('archived') },
order: { lockerNumber: 'ASC' },
});
}
async getRoomAvailableLockers(roomId: number): Promise<Locker[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
return this.lockerRepo.find({
where: { roomId, status: 'available' },
order: { lockerNumber: 'ASC' },
});
}
async createLocker(roomId: number, dto: CreateLockerDto): Promise<Locker> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
const existing = await this.lockerRepo.findOne({
where: { roomId, lockerNumber: dto.lockerNumber },
});
if (existing) throw new BadRequestException('该柜子编号已存在');
const locker = this.lockerRepo.create({ ...dto, roomId });
return this.lockerRepo.save(locker);
}
async updateLocker(roomId: number, id: number, dto: UpdateLockerDto): Promise<Locker> {
const locker = await this.lockerRepo.findOne({ where: { id, roomId } });
if (!locker) throw new NotFoundException('柜子不存在');
if (dto.status === 'maintenance' && locker.status === 'occupied') {
throw new BadRequestException('该柜子有人占用,请先释放');
}
if (dto.lockerNumber && dto.lockerNumber !== locker.lockerNumber) {
const dup = await this.lockerRepo.findOne({
where: { roomId, lockerNumber: dto.lockerNumber },
});
if (dup) throw new BadRequestException('该柜子编号已存在');
}
Object.assign(locker, dto);
return this.lockerRepo.save(locker);
}
async deleteLocker(roomId: number, id: number): Promise<void> {
const locker = await this.lockerRepo.findOne({ where: { id, roomId } });
if (!locker) throw new NotFoundException('柜子不存在');
if (locker.status === 'occupied') throw new BadRequestException('该柜子有人占用,无法归档');
if (locker.status === 'archived') throw new BadRequestException('该柜子已归档');
await this.lockerRepo.update(id, { status: 'archived' });
}
async batchCreateLockers(roomId: number, dto: BatchCreateLockerDto): Promise<Locker[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
const existing = await this.lockerRepo.find({
where: { roomId, status: Not('archived') },
order: { lockerNumber: 'ASC' },
});
const numbers = existing.map((b) => {
const match = b.lockerNumber.match(/^\d+/);
return match ? parseInt(match[0]) : 0;
});
const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
const lockers: Locker[] = [];
for (let i = 0; i < dto.count; i++) {
lockers.push(this.lockerRepo.create({ roomId, lockerNumber: `${start + i}号柜` }));
}
return this.lockerRepo.save(lockers);
}
}

View File

@@ -2,7 +2,6 @@ import { BadRequestException, Injectable, Logger, OnApplicationBootstrap } from
import { Cron } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { Bed } from '../entities/bed.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { RoomInspection } from '../entities/room-inspection.entity';
@@ -35,7 +34,10 @@ export class RoomInspectionsService implements OnApplicationBootstrap {
async onApplicationBootstrap(): Promise<void> {
await this.settlePreviousDay().catch((error) => {
this.logger.error('补记昨日宿舍查寝失败', error instanceof Error ? error.stack : String(error));
this.logger.error(
'补记昨日宿舍查寝失败',
error instanceof Error ? error.stack : String(error),
);
});
}
@@ -67,7 +69,9 @@ export class RoomInspectionsService implements OnApplicationBootstrap {
const allowedIds = new Set(occupancies.map((occupancy) => occupancy.id));
const invalidIds = uniquePresentIds.filter((id) => !allowedIds.has(id));
if (invalidIds.length > 0) {
throw new BadRequestException(`存在不属于该宿舍当日住户的入住记录: ${invalidIds.join(', ')}`);
throw new BadRequestException(
`存在不属于该宿舍当日住户的入住记录: ${invalidIds.join(', ')}`,
);
}
const inspectionRepo = manager.getRepository(RoomInspection);
@@ -128,7 +132,10 @@ export class RoomInspectionsService implements OnApplicationBootstrap {
async settleDate(inspectionDate: string): Promise<number> {
const existing = await this.inspectionRepo.find({ where: { inspectionDate } });
const existingRoomIds = new Set(existing.map((inspection) => inspection.roomId));
const occupancies = await this.findAllOccupanciesForDate(this.dataSource.manager, inspectionDate);
const occupancies = await this.findAllOccupanciesForDate(
this.dataSource.manager,
inspectionDate,
);
const byRoom = new Map<number, Occupancy[]>();
for (const occupancy of occupancies) {
if (existingRoomIds.has(occupancy.roomId)) continue;

View File

@@ -0,0 +1,38 @@
/** 智能解析房间号,自动推导楼栋、楼层、宿舍类型 */
export function parseRoomNumber(roomNumber: string): {
building?: string;
floor?: number;
roomType?: string;
capacity?: number;
} {
const cleaned = roomNumber.replace(/[(].*?[)]/g, '').trim();
// 家庭房: X-Y-ZZZ 格式
const familyMatch = cleaned.match(/^(\d+)-(\d+)-(\d+)$/);
if (familyMatch) {
const bldg = `${familyMatch[1]}-${familyMatch[2]}`;
const roomPart = familyMatch[3];
const rawFloor = parseInt(roomPart.charAt(0), 10);
const floor = Number.isNaN(rawFloor) ? undefined : rawFloor;
return { building: bldg, floor, roomType: '家庭房', capacity: 4 };
}
// 标准: X-YZZ 格式
const stdMatch = cleaned.match(/^(\d+)-(\d+)$/);
if (stdMatch) {
const bldgNum = stdMatch[1];
const roomPart = stdMatch[2];
const rawFloor = parseInt(roomPart.charAt(0), 10);
const floor = Number.isNaN(rawFloor) ? undefined : rawFloor;
const building = `${bldgNum}号楼`;
let roomType = '四人间';
let capacity = 4;
if (bldgNum === '2') {
roomType = '单人间';
capacity = 1;
} else if (bldgNum === '8') {
roomType = '爆改房';
capacity = 2;
}
return { building, floor, roomType, capacity };
}
return { capacity: 4, roomType: '四人间' };
}

View File

@@ -0,0 +1,282 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not, In } from 'typeorm';
import { Room } from '../entities/room.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { Bed } from '../entities/bed.entity';
import { RoomInspectionsService } from './room-inspections.service';
import { occupancyWhereOnDate } from './room-occupancy-date';
import { parseRoomNumber } from './room-number';
@Injectable()
export class RoomQueryService {
constructor(
@InjectRepository(Room) private repo: Repository<Room>,
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
private readonly inspectionsService: RoomInspectionsService,
) {}
async agentSearchRooms(query: { building?: string; keyword?: string; status?: string; limit?: number }) {
const qb = this.repo.createQueryBuilder('room');
if (query.building) qb.andWhere('room.building = :building', { building: query.building });
if (query.keyword) {
qb.andWhere('(room.roomNumber LIKE :keyword OR room.building LIKE :keyword)', {
keyword: `%${query.keyword}%`,
});
}
if (query.status) qb.andWhere('room.status = :status', { status: query.status });
const rows = await qb
.select([
'room.id',
'room.roomNumber',
'room.building',
'room.floor',
'room.capacity',
'room.roomType',
'room.status',
])
.orderBy('room.roomNumber', 'ASC')
.limit(Math.max(1, Math.min(query.limit ?? 20, 50)))
.getRawMany();
return rows.map((row) => ({
id: Number(row.room_id),
roomNumber: String(row.room_room_number),
building: row.room_building == null ? null : String(row.room_building),
floor: row.room_floor == null ? null : Number(row.room_floor),
capacity: Number(row.room_capacity),
roomType: row.room_room_type == null ? null : String(row.room_room_type),
status: String(row.room_status),
}));
}
async agentGetRoomOccupancySummary(query: { date?: string; building?: string; limit?: number }) {
const targetDate = query.date || this.getChinaDate(new Date());
const qb = this.occRepo
.createQueryBuilder('o')
.innerJoin('o.room', 'room')
.where('o.checkInDate <= :date', { date: targetDate })
.andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :date)', { date: targetDate });
if (query.building) qb.andWhere('room.building = :building', { building: query.building });
const rows = await qb
.select('room.id', 'roomId')
.addSelect('room.roomNumber', 'roomNumber')
.addSelect('COUNT(o.id)', 'occupied')
.addSelect('room.capacity', 'capacity')
.groupBy('room.id')
.orderBy('room.roomNumber', 'ASC')
.limit(Math.max(1, Math.min(query.limit ?? 20, 50)))
.getRawMany();
return rows.map((row) => ({
roomId: Number(row.roomId),
roomNumber: String(row.roomNumber),
occupied: Number(row.occupied),
capacity: Number(row.capacity),
rate: Number(row.capacity) > 0 ? Number(((Number(row.occupied) / Number(row.capacity)) * 100).toFixed(1)) : 0,
}));
}
async getRoomVisual(asOf?: string) {
// asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。
const isHistorical = !!asOf;
const targetDate = asOf || this.getChinaDate(new Date());
// 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。
const rooms = await this.repo.find({
where: isHistorical ? {} : { status: Not('archived') },
order: { building: 'ASC', roomNumber: 'ASC' },
});
const occupancies = await this.occRepo.find({
where: occupancyWhereOnDate(targetDate),
relations: ['student', 'student.organization', 'responsibleOrganization', 'bed'],
order: { checkInDate: 'ASC' },
});
// 按roomId分组入住记录
const occMap = new Map<number, any[]>();
// days已住天数相对目标日期计算而非固定今天历史视图才准确。
const refTime = new Date(targetDate).getTime();
for (const occ of occupancies) {
if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []);
const checkIn = new Date(occ.checkInDate);
const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)));
occMap.get(occ.roomId)!.push({
studentId: occ.studentId,
occupancyId: occ.id,
studentName: occ.student?.name || '未知',
bedId: occ.bedId ?? null,
bedNumber: occ.bed?.bedNumber || null,
checkInDate: occ.checkInDate,
billingStartDate: occ.billingStartDate,
days,
organization: occ.student?.organization?.name || null,
supervisor: occ.student?.supervisor || null,
organizationId: occ.responsibleOrganizationId || null,
organizationName: occ.responsibleOrganization?.name || null,
organizationColor: occ.responsibleOrganization?.color || null,
});
}
// 获取各楼栋列表
const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))];
// 历史视图纳入了已归档房间,但只保留当时确实有人住的归档房间,避免空归档房间刷屏。
const visibleRooms = isHistorical
? rooms.filter((r) => r.status !== 'archived' || (occMap.get(r.id)?.length ?? 0) > 0)
: rooms;
// 批量获取床位统计
const allBeds = await this.bedRepo.find({
where: { roomId: In(visibleRooms.map((r) => r.id)) },
});
const bedMap = new Map<number, { total: number; occupied: number }>();
for (const bed of allBeds) {
if (!bedMap.has(bed.roomId)) bedMap.set(bed.roomId, { total: 0, occupied: 0 });
const entry = bedMap.get(bed.roomId)!;
entry.total++;
if (bed.status === 'occupied') entry.occupied++;
}
const inspectionMap = await this.inspectionsService.getByRoomsAndDate(
visibleRooms.map((room) => room.id),
targetDate,
);
return {
buildings,
rooms: visibleRooms.map((room) => {
const occ = occMap.get(room.id) || [];
const inspection = inspectionMap.get(room.id);
const inspectionByOccupancyId = new Map(
(inspection?.details || []).map((detail) => [detail.occupancyId, detail.status]),
);
const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))];
let orgLabel: string | null = null;
if (orgs.length > 0 && occ.length > 0) {
const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]);
orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`;
}
const organizationColors = [
...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean)),
];
const organizationColor: string | null =
organizationColors.length === 1 ? organizationColors[0] : null;
const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))];
return {
id: room.id,
roomNumber: room.roomNumber,
building: room.building,
floor: room.floor,
capacity: room.capacity,
status: room.status,
currentCount: occ.length,
totalBeds: bedMap.get(room.id)?.total ?? 0,
occupiedBeds: bedMap.get(room.id)?.occupied ?? 0,
occupants: occ.map((occupant) => ({
...occupant,
inspectionStatus: inspectionByOccupancyId.get(occupant.occupancyId) || null,
})),
inspection: inspection
? {
submitted: true,
inspectorId: inspection.inspectorId,
inspectorName: inspection.inspectorName,
source: inspection.source,
submittedAt: inspection.submittedAt,
}
: { submitted: false },
orgLabel,
organizationColor,
organizationIds,
};
}),
// 当前视图内出现过的负责机构,供筛选下拉使用
organizations: [
...new Map(
occupancies
.filter((o) => o.responsibleOrganizationId && o.responsibleOrganization)
.map((o) => [
o.responsibleOrganizationId,
{
id: o.responsibleOrganizationId,
name: o.responsibleOrganization.name,
color: o.responsibleOrganization.color || null,
},
]),
).values(),
].sort((a, b) => a.name.localeCompare(b.name)),
};
}
private getChinaDate(now: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
}
async batchImport(
rows: {
roomNumber: string;
building?: string;
floor?: number;
capacity?: number;
roomType?: string;
rentalCategory?: string;
monthlyRate?: number;
}[],
) {
let imported = 0;
let skipped = 0;
for (const row of rows) {
if (!row.roomNumber || !row.roomNumber.trim()) {
skipped++;
continue;
}
const exists = await this.repo.findOne({ where: { roomNumber: row.roomNumber.trim() } });
if (exists) {
skipped++;
continue;
}
// 智能解析房间号
const parsed = parseRoomNumber(row.roomNumber.trim());
const room = await this.repo.save(
this.repo.create({
roomNumber: row.roomNumber.trim(),
building: row.building?.trim() || parsed.building || undefined,
floor: row.floor ?? parsed.floor,
capacity: row.capacity ?? parsed.capacity ?? 4,
roomType: row.roomType || parsed.roomType || undefined,
rentalCategory: row.rentalCategory || undefined,
monthlyRate: row.monthlyRate ?? undefined,
}),
);
await this.createDefaultBeds(room.id, room.capacity);
imported++;
}
return {
message: `成功导入 ${imported} 间宿舍,跳过 ${skipped} 条(重复或空行)`,
imported,
skipped,
};
}
// ── 床位管理 ──
async createDefaultBeds(roomId: number, capacity: number): Promise<void> {
const count = Math.max(capacity ?? 0, 0);
if (count === 0) return;
const beds = Array.from({ length: count }, (_, index) =>
this.bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }),
);
await this.bedRepo.save(beds);
}
}

View File

@@ -25,6 +25,7 @@ import { UpdateRoomInspectionDto } from './dto/room-inspection.dto';
import { RoomInspectionsService } from './room-inspections.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { logAudit } from '../common/with-audit-log';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { BatchIdsDto } from '../common/batch-ids.dto';
@@ -64,16 +65,9 @@ export class RoomsController {
@RequirePermission('room:edit')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRestore(dto.ids);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '批量恢复宿舍',
detail: `IDs: ${dto.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '宿舍', action: '批量恢复宿舍', detail: `IDs: ${dto.ids.join(',')}`,
});
return result;
}
@@ -86,23 +80,14 @@ export class RoomsController {
@Body() dto: UpdateRoomInspectionDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.inspectionsService.submit(
+roomId,
date,
dto.presentOccupancyIds,
{ id: req.user?.id, username: req.user?.username },
);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍查寝',
action: result.isUpdate ? '修改查寝记录' : '提交查寝记录',
targetId: +roomId,
targetType: 'room',
detail: `查寝日期: ${date}, 宿舍: ${result.roomNumber}, 在寝: ${result.presentNames.join('、') || '无'}, 缺勤: ${result.absentNames.join('、') || '无'}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '宿舍查寝', action: result.isUpdate ? '修改查寝记录' : '提交查寝记录', targetId: +roomId, targetType: 'room', detail: `查寝日期: ${date}, 宿舍: ${result.roomNumber}, 在寝: ${result.presentNames.join('、') || '无'}, 缺勤: ${result.absentNames.join('、') || '无'}`,
});
return result.inspection;
}
@@ -285,16 +270,9 @@ export class RoomsController {
@Post()
@RequirePermission('room:create')
async create(@Body() dto: CreateRoomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '添加宿舍',
detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '宿舍', action: '添加宿舍', detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}`,
});
return result;
}
@@ -302,18 +280,9 @@ export class RoomsController {
@Put(':id')
@RequirePermission('room:edit')
async update(@Param('id') id: string, @Body() dto: UpdateRoomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '编辑宿舍',
targetId: +id,
targetType: 'room',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '宿舍', action: '编辑宿舍', targetId: +id, targetType: 'room', detail: JSON.stringify(dto),
});
return result;
}
@@ -321,17 +290,9 @@ export class RoomsController {
@Delete(':id')
@RequirePermission('room:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '归档宿舍',
targetId: +id,
targetType: 'room',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '宿舍', action: '归档宿舍', targetId: +id, targetType: 'room',
});
return result;
}
@@ -339,16 +300,29 @@ export class RoomsController {
@Post('batch-delete')
@RequirePermission('room:delete')
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRemove(body.ids || []);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '批量归档宿舍',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '宿舍', action: '批量归档宿舍', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('room:purge')
async purge(@Param('id') id: string, @Request() req: any) {
const result = await this.service.purge(+id);
await logAudit(this.logService, req, {
module: '宿舍', action: '永久删除宿舍', targetId: +id, targetType: 'room', detail: '物理删除,不可恢复',
});
return result;
}
@Post('batch-permanent-delete')
@RequirePermission('room:purge')
async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) {
const result = await this.service.batchPurge(body.ids || []);
await logAudit(this.logService, req, {
module: '宿舍', action: '批量永久删除宿舍', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@@ -356,17 +330,9 @@ export class RoomsController {
@Put(':id/restore')
@RequirePermission('room:edit')
async restore(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.restore(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '恢复宿舍',
targetId: +id,
targetType: 'room',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '宿舍', action: '恢复宿舍', targetId: +id, targetType: 'room',
});
return result;
}
@@ -377,7 +343,7 @@ export class RoomsController {
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
const ws = workbook.worksheets[0];
const rows: {
roomNumber: string;

View File

@@ -8,6 +8,8 @@ import { Locker } from '../entities/locker.entity';
import { RoomInspection } from '../entities/room-inspection.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
import { RoomsService } from './rooms.service';
import { RoomQueryService } from './room-query.service';
import { RoomBedLockerService } from './room-bed-locker.service';
import { RoomsController } from './rooms.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { RoomInspectionsService } from './room-inspections.service';
@@ -26,7 +28,7 @@ import { RoomInspectionsService } from './room-inspections.service';
OperationLogsModule,
],
controllers: [RoomsController],
providers: [RoomsService, RoomInspectionsService],
providers: [RoomsService, RoomInspectionsService, RoomQueryService, RoomBedLockerService],
exports: [RoomsService, RoomInspectionsService],
})
export class RoomsModule {}

View File

@@ -0,0 +1,26 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { RoomsController } from './rooms.controller';
describe('RoomsController purge routes', () => {
it('requires room:purge on permanent delete routes', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, RoomsController.prototype.purge)).toEqual([
'room:purge',
]);
expect(Reflect.getMetadata(PERMISSION_KEY, RoomsController.prototype.batchPurge)).toEqual([
'room:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除宿舍(不可恢复)' }) };
const log = jest.fn().mockResolvedValue(undefined);
const controller = new RoomsController(service as never, { log } as never, {} as never);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge('1', req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '宿舍', action: '永久删除宿舍', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,71 @@
import { BadRequestException } from '@nestjs/common';
import { RoomsService } from './rooms.service';
describe('RoomsService.purge', () => {
const createService = (overrides?: {
room?: Record<string, unknown>;
occupancyCount?: number;
expenseCount?: number;
}) => {
const room = { id: 1, roomNumber: '101', status: 'archived', ...overrides?.room };
const repo = {
findOne: jest.fn().mockResolvedValue(room),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
find: jest.fn().mockResolvedValue([room]),
};
const occRepo = { count: jest.fn().mockResolvedValue(overrides?.occupancyCount ?? 0) };
const roomExpRepo = { count: jest.fn().mockResolvedValue(overrides?.expenseCount ?? 0) };
const service = new RoomsService(
repo as never,
occRepo as never,
roomExpRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
return { service, repo, occRepo, roomExpRepo };
};
it('rejects rooms that are not archived', async () => {
const { service, repo } = createService({ room: { status: 'available' } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已归档宿舍可以永久删除,请先归档'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('rejects rooms referenced by occupancies or expenses', async () => {
const withOccupancy = createService({ occupancyCount: 1 });
await expect(withOccupancy.service.purge(1)).rejects.toThrow(
new BadRequestException('该宿舍存在入住记录,无法永久删除'),
);
expect(withOccupancy.repo.delete).not.toHaveBeenCalled();
const withExpense = createService({ expenseCount: 1 });
await expect(withExpense.service.purge(1)).rejects.toThrow(
new BadRequestException('该宿舍存在宿舍费用,无法永久删除'),
);
expect(withExpense.repo.delete).not.toHaveBeenCalled();
});
it('deletes an archived room with no references', async () => {
const { service, repo } = createService();
await expect(service.purge(1)).resolves.toEqual({ message: '已永久删除宿舍(不可恢复)' });
expect(repo.delete).toHaveBeenCalledWith(1);
});
it('batch purge skips referenced rooms', async () => {
const { service, repo, occRepo } = createService();
repo.find = jest.fn().mockResolvedValue([
{ id: 1, roomNumber: '101', status: 'archived' },
{ id: 2, roomNumber: '102', status: 'archived' },
]);
occRepo.count
.mockResolvedValueOnce(1)
.mockResolvedValueOnce(0);
const result = await service.batchPurge([1, 2]);
expect(result).toMatchObject({ deleted: 1, skipped: 1 });
expect(repo.delete).toHaveBeenCalledWith(2);
});
});

View File

@@ -1,14 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
DataSource,
Repository,
Like,
IsNull,
Not,
In,
LessThanOrEqual,
} from 'typeorm';
import { DataSource, Repository, IsNull, Not, In } from 'typeorm';
import { Room } from '../entities/room.entity';
import { Occupancy } from '../entities/occupancy.entity';
@@ -19,26 +11,9 @@ import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto';
import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto';
import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto';
import { RoomInspectionsService } from './room-inspections.service';
import { occupancyWhereOnDate } from './room-occupancy-date';
interface AgentRoomRow {
id: string | number;
roomNumber: string;
building: string | null;
floor: string | number | null;
capacity: string | number;
roomType: string | null;
status: string;
occupiedBeds: string | number;
}
interface AgentRoomOccupancyRow {
roomId: string | number;
roomNumber: string;
building: string | null;
capacity: string | number;
occupiedBeds: string | number;
}
import { RoomQueryService } from './room-query.service';
import { RoomBedLockerService } from './room-bed-locker.service';
import { parseRoomNumber } from './room-number';
@Injectable()
export class RoomsService {
@@ -50,8 +25,24 @@ export class RoomsService {
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
private dataSource: DataSource,
private readonly inspectionsService: RoomInspectionsService,
@Optional() private queryService?: RoomQueryService,
@Optional() private beds?: RoomBedLockerService,
) {}
private get queries(): RoomQueryService {
if (!this.queryService) {
this.queryService = new RoomQueryService(this.repo, this.occRepo, this.bedRepo, this.inspectionsService);
}
return this.queryService;
}
private get bedOps(): RoomBedLockerService {
if (!this.beds) {
this.beds = new RoomBedLockerService(this.repo, this.bedRepo, this.lockerRepo);
}
return this.beds;
}
/**
* 智能解析房间号,自动推导楼栋、楼层、宿舍类型
* "4-102" → building:"4号楼", floor:1, roomType:"四人间"
@@ -59,42 +50,8 @@ export class RoomsService {
* "3-301" → building:"3号楼", floor:3, roomType:"四人间"
* "8-102" → building:"8号楼", floor:1, roomType:"爆改房"
*/
static parseRoomNumber(roomNumber: string): {
building?: string;
floor?: number;
roomType?: string;
capacity?: number;
} {
const cleaned = roomNumber.replace(/[(].*?[)]/g, '').trim();
// 家庭房: X-Y-ZZZ 格式
const familyMatch = cleaned.match(/^(\d+)-(\d+)-(\d+)$/);
if (familyMatch) {
const bldg = `${familyMatch[1]}-${familyMatch[2]}`;
const roomPart = familyMatch[3];
const rawFloor = parseInt(roomPart.charAt(0), 10);
const floor = Number.isNaN(rawFloor) ? undefined : rawFloor;
return { building: bldg, floor, roomType: '家庭房', capacity: 4 };
}
// 标准: X-YZZ 格式
const stdMatch = cleaned.match(/^(\d+)-(\d+)$/);
if (stdMatch) {
const bldgNum = stdMatch[1];
const roomPart = stdMatch[2];
const rawFloor = parseInt(roomPart.charAt(0), 10);
const floor = Number.isNaN(rawFloor) ? undefined : rawFloor;
const building = `${bldgNum}号楼`;
let roomType = '四人间';
let capacity = 4;
if (bldgNum === '2') {
roomType = '单人间';
capacity = 1;
} else if (bldgNum === '8') {
roomType = '爆改房';
capacity = 2;
}
return { building, floor, roomType, capacity };
}
return { capacity: 4, roomType: '四人间' };
static parseRoomNumber(roomNumber: string) {
return parseRoomNumber(roomNumber);
}
async findAll(query?: { building?: string; includeArchived?: boolean }) {
@@ -104,59 +61,6 @@ export class RoomsService {
return this.repo.find({ where, order: { roomNumber: 'ASC' } });
}
async agentSearchRooms(query: { keyword?: string; building?: string; status?: string; limit?: number }) {
const qb = this.repo
.createQueryBuilder('room')
.leftJoin(
Occupancy,
'occupancy',
'occupancy.roomId = room.id AND occupancy.checkOutDate IS NULL',
)
.select('room.id', 'id')
.addSelect('room.roomNumber', 'roomNumber')
.addSelect('room.building', 'building')
.addSelect('room.floor', 'floor')
.addSelect('room.capacity', 'capacity')
.addSelect('room.roomType', 'roomType')
.addSelect('room.status', 'status')
.addSelect('COUNT(occupancy.id)', 'occupiedBeds')
.where('room.status != :archived', { archived: 'archived' });
if (query.keyword) qb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${query.keyword}%` });
if (query.building) qb.andWhere('room.building = :building', { building: query.building });
if (query.status) qb.andWhere('room.status = :status', { status: query.status });
const rows = await qb.groupBy('room.id').orderBy('room.roomNumber', 'ASC').limit(query.limit ?? 20).getRawMany<AgentRoomRow>();
return rows.map((row) => ({
...row,
id: Number(row.id), floor: row.floor == null ? null : Number(row.floor),
capacity: Number(row.capacity), occupiedBeds: Number(row.occupiedBeds || 0),
}));
}
async agentGetRoomOccupancySummary(query: { date?: string; building?: string; limit?: number }) {
const targetDate = query.date || this.getChinaDate(new Date());
const qb = this.repo
.createQueryBuilder('room')
.leftJoin(
Occupancy,
'occupancy',
'occupancy.roomId = room.id AND occupancy.checkInDate <= :targetDate AND (occupancy.checkOutDate IS NULL OR occupancy.checkOutDate > :targetDate)',
{ targetDate },
)
.select('room.id', 'roomId')
.addSelect('room.roomNumber', 'roomNumber')
.addSelect('room.building', 'building')
.addSelect('room.capacity', 'capacity')
.addSelect('COUNT(occupancy.id)', 'occupiedBeds')
.where('room.status != :archived', { archived: 'archived' });
if (query.building) qb.andWhere('room.building = :building', { building: query.building });
const rows = await qb.groupBy('room.id').orderBy('room.roomNumber', 'ASC').limit(query.limit ?? 50).getRawMany<AgentRoomOccupancyRow>();
return rows.map((row) => {
const capacity = Number(row.capacity || 0);
const occupiedBeds = Number(row.occupiedBeds || 0);
return { date: targetDate, roomId: Number(row.roomId), roomNumber: row.roomNumber, building: row.building, capacity, occupiedBeds, availableBeds: Math.max(0, capacity - occupiedBeds) };
});
}
async findOne(id: number) {
const room = await this.repo.findOne({ where: { id } });
if (!room) throw new NotFoundException('宿舍不存在');
@@ -306,6 +210,54 @@ export class RoomsService {
return { message: '已恢复' };
}
async purge(id: number) {
const room = await this.findOne(id);
if (room.status !== 'archived')
throw new BadRequestException('仅已归档宿舍可以永久删除,请先归档');
const [occupancyCount, expenseCount] = await Promise.all([
this.occRepo.count({ where: { roomId: id } }),
this.roomExpRepo.count({ where: { roomId: id } }),
]);
if (occupancyCount > 0) throw new BadRequestException('该宿舍存在入住记录,无法永久删除');
if (expenseCount > 0) throw new BadRequestException('该宿舍存在宿舍费用,无法永久删除');
await this.repo.delete(id);
return { message: '已永久删除宿舍(不可恢复)' };
}
async batchPurge(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的宿舍');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('宿舍 ID 无效');
}
const rooms = await this.repo.find({ where: { id: In(uniqueIds) } });
if (rooms.length !== uniqueIds.length) throw new NotFoundException('部分宿舍不存在');
const deleted: number[] = [];
const skipped: string[] = [];
for (const room of rooms) {
if (room.status !== 'archived') {
skipped.push(`${room.roomNumber}(未归档)`);
continue;
}
const [occupancyCount, expenseCount] = await Promise.all([
this.occRepo.count({ where: { roomId: room.id } }),
this.roomExpRepo.count({ where: { roomId: room.id } }),
]);
if (occupancyCount > 0 || expenseCount > 0) {
skipped.push(`${room.roomNumber}(存在关联数据)`);
continue;
}
await this.repo.delete(room.id);
deleted.push(room.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 };
}
async batchRestore(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的宿舍');
@@ -329,147 +281,16 @@ export class RoomsService {
}
return { message: `已批量恢复 ${restored} 间宿舍`, restored, skipped };
}
async getRoomVisual(asOf?: string) {
// asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。
const isHistorical = !!asOf;
const targetDate = asOf || this.getChinaDate(new Date());
// 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。
const rooms = await this.repo.find({
where: isHistorical ? {} : { status: Not('archived') },
order: { building: 'ASC', roomNumber: 'ASC' },
});
const occupancies = await this.occRepo.find({
where: occupancyWhereOnDate(targetDate),
relations: ['student', 'student.organization', 'responsibleOrganization', 'bed'],
order: { checkInDate: 'ASC' },
});
// 按roomId分组入住记录
const occMap = new Map<number, any[]>();
// days已住天数相对目标日期计算而非固定今天历史视图才准确。
const refTime = new Date(targetDate).getTime();
for (const occ of occupancies) {
if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []);
const checkIn = new Date(occ.checkInDate);
const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)));
occMap.get(occ.roomId)!.push({
studentId: occ.studentId,
occupancyId: occ.id,
studentName: occ.student?.name || '未知',
bedId: occ.bedId ?? null,
bedNumber: occ.bed?.bedNumber || null,
checkInDate: occ.checkInDate,
billingStartDate: occ.billingStartDate,
days,
organization: occ.student?.organization?.name || null,
supervisor: occ.student?.supervisor || null,
organizationId: occ.responsibleOrganizationId || null,
organizationName: occ.responsibleOrganization?.name || null,
organizationColor: occ.responsibleOrganization?.color || null,
});
}
// 获取各楼栋列表
const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))];
// 历史视图纳入了已归档房间,但只保留当时确实有人住的归档房间,避免空归档房间刷屏。
const visibleRooms = isHistorical
? rooms.filter((r) => r.status !== 'archived' || (occMap.get(r.id)?.length ?? 0) > 0)
: rooms;
// 批量获取床位统计
const allBeds = await this.bedRepo.find({
where: { roomId: In(visibleRooms.map((r) => r.id)) },
});
const bedMap = new Map<number, { total: number; occupied: number }>();
for (const bed of allBeds) {
if (!bedMap.has(bed.roomId)) bedMap.set(bed.roomId, { total: 0, occupied: 0 });
const entry = bedMap.get(bed.roomId)!;
entry.total++;
if (bed.status === 'occupied') entry.occupied++;
}
const inspectionMap = await this.inspectionsService.getByRoomsAndDate(
visibleRooms.map((room) => room.id),
targetDate,
);
return {
buildings,
rooms: visibleRooms.map((room) => {
const occ = occMap.get(room.id) || [];
const inspection = inspectionMap.get(room.id);
const inspectionByOccupancyId = new Map(
(inspection?.details || []).map((detail) => [detail.occupancyId, detail.status]),
);
const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))];
let orgLabel: string | null = null;
if (orgs.length > 0 && occ.length > 0) {
const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]);
orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`;
}
const organizationColors = [
...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean)),
];
const organizationColor: string | null =
organizationColors.length === 1 ? organizationColors[0] : null;
const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))];
return {
id: room.id,
roomNumber: room.roomNumber,
building: room.building,
floor: room.floor,
capacity: room.capacity,
status: room.status,
currentCount: occ.length,
totalBeds: bedMap.get(room.id)?.total ?? 0,
occupiedBeds: bedMap.get(room.id)?.occupied ?? 0,
occupants: occ.map((occupant) => ({
...occupant,
inspectionStatus: inspectionByOccupancyId.get(occupant.occupancyId) || null,
})),
inspection: inspection
? {
submitted: true,
inspectorId: inspection.inspectorId,
inspectorName: inspection.inspectorName,
source: inspection.source,
submittedAt: inspection.submittedAt,
}
: { submitted: false },
orgLabel,
organizationColor,
organizationIds,
};
}),
// 当前视图内出现过的负责机构,供筛选下拉使用
organizations: [
...new Map(
occupancies
.filter((o) => o.responsibleOrganizationId && o.responsibleOrganization)
.map((o) => [
o.responsibleOrganizationId,
{
id: o.responsibleOrganizationId,
name: o.responsibleOrganization.name,
color: o.responsibleOrganization.color || null,
},
]),
).values(),
].sort((a, b) => a.name.localeCompare(b.name)),
};
async agentSearchRooms(query: { building?: string; keyword?: string; status?: string; limit?: number }) {
return this.queries.agentSearchRooms(query);
}
private getChinaDate(now: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
async agentGetRoomOccupancySummary(query: { date?: string; building?: string; limit?: number }) {
return this.queries.agentGetRoomOccupancySummary(query);
}
async getRoomVisual(asOf?: string) {
return this.queries.getRoomVisual(asOf);
}
async batchImport(
@@ -483,212 +304,63 @@ export class RoomsService {
monthlyRate?: number;
}[],
) {
let imported = 0;
let skipped = 0;
for (const row of rows) {
if (!row.roomNumber || !row.roomNumber.trim()) {
skipped++;
continue;
}
const exists = await this.repo.findOne({ where: { roomNumber: row.roomNumber.trim() } });
if (exists) {
skipped++;
continue;
}
// 智能解析房间号
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
const room = await this.repo.save(
this.repo.create({
roomNumber: row.roomNumber.trim(),
building: row.building?.trim() || parsed.building || undefined,
floor: row.floor ?? parsed.floor,
capacity: row.capacity ?? parsed.capacity ?? 4,
roomType: row.roomType || parsed.roomType || undefined,
rentalCategory: row.rentalCategory || undefined,
monthlyRate: row.monthlyRate ?? undefined,
}),
);
await this.createDefaultBeds(room.id, room.capacity);
imported++;
}
return {
message: `成功导入 ${imported} 间宿舍,跳过 ${skipped} 条(重复或空行)`,
imported,
skipped,
};
return this.queries.batchImport(rows);
}
// ── 床位管理 ──
async getRoomBeds(roomId: number): Promise<Bed[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
return this.bedRepo.find({ where: { roomId, status: Not('archived') }, order: { bedNumber: 'ASC' } });
}
async getRoomAvailableBeds(roomId: number): Promise<Bed[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
return this.bedRepo.find({
where: { roomId, status: 'available' },
order: { bedNumber: 'ASC' },
});
}
async createBed(roomId: number, dto: CreateBedDto): Promise<Bed> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
await this.assertCanAddBeds(room, 1);
const existing = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } });
if (existing) throw new BadRequestException('该床位编号已存在');
const bed = this.bedRepo.create({ ...dto, roomId });
return this.bedRepo.save(bed);
}
async updateBed(roomId: number, id: number, dto: UpdateBedDto): Promise<Bed> {
const bed = await this.bedRepo.findOne({ where: { id, roomId } });
if (!bed) throw new NotFoundException('床位不存在');
// 不允许将 occupied 的床位改为 maintenance
if (dto.status === 'maintenance' && bed.status === 'occupied') {
throw new BadRequestException('该床位有人入住,请先退宿');
}
// 编号唯一性检查
if (dto.bedNumber && dto.bedNumber !== bed.bedNumber) {
const dup = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } });
if (dup) throw new BadRequestException('该床位编号已存在');
}
Object.assign(bed, dto);
return this.bedRepo.save(bed);
}
async deleteBed(roomId: number, id: number): Promise<void> {
const bed = await this.bedRepo.findOne({ where: { id, roomId } });
if (!bed) throw new NotFoundException('床位不存在');
if (bed.status === 'occupied') throw new BadRequestException('该床位有人入住,无法归档');
if (bed.status === 'archived') throw new BadRequestException('该床位已归档');
await this.bedRepo.update(id, { status: 'archived' });
}
async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise<Bed[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
const existing = await this.bedRepo.find({ where: { roomId, status: Not('archived') }, order: { bedNumber: 'ASC' } });
this.assertCanAddBedsFromCount(room, existing.length, dto.count);
const numbers = existing.map((b) => {
const match = b.bedNumber.match(/^\d+/);
return match ? parseInt(match[0]) : 0;
});
const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
const beds: Bed[] = [];
for (let i = 0; i < dto.count; i++) {
beds.push(this.bedRepo.create({ roomId, bedNumber: `${start + i}号床` }));
}
return this.bedRepo.save(beds);
}
private async createDefaultBeds(roomId: number, capacity: number): Promise<void> {
const count = Math.max(capacity ?? 0, 0);
if (count === 0) return;
const beds = Array.from({ length: count }, (_, index) =>
this.bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }),
);
await this.bedRepo.save(beds);
private createDefaultBeds(roomId: number, capacity: number): Promise<void> {
return this.queries.createDefaultBeds(roomId, capacity);
}
private getNextBedNumber(beds: Pick<Bed, 'bedNumber'>[]): number {
const numbers = beds.map((bed) => {
const match = bed.bedNumber.match(/^\d+/);
return match ? parseInt(match[0], 10) : 0;
});
return numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
return this.bedOps.getNextBedNumber(beds);
}
private async assertCanAddBeds(room: Room, count: number): Promise<void> {
const existingCount = await this.bedRepo.count({ where: { roomId: room.id } });
this.assertCanAddBedsFromCount(room, existingCount, count);
async getRoomBeds(roomId: number): Promise<Bed[]> {
return this.bedOps.getRoomBeds(roomId);
}
private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void {
const remaining = Math.max((room.capacity ?? 0) - existingCount, 0);
if (count > remaining) {
throw new BadRequestException(
`床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining}`,
);
}
async getRoomAvailableBeds(roomId: number): Promise<Bed[]> {
return this.bedOps.getRoomAvailableBeds(roomId);
}
// ── 柜子管理 ──
async createBed(roomId: number, dto: CreateBedDto): Promise<Bed> {
return this.bedOps.createBed(roomId, dto);
}
async updateBed(roomId: number, id: number, dto: UpdateBedDto): Promise<Bed> {
return this.bedOps.updateBed(roomId, id, dto);
}
async deleteBed(roomId: number, id: number): Promise<void> {
return this.bedOps.deleteBed(roomId, id);
}
async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise<Bed[]> {
return this.bedOps.batchCreateBeds(roomId, dto);
}
async getRoomLockers(roomId: number): Promise<Locker[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
return this.lockerRepo.find({ where: { roomId, status: Not('archived') }, order: { lockerNumber: 'ASC' } });
return this.bedOps.getRoomLockers(roomId);
}
async getRoomAvailableLockers(roomId: number): Promise<Locker[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
return this.lockerRepo.find({
where: { roomId, status: 'available' },
order: { lockerNumber: 'ASC' },
});
return this.bedOps.getRoomAvailableLockers(roomId);
}
async createLocker(roomId: number, dto: CreateLockerDto): Promise<Locker> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
const existing = await this.lockerRepo.findOne({
where: { roomId, lockerNumber: dto.lockerNumber },
});
if (existing) throw new BadRequestException('该柜子编号已存在');
const locker = this.lockerRepo.create({ ...dto, roomId });
return this.lockerRepo.save(locker);
return this.bedOps.createLocker(roomId, dto);
}
async updateLocker(roomId: number, id: number, dto: UpdateLockerDto): Promise<Locker> {
const locker = await this.lockerRepo.findOne({ where: { id, roomId } });
if (!locker) throw new NotFoundException('柜子不存在');
if (dto.status === 'maintenance' && locker.status === 'occupied') {
throw new BadRequestException('该柜子有人占用,请先释放');
}
if (dto.lockerNumber && dto.lockerNumber !== locker.lockerNumber) {
const dup = await this.lockerRepo.findOne({
where: { roomId, lockerNumber: dto.lockerNumber },
});
if (dup) throw new BadRequestException('该柜子编号已存在');
}
Object.assign(locker, dto);
return this.lockerRepo.save(locker);
return this.bedOps.updateLocker(roomId, id, dto);
}
async deleteLocker(roomId: number, id: number): Promise<void> {
const locker = await this.lockerRepo.findOne({ where: { id, roomId } });
if (!locker) throw new NotFoundException('柜子不存在');
if (locker.status === 'occupied') throw new BadRequestException('该柜子有人占用,无法归档');
if (locker.status === 'archived') throw new BadRequestException('该柜子已归档');
await this.lockerRepo.update(id, { status: 'archived' });
return this.bedOps.deleteLocker(roomId, id);
}
async batchCreateLockers(roomId: number, dto: BatchCreateLockerDto): Promise<Locker[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
const existing = await this.lockerRepo.find({
where: { roomId, status: Not('archived') },
order: { lockerNumber: 'ASC' },
});
const numbers = existing.map((b) => {
const match = b.lockerNumber.match(/^\d+/);
return match ? parseInt(match[0]) : 0;
});
const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
const lockers: Locker[] = [];
for (let i = 0; i < dto.count; i++) {
lockers.push(this.lockerRepo.create({ roomId, lockerNumber: `${start + i}号柜` }));
}
return this.lockerRepo.save(lockers);
return this.bedOps.batchCreateLockers(roomId, dto);
}
}
}

View File

@@ -0,0 +1,183 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ClassSchedule } from '../entities';
import type { WeeklyViewQueryDto } from './dto/schedule.dto';
const ACTIVE_SCHEDULE_STATUS = 'active';
@Injectable()
export class ScheduleQueriesService {
constructor(
@InjectRepository(ClassSchedule)
private readonly scheduleRepo: Repository<ClassSchedule>,
) {}
maskScheduleOccupancy(schedule: ClassSchedule) {
return {
id: null,
classId: null,
classroomId: schedule.classroomId,
weekDay: schedule.weekDay,
startTime: schedule.startTime,
endTime: schedule.endTime,
attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes,
startDate: schedule.startDate,
endDate: schedule.endDate,
subject: '已占用',
teacherId: null,
scheduleType: schedule.scheduleType,
status: schedule.status,
notes: null,
canViewDetails: false,
};
}
async agentSearchSchedules(
accessibleClassIds: number[] | undefined,
query?: { classId?: number; classroomId?: number; weekDay?: number; limit?: number },
): Promise<
{
id: number;
classId: number | null;
className: string | null;
classroomId: number;
classroomName: string | null;
weekDay: number;
startTime: string;
endTime: string;
subject: string;
teacherName: string | null;
startDate: string;
endDate: string;
scheduleType: string;
status: string;
}[]
> {
if (query?.classId && accessibleClassIds && !accessibleClassIds.includes(query.classId)) {
return [];
}
if (accessibleClassIds && accessibleClassIds.length === 0) {
return [];
}
const qb = this.scheduleRepo
.createQueryBuilder('cs')
.leftJoin('cs.class', 'class')
.leftJoin('cs.classroom', 'classroom')
.leftJoin('cs.teacher', 'teacher')
.select([
'cs.id',
'cs.classId',
'cs.classroomId',
'cs.weekDay',
'cs.startTime',
'cs.endTime',
'cs.subject',
'cs.teacherId',
'cs.startDate',
'cs.endDate',
'cs.scheduleType',
'cs.status',
'class.name',
'classroom.name',
'teacher.name',
])
.where('cs.status = :active', { active: 'active' });
if (query?.classroomId) {
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
}
if (query?.classId) {
qb.andWhere('cs.classId = :classId', { classId: query.classId });
}
if (accessibleClassIds) {
qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query?.weekDay) {
qb.andWhere('cs.weekDay = :weekDay', { weekDay: query.weekDay });
}
const rows = await qb
.orderBy('cs.weekDay', 'ASC')
.addOrderBy('cs.startTime', 'ASC')
.limit(Math.max(1, Math.min(query?.limit ?? 20, 50)))
.getRawMany<Record<string, unknown>>();
return rows.map((row) => ({
id: Number(row.cs_id),
classId: row.cs_class_id == null ? null : Number(row.cs_class_id),
className: row.class_name == null ? null : String(row.class_name),
classroomId: Number(row.cs_classroom_id),
classroomName: row.classroom_name == null ? null : String(row.classroom_name),
weekDay: Number(row.cs_week_day),
startTime: String(row.cs_start_time),
endTime: String(row.cs_end_time),
subject: String(row.cs_subject),
teacherName: row.teacher_name == null ? null : String(row.teacher_name),
startDate: String(row.cs_start_date),
endDate: String(row.cs_end_date),
scheduleType: String(row.cs_schedule_type),
status: String(row.cs_status),
}));
}
async getWeeklyView(query: WeeklyViewQueryDto, accessibleClassIds?: number[]) {
const qb = this.scheduleRepo.createQueryBuilder('cs');
if (query.classroomId) {
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
}
if (query.startDate) {
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
}
if (query.endDate) {
qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate });
}
const schedules = await qb
.andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS })
.orderBy('cs.weekDay', 'ASC')
.addOrderBy('cs.startTime', 'ASC')
.getMany();
const allowedClassIds = accessibleClassIds ? new Set(accessibleClassIds) : null;
const visibleSchedules = schedules.map((schedule) => {
const canViewDetails =
allowedClassIds === null ||
(schedule.classId !== null && allowedClassIds.has(schedule.classId));
if (canViewDetails) return { ...schedule, canViewDetails: true };
// Other classes remain visible only as a room/time occupancy block.
// Do not expose class, subject, teacher, notes, or internal record IDs.
return this.maskScheduleOccupancy(schedule);
});
// Group by classroomId → weekDay
const matrix: Record<number, Record<number, typeof visibleSchedules>> = {};
for (const schedule of visibleSchedules) {
if (!matrix[schedule.classroomId]) matrix[schedule.classroomId] = {};
if (!matrix[schedule.classroomId][schedule.weekDay])
matrix[schedule.classroomId][schedule.weekDay] = [];
matrix[schedule.classroomId][schedule.weekDay].push(schedule);
}
return matrix;
}
async getClassroomOccupancy(classroomId: number, date?: string) {
const qb = this.scheduleRepo
.createQueryBuilder('cs')
.where('cs.classroomId = :classroomId', { classroomId })
.andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS })
.andWhere('cs.scheduleType IN (:...scheduleTypes)', {
scheduleTypes: ['INTERNAL', 'RENTAL'],
});
if (date) {
qb.andWhere('cs.startDate <= :date', { date }).andWhere('cs.endDate >= :date', { date });
}
return qb.orderBy('cs.weekDay', 'ASC').addOrderBy('cs.startTime', 'ASC').getMany();
}
}

View File

@@ -22,6 +22,7 @@ import {
} from './dto/schedule.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { logAudit } from '../common/with-audit-log';
import { extractRequestInfo } from '../common/request-utils';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity';
@@ -170,17 +171,7 @@ export class SchedulesController {
dto.startDate,
dto.endDate,
);
const teacherIds = [
...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)),
];
if (teacherIds.length > 0) {
void this.notificationsService.create({
recipientIds: teacherIds,
type: NotificationType.SCHEDULE_CONFLICT,
title: '排课冲突',
content: `教室${dto.classroomId}${dto.weekDay} ${dto.startTime}-${dto.endTime} 与已有排课冲突`,
});
}
this.notifyScheduleConflict(conflicts, dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, '');
} catch {
// Best-effort conflict notification must not hide the original conflict.
}
@@ -189,6 +180,27 @@ export class SchedulesController {
}
}
private notifyScheduleConflict(
conflicts: Array<{ teacherId: number | null }>,
classroomId: number,
weekDay: number,
startTime: string,
endTime: string,
suffix: string,
): void {
const teacherIds = [
...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)),
];
if (teacherIds.length > 0) {
void this.notificationsService.create({
recipientIds: teacherIds,
type: NotificationType.SCHEDULE_CONFLICT,
title: '排课冲突',
content: `教室${classroomId}${weekDay} ${startTime}-${endTime} ${suffix}与已有排课冲突`,
});
}
}
@Put(':id')
@RequirePermission('schedule:edit')
async update(
@@ -226,17 +238,7 @@ export class SchedulesController {
existing.startDate,
existing.endDate,
);
const teacherIds = [
...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)),
];
if (teacherIds.length > 0) {
void this.notificationsService.create({
recipientIds: teacherIds,
type: NotificationType.SCHEDULE_CONFLICT,
title: '排课冲突',
content: `教室${existing.classroomId}${existing.weekDay} ${existing.startTime}-${existing.endTime} (更新) 与已有排课冲突`,
});
}
this.notifyScheduleConflict(conflicts, existing.classroomId, existing.weekDay, existing.startTime, existing.endTime, ' (更新)');
} catch {
// Best-effort conflict notification must not hide the original conflict.
}
@@ -251,18 +253,10 @@ export class SchedulesController {
@Param('id') id: string,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.getAuthorizedSchedule(+id, req as { user: RequestUser });
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '排课管理',
action: '停用排课',
targetId: +id,
targetType: 'class-schedule',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '排课管理', action: '停用排课', targetId: +id, targetType: 'class-schedule',
});
return result;
}

View File

@@ -9,6 +9,7 @@ import {
AttendanceSession,
} from '../entities';
import { SchedulesService } from './schedules.service';
import { ScheduleQueriesService } from './schedule-queries.service';
import { SchedulesController } from './schedules.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@@ -27,7 +28,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
NotificationsModule,
],
controllers: [SchedulesController],
providers: [SchedulesService],
providers: [SchedulesService, ScheduleQueriesService],
exports: [SchedulesService],
})
export class SchedulesModule {}

View File

@@ -1,4 +1,5 @@
import { SchedulesService } from './schedules.service';
import { ScheduleQueriesService } from './schedule-queries.service';
const createQb = () => ({
andWhere: jest.fn().mockReturnThis(),
@@ -20,6 +21,7 @@ function serviceWithAssignments(assignments: number[]) {
.fn()
.mockResolvedValue(assignments.map((classId) => ({ classId, userId: 7 }))),
};
const queries = new ScheduleQueriesService(scheduleRepo as never);
const service = new SchedulesService(
scheduleRepo as never,
{} as never,
@@ -27,6 +29,7 @@ function serviceWithAssignments(assignments: number[]) {
{} as never,
classTeacherRepo as never,
{} as never,
queries,
);
return { service, qb, scheduleRepo };
}
@@ -40,6 +43,9 @@ describe('SchedulesService — teacher class scope', () => {
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
await service.findAll({}, [3, 5]);
@@ -57,6 +63,9 @@ describe('SchedulesService — teacher class scope', () => {
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
await expect(service.findAll({}, [])).resolves.toEqual([]);
@@ -132,11 +141,15 @@ describe('SchedulesService — shared classroom occupancy visibility', () => {
notes: '其他班备注',
},
]);
const scheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
const service = new SchedulesService(
{ createQueryBuilder: jest.fn().mockReturnValue(qb) } as never,
scheduleRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
new ScheduleQueriesService(scheduleRepo as never),
);
const result = await service.getWeeklyView({}, [3]);

View File

@@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { BadRequestException, ConflictException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { SchedulesService } from './schedules.service';
import { ScheduleQueriesService } from './schedule-queries.service';
import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Class } from '../entities/class.entity';
@@ -38,6 +39,7 @@ describe('SchedulesService — getLookups', () => {
const module = await Test.createTestingModule({
providers: [
SchedulesService,
ScheduleQueriesService,
{
provide: getRepositoryToken(ClassSchedule),
useValue: { createQueryBuilder: jest.fn().mockReturnValue(scheduleQb) },
@@ -74,6 +76,7 @@ describe('SchedulesService — checkConflict', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SchedulesService,
ScheduleQueriesService,
{ provide: getRepositoryToken(ClassSchedule), useValue: mockRepo },
{ provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
@@ -223,6 +226,7 @@ describe('SchedulesService — getClassroomOccupancy', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SchedulesService,
ScheduleQueriesService,
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } },
@@ -303,6 +307,7 @@ describe('SchedulesService — remove/update status', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SchedulesService,
ScheduleQueriesService,
{
provide: getRepositoryToken(ClassSchedule),
useValue: scheduleRepoMock,
@@ -445,6 +450,7 @@ describe('SchedulesService — range boundaries', () => {
const makeService = () => {
const scheduleRepo = { create: jest.fn() };
return {
queries: new ScheduleQueriesService(scheduleRepo as never),
service: new SchedulesService(
scheduleRepo as never,
{} as never,

View File

@@ -6,7 +6,7 @@ import {
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Not, Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import {
ClassSchedule,
Class,
@@ -16,6 +16,7 @@ import {
ClassTeacher,
AttendanceSession,
} from '../entities';
import { ScheduleQueriesService } from './schedule-queries.service';
import {
CreateScheduleDto,
UpdateScheduleDto,
@@ -27,7 +28,10 @@ const SCHEDULE_GAP_MINUTES = 10;
const ACTIVE_SCHEDULE_STATUS = 'active';
const INACTIVE_SCHEDULE_STATUSES = ['inactive', 'cancelled'] as const;
type ScheduleStatus = typeof ACTIVE_SCHEDULE_STATUS | (typeof INACTIVE_SCHEDULE_STATUSES)[number];
const SCHEDULE_STATUSES: readonly ScheduleStatus[] = [ACTIVE_SCHEDULE_STATUS, ...INACTIVE_SCHEDULE_STATUSES];
const SCHEDULE_STATUSES: readonly ScheduleStatus[] = [
ACTIVE_SCHEDULE_STATUS,
...INACTIVE_SCHEDULE_STATUSES,
];
function shiftTime(time: string, minutes: number): string {
const [hours, minutePart] = time.split(':').map(Number);
@@ -50,6 +54,7 @@ export class SchedulesService {
private readonly classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(AttendanceSession)
private readonly attendanceSessionRepo: Repository<AttendanceSession>,
private readonly queries: ScheduleQueriesService,
) {}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
@@ -64,26 +69,6 @@ export class SchedulesService {
if (!assignment) throw new ForbiddenException('只能管理自己被分配班级的排课');
}
maskScheduleOccupancy(schedule: ClassSchedule) {
return {
id: null,
classId: null,
classroomId: schedule.classroomId,
weekDay: schedule.weekDay,
startTime: schedule.startTime,
endTime: schedule.endTime,
attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes,
startDate: schedule.startDate,
endDate: schedule.endDate,
subject: '已占用',
teacherId: null,
scheduleType: schedule.scheduleType,
status: schedule.status,
notes: null,
canViewDetails: false,
};
}
async getLookups(accessibleClassIds?: number[]) {
const classes = accessibleClassIds
? accessibleClassIds.length > 0
@@ -133,95 +118,6 @@ export class SchedulesService {
* Agent tool: 查询当前用户有权查看的排课,返回白名单字段。
* 教师范围按班级授课关系过滤。
*/
async agentSearchSchedules(
userId: number,
canManageAll: boolean,
query?: { classId?: number; classroomId?: number; weekDay?: number; limit?: number },
): Promise<
{
id: number;
classId: number | null;
className: string | null;
classroomId: number;
classroomName: string | null;
weekDay: number;
startTime: string;
endTime: string;
subject: string;
teacherName: string | null;
startDate: string;
endDate: string;
scheduleType: string;
status: string;
}[]
> {
const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll);
if (query?.classId && accessibleClassIds && !accessibleClassIds.includes(query.classId)) {
return [];
}
if (accessibleClassIds && accessibleClassIds.length === 0) {
return [];
}
const qb = this.scheduleRepo
.createQueryBuilder('cs')
.leftJoin('cs.class', 'class')
.leftJoin('cs.classroom', 'classroom')
.leftJoin('cs.teacher', 'teacher')
.select([
'cs.id',
'cs.classId',
'cs.classroomId',
'cs.weekDay',
'cs.startTime',
'cs.endTime',
'cs.subject',
'cs.teacherId',
'cs.startDate',
'cs.endDate',
'cs.scheduleType',
'cs.status',
'class.name',
'classroom.name',
'teacher.name',
])
.where('cs.status = :active', { active: 'active' });
if (query?.classroomId) {
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
}
if (query?.classId) {
qb.andWhere('cs.classId = :classId', { classId: query.classId });
}
if (accessibleClassIds) {
qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query?.weekDay) {
qb.andWhere('cs.weekDay = :weekDay', { weekDay: query.weekDay });
}
const rows = await qb
.orderBy('cs.weekDay', 'ASC')
.addOrderBy('cs.startTime', 'ASC')
.limit(Math.max(1, Math.min(query?.limit ?? 20, 50)))
.getRawMany<Record<string, unknown>>();
return rows.map((row) => ({
id: Number(row.cs_id),
classId: row.cs_class_id == null ? null : Number(row.cs_class_id),
className: row.class_name == null ? null : String(row.class_name),
classroomId: Number(row.cs_classroom_id),
classroomName: row.classroom_name == null ? null : String(row.classroom_name),
weekDay: Number(row.cs_week_day),
startTime: String(row.cs_start_time),
endTime: String(row.cs_end_time),
subject: String(row.cs_subject),
teacherName: row.teacher_name == null ? null : String(row.teacher_name),
startDate: String(row.cs_start_date),
endDate: String(row.cs_end_date),
scheduleType: String(row.cs_schedule_type),
status: String(row.cs_status),
}));
}
async getClassTeachers(classId: number) {
const teachers = await this.classTeacherRepo.find({
where: { classId },
@@ -331,6 +227,8 @@ export class SchedulesService {
const weekDay = dto.weekDay ?? existing.weekDay;
const startTime = dto.startTime ?? existing.startTime;
const endTime = dto.endTime ?? existing.endTime;
const startDate = dto.startDate ?? existing.startDate;
const endDate = dto.endDate ?? existing.endDate;
this.assertValidScheduleRange(startTime, endTime, startDate, endDate);
@@ -361,6 +259,26 @@ export class SchedulesService {
return this.findOne(id);
}
maskScheduleOccupancy(schedule: ClassSchedule) {
return this.queries.maskScheduleOccupancy(schedule);
}
async agentSearchSchedules(
userId: number,
canManageAll: boolean,
query?: { classId?: number; classroomId?: number; weekDay?: number; limit?: number },
) {
const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll);
return this.queries.agentSearchSchedules(accessibleClassIds, query);
}
async getWeeklyView(query: WeeklyViewQueryDto, accessibleClassIds?: number[]) {
return this.queries.getWeeklyView(query, accessibleClassIds);
}
async getClassroomOccupancy(classroomId: number, date?: string) {
return this.queries.getClassroomOccupancy(classroomId, date);
}
async remove(id: number) {
const schedule = await this.scheduleRepo.findOne({ where: { id } });
if (!schedule) throw new NotFoundException('排课记录不存在');
@@ -421,61 +339,4 @@ export class SchedulesService {
return conflicts;
}
async getWeeklyView(query: WeeklyViewQueryDto, accessibleClassIds?: number[]) {
const qb = this.scheduleRepo.createQueryBuilder('cs');
if (query.classroomId) {
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
}
if (query.startDate) {
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
}
if (query.endDate) {
qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate });
}
const schedules = await qb
.andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS })
.orderBy('cs.weekDay', 'ASC')
.addOrderBy('cs.startTime', 'ASC')
.getMany();
const allowedClassIds = accessibleClassIds ? new Set(accessibleClassIds) : null;
const visibleSchedules = schedules.map((schedule) => {
const canViewDetails =
allowedClassIds === null ||
(schedule.classId !== null && allowedClassIds.has(schedule.classId));
if (canViewDetails) return { ...schedule, canViewDetails: true };
// Other classes remain visible only as a room/time occupancy block.
// Do not expose class, subject, teacher, notes, or internal record IDs.
return this.maskScheduleOccupancy(schedule);
});
// Group by classroomId → weekDay
const matrix: Record<number, Record<number, typeof visibleSchedules>> = {};
for (const schedule of visibleSchedules) {
if (!matrix[schedule.classroomId]) matrix[schedule.classroomId] = {};
if (!matrix[schedule.classroomId][schedule.weekDay])
matrix[schedule.classroomId][schedule.weekDay] = [];
matrix[schedule.classroomId][schedule.weekDay].push(schedule);
}
return matrix;
}
async getClassroomOccupancy(classroomId: number, date?: string) {
const qb = this.scheduleRepo
.createQueryBuilder('cs')
.where('cs.classroomId = :classroomId', { classroomId })
.andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS })
.andWhere('cs.scheduleType IN (:...scheduleTypes)', {
scheduleTypes: ['INTERNAL', 'RENTAL'],
});
if (date) {
qb.andWhere('cs.startDate <= :date', { date }).andWhere('cs.endDate >= :date', { date });
}
return qb.orderBy('cs.weekDay', 'ASC').addOrderBy('cs.startTime', 'ASC').getMany();
}
}

View File

@@ -0,0 +1,233 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Student } from '../entities/student.entity';
import { ClassStudent } from '../entities/class-student.entity';
import type { StudentAccessScope } from './student-access-scope';
@Injectable()
export class StudentsAgentService {
/**
* Whitelisted output type for agent student searches.
* NEVER exposes phone, idNumber, emergencyContact, or emergencyPhone.
*/
private static readonly AGENT_STUDENT_SELECT = [
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'organization.name',
] as const;
constructor(
@InjectRepository(Student) private readonly repo: Repository<Student>,
@InjectRepository(ClassStudent)
private readonly classStudentRepo: Repository<ClassStudent>,
) {}
/**
* Search students with SQL-enforced scope, field whitelist, and limit.
*
* @param scope — data-range discriminator (manageAll or teacher).
* @param query — optional keyword, classId, organizationId, limit.
* @returns formatted whitelist-only results with classIds.
*/
async agentSearchStudents(
scope: StudentAccessScope,
query?: {
keyword?: string;
classId?: number;
organizationId?: number;
limit?: number;
},
): Promise<
{
id: number;
name: string;
studentNo: string;
gender: string;
status: string;
organizationId: number;
organizationName: string;
classIds: number[];
}[]
> {
const limit = Math.max(1, Math.min(query?.limit ?? 20, 50));
const qb = this.repo
.createQueryBuilder('student')
.distinct(true)
.select([
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'student.createdAt',
'organization.name',
])
.leftJoin('student.organization', 'organization');
this.applyStudentScope(qb, scope, query?.classId);
if (query?.keyword) {
qb.andWhere('(student.name LIKE :keyword OR student.student_no LIKE :keyword)', {
keyword: `%${query.keyword}%`,
});
}
if (query?.organizationId) {
qb.andWhere('student.organization_id = :orgId', { orgId: query.organizationId });
}
qb.orderBy('student.createdAt', 'DESC').take(limit);
const rows: Record<string, unknown>[] = await qb.getRawMany();
if (rows.length === 0) return [];
// Second bounded query: classIds only for the returned student ids.
// For teacher scope, the class filter MUST be re-applied so the
// teacher only sees classIds they are assigned to.
const studentIds = rows.map((r) => r.student_id as number);
const csQb = this.classStudentRepo
.createQueryBuilder('cs')
.select(['cs.studentId', 'cs.classId'])
.where('cs.student_id IN (:...ids)', { ids: studentIds })
.andWhere('cs.status = :status', { status: 'active' });
if (scope.type === 'teacher') {
csQb.andWhere(
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
{ scopeTeacherUserId: scope.userId },
);
}
const classRows = await csQb.getRawMany();
const classMap = new Map<number, number[]>();
for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) {
const sid = cr.cs_student_id;
if (!classMap.has(sid)) classMap.set(sid, []);
classMap.get(sid)!.push(cr.cs_class_id);
}
return rows.map((r) => ({
id: r.student_id as number,
name: r.student_name as string,
studentNo: (r.student_student_no as string) ?? '',
gender: (r.student_gender as string) ?? '',
status: r.student_status as string,
organizationId: r.student_organization_id as number,
organizationName: (r.organization_name as string) ?? '',
classIds: classMap.get(r.student_id as number) ?? [],
}));
}
/**
* Get single student basic info with SQL-enforced scope + whitelist.
* Returns `null` for students out of scope or non-existent (no leak).
*/
async agentGetStudentBasic(
scope: StudentAccessScope,
studentId: number,
): Promise<{
id: number;
name: string;
studentNo: string;
gender: string;
status: string;
organizationId: number;
organizationName: string;
classIds: number[];
} | null> {
const qb = this.repo
.createQueryBuilder('student')
.select([
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'organization.name',
])
.leftJoin('student.organization', 'organization')
.where('student.id = :studentId', { studentId });
this.applyStudentScope(qb, scope);
const row = await qb.getRawOne();
if (!row) return null;
// For teacher scope, re-apply class filter so teacher only sees
// classIds they are assigned to (not ALL active classIds of the student).
const csQb = this.classStudentRepo
.createQueryBuilder('cs')
.select(['cs.classId'])
.where('cs.student_id = :studentId', { studentId })
.andWhere('cs.status = :status', { status: 'active' });
if (scope.type === 'teacher') {
csQb.andWhere(
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
{ scopeTeacherUserId: scope.userId },
);
}
const classRows = await csQb.getRawMany();
return {
id: row.student_id as number,
name: row.student_name as string,
studentNo: (row.student_student_no as string) ?? '',
gender: (row.student_gender as string) ?? '',
status: row.student_status as string,
organizationId: row.student_organization_id as number,
organizationName: (row.organization_name as string) ?? '',
classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id),
};
}
/**
* Apply data-range scope to a student QueryBuilder.
*
* - `manageAll`: no restriction.
* - `teacher`: INNER JOIN ClassStudent → active students in the
* teacher's assigned classes (via ClassTeacher).
* - When `classId` is provided, it is ANDed with the scope
* (intersection) — the model cannot widen access.
*/
private applyStudentScope(
qb: ReturnType<typeof this.repo.createQueryBuilder>,
scope: StudentAccessScope,
classId?: number,
): void {
if (scope.type === 'manageAll') {
if (classId != null) {
qb.innerJoin(
'class_student',
'cs_scope',
'cs_scope.student_id = student.id AND cs_scope.class_id = :scopeClassId AND cs_scope.status = :scopeCsStatus',
{ scopeClassId: classId, scopeCsStatus: 'active' },
);
}
return;
}
// Teacher scope: active students in teacher's assigned classes
const teacherClause =
'cs_scope.student_id = student.id AND cs_scope.status = :scopeCsStatus AND cs_scope.class_id IN ' +
'(SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)';
qb.innerJoin('class_student', 'cs_scope', teacherClause, {
scopeTeacherUserId: scope.userId,
scopeCsStatus: 'active',
});
if (classId != null) {
qb.andWhere('cs_scope.class_id = :scopeClassId', { scopeClassId: classId });
}
}
}

View File

@@ -12,7 +12,6 @@ import {
Res,
UseInterceptors,
UploadedFile,
Inject,
ParseIntPipe,
UsePipes,
ValidationPipe,
@@ -20,17 +19,20 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Organization } from '../entities/organization.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { StudentsService } from './students.service';
import { CreateStudentDto, QueryStudentDto, UpdateStudentDto } from './dto/student.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
import type { AuthenticatedUser } from '../authorization';
import {
AuthorizationService,
CaslAction,
SubjectName,
type AuthenticatedUser,
} from '../authorization';
import * as ExcelJS from 'exceljs';
import {
createStudentImportTemplateWorkbook,
@@ -79,27 +81,17 @@ export class StudentsController {
@Get()
@RequirePermission('student:view')
async findAll(
@Query() query: QueryStudentDto,
@Request() req: AuthenticatedRequest,
) {
async findAll(@Query() query: QueryStudentDto, @Request() req: AuthenticatedRequest) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllStudents(req),
);
return this.service.findAll(
query,
classIds,
);
return this.service.findAll(query, classIds);
}
@Get('export')
@RequirePermission('student:export')
async exportExcel(
@Query() query: QueryStudentDto,
@Res() res?: Response,
@Request() req?: any,
) {
async exportExcel(@Query() query: QueryStudentDto, @Res() res?: Response, @Request() req?: any) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllStudents(req),
@@ -142,15 +134,8 @@ export class StudentsController {
admittedMajor: result?.admittedMajor || '',
});
}
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '导出学生',
detail: `导出 ${students.length} 名学生`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '导出学生', detail: `导出 ${students.length} 名学生`,
});
res!.setHeader(
'Content-Type',
@@ -183,18 +168,9 @@ export class StudentsController {
@Post()
@RequirePermission('student:create')
async create(@Body() dto: CreateStudentDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '新增学生',
targetId: result.id,
targetType: 'student',
detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`,
});
return result;
}
@@ -203,35 +179,23 @@ export class StudentsController {
@RequirePermission('student:edit')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRestore(dto.ids);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '批量恢复学生',
detail: `IDs: ${dto.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '批量恢复学生', detail: `IDs: ${dto.ids.join(',')}`,
});
return result;
}
@Put(':id')
@RequirePermission('student:edit')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStudentDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
async update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateStudentDto,
@Request() req: any,
) {
const result = await this.service.update(id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '编辑学生',
targetId: id,
targetType: 'student',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '编辑学生', targetId: id, targetType: 'student', detail: JSON.stringify(dto),
});
return result;
}
@@ -239,17 +203,9 @@ export class StudentsController {
@Delete(':id')
@RequirePermission('student:delete')
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '归档学生',
targetId: id,
targetType: 'student',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '归档学生', targetId: id, targetType: 'student',
});
return result;
}
@@ -257,16 +213,29 @@ export class StudentsController {
@Post('batch-delete')
@RequirePermission('student:delete')
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRemove(body.ids || []);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '批量归档学生',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '批量归档学生', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('student:purge')
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.purge(id);
await logAudit(this.logService, req, {
module: '学生管理', action: '永久删除学生', targetId: id, targetType: 'student', detail: '物理删除,不可恢复',
});
return result;
}
@Post('batch-permanent-delete')
@RequirePermission('student:purge')
async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) {
const result = await this.service.batchPurge(body.ids || []);
await logAudit(this.logService, req, {
module: '学生管理', action: '批量永久删除学生', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@@ -274,17 +243,9 @@ export class StudentsController {
@Put(':id/restore')
@RequirePermission('student:edit')
async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.restore(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '恢复学生',
targetId: id,
targetType: 'student',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '恢复学生', targetId: id, targetType: 'student',
});
return result;
}
@@ -293,9 +254,8 @@ export class StudentsController {
@RequirePermission('student:import')
@UseInterceptors(FileInterceptor('file'))
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
const importData = parseStudentImportWorkbook(workbook);
// Resolve organization names to IDs
for (const row of importData.students) {
@@ -309,14 +269,8 @@ export class StudentsController {
}
}
const result = await this.service.batchImport(importData);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '导入学生',
detail: result.message,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '导入学生', detail: result.message,
});
return result;
}
@@ -325,9 +279,8 @@ export class StudentsController {
@RequirePermission('student:import')
@UseInterceptors(FileInterceptor('file'))
async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
const importData = parseStudentImportWorkbook(workbook);
// Resolve organization names to IDs
for (const row of importData.students) {
@@ -339,14 +292,8 @@ export class StudentsController {
}
}
const result = await this.service.matchImport(importData);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '更新已有学生资料',
detail: result.message,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '更新已有学生资料', detail: result.message,
});
return result;
}

View File

@@ -0,0 +1,314 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
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';
import type {
ExamScoreImportRow,
LearningRecordImportRow,
StudentEnrollmentImportRow,
StudentImportRow,
StudentWorkbookImport,
} from './student-import';
import { getHostOrganizationId } from './students.organization';
@Injectable()
export class StudentsImportService {
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;
}
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,
};
}
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() } });
}
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,
};
}
private normalizeImportData(
importData: StudentWorkbookImport | StudentImportRow[],
): StudentWorkbookImport {
if (Array.isArray(importData)) {
return { students: importData, enrollments: [], examScores: [], learningRecords: [] };
}
return importData;
}
private normalizePhone(phone?: string) {
return phone?.trim() || '';
}
private sameValue(left?: string | number | null, right?: string | number | null) {
return String(left ?? '').trim() === String(right ?? '').trim();
}
private hasProfileData(row: StudentImportRow) {
return [
row.targetCollege,
row.targetMajor,
row.collegeSchool,
row.collegeMajor,
row.subjectDirection,
row.grade,
row.profileDate,
row.notes,
].some((value) => value !== undefined && String(value).trim() !== '');
}
private hasResultData(row: StudentImportRow) {
return [
row.cultureFinalScore,
row.professionalFinalScore,
row.admissionStatus,
row.admittedCollege,
row.admittedMajor,
].some((value) => value !== undefined && String(value).trim() !== '');
}
private async importArchiveData(
studentId: number,
row: StudentImportRow,
data: StudentWorkbookImport,
) {
const phone = this.normalizePhone(row.phone);
let imported = 0;
if (this.hasProfileData(row)) {
await this.upsertProfileFromImport(studentId, row);
imported++;
}
if (this.hasResultData(row)) {
await this.upsertResultFromImport(studentId, row);
imported++;
}
if (!phone) return imported;
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);
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)) {
imported++;
}
}
for (const learningRow of data.learningRecords.filter(
(item) => this.normalizePhone(item.phone) === phone,
)) {
if (await this.upsertLearningRecordFromImport(studentId, learningRow)) {
imported++;
}
}
return imported;
}
private async upsertProfileFromImport(studentId: number, row: StudentImportRow) {
const entity =
(await this.profileRepo.findOne({ where: { studentId } })) ||
this.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.notes?.trim()) entity.notes = row.notes.trim();
await this.profileRepo.save(entity);
}
private async upsertResultFromImport(studentId: number, row: StudentImportRow) {
const entity =
(await this.resultRepo.findOne({ where: { studentId } })) ||
this.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);
}
private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) {
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 });
entity.courseCategory = row.courseCategory.trim();
entity.classType = row.classType.trim();
if (row.className?.trim()) entity.className = row.className.trim();
if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim();
if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim();
if (row.startDate?.trim()) entity.startDate = row.startDate.trim();
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);
}
private async upsertExamScoreFromImport(
studentId: number,
row: ExamScoreImportRow,
enrollmentByClassName: Map<string, StudentEnrollment>,
) {
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 });
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.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);
return true;
}
private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) {
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 });
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);
return true;
}
}

View File

@@ -0,0 +1,235 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { Student } from '../entities/student.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { PersonalExpense } from '../entities/personal-expense.entity';
import { Bill } from '../entities/bill.entity';
import { Deposit } from '../entities/deposit.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 { ArchiveAttachment } from '../entities/archive-attachment.entity';
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
import { StudentWallet } from '../entities/student-wallet.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
@Injectable()
export class StudentsLifecycleService {
constructor(
@InjectRepository(Student) private readonly repo: Repository<Student>,
@InjectRepository(ClassStudent) private readonly classStudentRepo: Repository<ClassStudent>,
@InjectRepository(AttendanceRecord)
private readonly attendanceRepo: Repository<AttendanceRecord>,
@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(Occupancy) private readonly occupancyRepo: Repository<Occupancy>,
@InjectRepository(PersonalExpense)
private readonly personalExpenseRepo: Repository<PersonalExpense>,
@InjectRepository(Bill) private readonly billRepo: Repository<Bill>,
@InjectRepository(Deposit) private readonly depositRepo: Repository<Deposit>,
@InjectRepository(ArchiveAttachment)
private readonly attachmentRepo: Repository<ArchiveAttachment>,
@InjectRepository(StudentDingMapping)
private readonly dingMappingRepo: Repository<StudentDingMapping>,
@InjectRepository(StudentWallet) private readonly walletRepo: Repository<StudentWallet>,
@InjectRepository(RoomInspectionDetail)
private readonly inspectionDetailRepo: Repository<RoomInspectionDetail>,
) {}
private async findOne(id: number) {
const student = await this.repo.findOne({
where: { id },
relations: ['occupancies', 'occupancies.room'],
});
if (!student) throw new NotFoundException('学生不存在');
return student;
}
async getArchiveExportMaps(studentIds: number[]) {
if (studentIds.length === 0) {
return {
profiles: new Map<number, StudentProfile>(),
results: new Map<number, ResultArchive>(),
};
}
const [profiles, results] = await Promise.all([
this.profileRepo.find({ where: { studentId: In(studentIds) } }),
this.resultRepo.find({ where: { studentId: In(studentIds) } }),
]);
return {
profiles: new Map(profiles.map((profile) => [profile.studentId, profile])),
results: new Map(results.map((result) => [result.studentId, result])),
};
}
async batchRemove(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的学生');
const students = await this.repo.find({ where: { id: In(ids) } });
const skipped: string[] = [];
const targetIds: number[] = [];
for (const s of students) {
if (s.status === 'archived') skipped.push(s.name);
else targetIds.push(s.id);
}
let affected = 0;
if (targetIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
affected = result.affected || 0;
}
const message =
skipped.length > 0
? `成功归档 ${affected} 人;${skipped.length} 人已是归档状态被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已批量归档 ${affected} 人(数据已保留,可随时恢复)`;
return { message, archived: affected, skipped: skipped.length };
}
async restore(id: number) {
const student = await this.findOne(id);
if (student.status !== 'archived') {
throw new BadRequestException('该学生未被归档');
}
await this.repo.update(id, { status: 'active' });
return { message: '已恢复' };
}
private async assertNoStudentReferences(studentId: number) {
const [
occupancyCount,
personalExpenseCount,
billCount,
depositCount,
classMemberCount,
profileCount,
enrollmentCount,
examScoreCount,
learningRecordCount,
attachmentCount,
resultCount,
attendanceCount,
dingMappingCount,
walletCount,
inspectionDetailCount,
] = await Promise.all([
this.occupancyRepo.count({ where: { studentId } }),
this.personalExpenseRepo.count({ where: { studentId } }),
this.billRepo.count({ where: { studentId } }),
this.depositRepo.count({ where: { studentId } }),
this.classStudentRepo.count({ where: { studentId } }),
this.profileRepo.count({ where: { studentId } }),
this.enrollmentRepo.count({ where: { studentId } }),
this.examScoreRepo.count({ where: { studentId } }),
this.learningRecordRepo.count({ where: { studentId } }),
this.attachmentRepo.count({ where: { studentId } }),
this.resultRepo.count({ where: { studentId } }),
this.attendanceRepo.count({ where: { studentId } }),
this.dingMappingRepo.count({ where: { studentId } }),
this.walletRepo.count({ where: { studentId } }),
this.inspectionDetailRepo.count({ where: { studentId } }),
]);
const refs: Array<[string, number]> = [
['入住记录', occupancyCount],
['个人费用', personalExpenseCount],
['账单', billCount],
['押金', depositCount],
['班级成员', classMemberCount],
['档案信息', profileCount],
['报名记录', enrollmentCount],
['考试成绩', examScoreCount],
['学习记录', learningRecordCount],
['档案附件', attachmentCount],
['录取结果', resultCount],
['考勤记录', attendanceCount],
['钉钉映射', dingMappingCount],
['学生钱包', walletCount],
['查寝明细', inspectionDetailCount],
];
const references = refs.filter(([, count]) => count > 0);
if (references.length > 0) {
const names = references.map(([name]) => name).join('、');
throw new BadRequestException(`该学生存在关联数据(${names}),无法永久删除`);
}
}
async purge(id: number) {
const student = await this.findOne(id);
if (student.status !== 'archived') {
throw new BadRequestException('仅已归档学生可以永久删除,请先归档');
}
await this.assertNoStudentReferences(id);
await this.repo.delete(id);
return { message: '已永久删除学生(不可恢复)' };
}
async batchPurge(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的学生');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('学生 ID 无效');
}
const students = await this.repo.find({ where: { id: In(uniqueIds) } });
if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在');
const deleted: number[] = [];
const skipped: string[] = [];
for (const student of students) {
if (student.status !== 'archived') {
skipped.push(`${student.name}(未归档)`);
continue;
}
try {
await this.assertNoStudentReferences(student.id);
} catch {
skipped.push(`${student.name}(存在关联数据)`);
continue;
}
await this.repo.delete(student.id);
deleted.push(student.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 };
}
async batchRestore(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的学生');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('学生 ID 无效');
}
const students = await this.repo.find({ where: { id: In(uniqueIds) } });
if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在');
const targetIds = students
.filter((student) => student.status === 'archived')
.map((student) => student.id);
const skipped = students.length - targetIds.length;
let restored = 0;
if (targetIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'active' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
restored = result.affected || 0;
}
return { message: `已批量恢复 ${restored} 名学生`, restored, skipped };
}
}

View File

@@ -11,6 +11,14 @@ 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 { Occupancy } from '../entities/occupancy.entity';
import { PersonalExpense } from '../entities/personal-expense.entity';
import { Bill } from '../entities/bill.entity';
import { Deposit } from '../entities/deposit.entity';
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
import { StudentWallet } from '../entities/student-wallet.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
import { StudentsService } from './students.service';
import { StudentAccessScopeFactory } from './student-access-scope.factory';
import { StudentsController } from './students.controller';
@@ -29,6 +37,14 @@ import { StudentsController } from './students.controller';
ExamScore,
LearningRecord,
ResultArchive,
Occupancy,
PersonalExpense,
Bill,
Deposit,
ArchiveAttachment,
StudentDingMapping,
StudentWallet,
RoomInspectionDetail,
]),
],
controllers: [StudentsController],

View File

@@ -0,0 +1,21 @@
import { BadRequestException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { Organization } from '../entities/organization.entity';
export async function assertActiveOrganization(
organizationRepo: Repository<Organization>,
id: number,
): Promise<void> {
const organization = await organizationRepo.findOne({ where: { id, status: 'active' } });
if (!organization) throw new BadRequestException('所属机构不存在或已归档');
}
export async function getHostOrganizationId(
organizationRepo: Repository<Organization>,
): Promise<number> {
const organization = await organizationRepo.findOne({
where: { isHost: true, status: 'active' },
});
if (!organization) throw new BadRequestException('尚未配置本机构');
return organization.id;
}

View File

@@ -0,0 +1,31 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { StudentsController } from './students.controller';
describe('StudentsController purge routes', () => {
it('requires student:purge on permanent delete routes', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, StudentsController.prototype.purge)).toEqual([
'student:purge',
]);
expect(
Reflect.getMetadata(PERMISSION_KEY, StudentsController.prototype.batchPurge),
).toEqual(['student:purge']);
});
it('writes permanent delete audit logs', async () => {
const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除学生(不可恢复)' }) };
const log = jest.fn().mockResolvedValue(undefined);
const controller = new StudentsController(
service as never,
{ log } as never,
{} as never,
{} as never,
);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge(1, req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '学生管理', action: '永久删除学生', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,77 @@
import { BadRequestException } from '@nestjs/common';
import { StudentsService } from './students.service';
const student = { id: 1, name: '张三', status: 'archived' };
const createService = (overrides?: {
student?: Record<string, unknown>;
counts?: Record<string, number>;
}) => {
const counts = overrides?.counts ?? {};
const countFor = (key: string) => jest.fn().mockResolvedValue(counts[key] ?? 0);
const repo = {
findOne: jest.fn().mockResolvedValue(overrides?.student ?? student),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
find: jest.fn().mockResolvedValue([overrides?.student ?? student]),
};
const occupancyCount = countFor('occupancy');
const service = new StudentsService(
repo as never,
{ count: countFor('classStudent') } as never,
{} as never,
{ count: countFor('attendance') } as never,
{} as never,
{} as never,
{ count: countFor('profile') } as never,
{ count: countFor('enrollment') } as never,
{ count: countFor('examScore') } as never,
{ count: countFor('learningRecord') } as never,
{ count: countFor('result') } as never,
{ count: occupancyCount } as never,
{ count: countFor('personalExpense') } as never,
{ count: countFor('bill') } as never,
{ count: countFor('deposit') } as never,
{ count: countFor('attachment') } as never,
{ count: countFor('dingMapping') } as never,
{ count: countFor('wallet') } as never,
{ count: countFor('inspectionDetail') } as never,
);
return { service, repo, occupancyCount };
};
describe('StudentsService.purge', () => {
it('rejects students that are not archived', async () => {
const { service, repo } = createService({ student: { id: 1, name: '张三', status: 'active' } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已归档学生可以永久删除,请先归档'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('rejects students with any reference', async () => {
const { service, repo } = createService({ counts: { occupancy: 2 } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('该学生存在关联数据(入住记录),无法永久删除'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('deletes an archived student with no references', async () => {
const { service, repo } = createService();
await expect(service.purge(1)).resolves.toEqual({ message: '已永久删除学生(不可恢复)' });
expect(repo.delete).toHaveBeenCalledWith(1);
});
it('batch purge returns deleted and skipped counts', async () => {
const { service, repo, occupancyCount } = createService();
repo.find = jest.fn().mockResolvedValue([
{ id: 1, name: '甲', status: 'archived' },
{ id: 2, name: '乙', status: 'archived' },
{ id: 3, name: '丙', status: 'active' },
]);
occupancyCount.mockResolvedValueOnce(1).mockResolvedValue(0);
const result = await service.batchPurge([1, 2, 3]);
expect(result).toMatchObject({ deleted: 1, skipped: 2 });
expect(repo.delete).toHaveBeenCalledWith(2);
});
});

View File

@@ -1,29 +1,37 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, Not, In, FindOptionsWhere, IsNull } from 'typeorm';
import { Like, Not, In, FindOptionsWhere, IsNull, Repository } from 'typeorm';
import { Student } from '../entities/student.entity';
import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { Organization } from '../entities/organization.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { PersonalExpense } from '../entities/personal-expense.entity';
import { Bill } from '../entities/bill.entity';
import { Deposit } from '../entities/deposit.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 { ArchiveAttachment } from '../entities/archive-attachment.entity';
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
import { StudentWallet } from '../entities/student-wallet.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
import type {
ExamScoreImportRow,
LearningRecordImportRow,
StudentEnrollmentImportRow,
StudentImportRow,
StudentWorkbookImport,
} from './student-import';
import type { StudentAccessScope } from './student-access-scope';
import { assertActiveOrganization } from './students.organization';
import { StudentsImportService } from './students.import.service';
import { StudentsLifecycleService } from './students.lifecycle.service';
import { StudentsAgentService } from './students.agent.service';
@Injectable()
export class StudentsService {
private importService?: StudentsImportService;
private lifecycleService?: StudentsLifecycleService;
private agentService?: StudentsAgentService;
constructor(
@InjectRepository(Student) private repo: Repository<Student>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@@ -36,8 +44,63 @@ export class StudentsService {
@InjectRepository(ExamScore) private examScoreRepo: Repository<ExamScore>,
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
@InjectRepository(Occupancy) private occupancyRepo: Repository<Occupancy>,
@InjectRepository(PersonalExpense) private personalExpenseRepo: Repository<PersonalExpense>,
@InjectRepository(Bill) private billRepo: Repository<Bill>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
@InjectRepository(StudentDingMapping) private dingMappingRepo: Repository<StudentDingMapping>,
@InjectRepository(StudentWallet) private walletRepo: Repository<StudentWallet>,
@InjectRepository(RoomInspectionDetail)
private inspectionDetailRepo: Repository<RoomInspectionDetail>,
) {}
private get imports(): StudentsImportService {
if (!this.importService) {
this.importService = new StudentsImportService(
this.repo,
this.profileRepo,
this.enrollmentRepo,
this.examScoreRepo,
this.learningRecordRepo,
this.resultRepo,
this.organizationRepo,
);
}
return this.importService;
}
private get lifecycle(): StudentsLifecycleService {
if (!this.lifecycleService) {
this.lifecycleService = new StudentsLifecycleService(
this.repo,
this.classStudentRepo,
this.attendanceRepo,
this.profileRepo,
this.enrollmentRepo,
this.examScoreRepo,
this.learningRecordRepo,
this.resultRepo,
this.occupancyRepo,
this.personalExpenseRepo,
this.billRepo,
this.depositRepo,
this.attachmentRepo,
this.dingMappingRepo,
this.walletRepo,
this.inspectionDetailRepo,
);
}
return this.lifecycleService;
}
private get agents(): StudentsAgentService {
if (!this.agentService) {
this.agentService = new StudentsAgentService(this.repo, this.classStudentRepo);
}
return this.agentService;
}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
@@ -52,21 +115,8 @@ export class StudentsService {
});
}
async getArchiveExportMaps(studentIds: number[]) {
if (studentIds.length === 0) {
return {
profiles: new Map<number, StudentProfile>(),
results: new Map<number, ResultArchive>(),
};
}
const [profiles, results] = await Promise.all([
this.profileRepo.find({ where: { studentId: In(studentIds) } }),
this.resultRepo.find({ where: { studentId: In(studentIds) } }),
]);
return {
profiles: new Map(profiles.map((profile) => [profile.studentId, profile])),
results: new Map(results.map((result) => [result.studentId, result])),
};
async getArchiveExportMaps(...args: Parameters<StudentsLifecycleService['getArchiveExportMaps']>) {
return this.lifecycle.getArchiveExportMaps(...args);
}
async findAll(
@@ -164,13 +214,13 @@ export class StudentsService {
}
async create(dto: CreateStudentDto) {
await this.assertActiveOrganization(dto.organizationId);
await assertActiveOrganization(this.organizationRepo, dto.organizationId);
return this.repo.save(this.repo.create(dto));
}
async update(id: number, dto: UpdateStudentDto) {
await this.findOne(id);
if (dto.organizationId) await this.assertActiveOrganization(dto.organizationId);
if (dto.organizationId) await assertActiveOrganization(this.organizationRepo, dto.organizationId);
await this.repo.update(id, dto);
return this.repo.findOne({ where: { id } });
}
@@ -184,345 +234,32 @@ export class StudentsService {
return { message: '已归档(数据已保留,可随时恢复)' };
}
async batchRemove(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的学生');
const students = await this.repo.find({ where: { id: In(ids) } });
const skipped: string[] = [];
const targetIds: number[] = [];
for (const s of students) {
if (s.status === 'archived') skipped.push(s.name);
else targetIds.push(s.id);
}
let affected = 0;
if (targetIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
affected = result.affected || 0;
}
const message =
skipped.length > 0
? `成功归档 ${affected} 人;${skipped.length} 人已是归档状态被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已批量归档 ${affected} 人(数据已保留,可随时恢复)`;
return { message, archived: affected, skipped: skipped.length };
async batchRemove(...args: Parameters<StudentsLifecycleService['batchRemove']>) {
return this.lifecycle.batchRemove(...args);
}
async restore(id: number) {
const student = await this.findOne(id);
if (student.status !== 'archived') {
throw new BadRequestException('该学生未被归档');
}
await this.repo.update(id, { status: 'active' });
return { message: '已恢复' };
async restore(...args: Parameters<StudentsLifecycleService['restore']>) {
return this.lifecycle.restore(...args);
}
async batchRestore(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的学生');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('学生 ID 无效');
}
const students = await this.repo.find({ where: { id: In(uniqueIds) } });
if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在');
const targetIds = students.filter((student) => student.status === 'archived').map((student) => student.id);
const skipped = students.length - targetIds.length;
let restored = 0;
if (targetIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'active' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
restored = result.affected || 0;
}
return { message: `已批量恢复 ${restored} 名学生`, restored, skipped };
async purge(...args: Parameters<StudentsLifecycleService['purge']>) {
return this.lifecycle.purge(...args);
}
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;
}
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 this.getHostOrganizationId()),
}),
);
archiveImported += await this.importArchiveData(student.id, row, data);
imported++;
}
return {
message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`,
imported,
archiveImported,
skipped,
};
async batchPurge(...args: Parameters<StudentsLifecycleService['batchPurge']>) {
return this.lifecycle.batchPurge(...args);
}
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() } });
}
if (!student) {
skipped++;
continue;
}
// Update matched student with non-empty imported fields
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,
};
async batchRestore(...args: Parameters<StudentsLifecycleService['batchRestore']>) {
return this.lifecycle.batchRestore(...args);
}
private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport {
if (Array.isArray(importData)) {
return { students: importData, enrollments: [], examScores: [], learningRecords: [] };
}
return importData;
async batchImport(...args: Parameters<StudentsImportService['batchImport']>) {
return this.imports.batchImport(...args);
}
private normalizePhone(phone?: string) {
return phone?.trim() || '';
}
private sameValue(left?: string | number | null, right?: string | number | null) {
return String(left ?? '').trim() === String(right ?? '').trim();
}
private hasProfileData(row: StudentImportRow) {
return [
row.targetCollege,
row.targetMajor,
row.collegeSchool,
row.collegeMajor,
row.subjectDirection,
row.grade,
row.profileDate,
row.notes,
].some((value) => value !== undefined && String(value).trim() !== '');
}
private hasResultData(row: StudentImportRow) {
return [
row.cultureFinalScore,
row.professionalFinalScore,
row.admissionStatus,
row.admittedCollege,
row.admittedMajor,
].some((value) => value !== undefined && String(value).trim() !== '');
}
private async importArchiveData(
studentId: number,
row: StudentImportRow,
data: StudentWorkbookImport,
) {
const phone = this.normalizePhone(row.phone);
let imported = 0;
if (this.hasProfileData(row)) {
await this.upsertProfileFromImport(studentId, row);
imported++;
}
if (this.hasResultData(row)) {
await this.upsertResultFromImport(studentId, row);
imported++;
}
if (!phone) return imported;
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);
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)) {
imported++;
}
}
for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) {
if (await this.upsertLearningRecordFromImport(studentId, learningRow)) {
imported++;
}
}
return imported;
}
private async upsertProfileFromImport(studentId: number, row: StudentImportRow) {
const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.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.notes?.trim()) entity.notes = row.notes.trim();
await this.profileRepo.save(entity);
}
private async upsertResultFromImport(studentId: number, row: StudentImportRow) {
const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.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);
}
private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) {
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 });
entity.courseCategory = row.courseCategory.trim();
entity.classType = row.classType.trim();
if (row.className?.trim()) entity.className = row.className.trim();
if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim();
if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim();
if (row.startDate?.trim()) entity.startDate = row.startDate.trim();
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);
}
private async upsertExamScoreFromImport(
studentId: number,
row: ExamScoreImportRow,
enrollmentByClassName: Map<string, StudentEnrollment>,
) {
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 });
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.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);
return true;
}
private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) {
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 });
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);
return true;
}
private async assertActiveOrganization(id: number) {
const organization = await this.organizationRepo.findOne({ where: { id, status: 'active' } });
if (!organization) throw new BadRequestException('所属机构不存在或已归档');
}
private async getHostOrganizationId() {
const organization = await this.organizationRepo.findOne({
where: { isHost: true, status: 'active' },
});
if (!organization) throw new BadRequestException('尚未配置本机构');
return organization.id;
async matchImport(...args: Parameters<StudentsImportService['matchImport']>) {
return this.imports.matchImport(...args);
}
async compareClasses(studentId: number) {
@@ -581,228 +318,15 @@ export class StudentsService {
return { student, enrollments: comparison };
}
// -------------------------------------------------------------------------
// Agent-safe query APIs — SQL-level scope + field whitelist
// -------------------------------------------------------------------------
/**
* Whitelisted output type for agent student searches.
* NEVER exposes phone, idNumber, emergencyContact, or emergencyPhone.
*/
private static readonly AGENT_STUDENT_SELECT = [
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'organization.name',
] as const;
/**
* Search students with SQL-enforced scope, field whitelist, and limit.
*
* @param scope — data-range discriminator (manageAll or teacher).
* @param query — optional keyword, classId, organizationId, limit.
* @returns formatted whitelist-only results with classIds.
*/
async agentSearchStudents(
scope: StudentAccessScope,
query?: {
keyword?: string;
classId?: number;
organizationId?: number;
limit?: number;
},
): Promise<
{
id: number;
name: string;
studentNo: string;
gender: string;
status: string;
organizationId: number;
organizationName: string;
classIds: number[];
}[]
> {
const limit = Math.max(1, Math.min(query?.limit ?? 20, 50));
const qb = this.repo
.createQueryBuilder('student')
.distinct(true)
.select([
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'student.createdAt',
'organization.name',
])
.leftJoin('student.organization', 'organization');
// ---- Scope enforcement ----
this.applyStudentScope(qb, scope, query?.classId);
// ---- Filters ----
if (query?.keyword) {
qb.andWhere(
'(student.name LIKE :keyword OR student.student_no LIKE :keyword)',
{ keyword: `%${query.keyword}%` },
);
}
if (query?.organizationId) {
qb.andWhere('student.organization_id = :orgId', { orgId: query.organizationId });
}
qb.orderBy('student.createdAt', 'DESC').take(limit);
const rows: Record<string, unknown>[] = await qb.getRawMany();
if (rows.length === 0) return [];
// Second bounded query: classIds only for the returned student ids.
// For teacher scope, the class filter MUST be re-applied so the
// teacher only sees classIds they are assigned to.
const studentIds = rows.map((r) => r.student_id as number);
const csQb = this.classStudentRepo
.createQueryBuilder('cs')
.select(['cs.studentId', 'cs.classId'])
.where('cs.student_id IN (:...ids)', { ids: studentIds })
.andWhere('cs.status = :status', { status: 'active' });
if (scope.type === 'teacher') {
csQb.andWhere(
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
{ scopeTeacherUserId: scope.userId },
);
}
const classRows = await csQb.getRawMany();
const classMap = new Map<number, number[]>();
for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) {
const sid = cr.cs_student_id;
if (!classMap.has(sid)) classMap.set(sid, []);
classMap.get(sid)!.push(cr.cs_class_id);
}
return rows.map((r) => ({
id: r.student_id as number,
name: r.student_name as string,
studentNo: (r.student_student_no as string) ?? '',
gender: (r.student_gender as string) ?? '',
status: r.student_status as string,
organizationId: r.student_organization_id as number,
organizationName: (r.organization_name as string) ?? '',
classIds: classMap.get(r.student_id as number) ?? [],
}));
...args: Parameters<StudentsAgentService['agentSearchStudents']>
) {
return this.agents.agentSearchStudents(...args);
}
/**
* Get single student basic info with SQL-enforced scope + whitelist.
* Returns `null` for students out of scope or non-existent (no leak).
*/
async agentGetStudentBasic(
scope: StudentAccessScope,
studentId: number,
): Promise<{
id: number;
name: string;
studentNo: string;
gender: string;
status: string;
organizationId: number;
organizationName: string;
classIds: number[];
} | null> {
const qb = this.repo
.createQueryBuilder('student')
.select([
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'organization.name',
])
.leftJoin('student.organization', 'organization')
.where('student.id = :studentId', { studentId });
this.applyStudentScope(qb, scope);
const row = await qb.getRawOne();
if (!row) return null;
// For teacher scope, re-apply class filter so teacher only sees
// classIds they are assigned to (not ALL active classIds of the student).
const csQb = this.classStudentRepo
.createQueryBuilder('cs')
.select(['cs.classId'])
.where('cs.student_id = :studentId', { studentId })
.andWhere('cs.status = :status', { status: 'active' });
if (scope.type === 'teacher') {
csQb.andWhere(
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
{ scopeTeacherUserId: scope.userId },
);
}
const classRows = await csQb.getRawMany();
return {
id: row.student_id as number,
name: row.student_name as string,
studentNo: (row.student_student_no as string) ?? '',
gender: (row.student_gender as string) ?? '',
status: row.student_status as string,
organizationId: row.student_organization_id as number,
organizationName: (row.organization_name as string) ?? '',
classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id),
};
}
/**
* Apply data-range scope to a student QueryBuilder.
*
* - `manageAll`: no restriction.
* - `teacher`: INNER JOIN ClassStudent → active students in the
* teacher's assigned classes (via ClassTeacher).
* - When `classId` is provided, it is ANDed with the scope
* (intersection) — the model cannot widen access.
*/
private applyStudentScope(
qb: ReturnType<typeof this.repo.createQueryBuilder>,
scope: StudentAccessScope,
classId?: number,
): void {
if (scope.type === 'manageAll') {
if (classId != null) {
qb.innerJoin(
'class_student',
'cs_scope',
'cs_scope.student_id = student.id AND cs_scope.class_id = :scopeClassId AND cs_scope.status = :scopeCsStatus',
{ scopeClassId: classId, scopeCsStatus: 'active' },
);
}
return;
}
// Teacher scope: active students in teacher's assigned classes
const teacherClause =
'cs_scope.student_id = student.id AND cs_scope.status = :scopeCsStatus AND cs_scope.class_id IN ' +
'(SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)';
qb.innerJoin('class_student', 'cs_scope', teacherClause, {
scopeTeacherUserId: scope.userId,
scopeCsStatus: 'active',
});
if (classId != null) {
qb.andWhere('cs_scope.class_id = :scopeClassId', { scopeClassId: classId });
}
...args: Parameters<StudentsAgentService['agentGetStudentBasic']>
) {
return this.agents.agentGetStudentBasic(...args);
}
}

View File

@@ -0,0 +1,46 @@
import { ConflictException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { JinshujuMatchRule, type JinshujuFieldMapping } from '../entities/jinshuju-match-rule.entity';
export async function getMatchRule(
repo: Repository<JinshujuMatchRule>,
id: number,
formToken: string,
): Promise<JinshujuMatchRule> {
const rule = await repo.findOne({ where: { id } });
if (!rule) throw new ConflictException('规则不存在');
if (rule.formToken !== formToken) {
throw new ConflictException('匹配规则不属于当前表单');
}
return rule;
}
export function validateMatchRule(formToken: string, mappings: JinshujuFieldMapping): void {
if (!formToken.trim()) throw new ConflictException('表单 Token 不能为空');
if (!mappings.name) throw new ConflictException('匹配规则必须映射姓名字段');
const allowedStudentFields = new Set([
'name',
'studentNo',
'phone',
'idNumber',
'gender',
'ethnicity',
'emergencyContact',
'emergencyPhone',
]);
for (const [studentField, fieldKey] of Object.entries(mappings)) {
if (!allowedStudentFields.has(studentField)) {
throw new ConflictException(`不允许映射学生字段:${studentField}`);
}
if (fieldKey && !/^field_\d+$/.test(fieldKey)) {
throw new ConflictException(`无效的金数据字段:${fieldKey}`);
}
}
}
/** Extract value from a Jinshuju entry by field mapping. */
export function extractField(entry: Record<string, unknown>, fieldKey: string | undefined): string {
if (!fieldKey) return '';
const val = entry[fieldKey];
return typeof val === 'string' ? val.trim() : '';
}

View File

@@ -0,0 +1,185 @@
import { Repository, In } from 'typeorm';
import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities';
import type { DingTalkScheduleItem } from '../integration/dingtalk.service';
export interface DailySchedulePeriod {
startTime: string;
endTime: string;
scheduleId: number;
}
export interface DailySchedulePlan {
classId: number;
date: string;
shiftKey: string;
periods: DailySchedulePeriod[];
}
/** 单次排班同步的结果 */
export interface ScheduleSyncResult {
/** 参与同步的排课记录数 */
scheduleCount: number;
/** 创建/复用的班次数 */
shiftCount: number;
/** 创建/复用的考勤组数 */
groupCount: number;
/** 实际写入钉钉的排班条数 */
syncedItems: number;
/** 因无学生或无钉钉映射而跳过的排课数 */
skippedNoMapping: number;
/** 写入失败的排班批次数 */
failedBatchCount: number;
/** 写入失败的排班条数 */
failedItems: number;
/** 失败批次错误详情 */
errors: string[];
/** 按班级分组的详情 */
groups: Array<{
className: string;
groupId: number;
itemCount: number;
}>;
}
export async function buildClassDingUserMap(
classStudentRepo: Repository<ClassStudent>,
mappingRepo: Repository<StudentDingMapping>,
classIds: number[],
): Promise<Map<number, string[]>> {
const result = new Map<number, string[]>();
if (classIds.length === 0) return result;
// 班级 → 活跃学生
const links = await classStudentRepo.find({
where: { classId: In(classIds), status: 'active' },
});
if (links.length === 0) return result;
// 学生 → 钉钉 userId
const studentIds = [...new Set(links.map((l) => l.studentId))];
const mappings = await mappingRepo.find({
where: { studentId: In(studentIds) },
});
const studentToDing = new Map(mappings.map((m) => [m.studentId, m.dingUserId]));
for (const link of links) {
const dingId = studentToDing.get(link.studentId);
if (!dingId) continue;
if (!result.has(link.classId)) result.set(link.classId, []);
const arr = result.get(link.classId)!;
if (!arr.includes(dingId)) arr.push(dingId);
}
return result;
}
export async function loadClassNames(
classRepo: Repository<Class>,
classIds: number[],
): Promise<Map<number, string>> {
const map = new Map<number, string>();
if (classIds.length === 0) return map;
const classes = await classRepo.find({ where: { id: In(classIds) } });
for (const c of classes) map.set(c.id, c.name);
return map;
}
/**
* 把本地排课转换为“班级 + 日期”的日排班计划。
* 同一天相同时间段会去重,多节课按开始时间排序并合并为一个钉钉班次。
*/
export function buildDailySchedulePlans(
schedules: ClassSchedule[],
syncFrom: string,
syncTo: string,
): DailySchedulePlan[] {
const periodMapByClassDate = new Map<string, Map<string, DailySchedulePeriod>>();
const fromDate = new Date(`${syncFrom}T00:00:00.000Z`);
const toDate = new Date(`${syncTo}T00:00:00.000Z`);
for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) {
const dateStr = date.toISOString().slice(0, 10);
const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay();
for (const schedule of schedules) {
if (schedule.classId == null || schedule.weekDay !== weekDay) continue;
if (dateStr < schedule.startDate || dateStr > schedule.endDate) continue;
const classDateKey = `${schedule.classId}|${dateStr}`;
if (!periodMapByClassDate.has(classDateKey)) {
periodMapByClassDate.set(classDateKey, new Map());
}
const periods = periodMapByClassDate.get(classDateKey)!;
const periodKey = `${schedule.startTime}-${schedule.endTime}`;
const existing = periods.get(periodKey);
if (!existing || schedule.id < existing.scheduleId) {
periods.set(periodKey, {
startTime: schedule.startTime,
endTime: schedule.endTime,
scheduleId: schedule.id,
});
}
}
}
const plans: DailySchedulePlan[] = [];
for (const [classDateKey, periodMap] of periodMapByClassDate) {
const separator = classDateKey.indexOf('|');
const classId = Number(classDateKey.slice(0, separator));
const date = classDateKey.slice(separator + 1);
const periods = [...periodMap.values()].sort(
(left, right) =>
left.startTime.localeCompare(right.startTime) ||
left.endTime.localeCompare(right.endTime) ||
left.scheduleId - right.scheduleId,
);
const periodSignature = periods
.map((period) => `${period.startTime}-${period.endTime}`)
.join('+');
plans.push({
classId,
date,
shiftKey: `${classId}|${periodSignature}`,
periods,
});
}
return plans.sort(
(left, right) => left.date.localeCompare(right.date) || left.classId - right.classId,
);
}
/** 每个学生每天仅生成一条钉钉排班shift 内可包含多个课程卡段。 */
export function expandDailySchedulePlans(
plans: DailySchedulePlan[],
dingUserIds: string[],
planToShiftId: Map<string, number>,
): DingTalkScheduleItem[] {
const items: DingTalkScheduleItem[] = [];
for (const plan of plans) {
const shiftId = planToShiftId.get(plan.shiftKey);
if (!shiftId) continue;
const workDate = new Date(`${plan.date}T00:00:00+08:00`).getTime();
for (const userid of dingUserIds) {
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false });
}
}
return items;
}
export function toMinutes(time: string): number {
const [hour, minute] = time.split(':').map(Number);
return hour * 60 + minute;
}
export function minutesBetween(startTime: string, endTime: string): number {
const start = toMinutes(startTime);
let end = toMinutes(endTime);
if (end <= start) end += 24 * 60;
return end - start;
}
export function addDays(dateStr: string, days: number): string {
const d = new Date(`${dateStr}T00:00:00.000Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}

View File

@@ -1,69 +1,21 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { Repository } from 'typeorm';
import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities';
import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service';
import { DingTalkService } from '../integration/dingtalk.service';
import {
buildClassDingUserMap,
loadClassNames,
buildDailySchedulePlans,
expandDailySchedulePlans,
toMinutes,
minutesBetween,
addDays,
type DailySchedulePeriod,
type DailySchedulePlan,
type ScheduleSyncResult,
} from './schedule-sync.helpers';
interface DailySchedulePeriod {
startTime: string;
endTime: string;
scheduleId: number;
}
interface DailySchedulePlan {
classId: number;
date: string;
shiftKey: string;
periods: DailySchedulePeriod[];
}
/** 单次排班同步的结果 */
export interface ScheduleSyncResult {
/** 参与同步的排课记录数 */
scheduleCount: number;
/** 创建/复用的班次数 */
shiftCount: number;
/** 创建/复用的考勤组数 */
groupCount: number;
/** 实际写入钉钉的排班条数 */
syncedItems: number;
/** 因无学生或无钉钉映射而跳过的排课数 */
skippedNoMapping: number;
/** 写入失败的排班批次数 */
failedBatchCount: number;
/** 写入失败的排班条数 */
failedItems: number;
/** 失败批次错误详情 */
errors: string[];
/** 按班级分组的详情 */
groups: Array<{
className: string;
groupId: number;
itemCount: number;
}>;
}
/**
* 排班同步服务 — 将本地 ClassSchedule 同步到钉钉考勤排班。
*
* ## 同步流程(按班级学生)
* 1. 查询活跃排课,按 classId 分组
* 2. 通过 ClassStudent + StudentDingMapping 拿到每个班级学生的钉钉 userId
* 3. 按 (startTime, endTime) 创建/匹配钉钉班次(班次列表只拉一次)
* 4. 每个班级创建/匹配一个排班制考勤组(考勤组列表只拉一次)
* 5. 将排课展开为每个学生的每日排班,批量写入钉钉
*
* ## 残余风险:同步窗口内已不存在的旧排班无法清理
* 钉钉开放平台未暴露排班删除接口(仅提供 `schedule/listbyusers` 查询和
* `group/schedule/async` 写入)。`queryScheduleByUsers` 受限于 7 天窗口
* 和每次 50 个用户,且无配套删除能力,无法在同步前清理旧排班。
* 当前产品流程为"排课后手动同步钉钉",依赖运营人员知晓同步时机;
* 若后续需要自动清理,需等钉钉开放排班删除 API 或改用考勤组覆盖策略。
*
* ## API 调用优化
* - 班次列表、考勤组列表各只查询一次,在内存中按名称匹配,避免每次 findOrCreate 都发一次查询。
* - 排班写入按考勤组分批(钉钉单次最多 200 条)。
*/
@Injectable()
export class ScheduleSyncService {
private readonly logger = new Logger(ScheduleSyncService.name);
@@ -95,7 +47,7 @@ export class ScheduleSyncService {
): Promise<ScheduleSyncResult> {
const startDate = dateFrom || new Date().toISOString().slice(0, 10);
const normalizedDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30;
const endDate = this.addDays(startDate, normalizedDays - 1);
const endDate = addDays(startDate, normalizedDays - 1);
const empty: ScheduleSyncResult = {
scheduleCount: 0,
@@ -121,13 +73,13 @@ export class ScheduleSyncService {
// ── Step 2: 班级 → 学生钉钉ID 映射 ──
const classIds = [...new Set(schedules.map((s) => s.classId as number))];
const classDingUsers = await this.buildClassDingUserMap(classIds);
const classNameMap = await this.loadClassNames(classIds);
const classDingUsers = await buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds);
const classNameMap = await loadClassNames(this.classRepo, classIds);
// ── Step 3: 将每天的多节课合并成一个钉钉班次 ──
// 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为
// 同一个班次的多个 sections 写入,不能拆成多条 schedule item。
const dailyPlans = this.buildDailySchedulePlans(schedules, startDate, endDate);
const dailyPlans = buildDailySchedulePlans(schedules, startDate, endDate);
const uniqueShifts = new Map<
string,
{ className: string; periods: DailySchedulePeriod[] }
@@ -171,7 +123,7 @@ export class ScheduleSyncService {
},
{
check_type: 'OffDuty' as const,
across: this.toMinutes(period.endTime) <= this.toMinutes(period.startTime) ? 1 : 0,
across: toMinutes(period.endTime) <= toMinutes(period.startTime) ? 1 : 0,
check_time: `1970-01-01 ${period.endTime}:00`,
free_check: false,
},
@@ -181,7 +133,7 @@ export class ScheduleSyncService {
is_flexible: false,
serious_late_minutes: -1,
absenteeism_late_minutes: Math.max(
...periods.map((period) => this.minutesBetween(period.startTime, period.endTime)),
...periods.map((period) => minutesBetween(period.startTime, period.endTime)),
),
},
};
@@ -242,7 +194,7 @@ export class ScheduleSyncService {
}
// 先展开排班以计算受影响条数
const items = this.expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId);
const items = expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId);
if (items.length === 0) {
this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`);
@@ -333,143 +285,6 @@ export class ScheduleSyncService {
* 构建 classId → 学生钉钉 userId 列表。
* 一次性查询所有班级的活跃学生与钉钉映射,避免 N+1。
*/
private async buildClassDingUserMap(classIds: number[]): Promise<Map<number, string[]>> {
const result = new Map<number, string[]>();
if (classIds.length === 0) return result;
// 班级 → 活跃学生
const links = await this.classStudentRepo.find({
where: { classId: In(classIds), status: 'active' },
});
if (links.length === 0) return result;
// 学生 → 钉钉 userId
const studentIds = [...new Set(links.map((l) => l.studentId))];
const mappings = await this.mappingRepo.find({
where: { studentId: In(studentIds) },
});
const studentToDing = new Map(mappings.map((m) => [m.studentId, m.dingUserId]));
for (const link of links) {
const dingId = studentToDing.get(link.studentId);
if (!dingId) continue;
if (!result.has(link.classId)) result.set(link.classId, []);
const arr = result.get(link.classId)!;
if (!arr.includes(dingId)) arr.push(dingId);
}
return result;
}
private async loadClassNames(classIds: number[]): Promise<Map<number, string>> {
const map = new Map<number, string>();
if (classIds.length === 0) return map;
const classes = await this.classRepo.find({ where: { id: In(classIds) } });
for (const c of classes) map.set(c.id, c.name);
return map;
}
/**
* 把本地排课转换为“班级 + 日期”的日排班计划。
* 同一天相同时间段会去重,多节课按开始时间排序并合并为一个钉钉班次。
*/
private buildDailySchedulePlans(
schedules: ClassSchedule[],
syncFrom: string,
syncTo: string,
): DailySchedulePlan[] {
const periodMapByClassDate = new Map<string, Map<string, DailySchedulePeriod>>();
const fromDate = new Date(`${syncFrom}T00:00:00.000Z`);
const toDate = new Date(`${syncTo}T00:00:00.000Z`);
for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) {
const dateStr = date.toISOString().slice(0, 10);
const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay();
for (const schedule of schedules) {
if (schedule.classId == null || schedule.weekDay !== weekDay) continue;
if (dateStr < schedule.startDate || dateStr > schedule.endDate) continue;
const classDateKey = `${schedule.classId}|${dateStr}`;
if (!periodMapByClassDate.has(classDateKey)) {
periodMapByClassDate.set(classDateKey, new Map());
}
const periods = periodMapByClassDate.get(classDateKey)!;
const periodKey = `${schedule.startTime}-${schedule.endTime}`;
const existing = periods.get(periodKey);
if (!existing || schedule.id < existing.scheduleId) {
periods.set(periodKey, {
startTime: schedule.startTime,
endTime: schedule.endTime,
scheduleId: schedule.id,
});
}
}
}
const plans: DailySchedulePlan[] = [];
for (const [classDateKey, periodMap] of periodMapByClassDate) {
const separator = classDateKey.indexOf('|');
const classId = Number(classDateKey.slice(0, separator));
const date = classDateKey.slice(separator + 1);
const periods = [...periodMap.values()].sort(
(left, right) =>
left.startTime.localeCompare(right.startTime) ||
left.endTime.localeCompare(right.endTime) ||
left.scheduleId - right.scheduleId,
);
const periodSignature = periods
.map((period) => `${period.startTime}-${period.endTime}`)
.join('+');
plans.push({
classId,
date,
shiftKey: `${classId}|${periodSignature}`,
periods,
});
}
return plans.sort(
(left, right) => left.date.localeCompare(right.date) || left.classId - right.classId,
);
}
/** 每个学生每天仅生成一条钉钉排班shift 内可包含多个课程卡段。 */
private expandDailySchedulePlans(
plans: DailySchedulePlan[],
dingUserIds: string[],
planToShiftId: Map<string, number>,
): DingTalkScheduleItem[] {
const items: DingTalkScheduleItem[] = [];
for (const plan of plans) {
const shiftId = planToShiftId.get(plan.shiftKey);
if (!shiftId) continue;
const workDate = new Date(`${plan.date}T00:00:00+08:00`).getTime();
for (const userid of dingUserIds) {
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false });
}
}
return items;
}
private toMinutes(time: string): number {
const [hour, minute] = time.split(':').map(Number);
return hour * 60 + minute;
}
private minutesBetween(startTime: string, endTime: string): number {
const start = this.toMinutes(startTime);
let end = this.toMinutes(endTime);
if (end <= start) end += 24 * 60;
return end - start;
}
private addDays(dateStr: string, days: number): string {
const d = new Date(`${dateStr}T00:00:00.000Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
/** 获取排班同步状态:活跃排课数、有钉钉映射学生的班级数 */
async getStatus(_targetDate: string): Promise<{
activeSchedules: number;
mappedClasses: number;
@@ -478,7 +293,7 @@ export class ScheduleSyncService {
const allSchedules = await this.scheduleRepo.find({ where: { status: 'active' } });
const schedules = allSchedules.filter((s) => s.classId != null);
const classIds = [...new Set(schedules.map((s) => s.classId as number))];
const classDingUsers = await this.buildClassDingUserMap(classIds);
const classDingUsers = await buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds);
const mappedClasses = [...classDingUsers.values()].filter((u) => u.length > 0).length;
return {

View File

@@ -0,0 +1,112 @@
import { ConflictException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'node:crypto';
import { Repository } from 'typeorm';
import { SyncLog, SyncState } from '../entities';
import type { SyncPlatform, SyncStatus, SyncType } from '../entities/sync-log.entity';
const LEASE_MS = 30 * 60 * 1000;
@Injectable()
export class SyncRunner {
private readonly logger = new Logger('SyncRunner');
constructor(
@InjectRepository(SyncState)
private readonly syncStateRepo: Repository<SyncState>,
@InjectRepository(SyncLog)
private readonly syncLogRepo: Repository<SyncLog>,
) {}
async run(
platform: SyncPlatform,
operation: (lastSyncAt: Date | null) => Promise<{
recordsCount: number;
status: Extract<SyncStatus, 'success' | 'partial'>;
message?: string;
}>,
): Promise<SyncLog> {
const runId = await this.acquireLease(platform);
let log: SyncLog | undefined;
try {
const lastSyncAt = await this.getLastSyncAt(platform);
log = await this.createSyncLog(platform, lastSyncAt ? 'incremental' : 'full', 'running');
const result = await operation(lastSyncAt);
await this.syncStateRepo.update({ platform }, { lastSyncAt: new Date() });
await this.finishSyncLog(log, result.status, result.recordsCount, result.message);
return log;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (log) await this.finishSyncLog(log, 'failed', 0, message);
this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined);
throw error;
} finally {
await this.releaseLease(platform, runId);
}
}
private async acquireLease(platform: SyncPlatform): Promise<string> {
await this.syncStateRepo
.createQueryBuilder()
.insert()
.values({ platform, lastSyncAt: null, runId: null, runningSince: null })
.orIgnore()
.execute();
const runId = randomUUID();
const result = await this.syncStateRepo
.createQueryBuilder()
.update()
.set({ runId, runningSince: new Date() })
.where('platform = :platform', { platform })
.andWhere('(running_since IS NULL OR running_since < :staleBefore)', {
staleBefore: new Date(Date.now() - LEASE_MS),
})
.execute();
if (result.affected !== 1) throw new ConflictException(`${platform} 同步正在进行中`);
return runId;
}
private async releaseLease(platform: SyncPlatform, runId: string): Promise<void> {
await this.syncStateRepo
.createQueryBuilder()
.update()
.set({ runId: null, runningSince: null })
.where('platform = :platform AND run_id = :runId', { platform, runId })
.execute();
}
private async getLastSyncAt(platform: SyncPlatform): Promise<Date | null> {
const state = await this.syncStateRepo.findOne({ where: { platform } });
return state?.lastSyncAt ?? null;
}
private async createSyncLog(
platform: SyncPlatform,
syncType: SyncType,
status: SyncStatus,
): Promise<SyncLog> {
return this.syncLogRepo.save(
this.syncLogRepo.create({
platform,
syncType,
status,
recordsCount: 0,
startedAt: new Date(),
}),
);
}
private async finishSyncLog(
log: SyncLog,
status: SyncStatus,
recordsCount: number,
errorMessage?: string,
): Promise<void> {
log.status = status;
log.recordsCount = recordsCount;
log.finishedAt = new Date();
log.errorMessage = errorMessage ?? null;
await this.syncLogRepo.save(log);
}
}

Some files were not shown because too many files have changed in this diff Show More