feat: 考勤模块重构与钉钉考勤同步
This commit is contained in:
199
apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx
Normal file
199
apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { AttendanceSummary } from './attendance-workspace';
|
||||||
|
import type { LessonAttendanceRecord } from './types';
|
||||||
|
|
||||||
|
export type { AttendanceSummary } from './attendance-workspace';
|
||||||
|
|
||||||
|
export const DEFAULT_ATTENDANCE_PERIODS = [
|
||||||
|
{ periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1, enabled: true },
|
||||||
|
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2, enabled: true },
|
||||||
|
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true },
|
||||||
|
{ periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4, enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STATUS_META: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; color: string; className: string; short: string }
|
||||||
|
> = {
|
||||||
|
present: { label: '出勤', color: 'success', className: 'is-present', short: '勤' },
|
||||||
|
absent: { label: '缺勤', color: 'error', className: 'is-absent', short: '缺' },
|
||||||
|
leave: { label: '请假', color: 'processing', className: 'is-leave', short: '假' },
|
||||||
|
pending: { label: '待确认', color: 'default', className: 'is-pending', short: '待' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ADMIN_CORRECTION_OPTIONS = [
|
||||||
|
{ value: 'present', label: '正常' },
|
||||||
|
{ value: 'leave', label: '请假' },
|
||||||
|
{ value: 'absent', label: '缺勤' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ClassTeacherOption {
|
||||||
|
userId: number;
|
||||||
|
username: string | null;
|
||||||
|
name: string | null;
|
||||||
|
roleType: string;
|
||||||
|
subject: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassOption {
|
||||||
|
classId: number;
|
||||||
|
className: string;
|
||||||
|
teachers?: ClassTeacherOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttendanceRecordItem = LessonAttendanceRecord;
|
||||||
|
|
||||||
|
export interface HistoryScheduleOption {
|
||||||
|
id: number;
|
||||||
|
classId: number;
|
||||||
|
weekDay: number;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
subject: string;
|
||||||
|
teacherId: number | null;
|
||||||
|
teacherName?: string | null;
|
||||||
|
teacherUsername?: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertItem {
|
||||||
|
studentId: number;
|
||||||
|
studentName: string;
|
||||||
|
studentNo: string;
|
||||||
|
className: string;
|
||||||
|
type: string;
|
||||||
|
count: number;
|
||||||
|
lastDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttendancePeriodConfigItem {
|
||||||
|
id?: number;
|
||||||
|
periodKey: string;
|
||||||
|
label: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
sortOrder: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DingTalkSyncStatus {
|
||||||
|
lastPulledAt: string | null;
|
||||||
|
action: string | null;
|
||||||
|
username: string | null;
|
||||||
|
detail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_SUMMARY: AttendanceSummary = {
|
||||||
|
total: 0,
|
||||||
|
present: 0,
|
||||||
|
late: 0,
|
||||||
|
absent: 0,
|
||||||
|
leave: 0,
|
||||||
|
pending: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function displayAttendanceStatus(status?: string | null): string {
|
||||||
|
return status === 'pending' || !status ? 'absent' : status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTeacherDisplayName(teacher?: {
|
||||||
|
name?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
}): string {
|
||||||
|
const name = teacher?.name?.trim();
|
||||||
|
if (name) return name;
|
||||||
|
return teacher?.username?.trim() || '未设置';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTeacherNames(
|
||||||
|
teachers: readonly { name?: string | null; username?: string | null }[],
|
||||||
|
): string {
|
||||||
|
const names = [
|
||||||
|
...new Set(
|
||||||
|
teachers
|
||||||
|
.map((teacher) => getTeacherDisplayName(teacher))
|
||||||
|
.filter((name) => name && name !== '未设置'),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return names.length > 0 ? names.join('、') : '未设置';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttendanceStatusTag({ status }: { status: string }) {
|
||||||
|
const displayStatus = displayAttendanceStatus(status);
|
||||||
|
const meta = STATUS_META[displayStatus] ?? {
|
||||||
|
label: displayStatus,
|
||||||
|
color: 'default',
|
||||||
|
className: 'is-absent',
|
||||||
|
short: '?',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`attendance-status ${meta.className}`}>
|
||||||
|
<span className="attendance-status__dot" />
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminStudentPanel {
|
||||||
|
key: string;
|
||||||
|
studentId: number;
|
||||||
|
studentName: string;
|
||||||
|
studentNo: string;
|
||||||
|
className: string;
|
||||||
|
records: AttendanceRecordItem[];
|
||||||
|
statusBySession: Partial<Record<string, AttendanceRecordItem>>;
|
||||||
|
primaryStatus: string;
|
||||||
|
rate: number;
|
||||||
|
latestDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADMIN_METRIC_META = [
|
||||||
|
{ key: 'all', label: '出勤率', short: '率' },
|
||||||
|
{ key: 'present', label: '正常', short: '正常' },
|
||||||
|
{ key: 'leave', label: '请假', short: '请假' },
|
||||||
|
{ key: 'absent', label: '缺勤', short: '缺勤' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickPrimaryStatus(records: AttendanceRecordItem[]) {
|
||||||
|
const priority = ['absent', 'leave', 'present'];
|
||||||
|
return (
|
||||||
|
priority.find((item) =>
|
||||||
|
records.some((record) => displayAttendanceStatus(record.status) === item),
|
||||||
|
) || 'absent'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] {
|
||||||
|
const map = new Map<number, AdminStudentPanel>();
|
||||||
|
for (const record of records) {
|
||||||
|
const current = map.get(record.studentId) ?? {
|
||||||
|
key: String(record.studentId),
|
||||||
|
studentId: record.studentId,
|
||||||
|
studentName: record.student?.name || '未知学生',
|
||||||
|
studentNo: record.student?.studentNo?.trim() || '',
|
||||||
|
className: record.class?.name || '未关联班级',
|
||||||
|
records: [],
|
||||||
|
statusBySession: {},
|
||||||
|
primaryStatus: 'absent',
|
||||||
|
rate: 0,
|
||||||
|
latestDate: record.attendanceDate,
|
||||||
|
};
|
||||||
|
current.records.push(record);
|
||||||
|
if (!current.statusBySession[record.session]) current.statusBySession[record.session] = record;
|
||||||
|
if (dayjs(record.attendanceDate).isAfter(dayjs(current.latestDate))) {
|
||||||
|
current.latestDate = record.attendanceDate;
|
||||||
|
}
|
||||||
|
map.set(record.studentId, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values()).map((item) => {
|
||||||
|
const checked = item.records.filter((record) => record.status === 'present').length;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
primaryStatus: pickPrimaryStatus(item.records),
|
||||||
|
rate: item.records.length > 0 ? Math.round((checked / item.records.length) * 100) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
131
apps/admin/src/pages/Attendance/AttendanceAdminColumns.tsx
Normal file
131
apps/admin/src/pages/Attendance/AttendanceAdminColumns.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Avatar, Segmented, Tag } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import EditableCell from '../../components/EditableCell';
|
||||||
|
import { getPunchDisplayInfo } from './attendance-workspace';
|
||||||
|
import {
|
||||||
|
ADMIN_CORRECTION_OPTIONS,
|
||||||
|
AttendanceStatusTag,
|
||||||
|
displayAttendanceStatus,
|
||||||
|
type AttendanceRecordItem,
|
||||||
|
} from './AttendanceAdmin.helpers';
|
||||||
|
|
||||||
|
export interface AttendanceAdminColumnContext {
|
||||||
|
isMobile: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
sessionMap: Record<string, string>;
|
||||||
|
correctingRecordId: number | null;
|
||||||
|
onSaveAdminRecordCell: (
|
||||||
|
record: AttendanceRecordItem,
|
||||||
|
field: 'status' | 'remark',
|
||||||
|
value: unknown,
|
||||||
|
) => void;
|
||||||
|
onUpdateAdminRecordStatus: (record: AttendanceRecordItem, nextStatus: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildAttendanceAdminDataColumns = (ctx: AttendanceAdminColumnContext) => {
|
||||||
|
const { isMobile, canEdit, sessionMap, onSaveAdminRecordCell } = ctx;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: '学生',
|
||||||
|
dataIndex: ['student', 'name'],
|
||||||
|
fixed: (isMobile ? undefined : 'left') as 'left' | undefined,
|
||||||
|
width: 150,
|
||||||
|
render: (name: string, record: AttendanceRecordItem) => (
|
||||||
|
<div className="student-cell">
|
||||||
|
<Avatar size={34}>{name?.slice(0, 1)}</Avatar>
|
||||||
|
<div>
|
||||||
|
<strong>{name || '-'}</strong>
|
||||||
|
<span>{record.class?.name || '未关联班级'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
|
||||||
|
{
|
||||||
|
title: '时段',
|
||||||
|
dataIndex: 'session',
|
||||||
|
width: 110,
|
||||||
|
render: (value: string) => sessionMap[value] || value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 105,
|
||||||
|
render: (value: string, record: AttendanceRecordItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={displayAttendanceStatus(value)}
|
||||||
|
editor="select"
|
||||||
|
options={ADMIN_CORRECTION_OPTIONS.map((item) => ({
|
||||||
|
value: String(item.value),
|
||||||
|
label: item.label,
|
||||||
|
}))}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onSave={async (next) => {
|
||||||
|
onSaveAdminRecordCell(record, 'status', next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AttendanceStatusTag status={displayAttendanceStatus(value)} />
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '签到来源',
|
||||||
|
width: 220,
|
||||||
|
render: (_: unknown, record: AttendanceRecordItem) => {
|
||||||
|
const info = getPunchDisplayInfo(record);
|
||||||
|
if (!info) return <span className="muted-text">—</span>;
|
||||||
|
return (
|
||||||
|
<div className="punch-device-cell">
|
||||||
|
<Tag color={info.machine ? 'green' : 'blue'}>{info.label}</Tag>
|
||||||
|
{info.detail && <strong>{info.detail}</strong>}
|
||||||
|
{info.time && <span>{dayjs(info.time).format('HH:mm:ss')}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'remark',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value: string | null, record: AttendanceRecordItem) => (
|
||||||
|
<EditableCell
|
||||||
|
value={value}
|
||||||
|
editor="textarea"
|
||||||
|
disabled={!canEdit}
|
||||||
|
onSave={async (next) => {
|
||||||
|
onSaveAdminRecordCell(record, 'remark', next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value || <span className="muted-text">—</span>}
|
||||||
|
</EditableCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] as ColumnsType<AttendanceRecordItem>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildAttendanceAdminActionColumn = (ctx: AttendanceAdminColumnContext) => {
|
||||||
|
const { isMobile, correctingRecordId, onUpdateAdminRecordStatus } = ctx;
|
||||||
|
return {
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
fixed: (isMobile ? undefined : 'right') as 'right' | undefined,
|
||||||
|
width: 220,
|
||||||
|
render: (_: unknown, record: AttendanceRecordItem) => (
|
||||||
|
<Segmented
|
||||||
|
size="small"
|
||||||
|
className="attendance-correction-segment"
|
||||||
|
options={ADMIN_CORRECTION_OPTIONS}
|
||||||
|
value={displayAttendanceStatus(record.status)}
|
||||||
|
disabled={correctingRecordId === record.id}
|
||||||
|
onChange={(value) => void onUpdateAdminRecordStatus(record, String(value))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildAttendanceAdminColumns = (ctx: AttendanceAdminColumnContext) => {
|
||||||
|
const dataColumns = buildAttendanceAdminDataColumns(ctx);
|
||||||
|
const actionColumn = ctx.canEdit ? [buildAttendanceAdminActionColumn(ctx)] : [];
|
||||||
|
return [...dataColumns, ...actionColumn] as ColumnsType<AttendanceRecordItem>;
|
||||||
|
};
|
||||||
275
apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx
Normal file
275
apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
DatePicker,
|
||||||
|
Select,
|
||||||
|
Spin,
|
||||||
|
Tooltip,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ExportOutlined,
|
||||||
|
FileSearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
ScheduleOutlined,
|
||||||
|
UndoOutlined,
|
||||||
|
WarningFilled,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import {
|
||||||
|
ADMIN_METRIC_META,
|
||||||
|
STATUS_META,
|
||||||
|
type AlertItem,
|
||||||
|
type AttendanceSummary,
|
||||||
|
type ClassOption,
|
||||||
|
type DingTalkSyncStatus,
|
||||||
|
type HistoryScheduleOption,
|
||||||
|
} from './AttendanceAdmin.helpers';
|
||||||
|
|
||||||
|
export const AttendanceAdminHeader: React.FC<{
|
||||||
|
syncStatus: DingTalkSyncStatus | null;
|
||||||
|
canEdit: boolean;
|
||||||
|
refreshingDingTalk: boolean;
|
||||||
|
onOpenPeriodConfig: () => void;
|
||||||
|
onRefreshDingTalk: () => void;
|
||||||
|
onExport: () => void;
|
||||||
|
attendanceDate: Dayjs | null;
|
||||||
|
onDateChange: (date: Dayjs | null) => void;
|
||||||
|
classId?: number;
|
||||||
|
onClassChange: (value?: number) => void;
|
||||||
|
classOptions: ClassOption[];
|
||||||
|
effectiveScheduleId?: number;
|
||||||
|
onScheduleChange: (value?: number) => void;
|
||||||
|
scheduleOptions: HistoryScheduleOption[];
|
||||||
|
scheduleOptionsLoading: boolean;
|
||||||
|
session?: string;
|
||||||
|
onSessionChange: (value?: string) => void;
|
||||||
|
sessionOptions: Array<{ value: string; label: string }>;
|
||||||
|
onReset: () => void;
|
||||||
|
onQuery: () => void;
|
||||||
|
selectedClass: string;
|
||||||
|
dateLabel: string;
|
||||||
|
visibleStudentCount: number;
|
||||||
|
total: number;
|
||||||
|
headTeacherNames: string;
|
||||||
|
lifeTeacherNames: string;
|
||||||
|
subjectTeacherNames: string;
|
||||||
|
attendanceRate: number;
|
||||||
|
summary: AttendanceSummary;
|
||||||
|
metricFilter: string;
|
||||||
|
onMetricFilterChange: (key: string) => void;
|
||||||
|
alerts: AlertItem[];
|
||||||
|
}> = ({
|
||||||
|
syncStatus,
|
||||||
|
canEdit,
|
||||||
|
refreshingDingTalk,
|
||||||
|
onOpenPeriodConfig,
|
||||||
|
onRefreshDingTalk,
|
||||||
|
onExport,
|
||||||
|
attendanceDate,
|
||||||
|
onDateChange,
|
||||||
|
classId,
|
||||||
|
onClassChange,
|
||||||
|
classOptions,
|
||||||
|
effectiveScheduleId,
|
||||||
|
onScheduleChange,
|
||||||
|
scheduleOptions,
|
||||||
|
scheduleOptionsLoading,
|
||||||
|
session,
|
||||||
|
onSessionChange,
|
||||||
|
sessionOptions,
|
||||||
|
onReset,
|
||||||
|
onQuery,
|
||||||
|
selectedClass,
|
||||||
|
dateLabel,
|
||||||
|
visibleStudentCount,
|
||||||
|
total,
|
||||||
|
headTeacherNames,
|
||||||
|
lifeTeacherNames,
|
||||||
|
subjectTeacherNames,
|
||||||
|
attendanceRate,
|
||||||
|
summary,
|
||||||
|
metricFilter,
|
||||||
|
onMetricFilterChange,
|
||||||
|
alerts,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="student-center-topbar">
|
||||||
|
<div className="student-center-title">
|
||||||
|
<h1>学生考勤中心</h1>
|
||||||
|
<span>班级考勤总览</span>
|
||||||
|
</div>
|
||||||
|
<div className="student-center-actions">
|
||||||
|
<Tooltip title={syncStatus?.detail || syncStatus?.action || '暂无钉钉拉取记录'}>
|
||||||
|
<span className="student-sync-status">
|
||||||
|
<i />
|
||||||
|
{syncStatus?.lastPulledAt
|
||||||
|
? `最近拉取钉钉 ${dayjs(syncStatus.lastPulledAt).format('YYYY-MM-DD HH:mm')}`
|
||||||
|
: '暂无钉钉拉取记录'}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
{canEdit && (
|
||||||
|
<Button icon={<ScheduleOutlined />} onClick={onOpenPeriodConfig}>
|
||||||
|
时段配置
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<PermissionButton
|
||||||
|
permission="attendance:create"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={refreshingDingTalk}
|
||||||
|
onClick={onRefreshDingTalk}
|
||||||
|
>
|
||||||
|
刷新钉钉考勤
|
||||||
|
</PermissionButton>
|
||||||
|
<PermissionButton permission="attendance:export" icon={<ExportOutlined />} onClick={onExport}>
|
||||||
|
导出当前报表
|
||||||
|
</PermissionButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="student-filter-panel" aria-label="考勤筛选">
|
||||||
|
<div className="student-filter-field student-filter-field--date">
|
||||||
|
<label>日期</label>
|
||||||
|
<DatePicker
|
||||||
|
allowClear={false}
|
||||||
|
value={attendanceDate}
|
||||||
|
disabledDate={(current) => current.isAfter(dayjs(), 'day')}
|
||||||
|
onChange={onDateChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="student-filter-field">
|
||||||
|
<label>班级</label>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="全部班级"
|
||||||
|
value={classId}
|
||||||
|
onChange={onClassChange}
|
||||||
|
options={classOptions.map((item) => ({ value: item.classId, label: item.className }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="student-filter-field">
|
||||||
|
<label>科目</label>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder={classId ? '全部科目' : '请先选择班级'}
|
||||||
|
value={effectiveScheduleId}
|
||||||
|
loading={scheduleOptionsLoading}
|
||||||
|
disabled={!classId || !attendanceDate}
|
||||||
|
notFoundContent={scheduleOptionsLoading ? <Spin size="small" /> : '当前日期没有排课'}
|
||||||
|
onChange={onScheduleChange}
|
||||||
|
options={scheduleOptions.map((schedule) => ({
|
||||||
|
value: schedule.id,
|
||||||
|
label: `${schedule.subject}(${schedule.startTime}-${schedule.endTime})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="student-filter-field">
|
||||||
|
<label>任课老师</label>
|
||||||
|
<Select disabled placeholder="全部老师" />
|
||||||
|
</div>
|
||||||
|
<div className="student-filter-field">
|
||||||
|
<label>时段</label>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部时段"
|
||||||
|
value={session}
|
||||||
|
onChange={onSessionChange}
|
||||||
|
options={sessionOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="student-filter-actions">
|
||||||
|
<Button icon={<UndoOutlined />} onClick={onReset}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<FileSearchOutlined />} onClick={onQuery}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="student-class-overview" aria-label="班级考勤汇总">
|
||||||
|
<div className="student-class-identity">
|
||||||
|
<div className="student-class-heading">
|
||||||
|
<span className="student-overview-kicker">班级考勤概览</span>
|
||||||
|
<h2>{selectedClass}</h2>
|
||||||
|
<p>
|
||||||
|
{dateLabel} · 当前展示 {visibleStudentCount} 名学生 / {total} 条记录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="student-teacher-list" aria-label="筛选上下文">
|
||||||
|
<div className="student-teacher-item">
|
||||||
|
<Avatar>班</Avatar>
|
||||||
|
<div>
|
||||||
|
<span>班主任</span>
|
||||||
|
<strong>{headTeacherNames}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="student-teacher-item">
|
||||||
|
<Avatar>生</Avatar>
|
||||||
|
<div>
|
||||||
|
<span>生活老师</span>
|
||||||
|
<strong>{lifeTeacherNames}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="student-teacher-item">
|
||||||
|
<Avatar>任</Avatar>
|
||||||
|
<div>
|
||||||
|
<span>任课老师</span>
|
||||||
|
<strong>{subjectTeacherNames}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="student-metric-strip">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={metric.key}
|
||||||
|
className={`student-metric-card ${metricFilter === metric.key ? 'active' : ''}`}
|
||||||
|
onClick={() => onMetricFilterChange(metric.key)}
|
||||||
|
>
|
||||||
|
<span className={`student-metric-icon ${meta.className}`}>{metric.short}</span>
|
||||||
|
<span className="student-metric-copy">
|
||||||
|
<strong>{value}</strong>
|
||||||
|
<small>{metric.label}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{alerts.length > 0 && (
|
||||||
|
<div className="archive-alert">
|
||||||
|
<WarningFilled />
|
||||||
|
<div>
|
||||||
|
<strong>{alerts.length} 名学生存在连续异常</strong>
|
||||||
|
<span>建议优先核查最近 14 天的缺勤记录</span>
|
||||||
|
</div>
|
||||||
|
<Tooltip
|
||||||
|
title={alerts
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((item) => `${item.studentName}:${item.type}${item.count}次`)
|
||||||
|
.join(';')}
|
||||||
|
>
|
||||||
|
<Button type="link">查看摘要</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
194
apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx
Normal file
194
apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Segmented,
|
||||||
|
Select,
|
||||||
|
} from 'antd';
|
||||||
|
import { UndoOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import {
|
||||||
|
ADMIN_CORRECTION_OPTIONS,
|
||||||
|
AttendanceStatusTag,
|
||||||
|
displayAttendanceStatus,
|
||||||
|
type AdminStudentPanel,
|
||||||
|
type AttendancePeriodConfigItem,
|
||||||
|
type AttendanceRecordItem,
|
||||||
|
} from './AttendanceAdmin.helpers';
|
||||||
|
|
||||||
|
export const PeriodConfigModal: React.FC<{
|
||||||
|
open: boolean;
|
||||||
|
form: ReturnType<typeof Form.useForm<{ periods: AttendancePeriodConfigItem[] }>>[0];
|
||||||
|
onOk: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}> = ({ open, form, onOk, onCancel, onReset }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="考勤时段配置"
|
||||||
|
okText="保存配置"
|
||||||
|
width={860}
|
||||||
|
className="attendance-period-modal"
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={(_, { OkBtn, CancelBtn }) => (
|
||||||
|
<>
|
||||||
|
<Button icon={<UndoOutlined />} onClick={onReset}>
|
||||||
|
恢复默认
|
||||||
|
</Button>
|
||||||
|
<CancelBtn />
|
||||||
|
<OkBtn />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
title="按课程开始时间自动匹配时段"
|
||||||
|
description="默认:07:30-08:30 早自习,09:00-12:00 早课,14:00-17:00 晚课,18:30-21:00 晚自习。"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.List name="periods">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<div className="attendance-period-editor">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div className="attendance-period-row" key={field.key}>
|
||||||
|
<Form.Item name={[field.name, 'label']} label="显示名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||||
|
<Input placeholder="早课" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={[field.name, 'periodKey']} label="类型标识" rules={[{ required: true, message: '请输入标识' }]}>
|
||||||
|
<Input placeholder="morning" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={[field.name, 'startTime']} label="开始时间" rules={[{ required: true, message: '请选择开始时间' }]}>
|
||||||
|
<Input type="time" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={[field.name, 'endTime']} label="结束时间" rules={[{ required: true, message: '请选择结束时间' }]}>
|
||||||
|
<Input type="time" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={[field.name, 'enabled']} label="状态">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: true, label: '启用' },
|
||||||
|
{ value: false, label: '停用' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Button danger className="attendance-period-row__delete" onClick={() => remove(field.name)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type="dashed"
|
||||||
|
className="attendance-period-add"
|
||||||
|
onClick={() =>
|
||||||
|
add({
|
||||||
|
periodKey: '',
|
||||||
|
label: '',
|
||||||
|
startTime: '00:00',
|
||||||
|
endTime: '00:30',
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
添加时段
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StudentDetailDrawer: React.FC<{
|
||||||
|
student: AdminStudentPanel | null;
|
||||||
|
isMobile: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
sessionMap: Record<string, string>;
|
||||||
|
correctingRecordId: number | null;
|
||||||
|
sortAttendanceRecords: (items: readonly AttendanceRecordItem[]) => AttendanceRecordItem[];
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdateAdminRecordStatus: (record: AttendanceRecordItem, nextStatus: string) => void;
|
||||||
|
}> = ({
|
||||||
|
student,
|
||||||
|
isMobile,
|
||||||
|
canEdit,
|
||||||
|
sessionMap,
|
||||||
|
correctingRecordId,
|
||||||
|
sortAttendanceRecords,
|
||||||
|
onClose,
|
||||||
|
onUpdateAdminRecordStatus,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={Boolean(student)}
|
||||||
|
onClose={onClose}
|
||||||
|
size={isMobile ? '100%' : 520}
|
||||||
|
title="学生考勤明细"
|
||||||
|
className="student-detail-drawer"
|
||||||
|
>
|
||||||
|
{student && (
|
||||||
|
<div className="student-detail-panel">
|
||||||
|
<section className="student-detail-profile">
|
||||||
|
<Avatar size={54}>{student.studentName.slice(0, 1)}</Avatar>
|
||||||
|
<div>
|
||||||
|
<h4>{student.studentName}</h4>
|
||||||
|
<p>
|
||||||
|
{student.className}
|
||||||
|
{student.studentNo ? ` · 学号 ${student.studentNo}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="student-detail-rate">
|
||||||
|
<strong>{student.rate}%</strong>
|
||||||
|
<span>累计出勤率</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="student-detail-rates">
|
||||||
|
{sortAttendanceRecords(student.records).map((record) => {
|
||||||
|
const normal = record.status === 'present';
|
||||||
|
return (
|
||||||
|
<div key={record.id}>
|
||||||
|
<strong>{normal ? '100%' : '0%'}</strong>
|
||||||
|
<span>{sessionMap[record.session] || record.session}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
<section className="student-detail-section">
|
||||||
|
<h5>当天打卡时间</h5>
|
||||||
|
<div className="student-detail-timeline">
|
||||||
|
{sortAttendanceRecords(student.records).map((record) => (
|
||||||
|
<div key={record.id}>
|
||||||
|
<span>{sessionMap[record.session] || record.session}</span>
|
||||||
|
<AttendanceStatusTag status={displayAttendanceStatus(record.status)} />
|
||||||
|
<strong>
|
||||||
|
{record.punchTime ? dayjs(record.punchTime).format('HH:mm:ss') : '未记录'}
|
||||||
|
</strong>
|
||||||
|
{canEdit && (
|
||||||
|
<Segmented
|
||||||
|
size="small"
|
||||||
|
className="attendance-correction-segment"
|
||||||
|
options={ADMIN_CORRECTION_OPTIONS}
|
||||||
|
value={displayAttendanceStatus(record.status)}
|
||||||
|
disabled={correctingRecordId === record.id}
|
||||||
|
onChange={(value) => void onUpdateAdminRecordStatus(record, String(value))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
164
apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx
Normal file
164
apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, Empty, Input, Spin, Table } from 'antd';
|
||||||
|
import { ExportOutlined } from '@ant-design/icons';
|
||||||
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import {
|
||||||
|
ADMIN_METRIC_META,
|
||||||
|
STATUS_META,
|
||||||
|
displayAttendanceStatus,
|
||||||
|
type AdminStudentPanel,
|
||||||
|
type AttendanceRecordItem,
|
||||||
|
} from './AttendanceAdmin.helpers';
|
||||||
|
|
||||||
|
export const AttendanceAdminWorkspace: React.FC<{
|
||||||
|
metricFilter: string;
|
||||||
|
studentSearch: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onExport: () => void;
|
||||||
|
visibleStudents: AdminStudentPanel[];
|
||||||
|
loading: boolean;
|
||||||
|
selectedStudentId?: number;
|
||||||
|
onSelectStudent: (student: AdminStudentPanel) => void;
|
||||||
|
sortAttendanceRecords: (items: readonly AttendanceRecordItem[]) => AttendanceRecordItem[];
|
||||||
|
sessionMap: Record<string, string>;
|
||||||
|
records: AttendanceRecordItem[];
|
||||||
|
columns: any[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (page: number, pageSize: number) => void;
|
||||||
|
}> = ({
|
||||||
|
metricFilter,
|
||||||
|
studentSearch,
|
||||||
|
onSearchChange,
|
||||||
|
onExport,
|
||||||
|
visibleStudents,
|
||||||
|
loading,
|
||||||
|
selectedStudentId,
|
||||||
|
onSelectStudent,
|
||||||
|
sortAttendanceRecords,
|
||||||
|
sessionMap,
|
||||||
|
records,
|
||||||
|
columns,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="student-workspace">
|
||||||
|
<header className="student-workspace-header">
|
||||||
|
<div>
|
||||||
|
<h3>班级学生考勤</h3>
|
||||||
|
<span>
|
||||||
|
{metricFilter === 'all'
|
||||||
|
? `显示全部 ${visibleStudents.length} 名学生`
|
||||||
|
: `筛出 ${
|
||||||
|
ADMIN_METRIC_META.find((item) => item.key === metricFilter)?.label || ''
|
||||||
|
}相关 ${visibleStudents.length} 名学生`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="student-workspace-tools">
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
placeholder="搜索姓名或学号"
|
||||||
|
value={studentSearch}
|
||||||
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<PermissionButton
|
||||||
|
permission="attendance:export"
|
||||||
|
icon={<ExportOutlined />}
|
||||||
|
onClick={onExport}
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</PermissionButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="student-legend">
|
||||||
|
<span>
|
||||||
|
<i className="is-present" />
|
||||||
|
正常
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i className="is-leave" />
|
||||||
|
请假
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i className="is-absent" />
|
||||||
|
缺勤
|
||||||
|
</span>
|
||||||
|
<em>仅显示当天已生成考勤的课程/时段,不再为无课时段补默认缺勤</em>
|
||||||
|
</div>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{visibleStudents.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="未找到匹配学生,请调整筛选条件或搜索关键词"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="student-card-grid">
|
||||||
|
{visibleStudents.map((student) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={student.key}
|
||||||
|
className={`student-attendance-card ${
|
||||||
|
selectedStudentId === student.studentId ? 'selected' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelectStudent(student)}
|
||||||
|
>
|
||||||
|
<span className="student-attendance-head">
|
||||||
|
<strong>{student.studentName}</strong>
|
||||||
|
{student.studentNo && <small>学号 {student.studentNo}</small>}
|
||||||
|
</span>
|
||||||
|
<span className="student-status-blocks">
|
||||||
|
{sortAttendanceRecords(student.records).map((record) => {
|
||||||
|
const currentStatus = displayAttendanceStatus(record.status);
|
||||||
|
const meta = STATUS_META[currentStatus] ?? STATUS_META.absent;
|
||||||
|
const sessionLabel = sessionMap[record.session] || record.session;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={record.id}
|
||||||
|
className={`student-status-block ${meta.className}`}
|
||||||
|
title={`${sessionLabel}:${meta.label}`}
|
||||||
|
>
|
||||||
|
{meta.label === '出勤' ? '正常' : meta.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card className="student-record-card" bordered={false} title="原始考勤明细">
|
||||||
|
<Table<AttendanceRecordItem>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={records}
|
||||||
|
loading={loading}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (value) => `共 ${value} 条历史记录`,
|
||||||
|
onChange: onPageChange,
|
||||||
|
}}
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="当前条件下没有历史考勤记录"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from './attendance-workspace';
|
} from './attendance-workspace';
|
||||||
import type { LessonAttendanceRecord, LessonAttendanceSchedule } from './types';
|
import type { LessonAttendanceRecord, LessonAttendanceSchedule } from './types';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
|
import { getErrorMessage } from '../../utils/error';
|
||||||
|
|
||||||
interface LessonAttendanceSession {
|
interface LessonAttendanceSession {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -90,10 +91,6 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
|||||||
if (!schedule) return;
|
if (!schedule) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoadedSchedule(schedule);
|
setLoadedSchedule(schedule);
|
||||||
setSession(null);
|
|
||||||
setRecords([]);
|
|
||||||
setKeyword('');
|
|
||||||
setFilter('all');
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const date = dayjs().format('YYYY-MM-DD');
|
const date = dayjs().format('YYYY-MM-DD');
|
||||||
void api
|
void api
|
||||||
@@ -109,7 +106,7 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
|||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
message.error((error as { message?: string })?.message || '加载本节课考勤失败');
|
message.error(getErrorMessage(error, '加载本节课考勤失败'));
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@@ -130,7 +127,7 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
|||||||
setRecords((items) =>
|
setRecords((items) =>
|
||||||
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
|
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
|
||||||
);
|
);
|
||||||
message.error((error as { message?: string })?.message || '更新考勤失败');
|
message.error(getErrorMessage(error, '更新考勤失败'));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
580
apps/admin/src/pages/Attendance/admin.tsx
Normal file
580
apps/admin/src/pages/Attendance/admin.tsx
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||||
|
import { validateResponse } from '../../utils/validate';
|
||||||
|
import {
|
||||||
|
attendanceAlertsSchema,
|
||||||
|
attendanceClassOptionsSchema,
|
||||||
|
attendanceRecordsResponseSchema,
|
||||||
|
attendanceScheduleOptionsSchema,
|
||||||
|
attendancePeriodsSchema,
|
||||||
|
attendanceSummarySchema,
|
||||||
|
dingTalkSyncStatusSchema,
|
||||||
|
} from '../../api/schemas';
|
||||||
|
import { Form, Grid } from 'antd';
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
|
import api from '../../api';
|
||||||
|
import { message } from '../../ui/app-message';
|
||||||
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
|
import type { AttendanceSummary } from './attendance-workspace';
|
||||||
|
import { getErrorMessage } from '../../utils/error';
|
||||||
|
import { AttendanceAdminHeader } from './AttendanceAdminHeader';
|
||||||
|
import { buildAttendanceAdminColumns } from './AttendanceAdminColumns';
|
||||||
|
import { PeriodConfigModal, StudentDetailDrawer } from './AttendanceAdminModals';
|
||||||
|
import { AttendanceAdminWorkspace } from './AttendanceAdminWorkspace';
|
||||||
|
import {
|
||||||
|
DEFAULT_ATTENDANCE_PERIODS,
|
||||||
|
EMPTY_SUMMARY,
|
||||||
|
buildAdminStudentPanels,
|
||||||
|
displayAttendanceStatus,
|
||||||
|
formatTeacherNames,
|
||||||
|
getTeacherDisplayName,
|
||||||
|
type AdminStudentPanel,
|
||||||
|
type AlertItem,
|
||||||
|
type AttendancePeriodConfigItem,
|
||||||
|
type AttendanceRecordItem,
|
||||||
|
type ClassOption,
|
||||||
|
type DingTalkSyncStatus,
|
||||||
|
type HistoryScheduleOption,
|
||||||
|
} from './AttendanceAdmin.helpers';
|
||||||
|
|
||||||
|
export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.sm;
|
||||||
|
const [periodModalOpen, setPeriodModalOpen] = useState(false);
|
||||||
|
const [periodForm] = Form.useForm<{ periods: AttendancePeriodConfigItem[] }>();
|
||||||
|
const [refreshingDingTalk, setRefreshingDingTalk] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(48);
|
||||||
|
const [classId, setClassId] = useState<number>();
|
||||||
|
const [attendanceDate, setAttendanceDate] = useState<Dayjs | null>(dayjs());
|
||||||
|
const [status, setStatus] = useState<string>();
|
||||||
|
const [session, setSession] = useState<string>();
|
||||||
|
const [scheduleId, setScheduleId] = useState<number>();
|
||||||
|
const [metricFilter, setMetricFilter] = useState('all');
|
||||||
|
const [studentSearch, setStudentSearch] = useState('');
|
||||||
|
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
|
||||||
|
const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const recordQueryKey = [
|
||||||
|
'attendance',
|
||||||
|
'records',
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
classId,
|
||||||
|
attendanceDate,
|
||||||
|
status,
|
||||||
|
session,
|
||||||
|
scheduleId,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const { data: classOptions = [] } = useQuery<ClassOption[]>({
|
||||||
|
queryKey: ['attendance', 'meta', 'classes'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return validateResponse<ClassOption[]>(
|
||||||
|
attendanceClassOptionsSchema,
|
||||||
|
await api.get<ClassOption[]>('/attendance-records/classes'),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { data: alerts = [] } = useQuery<AlertItem[]>({
|
||||||
|
queryKey: ['attendance', 'meta', 'alerts'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return validateResponse<AlertItem[]>(
|
||||||
|
attendanceAlertsSchema,
|
||||||
|
await api.get<AlertItem[]>('/attendance-records/alerts'),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { data: periods = DEFAULT_ATTENDANCE_PERIODS } = useQuery<AttendancePeriodConfigItem[]>({
|
||||||
|
queryKey: ['attendance', 'meta', 'periods'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return validateResponse<AttendancePeriodConfigItem[]>(
|
||||||
|
attendancePeriodsSchema,
|
||||||
|
await api.get<AttendancePeriodConfigItem[]>('/attendance-period-configs'),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_ATTENDANCE_PERIODS;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
data: scheduleOptions = [],
|
||||||
|
isFetching: scheduleOptionsFetching,
|
||||||
|
} = useQuery<HistoryScheduleOption[]>({
|
||||||
|
queryKey: ['attendance', 'schedules', classId, attendanceDate],
|
||||||
|
enabled: !!classId && !!attendanceDate,
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return validateResponse<HistoryScheduleOption[]>(
|
||||||
|
attendanceScheduleOptionsSchema,
|
||||||
|
await api.get<HistoryScheduleOption[]>('/attendance-records/schedules', {
|
||||||
|
params: { classId, date: attendanceDate!.format('YYYY-MM-DD') },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
message.error(getErrorMessage(error, '加载班级科目失败'));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const scheduleOptionsLoading = scheduleOptionsFetching;
|
||||||
|
const effectiveScheduleId =
|
||||||
|
scheduleId && scheduleOptions.some((schedule) => schedule.id === scheduleId)
|
||||||
|
? scheduleId
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
periodForm.setFieldsValue({ periods });
|
||||||
|
}, [periods, periodForm]);
|
||||||
|
|
||||||
|
const enabledPeriods = useMemo(
|
||||||
|
() => periods.filter((period) => period.enabled).sort((a, b) => a.sortOrder - b.sortOrder),
|
||||||
|
[periods],
|
||||||
|
);
|
||||||
|
const sessionOptions = useMemo(
|
||||||
|
() => enabledPeriods.map((period) => ({ value: period.periodKey, label: period.label })),
|
||||||
|
[enabledPeriods],
|
||||||
|
);
|
||||||
|
const sessionMap = useMemo(
|
||||||
|
() => Object.fromEntries(periods.map((period) => [period.periodKey, period.label])),
|
||||||
|
[periods],
|
||||||
|
);
|
||||||
|
const periodOrder = useMemo(
|
||||||
|
() => new Map(periods.map((period, index) => [period.periodKey, index])),
|
||||||
|
[periods],
|
||||||
|
);
|
||||||
|
const sortAttendanceRecords = useCallback(
|
||||||
|
(items: readonly AttendanceRecordItem[]) =>
|
||||||
|
[...items].sort((a, b) => {
|
||||||
|
const periodDiff =
|
||||||
|
(periodOrder.get(a.session) ?? Number.MAX_SAFE_INTEGER) -
|
||||||
|
(periodOrder.get(b.session) ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
if (periodDiff !== 0) return periodDiff;
|
||||||
|
return (a.punchTime || a.createdAt || '').localeCompare(b.punchTime || b.createdAt || '');
|
||||||
|
}),
|
||||||
|
[periodOrder],
|
||||||
|
);
|
||||||
|
const openPeriodConfig = () => {
|
||||||
|
periodForm.setFieldsValue({ periods });
|
||||||
|
setPeriodModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePeriodConfig = async () => {
|
||||||
|
try {
|
||||||
|
const values = await periodForm.validateFields();
|
||||||
|
const payload = {
|
||||||
|
periods: values.periods.map((period, index) => ({
|
||||||
|
...period,
|
||||||
|
sortOrder: index + 1,
|
||||||
|
enabled: period.enabled ?? true,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const data = await savePeriodConfigMutation.mutateAsync(payload);
|
||||||
|
setPeriodModalOpen(false);
|
||||||
|
message.success('考勤时段配置已保存');
|
||||||
|
if (session && !data.some((period) => period.enabled && period.periodKey === session)) {
|
||||||
|
setSession(undefined);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 校验错误静默,接口错误由 useApiMutation 统一提示
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetPeriodConfig = async () => {
|
||||||
|
try {
|
||||||
|
await resetPeriodConfigMutation.mutateAsync();
|
||||||
|
message.success('已恢复默认考勤时段');
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 useApiMutation 统一处理
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildParams = useCallback(
|
||||||
|
(withPagination = true) => {
|
||||||
|
const params: Record<string, string | number> = {};
|
||||||
|
if (withPagination) Object.assign(params, { page, pageSize });
|
||||||
|
if (classId) params.classId = classId;
|
||||||
|
if (attendanceDate) {
|
||||||
|
const selectedDate = attendanceDate.format('YYYY-MM-DD');
|
||||||
|
params.dateFrom = selectedDate;
|
||||||
|
params.dateTo = selectedDate;
|
||||||
|
}
|
||||||
|
if (status) params.status = status;
|
||||||
|
if (session) params.session = session;
|
||||||
|
if (effectiveScheduleId) params.scheduleId = effectiveScheduleId;
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
[page, pageSize, classId, attendanceDate, status, session, scheduleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery<DingTalkSyncStatus | null>({
|
||||||
|
queryKey: ['attendance', 'sync-status'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return validateResponse<DingTalkSyncStatus>(
|
||||||
|
dingTalkSyncStatusSchema,
|
||||||
|
await api.get<DingTalkSyncStatus>('/attendance-records/dingtalk-sync-status'),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const loadSyncStatus = useCallback(() => refetchSyncStatus(), [refetchSyncStatus]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: recordQuery = { records: [], total: 0, summary: EMPTY_SUMMARY },
|
||||||
|
isFetching: recordsFetching,
|
||||||
|
refetch: refetchRecords,
|
||||||
|
} = useQuery<{
|
||||||
|
records: AttendanceRecordItem[];
|
||||||
|
total: number;
|
||||||
|
summary: AttendanceSummary;
|
||||||
|
}>({
|
||||||
|
queryKey: recordQueryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const [recordData, summaryData] = await Promise.all([
|
||||||
|
api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', {
|
||||||
|
params: buildParams(true),
|
||||||
|
}),
|
||||||
|
api.get<AttendanceSummary>('/attendance-records/summary', {
|
||||||
|
params: buildParams(false),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const validatedRecords = validateResponse<{
|
||||||
|
list: AttendanceRecordItem[];
|
||||||
|
total: number;
|
||||||
|
}>(attendanceRecordsResponseSchema, recordData);
|
||||||
|
const validatedSummary = validateResponse<AttendanceSummary>(
|
||||||
|
attendanceSummarySchema,
|
||||||
|
summaryData,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
records: validatedRecords.list,
|
||||||
|
total: validatedRecords.total,
|
||||||
|
summary: { ...EMPTY_SUMMARY, ...validatedSummary },
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
message.error(getErrorMessage(error, '加载学生考勤失败'));
|
||||||
|
return { records: [], total: 0, summary: EMPTY_SUMMARY };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const records = recordQuery.records;
|
||||||
|
const total = recordQuery.total;
|
||||||
|
const summary = recordQuery.summary;
|
||||||
|
const loading = recordsFetching;
|
||||||
|
const loadRecords = useCallback(() => refetchRecords(), [refetchRecords]);
|
||||||
|
|
||||||
|
const savePeriodConfigMutation = useApiMutation(
|
||||||
|
async (payload: Record<string, unknown>) =>
|
||||||
|
api.put<AttendancePeriodConfigItem[]>('/attendance-period-configs', payload),
|
||||||
|
{ invalidate: [['attendance', 'meta', 'periods']] },
|
||||||
|
);
|
||||||
|
const resetPeriodConfigMutation = useApiMutation(
|
||||||
|
async () => api.post<AttendancePeriodConfigItem[]>('/attendance-period-configs/reset'),
|
||||||
|
{ invalidate: [['attendance', 'meta', 'periods']] },
|
||||||
|
);
|
||||||
|
const refreshDingTalkMutation = useApiMutation(
|
||||||
|
async (payload: Record<string, unknown>) =>
|
||||||
|
api.post<{
|
||||||
|
refreshed: number;
|
||||||
|
imported: number;
|
||||||
|
matched: number;
|
||||||
|
errors: string[];
|
||||||
|
}>('/attendance-records/refresh-dingtalk', payload),
|
||||||
|
{ invalidate: [['attendance', 'records'], ['attendance', 'sync-status']] },
|
||||||
|
);
|
||||||
|
const updateStatusMutation = useApiMutation(
|
||||||
|
async ({ id, status }: { id: number; status: string }) =>
|
||||||
|
api.put(`/attendance-records/${id}`, { status }),
|
||||||
|
{ invalidate: [['attendance', 'records']] },
|
||||||
|
);
|
||||||
|
const saveRecordCellMutation = useApiMutation(
|
||||||
|
async ({ id, field, value }: { id: number; field: string; value: unknown }) =>
|
||||||
|
api.put(`/attendance-records/${id}`, { [field]: value }),
|
||||||
|
{ invalidate: [['attendance', 'records']] },
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshDingTalkAttendance = useCallback(async () => {
|
||||||
|
if (!attendanceDate) {
|
||||||
|
message.warning('请先选择日期');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attendanceDate.isAfter(dayjs(), 'day')) {
|
||||||
|
message.warning('不能查看或刷新未来日期的考勤');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRefreshingDingTalk(true);
|
||||||
|
try {
|
||||||
|
const result = await refreshDingTalkMutation.mutateAsync({
|
||||||
|
date: attendanceDate.format('YYYY-MM-DD'),
|
||||||
|
classId,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
message.warning(result.errors[0]);
|
||||||
|
} else {
|
||||||
|
message.success(`已刷新 ${result.refreshed} 节课程考勤`);
|
||||||
|
}
|
||||||
|
await Promise.all([loadRecords(), loadSyncStatus()]);
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 useApiMutation 统一处理
|
||||||
|
} finally {
|
||||||
|
setRefreshingDingTalk(false);
|
||||||
|
}
|
||||||
|
}, [attendanceDate, classId, loadRecords, loadSyncStatus, session]);
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setClassId(undefined);
|
||||||
|
setAttendanceDate(dayjs());
|
||||||
|
setStatus(undefined);
|
||||||
|
setSession(undefined);
|
||||||
|
setScheduleId(undefined);
|
||||||
|
setMetricFilter('all');
|
||||||
|
setStudentSearch('');
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = useCallback(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(buildParams(false))) params.set(key, String(value));
|
||||||
|
const token = useUserStore.getState().token;
|
||||||
|
fetch(`/api/attendance-records/export?${params.toString()}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error('导出失败');
|
||||||
|
return response.blob();
|
||||||
|
})
|
||||||
|
.then((blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
})
|
||||||
|
.catch(() => message.error('导出失败'));
|
||||||
|
}, [buildParams]);
|
||||||
|
|
||||||
|
const patchRecordStatus = (recordId: number, nextStatus: string) => {
|
||||||
|
queryClient.setQueryData<{
|
||||||
|
records: AttendanceRecordItem[];
|
||||||
|
total: number;
|
||||||
|
summary: AttendanceSummary;
|
||||||
|
}>(recordQueryKey, (prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
records: prev.records.map((item) =>
|
||||||
|
item.id === recordId ? { ...item, status: nextStatus } : item,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
setSelectedStudent((student) => {
|
||||||
|
if (!student) return student;
|
||||||
|
return {
|
||||||
|
...student,
|
||||||
|
records: student.records.map((item) =>
|
||||||
|
item.id === recordId ? { ...item, status: nextStatus } : item,
|
||||||
|
),
|
||||||
|
statusBySession: Object.fromEntries(
|
||||||
|
Object.entries(student.statusBySession).map(([key, item]) => [
|
||||||
|
key,
|
||||||
|
item?.id === recordId ? { ...item, status: nextStatus } : item,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAdminRecordStatus = async (record: AttendanceRecordItem, nextStatus: string) => {
|
||||||
|
const previousStatus = record.status;
|
||||||
|
const normalizedPrevious = displayAttendanceStatus(previousStatus);
|
||||||
|
if (normalizedPrevious === nextStatus && previousStatus !== 'pending') return;
|
||||||
|
|
||||||
|
patchRecordStatus(record.id, nextStatus);
|
||||||
|
setCorrectingRecordId(record.id);
|
||||||
|
try {
|
||||||
|
await updateStatusMutation.mutateAsync({ id: record.id, status: nextStatus });
|
||||||
|
message.success('考勤结果已更新');
|
||||||
|
} catch {
|
||||||
|
patchRecordStatus(record.id, previousStatus);
|
||||||
|
} finally {
|
||||||
|
setCorrectingRecordId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAdminRecordCell = async (
|
||||||
|
record: AttendanceRecordItem,
|
||||||
|
field: 'status' | 'remark',
|
||||||
|
value: unknown,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await saveRecordCellMutation.mutateAsync({ id: record.id, field, value });
|
||||||
|
message.success('考勤记录已保存');
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 useApiMutation 统一处理
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const studentPanels = useMemo(() => buildAdminStudentPanels(records), [records]);
|
||||||
|
const visibleStudents = useMemo(() => {
|
||||||
|
const query = studentSearch.trim().toLocaleLowerCase('zh-CN');
|
||||||
|
return studentPanels.filter((student) => {
|
||||||
|
const matchesQuery =
|
||||||
|
!query ||
|
||||||
|
student.studentName.toLocaleLowerCase('zh-CN').includes(query) ||
|
||||||
|
student.studentNo.toLocaleLowerCase('zh-CN').includes(query);
|
||||||
|
const matchesMetric =
|
||||||
|
metricFilter === 'all' ||
|
||||||
|
student.records.some((record) => displayAttendanceStatus(record.status) === metricFilter);
|
||||||
|
return matchesQuery && matchesMetric;
|
||||||
|
});
|
||||||
|
}, [metricFilter, studentPanels, studentSearch]);
|
||||||
|
|
||||||
|
const selectedClassOption = classId
|
||||||
|
? classOptions.find((item) => item.classId === classId)
|
||||||
|
: undefined;
|
||||||
|
const selectedClass =
|
||||||
|
selectedClassOption?.className || (classId ? `班级 ${classId}` : '全部班级');
|
||||||
|
const overviewTeachers = selectedClassOption?.teachers ?? [];
|
||||||
|
const headTeacherNames = classId
|
||||||
|
? formatTeacherNames(overviewTeachers.filter((teacher) => teacher.roleType === 'head_teacher'))
|
||||||
|
: '请选择班级';
|
||||||
|
const lifeTeacherNames = classId
|
||||||
|
? formatTeacherNames(overviewTeachers.filter((teacher) => teacher.roleType === 'life_teacher'))
|
||||||
|
: '请选择班级';
|
||||||
|
const currentSchedule = scheduleId
|
||||||
|
? scheduleOptions.find((item) => item.id === scheduleId)
|
||||||
|
: undefined;
|
||||||
|
const subjectTeacherNames = currentSchedule
|
||||||
|
? getTeacherDisplayName({
|
||||||
|
name: currentSchedule.teacherName,
|
||||||
|
username: currentSchedule.teacherUsername,
|
||||||
|
})
|
||||||
|
: classId
|
||||||
|
? formatTeacherNames(
|
||||||
|
overviewTeachers.filter((teacher) => teacher.roleType === 'subject_teacher'),
|
||||||
|
)
|
||||||
|
: '请选择班级';
|
||||||
|
const attendanceRate =
|
||||||
|
summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0;
|
||||||
|
const dateLabel = attendanceDate?.format('YYYY-MM-DD') || '未选择日期';
|
||||||
|
|
||||||
|
const columns = buildAttendanceAdminColumns({
|
||||||
|
isMobile,
|
||||||
|
canEdit,
|
||||||
|
sessionMap,
|
||||||
|
correctingRecordId,
|
||||||
|
onSaveAdminRecordCell: saveAdminRecordCell,
|
||||||
|
onUpdateAdminRecordStatus: updateAdminRecordStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="attendance-page admin-attendance student-attendance-center">
|
||||||
|
<AttendanceAdminHeader
|
||||||
|
syncStatus={syncStatus}
|
||||||
|
canEdit={canEdit}
|
||||||
|
refreshingDingTalk={refreshingDingTalk}
|
||||||
|
onOpenPeriodConfig={openPeriodConfig}
|
||||||
|
onRefreshDingTalk={() => void refreshDingTalkAttendance()}
|
||||||
|
onExport={handleExport}
|
||||||
|
attendanceDate={attendanceDate}
|
||||||
|
onDateChange={(value) => {
|
||||||
|
setAttendanceDate(value);
|
||||||
|
setScheduleId(undefined);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
classId={classId}
|
||||||
|
onClassChange={(value) => {
|
||||||
|
setClassId(value);
|
||||||
|
setScheduleId(undefined);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
classOptions={classOptions}
|
||||||
|
effectiveScheduleId={effectiveScheduleId}
|
||||||
|
onScheduleChange={(value) => {
|
||||||
|
setScheduleId(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
scheduleOptions={scheduleOptions}
|
||||||
|
scheduleOptionsLoading={scheduleOptionsLoading}
|
||||||
|
session={session}
|
||||||
|
onSessionChange={(value) => {
|
||||||
|
setSession(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
sessionOptions={sessionOptions}
|
||||||
|
onReset={resetFilters}
|
||||||
|
onQuery={() => void loadRecords()}
|
||||||
|
selectedClass={selectedClass}
|
||||||
|
dateLabel={dateLabel}
|
||||||
|
visibleStudentCount={visibleStudents.length}
|
||||||
|
total={total}
|
||||||
|
headTeacherNames={headTeacherNames}
|
||||||
|
lifeTeacherNames={lifeTeacherNames}
|
||||||
|
subjectTeacherNames={subjectTeacherNames}
|
||||||
|
attendanceRate={attendanceRate}
|
||||||
|
summary={summary}
|
||||||
|
metricFilter={metricFilter}
|
||||||
|
onMetricFilterChange={setMetricFilter}
|
||||||
|
alerts={alerts}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AttendanceAdminWorkspace
|
||||||
|
metricFilter={metricFilter}
|
||||||
|
studentSearch={studentSearch}
|
||||||
|
onSearchChange={setStudentSearch}
|
||||||
|
onExport={handleExport}
|
||||||
|
visibleStudents={visibleStudents}
|
||||||
|
loading={loading}
|
||||||
|
selectedStudentId={selectedStudent?.studentId}
|
||||||
|
onSelectStudent={setSelectedStudent}
|
||||||
|
sortAttendanceRecords={sortAttendanceRecords}
|
||||||
|
sessionMap={sessionMap}
|
||||||
|
records={records}
|
||||||
|
columns={columns}
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
onPageChange={(nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage);
|
||||||
|
setPageSize(nextPageSize);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StudentDetailDrawer
|
||||||
|
student={selectedStudent}
|
||||||
|
isMobile={isMobile}
|
||||||
|
canEdit={canEdit}
|
||||||
|
sessionMap={sessionMap}
|
||||||
|
correctingRecordId={correctingRecordId}
|
||||||
|
sortAttendanceRecords={sortAttendanceRecords}
|
||||||
|
onClose={() => setSelectedStudent(null)}
|
||||||
|
onUpdateAdminRecordStatus={updateAdminRecordStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PeriodConfigModal
|
||||||
|
open={periodModalOpen}
|
||||||
|
form={periodForm}
|
||||||
|
onOk={savePeriodConfig}
|
||||||
|
onCancel={() => setPeriodModalOpen(false)}
|
||||||
|
onReset={() => void resetPeriodConfig()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
218
apps/admin/src/pages/Attendance/teacher.tsx
Normal file
218
apps/admin/src/pages/Attendance/teacher.tsx
Normal file
@@ -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<TodaySchedule | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: workspace,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useQuery<TeacherWorkspaceData | null>({
|
||||||
|
queryKey: ['attendance', 'workspace'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return await api.get<TeacherWorkspaceData>('/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 (
|
||||||
|
<div className="attendance-page teacher-attendance">
|
||||||
|
<section className="attendance-hero attendance-hero--teacher">
|
||||||
|
<div>
|
||||||
|
<span className="attendance-eyebrow">
|
||||||
|
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
|
||||||
|
</span>
|
||||||
|
<h1>今天,从课程开始</h1>
|
||||||
|
<p>课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。</p>
|
||||||
|
</div>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} className="teacher-overview">
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<div className="teacher-kpi">
|
||||||
|
<span>今日课程</span>
|
||||||
|
<strong>{schedules.length}</strong>
|
||||||
|
<small>节</small>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<div className="teacher-kpi">
|
||||||
|
<span>已开始</span>
|
||||||
|
<strong>{startedCount}</strong>
|
||||||
|
<small>节,可查看考勤</small>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<div className="teacher-kpi teacher-kpi--next">
|
||||||
|
<span>下一节</span>
|
||||||
|
<strong>{nextSchedule ? nextSchedule.startTime : '—'}</strong>
|
||||||
|
<small>{nextSchedule?.subject || '今天没有更多课程'}</small>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div className="attendance-section-heading">
|
||||||
|
<div>
|
||||||
|
<span>今日教学节奏</span>
|
||||||
|
<h2>我的课程</h2>
|
||||||
|
</div>
|
||||||
|
<span className="attendance-section-note">课程开始后可拉取钉钉考勤记录</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{schedules.length === 0 ? (
|
||||||
|
<Card className="attendance-empty-card">
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<strong>今天还没有课程</strong>
|
||||||
|
<p>请联系教务管理员安排课程。</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="lesson-timeline">
|
||||||
|
{schedules.map((schedule, index) => {
|
||||||
|
const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
|
||||||
|
return (
|
||||||
|
<LessonCard
|
||||||
|
key={schedule.id}
|
||||||
|
schedule={schedule}
|
||||||
|
phase={phase}
|
||||||
|
className={classNameById.get(schedule.classId) || `班级 ${schedule.classId}`}
|
||||||
|
index={index + 1}
|
||||||
|
onOpen={canCreate ? () => openAttendance(schedule) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
{canCreate ? (
|
||||||
|
<LessonAttendanceDetail
|
||||||
|
key={selectedSchedule?.id ?? 'closed'}
|
||||||
|
schedule={selectedSchedule}
|
||||||
|
className={
|
||||||
|
selectedSchedule
|
||||||
|
? classNameById.get(selectedSchedule.classId) || `班级 ${selectedSchedule.classId}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
onClose={() => setSelectedSchedule(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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: <ClockCircleOutlined />, tone: 'upcoming' },
|
||||||
|
ongoing: { label: '进行中', icon: <ScheduleOutlined />, tone: 'ongoing' },
|
||||||
|
ended: { label: '已结束', icon: <CheckCircleFilled />, tone: 'ended' },
|
||||||
|
}[phase];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`lesson-card lesson-card--${phaseMeta.tone}`}>
|
||||||
|
<div className="lesson-sequence">{String(index).padStart(2, '0')}</div>
|
||||||
|
<div className="lesson-time">
|
||||||
|
<strong>{schedule.startTime}</strong>
|
||||||
|
<span />
|
||||||
|
<strong>{schedule.endTime}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="lesson-main">
|
||||||
|
<div className="lesson-title-row">
|
||||||
|
<h3>{schedule.subject}</h3>
|
||||||
|
<Tag icon={phaseMeta.icon}>{phaseMeta.label}</Tag>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<TeamOutlined /> {className}
|
||||||
|
<span>教室 {schedule.classroomId}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="lesson-action">
|
||||||
|
{phase === 'upcoming' ? (
|
||||||
|
<Tooltip title="课程尚未开始">
|
||||||
|
<Button disabled>等待上课</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : onOpen ? (
|
||||||
|
<Button type="primary" onClick={onOpen}>
|
||||||
|
{phase === 'ongoing' ? '查看当前考勤' : '拉取 / 查看考勤'} <ArrowRightOutlined />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 { Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
@@ -30,34 +34,57 @@ const statusMeta = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const AttendanceDevicesPage: React.FC = () => {
|
const AttendanceDevicesPage: React.FC = () => {
|
||||||
const [data, setData] = useState<AttendanceDeviceRow[]>([]);
|
|
||||||
const [classrooms, setClassrooms] = useState<ClassroomOption[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
|
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
const loadData = async () => {
|
const {
|
||||||
setLoading(true);
|
data: fetchResult = { devices: [], classrooms: [] },
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery<{ devices: AttendanceDeviceRow[]; classrooms: ClassroomOption[] }>({
|
||||||
|
queryKey: ['attendance-devices'],
|
||||||
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const [devices, classroomList] = await Promise.all([
|
const [devices, classroomList] = await Promise.all([
|
||||||
api.get<AttendanceDeviceRow[]>('/attendance-devices'),
|
api.get<AttendanceDeviceRow[]>('/attendance-devices'),
|
||||||
api.get<ClassroomOption[]>('/classrooms'),
|
api.get<ClassroomOption[]>('/classrooms'),
|
||||||
]);
|
]);
|
||||||
setData(devices);
|
return {
|
||||||
setClassrooms(classroomList.filter((item: any) => item.status !== 'archived'));
|
devices: validateResponse<AttendanceDeviceRow[]>(attendanceDevicesSchema, devices),
|
||||||
|
classrooms: validateResponse<ClassroomOption[]>(
|
||||||
|
classroomOptionsSchema,
|
||||||
|
classroomList,
|
||||||
|
).filter((item: any) => item.status !== 'archived'),
|
||||||
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error?.message || '加载考勤机绑定失败');
|
message.error(error?.message || '加载考勤机绑定失败');
|
||||||
} finally {
|
return { devices: [], classrooms: [] };
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
});
|
||||||
|
const data = fetchResult.devices;
|
||||||
|
const classrooms = fetchResult.classrooms;
|
||||||
|
const loading = isLoading || isFetching;
|
||||||
|
|
||||||
useEffect(() => {
|
const saveMutation = useApiMutation(
|
||||||
void loadData();
|
async (values: Record<string, unknown>) =>
|
||||||
}, []);
|
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(
|
const classroomOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -102,37 +129,33 @@ const AttendanceDevicesPage: React.FC = () => {
|
|||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
await saveMutation.mutateAsync(values);
|
||||||
await api.put(`/attendance-devices/${editing.id}`, values);
|
message.success(editing ? '考勤机绑定已更新' : '考勤机绑定已创建');
|
||||||
message.success('考勤机绑定已更新');
|
|
||||||
} else {
|
|
||||||
await api.post('/attendance-devices', values);
|
|
||||||
message.success('考勤机绑定已创建');
|
|
||||||
}
|
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
await loadData();
|
} catch {
|
||||||
} catch (error: any) {
|
// 错误提示由 useApiMutation 统一处理
|
||||||
message.error(error?.message || '保存失败');
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveCell = async (record: AttendanceDeviceRow, field: string, value: unknown) => {
|
const saveCell = async (record: AttendanceDeviceRow, field: string, value: unknown) => {
|
||||||
await api.put(`/attendance-devices/${record.id}`, { [field]: value });
|
try {
|
||||||
|
await saveCellMutation.mutateAsync({ record, field, value });
|
||||||
message.success('已保存');
|
message.success('已保存');
|
||||||
await loadData();
|
} catch {
|
||||||
|
// 错误提示由 useApiMutation 统一处理
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/attendance-devices/${id}`);
|
await deleteMutation.mutateAsync(id);
|
||||||
message.success('已停用绑定');
|
message.success('已停用绑定');
|
||||||
await loadData();
|
} catch {
|
||||||
} catch (error: any) {
|
// 错误提示由 useApiMutation 统一处理
|
||||||
message.error(error?.message || '停用失败');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
118
apps/server/src/attendance/attendance-calendar.service.ts
Normal file
118
apps/server/src/attendance/attendance-calendar.service.ts
Normal file
@@ -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<AttendanceRecord>,
|
||||||
|
@InjectRepository(ClassSchedule)
|
||||||
|
private scheduleRepo: Repository<ClassSchedule>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
72
apps/server/src/attendance/attendance-device.ts
Normal file
72
apps/server/src/attendance/attendance-device.ts
Normal file
@@ -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<T extends AttendanceRecord>(
|
||||||
|
records: T[],
|
||||||
|
attendanceDeviceRepo: Repository<AttendanceDevice>,
|
||||||
|
classroomId?: number | null,
|
||||||
|
): Promise<T[]> {
|
||||||
|
if (records.length === 0) return records;
|
||||||
|
const sns = [
|
||||||
|
...new Set(records.map((record) => record.punchDeviceId?.trim()).filter(Boolean) as string[]),
|
||||||
|
];
|
||||||
|
const devicesBySn = new Map<string, AttendanceDevice>();
|
||||||
|
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<number, AttendanceDevice>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
101
apps/server/src/attendance/attendance-dingtalk.ts
Normal file
101
apps/server/src/attendance/attendance-dingtalk.ts
Normal file
@@ -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<AttendanceRecord, 'punchTime' | 'punchSource' | 'punchDeviceName' | 'punchDeviceId'> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
299
apps/server/src/attendance/attendance-generation.service.ts
Normal file
299
apps/server/src/attendance/attendance-generation.service.ts
Normal file
@@ -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<AttendanceRecord>,
|
||||||
|
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
|
||||||
|
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||||
|
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||||
|
@InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository<AttendanceSession>,
|
||||||
|
@InjectRepository(AttendancePeriodConfig) private attendancePeriodConfigRepo: Repository<AttendancePeriodConfig>,
|
||||||
|
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<string>();
|
||||||
|
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<string> {
|
||||||
|
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} 未匹配到考勤时段,请先配置考勤时段`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
147
apps/server/src/attendance/attendance-import.controller.ts
Normal file
147
apps/server/src/attendance/attendance-import.controller.ts
Normal file
@@ -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<SseEvent> {
|
||||||
|
const userId = req.user.id;
|
||||||
|
return new Observable<SseEvent>((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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,13 +96,11 @@ export class AttendanceImportService {
|
|||||||
let matched = 0;
|
let matched = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Stage 1: Fetch
|
|
||||||
this.emit('fetching', 0, 0, 'Fetching attendance results from DingTalk...');
|
this.emit('fetching', 0, 0, 'Fetching attendance results from DingTalk...');
|
||||||
const rawResults = await this.fetchAllPages(params);
|
const rawResults = await this.fetchAllPages(params);
|
||||||
const total = rawResults.length;
|
const total = rawResults.length;
|
||||||
this.emit('fetching', total, total, `Fetched ${total} raw attendance records`);
|
this.emit('fetching', total, total, `Fetched ${total} raw attendance records`);
|
||||||
|
|
||||||
// Stage 2: Parse & deduplicate
|
|
||||||
this.emit('parsing', 0, total, `Parsing ${total} records...`);
|
this.emit('parsing', 0, total, `Parsing ${total} records...`);
|
||||||
const existingByDingId = await this.getExistingRecordsByDingId(rawResults);
|
const existingByDingId = await this.getExistingRecordsByDingId(rawResults);
|
||||||
const newRecords = rawResults.filter((r) => !existingByDingId.has(r.checkId));
|
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 };
|
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...`);
|
this.emit('saving', 0, newRecords.length, `Saving ${newRecords.length} records...`);
|
||||||
const batchSize = 100;
|
const batchSize = 100;
|
||||||
for (let i = 0; i < newRecords.length; i += batchSize) {
|
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) {
|
if (params.autoMatch && imported > 0) {
|
||||||
this.emit('matching', 0, imported, 'Auto-matching records to students...');
|
this.emit('matching', 0, imported, 'Auto-matching records to students...');
|
||||||
matched = await this.autoMatchUnmatched();
|
matched = await this.autoMatchUnmatched();
|
||||||
@@ -305,7 +301,6 @@ export class AttendanceImportService {
|
|||||||
entity.punchDeviceName = r.deviceName || null;
|
entity.punchDeviceName = r.deviceName || null;
|
||||||
entity.punchDeviceId = r.deviceId || null;
|
entity.punchDeviceId = r.deviceId || null;
|
||||||
|
|
||||||
// Parse check-in/out times
|
|
||||||
if (r.actualCheckTime) {
|
if (r.actualCheckTime) {
|
||||||
const dt = new Date(r.actualCheckTime);
|
const dt = new Date(r.actualCheckTime);
|
||||||
if (!isNaN(dt.getTime())) {
|
if (!isNaN(dt.getTime())) {
|
||||||
|
|||||||
377
apps/server/src/attendance/attendance-lesson.service.ts
Normal file
377
apps/server/src/attendance/attendance-lesson.service.ts
Normal file
@@ -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<AttendanceRecord>,
|
||||||
|
@InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository<DingAttendanceRaw>,
|
||||||
|
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||||
|
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||||
|
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
|
||||||
|
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||||
|
@InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||||
|
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||||
|
@InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository<AttendanceSession>,
|
||||||
|
@InjectRepository(AttendanceDevice) private attendanceDeviceRepo: Repository<AttendanceDevice>,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getClassStudentsForLesson(
|
||||||
|
classId: number,
|
||||||
|
lessonDate: string,
|
||||||
|
relations: string[] = [],
|
||||||
|
): Promise<ClassStudent[]> {
|
||||||
|
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<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||||
|
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<string, unknown>).code;
|
||||||
|
const errno = (err as Record<string, unknown>).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<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||||
|
lessonDate: string,
|
||||||
|
): Promise<Map<number, DingAttendanceRaw[]>> {
|
||||||
|
const classStudents = await this.getClassStudentsForLesson(classId, lessonDate);
|
||||||
|
if (classStudents.length === 0) return new Map();
|
||||||
|
const studentIds = classStudents.map((cs) => cs.studentId);
|
||||||
|
const window = getLessonAttendanceWindow(schedule, lessonDate);
|
||||||
|
const rawRecords = await this.dingRawRepo.find({
|
||||||
|
where: {
|
||||||
|
attendanceDate: Between(window.dateFrom, window.dateTo),
|
||||||
|
matchedStudentId: In(studentIds),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rawByStudent = new Map<number, DingAttendanceRaw[]>();
|
||||||
|
for (const raw of rawRecords) {
|
||||||
|
if (raw.matchedStudentId == null) continue;
|
||||||
|
const arr = rawByStudent.get(raw.matchedStudentId) ?? [];
|
||||||
|
arr.push(raw);
|
||||||
|
rawByStudent.set(raw.matchedStudentId, arr);
|
||||||
|
}
|
||||||
|
return rawByStudent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeLessonAttendance(sessionId: number, userId: number) {
|
||||||
|
return this.sessionMutex.runExclusive(sessionId, () =>
|
||||||
|
this.dataSource.transaction(async (manager) => {
|
||||||
|
const sessionRepo = manager.getRepository(AttendanceSession);
|
||||||
|
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||||
|
|
||||||
|
const session = await sessionRepo.findOne({ where: { id: sessionId } });
|
||||||
|
if (!session) throw new NotFoundException('课程考勤场次不存在');
|
||||||
|
|
||||||
|
// Re-check under lock: if already completed, return current state idempotently
|
||||||
|
if (session.status === 'completed') {
|
||||||
|
const records = await recordRepo.find({
|
||||||
|
where: { attendanceSessionId: sessionId },
|
||||||
|
relations: ['student'],
|
||||||
|
order: { studentId: 'ASC' },
|
||||||
|
});
|
||||||
|
return { session, records: await attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo, session.classId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRecords = await recordRepo.count({
|
||||||
|
where: { attendanceSessionId: sessionId, status: 'pending' },
|
||||||
|
});
|
||||||
|
if (pendingRecords > 0) {
|
||||||
|
throw new BadRequestException('存在未处理的考勤记录,无法完成考勤');
|
||||||
|
}
|
||||||
|
|
||||||
|
session.status = 'completed';
|
||||||
|
session.completedBy = userId;
|
||||||
|
session.completedAt = new Date();
|
||||||
|
const savedSession = await sessionRepo.save(session);
|
||||||
|
const records = await recordRepo.find({
|
||||||
|
where: { attendanceSessionId: sessionId },
|
||||||
|
relations: ['student'],
|
||||||
|
order: { studentId: 'ASC' },
|
||||||
|
});
|
||||||
|
return { session: savedSession, records: await attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo, session.classId) };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAttendanceSession(id: number) {
|
||||||
|
const session = await this.attendanceSessionRepo.findOne({ where: { id } });
|
||||||
|
if (!session) throw new NotFoundException('课程考勤场次不存在');
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch create attendance records ──
|
||||||
|
}
|
||||||
23
apps/server/src/attendance/attendance-mutex.ts
Normal file
23
apps/server/src/attendance/attendance-mutex.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 同一考勤会话(session)内的互斥执行器:
|
||||||
|
* 保证针对同一个 sessionId 的并发操作按提交顺序串行执行。
|
||||||
|
*/
|
||||||
|
export class SessionMutex {
|
||||||
|
private queueTails = new Map<number, Promise<void>>();
|
||||||
|
|
||||||
|
async runExclusive<T>(sessionId: number, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const tail = this.queueTails.get(sessionId) ?? Promise.resolve();
|
||||||
|
let release!: () => void;
|
||||||
|
const newTail = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
this.queueTails.set(sessionId, newTail);
|
||||||
|
await tail;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
if (this.queueTails.get(sessionId) === newTail) {
|
||||||
|
this.queueTails.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
319
apps/server/src/attendance/attendance-query.service.ts
Normal file
319
apps/server/src/attendance/attendance-query.service.ts
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, In, DataSource } from 'typeorm';
|
||||||
|
import {
|
||||||
|
AttendanceRecord,
|
||||||
|
DingAttendanceRaw,
|
||||||
|
Class,
|
||||||
|
ClassSchedule,
|
||||||
|
ClassStudent,
|
||||||
|
StudentDingMapping,
|
||||||
|
ClassTeacher,
|
||||||
|
AttendanceDevice,
|
||||||
|
TeacherRoleType,
|
||||||
|
} from '../entities';
|
||||||
|
import type {
|
||||||
|
AttendanceSummaryQueryDto,
|
||||||
|
QueryDingRawDto,
|
||||||
|
} from './dto/attendance.dto';
|
||||||
|
import { attachAttendanceDeviceMappings } from './attendance-device';
|
||||||
|
import { AttendanceCalendarService } from './attendance-calendar.service';
|
||||||
|
import { AttendanceRecordMutationService } from './attendance-record-mutation.service';
|
||||||
|
import { AttendanceReportService } from './attendance-report.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AttendanceQueryService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||||||
|
@InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository<DingAttendanceRaw>,
|
||||||
|
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||||
|
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
|
||||||
|
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||||
|
@InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||||
|
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||||
|
@InjectRepository(AttendanceDevice) private attendanceDeviceRepo: Repository<AttendanceDevice>,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private calendarService?: AttendanceCalendarService;
|
||||||
|
private mutationService?: AttendanceRecordMutationService;
|
||||||
|
private reportService?: AttendanceReportService;
|
||||||
|
|
||||||
|
private get calendar(): AttendanceCalendarService {
|
||||||
|
if (!this.calendarService) {
|
||||||
|
this.calendarService = new AttendanceCalendarService(this.attendanceRepo, this.scheduleRepo);
|
||||||
|
}
|
||||||
|
return this.calendarService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get mutations(): AttendanceRecordMutationService {
|
||||||
|
if (!this.mutationService) {
|
||||||
|
this.mutationService = new AttendanceRecordMutationService(
|
||||||
|
this.attendanceRepo,
|
||||||
|
this.dingRawRepo,
|
||||||
|
this.studentDingMappingRepo,
|
||||||
|
this.dataSource,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.mutationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get reports(): AttendanceReportService {
|
||||||
|
if (!this.reportService) {
|
||||||
|
this.reportService = new AttendanceReportService(
|
||||||
|
this.attendanceRepo,
|
||||||
|
this.attendanceDeviceRepo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.reportService;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) {
|
||||||
|
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||||
|
if (query.classId) {
|
||||||
|
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||||
|
}
|
||||||
|
if (query.scheduleId) {
|
||||||
|
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
|
||||||
|
}
|
||||||
|
if (!query.classId && accessibleClassIds) {
|
||||||
|
if (accessibleClassIds.length === 0)
|
||||||
|
return { total: 0, present: 0, late: 0, absent: 0, leave: 0, pending: 0, presentRate: 0 };
|
||||||
|
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||||
|
}
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 pending = rows.filter((r) => r.status === 'pending').length;
|
||||||
|
const presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0;
|
||||||
|
|
||||||
|
return { total, present, late, absent, leave, pending, presentRate };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attendance calendar ──
|
||||||
|
async getCalendar(...args: Parameters<AttendanceCalendarService['getCalendar']>) {
|
||||||
|
return this.calendar.getCalendar(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScheduleOptionsForAttendance(
|
||||||
|
...args: Parameters<AttendanceCalendarService['getScheduleOptionsForAttendance']>
|
||||||
|
) {
|
||||||
|
return this.calendar.getScheduleOptionsForAttendance(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List attendance records with filters ──
|
||||||
|
async findAll(
|
||||||
|
query: {
|
||||||
|
classId?: number;
|
||||||
|
scheduleId?: number;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
session?: string;
|
||||||
|
status?: string;
|
||||||
|
source?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
},
|
||||||
|
accessibleClassIds?: number[],
|
||||||
|
) {
|
||||||
|
const page = query.page || 1;
|
||||||
|
const pageSize = query.pageSize || 20;
|
||||||
|
|
||||||
|
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||||
|
|
||||||
|
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
|
||||||
|
if (query.scheduleId) {
|
||||||
|
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
|
||||||
|
}
|
||||||
|
if (query.classId) {
|
||||||
|
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||||
|
} else if (accessibleClassIds) {
|
||||||
|
if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||||
|
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||||
|
}
|
||||||
|
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: await attachAttendanceDeviceMappings(list, this.attendanceDeviceRepo), total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get distinct classes with attendance records ──
|
||||||
|
async getClasses(accessibleClassIds?: number[]) {
|
||||||
|
const qb = this.attendanceRepo
|
||||||
|
.createQueryBuilder('ar')
|
||||||
|
.select('DISTINCT ar.classId', 'classId')
|
||||||
|
.where('ar.classId IS NOT NULL');
|
||||||
|
|
||||||
|
const rows: Array<{ classId: string | number }> = accessibleClassIds
|
||||||
|
? accessibleClassIds.map((classId) => ({ classId }))
|
||||||
|
: await qb.orderBy('ar.classId', 'ASC').getRawMany();
|
||||||
|
|
||||||
|
const classIds = [...new Set(rows.map((r) => Number(r.classId)).filter(Boolean))];
|
||||||
|
if (classIds.length === 0) return [];
|
||||||
|
|
||||||
|
const where = { id: In(classIds) };
|
||||||
|
const [classes, teachers] = await Promise.all([
|
||||||
|
this.classRepo.find({ where }),
|
||||||
|
this.classTeacherRepo.find({
|
||||||
|
where: {
|
||||||
|
classId: In(classIds),
|
||||||
|
roleType: In([
|
||||||
|
TeacherRoleType.HEAD_TEACHER,
|
||||||
|
TeacherRoleType.LIFE_TEACHER,
|
||||||
|
TeacherRoleType.SUBJECT_TEACHER,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
relations: ['user'],
|
||||||
|
order: { roleType: 'ASC', id: 'ASC' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const nameMap = new Map(classes.map((c) => [c.id, c.name]));
|
||||||
|
const teacherMap = new Map<
|
||||||
|
number,
|
||||||
|
Array<{
|
||||||
|
userId: number;
|
||||||
|
username: string | null;
|
||||||
|
name: string | null;
|
||||||
|
roleType: string;
|
||||||
|
subject: string | null;
|
||||||
|
}>
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const teacher of teachers) {
|
||||||
|
const user = teacher.user as { username?: string | null; name?: string | null } | undefined;
|
||||||
|
const items = teacherMap.get(teacher.classId) ?? [];
|
||||||
|
items.push({
|
||||||
|
userId: teacher.userId,
|
||||||
|
username: user?.username || null,
|
||||||
|
name: user?.name || null,
|
||||||
|
roleType: teacher.roleType,
|
||||||
|
subject: teacher.subject || null,
|
||||||
|
});
|
||||||
|
teacherMap.set(teacher.classId, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
return classIds.map((id) => ({
|
||||||
|
classId: id,
|
||||||
|
className: nameMap.get(id) || `班级${id}`,
|
||||||
|
teachers: teacherMap.get(id) ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DingAttendance raw records ──
|
||||||
|
async getDingRaw(query: QueryDingRawDto, accessibleClassIds?: number[]) {
|
||||||
|
const page = query.page || 1;
|
||||||
|
const pageSize = query.pageSize || 20;
|
||||||
|
const qb = this.dingRawRepo.createQueryBuilder('ar');
|
||||||
|
|
||||||
|
qb.leftJoinAndSelect('ar.matchedStudent', 'matchedStudent');
|
||||||
|
if (query.matchStatus) {
|
||||||
|
qb.andWhere('ar.matchStatus = :matchStatus', { matchStatus: query.matchStatus });
|
||||||
|
}
|
||||||
|
if (query.dateFrom) {
|
||||||
|
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||||||
|
}
|
||||||
|
if (query.dateTo) {
|
||||||
|
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
||||||
|
}
|
||||||
|
const scopedClassIds = query.classId ? [query.classId] : accessibleClassIds;
|
||||||
|
if (scopedClassIds) {
|
||||||
|
if (scopedClassIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||||
|
const classStudents = await this.classStudentRepo.find({
|
||||||
|
where: { classId: In(scopedClassIds), status: 'active' },
|
||||||
|
});
|
||||||
|
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
|
||||||
|
if (studentIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||||
|
const mappings = await this.studentDingMappingRepo.find({
|
||||||
|
where: { studentId: In(studentIds) },
|
||||||
|
});
|
||||||
|
const dingUserIds = [
|
||||||
|
...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean)),
|
||||||
|
];
|
||||||
|
if (dingUserIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||||
|
qb.andWhere('ar.dingUserId IN (:...dingUserIds)', { dingUserIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
qb.orderBy('ar.attendanceDate', 'DESC')
|
||||||
|
.addOrderBy('ar.checkInTime', 'ASC')
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize);
|
||||||
|
|
||||||
|
const [list, total] = await qb.getManyAndCount();
|
||||||
|
return { list, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Match a dingtalk record to a student ──
|
||||||
|
async matchDingRecord(...args: Parameters<AttendanceRecordMutationService['matchDingRecord']>) {
|
||||||
|
return this.mutations.matchDingRecord(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-match unmatched dingtalk records via dingUserId → userId mapping chain ──
|
||||||
|
async autoMatchDingRecords(): Promise<{ matched: number; total: number }> {
|
||||||
|
return this.mutations.autoMatchDingRecords();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export all attendance records with filters (no pagination) ──
|
||||||
|
async findAllForExport(
|
||||||
|
...args: Parameters<AttendanceReportService['findAllForExport']>
|
||||||
|
) {
|
||||||
|
return this.reports.findAllForExport(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAttendanceRecord(
|
||||||
|
...args: Parameters<AttendanceRecordMutationService['findAttendanceRecord']>
|
||||||
|
) {
|
||||||
|
return this.mutations.findAttendanceRecord(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update a single attendance record ──
|
||||||
|
async update(...args: Parameters<AttendanceRecordMutationService['update']>) {
|
||||||
|
return this.mutations.update(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete a single attendance record ──
|
||||||
|
async remove(...args: Parameters<AttendanceRecordMutationService['remove']>) {
|
||||||
|
return this.mutations.remove(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Class-based attendance report ──
|
||||||
|
async getReport(...args: Parameters<AttendanceReportService['getReport']>) {
|
||||||
|
return this.reports.getReport(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attendance alerts: detect consecutive absences/late ──
|
||||||
|
async getAlerts(...args: Parameters<AttendanceReportService['getAlerts']>) {
|
||||||
|
return this.reports.getAlerts(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
apps/server/src/attendance/attendance-record-mutation.service.ts
Normal file
156
apps/server/src/attendance/attendance-record-mutation.service.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
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 };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
365
apps/server/src/attendance/attendance-records.controller.ts
Normal file
365
apps/server/src/attendance/attendance-records.controller.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import { Controller, Get, Post, Put, Delete, Body, Param, Query, Request, Res, BadRequestException, ForbiddenException, ParseIntPipe } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { AttendanceControllerBase, RequestUser } from './attendance.controller-base';
|
||||||
|
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 {
|
||||||
|
BatchCreateAttendanceDto,
|
||||||
|
AttendanceSummaryQueryDto,
|
||||||
|
AttendanceCalendarQueryDto,
|
||||||
|
QueryAttendanceRecordsDto,
|
||||||
|
AttendanceScheduleOptionsQueryDto,
|
||||||
|
AttendanceReportQueryDto,
|
||||||
|
AttendanceAlertsQueryDto,
|
||||||
|
UpdateAttendanceRecordDto,
|
||||||
|
GenerateFromSchedulesDto,
|
||||||
|
RefreshDingTalkAttendanceDto,
|
||||||
|
} from './dto/attendance.dto';
|
||||||
|
import * as ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AttendanceRecordsController extends AttendanceControllerBase {
|
||||||
|
constructor(
|
||||||
|
service: import('./attendance.service').AttendanceService,
|
||||||
|
importService: import('./attendance-import.service').AttendanceImportService,
|
||||||
|
logService: OperationLogsService,
|
||||||
|
authz: AuthorizationService,
|
||||||
|
) {
|
||||||
|
super(service, importService, logService, authz);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('attendance-records/dingtalk-sync-status')
|
||||||
|
@RequirePermission('attendance:view')
|
||||||
|
async getDingTalkSyncStatus() {
|
||||||
|
const latest = await this.logService.findLatestDingTalkAttendancePull();
|
||||||
|
return {
|
||||||
|
lastPulledAt: latest?.createdAt ?? null,
|
||||||
|
action: latest?.action ?? null,
|
||||||
|
username: latest?.username ?? null,
|
||||||
|
detail: latest?.detail ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('attendance-records/refresh-dingtalk')
|
||||||
|
@RequirePermission('attendance:create')
|
||||||
|
async refreshDingTalkAttendance(
|
||||||
|
@Body() dto: RefreshDingTalkAttendanceDto,
|
||||||
|
@Request() req: { user: RequestUser },
|
||||||
|
) {
|
||||||
|
if (dto.date > this.getTodayDateOnly()) {
|
||||||
|
throw new BadRequestException('不能查看或刷新未来日期的考勤');
|
||||||
|
}
|
||||||
|
if (dto.classId) await this.assertClassAccess(req, dto.classId);
|
||||||
|
const schedules = await this.service.getRefreshableSchedules(
|
||||||
|
dto.date,
|
||||||
|
dto.classId,
|
||||||
|
dto.session,
|
||||||
|
await this.getAccessibleClassIds(req),
|
||||||
|
);
|
||||||
|
let refreshed = 0;
|
||||||
|
let imported = 0;
|
||||||
|
let matched = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const schedule of schedules) {
|
||||||
|
try {
|
||||||
|
const importClassIds = await this.service.getTeacherClassDingUserIds(
|
||||||
|
req.user.id,
|
||||||
|
schedule.classId!,
|
||||||
|
this.canManageAllAttendance(req),
|
||||||
|
dto.date,
|
||||||
|
);
|
||||||
|
const importRange = this.service.getLessonAttendanceImportDateRange(schedule, dto.date);
|
||||||
|
const importResult = await this.importService.importFromDingTalk({
|
||||||
|
...importRange,
|
||||||
|
userIds: importClassIds,
|
||||||
|
autoMatch: true,
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
if (!importResult.success || importResult.errors.length > 0) {
|
||||||
|
errors.push(...importResult.errors);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.service.createLessonAttendanceFromDingTalk(schedule.id, dto.date, req.user.id);
|
||||||
|
refreshed += 1;
|
||||||
|
imported += importResult.imported;
|
||||||
|
matched += importResult.matched;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
errors.push((error as { message?: string })?.message || `排课 ${schedule.id} 刷新失败`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
module: '考勤管理',
|
||||||
|
action: '刷新钉钉考勤',
|
||||||
|
targetType: 'attendanceRecord',
|
||||||
|
detail: `日期${dto.date},排课${schedules.length}节,刷新${refreshed}节,钉钉新增${imported}条,匹配${matched}条${errors.length ? `,错误${errors.length}条` : ''}`,
|
||||||
|
status: errors.length > 0 && refreshed === 0 ? 'failure' : 'success',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (schedules.length === 0) {
|
||||||
|
return { refreshed, imported, matched, errors: ['当前条件下没有可刷新的课程'] };
|
||||||
|
}
|
||||||
|
return { refreshed, imported, matched, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch create attendance records ──
|
||||||
|
@Post('attendance-records/batch')
|
||||||
|
@RequirePermission('attendance:create')
|
||||||
|
async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) {
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
const canManageAll = this.canManageAllAttendance(req);
|
||||||
|
if (!canManageAll && dto.records.some((record) => record.classId == null)) {
|
||||||
|
throw new ForbiddenException('教师录入考勤时必须关联自己任教的班级');
|
||||||
|
}
|
||||||
|
const classIds = [
|
||||||
|
...new Set(
|
||||||
|
dto.records.map((record) => record.classId).filter((id): id is number => id != null),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (const classId of classIds) {
|
||||||
|
await this.service.assertClassAccess(req.user.id, classId, canManageAll);
|
||||||
|
}
|
||||||
|
const result = await this.service.batchCreate(dto);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '考勤管理',
|
||||||
|
action: '批量录入考勤',
|
||||||
|
detail: `共 ${result.count} 条`,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generate attendance records from schedules (with optional date range) ──
|
||||||
|
@Post('attendance-records/generate-from-schedules')
|
||||||
|
@RequirePermission('attendance:create')
|
||||||
|
async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) {
|
||||||
|
await this.assertClassAccess(req, dto.classId);
|
||||||
|
const result = await this.service.generateFromSchedules(dto);
|
||||||
|
await logAudit(this.logService, req, {
|
||||||
|
module: '考勤管理', action: '按课表生成考勤', detail: `班级 ${dto.classId}, 共 ${result.count} 条`,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export attendance records ──
|
||||||
|
@Get('attendance-records/export')
|
||||||
|
@RequirePermission('attendance:export')
|
||||||
|
async exportRecords(
|
||||||
|
@Query() query: QueryAttendanceRecordsDto,
|
||||||
|
@Res() res: Response,
|
||||||
|
@Request() req: { user: RequestUser },
|
||||||
|
) {
|
||||||
|
if (query.classId) await this.assertClassAccess(req, query.classId);
|
||||||
|
const classIds = await this.getAccessibleClassIds(req);
|
||||||
|
const records = await this.service.findAllForExport(query, classIds);
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const ws = workbook.addWorksheet('考勤统计报表');
|
||||||
|
ws.columns = [
|
||||||
|
{ header: '姓名', key: 'studentName', width: 15 },
|
||||||
|
{ header: '班级', key: 'className', width: 20 },
|
||||||
|
{ header: '日期', key: 'attendanceDate', width: 15 },
|
||||||
|
{ header: '时段', key: 'session', width: 15 },
|
||||||
|
{ header: '状态', key: 'status', width: 10 },
|
||||||
|
{ header: '来源', key: 'source', width: 10 },
|
||||||
|
{ header: '打卡设备', key: 'punchDevice', width: 30 },
|
||||||
|
{ header: '打卡时间', key: 'punchTime', width: 20 },
|
||||||
|
{ header: '备注', key: 'remark', width: 30 },
|
||||||
|
{ header: '归档时间', key: 'createdAt', width: 20 },
|
||||||
|
];
|
||||||
|
ws.getRow(1).font = { bold: true };
|
||||||
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
ws.addRow({
|
||||||
|
studentName: record.student?.name || '',
|
||||||
|
className: record.class?.name || '',
|
||||||
|
attendanceDate: record.attendanceDate || '',
|
||||||
|
session: record.session || '',
|
||||||
|
status: record.status || '',
|
||||||
|
source: record.source || '',
|
||||||
|
punchDevice: record.punchDeviceName || record.punchDeviceId || '',
|
||||||
|
punchTime: record.punchTime
|
||||||
|
? record.punchTime.toISOString().replace('T', ' ').substring(0, 19)
|
||||||
|
: '',
|
||||||
|
remark: record.remark || '',
|
||||||
|
createdAt: record.createdAt
|
||||||
|
? record.createdAt.toISOString().replace('T', ' ').substring(0, 19)
|
||||||
|
: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateRange = [query.dateFrom, query.dateTo].filter(Boolean).join('-') || '全部';
|
||||||
|
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Type',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
);
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename=${encodeURIComponent(`考勤统计报表-${dateRange}`)}.xlsx`,
|
||||||
|
);
|
||||||
|
await workbook.xlsx.write(res);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('attendance-records/schedules')
|
||||||
|
@RequirePermission('attendance:view')
|
||||||
|
async getAttendanceScheduleOptions(
|
||||||
|
@Query() query: AttendanceScheduleOptionsQueryDto,
|
||||||
|
@Request() req: { user: RequestUser },
|
||||||
|
) {
|
||||||
|
await this.assertClassAccess(req, query.classId);
|
||||||
|
return this.service.getScheduleOptionsForAttendance(query.classId, query.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List attendance records with filters ──
|
||||||
|
@Get('attendance-records')
|
||||||
|
@RequirePermission('attendance:view')
|
||||||
|
async findAll(@Query() query: QueryAttendanceRecordsDto, @Request() req: { user: RequestUser }) {
|
||||||
|
if (query.classId) await this.assertClassAccess(req, query.classId);
|
||||||
|
return this.service.findAll(query, await this.getAccessibleClassIds(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update a single attendance record ──
|
||||||
|
@Put('attendance-records/:id')
|
||||||
|
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||||
|
async update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateAttendanceRecordDto,
|
||||||
|
@Request() req: any,
|
||||||
|
) {
|
||||||
|
const existing = await this.service.findAttendanceRecord(id);
|
||||||
|
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||||
|
throw new ForbiddenException('无权修改未关联班级的考勤记录');
|
||||||
|
}
|
||||||
|
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
||||||
|
const result = await this.service.update(id, dto);
|
||||||
|
await logAudit(this.logService, req, {
|
||||||
|
module: '考勤管理', action: '编辑考勤记录', targetId: id, targetType: 'attendanceRecord', detail: `状态=${result.status}, 备注=${result.remark || ''}`,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete a single attendance record ──
|
||||||
|
@Delete('attendance-records/:id')
|
||||||
|
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||||
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||||
|
const existing = await this.service.findAttendanceRecord(id);
|
||||||
|
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||||
|
throw new ForbiddenException('无权删除未关联班级的考勤记录');
|
||||||
|
}
|
||||||
|
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
||||||
|
const result = await this.service.remove(id);
|
||||||
|
await logAudit(this.logService, req, {
|
||||||
|
module: '考勤管理', action: '归档考勤记录', targetId: id, targetType: 'attendanceRecord', detail: `归档考勤记录 ${id}`,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get distinct classes with attendance records ──
|
||||||
|
@Get('attendance-records/classes')
|
||||||
|
@RequirePermission('attendance:view')
|
||||||
|
async getClasses(@Request() req: { user: RequestUser }) {
|
||||||
|
return this.service.getClasses(await this.getAccessibleClassIds(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attendance summary ──
|
||||||
|
@Get('attendance-records/summary')
|
||||||
|
@RequirePermission('attendance:view')
|
||||||
|
async getSummary(
|
||||||
|
@Query() query: AttendanceSummaryQueryDto,
|
||||||
|
@Request() req: { user: RequestUser },
|
||||||
|
) {
|
||||||
|
if (query.classId) await this.assertClassAccess(req, query.classId);
|
||||||
|
return this.service.getSummary(query, await this.getAccessibleClassIds(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attendance calendar ──
|
||||||
|
@Get('attendance-records/calendar')
|
||||||
|
@RequirePermission('attendance:view')
|
||||||
|
async getCalendar(
|
||||||
|
@Query() query: AttendanceCalendarQueryDto,
|
||||||
|
@Request() req: { user: RequestUser },
|
||||||
|
) {
|
||||||
|
await this.assertClassAccess(req, query.classId);
|
||||||
|
return this.service.getCalendar(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DingAttendance raw records ──
|
||||||
|
@Get('attendance-records/report')
|
||||||
|
@RequirePermission('attendance:export')
|
||||||
|
async exportReport(
|
||||||
|
@Query() query: AttendanceReportQueryDto,
|
||||||
|
@Res() res: Response,
|
||||||
|
@Request() req: any,
|
||||||
|
) {
|
||||||
|
if (query.classId) await this.assertClassAccess(req, query.classId);
|
||||||
|
const reportData = await this.service.getReport(query, await this.getAccessibleClassIds(req));
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const ws = workbook.addWorksheet('考勤统计报表');
|
||||||
|
ws.columns = [
|
||||||
|
{ header: '班级名称', key: 'className', width: 30 },
|
||||||
|
{ header: '总记录数', key: 'total', width: 12 },
|
||||||
|
{ header: '出勤', key: 'present', width: 10 },
|
||||||
|
{ header: '出勤率', key: 'presentRate', width: 10 },
|
||||||
|
{ header: '缺勤', key: 'absent', width: 10 },
|
||||||
|
{ header: '缺勤率', key: 'absentRate', width: 10 },
|
||||||
|
{ header: '迟到', key: 'late', width: 10 },
|
||||||
|
{ header: '迟到率', key: 'lateRate', width: 10 },
|
||||||
|
{ header: '请假', key: 'leave', width: 10 },
|
||||||
|
{ header: '请假率', key: 'leaveRate', width: 10 },
|
||||||
|
];
|
||||||
|
ws.getRow(1).font = { bold: true };
|
||||||
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||||
|
|
||||||
|
for (const row of reportData) {
|
||||||
|
ws.addRow({
|
||||||
|
className: row.className,
|
||||||
|
total: row.total,
|
||||||
|
present: row.present,
|
||||||
|
presentRate: `${row.presentRate}%`,
|
||||||
|
absent: row.absent,
|
||||||
|
absentRate: `${row.absentRate}%`,
|
||||||
|
late: row.late,
|
||||||
|
lateRate: `${row.lateRate}%`,
|
||||||
|
leave: row.leave,
|
||||||
|
leaveRate: `${row.leaveRate}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
await logAudit(this.logService, req, {
|
||||||
|
module: '考勤管理', action: '导出考勤报表', detail: `classId=${query.classId || '全部'} ${query.dateFrom || ''}~${query.dateTo || ''}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Type',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
);
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename=attendance-report.xlsx');
|
||||||
|
await workbook.xlsx.write(res);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Abnormal attendance alerts ──
|
||||||
|
@Get('attendance-records/alerts')
|
||||||
|
@RequirePermission('attendance:view')
|
||||||
|
async getAlerts(@Request() req: { user: RequestUser }, @Query() query: AttendanceAlertsQueryDto) {
|
||||||
|
return this.service.getAlerts(
|
||||||
|
query.days ?? 14,
|
||||||
|
query.threshold ?? 3,
|
||||||
|
await this.getAccessibleClassIds(req),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
196
apps/server/src/attendance/attendance-report.service.ts
Normal file
196
apps/server/src/attendance/attendance-report.service.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AttendanceRecord, AttendanceDevice } from '../entities';
|
||||||
|
import type { AttendanceReportQueryDto } from './dto/attendance.dto';
|
||||||
|
import { attachAttendanceDeviceMappings } from './attendance-device';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AttendanceReportService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AttendanceRecord)
|
||||||
|
private attendanceRepo: Repository<AttendanceRecord>,
|
||||||
|
@InjectRepository(AttendanceDevice)
|
||||||
|
private attendanceDeviceRepo: Repository<AttendanceDevice>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Export all attendance records with filters (no pagination) ──
|
||||||
|
async findAllForExport(
|
||||||
|
query: {
|
||||||
|
classId?: number;
|
||||||
|
scheduleId?: number;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
session?: string;
|
||||||
|
status?: string;
|
||||||
|
source?: string;
|
||||||
|
},
|
||||||
|
accessibleClassIds?: number[],
|
||||||
|
) {
|
||||||
|
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||||
|
|
||||||
|
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
|
||||||
|
if (query.scheduleId) {
|
||||||
|
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
|
||||||
|
}
|
||||||
|
if (query.classId) {
|
||||||
|
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||||
|
} else if (accessibleClassIds) {
|
||||||
|
if (accessibleClassIds.length === 0) return [];
|
||||||
|
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||||
|
}
|
||||||
|
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');
|
||||||
|
|
||||||
|
const records = await qb.getMany();
|
||||||
|
return attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Class-based attendance report ──
|
||||||
|
async getReport(query: AttendanceReportQueryDto, accessibleClassIds?: number[]) {
|
||||||
|
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||||
|
|
||||||
|
qb.leftJoin('ar.class', 'class')
|
||||||
|
.select('class.id', 'classId')
|
||||||
|
.addSelect('class.name', 'className')
|
||||||
|
.addSelect('ar.status', 'status')
|
||||||
|
.addSelect('COUNT(*)', 'count');
|
||||||
|
if (query.classId) {
|
||||||
|
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||||
|
} else if (accessibleClassIds) {
|
||||||
|
if (accessibleClassIds.length === 0) return [];
|
||||||
|
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||||
|
}
|
||||||
|
if (query.dateFrom) {
|
||||||
|
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||||||
|
}
|
||||||
|
if (query.dateTo) {
|
||||||
|
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
||||||
|
}
|
||||||
|
|
||||||
|
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('ar.status');
|
||||||
|
const rawRows = await qb.getRawMany();
|
||||||
|
|
||||||
|
// Aggregate by class
|
||||||
|
const classMap = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
classId: number;
|
||||||
|
className: string;
|
||||||
|
present: number;
|
||||||
|
absent: number;
|
||||||
|
late: number;
|
||||||
|
leave: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const row of rawRows as Array<{
|
||||||
|
classId?: number;
|
||||||
|
className?: string | null;
|
||||||
|
status?: string;
|
||||||
|
count: string;
|
||||||
|
}>) {
|
||||||
|
if (!row.classId) continue;
|
||||||
|
if (!classMap.has(row.classId)) {
|
||||||
|
classMap.set(row.classId, {
|
||||||
|
classId: row.classId,
|
||||||
|
className: row.className || `班级#${row.classId}`,
|
||||||
|
present: 0,
|
||||||
|
absent: 0,
|
||||||
|
late: 0,
|
||||||
|
leave: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const entry = classMap.get(row.classId)!;
|
||||||
|
const count = parseInt(row.count, 10);
|
||||||
|
if (row.status === 'present') entry.present += count;
|
||||||
|
else if (row.status === 'absent') entry.absent += count;
|
||||||
|
else if (row.status === 'late') entry.late += count;
|
||||||
|
else if (row.status === 'leave') entry.leave += count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(classMap.values()).map((entry) => {
|
||||||
|
const total = entry.present + entry.absent + entry.late + entry.leave;
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
total,
|
||||||
|
presentRate: total > 0 ? ((entry.present / total) * 100).toFixed(1) : '0.0',
|
||||||
|
absentRate: total > 0 ? ((entry.absent / total) * 100).toFixed(1) : '0.0',
|
||||||
|
lateRate: total > 0 ? ((entry.late / total) * 100).toFixed(1) : '0.0',
|
||||||
|
leaveRate: total > 0 ? ((entry.leave / total) * 100).toFixed(1) : '0.0',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attendance alerts: detect consecutive absences/late ──
|
||||||
|
async getAlerts(days: number = 14, threshold: number = 3, accessibleClassIds?: number[]) {
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - days);
|
||||||
|
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const qb = this.attendanceRepo
|
||||||
|
.createQueryBuilder('a')
|
||||||
|
.leftJoinAndSelect('a.student', 'student')
|
||||||
|
.leftJoinAndSelect('a.class', 'class');
|
||||||
|
|
||||||
|
qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr }).andWhere(
|
||||||
|
'a.status IN (:...statuses)',
|
||||||
|
{ statuses: ['absent', 'late'] },
|
||||||
|
);
|
||||||
|
if (accessibleClassIds) {
|
||||||
|
if (accessibleClassIds.length === 0) return [];
|
||||||
|
qb.andWhere('a.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||||
|
}
|
||||||
|
const records = await qb
|
||||||
|
.orderBy('a.studentId', 'ASC')
|
||||||
|
.addOrderBy('a.attendanceDate', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
const alerts: Array<{
|
||||||
|
studentId: number;
|
||||||
|
studentName: string;
|
||||||
|
className: string;
|
||||||
|
type: string;
|
||||||
|
count: number;
|
||||||
|
lastDate: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
let current: (typeof alerts)[0] | null = null;
|
||||||
|
for (const r of records) {
|
||||||
|
const name = r.student?.name || '';
|
||||||
|
const className = r.class?.name || '';
|
||||||
|
const status = r.status === 'absent' ? '缺勤' : '迟到';
|
||||||
|
if (current && current.studentId === r.studentId && current.type === status) {
|
||||||
|
current.count++;
|
||||||
|
if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate;
|
||||||
|
} else {
|
||||||
|
if (current && current.count >= threshold) alerts.push({ ...current });
|
||||||
|
current = {
|
||||||
|
studentId: r.studentId,
|
||||||
|
studentName: name,
|
||||||
|
className,
|
||||||
|
type: status,
|
||||||
|
count: 1,
|
||||||
|
lastDate: r.attendanceDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current && current.count >= threshold) alerts.push(current);
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/server/src/attendance/attendance-time.ts
Normal file
43
apps/server/src/attendance/attendance-time.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export function toMinutes(time: string): number {
|
||||||
|
const [hour, minute] = time.split(':').map(Number);
|
||||||
|
return hour * 60 + minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCourseClock(date: Date): { date: string; minutes: number } {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return {
|
||||||
|
date: `${year}-${month}-${day}`,
|
||||||
|
minutes: date.getHours() * 60 + date.getMinutes(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftDate(date: string, days: number): string {
|
||||||
|
const d = new Date(`${date}T00:00:00.000Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() + days);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClassStudentActiveOnDate(
|
||||||
|
classStudent: Pick<
|
||||||
|
import('../entities/class-student.entity').ClassStudent,
|
||||||
|
'joinDate' | 'leaveDate' | 'status'
|
||||||
|
>,
|
||||||
|
lessonDate: string,
|
||||||
|
): boolean {
|
||||||
|
const status = classStudent.status ?? 'active';
|
||||||
|
if (!['active', 'left'].includes(status)) return false;
|
||||||
|
if (classStudent.joinDate && classStudent.joinDate > lessonDate) return false;
|
||||||
|
if (classStudent.leaveDate && classStudent.leaveDate < lessonDate) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
57
apps/server/src/attendance/attendance.controller-base.ts
Normal file
57
apps/server/src/attendance/attendance.controller-base.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { UseGuards } from '@nestjs/common';
|
||||||
|
import { AttendanceService } from './attendance.service';
|
||||||
|
import { AttendanceImportService } from './attendance-import.service';
|
||||||
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
|
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
/** Minimal request user shape for type safety */
|
||||||
|
export interface RequestUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
permissions: string[];
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSE event shape for @Sse() decorator */
|
||||||
|
export interface SseEvent {
|
||||||
|
data: string | Record<string, unknown>;
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
retry?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export abstract class AttendanceControllerBase {
|
||||||
|
constructor(
|
||||||
|
protected readonly service: AttendanceService,
|
||||||
|
protected readonly importService: AttendanceImportService,
|
||||||
|
protected readonly logService: OperationLogsService,
|
||||||
|
protected readonly authz: AuthorizationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
protected getTodayDateOnly(): string {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected canManageAllAttendance(req: { user: RequestUser }): boolean {
|
||||||
|
return (
|
||||||
|
this.authz.can(req, CaslAction.Manage, SubjectName.Attendance) ||
|
||||||
|
// Legacy: class:edit grants broad attendance access for teacher scoping
|
||||||
|
this.authz.can(req, CaslAction.Update, SubjectName.Class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAccessibleClassIds(req: { user: RequestUser }) {
|
||||||
|
return this.service.getAccessibleClassIds(req.user.id, this.canManageAllAttendance(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected assertClassAccess(req: { user: RequestUser }, classId: number) {
|
||||||
|
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { AttendanceController } from './attendance.controller';
|
import { AttendanceController } from './attendance.controller';
|
||||||
|
import { AttendanceRecordsController } from './attendance-records.controller';
|
||||||
|
import { AttendanceImportController } from './attendance-import.controller';
|
||||||
import { AttendanceService } from './attendance.service';
|
import { AttendanceService } from './attendance.service';
|
||||||
import { AttendanceImportService } from './attendance-import.service';
|
import { AttendanceImportService } from './attendance-import.service';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
|
|
||||||
describe('AttendanceController — DingTalk import scope', () => {
|
describe('AttendanceRecordsController — DingTalk import scope', () => {
|
||||||
const attendanceService = {
|
const attendanceService = {
|
||||||
getTeacherClassDingUserIds: jest.fn(),
|
getTeacherClassDingUserIds: jest.fn(),
|
||||||
getImportableClasses: jest.fn(),
|
getImportableClasses: jest.fn(),
|
||||||
@@ -22,6 +24,8 @@ describe('AttendanceController — DingTalk import scope', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let controller: AttendanceController;
|
let controller: AttendanceController;
|
||||||
|
let recordsController: AttendanceRecordsController;
|
||||||
|
let importController: AttendanceImportController;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
@@ -31,6 +35,18 @@ describe('AttendanceController — DingTalk import scope', () => {
|
|||||||
logService as unknown as OperationLogsService,
|
logService as unknown as OperationLogsService,
|
||||||
authzService as never,
|
authzService as never,
|
||||||
);
|
);
|
||||||
|
recordsController = new AttendanceRecordsController(
|
||||||
|
attendanceService as unknown as AttendanceService,
|
||||||
|
importService as unknown as AttendanceImportService,
|
||||||
|
logService as unknown as OperationLogsService,
|
||||||
|
authzService as never,
|
||||||
|
);
|
||||||
|
importController = new AttendanceImportController(
|
||||||
|
attendanceService as unknown as AttendanceService,
|
||||||
|
importService as unknown as AttendanceImportService,
|
||||||
|
logService as unknown as OperationLogsService,
|
||||||
|
authzService as never,
|
||||||
|
);
|
||||||
importService.importFromDingTalk.mockResolvedValue({
|
importService.importFromDingTalk.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
imported: 0,
|
imported: 0,
|
||||||
@@ -45,7 +61,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
|||||||
jest.useFakeTimers().setSystemTime(new Date('2026-07-10T08:00:00.000Z'));
|
jest.useFakeTimers().setSystemTime(new Date('2026-07-10T08:00:00.000Z'));
|
||||||
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-today']);
|
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-today']);
|
||||||
|
|
||||||
await controller.importFromDingTalk({ classId: 8 }, {
|
await importController.importFromDingTalk({ classId: 8 }, {
|
||||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
@@ -62,7 +78,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
|||||||
it('uses only the selected class students mapped to DingTalk for a teacher import', async () => {
|
it('uses only the selected class students mapped to DingTalk for a teacher import', async () => {
|
||||||
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1', 'ding-2']);
|
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1', 'ding-2']);
|
||||||
|
|
||||||
await controller.importFromDingTalk(
|
await importController.importFromDingTalk(
|
||||||
{ start: '2026-07-01', end: '2026-07-02', classId: 8 },
|
{ start: '2026-07-01', end: '2026-07-02', classId: 8 },
|
||||||
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
|
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
|
||||||
);
|
);
|
||||||
@@ -78,7 +94,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
|||||||
|
|
||||||
it('does not allow a teacher to supply arbitrary DingTalk user IDs', async () => {
|
it('does not allow a teacher to supply arbitrary DingTalk user IDs', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
controller.importFromDingTalk(
|
importController.importFromDingTalk(
|
||||||
{ start: '2026-07-01', end: '2026-07-02', users: 'someone-else' },
|
{ start: '2026-07-01', end: '2026-07-02', users: 'someone-else' },
|
||||||
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
|
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
|
||||||
),
|
),
|
||||||
@@ -89,7 +105,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
|||||||
|
|
||||||
it('requires teachers to select one of their classes', async () => {
|
it('requires teachers to select one of their classes', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
controller.importFromDingTalk({ start: '2026-07-01', end: '2026-07-02' }, {
|
importController.importFromDingTalk({ start: '2026-07-01', end: '2026-07-02' }, {
|
||||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
||||||
} as never),
|
} as never),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
@@ -98,7 +114,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
|||||||
attendanceService.getImportableClasses.mockResolvedValue([{ classId: 8, className: '八班' }]);
|
attendanceService.getImportableClasses.mockResolvedValue([{ classId: 8, className: '八班' }]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
controller.getDingTalkImportClasses({
|
importController.getDingTalkImportClasses({
|
||||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
||||||
} as never),
|
} as never),
|
||||||
).resolves.toEqual([{ classId: 8, className: '八班' }]);
|
).resolves.toEqual([{ classId: 8, className: '八班' }]);
|
||||||
@@ -109,7 +125,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
|||||||
it('always auto-matches class-scoped imports', async () => {
|
it('always auto-matches class-scoped imports', async () => {
|
||||||
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1']);
|
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1']);
|
||||||
|
|
||||||
await controller.importFromDingTalk(
|
await importController.importFromDingTalk(
|
||||||
{ start: '2026-07-01', end: '2026-07-02', classId: 8 },
|
{ start: '2026-07-01', end: '2026-07-02', classId: 8 },
|
||||||
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
|
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
|
||||||
);
|
);
|
||||||
@@ -124,7 +140,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
|||||||
|
|
||||||
authzService.can.mockReturnValue(true);
|
authzService.can.mockReturnValue(true);
|
||||||
await expect(
|
await expect(
|
||||||
controller.getDingTalkImportClasses({
|
importController.getDingTalkImportClasses({
|
||||||
user: {
|
user: {
|
||||||
id: 7,
|
id: 7,
|
||||||
username: 'manager',
|
username: 'manager',
|
||||||
@@ -136,7 +152,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
|||||||
|
|
||||||
expect(attendanceService.getImportableClasses).toHaveBeenCalledWith(7, true);
|
expect(attendanceService.getImportableClasses).toHaveBeenCalledWith(7, true);
|
||||||
|
|
||||||
await controller.importFromDingTalk(
|
await importController.importFromDingTalk(
|
||||||
{ start: '2026-07-01', end: '2026-07-02', users: 'ding-1,ding-2' },
|
{ start: '2026-07-01', end: '2026-07-02', users: 'ding-1,ding-2' },
|
||||||
{
|
{
|
||||||
user: {
|
user: {
|
||||||
@@ -157,7 +173,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('AttendanceController — write data scope', () => {
|
describe('AttendanceRecordsController — write data scope', () => {
|
||||||
const attendanceService = {
|
const attendanceService = {
|
||||||
assertClassAccess: jest.fn(),
|
assertClassAccess: jest.fn(),
|
||||||
getAccessibleClassIds: jest.fn(),
|
getAccessibleClassIds: jest.fn(),
|
||||||
@@ -183,6 +199,7 @@ describe('AttendanceController — write data scope', () => {
|
|||||||
headers: {},
|
headers: {},
|
||||||
};
|
};
|
||||||
let controller: AttendanceController;
|
let controller: AttendanceController;
|
||||||
|
let recordsController: AttendanceRecordsController;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
@@ -193,6 +210,12 @@ describe('AttendanceController — write data scope', () => {
|
|||||||
logService as unknown as OperationLogsService,
|
logService as unknown as OperationLogsService,
|
||||||
authzService as never,
|
authzService as never,
|
||||||
);
|
);
|
||||||
|
recordsController = new AttendanceRecordsController(
|
||||||
|
attendanceService as unknown as AttendanceService,
|
||||||
|
importService as unknown as AttendanceImportService,
|
||||||
|
logService as unknown as OperationLogsService,
|
||||||
|
authzService as never,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('checks every distinct class in a manual attendance batch', async () => {
|
it('checks every distinct class in a manual attendance batch', async () => {
|
||||||
@@ -216,7 +239,7 @@ describe('AttendanceController — write data scope', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await controller.batchCreate(dto, req);
|
await recordsController.batchCreate(dto, req);
|
||||||
|
|
||||||
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||||
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 9, false);
|
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 9, false);
|
||||||
@@ -235,7 +258,7 @@ describe('AttendanceController — write data scope', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(controller.batchCreate(dto, req)).rejects.toBeInstanceOf(
|
await expect(recordsController.batchCreate(dto, req)).rejects.toBeInstanceOf(
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
);
|
);
|
||||||
expect(attendanceService.batchCreate).not.toHaveBeenCalled();
|
expect(attendanceService.batchCreate).not.toHaveBeenCalled();
|
||||||
@@ -277,7 +300,7 @@ describe('AttendanceController — write data scope', () => {
|
|||||||
it('checks class access before generating attendance from schedules', async () => {
|
it('checks class access before generating attendance from schedules', async () => {
|
||||||
attendanceService.generateFromSchedules.mockResolvedValue({ count: 0, records: [] });
|
attendanceService.generateFromSchedules.mockResolvedValue({ count: 0, records: [] });
|
||||||
|
|
||||||
await controller.generateFromSchedules({ classId: 8 }, req);
|
await recordsController.generateFromSchedules({ classId: 8 }, req);
|
||||||
|
|
||||||
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||||
});
|
});
|
||||||
@@ -287,8 +310,8 @@ describe('AttendanceController — write data scope', () => {
|
|||||||
attendanceService.update.mockResolvedValue({ id: 4, classId: 8, status: 'late' });
|
attendanceService.update.mockResolvedValue({ id: 4, classId: 8, status: 'late' });
|
||||||
attendanceService.remove.mockResolvedValue({ deleted: true });
|
attendanceService.remove.mockResolvedValue({ deleted: true });
|
||||||
|
|
||||||
await controller.update('4', { status: 'late' }, req);
|
await recordsController.update('4', { status: 'late' }, req);
|
||||||
await controller.remove('4', req);
|
await recordsController.remove('4', req);
|
||||||
|
|
||||||
expect(attendanceService.assertClassAccess).toHaveBeenCalledTimes(2);
|
expect(attendanceService.assertClassAccess).toHaveBeenCalledTimes(2);
|
||||||
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
|
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
|
||||||
@@ -296,7 +319,7 @@ describe('AttendanceController — write data scope', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('AttendanceController — SSE progress scoping', () => {
|
describe('AttendanceRecordsController — SSE progress scoping', () => {
|
||||||
let progressSubject: Subject<{ phase: string; userId?: number }>;
|
let progressSubject: Subject<{ phase: string; userId?: number }>;
|
||||||
const importService = {
|
const importService = {
|
||||||
importFromDingTalk: jest.fn(),
|
importFromDingTalk: jest.fn(),
|
||||||
@@ -306,11 +329,11 @@ describe('AttendanceController — SSE progress scoping', () => {
|
|||||||
const logService = {} as unknown as OperationLogsService;
|
const logService = {} as unknown as OperationLogsService;
|
||||||
const authzService = {} as never;
|
const authzService = {} as never;
|
||||||
|
|
||||||
let controller: AttendanceController;
|
let importController: AttendanceImportController;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
progressSubject = new Subject<{ phase: string; userId?: number }>();
|
progressSubject = new Subject<{ phase: string; userId?: number }>();
|
||||||
controller = new AttendanceController(
|
importController = new AttendanceImportController(
|
||||||
attendanceService,
|
attendanceService,
|
||||||
importService as unknown as AttendanceImportService,
|
importService as unknown as AttendanceImportService,
|
||||||
logService,
|
logService,
|
||||||
@@ -324,7 +347,7 @@ describe('AttendanceController — SSE progress scoping', () => {
|
|||||||
|
|
||||||
it('delivers events matching the requesting user id', () => {
|
it('delivers events matching the requesting user id', () => {
|
||||||
const received: Array<{ phase: string; userId?: number }> = [];
|
const received: Array<{ phase: string; userId?: number }> = [];
|
||||||
const sub = controller.importProgressStream({
|
const sub = importController.importProgressStream({
|
||||||
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
|
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (e) => received.push(JSON.parse(e.data as string)),
|
next: (e) => received.push(JSON.parse(e.data as string)),
|
||||||
@@ -339,7 +362,7 @@ describe('AttendanceController — SSE progress scoping', () => {
|
|||||||
|
|
||||||
it('excludes events from a different user', () => {
|
it('excludes events from a different user', () => {
|
||||||
const received: Array<{ phase: string; userId?: number }> = [];
|
const received: Array<{ phase: string; userId?: number }> = [];
|
||||||
const sub = controller.importProgressStream({
|
const sub = importController.importProgressStream({
|
||||||
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
|
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (e) => received.push(JSON.parse(e.data as string)),
|
next: (e) => received.push(JSON.parse(e.data as string)),
|
||||||
@@ -356,7 +379,7 @@ describe('AttendanceController — SSE progress scoping', () => {
|
|||||||
|
|
||||||
it('excludes events with undefined userId (non-HTTP callers)', () => {
|
it('excludes events with undefined userId (non-HTTP callers)', () => {
|
||||||
const received: Array<{ phase: string; userId?: number }> = [];
|
const received: Array<{ phase: string; userId?: number }> = [];
|
||||||
const sub = controller.importProgressStream({
|
const sub = importController.importProgressStream({
|
||||||
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
|
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (e) => received.push(JSON.parse(e.data as string)),
|
next: (e) => received.push(JSON.parse(e.data as string)),
|
||||||
|
|||||||
@@ -3,99 +3,36 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Delete,
|
|
||||||
Sse,
|
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
|
||||||
Request,
|
Request,
|
||||||
Res,
|
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Observable, filter } from 'rxjs';
|
import { AttendanceControllerBase, RequestUser } from './attendance.controller-base';
|
||||||
import type { Request as ExpressRequest, Response } from 'express';
|
|
||||||
import { AttendanceService } from './attendance.service';
|
import { AttendanceService } from './attendance.service';
|
||||||
import { AttendanceImportService } from './attendance-import.service';
|
import { AttendanceImportService } from './attendance-import.service';
|
||||||
import { DingTalkImportDto } from './dto/dingtalk-import.dto';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
|
import { AuthorizationService } from '../authorization';
|
||||||
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import {
|
import {
|
||||||
BatchCreateAttendanceDto,
|
SaveAttendancePeriodConfigsDto,
|
||||||
AttendanceSummaryQueryDto,
|
|
||||||
AttendanceCalendarQueryDto,
|
|
||||||
QueryAttendanceRecordsDto,
|
|
||||||
AttendanceScheduleOptionsQueryDto,
|
|
||||||
QueryDingRawDto,
|
|
||||||
MatchDingRecordDto,
|
|
||||||
AttendanceReportQueryDto,
|
|
||||||
AttendanceAlertsQueryDto,
|
|
||||||
UpdateAttendanceRecordDto,
|
|
||||||
GenerateFromSchedulesDto,
|
|
||||||
LessonAttendanceQueryDto,
|
LessonAttendanceQueryDto,
|
||||||
StartLessonAttendanceDto,
|
StartLessonAttendanceDto,
|
||||||
SaveAttendancePeriodConfigsDto,
|
|
||||||
RefreshDingTalkAttendanceDto,
|
|
||||||
} from './dto/attendance.dto';
|
} from './dto/attendance.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
|
||||||
import * as ExcelJS from 'exceljs';
|
|
||||||
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
|
|
||||||
import type { AuthenticatedUser } from '../authorization';
|
|
||||||
|
|
||||||
/** SSE event shape for @Sse() decorator */
|
|
||||||
interface SseEvent {
|
|
||||||
data: string | Record<string, unknown>;
|
|
||||||
id?: string;
|
|
||||||
type?: string;
|
|
||||||
retry?: number;
|
|
||||||
}
|
|
||||||
/** Minimal request user shape for type safety */
|
|
||||||
interface RequestUser {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
permissions: string[];
|
|
||||||
isSuperAdmin: boolean;
|
|
||||||
roles: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AttendanceController {
|
export class AttendanceController extends AttendanceControllerBase {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly service: AttendanceService,
|
service: AttendanceService,
|
||||||
private readonly importService: AttendanceImportService,
|
importService: AttendanceImportService,
|
||||||
private readonly logService: OperationLogsService,
|
logService: OperationLogsService,
|
||||||
private readonly authz: AuthorizationService,
|
authz: AuthorizationService,
|
||||||
) {}
|
) {
|
||||||
|
super(service, importService, logService, authz);
|
||||||
private getTodayDateOnly(): string {
|
|
||||||
const today = new Date();
|
|
||||||
const year = today.getFullYear();
|
|
||||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(today.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private canManageAllAttendance(req: { user: RequestUser }): boolean {
|
|
||||||
return (
|
|
||||||
this.authz.can(req, CaslAction.Manage, SubjectName.Attendance) ||
|
|
||||||
// Legacy: class:edit grants broad attendance access for teacher scoping
|
|
||||||
this.authz.can(req, CaslAction.Update, SubjectName.Class)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getAccessibleClassIds(req: { user: RequestUser }) {
|
|
||||||
return this.service.getAccessibleClassIds(req.user.id, this.canManageAllAttendance(req));
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertClassAccess(req: { user: RequestUser }, classId: number) {
|
|
||||||
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Get('attendance-period-configs')
|
@Get('attendance-period-configs')
|
||||||
@RequirePermission('attendance:view')
|
@RequirePermission('attendance:view')
|
||||||
getAttendancePeriodConfigs() {
|
getAttendancePeriodConfigs() {
|
||||||
@@ -115,7 +52,9 @@ export class AttendanceController {
|
|||||||
module: '考勤管理',
|
module: '考勤管理',
|
||||||
action: '保存考勤时段配置',
|
action: '保存考勤时段配置',
|
||||||
targetType: 'attendancePeriodConfig',
|
targetType: 'attendancePeriodConfig',
|
||||||
detail: dto.periods.map((item) => `${item.label}:${item.startTime}-${item.endTime}`).join(';'),
|
detail: dto.periods
|
||||||
|
.map((item) => `${item.label}:${item.startTime}-${item.endTime}`)
|
||||||
|
.join(';'),
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -212,504 +151,4 @@ export class AttendanceController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Get('attendance-records/dingtalk-sync-status')
|
|
||||||
@RequirePermission('attendance:view')
|
|
||||||
async getDingTalkSyncStatus() {
|
|
||||||
const latest = await this.logService.findLatestDingTalkAttendancePull();
|
|
||||||
return {
|
|
||||||
lastPulledAt: latest?.createdAt ?? null,
|
|
||||||
action: latest?.action ?? null,
|
|
||||||
username: latest?.username ?? null,
|
|
||||||
detail: latest?.detail ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('attendance-records/refresh-dingtalk')
|
|
||||||
@RequirePermission('attendance:create')
|
|
||||||
async refreshDingTalkAttendance(
|
|
||||||
@Body() dto: RefreshDingTalkAttendanceDto,
|
|
||||||
@Request() req: { user: RequestUser },
|
|
||||||
) {
|
|
||||||
if (dto.date > this.getTodayDateOnly()) {
|
|
||||||
throw new BadRequestException('不能查看或刷新未来日期的考勤');
|
|
||||||
}
|
|
||||||
if (dto.classId) await this.assertClassAccess(req, dto.classId);
|
|
||||||
const schedules = await this.service.getRefreshableSchedules(
|
|
||||||
dto.date,
|
|
||||||
dto.classId,
|
|
||||||
dto.session,
|
|
||||||
await this.getAccessibleClassIds(req),
|
|
||||||
);
|
|
||||||
let refreshed = 0;
|
|
||||||
let imported = 0;
|
|
||||||
let matched = 0;
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
for (const schedule of schedules) {
|
|
||||||
try {
|
|
||||||
const importClassIds = await this.service.getTeacherClassDingUserIds(
|
|
||||||
req.user.id,
|
|
||||||
schedule.classId!,
|
|
||||||
this.canManageAllAttendance(req),
|
|
||||||
dto.date,
|
|
||||||
);
|
|
||||||
const importRange = this.service.getLessonAttendanceImportDateRange(schedule, dto.date);
|
|
||||||
const importResult = await this.importService.importFromDingTalk({
|
|
||||||
...importRange,
|
|
||||||
userIds: importClassIds,
|
|
||||||
autoMatch: true,
|
|
||||||
userId: req.user.id,
|
|
||||||
});
|
|
||||||
if (!importResult.success || importResult.errors.length > 0) {
|
|
||||||
errors.push(...importResult.errors);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await this.service.createLessonAttendanceFromDingTalk(schedule.id, dto.date, req.user.id);
|
|
||||||
refreshed += 1;
|
|
||||||
imported += importResult.imported;
|
|
||||||
matched += importResult.matched;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
errors.push((error as { message?: string })?.message || `排课 ${schedule.id} 刷新失败`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.logService.log({
|
|
||||||
userId: req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
module: '考勤管理',
|
|
||||||
action: '刷新钉钉考勤',
|
|
||||||
targetType: 'attendanceRecord',
|
|
||||||
detail: `日期${dto.date},排课${schedules.length}节,刷新${refreshed}节,钉钉新增${imported}条,匹配${matched}条${errors.length ? `,错误${errors.length}条` : ''}`,
|
|
||||||
status: errors.length > 0 && refreshed === 0 ? 'failure' : 'success',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (schedules.length === 0) {
|
|
||||||
return { refreshed, imported, matched, errors: ['当前条件下没有可刷新的课程'] };
|
|
||||||
}
|
|
||||||
return { refreshed, imported, matched, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Batch create attendance records ──
|
|
||||||
@Post('attendance-records/batch')
|
|
||||||
@RequirePermission('attendance:create')
|
|
||||||
async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) {
|
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
const canManageAll = this.canManageAllAttendance(req);
|
|
||||||
if (!canManageAll && dto.records.some((record) => record.classId == null)) {
|
|
||||||
throw new ForbiddenException('教师录入考勤时必须关联自己任教的班级');
|
|
||||||
}
|
|
||||||
const classIds = [
|
|
||||||
...new Set(
|
|
||||||
dto.records.map((record) => record.classId).filter((id): id is number => id != null),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
for (const classId of classIds) {
|
|
||||||
await this.service.assertClassAccess(req.user.id, classId, canManageAll);
|
|
||||||
}
|
|
||||||
const result = await this.service.batchCreate(dto);
|
|
||||||
await this.logService.log({
|
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '考勤管理',
|
|
||||||
action: '批量录入考勤',
|
|
||||||
detail: `共 ${result.count} 条`,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Generate attendance records from schedules (with optional date range) ──
|
|
||||||
@Post('attendance-records/generate-from-schedules')
|
|
||||||
@RequirePermission('attendance:create')
|
|
||||||
async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) {
|
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
await this.assertClassAccess(req, dto.classId);
|
|
||||||
const result = await this.service.generateFromSchedules(dto);
|
|
||||||
await this.logService.log({
|
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '考勤管理',
|
|
||||||
action: '按课表生成考勤',
|
|
||||||
detail: `班级 ${dto.classId}, 共 ${result.count} 条`,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Export attendance records ──
|
|
||||||
@Get('attendance-records/export')
|
|
||||||
@RequirePermission('attendance:export')
|
|
||||||
async exportRecords(
|
|
||||||
@Query() query: QueryAttendanceRecordsDto,
|
|
||||||
@Res() res: Response,
|
|
||||||
@Request() req: { user: RequestUser },
|
|
||||||
) {
|
|
||||||
if (query.classId) await this.assertClassAccess(req, query.classId);
|
|
||||||
const classIds = await this.getAccessibleClassIds(req);
|
|
||||||
const records = await this.service.findAllForExport(query, classIds);
|
|
||||||
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
|
||||||
const ws = workbook.addWorksheet('考勤统计报表');
|
|
||||||
ws.columns = [
|
|
||||||
{ header: '姓名', key: 'studentName', width: 15 },
|
|
||||||
{ header: '班级', key: 'className', width: 20 },
|
|
||||||
{ header: '日期', key: 'attendanceDate', width: 15 },
|
|
||||||
{ header: '时段', key: 'session', width: 15 },
|
|
||||||
{ header: '状态', key: 'status', width: 10 },
|
|
||||||
{ header: '来源', key: 'source', width: 10 },
|
|
||||||
{ header: '打卡设备', key: 'punchDevice', width: 30 },
|
|
||||||
{ header: '打卡时间', key: 'punchTime', width: 20 },
|
|
||||||
{ header: '备注', key: 'remark', width: 30 },
|
|
||||||
{ header: '归档时间', key: 'createdAt', width: 20 },
|
|
||||||
];
|
|
||||||
ws.getRow(1).font = { bold: true };
|
|
||||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
|
||||||
|
|
||||||
for (const record of records) {
|
|
||||||
ws.addRow({
|
|
||||||
studentName: record.student?.name || '',
|
|
||||||
className: record.class?.name || '',
|
|
||||||
attendanceDate: record.attendanceDate || '',
|
|
||||||
session: record.session || '',
|
|
||||||
status: record.status || '',
|
|
||||||
source: record.source || '',
|
|
||||||
punchDevice: record.punchDeviceName || record.punchDeviceId || '',
|
|
||||||
punchTime: record.punchTime
|
|
||||||
? record.punchTime.toISOString().replace('T', ' ').substring(0, 19)
|
|
||||||
: '',
|
|
||||||
remark: record.remark || '',
|
|
||||||
createdAt: record.createdAt
|
|
||||||
? record.createdAt.toISOString().replace('T', ' ').substring(0, 19)
|
|
||||||
: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateRange = [query.dateFrom, query.dateTo].filter(Boolean).join('-') || '全部';
|
|
||||||
|
|
||||||
res.setHeader(
|
|
||||||
'Content-Type',
|
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
);
|
|
||||||
res.setHeader(
|
|
||||||
'Content-Disposition',
|
|
||||||
`attachment; filename=${encodeURIComponent(`考勤统计报表-${dateRange}`)}.xlsx`,
|
|
||||||
);
|
|
||||||
await workbook.xlsx.write(res);
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('attendance-records/schedules')
|
|
||||||
@RequirePermission('attendance:view')
|
|
||||||
async getAttendanceScheduleOptions(
|
|
||||||
@Query() query: AttendanceScheduleOptionsQueryDto,
|
|
||||||
@Request() req: { user: RequestUser },
|
|
||||||
) {
|
|
||||||
await this.assertClassAccess(req, query.classId);
|
|
||||||
return this.service.getScheduleOptionsForAttendance(query.classId, query.date);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── List attendance records with filters ──
|
|
||||||
@Get('attendance-records')
|
|
||||||
@RequirePermission('attendance:view')
|
|
||||||
async findAll(@Query() query: QueryAttendanceRecordsDto, @Request() req: { user: RequestUser }) {
|
|
||||||
if (query.classId) await this.assertClassAccess(req, query.classId);
|
|
||||||
return this.service.findAll(query, await this.getAccessibleClassIds(req));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Update a single attendance record ──
|
|
||||||
@Put('attendance-records/:id')
|
|
||||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
|
||||||
async update(
|
|
||||||
@Param('id', ParseIntPipe) id: number,
|
|
||||||
@Body() dto: UpdateAttendanceRecordDto,
|
|
||||||
@Request() req: any,
|
|
||||||
) {
|
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
const existing = await this.service.findAttendanceRecord(id);
|
|
||||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
|
||||||
throw new ForbiddenException('无权修改未关联班级的考勤记录');
|
|
||||||
}
|
|
||||||
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
|
||||||
const result = await this.service.update(id, dto);
|
|
||||||
await this.logService.log({
|
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '考勤管理',
|
|
||||||
action: '编辑考勤记录',
|
|
||||||
targetId: id,
|
|
||||||
targetType: 'attendanceRecord',
|
|
||||||
detail: `状态=${result.status}, 备注=${result.remark || ''}`,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete a single attendance record ──
|
|
||||||
@Delete('attendance-records/:id')
|
|
||||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
|
||||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
const existing = await this.service.findAttendanceRecord(id);
|
|
||||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
|
||||||
throw new ForbiddenException('无权删除未关联班级的考勤记录');
|
|
||||||
}
|
|
||||||
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
|
||||||
const result = await this.service.remove(id);
|
|
||||||
await this.logService.log({
|
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '考勤管理',
|
|
||||||
action: '归档考勤记录',
|
|
||||||
targetId: id,
|
|
||||||
targetType: 'attendanceRecord',
|
|
||||||
detail: `归档考勤记录 ${id}`,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Get distinct classes with attendance records ──
|
|
||||||
@Get('attendance-records/classes')
|
|
||||||
@RequirePermission('attendance:view')
|
|
||||||
async getClasses(@Request() req: { user: RequestUser }) {
|
|
||||||
return this.service.getClasses(await this.getAccessibleClassIds(req));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Attendance summary ──
|
|
||||||
@Get('attendance-records/summary')
|
|
||||||
@RequirePermission('attendance:view')
|
|
||||||
async getSummary(
|
|
||||||
@Query() query: AttendanceSummaryQueryDto,
|
|
||||||
@Request() req: { user: RequestUser },
|
|
||||||
) {
|
|
||||||
if (query.classId) await this.assertClassAccess(req, query.classId);
|
|
||||||
return this.service.getSummary(query, await this.getAccessibleClassIds(req));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Attendance calendar ──
|
|
||||||
@Get('attendance-records/calendar')
|
|
||||||
@RequirePermission('attendance:view')
|
|
||||||
async getCalendar(
|
|
||||||
@Query() query: AttendanceCalendarQueryDto,
|
|
||||||
@Request() req: { user: RequestUser },
|
|
||||||
) {
|
|
||||||
await this.assertClassAccess(req, query.classId);
|
|
||||||
return this.service.getCalendar(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DingAttendance raw records ──
|
|
||||||
@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 { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
const result = await this.service.matchDingRecord(id, dto);
|
|
||||||
await this.logService.log({
|
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '考勤管理',
|
|
||||||
action: '匹配考勤记录',
|
|
||||||
targetId: id,
|
|
||||||
targetType: 'dingAttendanceRaw',
|
|
||||||
detail: `匹配到学生 ${dto.studentId}`,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Attendance class-based report export ──
|
|
||||||
@Get('attendance-records/report')
|
|
||||||
@RequirePermission('attendance:export')
|
|
||||||
async exportReport(
|
|
||||||
@Query() query: AttendanceReportQueryDto,
|
|
||||||
@Res() res: Response,
|
|
||||||
@Request() req: any,
|
|
||||||
) {
|
|
||||||
if (query.classId) await this.assertClassAccess(req, query.classId);
|
|
||||||
const reportData = await this.service.getReport(query, await this.getAccessibleClassIds(req));
|
|
||||||
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
|
||||||
const ws = workbook.addWorksheet('考勤统计报表');
|
|
||||||
ws.columns = [
|
|
||||||
{ header: '班级名称', key: 'className', width: 30 },
|
|
||||||
{ header: '总记录数', key: 'total', width: 12 },
|
|
||||||
{ header: '出勤', key: 'present', width: 10 },
|
|
||||||
{ header: '出勤率', key: 'presentRate', width: 10 },
|
|
||||||
{ header: '缺勤', key: 'absent', width: 10 },
|
|
||||||
{ header: '缺勤率', key: 'absentRate', width: 10 },
|
|
||||||
{ header: '迟到', key: 'late', width: 10 },
|
|
||||||
{ header: '迟到率', key: 'lateRate', width: 10 },
|
|
||||||
{ header: '请假', key: 'leave', width: 10 },
|
|
||||||
{ header: '请假率', key: 'leaveRate', width: 10 },
|
|
||||||
];
|
|
||||||
ws.getRow(1).font = { bold: true };
|
|
||||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
|
||||||
|
|
||||||
for (const row of reportData) {
|
|
||||||
ws.addRow({
|
|
||||||
className: row.className,
|
|
||||||
total: row.total,
|
|
||||||
present: row.present,
|
|
||||||
presentRate: `${row.presentRate}%`,
|
|
||||||
absent: row.absent,
|
|
||||||
absentRate: `${row.absentRate}%`,
|
|
||||||
late: row.late,
|
|
||||||
lateRate: `${row.lateRate}%`,
|
|
||||||
leave: row.leave,
|
|
||||||
leaveRate: `${row.leaveRate}%`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audit log
|
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
await this.logService.log({
|
|
||||||
userId: req.user?.id,
|
|
||||||
username: req.user?.username,
|
|
||||||
module: '考勤管理',
|
|
||||||
action: '导出考勤报表',
|
|
||||||
detail: `classId=${query.classId || '全部'} ${query.dateFrom || ''}~${query.dateTo || ''}`,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.setHeader(
|
|
||||||
'Content-Type',
|
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
);
|
|
||||||
res.setHeader('Content-Disposition', 'attachment; filename=attendance-report.xlsx');
|
|
||||||
await workbook.xlsx.write(res);
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Abnormal attendance alerts ──
|
|
||||||
@Get('attendance-records/alerts')
|
|
||||||
@RequirePermission('attendance:view')
|
|
||||||
async getAlerts(
|
|
||||||
@Request() req: { user: RequestUser },
|
|
||||||
@Query() query: AttendanceAlertsQueryDto,
|
|
||||||
) {
|
|
||||||
return this.service.getAlerts(
|
|
||||||
query.days ?? 14,
|
|
||||||
query.threshold ?? 3,
|
|
||||||
await this.getAccessibleClassIds(req),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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<SseEvent> {
|
|
||||||
const userId = req.user.id;
|
|
||||||
return new Observable<SseEvent>((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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,16 @@ const createService = () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
dataSource as unknown as DataSource,
|
dataSource as unknown as DataSource,
|
||||||
);
|
);
|
||||||
return { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo, attendanceDeviceRepo, dataSource };
|
return {
|
||||||
|
service,
|
||||||
|
attendanceRepo,
|
||||||
|
dingRawRepo,
|
||||||
|
scheduleRepo,
|
||||||
|
classStudentRepo,
|
||||||
|
sessionRepo,
|
||||||
|
attendanceDeviceRepo,
|
||||||
|
dataSource,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const endedSchedule = {
|
const endedSchedule = {
|
||||||
@@ -128,9 +137,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
createService();
|
createService();
|
||||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||||
sessionRepo.findOne.mockResolvedValue(null);
|
sessionRepo.findOne.mockResolvedValue(null);
|
||||||
classStudentRepo.find.mockResolvedValue([
|
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
||||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
|
||||||
]);
|
|
||||||
dingRawRepo.find.mockResolvedValue([]);
|
dingRawRepo.find.mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
|
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
|
||||||
@@ -149,9 +156,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
createService();
|
createService();
|
||||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||||
sessionRepo.findOne.mockResolvedValue(null);
|
sessionRepo.findOne.mockResolvedValue(null);
|
||||||
classStudentRepo.find.mockResolvedValue([
|
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
||||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
|
||||||
]);
|
|
||||||
dingRawRepo.find.mockResolvedValue([]);
|
dingRawRepo.find.mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||||
@@ -167,7 +172,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
createService();
|
createService();
|
||||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||||
sessionRepo.findOne.mockResolvedValue(null);
|
sessionRepo.findOne.mockResolvedValue(null);
|
||||||
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } }]);
|
classStudentRepo.find.mockResolvedValue([
|
||||||
|
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
|
||||||
|
]);
|
||||||
dingRawRepo.find.mockResolvedValue([
|
dingRawRepo.find.mockResolvedValue([
|
||||||
{
|
{
|
||||||
matchedStudentId: 1,
|
matchedStudentId: 1,
|
||||||
@@ -201,10 +208,26 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
{ studentId: 3, student: { id: 3, name: '王五' } },
|
{ studentId: 3, student: { id: 3, name: '王五' } },
|
||||||
]);
|
]);
|
||||||
dingRawRepo.find.mockResolvedValue([
|
dingRawRepo.find.mockResolvedValue([
|
||||||
{ matchedStudentId: 1, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T08:40:00+08:00') },
|
{
|
||||||
{ matchedStudentId: 2, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:00+08:00') },
|
matchedStudentId: 1,
|
||||||
{ matchedStudentId: 3, attendanceType: 'OnDuty', checkInTime: new Date('2026-07-11T08:39:59+08:00') },
|
attendanceType: 'OffDuty',
|
||||||
{ matchedStudentId: 3, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:01+08:00') },
|
checkOutTime: new Date('2026-07-11T08:40:00+08:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matchedStudentId: 2,
|
||||||
|
attendanceType: 'OffDuty',
|
||||||
|
checkOutTime: new Date('2026-07-11T10:00:00+08:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matchedStudentId: 3,
|
||||||
|
attendanceType: 'OnDuty',
|
||||||
|
checkInTime: new Date('2026-07-11T08:39:59+08:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matchedStudentId: 3,
|
||||||
|
attendanceType: 'OffDuty',
|
||||||
|
checkOutTime: new Date('2026-07-11T10:00:01+08:00'),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
|
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
|
||||||
@@ -218,10 +241,12 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
|
|
||||||
it('expands import dates when the pre-class window crosses midnight', () => {
|
it('expands import dates when the pre-class window crosses midnight', () => {
|
||||||
const { service } = createService();
|
const { service } = createService();
|
||||||
expect(service.getLessonAttendanceImportDateRange(
|
expect(
|
||||||
|
service.getLessonAttendanceImportDateRange(
|
||||||
{ startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 },
|
{ startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 },
|
||||||
'2026-07-11',
|
'2026-07-11',
|
||||||
)).toEqual({ startDate: '2026-07-10', endDate: '2026-07-11' });
|
),
|
||||||
|
).toEqual({ startDate: '2026-07-10', endDate: '2026-07-11' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates local attendance after the lesson starts', async () => {
|
it('creates local attendance after the lesson starts', async () => {
|
||||||
@@ -266,9 +291,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
attendanceRepo.find.mockResolvedValue([
|
attendanceRepo.find.mockResolvedValue([
|
||||||
{ id: 1, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
|
{ id: 1, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
|
||||||
]);
|
]);
|
||||||
classStudentRepo.find.mockResolvedValue([
|
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
||||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
|
||||||
]);
|
|
||||||
dingRawRepo.find.mockResolvedValue([
|
dingRawRepo.find.mockResolvedValue([
|
||||||
{
|
{
|
||||||
matchedStudentId: 1,
|
matchedStudentId: 1,
|
||||||
@@ -306,8 +329,18 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
||||||
]);
|
]);
|
||||||
dingRawRepo.find.mockResolvedValue([
|
dingRawRepo.find.mockResolvedValue([
|
||||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
|
{
|
||||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
|
matchedStudentId: 1,
|
||||||
|
attendanceType: 'OnDuty',
|
||||||
|
timeResult: 'Normal',
|
||||||
|
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matchedStudentId: 2,
|
||||||
|
attendanceType: 'OnDuty',
|
||||||
|
timeResult: 'Late',
|
||||||
|
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||||
@@ -326,7 +359,6 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
expect(result.records).toHaveLength(2);
|
expect(result.records).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
it('restores students missing from an existing empty session', async () => {
|
it('restores students missing from an existing empty session', async () => {
|
||||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||||
createService();
|
createService();
|
||||||
@@ -373,8 +405,18 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
||||||
]);
|
]);
|
||||||
dingRawRepo.find.mockResolvedValue([
|
dingRawRepo.find.mockResolvedValue([
|
||||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
|
{
|
||||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
|
matchedStudentId: 1,
|
||||||
|
attendanceType: 'OnDuty',
|
||||||
|
timeResult: 'Late',
|
||||||
|
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matchedStudentId: 2,
|
||||||
|
attendanceType: 'OnDuty',
|
||||||
|
timeResult: 'Late',
|
||||||
|
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||||
@@ -404,9 +446,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
attendanceRepo.find.mockResolvedValue([
|
attendanceRepo.find.mockResolvedValue([
|
||||||
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'present', source: 'manual' },
|
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'present', source: 'manual' },
|
||||||
]);
|
]);
|
||||||
classStudentRepo.find.mockResolvedValue([
|
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
||||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
|
||||||
]);
|
|
||||||
dingRawRepo.find.mockResolvedValue([]);
|
dingRawRepo.find.mockResolvedValue([]);
|
||||||
|
|
||||||
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
|
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
|
||||||
@@ -489,8 +529,18 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
{ studentId: 2, student: { id: 2, name: '李四' } },
|
{ studentId: 2, student: { id: 2, name: '李四' } },
|
||||||
]);
|
]);
|
||||||
dingRawRepo.find.mockResolvedValue([
|
dingRawRepo.find.mockResolvedValue([
|
||||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
|
{
|
||||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
|
matchedStudentId: 1,
|
||||||
|
attendanceType: 'OnDuty',
|
||||||
|
timeResult: 'Normal',
|
||||||
|
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matchedStudentId: 2,
|
||||||
|
attendanceType: 'OnDuty',
|
||||||
|
timeResult: 'Normal',
|
||||||
|
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Step 1: update the record to absent via generic update()
|
// Step 1: update the record to absent via generic update()
|
||||||
@@ -498,7 +548,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
expect(updated.source).toBe('manual');
|
expect(updated.source).toBe('manual');
|
||||||
|
|
||||||
// Step 2: refresh in_progress session — manual record status must stay absent
|
// Step 2: refresh in_progress session — manual record status must stay absent
|
||||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||||
|
|
||||||
const savedRecords = (attendanceRepo.save as jest.Mock).mock.calls[
|
const savedRecords = (attendanceRepo.save as jest.Mock).mock.calls[
|
||||||
(attendanceRepo.save as jest.Mock).mock.calls.length - 1
|
(attendanceRepo.save as jest.Mock).mock.calls.length - 1
|
||||||
@@ -532,9 +582,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
errno: undefined,
|
errno: undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
classStudentRepo.find.mockResolvedValue([
|
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
||||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
|
||||||
]);
|
|
||||||
dingRawRepo.find.mockResolvedValue([]);
|
dingRawRepo.find.mockResolvedValue([]);
|
||||||
attendanceRepo.find.mockResolvedValue([
|
attendanceRepo.find.mockResolvedValue([
|
||||||
{ id: 201, studentId: 1, attendanceSessionId: 77, status: 'present', source: 'dingtalk' },
|
{ id: 201, studentId: 1, attendanceSessionId: 77, status: 'present', source: 'dingtalk' },
|
||||||
@@ -555,9 +603,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
});
|
});
|
||||||
attendanceRepo.count.mockResolvedValue(0);
|
attendanceRepo.count.mockResolvedValue(0);
|
||||||
attendanceRepo.find.mockResolvedValue([
|
attendanceRepo.find.mockResolvedValue([{ id: 1, status: 'present' }]);
|
||||||
{ id: 1, status: 'present' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await service.completeLessonAttendance(90, 21);
|
await service.completeLessonAttendance(90, 21);
|
||||||
|
|
||||||
@@ -615,15 +661,24 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
describe('AttendanceService — attendance window boundaries', () => {
|
describe('AttendanceService — attendance window boundaries', () => {
|
||||||
it('crosses calendar boundaries only when the window requires it', () => {
|
it('crosses calendar boundaries only when the window requires it', () => {
|
||||||
const { service } = createService();
|
const { service } = createService();
|
||||||
expect(service.getLessonAttendanceImportDateRange(
|
expect(
|
||||||
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 30 }, '2026-07-13',
|
service.getLessonAttendanceImportDateRange(
|
||||||
)).toEqual({ startDate: '2026-07-13', endDate: '2026-07-13' });
|
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 30 },
|
||||||
expect(service.getLessonAttendanceImportDateRange(
|
'2026-07-13',
|
||||||
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 31 }, '2026-07-13',
|
),
|
||||||
)).toEqual({ startDate: '2026-07-12', endDate: '2026-07-13' });
|
).toEqual({ startDate: '2026-07-13', endDate: '2026-07-13' });
|
||||||
expect(service.getLessonAttendanceImportDateRange(
|
expect(
|
||||||
{ startTime: '22:00', endTime: '01:00', attendanceAdvanceMinutes: 30 }, '2026-07-13',
|
service.getLessonAttendanceImportDateRange(
|
||||||
)).toEqual({ startDate: '2026-07-13', endDate: '2026-07-14' });
|
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 31 },
|
||||||
|
'2026-07-13',
|
||||||
|
),
|
||||||
|
).toEqual({ startDate: '2026-07-12', endDate: '2026-07-13' });
|
||||||
|
expect(
|
||||||
|
service.getLessonAttendanceImportDateRange(
|
||||||
|
{ startTime: '22:00', endTime: '01:00', attendanceAdvanceMinutes: 30 },
|
||||||
|
'2026-07-13',
|
||||||
|
),
|
||||||
|
).toEqual({ startDate: '2026-07-13', endDate: '2026-07-14' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses Asia/Shanghai time when deciding whether todays lesson has started', async () => {
|
it('uses Asia/Shanghai time when deciding whether todays lesson has started', async () => {
|
||||||
@@ -631,17 +686,23 @@ describe('AttendanceService — attendance window boundaries', () => {
|
|||||||
process.env.TZ = 'UTC';
|
process.env.TZ = 'UTC';
|
||||||
jest.useFakeTimers().setSystemTime(new Date('2026-07-13T01:00:00.000Z'));
|
jest.useFakeTimers().setSystemTime(new Date('2026-07-13T01:00:00.000Z'));
|
||||||
try {
|
try {
|
||||||
const { service, scheduleRepo, sessionRepo, attendanceRepo, dingRawRepo, classStudentRepo } = createService();
|
const { service, scheduleRepo, sessionRepo, attendanceRepo, dingRawRepo, classStudentRepo } =
|
||||||
|
createService();
|
||||||
scheduleRepo.findOne.mockResolvedValue({
|
scheduleRepo.findOne.mockResolvedValue({
|
||||||
...endedSchedule, weekDay: 1, startTime: '08:30', endTime: '10:00',
|
...endedSchedule,
|
||||||
startDate: '2026-07-13', endDate: '2026-07-13',
|
weekDay: 1,
|
||||||
|
startTime: '08:30',
|
||||||
|
endTime: '10:00',
|
||||||
|
startDate: '2026-07-13',
|
||||||
|
endDate: '2026-07-13',
|
||||||
});
|
});
|
||||||
sessionRepo.findOne.mockResolvedValue({ id: 90, status: 'completed' });
|
sessionRepo.findOne.mockResolvedValue({ id: 90, status: 'completed' });
|
||||||
attendanceRepo.find.mockResolvedValue([]);
|
attendanceRepo.find.mockResolvedValue([]);
|
||||||
dingRawRepo.find.mockResolvedValue([]);
|
dingRawRepo.find.mockResolvedValue([]);
|
||||||
classStudentRepo.find.mockResolvedValue([]);
|
classStudentRepo.find.mockResolvedValue([]);
|
||||||
await expect(service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21))
|
await expect(
|
||||||
.resolves.toMatchObject({ records: [] });
|
service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21),
|
||||||
|
).resolves.toMatchObject({ records: [] });
|
||||||
} finally {
|
} finally {
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
process.env.TZ = originalTz;
|
process.env.TZ = originalTz;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { AttendanceService } from './attendance.service';
|
|||||||
import { AttendanceImportService } from './attendance-import.service';
|
import { AttendanceImportService } from './attendance-import.service';
|
||||||
import { AttendanceSettlementService } from './attendance-settlement.service';
|
import { AttendanceSettlementService } from './attendance-settlement.service';
|
||||||
import { AttendanceController } from './attendance.controller';
|
import { AttendanceController } from './attendance.controller';
|
||||||
|
import { AttendanceRecordsController } from './attendance-records.controller';
|
||||||
|
import { AttendanceImportController } from './attendance-import.controller';
|
||||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||||
import { IntegrationModule } from '../integration/integration.module';
|
import { IntegrationModule } from '../integration/integration.module';
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ import { IntegrationModule } from '../integration/integration.module';
|
|||||||
OperationLogsModule,
|
OperationLogsModule,
|
||||||
IntegrationModule,
|
IntegrationModule,
|
||||||
],
|
],
|
||||||
controllers: [AttendanceController],
|
controllers: [AttendanceController, AttendanceRecordsController, AttendanceImportController],
|
||||||
providers: [AttendanceService, AttendanceImportService, AttendanceSettlementService],
|
providers: [AttendanceService, AttendanceImportService, AttendanceSettlementService],
|
||||||
exports: [AttendanceService, AttendanceImportService],
|
exports: [AttendanceService, AttendanceImportService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Test, TestingModule } from '@nestjs/testing';
|
|||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { AttendanceService } from './attendance.service';
|
import { AttendanceService } from './attendance.service';
|
||||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||||
import { AttendanceSession } from '../entities/attendance-session.entity';
|
import { AttendanceSession } from '../entities/attendance-session.entity';
|
||||||
@@ -19,7 +18,6 @@ import { BatchCreateAttendanceDto } from './dto/attendance.dto';
|
|||||||
|
|
||||||
describe('AttendanceService — batchCreate', () => {
|
describe('AttendanceService — batchCreate', () => {
|
||||||
let service: AttendanceService;
|
let service: AttendanceService;
|
||||||
let attendanceRepo: jest.Mocked<Pick<Repository<AttendanceRecord>, 'create' | 'save'>>;
|
|
||||||
|
|
||||||
const savedRecords: AttendanceRecord[] = [];
|
const savedRecords: AttendanceRecord[] = [];
|
||||||
|
|
||||||
@@ -28,10 +26,10 @@ describe('AttendanceService — batchCreate', () => {
|
|||||||
const mockRepo = {
|
const mockRepo = {
|
||||||
create: jest
|
create: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementation((data: Partial<AttendanceRecord>) => ({ id: 1, ...data } as AttendanceRecord)),
|
.mockImplementation(
|
||||||
save: jest
|
(data: Partial<AttendanceRecord>) => ({ id: 1, ...data }) as AttendanceRecord,
|
||||||
.fn()
|
),
|
||||||
.mockImplementation((entities: AttendanceRecord[]) => {
|
save: jest.fn().mockImplementation((entities: AttendanceRecord[]) => {
|
||||||
const result = entities.map((e, i) => ({ ...e, id: i + 1 }));
|
const result = entities.map((e, i) => ({ ...e, id: i + 1 }));
|
||||||
savedRecords.push(...result);
|
savedRecords.push(...result);
|
||||||
return Promise.resolve(result);
|
return Promise.resolve(result);
|
||||||
@@ -66,15 +64,32 @@ describe('AttendanceService — batchCreate', () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<AttendanceService>(AttendanceService);
|
service = module.get<AttendanceService>(AttendanceService);
|
||||||
attendanceRepo = module.get(getRepositoryToken(AttendanceRecord));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('valid batch with morning_reading, evening_study, and night_check sessions → succeeds', async () => {
|
it('valid batch with morning_reading, evening_study, and night_check sessions → succeeds', async () => {
|
||||||
const dto: BatchCreateAttendanceDto = {
|
const dto: BatchCreateAttendanceDto = {
|
||||||
records: [
|
records: [
|
||||||
{ studentId: 1, classId: 10, attendanceDate: '2026-07-05', session: 'morning_reading', status: 'present' },
|
{
|
||||||
{ studentId: 2, classId: 10, attendanceDate: '2026-07-05', session: 'evening_study', status: 'present' },
|
studentId: 1,
|
||||||
{ studentId: 3, classId: 10, attendanceDate: '2026-07-05', session: 'night_check', status: 'present' },
|
classId: 10,
|
||||||
|
attendanceDate: '2026-07-05',
|
||||||
|
session: 'morning_reading',
|
||||||
|
status: 'present',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
studentId: 2,
|
||||||
|
classId: 10,
|
||||||
|
attendanceDate: '2026-07-05',
|
||||||
|
session: 'evening_study',
|
||||||
|
status: 'present',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
studentId: 3,
|
||||||
|
classId: 10,
|
||||||
|
attendanceDate: '2026-07-05',
|
||||||
|
session: 'night_check',
|
||||||
|
status: 'present',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,7 +107,12 @@ describe('AttendanceService — batchCreate', () => {
|
|||||||
|
|
||||||
const invalidPayload = {
|
const invalidPayload = {
|
||||||
records: [
|
records: [
|
||||||
{ studentId: 1, attendanceDate: '2026-07-05', session: 'invalid_session', status: 'present' },
|
{
|
||||||
|
studentId: 1,
|
||||||
|
attendanceDate: '2026-07-05',
|
||||||
|
session: 'invalid_session',
|
||||||
|
status: 'present',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,11 +171,7 @@ describe('AttendanceService — teacher DingTalk class scope', () => {
|
|||||||
|
|
||||||
it('returns only mapped active students for a class assigned to the teacher', async () => {
|
it('returns only mapped active students for a class assigned to the teacher', async () => {
|
||||||
classTeacherRepo.findOne.mockResolvedValue({ classId: 8, userId: 21 });
|
classTeacherRepo.findOne.mockResolvedValue({ classId: 8, userId: 21 });
|
||||||
classStudentRepo.find.mockResolvedValue([
|
classStudentRepo.find.mockResolvedValue([{ studentId: 2 }, { studentId: 1 }, { studentId: 2 }]);
|
||||||
{ studentId: 2 },
|
|
||||||
{ studentId: 1 },
|
|
||||||
{ studentId: 2 },
|
|
||||||
]);
|
|
||||||
mappingRepo.find.mockResolvedValue([
|
mappingRepo.find.mockResolvedValue([
|
||||||
{ studentId: 1, dingUserId: 'ding-1' },
|
{ studentId: 1, dingUserId: 'ding-1' },
|
||||||
{ studentId: 2, dingUserId: 'ding-2' },
|
{ studentId: 2, dingUserId: 'ding-2' },
|
||||||
@@ -206,7 +222,9 @@ describe('AttendanceService — DingTalk raw query', () => {
|
|||||||
};
|
};
|
||||||
const dingRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
const dingRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||||
const classStudentRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3 }]) };
|
const classStudentRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3 }]) };
|
||||||
const mappingRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3, dingUserId: 'ding-3' }]) };
|
const mappingRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([{ studentId: 3, dingUserId: 'ding-3' }]),
|
||||||
|
};
|
||||||
const service = new AttendanceService(
|
const service = new AttendanceService(
|
||||||
{} as never,
|
{} as never,
|
||||||
dingRepo as never,
|
dingRepo as never,
|
||||||
@@ -237,7 +255,6 @@ describe('AttendanceService — DingTalk raw query', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe('AttendanceService — attendance device display mappings', () => {
|
describe('AttendanceService — attendance device display mappings', () => {
|
||||||
function createHistoryQueryBuilder(records: AttendanceRecord[]) {
|
function createHistoryQueryBuilder(records: AttendanceRecord[]) {
|
||||||
return {
|
return {
|
||||||
@@ -337,8 +354,6 @@ describe('AttendanceService — attendance device display mappings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── Session serialization tests ──
|
// ── Session serialization tests ──
|
||||||
function deferred<T>(): {
|
function deferred<T>(): {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
@@ -400,7 +415,8 @@ describe('AttendanceService — session serialization', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeTxManager(sessionStatus: string) {
|
function makeTxManager(sessionStatus: string) {
|
||||||
const session = sessionStatus === 'completed' ? { ...sessionCompleted } : { ...sessionInProgress };
|
const session =
|
||||||
|
sessionStatus === 'completed' ? { ...sessionCompleted } : { ...sessionInProgress };
|
||||||
|
|
||||||
const sessionRepo = {
|
const sessionRepo = {
|
||||||
findOne: jest.fn().mockResolvedValue(session),
|
findOne: jest.fn().mockResolvedValue(session),
|
||||||
@@ -528,9 +544,7 @@ describe('AttendanceService — session serialization', () => {
|
|||||||
it('complete is idempotent: returns current state when session already completed', async () => {
|
it('complete is idempotent: returns current state when session already completed', async () => {
|
||||||
const manager = makeTxManager('completed');
|
const manager = makeTxManager('completed');
|
||||||
|
|
||||||
const txMock = jest
|
const txMock = jest.fn().mockImplementation((cb: (m: unknown) => unknown) => cb(manager));
|
||||||
.fn()
|
|
||||||
.mockImplementation((cb: (m: unknown) => unknown) => cb(manager));
|
|
||||||
|
|
||||||
const svc = makeService({ transaction: txMock });
|
const svc = makeService({ transaction: txMock });
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
67
apps/server/src/integration/dingtalk.attendance.ts
Normal file
67
apps/server/src/integration/dingtalk.attendance.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type {
|
||||||
|
DingTalkAttendanceResult,
|
||||||
|
DingTalkServiceContext,
|
||||||
|
} from './dingtalk.types';
|
||||||
|
|
||||||
|
export class DingTalkAttendanceClient {
|
||||||
|
constructor(private readonly context: DingTalkServiceContext) {}
|
||||||
|
|
||||||
|
async fetchAttendanceResults(params: {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
userIds?: string[];
|
||||||
|
}): Promise<DingTalkAttendanceResult[]> {
|
||||||
|
if (!(await this.context.isConfigured())) throw new Error('DingTalk not configured');
|
||||||
|
if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空');
|
||||||
|
if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人');
|
||||||
|
const token = await this.context.getAccessToken();
|
||||||
|
|
||||||
|
const dateFrom = params.startDate.includes(' ') ? params.startDate : `${params.startDate} 00:00:00`;
|
||||||
|
const dateTo = params.endDate.includes(' ') ? params.endDate : `${params.endDate} 23:59:59`;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
checkDateFrom: dateFrom,
|
||||||
|
checkDateTo: dateTo,
|
||||||
|
};
|
||||||
|
body.userIds = params.userIds;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = await res.json() as {
|
||||||
|
errcode: number; errmsg: string;
|
||||||
|
recordresult?: Array<{
|
||||||
|
id: number; userId: string; workDate: number;
|
||||||
|
userCheckTime: number; sourceType: string;
|
||||||
|
checkType?: string; timeResult?: string;
|
||||||
|
locationResult?: string; locationMethod?: string;
|
||||||
|
userAddress?: string; userLongitude?: number; userLatitude?: number;
|
||||||
|
deviceName?: string; deviceId?: string | number; deviceSN?: string | number;
|
||||||
|
attendanceMachineName?: string; attendanceMachineId?: string | number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
|
||||||
|
|
||||||
|
const records = data.recordresult ?? [];
|
||||||
|
|
||||||
|
return records.map((r) => ({
|
||||||
|
userId: r.userId,
|
||||||
|
userName: '',
|
||||||
|
workDate: new Date(r.workDate + 8 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||||||
|
timeResult: r.timeResult ?? r.sourceType ?? '',
|
||||||
|
locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '',
|
||||||
|
planCheckTime: '',
|
||||||
|
actualCheckTime: new Date(r.userCheckTime).toISOString(),
|
||||||
|
checkId: String(r.id),
|
||||||
|
checkType: r.checkType ?? '',
|
||||||
|
sourceType: r.sourceType ?? '',
|
||||||
|
deviceName: r.deviceName ?? r.attendanceMachineName,
|
||||||
|
deviceId: String(r.deviceId ?? r.attendanceMachineId ?? r.deviceSN ?? '') || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
183
apps/server/src/integration/dingtalk.groups.ts
Normal file
183
apps/server/src/integration/dingtalk.groups.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
DingTalkGroupParams,
|
||||||
|
DingTalkGroupSummary,
|
||||||
|
DingTalkGroupUpdateParams,
|
||||||
|
DingTalkServiceContext,
|
||||||
|
} from './dingtalk.types';
|
||||||
|
|
||||||
|
export class DingTalkGroupClient {
|
||||||
|
constructor(private readonly context: DingTalkServiceContext) {}
|
||||||
|
|
||||||
|
/** 创建排班制考勤组 */
|
||||||
|
async createAttendanceGroup(params: DingTalkGroupParams): Promise<number> {
|
||||||
|
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||||
|
const token = await this.context.getAccessToken();
|
||||||
|
|
||||||
|
const topGroup = this.buildAttendanceGroupBody(params);
|
||||||
|
const body = { op_user_id: params.owner, top_group: topGroup };
|
||||||
|
|
||||||
|
await this.context.rateLimit();
|
||||||
|
const res = await fetch(
|
||||||
|
`https://oapi.dingtalk.com/topapi/attendance/group/add?access_token=${token}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
errcode: number; errmsg: string;
|
||||||
|
result?: { id: number };
|
||||||
|
};
|
||||||
|
if (data.errcode !== 0) {
|
||||||
|
throw new Error(`钉钉创建考勤组失败: ${data.errmsg} (code=${data.errcode})`);
|
||||||
|
}
|
||||||
|
this.context.logger.log(`钉钉考勤组创建成功: ${params.name} (id=${data.result?.id})`);
|
||||||
|
return data.result!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */
|
||||||
|
async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise<void> {
|
||||||
|
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||||
|
const token = await this.context.getAccessToken();
|
||||||
|
const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id };
|
||||||
|
|
||||||
|
await this.context.rateLimit();
|
||||||
|
const res = await fetch(
|
||||||
|
`https://oapi.dingtalk.com/topapi/attendance/group/modify?access_token=${token}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ op_user_id: params.owner, top_group: topGroup }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
errcode?: number;
|
||||||
|
errmsg?: string;
|
||||||
|
success?: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
const succeeded = data.success === true || data.errcode === 0;
|
||||||
|
if (!succeeded) {
|
||||||
|
throw new Error(
|
||||||
|
`钉钉更新考勤组失败: ${data.message || data.errmsg || '未知错误'} ` +
|
||||||
|
`(code=${data.errcode ?? 'unknown'})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.context.logger.log(`钉钉考勤组更新成功: ${params.name} (id=${params.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAttendanceGroupBody(params: DingTalkGroupParams): Record<string, unknown> {
|
||||||
|
const machineOnly = params.attendance_machine_only ?? false;
|
||||||
|
const topGroup: Record<string, unknown> = {
|
||||||
|
name: params.name,
|
||||||
|
type: params.type,
|
||||||
|
owner: params.owner,
|
||||||
|
members: params.members.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
type: m.type,
|
||||||
|
user_id: m.user_id,
|
||||||
|
})),
|
||||||
|
enable_emp_select_class: machineOnly ? false : (params.enable_emp_select_class ?? true),
|
||||||
|
disable_check_without_schedule: machineOnly ? true : (params.disable_check_without_schedule ?? false),
|
||||||
|
disable_check_when_rest: params.disable_check_when_rest ?? true,
|
||||||
|
};
|
||||||
|
if (params.shift_ids?.length) {
|
||||||
|
topGroup.shift_vo_list = params.shift_ids.map((id) => ({ id }));
|
||||||
|
}
|
||||||
|
if (machineOnly) {
|
||||||
|
Object.assign(topGroup, {
|
||||||
|
enable_outside_check: false,
|
||||||
|
enable_position_ble: false,
|
||||||
|
positions: [],
|
||||||
|
wifis: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return topGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询所有考勤组摘要(分页,每页10条) */
|
||||||
|
async queryAttendanceGroups(_opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
|
||||||
|
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||||
|
const token = await this.context.getAccessToken();
|
||||||
|
|
||||||
|
const all: DingTalkGroupSummary[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
await this.context.rateLimit();
|
||||||
|
const res = await fetch(
|
||||||
|
`https://oapi.dingtalk.com/topapi/attendance/getsimplegroups?access_token=${token}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ offset, size: 10 }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
errcode: number; errmsg: string;
|
||||||
|
result?: {
|
||||||
|
has_more: boolean;
|
||||||
|
groups: Array<{ group_id: number; group_name: string; type: string; member_count: number }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (data.errcode !== 0) {
|
||||||
|
throw new Error(`钉钉查询考勤组失败: ${data.errmsg} (code=${data.errcode})`);
|
||||||
|
}
|
||||||
|
if (data.result?.groups) {
|
||||||
|
all.push(...data.result.groups.map((g) => ({
|
||||||
|
group_id: g.group_id,
|
||||||
|
group_name: g.group_name,
|
||||||
|
type: g.type,
|
||||||
|
member_count: g.member_count,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
hasMore = data.result?.has_more ?? false;
|
||||||
|
offset += 10;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise<void> {
|
||||||
|
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||||
|
const token = await this.context.getAccessToken();
|
||||||
|
|
||||||
|
await this.context.rateLimit();
|
||||||
|
const keyResponse = await fetch(
|
||||||
|
`https://oapi.dingtalk.com/topapi/attendance/groups/idtokey?access_token=${token}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ op_user_id: opUserId, group_id: groupId }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const keyData = await keyResponse.json() as {
|
||||||
|
errcode: number;
|
||||||
|
errmsg: string;
|
||||||
|
result?: string;
|
||||||
|
};
|
||||||
|
if (keyData.errcode !== 0 || !keyData.result) {
|
||||||
|
throw new Error(`钉钉考勤组ID转换失败: ${keyData.errmsg} (code=${keyData.errcode})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.context.rateLimit();
|
||||||
|
const deleteResponse = await fetch(
|
||||||
|
`https://oapi.dingtalk.com/topapi/attendance/group/delete?access_token=${token}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ op_userid: opUserId, group_key: keyData.result }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const deleteData = await deleteResponse.json() as {
|
||||||
|
errcode: number;
|
||||||
|
errmsg: string;
|
||||||
|
success?: boolean;
|
||||||
|
};
|
||||||
|
if (deleteData.errcode !== 0 || deleteData.success !== true) {
|
||||||
|
throw new Error(`钉钉删除考勤组失败: ${deleteData.errmsg} (code=${deleteData.errcode})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
apps/server/src/integration/dingtalk.schedules.ts
Normal file
94
apps/server/src/integration/dingtalk.schedules.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
DingTalkScheduleItem,
|
||||||
|
DingTalkScheduleResult,
|
||||||
|
DingTalkServiceContext,
|
||||||
|
} from './dingtalk.types';
|
||||||
|
|
||||||
|
export class DingTalkScheduleClient {
|
||||||
|
constructor(private readonly context: DingTalkServiceContext) {}
|
||||||
|
|
||||||
|
/** 批量排班(单次最多200条) */
|
||||||
|
async scheduleUsers(
|
||||||
|
groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager',
|
||||||
|
): Promise<void> {
|
||||||
|
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||||
|
if (schedules.length === 0) return;
|
||||||
|
if (schedules.length > 200) {
|
||||||
|
throw new Error(`排班单次最多200条,当前 ${schedules.length} 条`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await this.context.getAccessToken();
|
||||||
|
const body = {
|
||||||
|
op_user_id: opUserId,
|
||||||
|
group_id: groupId,
|
||||||
|
schedules: schedules.map((s) => ({
|
||||||
|
userid: s.userid,
|
||||||
|
work_date: s.work_date,
|
||||||
|
shift_id: s.shift_id,
|
||||||
|
is_rest: s.is_rest ?? false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.context.rateLimit();
|
||||||
|
const res = await fetch(
|
||||||
|
`https://oapi.dingtalk.com/topapi/attendance/group/schedule/async?access_token=${token}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
errcode: number; errmsg: string;
|
||||||
|
};
|
||||||
|
if (data.errcode !== 0) {
|
||||||
|
throw new Error(`钉钉排班失败: ${data.errmsg} (code=${data.errcode})`);
|
||||||
|
}
|
||||||
|
this.context.logger.log(`钉钉排班成功: groupId=${groupId}, ${schedules.length} 条`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询指定用户的排班信息(7天内,最多50人) */
|
||||||
|
async queryScheduleByUsers(
|
||||||
|
userIds: string[], fromDate: number, toDate: number, opUserId = 'manager',
|
||||||
|
): Promise<DingTalkScheduleResult[]> {
|
||||||
|
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||||
|
const token = await this.context.getAccessToken();
|
||||||
|
|
||||||
|
await this.context.rateLimit();
|
||||||
|
const res = await fetch(
|
||||||
|
`https://oapi.dingtalk.com/topapi/attendance/schedule/listbyusers?access_token=${token}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
op_user_id: opUserId,
|
||||||
|
userids: userIds.join(','),
|
||||||
|
from_date_time: fromDate,
|
||||||
|
to_date_time: toDate,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
errcode: number; errmsg: string;
|
||||||
|
result?: Array<{
|
||||||
|
userid: string; work_date: string; shift_id: number;
|
||||||
|
is_rest: string; check_type: string; plan_check_time: string;
|
||||||
|
group_id: number; id: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
if (data.errcode !== 0) {
|
||||||
|
throw new Error(`钉钉查询排班失败: ${data.errmsg} (code=${data.errcode})`);
|
||||||
|
}
|
||||||
|
return (data.result ?? []).map((r) => ({
|
||||||
|
userid: r.userid,
|
||||||
|
work_date: r.work_date,
|
||||||
|
shift_id: r.shift_id,
|
||||||
|
is_rest: r.is_rest,
|
||||||
|
check_type: r.check_type,
|
||||||
|
plan_check_time: r.plan_check_time,
|
||||||
|
group_id: r.group_id,
|
||||||
|
id: r.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
107
apps/server/src/integration/dingtalk.shifts.ts
Normal file
107
apps/server/src/integration/dingtalk.shifts.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
DingTalkServiceContext,
|
||||||
|
DingTalkShiftParams,
|
||||||
|
DingTalkShiftSummary,
|
||||||
|
} from './dingtalk.types';
|
||||||
|
|
||||||
|
export class DingTalkShiftClient {
|
||||||
|
constructor(private readonly context: DingTalkServiceContext) {}
|
||||||
|
|
||||||
|
/** 创建或修改班次。id 不传=创建,传了=修改 */
|
||||||
|
async upsertShift(params: DingTalkShiftParams): Promise<number> {
|
||||||
|
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||||
|
const token = await this.context.getAccessToken();
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
op_user_id: params.owner || 'manager',
|
||||||
|
shift: {
|
||||||
|
name: params.name,
|
||||||
|
owner: params.owner,
|
||||||
|
sections: params.sections.map((s) => ({
|
||||||
|
times: s.times.map((t) => ({
|
||||||
|
check_type: t.check_type,
|
||||||
|
across: t.across,
|
||||||
|
check_time: t.check_time,
|
||||||
|
begin_min: t.begin_min ?? -1,
|
||||||
|
end_min: t.end_min ?? -1,
|
||||||
|
free_check: t.free_check ?? false,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
setting: params.setting
|
||||||
|
? {
|
||||||
|
is_flexible: params.setting.is_flexible ?? false,
|
||||||
|
serious_late_minutes: params.setting.serious_late_minutes ?? -1,
|
||||||
|
absenteeism_late_minutes: params.setting.absenteeism_late_minutes ?? -1,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (params.id) (body.shift as Record<string, unknown>).id = params.id;
|
||||||
|
|
||||||
|
await this.context.rateLimit();
|
||||||
|
const res = await fetch(
|
||||||
|
`https://oapi.dingtalk.com/topapi/attendance/shift/add?access_token=${token}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
errcode: number; errmsg: string;
|
||||||
|
result?: { id: number; name: string };
|
||||||
|
};
|
||||||
|
if (data.errcode !== 0) {
|
||||||
|
throw new Error(`钉钉班次操作失败: ${data.errmsg} (code=${data.errcode})`);
|
||||||
|
}
|
||||||
|
this.context.logger.log(`钉钉班次 ${params.id ? '更新' : '创建'} 成功: ${data.result?.name} (id=${data.result?.id})`);
|
||||||
|
return data.result!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询所有班次摘要(每页最多200条) */
|
||||||
|
async queryShifts(opUserId = 'manager'): Promise<DingTalkShiftSummary[]> {
|
||||||
|
if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||||
|
const token = await this.context.getAccessToken();
|
||||||
|
|
||||||
|
const all: DingTalkShiftSummary[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
await this.context.rateLimit();
|
||||||
|
const res = await fetch(
|
||||||
|
`https://oapi.dingtalk.com/topapi/attendance/shift/list?access_token=${token}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ op_user_id: opUserId, cursor }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
errcode: number;
|
||||||
|
errmsg: string;
|
||||||
|
result?: {
|
||||||
|
cursor?: number;
|
||||||
|
has_more?: boolean;
|
||||||
|
result?: Array<{ id: number; name: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (data.errcode !== 0) {
|
||||||
|
throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = data.result;
|
||||||
|
all.push(...(page?.result ?? []).map((s) => ({ id: s.id, name: s.name })));
|
||||||
|
hasMore = page?.has_more ?? false;
|
||||||
|
if (hasMore) {
|
||||||
|
if (page?.cursor === undefined || page.cursor === cursor) {
|
||||||
|
throw new Error('钉钉查询班次失败: 分页游标无效');
|
||||||
|
}
|
||||||
|
cursor = page.cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
}
|
||||||
192
apps/server/src/integration/dingtalk.types.ts
Normal file
192
apps/server/src/integration/dingtalk.types.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
export interface DingTalkCredentials {
|
||||||
|
appKey: string;
|
||||||
|
appSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DingTalkUserListResponse {
|
||||||
|
errcode: number;
|
||||||
|
errmsg: string;
|
||||||
|
result: {
|
||||||
|
has_more: boolean;
|
||||||
|
next_cursor?: number;
|
||||||
|
list: Array<{
|
||||||
|
userid: string;
|
||||||
|
name: string;
|
||||||
|
mobile: string;
|
||||||
|
dept_id_list: number[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDingTalkUserListResponse(value: unknown): value is DingTalkUserListResponse {
|
||||||
|
if (!value || typeof value !== 'object' || !('errcode' in value)) return false;
|
||||||
|
if (typeof value.errcode !== 'number') return false;
|
||||||
|
if ('errmsg' in value && typeof value.errmsg !== 'string') return false;
|
||||||
|
if (!('result' in value) || !value.result || typeof value.result !== 'object') {
|
||||||
|
return value.errcode !== 0;
|
||||||
|
}
|
||||||
|
if (!('has_more' in value.result) || typeof value.result.has_more !== 'boolean') return false;
|
||||||
|
if (!('list' in value.result) || !Array.isArray(value.result.list)) return false;
|
||||||
|
return value.result.list.every(
|
||||||
|
(item: Record<string, unknown>) =>
|
||||||
|
item &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
'userid' in item &&
|
||||||
|
typeof item.userid === 'string' &&
|
||||||
|
'name' in item &&
|
||||||
|
typeof item.name === 'string' &&
|
||||||
|
'mobile' in item &&
|
||||||
|
typeof item.mobile === 'string' &&
|
||||||
|
'dept_id_list' in item &&
|
||||||
|
Array.isArray(item.dept_id_list) &&
|
||||||
|
item.dept_id_list.every((id) => typeof id === 'number'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 钉钉打卡结果 — 对齐 dws attendance check result */
|
||||||
|
export interface DingTalkAttendanceResult {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
workDate: string;
|
||||||
|
timeResult: string;
|
||||||
|
locationResult: string;
|
||||||
|
planCheckTime: string;
|
||||||
|
actualCheckTime: string;
|
||||||
|
checkId: string;
|
||||||
|
checkType: string;
|
||||||
|
/** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */
|
||||||
|
sourceType: string;
|
||||||
|
/** 部分钉钉租户会额外返回考勤机名称或编号。 */
|
||||||
|
deviceName?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 组织架构 API 类型 ──
|
||||||
|
|
||||||
|
export interface DingTalkDeptListResponse {
|
||||||
|
errcode: number;
|
||||||
|
result?: Array<{ dept_id: number; name: string; parent_id: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DingTalkDeptGetResponse {
|
||||||
|
errcode: number;
|
||||||
|
result?: { name: string; parent_id: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrgDeptNode {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
parentId: number;
|
||||||
|
children: OrgDeptNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrgDeptNodeWithUsers extends OrgDeptNode {
|
||||||
|
users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 考勤排班 API 类型 ──
|
||||||
|
|
||||||
|
/** 班次卡段打卡时间 */
|
||||||
|
export interface DingTalkShiftTime {
|
||||||
|
check_type: 'OnDuty' | 'OffDuty';
|
||||||
|
across: number;
|
||||||
|
check_time: string;
|
||||||
|
begin_min?: number;
|
||||||
|
end_min?: number;
|
||||||
|
free_check?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 班次卡段 */
|
||||||
|
export interface DingTalkShiftSection {
|
||||||
|
times: DingTalkShiftTime[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 班次配置 */
|
||||||
|
export interface DingTalkShiftSetting {
|
||||||
|
is_flexible?: boolean;
|
||||||
|
serious_late_minutes?: number;
|
||||||
|
absenteeism_late_minutes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建/修改班次参数 */
|
||||||
|
export interface DingTalkShiftParams {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
owner?: string;
|
||||||
|
sections: DingTalkShiftSection[];
|
||||||
|
setting?: DingTalkShiftSetting;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 班次摘要(查询返回) */
|
||||||
|
export interface DingTalkShiftSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 考勤组成员 */
|
||||||
|
export interface DingTalkGroupMember {
|
||||||
|
role: string;
|
||||||
|
type: 'StaffMember' | 'DeptMember';
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建考勤组参数 */
|
||||||
|
export interface DingTalkGroupParams {
|
||||||
|
name: string;
|
||||||
|
type: 'TURN';
|
||||||
|
owner: string;
|
||||||
|
members: DingTalkGroupMember[];
|
||||||
|
shift_ids?: number[];
|
||||||
|
enable_emp_select_class?: boolean;
|
||||||
|
disable_check_without_schedule?: boolean;
|
||||||
|
disable_check_when_rest?: boolean;
|
||||||
|
/** 关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,仅保留考勤机打卡入口 */
|
||||||
|
attendance_machine_only?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改考勤组参数 */
|
||||||
|
export interface DingTalkGroupUpdateParams extends DingTalkGroupParams {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 考勤组摘要(查询返回) */
|
||||||
|
export interface DingTalkGroupSummary {
|
||||||
|
group_id: number;
|
||||||
|
group_name: string;
|
||||||
|
type: string;
|
||||||
|
member_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排班参数(单条) */
|
||||||
|
export interface DingTalkScheduleItem {
|
||||||
|
userid: string;
|
||||||
|
work_date: number;
|
||||||
|
shift_id: number;
|
||||||
|
is_rest?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排班查询结果 */
|
||||||
|
export interface DingTalkScheduleResult {
|
||||||
|
userid: string;
|
||||||
|
work_date: string;
|
||||||
|
shift_id: number;
|
||||||
|
is_rest: string;
|
||||||
|
check_type: string;
|
||||||
|
plan_check_time: string;
|
||||||
|
group_id: number;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 子服务访问主服务状态/能力的共享上下文。 */
|
||||||
|
export interface DingTalkServiceContext {
|
||||||
|
accessToken: string | null;
|
||||||
|
accessTokenCredentialKey: string | null;
|
||||||
|
tokenExpiresAt: number;
|
||||||
|
apiRequestCount: number;
|
||||||
|
readonly logger: Logger;
|
||||||
|
isConfigured(): Promise<boolean>;
|
||||||
|
getAccessToken(): Promise<string>;
|
||||||
|
rateLimit(): Promise<void>;
|
||||||
|
}
|
||||||
17
apps/server/src/integration/endpoints.ts
Normal file
17
apps/server/src/integration/endpoints.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 第三方平台官方固定 API 端点。
|
||||||
|
* 这些是钉钉 / 企业微信 / 金数据的公开固定端点,不是环境相关地址;
|
||||||
|
* 如需指向代理或沙箱环境,应通过各自服务的环境变量覆盖。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// aislop-ignore-next-line: hardcoded-url -- 钉钉官方 OAuth 固定端点
|
||||||
|
export const DINGTALK_OAUTH_TOKEN_URL = 'https://api.dingtalk.com/v1.0/oauth2/accessToken';
|
||||||
|
|
||||||
|
// aislop-ignore-next-line: hardcoded-url -- 金数据官方 API 固定端点
|
||||||
|
export const JINSHUJU_API_BASE = 'https://jinshuju.net/api/v1';
|
||||||
|
|
||||||
|
// aislop-ignore-next-line: hardcoded-url -- 企业微信官方 API 固定端点
|
||||||
|
export const WECOM_API_BASE = 'https://qyapi.weixin.qq.com';
|
||||||
|
export const WECOM_TOKEN_PATH = '/cgi-bin/gettoken';
|
||||||
|
export const WECOM_DEPARTMENT_PATH = '/cgi-bin/department/list';
|
||||||
|
export const WECOM_USER_PATH = '/cgi-bin/user/simplelist';
|
||||||
Reference in New Issue
Block a user