-
-
-
-
-
- current.isAfter(dayjs(), 'day')}
- onChange={(value) => {
- setAttendanceDate(value);
- setScheduleId(undefined);
- setPage(1);
- }}
- />
-
-
-
-
-
-
- : '当前日期没有排课'}
- onChange={(value) => {
- setScheduleId(value);
- setPage(1);
- }}
- options={subjectScheduleOptions}
- />
-
-
-
-
-
-
-
-
-
-
- } onClick={() => void loadRecords()}>
- 查询
-
-
-
-
-
-
-
-
班级考勤概览
-
{selectedClass}
-
- {dateLabel} · 当前展示 {visibleStudents.length} 名学生 / {total} 条记录
-
-
-
-
-
班
-
- 班主任
- {headTeacherNames}
-
-
-
-
生
-
- 生活老师
- {lifeTeacherNames}
-
-
-
-
任
-
- 任课老师
- {subjectTeacherNames}
-
-
-
-
-
- {ADMIN_METRIC_META.map((metric) => {
- const value =
- metric.key === 'all'
- ? `${attendanceRate}%`
- : metric.key === 'absent'
- ? summary.absent + summary.pending
- : (summary[metric.key as keyof AttendanceSummary] ?? 0);
- const meta = STATUS_META[metric.key] ?? { className: 'is-present' };
- return (
-
- );
- })}
-
-
-
- {alerts.length > 0 && (
-
-
-
- {alerts.length} 名学生存在连续异常
- 建议优先核查最近 14 天的缺勤记录
-
-
`${item.studentName}:${item.type}${item.count}次`)
- .join(';')}
- >
-
-
-
- )}
-
-
-
-
-
-
- 正常
-
-
-
- 请假
-
-
-
- 缺勤
-
- 仅显示当天已生成考勤的课程/时段,不再为无课时段补默认缺勤
-
-
- {visibleStudents.length === 0 ? (
-
- ) : (
-
- {visibleStudents.map((student) => (
-
- ))}
-
- )}
-
-
-
-
-
- rowKey="id"
- columns={columns}
- dataSource={records}
- loading={loading}
- scroll={{ x: 'max-content' }}
- pagination={{
- current: page,
- pageSize,
- total,
- showSizeChanger: true,
- showTotal: (value) => `共 ${value} 条历史记录`,
- onChange: (nextPage, nextPageSize) => {
- setPage(nextPage);
- setPageSize(nextPageSize);
- },
- }}
- locale={{
- emptyText: (
-
- ),
- }}
- />
-
-
- setSelectedStudent(null)}
- width={isMobile ? '100%' : 520}
- title="学生考勤明细"
- className="student-detail-drawer"
- >
- {selectedStudent && (
-
-
- {selectedStudent.studentName.slice(0, 1)}
-
-
{selectedStudent.studentName}
-
- {selectedStudent.className}
- {selectedStudent.studentNo ? ` · 学号 ${selectedStudent.studentNo}` : ''}
-
-
-
- {selectedStudent.rate}%
- 累计出勤率
-
-
-
- {sortAttendanceRecords(selectedStudent.records).map((record) => {
- const normal = record.status === 'present';
- return (
-
- {normal ? '100%' : '0%'}
- {sessionMap[record.session] || record.session}
-
- );
- })}
-
-
- 当天打卡时间
-
- {sortAttendanceRecords(selectedStudent.records).map((record) => (
-
-
{sessionMap[record.session] || record.session}
-
-
- {record.punchTime ? dayjs(record.punchTime).format('HH:mm:ss') : '未记录'}
-
- {canEdit && (
-
void updateAdminRecordStatus(record, String(value))}
- />
- )}
-
- ))}
-
-
-
- )}
-
-
- setPeriodModalOpen(false)}
- footer={(_, { OkBtn, CancelBtn }) => (
- <>
-
-
-
- >
- )}
- >
-
-
- {(fields, { add, remove }) => (
-
- )}
-
-
-
-
- );
-};
-
export default AttendancePage;
diff --git a/apps/admin/src/pages/Attendance/teacher.tsx b/apps/admin/src/pages/Attendance/teacher.tsx
new file mode 100644
index 0000000..582e056
--- /dev/null
+++ b/apps/admin/src/pages/Attendance/teacher.tsx
@@ -0,0 +1,218 @@
+import React, { useCallback, useMemo, useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { Button, Card, Col, Empty, Row, Spin, Tag, Tooltip } from 'antd';
+import {
+ ArrowRightOutlined,
+ CheckCircleFilled,
+ ClockCircleOutlined,
+ ReloadOutlined,
+ ScheduleOutlined,
+ TeamOutlined,
+} from '@ant-design/icons';
+import dayjs from 'dayjs';
+import api from '../../api';
+import { message } from '../../ui/app-message';
+import { getErrorMessage } from '../../utils/error';
+import {
+ canPullAttendance,
+ getSchedulePhase,
+ type SchedulePhase,
+} from './attendance-workspace';
+import LessonAttendanceDetail from './LessonAttendanceDetail';
+import type { LessonAttendanceSchedule } from './types';
+
+type TodaySchedule = LessonAttendanceSchedule;
+
+interface AssignedClass {
+ classId: number;
+ className: string;
+ classCode: string;
+ roleType: string;
+ subject: string;
+}
+
+interface TeacherWorkspaceData {
+ assignedClasses: AssignedClass[];
+ todaySchedules: TodaySchedule[];
+}
+
+export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ canCreate }) => {
+ const [selectedSchedule, setSelectedSchedule] = useState(null);
+
+ const {
+ data: workspace,
+ isLoading,
+ isFetching,
+ refetch,
+ } = useQuery({
+ queryKey: ['attendance', 'workspace'],
+ queryFn: async () => {
+ try {
+ return await api.get('/rbac/teacher-workspace');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, '加载今日课程失败'));
+ return null;
+ }
+ },
+ });
+ const loading = isLoading || isFetching;
+ const loadWorkspace = useCallback(() => refetch(), [refetch]);
+
+ const classNameById = useMemo(
+ () => new Map(workspace?.assignedClasses.map((item) => [item.classId, item.className]) ?? []),
+ [workspace],
+ );
+
+ const openAttendance = useCallback((schedule: TodaySchedule) => {
+ setSelectedSchedule(schedule);
+ }, []);
+
+ const now = new Date();
+ const schedules = workspace?.todaySchedules ?? [];
+ const startedCount = schedules.filter((item) =>
+ canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)),
+ ).length;
+ const nextSchedule = schedules.find(
+ (item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
+ );
+
+ return (
+
+
+
+
+ TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
+
+
今天,从课程开始
+
课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。
+
+ } onClick={() => void loadWorkspace()}>
+ 刷新
+
+
+
+
+
+
+ 今日课程
+ {schedules.length}
+ 节
+
+
+
+
+ 已开始
+ {startedCount}
+ 节,可查看考勤
+
+
+
+
+ 下一节
+ {nextSchedule ? nextSchedule.startTime : '—'}
+ {nextSchedule?.subject || '今天没有更多课程'}
+
+
+
+
+
+
+ 今日教学节奏
+
我的课程
+
+
课程开始后可拉取钉钉考勤记录
+
+
+
+ {schedules.length === 0 ? (
+
+
+ 今天还没有课程
+ 请联系教务管理员安排课程。
+
+ }
+ />
+
+ ) : (
+
+ {schedules.map((schedule, index) => {
+ const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
+ return (
+ openAttendance(schedule) : undefined}
+ />
+ );
+ })}
+
+ )}
+
+
+ {canCreate ? (
+ setSelectedSchedule(null)}
+ />
+ ) : null}
+
+ );
+};
+
+export const LessonCard: React.FC<{
+ schedule: TodaySchedule;
+ phase: SchedulePhase;
+ className: string;
+ index: number;
+ onOpen?: () => void;
+}> = ({ schedule, phase, className, index, onOpen }) => {
+ const phaseMeta = {
+ upcoming: { label: '待上课', icon: , tone: 'upcoming' },
+ ongoing: { label: '进行中', icon: , tone: 'ongoing' },
+ ended: { label: '已结束', icon: , tone: 'ended' },
+ }[phase];
+
+ return (
+
+ {String(index).padStart(2, '0')}
+
+ {schedule.startTime}
+
+ {schedule.endTime}
+
+
+
+
{schedule.subject}
+ {phaseMeta.label}
+
+
+ {className}
+ 教室 {schedule.classroomId}
+
+
+
+ {phase === 'upcoming' ? (
+
+
+
+ ) : onOpen ? (
+
+ ) : null}
+
+
+ );
+};
diff --git a/apps/admin/src/pages/AttendanceDevices.tsx b/apps/admin/src/pages/AttendanceDevices.tsx
index 1a2eedc..b022f24 100644
--- a/apps/admin/src/pages/AttendanceDevices.tsx
+++ b/apps/admin/src/pages/AttendanceDevices.tsx
@@ -1,4 +1,8 @@
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useMemo, useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useApiMutation } from '../hooks/useApiMutation';
+import { validateResponse } from '../utils/validate';
+import { attendanceDevicesSchema, classroomOptionsSchema } from '../api/schemas';
import { Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined } from '@ant-design/icons';
@@ -30,34 +34,57 @@ const statusMeta = {
} as const;
const AttendanceDevicesPage: React.FC = () => {
- const [data, setData] = useState([]);
- const [classrooms, setClassrooms] = useState([]);
- const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState(null);
const [saving, setSaving] = useState(false);
const [keyword, setKeyword] = useState('');
const [form] = Form.useForm();
- const loadData = async () => {
- setLoading(true);
- try {
- const [devices, classroomList] = await Promise.all([
- api.get('/attendance-devices'),
- api.get('/classrooms'),
- ]);
- setData(devices);
- setClassrooms(classroomList.filter((item: any) => item.status !== 'archived'));
- } catch (error: any) {
- message.error(error?.message || '加载考勤机绑定失败');
- } finally {
- setLoading(false);
- }
- };
+ const {
+ data: fetchResult = { devices: [], classrooms: [] },
+ isLoading,
+ isFetching,
+ } = useQuery<{ devices: AttendanceDeviceRow[]; classrooms: ClassroomOption[] }>({
+ queryKey: ['attendance-devices'],
+ queryFn: async () => {
+ try {
+ const [devices, classroomList] = await Promise.all([
+ api.get('/attendance-devices'),
+ api.get('/classrooms'),
+ ]);
+ return {
+ devices: validateResponse(attendanceDevicesSchema, devices),
+ classrooms: validateResponse(
+ classroomOptionsSchema,
+ classroomList,
+ ).filter((item: any) => item.status !== 'archived'),
+ };
+ } catch (error: any) {
+ message.error(error?.message || '加载考勤机绑定失败');
+ return { devices: [], classrooms: [] };
+ }
+ },
+ });
+ const data = fetchResult.devices;
+ const classrooms = fetchResult.classrooms;
+ const loading = isLoading || isFetching;
- useEffect(() => {
- void loadData();
- }, []);
+ const saveMutation = useApiMutation(
+ async (values: Record) =>
+ editing
+ ? api.put(`/attendance-devices/${editing.id}`, values)
+ : api.post('/attendance-devices', values),
+ { invalidate: [['attendance-devices']] },
+ );
+ const saveCellMutation = useApiMutation(
+ async ({ record, field, value }: { record: AttendanceDeviceRow; field: string; value: unknown }) =>
+ api.put(`/attendance-devices/${record.id}`, { [field]: value }),
+ { invalidate: [['attendance-devices']] },
+ );
+ const deleteMutation = useApiMutation(
+ async (id: number) => api.delete(`/attendance-devices/${id}`),
+ { invalidate: [['attendance-devices']] },
+ );
const classroomOptions = useMemo(
() =>
@@ -102,37 +129,33 @@ const AttendanceDevicesPage: React.FC = () => {
const values = await form.validateFields();
setSaving(true);
try {
- if (editing) {
- await api.put(`/attendance-devices/${editing.id}`, values);
- message.success('考勤机绑定已更新');
- } else {
- await api.post('/attendance-devices', values);
- message.success('考勤机绑定已创建');
- }
+ await saveMutation.mutateAsync(values);
+ message.success(editing ? '考勤机绑定已更新' : '考勤机绑定已创建');
setModalOpen(false);
setEditing(null);
form.resetFields();
- await loadData();
- } catch (error: any) {
- message.error(error?.message || '保存失败');
+ } catch {
+ // 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const saveCell = async (record: AttendanceDeviceRow, field: string, value: unknown) => {
- await api.put(`/attendance-devices/${record.id}`, { [field]: value });
- message.success('已保存');
- await loadData();
+ try {
+ await saveCellMutation.mutateAsync({ record, field, value });
+ message.success('已保存');
+ } catch {
+ // 错误提示由 useApiMutation 统一处理
+ }
};
const handleDelete = async (id: number) => {
try {
- await api.delete(`/attendance-devices/${id}`);
+ await deleteMutation.mutateAsync(id);
message.success('已停用绑定');
- await loadData();
- } catch (error: any) {
- message.error(error?.message || '停用失败');
+ } catch {
+ // 错误提示由 useApiMutation 统一处理
}
};
diff --git a/apps/server/src/attendance/attendance-calendar.service.ts b/apps/server/src/attendance/attendance-calendar.service.ts
new file mode 100644
index 0000000..2c5b7c7
--- /dev/null
+++ b/apps/server/src/attendance/attendance-calendar.service.ts
@@ -0,0 +1,118 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository, Between } from 'typeorm';
+import { AttendanceRecord, ClassSchedule } from '../entities';
+import type { AttendanceCalendarQueryDto } from './dto/attendance.dto';
+
+@Injectable()
+export class AttendanceCalendarService {
+ constructor(
+ @InjectRepository(AttendanceRecord)
+ private attendanceRepo: Repository,
+ @InjectRepository(ClassSchedule)
+ private scheduleRepo: Repository,
+ ) {}
+
+ 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 getWeekDayForDate(date: string): number {
+ const day = new Date(`${date}T00:00:00+08:00`).getUTCDay();
+ return day === 0 ? 7 : day;
+ }
+
+ async getScheduleOptionsForAttendance(classId: number, date: string) {
+ const weekDay = this.getWeekDayForDate(date);
+ const { entities, raw } = await this.scheduleRepo
+ .createQueryBuilder('cs')
+ .leftJoin('cs.teacher', 'teacher')
+ .addSelect('cs.id', 'scheduleIdForTeacherMap')
+ .addSelect('teacher.username', 'teacherUsername')
+ .addSelect('teacher.name', 'teacherName')
+ .where('cs.classId = :classId', { classId })
+ .andWhere('cs.weekDay = :weekDay', { weekDay })
+ .andWhere('cs.startDate <= :date', { date })
+ .andWhere('cs.endDate >= :date', { date })
+ .andWhere('cs.status = :status', { status: 'active' })
+ .orderBy('cs.startTime', 'ASC')
+ .addOrderBy('cs.subject', 'ASC')
+ .getRawAndEntities();
+
+ const teacherByScheduleId = new Map(
+ raw.map((row: { scheduleIdForTeacherMap: string; teacherName: string | null; teacherUsername: string | null }) => [
+ Number(row.scheduleIdForTeacherMap),
+ {
+ teacherName: row.teacherName || null,
+ teacherUsername: row.teacherUsername || null,
+ },
+ ]),
+ );
+
+ return entities.map((schedule) => {
+ const teacher = teacherByScheduleId.get(schedule.id) ?? {
+ teacherName: null,
+ teacherUsername: null,
+ };
+ return { ...schedule, ...teacher };
+ });
+ }
+
+ 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);
+
+ 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());
+ }
+}
diff --git a/apps/server/src/attendance/attendance-device.ts b/apps/server/src/attendance/attendance-device.ts
new file mode 100644
index 0000000..88e5bc4
--- /dev/null
+++ b/apps/server/src/attendance/attendance-device.ts
@@ -0,0 +1,72 @@
+import { In, Repository } from 'typeorm';
+import { AttendanceDevice } from '../entities/attendance-device.entity';
+import type { AttendanceRecord } from '../entities/attendance-record.entity';
+
+function formatDeviceDetail(device: AttendanceDevice): string {
+ const classroomName = device.classroom?.name;
+ return classroomName ? `${device.deviceName} · ${classroomName}` : device.deviceName;
+}
+
+/**
+ * 为考勤记录补充打卡设备名称:
+ * 优先按设备序列号匹配,其次按教室绑定的设备兜底。
+ */
+export async function attachAttendanceDeviceMappings(
+ records: T[],
+ attendanceDeviceRepo: Repository,
+ classroomId?: number | null,
+): Promise {
+ if (records.length === 0) return records;
+ const sns = [
+ ...new Set(records.map((record) => record.punchDeviceId?.trim()).filter(Boolean) as string[]),
+ ];
+ const devicesBySn = new Map();
+ if (sns.length > 0) {
+ const devices = await attendanceDeviceRepo.find({
+ where: { deviceSn: In(sns) },
+ relations: ['classroom'],
+ });
+ for (const device of devices) devicesBySn.set(device.deviceSn, device);
+ }
+
+ const classroomIds = [
+ ...new Set([
+ ...records.map((record) => record.classId).filter((id): id is number => id != null),
+ ...(classroomId != null ? [classroomId] : []),
+ ]),
+ ];
+ const devicesByClassroom = new Map();
+ if (classroomIds.length > 0) {
+ const devices = await attendanceDeviceRepo.find({
+ where: { classroomId: In(classroomIds), status: 'active' },
+ relations: ['classroom'],
+ order: { id: 'ASC' },
+ });
+ for (const device of devices) {
+ if (!devicesByClassroom.has(device.classroomId)) devicesByClassroom.set(device.classroomId, device);
+ }
+ }
+
+ for (const record of records) {
+ const sn = record.punchDeviceId?.trim();
+ const mappedBySn = sn ? devicesBySn.get(sn) : undefined;
+ if (mappedBySn) {
+ record.punchDeviceName = formatDeviceDetail(mappedBySn);
+ record.punchDeviceId = mappedBySn.deviceSn;
+ continue;
+ }
+ const source = (record.punchSource || '').trim().toUpperCase();
+ const isMachine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some(
+ (value) => source === value || source.includes(value),
+ );
+ const fallbackClassroomId = record.classId ?? classroomId ?? undefined;
+ const mappedByClassroom = fallbackClassroomId
+ ? devicesByClassroom.get(fallbackClassroomId)
+ : undefined;
+ if (isMachine && mappedByClassroom && !record.punchDeviceName) {
+ record.punchDeviceName = formatDeviceDetail(mappedByClassroom);
+ record.punchDeviceId = record.punchDeviceId || mappedByClassroom.deviceSn;
+ }
+ }
+ return records;
+}
diff --git a/apps/server/src/attendance/attendance-dingtalk.ts b/apps/server/src/attendance/attendance-dingtalk.ts
new file mode 100644
index 0000000..e397411
--- /dev/null
+++ b/apps/server/src/attendance/attendance-dingtalk.ts
@@ -0,0 +1,101 @@
+import { AttendanceRecord, DingAttendanceRaw, ClassSchedule } from '../entities';
+import { toMinutes, shiftDate } from './attendance-time';
+
+export type LessonScheduleLike = Pick<
+ ClassSchedule,
+ 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'
+>;
+
+export function getLessonAttendanceWindow(
+ schedule: LessonScheduleLike,
+ lessonDate: string,
+): { start: number; end: number; dateFrom: string; dateTo: string } {
+ const startMinuteOfDay = toMinutes(schedule.startTime);
+ const endMinuteOfDay = toMinutes(schedule.endTime);
+ const advanceMinutes = Math.max(0, schedule.attendanceAdvanceMinutes ?? 30);
+ const lessonStart = new Date(`${lessonDate}T${schedule.startTime}:00+08:00`).getTime();
+ let lessonEnd = new Date(`${lessonDate}T${schedule.endTime}:00+08:00`).getTime();
+ const overnight = endMinuteOfDay <= startMinuteOfDay;
+ if (overnight) lessonEnd += 24 * 60 * 60 * 1000;
+
+ return {
+ start: lessonStart - advanceMinutes * 60 * 1000,
+ end: lessonEnd,
+ dateFrom: advanceMinutes > startMinuteOfDay ? shiftDate(lessonDate, -1) : lessonDate,
+ dateTo: overnight ? shiftDate(lessonDate, 1) : lessonDate,
+ };
+}
+
+export function getLessonAttendanceImportDateRange(
+ schedule: LessonScheduleLike,
+ lessonDate: string,
+): { startDate: string; endDate: string } {
+ const window = getLessonAttendanceWindow(schedule, lessonDate);
+ return { startDate: window.dateFrom, endDate: window.dateTo };
+}
+
+export function selectDingTalkRecordsForLesson(
+ records: DingAttendanceRaw[],
+ schedule: LessonScheduleLike,
+ lessonDate: string,
+): DingAttendanceRaw[] {
+ const window = getLessonAttendanceWindow(schedule, lessonDate);
+ return records.filter((record) => {
+ // 上班、下班打卡都有效,按原始记录中实际存在的时间判断。
+ const time = record.checkInTime ?? record.checkOutTime;
+ return time && time.getTime() >= window.start && time.getTime() <= window.end;
+ });
+}
+
+export function mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
+ const hasPunch = records.some((record) => record.checkInTime || record.checkOutTime);
+ if (hasPunch) return 'present';
+ return finalize ? 'absent' : 'pending';
+}
+
+export function getLessonPunchMetadata(
+ records: DingAttendanceRaw[],
+ lessonDate: string,
+ startTime: string,
+): Pick {
+ const punches = records
+ .map((record) => ({ record, time: record.checkInTime ?? record.checkOutTime }))
+ .filter((item): item is { record: DingAttendanceRaw; time: Date } => !!item.time);
+ if (punches.length === 0) {
+ return {
+ punchTime: null,
+ punchSource: null,
+ punchDeviceName: null,
+ punchDeviceId: null,
+ };
+ }
+
+ const lessonStart = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
+ punches.sort(
+ (left, right) =>
+ Math.abs(left.time.getTime() - lessonStart) - Math.abs(right.time.getTime() - lessonStart),
+ );
+ const primary = punches[0];
+ const metadataRecord = [...punches]
+ .filter(({ record }) =>
+ !!(record.punchSource || record.punchDeviceName || record.punchDeviceId) ||
+ !['OnDuty', 'OffDuty'].includes(record.attendanceType),
+ )
+ .sort(
+ (left, right) =>
+ Math.abs(left.time.getTime() - primary.time.getTime()) -
+ Math.abs(right.time.getTime() - primary.time.getTime()),
+ )[0]?.record;
+ const source =
+ metadataRecord?.punchSource ||
+ (metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType)
+ ? metadataRecord.attendanceType
+ : primary.record.punchSource);
+
+ return {
+ punchTime: primary.time,
+ punchSource: source || null,
+ punchDeviceName: metadataRecord?.punchDeviceName || primary.record.punchDeviceName || null,
+ punchDeviceId: metadataRecord?.punchDeviceId || primary.record.punchDeviceId || null,
+ };
+}
diff --git a/apps/server/src/attendance/attendance-generation.service.ts b/apps/server/src/attendance/attendance-generation.service.ts
new file mode 100644
index 0000000..734b1a8
--- /dev/null
+++ b/apps/server/src/attendance/attendance-generation.service.ts
@@ -0,0 +1,299 @@
+import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { DataSource, Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
+import {
+ AttendanceRecord,
+ Class,
+ ClassSchedule,
+ ClassStudent,
+ AttendanceSession,
+ AttendancePeriodConfig,
+ ScheduleType,
+} from '../entities';
+import { toMinutes, isClassStudentActiveOnDate } from './attendance-time';
+import type { BatchCreateAttendanceDto, GenerateAttendanceFromSchedulesDto, GenerateFromSchedulesDto, SaveAttendancePeriodConfigsDto } from './dto/attendance.dto';
+
+@Injectable()
+export class AttendanceGenerationService {
+ private readonly defaultAttendancePeriods = [
+ { periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1 },
+ { periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2 },
+ { periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 },
+ { periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4 },
+ ] as const;
+
+ constructor(
+ @InjectRepository(AttendanceRecord) private attendanceRepo: Repository,
+ @InjectRepository(ClassSchedule) private scheduleRepo: Repository,
+ @InjectRepository(Class) private classRepo: Repository,
+ @InjectRepository(ClassStudent) private classStudentRepo: Repository,
+ @InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository,
+ @InjectRepository(AttendancePeriodConfig) private attendancePeriodConfigRepo: Repository,
+ private dataSource: DataSource,
+ ) {}
+
+ 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) => {
+ const entity = 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',
+ });
+ return entity;
+ });
+
+ const saved = await this.attendanceRepo.save(entities);
+ return { count: saved.length, records: saved };
+ }
+
+ // ── Generate attendance records from class schedules ──
+ async generateAttendanceFromSchedules(dto: GenerateAttendanceFromSchedulesDto) {
+ const { classId, dateFrom, dateTo } = dto;
+
+ if (dateFrom > dateTo) {
+ throw new BadRequestException('dateFrom must not be later than dateTo');
+ }
+
+ const cls = await this.classRepo.findOne({ where: { id: classId } });
+ if (!cls) {
+ throw new NotFoundException(`Class ${classId} not found`);
+ }
+
+ const schedules = await this.scheduleRepo.find({
+ where: {
+ classId,
+ scheduleType: ScheduleType.INTERNAL,
+ status: 'active',
+ startDate: LessThanOrEqual(dateTo),
+ endDate: MoreThanOrEqual(dateFrom),
+ },
+ });
+
+ const classStudents = await this.classStudentRepo.find({
+ where: { classId, status: In(['active', 'left']) },
+ relations: ['student'],
+ });
+
+ if (schedules.length === 0 || classStudents.length === 0) {
+ return { count: 0, records: [] };
+ }
+
+ const existingRecords = await this.attendanceRepo.find({
+ where: { classId, attendanceDate: Between(dateFrom, dateTo) },
+ });
+ const existingKeys = new Set(
+ existingRecords.map((r) => `${r.studentId}|${r.attendanceDate}|${r.session}`),
+ );
+
+ const entities: AttendanceRecord[] = [];
+ const end = new Date(dateTo);
+ for (let d = new Date(dateFrom); d <= end; d.setDate(d.getDate() + 1)) {
+ const dateStr = d.toISOString().slice(0, 10);
+ const weekDay = d.getDay() === 0 ? 7 : d.getDay();
+
+ for (const sched of schedules) {
+ if (sched.weekDay !== weekDay) continue;
+ if (dateStr < sched.startDate || dateStr > sched.endDate) continue;
+
+ const session = await this.mapScheduleTimeToSession(sched.startTime);
+ const classStudentsForDate = classStudents.filter((cs) =>
+ isClassStudentActiveOnDate(cs, dateStr),
+ );
+ for (const cs of classStudentsForDate) {
+ const key = `${cs.studentId}|${dateStr}|${session}`;
+ if (existingKeys.has(key)) continue;
+
+ const entity = this.attendanceRepo.create({
+ studentId: cs.studentId,
+ classId,
+ attendanceDate: dateStr,
+ session,
+ status: 'pending',
+ source: 'schedule',
+ });
+ entities.push(entity);
+ existingKeys.add(key);
+ }
+ }
+ }
+
+ const saved = await this.attendanceRepo.save(entities);
+ return { count: saved.length, records: saved };
+ }
+
+ // ── Generate attendance records from schedules (optional date range, defaults to current week) ──
+ async generateFromSchedules(
+ dto: GenerateFromSchedulesDto,
+ ): Promise<{ count: number; records: AttendanceRecord[] }> {
+ const { classId, startDate, endDate } = dto;
+
+ // Default to current week (Monday–Sunday)
+ const now = new Date();
+ const dayOfWeek = now.getDay();
+ const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
+ const monday = new Date(now);
+ monday.setDate(now.getDate() + mondayOffset);
+ monday.setHours(0, 0, 0, 0);
+ const sunday = new Date(monday);
+ sunday.setDate(monday.getDate() + 6);
+ sunday.setHours(23, 59, 59, 999);
+
+ const dateFrom = startDate ?? monday.toISOString().slice(0, 10);
+ const dateTo = endDate ?? sunday.toISOString().slice(0, 10);
+
+ return this.generateAttendanceFromSchedules({
+ classId,
+ dateFrom,
+ dateTo,
+ });
+ }
+
+ private toMinutes(time: string): number {
+ const [hour, minute] = time.split(':').map(Number);
+ return hour * 60 + minute;
+ }
+
+ private getCourseClock(date: Date): { date: string; minutes: number } {
+ const parts = Object.fromEntries(
+ new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'Asia/Shanghai',
+ year: 'numeric', month: '2-digit', day: '2-digit',
+ hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
+ }).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
+ );
+ return {
+ date: `${parts.year}-${parts.month}-${parts.day}`,
+ minutes: Number(parts.hour) * 60 + Number(parts.minute),
+ };
+ }
+
+ private shiftDate(date: string, days: number): string {
+ const shifted = new Date(`${date}T00:00:00.000Z`);
+ shifted.setUTCDate(shifted.getUTCDate() + days);
+ return shifted.toISOString().slice(0, 10);
+ }
+
+ private async ensureAttendancePeriodConfigs() {
+ const count = await this.attendancePeriodConfigRepo.count();
+ if (count === 0) {
+ await this.attendancePeriodConfigRepo.save(
+ this.defaultAttendancePeriods.map((period) => this.attendancePeriodConfigRepo.create({
+ ...period,
+ enabled: true,
+ })),
+ );
+ }
+ return this.attendancePeriodConfigRepo.find({ order: { sortOrder: 'ASC', id: 'ASC' } });
+ }
+
+ async getAttendancePeriodConfigs() {
+ return this.ensureAttendancePeriodConfigs();
+ }
+
+ async getRefreshableSchedules(date: string, classId?: number, session?: string, accessibleClassIds?: number[]) {
+ const parsedDate = new Date(`${date}T00:00:00`);
+ if (Number.isNaN(parsedDate.getTime())) throw new BadRequestException('无效日期');
+ const weekDay = parsedDate.getDay() === 0 ? 7 : parsedDate.getDay();
+ const qb = this.scheduleRepo
+ .createQueryBuilder('schedule')
+ .where('schedule.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL })
+ .andWhere('schedule.status = :status', { status: 'active' })
+ .andWhere('schedule.classId IS NOT NULL')
+ .andWhere('schedule.weekDay = :weekDay', { weekDay })
+ .andWhere('schedule.startDate <= :date', { date })
+ .andWhere('schedule.endDate >= :date', { date });
+
+ if (classId) {
+ qb.andWhere('schedule.classId = :classId', { classId });
+ } else if (accessibleClassIds) {
+ if (accessibleClassIds.length === 0) return [];
+ qb.andWhere('schedule.classId IN (:...accessibleClassIds)', { accessibleClassIds });
+ }
+
+ const schedules = await qb.orderBy('schedule.startTime', 'ASC').getMany();
+ if (!session) return schedules;
+
+ const matchedSchedules: ClassSchedule[] = [];
+ for (const schedule of schedules) {
+ if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) {
+ matchedSchedules.push(schedule);
+ }
+ }
+ return matchedSchedules;
+ }
+
+ async saveAttendancePeriodConfigs(dto: SaveAttendancePeriodConfigsDto) {
+ const seen = new Set();
+ const normalized = dto.periods.map((period, index) => {
+ const periodKey = period.periodKey.trim();
+ const label = period.label.trim();
+ if (!periodKey || !label) throw new BadRequestException('时段标识和名称不能为空');
+ if (seen.has(periodKey)) throw new BadRequestException(`时段标识 ${periodKey} 重复`);
+ seen.add(periodKey);
+ if (toMinutes(period.endTime) <= toMinutes(period.startTime)) {
+ throw new BadRequestException(`${label} 的结束时间必须晚于开始时间`);
+ }
+ return {
+ periodKey,
+ label,
+ startTime: period.startTime,
+ endTime: period.endTime,
+ sortOrder: period.sortOrder ?? index + 1,
+ enabled: period.enabled ?? true,
+ };
+ }).sort((left, right) => left.sortOrder - right.sortOrder);
+
+ // 按开始时间排序后再检查重叠,避免 sortOrder 与时间顺序不一致时漏检
+ const sortedByTime = [...normalized].sort(
+ (left, right) => toMinutes(left.startTime) - toMinutes(right.startTime),
+ );
+ for (let index = 1; index < sortedByTime.length; index += 1) {
+ const previous = sortedByTime[index - 1];
+ const current = sortedByTime[index];
+ if (previous.enabled && current.enabled && toMinutes(current.startTime) < toMinutes(previous.endTime)) {
+ throw new BadRequestException(`${previous.label} 和 ${current.label} 时间段不能重叠`);
+ }
+ }
+
+ await this.attendancePeriodConfigRepo.clear();
+ await this.attendancePeriodConfigRepo.save(
+ normalized.map((period) => this.attendancePeriodConfigRepo.create(period)),
+ );
+ return this.getAttendancePeriodConfigs();
+ }
+
+ async resetAttendancePeriodConfigs() {
+ await this.attendancePeriodConfigRepo.clear();
+ return this.ensureAttendancePeriodConfigs();
+ }
+
+ private mapLessonScheduleTimeToSession(startTime: string): string {
+ const hour = parseInt(startTime.slice(0, 2), 10);
+ if (hour < 8) return 'morning_reading';
+ if (hour < 12) return 'morning';
+ if (hour < 17) return 'afternoon';
+ if (hour < 20) return 'evening_study';
+ return 'night_check';
+ }
+
+ private async mapScheduleTimeToSession(startTime: string): Promise {
+ const startMinutes = toMinutes(startTime);
+ const periods = (await this.ensureAttendancePeriodConfigs()).filter((period) => period.enabled);
+ const matched = periods.find((period) => {
+ const periodStart = toMinutes(period.startTime);
+ const periodEnd = toMinutes(period.endTime);
+ return startMinutes >= periodStart && startMinutes < periodEnd;
+ });
+ if (matched) return matched.periodKey;
+ throw new BadRequestException(`课程开始时间 ${startTime} 未匹配到考勤时段,请先配置考勤时段`);
+ }
+
+}
diff --git a/apps/server/src/attendance/attendance-import.controller.ts b/apps/server/src/attendance/attendance-import.controller.ts
new file mode 100644
index 0000000..e3a93fa
--- /dev/null
+++ b/apps/server/src/attendance/attendance-import.controller.ts
@@ -0,0 +1,147 @@
+import { Controller, Get, Post, Sse, Body, Param, Query, Request, BadRequestException, ForbiddenException, ParseIntPipe } from '@nestjs/common';
+import { Observable, filter } from 'rxjs';
+import { AttendanceControllerBase, RequestUser, SseEvent } from './attendance.controller-base';
+import { AttendanceService } from './attendance.service';
+import { AttendanceImportService } from './attendance-import.service';
+import { OperationLogsService } from '../operation-logs/operation-logs.service';
+import { AuthorizationService } from '../authorization';
+import { logAudit } from '../common/with-audit-log';
+import { extractRequestInfo } from '../common/request-utils';
+import { RequirePermission } from '../auth/decorators/permission.decorator';
+import { DingTalkImportDto } from './dto/dingtalk-import.dto';
+import { QueryDingRawDto, MatchDingRecordDto } from './dto/attendance.dto';
+
+@Controller()
+export class AttendanceImportController extends AttendanceControllerBase {
+ constructor(
+ service: AttendanceService,
+ importService: AttendanceImportService,
+ logService: OperationLogsService,
+ authz: AuthorizationService,
+ ) {
+ super(service, importService, logService, authz);
+ }
+
+ @Get('ding-attendance-raw')
+ @RequirePermission('attendance:view')
+ async getDingRaw(@Query() query: QueryDingRawDto, @Request() req: { user: RequestUser }) {
+ if (query.classId) await this.assertClassAccess(req, query.classId);
+ return this.service.getDingRaw(query, await this.getAccessibleClassIds(req));
+ }
+
+ // ── Match a dingtalk record to a student ──
+ @Post('ding-attendance-raw/:id/match')
+ @RequirePermission('attendance:edit')
+ async matchDingRecord(
+ @Param('id', ParseIntPipe) id: number,
+ @Body() dto: MatchDingRecordDto,
+ @Request() req: any,
+ ) {
+ const result = await this.service.matchDingRecord(id, dto);
+ await logAudit(this.logService, req, {
+ module: '考勤管理', action: '匹配考勤记录', targetId: id, targetType: 'dingAttendanceRaw', detail: `匹配到学生 ${dto.studentId}`,
+ });
+ return result;
+ }
+
+ // ── Attendance class-based report export ──
+
+ @Post('ding-attendance-raw/auto-match')
+ @RequirePermission('attendance:edit')
+ async autoMatch() {
+ return this.service.autoMatchDingRecords();
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // DingTalk attendance import with SSE streaming progress
+ // ═══════════════════════════════════════════════════════════════
+
+ @Get('attendance-records/import/dingtalk/classes')
+ @RequirePermission('attendance:create')
+ getDingTalkImportClasses(@Request() req: { user: RequestUser }) {
+ return this.service.getImportableClasses(req.user.id, this.canManageAllAttendance(req));
+ }
+
+ /**
+ * Trigger DingTalk attendance import.
+ * Mirrors `dws attendance check result` pipeline:
+ * fetch → parse → deduplicate → save → auto-match.
+ */
+ @Post('attendance-records/import/dingtalk')
+ @RequirePermission('attendance:create')
+ async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) {
+ const { ipAddress, userAgent } = extractRequestInfo(req);
+ const canManageAll = this.canManageAllAttendance(req);
+ let userIds: string[];
+
+ if (dto.users) {
+ if (!canManageAll) {
+ throw new ForbiddenException('仅管理员可指定钉钉用户范围');
+ }
+ userIds = dto.users
+ .split(',')
+ .map((value) => value.trim())
+ .filter(Boolean);
+ } else {
+ if (!dto.classId) {
+ throw new BadRequestException('请选择要拉取考勤的班级');
+ }
+ userIds = await this.service.getTeacherClassDingUserIds(
+ req.user.id,
+ dto.classId,
+ canManageAll,
+ dto.start,
+ );
+ }
+
+ const startDate = dto.start ?? this.getTodayDateOnly();
+ const endDate = dto.end ?? startDate;
+ const result = await this.importService.importFromDingTalk({
+ startDate,
+ endDate,
+ userIds,
+ autoMatch: true,
+ userId: req.user.id,
+ });
+
+ await this.logService.log({
+ userId: req.user?.id,
+ username: req.user?.username,
+ module: '考勤管理',
+ action: '钉钉考勤导入',
+ detail: `${startDate}~${endDate}, 导入=${result.imported}, 跳过=${result.skipped}, 匹配=${result.matched}`,
+ ipAddress,
+ userAgent,
+ });
+
+ return result;
+ }
+
+ /**
+ * SSE stream for live import progress.
+ * Connect before triggering the import to receive real-time progress events.
+ *
+ * NOTE: @RequirePermission works with @Sse() in NestJS because guards
+ * execute in the standard request pipeline before the SSE handler is invoked.
+ * If this ever breaks after a NestJS upgrade, verify guard execution order.
+ */
+ @Sse('attendance-records/import/dingtalk/stream')
+ @RequirePermission('attendance:view')
+ importProgressStream(@Request() req: { user: RequestUser }): Observable {
+ const userId = req.user.id;
+ return new Observable((subscriber) => {
+ const subscription = this.importService.progress$
+ .pipe(filter((event) => event.userId === userId))
+ .subscribe({
+ next: (event) => {
+ subscriber.next({ data: JSON.stringify(event) });
+ if (event.phase === 'complete' || event.phase === 'error') {
+ subscriber.complete();
+ }
+ },
+ error: (err: unknown) => subscriber.error(err),
+ });
+ return () => subscription.unsubscribe();
+ });
+ }
+}
diff --git a/apps/server/src/attendance/attendance-import.service.ts b/apps/server/src/attendance/attendance-import.service.ts
index f133074..8895a17 100644
--- a/apps/server/src/attendance/attendance-import.service.ts
+++ b/apps/server/src/attendance/attendance-import.service.ts
@@ -96,13 +96,11 @@ export class AttendanceImportService {
let matched = 0;
try {
- // Stage 1: Fetch
this.emit('fetching', 0, 0, 'Fetching attendance results from DingTalk...');
const rawResults = await this.fetchAllPages(params);
const total = rawResults.length;
this.emit('fetching', total, total, `Fetched ${total} raw attendance records`);
- // Stage 2: Parse & deduplicate
this.emit('parsing', 0, total, `Parsing ${total} records...`);
const existingByDingId = await this.getExistingRecordsByDingId(rawResults);
const newRecords = rawResults.filter((r) => !existingByDingId.has(r.checkId));
@@ -117,7 +115,6 @@ export class AttendanceImportService {
return { success: true, imported, skipped, matched, errors, duration: Date.now() - startedAt };
}
- // Stage 3: Batch save
this.emit('saving', 0, newRecords.length, `Saving ${newRecords.length} records...`);
const batchSize = 100;
for (let i = 0; i < newRecords.length; i += batchSize) {
@@ -134,7 +131,6 @@ export class AttendanceImportService {
}
}
- // Stage 4: Auto-match (optional)
if (params.autoMatch && imported > 0) {
this.emit('matching', 0, imported, 'Auto-matching records to students...');
matched = await this.autoMatchUnmatched();
@@ -305,7 +301,6 @@ export class AttendanceImportService {
entity.punchDeviceName = r.deviceName || null;
entity.punchDeviceId = r.deviceId || null;
- // Parse check-in/out times
if (r.actualCheckTime) {
const dt = new Date(r.actualCheckTime);
if (!isNaN(dt.getTime())) {
diff --git a/apps/server/src/attendance/attendance-lesson.service.ts b/apps/server/src/attendance/attendance-lesson.service.ts
new file mode 100644
index 0000000..632f6c2
--- /dev/null
+++ b/apps/server/src/attendance/attendance-lesson.service.ts
@@ -0,0 +1,377 @@
+import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { DataSource, Repository, In, Between } from 'typeorm';
+import {
+ AttendanceRecord,
+ DingAttendanceRaw,
+ Class,
+ Student,
+ ClassSchedule,
+ ClassStudent,
+ StudentDingMapping,
+ ClassTeacher,
+ AttendanceSession,
+ AttendanceDevice,
+ ScheduleType,
+} from '../entities';
+import { SessionMutex } from './attendance-mutex';
+import { attachAttendanceDeviceMappings } from './attendance-device';
+import { getCourseClock, mapLessonScheduleTimeToSession, isClassStudentActiveOnDate } from './attendance-time';
+import {
+ getLessonAttendanceImportDateRange,
+ getLessonAttendanceWindow,
+ selectDingTalkRecordsForLesson,
+ mapDingTalkStatus,
+ getLessonPunchMetadata,
+} from './attendance-dingtalk';
+
+@Injectable()
+export class AttendanceLessonService {
+ private readonly sessionMutex = new SessionMutex();
+
+ constructor(
+ @InjectRepository(AttendanceRecord) private attendanceRepo: Repository,
+ @InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository,
+ @InjectRepository(Class) private classRepo: Repository,
+ @InjectRepository(Student) private studentRepo: Repository,
+ @InjectRepository(ClassSchedule) private scheduleRepo: Repository,
+ @InjectRepository(ClassStudent) private classStudentRepo: Repository,
+ @InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository,
+ @InjectRepository(ClassTeacher) private classTeacherRepo: Repository,
+ @InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository,
+ @InjectRepository(AttendanceDevice) private attendanceDeviceRepo: Repository,
+ private dataSource: DataSource,
+ ) {}
+
+ async getClassStudentsForLesson(
+ classId: number,
+ lessonDate: string,
+ relations: string[] = [],
+ ): Promise {
+ const classStudents = await this.classStudentRepo.find({
+ where: { classId, status: In(['active', 'left']) },
+ relations,
+ });
+ return classStudents.filter((classStudent) =>
+ isClassStudentActiveOnDate(classStudent, lessonDate),
+ );
+ }
+
+ /** List classes the current user may select for DingTalk attendance import. */
+ private async getScheduleOccurrence(scheduleId: number, lessonDate: string) {
+ const schedule = await this.scheduleRepo.findOne({ where: { id: scheduleId } });
+ if (!schedule) throw new NotFoundException('排课记录不存在');
+ if ((schedule.scheduleType as ScheduleType) !== ScheduleType.INTERNAL || schedule.status !== 'active') {
+ throw new BadRequestException('该排课不能进行课程考勤');
+ }
+ if (schedule.classId == null) throw new BadRequestException('该排课未关联班级');
+ if (lessonDate < schedule.startDate || lessonDate > schedule.endDate) {
+ throw new BadRequestException('所选日期不在排课有效期内');
+ }
+ const date = new Date(`${lessonDate}T00:00:00`);
+ const weekDay = date.getDay() === 0 ? 7 : date.getDay();
+ if (weekDay !== schedule.weekDay) throw new BadRequestException('所选日期不是该课程的上课日');
+ return schedule;
+ }
+
+ async getLessonAttendance(scheduleId: number, lessonDate: string) {
+ const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
+ const session = await this.attendanceSessionRepo.findOne({
+ where: { scheduleId, lessonDate },
+ });
+ const records = session
+ ? await this.attendanceRepo.find({
+ where: { attendanceSessionId: session.id },
+ relations: ['student'],
+ order: { studentId: 'ASC' },
+ })
+ : [];
+ return { schedule, session, records: await attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo, schedule.classId) };
+ }
+
+ getLessonAttendanceImportDateRange(
+ schedule: Pick,
+ lessonDate: string,
+ ): { startDate: string; endDate: string } {
+ return getLessonAttendanceImportDateRange(schedule, lessonDate);
+ }
+ async createLessonAttendanceFromDingTalk(
+ scheduleId: number,
+ lessonDate: string,
+ userId: number,
+ finalize = false,
+ ) {
+ const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
+ const now = new Date();
+ const courseClock = getCourseClock(now);
+ const today = courseClock.date;
+ if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤');
+ if (lessonDate === today) {
+ const [hour, minute] = schedule.startTime.split(':').map(Number);
+ const startMinute = hour * 60 + minute;
+ const currentMinute = courseClock.minutes;
+ if (currentMinute < startMinute) {
+ throw new BadRequestException('课程尚未开始,不能拉取考勤');
+ }
+ }
+
+ const existing = await this.attendanceSessionRepo.findOne({
+ where: { scheduleId, lessonDate },
+ });
+
+ if (existing) {
+ if (
+ existing.status !== 'in_progress' &&
+ existing.status !== 'completed' &&
+ !(finalize && existing.status === 'settling')
+ ) {
+ throw new BadRequestException('课程考勤正在结算');
+ }
+
+ // Refresh latest DingTalk data even after automatic settlement; late-arriving punches
+ // may legitimately change a DingTalk-generated absence to present.
+ return this.dataSource.transaction(async (manager) => {
+ const sessionRepo = manager.getRepository(AttendanceSession);
+ const recordRepo = manager.getRepository(AttendanceRecord);
+ const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
+ const effectiveFinalize = finalize || existing.status === 'completed';
+ const existingRecords = await recordRepo.find({
+ where: { attendanceSessionId: existing.id },
+ order: { studentId: 'ASC' },
+ });
+ const classStudents = await this.getClassStudentsForLesson(
+ schedule.classId!,
+ lessonDate,
+ ['student'],
+ );
+ const studentsById = new Map(
+ classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
+ );
+ const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
+
+ const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
+ const updatedRecords = existingRecords.map((record) => {
+ record.student = studentsById.get(record.studentId)!;
+ // Preserve manual corrections only while the lesson is still in progress.
+ if (!finalize && record.source !== 'dingtalk') return record;
+
+ const raw = selectDingTalkRecordsForLesson(
+ rawByStudent.get(record.studentId) ?? [],
+ schedule,
+ lessonDate,
+ );
+ record.status = mapDingTalkStatus(raw, effectiveFinalize);
+ Object.assign(record, getLessonPunchMetadata(
+ raw,
+ lessonDate,
+ schedule.startTime,
+ ));
+ record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
+ ? null
+ : effectiveFinalize
+ ? '课程截止仍未打卡'
+ : '未获取到钉钉打卡结果';
+ return record;
+ });
+ for (const classStudent of classStudents) {
+ if (existingStudentIds.has(classStudent.studentId)) continue;
+ const raw = selectDingTalkRecordsForLesson(
+ rawByStudent.get(classStudent.studentId) ?? [],
+ schedule,
+ lessonDate,
+ );
+ updatedRecords.push(
+ recordRepo.create({
+ studentId: classStudent.studentId,
+ student: classStudent.student,
+ classId: schedule.classId!,
+ scheduleId,
+ attendanceSessionId: existing.id,
+ attendanceDate: lessonDate,
+ session: lessonSessionKey,
+ status: mapDingTalkStatus(raw, effectiveFinalize),
+ source: 'dingtalk',
+ ...getLessonPunchMetadata(
+ raw,
+ lessonDate,
+ schedule.startTime,
+ ),
+ remark: raw.some((item) => item.checkInTime || item.checkOutTime)
+ ? undefined
+ : effectiveFinalize
+ ? '课程截止仍未打卡'
+ : '未获取到钉钉打卡结果',
+ }),
+ );
+ }
+
+ const saved = await recordRepo.save(updatedRecords);
+ if (finalize) {
+ existing.status = 'completed';
+ existing.completedBy = userId;
+ existing.completedAt = new Date();
+ await sessionRepo.save(existing);
+ }
+ return { schedule, session: existing, records: await attachAttendanceDeviceMappings(saved, this.attendanceDeviceRepo, schedule.classId) };
+ });
+ }
+
+ // First pull: create session and records atomically
+ return this.dataSource.transaction(async (manager) => {
+ const sessionRepo = manager.getRepository(AttendanceSession);
+ const recordRepo = manager.getRepository(AttendanceRecord);
+ const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
+
+ const classStudents = await this.getClassStudentsForLesson(
+ schedule.classId!,
+ lessonDate,
+ ['student'],
+ );
+ if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生');
+
+ let session: AttendanceSession;
+ try {
+ session = await sessionRepo.save(
+ sessionRepo.create({
+ scheduleId,
+ classId: schedule.classId!,
+ lessonDate,
+ status: 'in_progress',
+ startedBy: userId,
+ startedAt: new Date(),
+ }),
+ );
+ } catch (err: unknown) {
+ const code = (err as Record).code;
+ const errno = (err as Record).errno;
+ // MySQL: ER_DUP_ENTRY or errno 1062; SQLite: SQLITE_CONSTRAINT
+ if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') {
+ const existing = await sessionRepo.findOne({
+ where: { scheduleId, lessonDate },
+ });
+ if (existing) {
+ session = existing;
+ const existingRecords = await recordRepo.find({
+ where: { attendanceSessionId: session.id },
+ relations: ['student'],
+ order: { studentId: 'ASC' },
+ });
+ return { schedule, session, records: await attachAttendanceDeviceMappings(existingRecords, this.attendanceDeviceRepo, schedule.classId) };
+ }
+ }
+ throw err;
+ }
+
+ const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
+ const records = classStudents.map((classStudent) => {
+ const raw = selectDingTalkRecordsForLesson(
+ rawByStudent.get(classStudent.studentId) ?? [],
+ schedule,
+ lessonDate,
+ );
+ return recordRepo.create({
+ studentId: classStudent.studentId,
+ student: classStudent.student,
+ classId: schedule.classId!,
+ scheduleId,
+ attendanceSessionId: session.id,
+ attendanceDate: lessonDate,
+ session: lessonSessionKey,
+ status: mapDingTalkStatus(raw, finalize),
+ source: 'dingtalk',
+ ...getLessonPunchMetadata(
+ raw,
+ lessonDate,
+ schedule.startTime,
+ ),
+ remark: raw.some((item) => item.checkInTime || item.checkOutTime)
+ ? undefined
+ : finalize
+ ? '课程截止仍未打卡'
+ : '未获取到钉钉打卡结果',
+ });
+ });
+ const saved = await recordRepo.save(records);
+ if (finalize) {
+ session.status = 'completed';
+ session.completedBy = userId;
+ session.completedAt = new Date();
+ session = await sessionRepo.save(session);
+ }
+ return { schedule, session, records: await attachAttendanceDeviceMappings(saved, this.attendanceDeviceRepo, schedule.classId) };
+ });
+ }
+
+ private async fetchDingTalkRawByStudent(
+ classId: number,
+ schedule: Pick,
+ lessonDate: string,
+ ): Promise