Files
gongxue-base/apps/server/src/attendance/attendance-record-mutation.service.ts

157 lines
5.6 KiB
TypeScript

import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import {
AttendanceRecord,
DingAttendanceRaw,
StudentDingMapping,
AttendanceSession,
} from '../entities';
import type { MatchDingRecordDto, UpdateAttendanceRecordDto } from './dto/attendance.dto';
import { SessionMutex } from './attendance-mutex';
@Injectable()
export class AttendanceRecordMutationService {
private readonly sessionMutex = new SessionMutex();
constructor(
@InjectRepository(AttendanceRecord)
private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(DingAttendanceRaw)
private dingRawRepo: Repository<DingAttendanceRaw>,
@InjectRepository(StudentDingMapping)
private studentDingMappingRepo: Repository<StudentDingMapping>,
private dataSource: DataSource,
) {}
async findAttendanceRecord(id: number) {
const record = await this.attendanceRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`AttendanceRecord ${id} not found`);
}
return record;
}
// ── Match a dingtalk record to a student ──
async matchDingRecord(id: number, dto: MatchDingRecordDto) {
const record = await this.dingRawRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`DingAttendanceRaw ${id} not found`);
}
record.matchedStudentId = dto.studentId;
record.matchStatus = 'matched';
return this.dingRawRepo.save(record);
}
// ── Auto-match unmatched dingtalk records via dingUserId → userId mapping chain ──
async autoMatchDingRecords(): Promise<{ matched: number; total: number }> {
const unmatched = await this.dingRawRepo.find({
where: { matchStatus: 'unmatched' },
});
if (unmatched.length === 0) return { matched: 0, total: 0 };
const mappings = await this.studentDingMappingRepo.find();
const dingToStudentId = new Map<string, number>();
for (const m of mappings) {
dingToStudentId.set(m.dingUserId, m.studentId);
}
let matched = 0;
for (const record of unmatched) {
const studentId = dingToStudentId.get(record.dingUserId);
if (studentId == null) continue;
record.matchedStudentId = studentId;
record.matchStatus = 'matched';
await this.dingRawRepo.save(record);
matched++;
}
return { matched, total: unmatched.length };
}
// ── Update a single attendance record ──
async update(id: number, dto: UpdateAttendanceRecordDto) {
const record = await this.findAttendanceRecord(id);
// Records without a lesson session keep original behaviour
if (record.attendanceSessionId == null) {
if (dto.status !== undefined) {
record.status = dto.status;
record.source = 'manual';
record.punchTime = null;
record.punchSource = null;
record.punchDeviceName = null;
record.punchDeviceId = null;
}
if (dto.remark !== undefined) {
record.remark = dto.remark;
record.source = 'manual';
}
return this.attendanceRepo.save(record);
}
return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
this.dataSource.transaction(async (manager) => {
const recordRepo = manager.getRepository(AttendanceRecord);
const sessionRepo = manager.getRepository(AttendanceSession);
// Re-check session status inside the transaction while holding the lock
const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
if (!session || session.status === 'completed') {
throw new BadRequestException('已完成考勤的记录不允许修改或删除');
}
const freshRecord = await recordRepo.findOne({ where: { id } });
if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);
if (dto.status !== undefined) {
freshRecord.status = dto.status;
freshRecord.source = 'manual';
freshRecord.punchTime = null;
freshRecord.punchSource = null;
freshRecord.punchDeviceName = null;
freshRecord.punchDeviceId = null;
}
if (dto.remark !== undefined) {
freshRecord.remark = dto.remark;
freshRecord.source = 'manual';
}
return recordRepo.save(freshRecord);
}),
);
}
// ── Delete a single attendance record ──
async remove(id: number) {
const record = await this.findAttendanceRecord(id);
// Records without a lesson session keep original behaviour
if (record.attendanceSessionId == null) {
await this.attendanceRepo.remove(record);
return { deleted: true };
}
return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
this.dataSource.transaction(async (manager) => {
const recordRepo = manager.getRepository(AttendanceRecord);
const sessionRepo = manager.getRepository(AttendanceSession);
// Re-check session status inside the transaction while holding the lock
const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
if (!session || session.status === 'completed') {
throw new BadRequestException('已完成考勤的记录不允许修改或删除');
}
const freshRecord = await recordRepo.findOne({ where: { id } });
if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);
await recordRepo.remove(freshRecord);
return { deleted: true };
}),
);
}
}