forked from wangziqi/gongxue-base
266 lines
8.0 KiB
TypeScript
266 lines
8.0 KiB
TypeScript
import {
|
|
Injectable,
|
|
NotFoundException,
|
|
BadRequestException,
|
|
} from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository, In, Between } from 'typeorm';
|
|
import { AttendanceRecord, DingAttendanceRaw } from '../entities';
|
|
import {
|
|
BatchCreateAttendanceDto,
|
|
AttendanceSummaryQueryDto,
|
|
AttendanceCalendarQueryDto,
|
|
QueryDingRawDto,
|
|
MatchDingRecordDto,
|
|
} from './dto/attendance.dto';
|
|
|
|
@Injectable()
|
|
export class AttendanceService {
|
|
constructor(
|
|
@InjectRepository(AttendanceRecord)
|
|
private attendanceRepo: Repository<AttendanceRecord>,
|
|
@InjectRepository(DingAttendanceRaw)
|
|
private dingRawRepo: Repository<DingAttendanceRaw>,
|
|
@InjectRepository(Class)
|
|
private classRepo: Repository<Class>,
|
|
) {}
|
|
|
|
// ── Batch create attendance records ──
|
|
async batchCreate(dto: BatchCreateAttendanceDto) {
|
|
if (!dto.records || dto.records.length === 0) {
|
|
throw new BadRequestException('records array must not be empty');
|
|
}
|
|
|
|
const entities = dto.records.map((r) =>
|
|
this.attendanceRepo.create({
|
|
studentId: r.studentId,
|
|
classId: r.classId ?? undefined,
|
|
attendanceDate: r.attendanceDate,
|
|
session: r.session,
|
|
status: r.status,
|
|
remark: r.remark,
|
|
source: r.source || 'manual',
|
|
}),
|
|
);
|
|
|
|
const saved = await this.attendanceRepo.save(entities);
|
|
return { count: saved.length, records: saved };
|
|
}
|
|
|
|
// ── Attendance summary ──
|
|
async getSummary(query: AttendanceSummaryQueryDto) {
|
|
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
|
|
|
if (query.classId) {
|
|
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
|
}
|
|
if (query.dateFrom) {
|
|
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
|
}
|
|
if (query.dateTo) {
|
|
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
|
}
|
|
|
|
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;
|
|
const presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0;
|
|
|
|
return { total, present, late, absent, leave, presentRate };
|
|
}
|
|
|
|
// ── Attendance calendar ──
|
|
async getCalendar(query: AttendanceCalendarQueryDto) {
|
|
const { classId, weekStart } = query;
|
|
|
|
if (!weekStart) {
|
|
// Default to the Monday of the current week
|
|
const now = new Date();
|
|
const day = now.getDay();
|
|
const diff = day === 0 ? -6 : 1 - day; // Monday offset
|
|
const monday = new Date(now);
|
|
monday.setDate(now.getDate() + diff);
|
|
const mondayStr = monday.toISOString().slice(0, 10);
|
|
|
|
return this.buildCalendar(classId, mondayStr);
|
|
}
|
|
|
|
return this.buildCalendar(classId, weekStart);
|
|
}
|
|
|
|
private async buildCalendar(classId: number, weekStart: string) {
|
|
// Compute weekEnd (Sunday = weekStart + 6 days)
|
|
const start = new Date(weekStart);
|
|
const end = new Date(start);
|
|
end.setDate(start.getDate() + 6);
|
|
const endStr = end.toISOString().slice(0, 10);
|
|
|
|
// Fetch attendance records for the week
|
|
const records = await this.attendanceRepo.find({
|
|
where: {
|
|
classId,
|
|
attendanceDate: Between(weekStart, endStr),
|
|
},
|
|
relations: ['student'],
|
|
order: { attendanceDate: 'ASC', session: 'ASC' },
|
|
});
|
|
|
|
// Group by studentId
|
|
const studentMap = new Map<
|
|
number,
|
|
{
|
|
studentId: number;
|
|
studentName: string;
|
|
days: Array<{ date: string; session: string; status: string }>;
|
|
}
|
|
>();
|
|
|
|
for (const r of records) {
|
|
if (!studentMap.has(r.studentId)) {
|
|
studentMap.set(r.studentId, {
|
|
studentId: r.studentId,
|
|
studentName: r.student?.name ?? `Student#${r.studentId}`,
|
|
days: [],
|
|
});
|
|
}
|
|
studentMap.get(r.studentId)!.days.push({
|
|
date: r.attendanceDate,
|
|
session: r.session,
|
|
status: r.status,
|
|
});
|
|
}
|
|
|
|
return Array.from(studentMap.values());
|
|
}
|
|
|
|
// ── List attendance records with filters ──
|
|
async findAll(query: {
|
|
classId?: number;
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
session?: string;
|
|
status?: string;
|
|
source?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
}) {
|
|
const page = query.page || 1;
|
|
const pageSize = query.pageSize || 20;
|
|
|
|
const qb = this.attendanceRepo
|
|
.createQueryBuilder('ar')
|
|
.leftJoinAndSelect('ar.student', 'student')
|
|
.leftJoinAndSelect('ar.class', 'class');
|
|
|
|
if (query.classId) {
|
|
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
|
}
|
|
if (query.dateFrom) {
|
|
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
|
}
|
|
if (query.dateTo) {
|
|
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
|
}
|
|
if (query.session) {
|
|
qb.andWhere('ar.session = :session', { session: query.session });
|
|
}
|
|
if (query.status) {
|
|
qb.andWhere('ar.status = :status', { status: query.status });
|
|
}
|
|
if (query.source) {
|
|
qb.andWhere('ar.source = :source', { source: query.source });
|
|
}
|
|
|
|
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
|
|
qb.skip((page - 1) * pageSize).take(pageSize);
|
|
|
|
const [list, total] = await qb.getManyAndCount();
|
|
return { list, total, page, pageSize };
|
|
}
|
|
|
|
// ── Get distinct classes with attendance records ──
|
|
async getClasses() {
|
|
const rows = await this.attendanceRepo
|
|
.createQueryBuilder('ar')
|
|
.select('DISTINCT ar.classId', 'classId')
|
|
.where('ar.classId IS NOT NULL')
|
|
.orderBy('ar.classId', 'ASC')
|
|
.getRawMany();
|
|
|
|
const classIds = rows.map((r) => r.classId).filter(Boolean) as number[];
|
|
if (classIds.length === 0) return [];
|
|
|
|
const classes = await this.classRepo.find({ where: { id: In(classIds) } });
|
|
const nameMap = new Map(classes.map((c) => [c.id, c.name]));
|
|
return classIds.map((id) => ({ classId: id, className: nameMap.get(id) || `班级${id}` }));
|
|
}
|
|
|
|
// ── DingAttendance raw records ──
|
|
async getDingRaw(query: QueryDingRawDto) {
|
|
const where: any = {};
|
|
if (query.matchStatus) {
|
|
where.matchStatus = query.matchStatus;
|
|
}
|
|
|
|
return this.dingRawRepo.find({
|
|
where,
|
|
relations: ['matchedStudent'],
|
|
order: { attendanceDate: 'DESC', checkInTime: 'ASC' },
|
|
});
|
|
}
|
|
|
|
// ── 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 = '已匹配';
|
|
return this.dingRawRepo.save(record);
|
|
}
|
|
|
|
// ── Export all attendance records with filters (no pagination) ──
|
|
async findAllForExport(query: {
|
|
classId?: number;
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
session?: string;
|
|
status?: string;
|
|
source?: string;
|
|
}) {
|
|
const qb = this.attendanceRepo
|
|
.createQueryBuilder('ar')
|
|
.leftJoinAndSelect('ar.student', 'student')
|
|
.leftJoinAndSelect('ar.class', 'class');
|
|
|
|
if (query.classId) {
|
|
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
|
}
|
|
if (query.dateFrom) {
|
|
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
|
}
|
|
if (query.dateTo) {
|
|
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
|
}
|
|
if (query.session) {
|
|
qb.andWhere('ar.session = :session', { session: query.session });
|
|
}
|
|
if (query.status) {
|
|
qb.andWhere('ar.status = :status', { status: query.status });
|
|
}
|
|
if (query.source) {
|
|
qb.andWhere('ar.source = :source', { source: query.source });
|
|
}
|
|
|
|
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
|
|
|
|
return qb.getMany();
|
|
}
|
|
}
|