- 替换散落的 toISOString().slice(0,10) / split('T')[0] / replace('T',' ')(UTC 语义用 dayjs(...).utc() 保持完全一致)
- Asia/Shanghai 固定时区日期(Intl.DateTimeFormat en-CA)改用 dayjs().utcOffset(8)
- 月份边界 first/last、daysInMonth、nextMonth、shiftDate/addDays 等改 dayjs 简洁实现
- 涉及 attendance/classes/classrooms/room/dashboard/bills/expenses/occupancies/sync/ai-chat/rental 等 28 个文件
174 lines
6.1 KiB
TypeScript
174 lines
6.1 KiB
TypeScript
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';
|
|
import dayjs from '../common/dayjs';
|
|
|
|
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 = dayjs().utc().format('YYYY-MM-DD');
|
|
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,
|
|
};
|
|
}
|
|
}
|