157 lines
5.6 KiB
TypeScript
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 };
|
|
}),
|
|
);
|
|
}
|
|
}
|