feat: 重构各业务模块管理页面与服务
This commit is contained in:
@@ -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);
|
||||
|
||||
285
apps/server/src/bills/bills-generation.service.ts
Normal file
285
apps/server/src/bills/bills-generation.service.ts
Normal 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())}`,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
33
apps/server/src/bills/bills.purge.controller.spec.ts
Normal file
33
apps/server/src/bills/bills.purge.controller.spec.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
71
apps/server/src/bills/bills.purge.spec.ts
Normal file
71
apps/server/src/bills/bills.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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('账单状态必须与实付及未付金额一致');
|
||||
}
|
||||
}
|
||||
|
||||
172
apps/server/src/classes/classes-queries.service.ts
Normal file
172
apps/server/src/classes/classes-queries.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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, [
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
54
apps/server/src/classes/classes.purge.spec.ts
Normal file
54
apps/server/src/classes/classes.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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_schedules(schedule_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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
341
apps/server/src/classroom-rentals/rental-schedule.service.ts
Normal file
341
apps/server/src/classroom-rentals/rental-schedule.service.ts
Normal 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_schedules(schedule_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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
59
apps/server/src/classrooms/classrooms.purge.spec.ts
Normal file
59
apps/server/src/classrooms/classrooms.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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' })
|
||||
|
||||
@@ -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);
|
||||
|
||||
241
apps/server/src/dashboard/dashboard-queries.service.ts
Normal file
241
apps/server/src/dashboard/dashboard-queries.service.ts
Normal 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() };
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
28
apps/server/src/deposits/deposits.purge.controller.spec.ts
Normal file
28
apps/server/src/deposits/deposits.purge.controller.spec.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
65
apps/server/src/deposits/deposits.purge.spec.ts
Normal file
65
apps/server/src/deposits/deposits.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
56
apps/server/src/exams/exams.purge.spec.ts
Normal file
56
apps/server/src/exams/exams.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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}的考试`);
|
||||
|
||||
438
apps/server/src/expenses/expense-operations.service.ts
Normal file
438
apps/server/src/expenses/expense-operations.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
34
apps/server/src/expenses/expenses.purge.controller.spec.ts
Normal file
34
apps/server/src/expenses/expenses.purge.controller.spec.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
91
apps/server/src/expenses/expenses.purge.spec.ts
Normal file
91
apps/server/src/expenses/expenses.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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=***')}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
80
apps/server/src/occupancies/occupancies.purge.spec.ts
Normal file
80
apps/server/src/occupancies/occupancies.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
|
||||
311
apps/server/src/occupancies/occupancy-import.service.ts
Normal file
311
apps/server/src/occupancies/occupancy-import.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/occupancies/occupancy-lock.ts
Normal file
12
apps/server/src/occupancies/occupancy-lock.ts
Normal 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;
|
||||
}
|
||||
420
apps/server/src/occupancies/occupancy-operations.service.ts
Normal file
420
apps/server/src/occupancies/occupancy-operations.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
78
apps/server/src/organizations/organizations.purge.spec.ts
Normal file
78
apps/server/src/organizations/organizations.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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: '已永久删除机构(不可恢复)' };
|
||||
}
|
||||
}
|
||||
|
||||
190
apps/server/src/rooms/room-bed-locker.service.ts
Normal file
190
apps/server/src/rooms/room-bed-locker.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
38
apps/server/src/rooms/room-number.ts
Normal file
38
apps/server/src/rooms/room-number.ts
Normal 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: '四人间' };
|
||||
}
|
||||
282
apps/server/src/rooms/room-query.service.ts
Normal file
282
apps/server/src/rooms/room-query.service.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
26
apps/server/src/rooms/rooms.purge.controller.spec.ts
Normal file
26
apps/server/src/rooms/rooms.purge.controller.spec.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
71
apps/server/src/rooms/rooms.purge.spec.ts
Normal file
71
apps/server/src/rooms/rooms.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
183
apps/server/src/schedules/schedule-queries.service.ts
Normal file
183
apps/server/src/schedules/schedule-queries.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
233
apps/server/src/students/students.agent.service.ts
Normal file
233
apps/server/src/students/students.agent.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
314
apps/server/src/students/students.import.service.ts
Normal file
314
apps/server/src/students/students.import.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
235
apps/server/src/students/students.lifecycle.service.ts
Normal file
235
apps/server/src/students/students.lifecycle.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
21
apps/server/src/students/students.organization.ts
Normal file
21
apps/server/src/students/students.organization.ts
Normal 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;
|
||||
}
|
||||
31
apps/server/src/students/students.purge.controller.spec.ts
Normal file
31
apps/server/src/students/students.purge.controller.spec.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
77
apps/server/src/students/students.purge.spec.ts
Normal file
77
apps/server/src/students/students.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
46
apps/server/src/sync/jinshuju-rules.ts
Normal file
46
apps/server/src/sync/jinshuju-rules.ts
Normal 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() : '';
|
||||
}
|
||||
185
apps/server/src/sync/schedule-sync.helpers.ts
Normal file
185
apps/server/src/sync/schedule-sync.helpers.ts
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
112
apps/server/src/sync/sync-runner.ts
Normal file
112
apps/server/src/sync/sync-runner.ts
Normal 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
Reference in New Issue
Block a user