feat: refine deposit room type workflows
This commit is contained in:
@@ -236,8 +236,10 @@ export class AttendanceController {
|
||||
{ header: '时段', key: 'session', width: 15 },
|
||||
{ header: '状态', key: 'status', width: 10 },
|
||||
{ header: '来源', key: 'source', width: 10 },
|
||||
{ header: '打卡设备', key: 'punchDevice', width: 30 },
|
||||
{ header: '打卡时间', key: 'punchTime', width: 20 },
|
||||
{ header: '备注', key: 'remark', width: 30 },
|
||||
{ header: '打卡时间', key: 'createdAt', width: 20 },
|
||||
{ header: '归档时间', key: 'createdAt', width: 20 },
|
||||
];
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
@@ -250,6 +252,10 @@ export class AttendanceController {
|
||||
session: record.session || '',
|
||||
status: record.status || '',
|
||||
source: record.source || '',
|
||||
punchDevice: record.punchDeviceName || record.punchDeviceId || '',
|
||||
punchTime: record.punchTime
|
||||
? record.punchTime.toISOString().replace('T', ' ').substring(0, 19)
|
||||
: '',
|
||||
remark: record.remark || '',
|
||||
createdAt: record.createdAt
|
||||
? record.createdAt.toISOString().replace('T', ' ').substring(0, 19)
|
||||
|
||||
@@ -234,6 +234,106 @@ describe('AttendanceService — DingTalk raw query', () => {
|
||||
});
|
||||
|
||||
|
||||
describe('AttendanceService — attendance device display mappings', () => {
|
||||
function createHistoryQueryBuilder(records: AttendanceRecord[]) {
|
||||
return {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([records, records.length]),
|
||||
getMany: jest.fn().mockResolvedValue(records),
|
||||
};
|
||||
}
|
||||
|
||||
function createServiceWithRecords(records: AttendanceRecord[]) {
|
||||
const qb = createHistoryQueryBuilder(records);
|
||||
const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||
const attendanceDeviceRepo = {
|
||||
find: jest.fn().mockImplementation(async (options: { where?: Record<string, unknown> }) => {
|
||||
if (options.where && 'deviceSn' in options.where) {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
deviceSn: 'ATM-01',
|
||||
deviceName: '东门考勤机',
|
||||
classroomId: 8,
|
||||
classroom: { id: 8, name: '一号教室' },
|
||||
status: 'disabled',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
};
|
||||
const service = new AttendanceService(
|
||||
attendanceRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
attendanceDeviceRepo as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, qb, attendanceDeviceRepo };
|
||||
}
|
||||
|
||||
it('maps history list punch device ids to configured attendance device names', async () => {
|
||||
const record = {
|
||||
id: 1,
|
||||
classId: 8,
|
||||
status: 'present',
|
||||
source: 'dingtalk',
|
||||
punchSource: 'ATM',
|
||||
punchDeviceId: 'ATM-01',
|
||||
punchDeviceName: '钉钉原始设备名',
|
||||
} as AttendanceRecord;
|
||||
const { service, attendanceDeviceRepo } = createServiceWithRecords([record]);
|
||||
|
||||
await expect(service.findAll({}, [8])).resolves.toMatchObject({
|
||||
list: [
|
||||
{
|
||||
punchDeviceId: 'ATM-01',
|
||||
punchDeviceName: '东门考勤机 · 一号教室',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
expect(attendanceDeviceRepo.find).toHaveBeenCalledWith({
|
||||
where: { deviceSn: expect.any(Object) },
|
||||
relations: ['classroom'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps exported punch device ids to configured attendance device names', async () => {
|
||||
const record = {
|
||||
id: 2,
|
||||
classId: 8,
|
||||
status: 'present',
|
||||
source: 'dingtalk',
|
||||
punchSource: 'ATM',
|
||||
punchDeviceId: 'ATM-01',
|
||||
punchDeviceName: '钉钉原始设备名',
|
||||
} as AttendanceRecord;
|
||||
const { service } = createServiceWithRecords([record]);
|
||||
|
||||
await expect(service.findAllForExport({}, [8])).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
punchDeviceId: 'ATM-01',
|
||||
punchDeviceName: '东门考勤机 · 一号教室',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ── Session serialization tests ──
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
|
||||
@@ -88,7 +88,7 @@ export class AttendanceService {
|
||||
const devicesBySn = new Map<string, AttendanceDevice>();
|
||||
if (sns.length > 0) {
|
||||
const devices = await this.attendanceDeviceRepo.find({
|
||||
where: { deviceSn: In(sns), status: 'active' },
|
||||
where: { deviceSn: In(sns) },
|
||||
relations: ['classroom'],
|
||||
});
|
||||
for (const device of devices) devicesBySn.set(device.deviceSn, device);
|
||||
@@ -907,7 +907,7 @@ export class AttendanceService {
|
||||
qb.skip((page - 1) * pageSize).take(pageSize);
|
||||
|
||||
const [list, total] = await qb.getManyAndCount();
|
||||
return { list, total, page, pageSize };
|
||||
return { list: await this.attachAttendanceDeviceMappings(list), total, page, pageSize };
|
||||
}
|
||||
|
||||
// ── Get distinct classes with attendance records ──
|
||||
@@ -1053,7 +1053,8 @@ export class AttendanceService {
|
||||
|
||||
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
|
||||
|
||||
return qb.getMany();
|
||||
const records = await qb.getMany();
|
||||
return this.attachAttendanceDeviceMappings(records);
|
||||
}
|
||||
|
||||
async findAttendanceRecord(id: number) {
|
||||
|
||||
@@ -16,8 +16,8 @@ import { Repository } from 'typeorm';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { DepositsService } from './deposits.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationType } from '../entities/notification.entity';
|
||||
import {
|
||||
BatchCreateDepositDto,
|
||||
CreateDepositDto,
|
||||
CreateDepositInstallmentDto,
|
||||
RefundDepositDto,
|
||||
@@ -44,6 +44,13 @@ export class DepositsController {
|
||||
return this.service.getStudentLookups();
|
||||
}
|
||||
|
||||
|
||||
@Get('eligible-students')
|
||||
@RequirePermission('deposit:view')
|
||||
getEligibleStudents(@Query('roomType') roomType?: string) {
|
||||
return this.service.getEligibleStudents(roomType || undefined);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('deposit:view')
|
||||
findAll(
|
||||
@@ -99,6 +106,25 @@ export class DepositsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@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,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post(':id/installments')
|
||||
@RequirePermission('deposit:edit')
|
||||
async addInstallment(
|
||||
|
||||
@@ -3,13 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { DepositsService } from './deposits.service';
|
||||
import { DepositsController } from './deposits.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule],
|
||||
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student, Occupancy]), OperationLogsModule, NotificationsModule],
|
||||
controllers: [DepositsController],
|
||||
providers: [DepositsService],
|
||||
exports: [DepositsService],
|
||||
|
||||
98
apps/server/src/deposits/deposits.room-type.spec.ts
Normal file
98
apps/server/src/deposits/deposits.room-type.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { DepositsService } from './deposits.service';
|
||||
|
||||
const createQb = (rows: unknown[] = []) => ({
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue(rows),
|
||||
});
|
||||
|
||||
function makeService(rows: unknown[] = []) {
|
||||
const qb = createQb(rows);
|
||||
const repo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => value),
|
||||
};
|
||||
const studentRepo = { findOne: jest.fn(async ({ where }: any) => ({ id: where.id })) };
|
||||
const occupancyRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||
const service = new DepositsService(repo as never, {} as never, studentRepo as never, occupancyRepo as never);
|
||||
return { service, qb, repo, studentRepo };
|
||||
}
|
||||
|
||||
describe('DepositsService room-type deposits', () => {
|
||||
it('filters current occupants by room type with capacity fallback', async () => {
|
||||
const { service, qb } = makeService([
|
||||
{
|
||||
studentId: 1,
|
||||
studentName: '张三',
|
||||
studentNo: 'S1',
|
||||
roomId: 8,
|
||||
roomNumber: '401',
|
||||
building: 'A',
|
||||
roomType: null,
|
||||
capacity: 4,
|
||||
depositAmount: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.getEligibleStudents('四人间')).resolves.toEqual([
|
||||
{
|
||||
studentId: 1,
|
||||
studentName: '张三',
|
||||
studentNo: 'S1',
|
||||
roomId: 8,
|
||||
roomNumber: '401',
|
||||
building: 'A',
|
||||
roomType: '四人间',
|
||||
capacity: 4,
|
||||
depositAmount: 0,
|
||||
},
|
||||
]);
|
||||
expect(qb.where).toHaveBeenCalledWith('o.status = :activeStatus', { activeStatus: 'active' });
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('o.checkOutDate IS NULL');
|
||||
expect(qb.andWhere).toHaveBeenCalledWith(
|
||||
'(room.roomType = :roomType OR ((room.roomType IS NULL OR room.roomType = :emptyRoomType) AND room.capacity = :fallbackCapacity))',
|
||||
{ roomType: '四人间', emptyRoomType: '', fallbackCapacity: 4 },
|
||||
);
|
||||
});
|
||||
|
||||
it('creates or accumulates deposits for a batch of selected students', async () => {
|
||||
const { service, repo } = makeService();
|
||||
const existing = { id: 1, studentId: 2, amount: 50, status: 'paid' };
|
||||
repo.findOne.mockImplementation(async ({ where }: any) => {
|
||||
if (where.id) return existing;
|
||||
if (where.studentId === 2) return existing;
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await service.batchCreate({
|
||||
studentIds: [2, 3, 3],
|
||||
amount: 100,
|
||||
paidDate: '2026-07-17',
|
||||
notes: '四人间押金',
|
||||
}, 9);
|
||||
|
||||
expect(result.count).toBe(2);
|
||||
expect(existing.amount).toBe(150);
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ studentId: 3, amount: 100 }));
|
||||
expect(repo.save).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects invalid batch amounts', async () => {
|
||||
const { service, repo } = makeService();
|
||||
|
||||
await expect(service.batchCreate({
|
||||
studentIds: [1],
|
||||
amount: 0.004,
|
||||
paidDate: '2026-07-17',
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,38 @@ import { Repository } from 'typeorm';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
|
||||
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||
import { BatchCreateDepositDto, CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||
|
||||
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
|
||||
|
||||
const capacityRoomTypeText: Record<number, string> = {
|
||||
1: '单人间',
|
||||
2: '二人间',
|
||||
3: '三人间',
|
||||
4: '四人间',
|
||||
5: '五人间',
|
||||
6: '六人间',
|
||||
8: '八人间',
|
||||
};
|
||||
|
||||
const normalizeRoomType = (roomType?: string | null, capacity?: number | string | null) => {
|
||||
const trimmed = roomType?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
const normalizedCapacity = Number(capacity);
|
||||
return capacityRoomTypeText[normalizedCapacity] || (normalizedCapacity > 0 ? `${normalizedCapacity}人间` : '');
|
||||
};
|
||||
|
||||
const roomTypeCapacity = (roomType?: string) => {
|
||||
const text = roomType?.trim();
|
||||
if (!text) return undefined;
|
||||
const knownCapacity = Object.entries(capacityRoomTypeText).find(([, label]) => label === text);
|
||||
if (knownCapacity) return Number(knownCapacity[0]);
|
||||
const match = text.match(/^(\d+)人间$/);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DepositsService {
|
||||
|
||||
@@ -18,6 +45,8 @@ export class DepositsService {
|
||||
private installmentRepo: Repository<DepositInstallment>,
|
||||
@InjectRepository(Student)
|
||||
private studentRepo: Repository<Student>,
|
||||
@InjectRepository(Occupancy)
|
||||
private occupancyRepo?: Repository<Occupancy>,
|
||||
) {}
|
||||
|
||||
async getStudentLookups() {
|
||||
@@ -28,6 +57,78 @@ export class DepositsService {
|
||||
});
|
||||
}
|
||||
|
||||
async getEligibleStudents(roomType?: string) {
|
||||
const trimmedRoomType = roomType?.trim();
|
||||
const fallbackCapacity = roomTypeCapacity(trimmedRoomType);
|
||||
const qb = this.occupancyRepo!
|
||||
.createQueryBuilder('o')
|
||||
.innerJoin('o.student', 'student')
|
||||
.innerJoin('o.room', 'room')
|
||||
.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' })
|
||||
.andWhere('o.checkOutDate IS NULL')
|
||||
.andWhere('student.status = :studentStatus', { studentStatus: 'active' })
|
||||
.orderBy('room.building', 'ASC')
|
||||
.addOrderBy('room.roomNumber', 'ASC')
|
||||
.addOrderBy('student.name', 'ASC');
|
||||
|
||||
if (trimmedRoomType) {
|
||||
if (fallbackCapacity) {
|
||||
qb.andWhere(
|
||||
'(room.roomType = :roomType OR ((room.roomType IS NULL OR room.roomType = :emptyRoomType) AND room.capacity = :fallbackCapacity))',
|
||||
{ roomType: trimmedRoomType, emptyRoomType: '', fallbackCapacity },
|
||||
);
|
||||
} else {
|
||||
qb.andWhere('room.roomType = :roomType', { roomType: trimmedRoomType });
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await qb.getRawMany();
|
||||
return rows.map((row) => ({
|
||||
studentId: Number(row.studentId),
|
||||
studentName: row.studentName,
|
||||
studentNo: row.studentNo ?? null,
|
||||
roomId: Number(row.roomId),
|
||||
roomNumber: row.roomNumber,
|
||||
building: row.building ?? null,
|
||||
roomType: normalizeRoomType(row.roomType, row.capacity),
|
||||
capacity: Number(row.capacity),
|
||||
depositAmount: money(row.depositAmount),
|
||||
}));
|
||||
}
|
||||
|
||||
async batchCreate(dto: BatchCreateDepositDto, userId?: number) {
|
||||
const studentIds = [...new Set(dto.studentIds)];
|
||||
if (studentIds.length === 0) throw new BadRequestException('请选择学生');
|
||||
const amount = money(dto.amount);
|
||||
if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) {
|
||||
throw new BadRequestException('收取金额最多保留两位小数');
|
||||
}
|
||||
if (amount <= 0) throw new BadRequestException('收取金额必须大于0');
|
||||
|
||||
const results: Deposit[] = [];
|
||||
for (const studentId of studentIds) {
|
||||
results.push(await this.create({
|
||||
studentId,
|
||||
amount,
|
||||
paidDate: dto.paidDate,
|
||||
notes: dto.notes,
|
||||
}, userId));
|
||||
}
|
||||
return { count: results.length, amount, results };
|
||||
}
|
||||
|
||||
async findAll(query?: { studentId?: number; status?: string }) {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('d')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator';
|
||||
import { ArrayNotEmpty, IsArray, IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class CreateDepositDto {
|
||||
@IsInt()
|
||||
@@ -16,6 +16,28 @@ export class CreateDepositDto {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class BatchCreateDepositDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsInt({ each: true })
|
||||
studentIds: number[];
|
||||
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0.01)
|
||||
amount: number;
|
||||
|
||||
@IsDateString()
|
||||
paidDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
roomType?: string;
|
||||
}
|
||||
|
||||
export class RefundDepositDto {
|
||||
@IsDateString()
|
||||
refundDate: string;
|
||||
|
||||
Reference in New Issue
Block a user