feat: improve attendance scheduling and API validation
This commit is contained in:
@@ -105,6 +105,20 @@ interface AttachmentRecord {
|
|||||||
fileSize: number;
|
fileSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AttendanceRecordItem {
|
||||||
|
id: number;
|
||||||
|
attendanceDate: string;
|
||||||
|
session: string;
|
||||||
|
status: string;
|
||||||
|
source?: string;
|
||||||
|
remark?: string | null;
|
||||||
|
punchTime?: string | null;
|
||||||
|
punchDeviceName?: string | null;
|
||||||
|
punchDeviceId?: string | null;
|
||||||
|
schedule?: { subject?: string } | null;
|
||||||
|
class?: { name?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface StudentProfileAggregate {
|
interface StudentProfileAggregate {
|
||||||
student: StudentInfo;
|
student: StudentInfo;
|
||||||
profile: ProfileData | null;
|
profile: ProfileData | null;
|
||||||
@@ -113,6 +127,7 @@ interface StudentProfileAggregate {
|
|||||||
learningRecords: LearningRecord[];
|
learningRecords: LearningRecord[];
|
||||||
result: ResultData | null;
|
result: ResultData | null;
|
||||||
attachments: AttachmentRecord[];
|
attachments: AttachmentRecord[];
|
||||||
|
attendances: AttendanceRecordItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StudentProfileContentProps {
|
export interface StudentProfileContentProps {
|
||||||
@@ -178,6 +193,58 @@ const formatFileSize = (bytes: number): string => {
|
|||||||
|
|
||||||
// ---- Tab Components ----
|
// ---- Tab Components ----
|
||||||
|
|
||||||
|
const ATTENDANCE_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||||
|
present: { text: '出勤', color: 'green' },
|
||||||
|
late: { text: '迟到', color: 'orange' },
|
||||||
|
absent: { text: '缺勤', color: 'red' },
|
||||||
|
leave: { text: '请假', color: 'blue' },
|
||||||
|
pending: { text: '待确认', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SESSION_LABELS: Record<string, string> = {
|
||||||
|
morning_reading: '早自习',
|
||||||
|
morning: '上午',
|
||||||
|
afternoon: '下午',
|
||||||
|
evening_study: '晚自习',
|
||||||
|
night_check: '晚寝',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => {
|
||||||
|
const columns: ColumnsType<AttendanceRecordItem> = [
|
||||||
|
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
|
||||||
|
{ title: '课程', render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤' },
|
||||||
|
{ title: '时段', dataIndex: 'session', width: 100, render: (value: string) => SESSION_LABELS[value] || value || '-' },
|
||||||
|
{
|
||||||
|
title: '结果', dataIndex: 'status', width: 90,
|
||||||
|
render: (value: string) => {
|
||||||
|
const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' };
|
||||||
|
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '打卡时间', dataIndex: 'punchTime', width: 170, render: (value?: string | null) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-' },
|
||||||
|
{
|
||||||
|
title: '打卡设备',
|
||||||
|
render: (_: unknown, record) => {
|
||||||
|
const name = record.punchDeviceName?.trim();
|
||||||
|
const id = record.punchDeviceId?.trim();
|
||||||
|
if (name && id && name !== id) return `${name}(${id})`;
|
||||||
|
return name || id || (record.source === 'manual' ? '老师手动标记' : '-');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '备注', dataIndex: 'remark', render: (value?: string | null) => value || '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return data.length > 0 ? (
|
||||||
|
<Table<AttendanceRecordItem>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }}
|
||||||
|
/>
|
||||||
|
) : <Empty description="暂无出勤记录" />;
|
||||||
|
};
|
||||||
|
|
||||||
interface TabProps {
|
interface TabProps {
|
||||||
studentId: number;
|
studentId: number;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
@@ -779,7 +846,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
|||||||
|
|
||||||
const tabItems = useMemo(() => {
|
const tabItems = useMemo(() => {
|
||||||
if (!aggregateData) return [];
|
if (!aggregateData) return [];
|
||||||
const { profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData;
|
const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } = aggregateData;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: 'profile',
|
key: 'profile',
|
||||||
@@ -807,8 +874,8 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'attendance',
|
key: 'attendance',
|
||||||
label: '出勤记录',
|
label: `出勤记录 (${attendances.length})`,
|
||||||
children: <Empty description="暂无出勤记录" />,
|
children: <AttendanceTab data={attendances} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'learning',
|
key: 'learning',
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface ClassScheduleItem {
|
|||||||
weekDay: number;
|
weekDay: number;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
attendanceAdvanceMinutes: number;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -353,6 +354,7 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
|
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
|
||||||
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
|
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
|
||||||
{ title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` },
|
{ title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` },
|
||||||
|
{ title: '签到窗口', render: (_: unknown, r: ClassScheduleItem) => `课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课` },
|
||||||
{ title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` },
|
{ title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` },
|
||||||
{ title: '科目', dataIndex: 'subject' },
|
{ title: '科目', dataIndex: 'subject' },
|
||||||
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },
|
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
InputNumber,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
TimePicker,
|
TimePicker,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
@@ -53,6 +54,7 @@ interface ClassScheduleItem {
|
|||||||
weekDay: number;
|
weekDay: number;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
attendanceAdvanceMinutes: number;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -351,7 +353,7 @@ const SchedulesPage: React.FC = () => {
|
|||||||
setEditingSchedule(null);
|
setEditingSchedule(null);
|
||||||
setModalMode('create');
|
setModalMode('create');
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({ classroomId, weekDay });
|
form.setFieldsValue({ classroomId, weekDay, attendanceAdvanceMinutes: 30 });
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -960,9 +962,28 @@ const SchedulesPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="attendanceAdvanceMinutes"
|
||||||
|
label="课前签到时间"
|
||||||
|
tooltip="从上课前指定分钟开始,到下课时间结束;期间任意上班或下班打卡都计为出勤"
|
||||||
|
initialValue={30}
|
||||||
|
rules={[{ required: true, message: '请设置课前签到时间' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={1440}
|
||||||
|
step={5}
|
||||||
|
addonAfter="分钟"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="例如 30"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="timeRange"
|
name="timeRange"
|
||||||
label="上课时段"
|
label="上课时段"
|
||||||
|
tooltip="同一教室的前后两节排课必须至少间隔10分钟"
|
||||||
|
extra="系统按10分钟选择时间,并为相邻排课强制预留至少10分钟。"
|
||||||
rules={[{ required: true, message: '请选择时段' }]}
|
rules={[{ required: true, message: '请选择时段' }]}
|
||||||
>
|
>
|
||||||
<TimePicker.RangePicker
|
<TimePicker.RangePicker
|
||||||
@@ -1008,6 +1029,7 @@ const SchedulesPage: React.FC = () => {
|
|||||||
weekDay:
|
weekDay:
|
||||||
selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined),
|
selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined),
|
||||||
dateRange: selectedDate ? [selectedDate, selectedDate] : undefined,
|
dateRange: selectedDate ? [selectedDate, selectedDate] : undefined,
|
||||||
|
attendanceAdvanceMinutes: 30,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1054,6 +1076,12 @@ const SchedulesPage: React.FC = () => {
|
|||||||
<strong>时段:</strong>
|
<strong>时段:</strong>
|
||||||
{s.startTime} ~ {s.endTime}
|
{s.startTime} ~ {s.endTime}
|
||||||
</div>
|
</div>
|
||||||
|
{!isMaskedSchedule(s) && (
|
||||||
|
<div>
|
||||||
|
<strong>签到窗口:</strong>
|
||||||
|
课前 {s.attendanceAdvanceMinutes ?? 30} 分钟至下课
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<strong>日期:</strong>
|
<strong>日期:</strong>
|
||||||
{s.startDate} ~ {s.endDate}
|
{s.startDate} ~ {s.endDate}
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ describe('schedule edit form mapping', () => {
|
|||||||
startDate: '2026-07-01',
|
startDate: '2026-07-01',
|
||||||
endDate: '2026-07-31',
|
endDate: '2026-07-31',
|
||||||
notes: '需要投影设备',
|
notes: '需要投影设备',
|
||||||
|
attendanceAdvanceMinutes: 45,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(values.classroomId).toBe(1);
|
expect(values.classroomId).toBe(1);
|
||||||
expect(values.weekDay).toBe(5);
|
expect(values.weekDay).toBe(5);
|
||||||
expect(values.notes).toBe('需要投影设备');
|
expect(values.notes).toBe('需要投影设备');
|
||||||
|
expect(values.attendanceAdvanceMinutes).toBe(45);
|
||||||
expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
|
expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
|
||||||
expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
|
expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
|
||||||
'2026-07-01',
|
'2026-07-01',
|
||||||
@@ -39,6 +41,7 @@ describe('schedule edit form mapping', () => {
|
|||||||
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
|
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
|
||||||
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
|
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
|
||||||
notes: ' 临时调整教室 ',
|
notes: ' 临时调整教室 ',
|
||||||
|
attendanceAdvanceMinutes: 20,
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
classId: 1,
|
classId: 1,
|
||||||
@@ -51,6 +54,7 @@ describe('schedule edit form mapping', () => {
|
|||||||
startDate: '2026-08-01',
|
startDate: '2026-08-01',
|
||||||
endDate: '2026-08-31',
|
endDate: '2026-08-31',
|
||||||
notes: '临时调整教室',
|
notes: '临时调整教室',
|
||||||
|
attendanceAdvanceMinutes: 20,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -67,6 +71,7 @@ describe('schedule notes normalization', () => {
|
|||||||
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
|
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
|
||||||
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
|
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
|
||||||
notes: ' ',
|
notes: ' ',
|
||||||
|
attendanceAdvanceMinutes: 30,
|
||||||
}).notes,
|
}).notes,
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface ScheduleFormValues {
|
|||||||
subject: string;
|
subject: string;
|
||||||
teacherId?: number;
|
teacherId?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
attendanceAdvanceMinutes: number;
|
||||||
timeRange: [Dayjs, Dayjs];
|
timeRange: [Dayjs, Dayjs];
|
||||||
dateRange: [Dayjs, Dayjs];
|
dateRange: [Dayjs, Dayjs];
|
||||||
}
|
}
|
||||||
@@ -19,6 +20,7 @@ export interface EditableSchedule {
|
|||||||
subject: string;
|
subject: string;
|
||||||
teacherId: number | null;
|
teacherId: number | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
|
attendanceAdvanceMinutes?: number | null;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
@@ -32,6 +34,7 @@ export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormVa
|
|||||||
subject: schedule.subject,
|
subject: schedule.subject,
|
||||||
teacherId: schedule.teacherId ?? undefined,
|
teacherId: schedule.teacherId ?? undefined,
|
||||||
notes: schedule.notes ?? undefined,
|
notes: schedule.notes ?? undefined,
|
||||||
|
attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes ?? 30,
|
||||||
timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
|
timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
|
||||||
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
|
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
|
||||||
});
|
});
|
||||||
@@ -43,6 +46,7 @@ export const buildSchedulePayload = (values: ScheduleFormValues) => ({
|
|||||||
subject: values.subject,
|
subject: values.subject,
|
||||||
teacherId: values.teacherId,
|
teacherId: values.teacherId,
|
||||||
notes: values.notes?.trim() || undefined,
|
notes: values.notes?.trim() || undefined,
|
||||||
|
attendanceAdvanceMinutes: values.attendanceAdvanceMinutes,
|
||||||
startTime: values.timeRange[0].format('HH:mm'),
|
startTime: values.timeRange[0].format('HH:mm'),
|
||||||
endTime: values.timeRange[1].format('HH:mm'),
|
endTime: values.timeRange[1].format('HH:mm'),
|
||||||
startDate: values.dateRange[0].format('YYYY-MM-DD'),
|
startDate: values.dateRange[0].format('YYYY-MM-DD'),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
Res,
|
Res,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import type { Request as ExpressRequest, Response } from 'express';
|
import type { Request as ExpressRequest, Response } from 'express';
|
||||||
@@ -47,15 +48,15 @@ export class ArchiveController {
|
|||||||
|
|
||||||
@Get(':studentId')
|
@Get(':studentId')
|
||||||
@RequirePermission('student:view')
|
@RequirePermission('student:view')
|
||||||
async getProfile(@Param('studentId') studentId: string, @Request() req: AuthenticatedRequest) {
|
async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.getProfile(+studentId);
|
const result = await this.archiveService.getProfile(studentId);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '查看档案',
|
action: '查看档案',
|
||||||
targetId: +studentId,
|
targetId: studentId,
|
||||||
targetType: 'archive',
|
targetType: 'archive',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -66,18 +67,18 @@ export class ArchiveController {
|
|||||||
@Put(':studentId/profile')
|
@Put(':studentId/profile')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async upsertProfile(
|
async upsertProfile(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Body() dto: UpsertProfileDto,
|
@Body() dto: UpsertProfileDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.upsertProfile(+studentId, dto);
|
const result = await this.archiveService.upsertProfile(studentId, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '更新档案信息',
|
action: '更新档案信息',
|
||||||
targetId: +studentId,
|
targetId: studentId,
|
||||||
targetType: 'student_profile',
|
targetType: 'student_profile',
|
||||||
detail: JSON.stringify(dto),
|
detail: JSON.stringify(dto),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -89,12 +90,12 @@ export class ArchiveController {
|
|||||||
@Post(':studentId/enrollments')
|
@Post(':studentId/enrollments')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async addEnrollment(
|
async addEnrollment(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Body() dto: CreateEnrollmentDto,
|
@Body() dto: CreateEnrollmentDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.addEnrollment(+studentId, dto);
|
const result = await this.archiveService.addEnrollment(studentId, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
@@ -112,18 +113,18 @@ export class ArchiveController {
|
|||||||
@Put('enrollments/:id')
|
@Put('enrollments/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async updateEnrollment(
|
async updateEnrollment(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateEnrollmentDto,
|
@Body() dto: UpdateEnrollmentDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.updateEnrollment(+id, dto);
|
const result = await this.archiveService.updateEnrollment(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '编辑报名记录',
|
action: '编辑报名记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'student_enrollment',
|
targetType: 'student_enrollment',
|
||||||
detail: JSON.stringify(dto),
|
detail: JSON.stringify(dto),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -134,15 +135,15 @@ export class ArchiveController {
|
|||||||
|
|
||||||
@Delete('enrollments/:id')
|
@Delete('enrollments/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async deleteEnrollment(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.deleteEnrollment(+id);
|
const result = await this.archiveService.deleteEnrollment(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '删除报名记录',
|
action: '删除报名记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'student_enrollment',
|
targetType: 'student_enrollment',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -153,12 +154,12 @@ export class ArchiveController {
|
|||||||
@Post(':studentId/exam-scores')
|
@Post(':studentId/exam-scores')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async addExamScore(
|
async addExamScore(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Body() dto: CreateExamScoreDto,
|
@Body() dto: CreateExamScoreDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.addExamScore(+studentId, dto);
|
const result = await this.archiveService.addExamScore(studentId, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
@@ -176,18 +177,18 @@ export class ArchiveController {
|
|||||||
@Put('exam-scores/:id')
|
@Put('exam-scores/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async updateExamScore(
|
async updateExamScore(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateExamScoreDto,
|
@Body() dto: UpdateExamScoreDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.updateExamScore(+id, dto);
|
const result = await this.archiveService.updateExamScore(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '编辑考试成绩',
|
action: '编辑考试成绩',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'exam_score',
|
targetType: 'exam_score',
|
||||||
detail: JSON.stringify(dto),
|
detail: JSON.stringify(dto),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -198,15 +199,15 @@ export class ArchiveController {
|
|||||||
|
|
||||||
@Delete('exam-scores/:id')
|
@Delete('exam-scores/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async deleteExamScore(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.deleteExamScore(+id);
|
const result = await this.archiveService.deleteExamScore(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '删除考试成绩',
|
action: '删除考试成绩',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'exam_score',
|
targetType: 'exam_score',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -217,12 +218,12 @@ export class ArchiveController {
|
|||||||
@Post(':studentId/learning-records')
|
@Post(':studentId/learning-records')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async addLearningRecord(
|
async addLearningRecord(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Body() dto: CreateLearningRecordDto,
|
@Body() dto: CreateLearningRecordDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.addLearningRecord(+studentId, dto);
|
const result = await this.archiveService.addLearningRecord(studentId, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
@@ -240,18 +241,18 @@ export class ArchiveController {
|
|||||||
@Put('learning-records/:id')
|
@Put('learning-records/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async updateLearningRecord(
|
async updateLearningRecord(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateLearningRecordDto,
|
@Body() dto: UpdateLearningRecordDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.updateLearningRecord(+id, dto);
|
const result = await this.archiveService.updateLearningRecord(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '编辑学习记录',
|
action: '编辑学习记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'learning_record',
|
targetType: 'learning_record',
|
||||||
detail: JSON.stringify(dto),
|
detail: JSON.stringify(dto),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -262,15 +263,15 @@ export class ArchiveController {
|
|||||||
|
|
||||||
@Delete('learning-records/:id')
|
@Delete('learning-records/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async deleteLearningRecord(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.deleteLearningRecord(+id);
|
const result = await this.archiveService.deleteLearningRecord(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '删除学习记录',
|
action: '删除学习记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'learning_record',
|
targetType: 'learning_record',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -281,18 +282,18 @@ export class ArchiveController {
|
|||||||
@Put(':studentId/result')
|
@Put(':studentId/result')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async upsertResult(
|
async upsertResult(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Body() dto: UpsertResultDto,
|
@Body() dto: UpsertResultDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.upsertResult(+studentId, dto);
|
const result = await this.archiveService.upsertResult(studentId, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '更新录取结果',
|
action: '更新录取结果',
|
||||||
targetId: +studentId,
|
targetId: studentId,
|
||||||
targetType: 'result_archive',
|
targetType: 'result_archive',
|
||||||
detail: JSON.stringify(dto),
|
detail: JSON.stringify(dto),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -305,13 +306,13 @@ export class ArchiveController {
|
|||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
async uploadAttachment(
|
async uploadAttachment(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@Body('category') category: string,
|
@Body('category') category: string,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.addAttachment(+studentId, file, category || 'other');
|
const result = await this.archiveService.addAttachment(studentId, file, category || 'other');
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
@@ -329,13 +330,13 @@ export class ArchiveController {
|
|||||||
@Get(':studentId/attachments/:id')
|
@Get(':studentId/attachments/:id')
|
||||||
@RequirePermission('student:view')
|
@RequirePermission('student:view')
|
||||||
async downloadAttachment(
|
async downloadAttachment(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(
|
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(
|
||||||
+studentId,
|
studentId,
|
||||||
+id,
|
id,
|
||||||
);
|
);
|
||||||
res.setHeader('Content-Type', mimeType);
|
res.setHeader('Content-Type', mimeType);
|
||||||
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`);
|
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`);
|
||||||
@@ -345,15 +346,15 @@ export class ArchiveController {
|
|||||||
|
|
||||||
@Delete('attachments/:id')
|
@Delete('attachments/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async deleteAttachment(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.deleteAttachment(+id);
|
const result = await this.archiveService.deleteAttachment(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '删除附件',
|
action: '删除附件',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'archive_attachment',
|
targetType: 'archive_attachment',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -364,7 +365,7 @@ export class ArchiveController {
|
|||||||
@Get(':studentId/report-html')
|
@Get(':studentId/report-html')
|
||||||
@RequirePermission('student:view')
|
@RequirePermission('student:view')
|
||||||
async generateReportHtml(
|
async generateReportHtml(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
@@ -373,12 +374,12 @@ export class ArchiveController {
|
|||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: 'archive',
|
module: 'archive',
|
||||||
action: 'generate_report_html',
|
action: 'generate_report_html',
|
||||||
targetId: +studentId,
|
targetId: studentId,
|
||||||
targetType: 'student',
|
targetType: 'student',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
});
|
});
|
||||||
const html = await this.reportService.generateReportHtml(+studentId);
|
const html = await this.reportService.generateReportHtml(studentId);
|
||||||
return { html };
|
return { html };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ describe('ArchiveService.getProfile', () => {
|
|||||||
const learningRecordRepo = { find: jest.fn().mockResolvedValue([]) };
|
const learningRecordRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
const resultRepo = { findOne: jest.fn().mockResolvedValue(result) };
|
const resultRepo = { findOne: jest.fn().mockResolvedValue(result) };
|
||||||
const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
|
const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
const attendanceRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
|
||||||
const service = new ArchiveService(
|
const service = new ArchiveService(
|
||||||
studentRepo as never,
|
studentRepo as never,
|
||||||
@@ -26,12 +27,13 @@ describe('ArchiveService.getProfile', () => {
|
|||||||
learningRecordRepo as never,
|
learningRecordRepo as never,
|
||||||
resultRepo as never,
|
resultRepo as never,
|
||||||
attachmentRepo as never,
|
attachmentRepo as never,
|
||||||
|
attendanceRepo as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await service.getProfile(7);
|
const response = await service.getProfile(7);
|
||||||
|
|
||||||
expect(response).toMatchObject({ student, result });
|
expect(response).toMatchObject({ student, result, attendances: [] });
|
||||||
expect(response).not.toHaveProperty('resultArchive');
|
expect(response).not.toHaveProperty('resultArchive');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ExamScore } from '../entities/exam-score.entity';
|
|||||||
import { LearningRecord } from '../entities/learning-record.entity';
|
import { LearningRecord } from '../entities/learning-record.entity';
|
||||||
import { ResultArchive } from '../entities/result-archive.entity';
|
import { ResultArchive } from '../entities/result-archive.entity';
|
||||||
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
|
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
|
||||||
|
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||||
import {
|
import {
|
||||||
UpsertProfileDto,
|
UpsertProfileDto,
|
||||||
CreateEnrollmentDto,
|
CreateEnrollmentDto,
|
||||||
@@ -33,6 +34,7 @@ export class ArchiveService {
|
|||||||
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
|
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
|
||||||
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
|
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
|
||||||
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
|
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
|
||||||
|
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||||||
private readonly notificationsService: NotificationsService,
|
private readonly notificationsService: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ export class ArchiveService {
|
|||||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||||
if (!student) throw new NotFoundException('学生不存在');
|
if (!student) throw new NotFoundException('学生不存在');
|
||||||
|
|
||||||
const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments] =
|
const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments, attendances] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.profileRepo.findOne({ where: { studentId } }),
|
this.profileRepo.findOne({ where: { studentId } }),
|
||||||
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||||
@@ -67,6 +69,11 @@ export class ArchiveService {
|
|||||||
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||||
this.resultRepo.findOne({ where: { studentId } }),
|
this.resultRepo.findOne({ where: { studentId } }),
|
||||||
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||||
|
this.attendanceRepo.find({
|
||||||
|
where: { studentId },
|
||||||
|
relations: ['schedule', 'class'],
|
||||||
|
order: { attendanceDate: 'DESC', punchTime: 'DESC' },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -77,6 +84,7 @@ export class ArchiveService {
|
|||||||
learningRecords,
|
learningRecords,
|
||||||
result: resultArchive,
|
result: resultArchive,
|
||||||
attachments,
|
attachments,
|
||||||
|
attendances,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ const createService = () => {
|
|||||||
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||||
};
|
};
|
||||||
const attendanceService = {
|
const attendanceService = {
|
||||||
|
getLessonAttendanceImportDateRange: jest.fn().mockImplementation((targetSchedule, lessonDate: string) => {
|
||||||
|
if (targetSchedule.endTime > targetSchedule.startTime) {
|
||||||
|
return { startDate: lessonDate, endDate: lessonDate };
|
||||||
|
}
|
||||||
|
const next = new Date(`${lessonDate}T00:00:00.000Z`);
|
||||||
|
next.setUTCDate(next.getUTCDate() + 1);
|
||||||
|
return { startDate: lessonDate, endDate: next.toISOString().slice(0, 10) };
|
||||||
|
}),
|
||||||
getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']),
|
getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']),
|
||||||
createLessonAttendanceFromDingTalk: jest.fn().mockImplementation(
|
createLessonAttendanceFromDingTalk: jest.fn().mockImplementation(
|
||||||
async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({
|
async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({
|
||||||
|
|||||||
@@ -110,9 +110,12 @@ export class AttendanceSettlementService {
|
|||||||
schedule.teacherId,
|
schedule.teacherId,
|
||||||
schedule.classId,
|
schedule.classId,
|
||||||
);
|
);
|
||||||
|
const importRange = this.attendanceService.getLessonAttendanceImportDateRange(
|
||||||
|
schedule,
|
||||||
|
lessonDate,
|
||||||
|
);
|
||||||
const imported = await this.importService.importFromDingTalk({
|
const imported = await this.importService.importFromDingTalk({
|
||||||
startDate: lessonDate,
|
...importRange,
|
||||||
endDate: this.isOvernight(schedule) ? this.shiftDate(lessonDate, 1) : lessonDate,
|
|
||||||
userIds,
|
userIds,
|
||||||
autoMatch: true,
|
autoMatch: true,
|
||||||
userId: schedule.teacherId,
|
userId: schedule.teacherId,
|
||||||
|
|||||||
@@ -162,6 +162,9 @@ describe('AttendanceController — write data scope', () => {
|
|||||||
assertClassAccess: jest.fn(),
|
assertClassAccess: jest.fn(),
|
||||||
getAccessibleClassIds: jest.fn(),
|
getAccessibleClassIds: jest.fn(),
|
||||||
getTeacherClassDingUserIds: jest.fn(),
|
getTeacherClassDingUserIds: jest.fn(),
|
||||||
|
getLessonAttendanceImportDateRange: jest.fn().mockImplementation(
|
||||||
|
(_schedule, lessonDate: string) => ({ startDate: lessonDate, endDate: lessonDate }),
|
||||||
|
),
|
||||||
batchCreate: jest.fn(),
|
batchCreate: jest.fn(),
|
||||||
generateFromSchedules: jest.fn(),
|
generateFromSchedules: jest.fn(),
|
||||||
findAttendanceRecord: jest.fn(),
|
findAttendanceRecord: jest.fn(),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Res,
|
Res,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Observable, filter } from 'rxjs';
|
import { Observable, filter } from 'rxjs';
|
||||||
import type { Request as ExpressRequest, Response } from 'express';
|
import type { Request as ExpressRequest, Response } from 'express';
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
QueryDingRawDto,
|
QueryDingRawDto,
|
||||||
MatchDingRecordDto,
|
MatchDingRecordDto,
|
||||||
AttendanceReportQueryDto,
|
AttendanceReportQueryDto,
|
||||||
|
AttendanceAlertsQueryDto,
|
||||||
UpdateAttendanceRecordDto,
|
UpdateAttendanceRecordDto,
|
||||||
GenerateFromSchedulesDto,
|
GenerateFromSchedulesDto,
|
||||||
LessonAttendanceQueryDto,
|
LessonAttendanceQueryDto,
|
||||||
@@ -93,11 +95,11 @@ export class AttendanceController {
|
|||||||
@Get('attendance-lessons/schedules/:scheduleId')
|
@Get('attendance-lessons/schedules/:scheduleId')
|
||||||
@RequirePermission('attendance:view')
|
@RequirePermission('attendance:view')
|
||||||
async getLessonAttendance(
|
async getLessonAttendance(
|
||||||
@Param('scheduleId') scheduleId: string,
|
@Param('scheduleId', ParseIntPipe) scheduleId: number,
|
||||||
@Query() query: LessonAttendanceQueryDto,
|
@Query() query: LessonAttendanceQueryDto,
|
||||||
@Request() req: { user: RequestUser },
|
@Request() req: { user: RequestUser },
|
||||||
) {
|
) {
|
||||||
const result = await this.service.getLessonAttendance(+scheduleId, query.date);
|
const result = await this.service.getLessonAttendance(scheduleId, query.date);
|
||||||
await this.assertClassAccess(req, result.schedule.classId!);
|
await this.assertClassAccess(req, result.schedule.classId!);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -105,26 +107,29 @@ export class AttendanceController {
|
|||||||
@Post('attendance-lessons/schedules/:scheduleId/pull')
|
@Post('attendance-lessons/schedules/:scheduleId/pull')
|
||||||
@RequirePermission('attendance:create')
|
@RequirePermission('attendance:create')
|
||||||
async pullLessonAttendance(
|
async pullLessonAttendance(
|
||||||
@Param('scheduleId') scheduleId: string,
|
@Param('scheduleId', ParseIntPipe) scheduleId: number,
|
||||||
@Body() dto: StartLessonAttendanceDto,
|
@Body() dto: StartLessonAttendanceDto,
|
||||||
@Request() req: { user: RequestUser },
|
@Request() req: { user: RequestUser },
|
||||||
) {
|
) {
|
||||||
const schedule = await this.service.getLessonAttendance(+scheduleId, dto.date);
|
const schedule = await this.service.getLessonAttendance(scheduleId, dto.date);
|
||||||
await this.assertClassAccess(req, schedule.schedule.classId!);
|
await this.assertClassAccess(req, schedule.schedule.classId!);
|
||||||
const importClassIds = await this.service.getTeacherClassDingUserIds(
|
const importClassIds = await this.service.getTeacherClassDingUserIds(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
schedule.schedule.classId!,
|
schedule.schedule.classId!,
|
||||||
this.canManageAllAttendance(req),
|
this.canManageAllAttendance(req),
|
||||||
);
|
);
|
||||||
|
const importRange = this.service.getLessonAttendanceImportDateRange(
|
||||||
|
schedule.schedule,
|
||||||
|
dto.date,
|
||||||
|
);
|
||||||
const importResult = await this.importService.importFromDingTalk({
|
const importResult = await this.importService.importFromDingTalk({
|
||||||
startDate: dto.date,
|
...importRange,
|
||||||
endDate: dto.date,
|
|
||||||
userIds: importClassIds,
|
userIds: importClassIds,
|
||||||
autoMatch: true,
|
autoMatch: true,
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
});
|
});
|
||||||
const result = await this.service.createLessonAttendanceFromDingTalk(
|
const result = await this.service.createLessonAttendanceFromDingTalk(
|
||||||
+scheduleId,
|
scheduleId,
|
||||||
dto.date,
|
dto.date,
|
||||||
req.user.id,
|
req.user.id,
|
||||||
);
|
);
|
||||||
@@ -143,18 +148,18 @@ export class AttendanceController {
|
|||||||
@Post('attendance-lessons/:sessionId/complete')
|
@Post('attendance-lessons/:sessionId/complete')
|
||||||
@RequirePermission('attendance:create')
|
@RequirePermission('attendance:create')
|
||||||
async completeLessonAttendance(
|
async completeLessonAttendance(
|
||||||
@Param('sessionId') sessionId: string,
|
@Param('sessionId', ParseIntPipe) sessionId: number,
|
||||||
@Request() req: { user: RequestUser },
|
@Request() req: { user: RequestUser },
|
||||||
) {
|
) {
|
||||||
const session = await this.service.findAttendanceSession(+sessionId);
|
const session = await this.service.findAttendanceSession(sessionId);
|
||||||
await this.assertClassAccess(req, session.classId);
|
await this.assertClassAccess(req, session.classId);
|
||||||
const result = await this.service.completeLessonAttendance(+sessionId, req.user.id);
|
const result = await this.service.completeLessonAttendance(sessionId, req.user.id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
username: req.user.username,
|
username: req.user.username,
|
||||||
module: '考勤管理',
|
module: '考勤管理',
|
||||||
action: '完成课程点名',
|
action: '完成课程点名',
|
||||||
targetId: +sessionId,
|
targetId: sessionId,
|
||||||
targetType: 'attendanceSession',
|
targetType: 'attendanceSession',
|
||||||
detail: `班级${session.classId} 日期${session.lessonDate}`,
|
detail: `班级${session.classId} 日期${session.lessonDate}`,
|
||||||
});
|
});
|
||||||
@@ -278,23 +283,23 @@ export class AttendanceController {
|
|||||||
@Put('attendance-records/:id')
|
@Put('attendance-records/:id')
|
||||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||||
async update(
|
async update(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateAttendanceRecordDto,
|
@Body() dto: UpdateAttendanceRecordDto,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const existing = await this.service.findAttendanceRecord(+id);
|
const existing = await this.service.findAttendanceRecord(id);
|
||||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||||
throw new ForbiddenException('无权修改未关联班级的考勤记录');
|
throw new ForbiddenException('无权修改未关联班级的考勤记录');
|
||||||
}
|
}
|
||||||
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
||||||
const result = await this.service.update(+id, dto);
|
const result = await this.service.update(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '考勤管理',
|
module: '考勤管理',
|
||||||
action: '编辑考勤记录',
|
action: '编辑考勤记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'attendanceRecord',
|
targetType: 'attendanceRecord',
|
||||||
detail: `状态=${result.status}, 备注=${result.remark || ''}`,
|
detail: `状态=${result.status}, 备注=${result.remark || ''}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -306,20 +311,20 @@ export class AttendanceController {
|
|||||||
// ── Delete a single attendance record ──
|
// ── Delete a single attendance record ──
|
||||||
@Delete('attendance-records/:id')
|
@Delete('attendance-records/:id')
|
||||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||||
async remove(@Param('id') id: string, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const existing = await this.service.findAttendanceRecord(+id);
|
const existing = await this.service.findAttendanceRecord(id);
|
||||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||||
throw new ForbiddenException('无权删除未关联班级的考勤记录');
|
throw new ForbiddenException('无权删除未关联班级的考勤记录');
|
||||||
}
|
}
|
||||||
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
||||||
const result = await this.service.remove(+id);
|
const result = await this.service.remove(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '考勤管理',
|
module: '考勤管理',
|
||||||
action: '删除考勤记录',
|
action: '删除考勤记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'attendanceRecord',
|
targetType: 'attendanceRecord',
|
||||||
detail: `删除考勤记录 ${id}`,
|
detail: `删除考勤记录 ${id}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -369,18 +374,18 @@ export class AttendanceController {
|
|||||||
@Post('ding-attendance-raw/:id/match')
|
@Post('ding-attendance-raw/:id/match')
|
||||||
@RequirePermission('attendance:edit')
|
@RequirePermission('attendance:edit')
|
||||||
async matchDingRecord(
|
async matchDingRecord(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: MatchDingRecordDto,
|
@Body() dto: MatchDingRecordDto,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.matchDingRecord(+id, dto);
|
const result = await this.service.matchDingRecord(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '考勤管理',
|
module: '考勤管理',
|
||||||
action: '匹配考勤记录',
|
action: '匹配考勤记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'dingAttendanceRaw',
|
targetType: 'dingAttendanceRaw',
|
||||||
detail: `匹配到学生 ${dto.studentId}`,
|
detail: `匹配到学生 ${dto.studentId}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -458,12 +463,11 @@ export class AttendanceController {
|
|||||||
@RequirePermission('attendance:view')
|
@RequirePermission('attendance:view')
|
||||||
async getAlerts(
|
async getAlerts(
|
||||||
@Request() req: { user: RequestUser },
|
@Request() req: { user: RequestUser },
|
||||||
@Query('days') days?: string,
|
@Query() query: AttendanceAlertsQueryDto,
|
||||||
@Query('threshold') threshold?: string,
|
|
||||||
) {
|
) {
|
||||||
return this.service.getAlerts(
|
return this.service.getAlerts(
|
||||||
days ? +days : 14,
|
query.days ?? 14,
|
||||||
threshold ? +threshold : 3,
|
query.threshold ?? 3,
|
||||||
await this.getAccessibleClassIds(req),
|
await this.getAccessibleClassIds(req),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const endedSchedule = {
|
|||||||
subject: '\u6570\u5B66',
|
subject: '\u6570\u5B66',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
scheduleType: 'INTERNAL',
|
scheduleType: 'INTERNAL',
|
||||||
|
attendanceAdvanceMinutes: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||||
@@ -186,6 +187,40 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts both OnDuty and OffDuty punches only inside the configured window', async () => {
|
||||||
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||||
|
createService();
|
||||||
|
scheduleRepo.findOne.mockResolvedValue({ ...endedSchedule, attendanceAdvanceMinutes: 20 });
|
||||||
|
sessionRepo.findOne.mockResolvedValue(null);
|
||||||
|
classStudentRepo.find.mockResolvedValue([
|
||||||
|
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||||
|
{ studentId: 2, student: { id: 2, name: '李四' } },
|
||||||
|
{ studentId: 3, student: { id: 3, name: '王五' } },
|
||||||
|
]);
|
||||||
|
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: 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);
|
||||||
|
|
||||||
|
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ studentId: 1, status: 'present' }),
|
||||||
|
expect.objectContaining({ studentId: 2, status: 'present' }),
|
||||||
|
expect.objectContaining({ studentId: 3, status: 'absent' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands import dates when the pre-class window crosses midnight', () => {
|
||||||
|
const { service } = createService();
|
||||||
|
expect(service.getLessonAttendanceImportDateRange(
|
||||||
|
{ startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 },
|
||||||
|
'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 () => {
|
||||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||||
createService();
|
createService();
|
||||||
|
|||||||
@@ -175,24 +175,45 @@ export class AttendanceService {
|
|||||||
return { schedule, session, records };
|
return { schedule, session, records };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getLessonAttendanceWindow(
|
||||||
|
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||||
|
lessonDate: string,
|
||||||
|
): { start: number; end: number; dateFrom: string; dateTo: string } {
|
||||||
|
const startMinuteOfDay = this.toMinutes(schedule.startTime);
|
||||||
|
const endMinuteOfDay = this.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 ? this.shiftDate(lessonDate, -1) : lessonDate,
|
||||||
|
dateTo: overnight ? this.shiftDate(lessonDate, 1) : lessonDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getLessonAttendanceImportDateRange(
|
||||||
|
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||||
|
lessonDate: string,
|
||||||
|
): { startDate: string; endDate: string } {
|
||||||
|
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
|
||||||
|
return { startDate: window.dateFrom, endDate: window.dateTo };
|
||||||
|
}
|
||||||
|
|
||||||
private selectDingTalkRecordsForLesson(
|
private selectDingTalkRecordsForLesson(
|
||||||
records: DingAttendanceRaw[],
|
records: DingAttendanceRaw[],
|
||||||
|
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||||
lessonDate: string,
|
lessonDate: string,
|
||||||
startTime: string,
|
|
||||||
endTime: string,
|
|
||||||
): DingAttendanceRaw[] {
|
): DingAttendanceRaw[] {
|
||||||
const [startHour, startMinute] = startTime.split(':').map(Number);
|
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
|
||||||
const [endHour, endMinute] = endTime.split(':').map(Number);
|
return records.filter((record) => {
|
||||||
const start = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
|
// 上班、下班打卡都有效,按原始记录中实际存在的时间判断。
|
||||||
let end = new Date(`${lessonDate}T${endTime}:00+08:00`).getTime();
|
|
||||||
if (endHour * 60 + endMinute <= startHour * 60 + startMinute) end += 24 * 60 * 60 * 1000;
|
|
||||||
const windowStart = start - 3 * 60 * 60 * 1000;
|
|
||||||
const windowEnd = end + 3 * 60 * 60 * 1000;
|
|
||||||
const timed = records.filter((record) => {
|
|
||||||
const time = record.checkInTime ?? record.checkOutTime;
|
const time = record.checkInTime ?? record.checkOutTime;
|
||||||
return time && time.getTime() >= windowStart && time.getTime() <= windowEnd;
|
return time && time.getTime() >= window.start && time.getTime() <= window.end;
|
||||||
});
|
});
|
||||||
return timed.length > 0 ? timed : records.filter((record) => !record.checkInTime && !record.checkOutTime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
|
private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
|
||||||
@@ -291,7 +312,7 @@ export class AttendanceService {
|
|||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const sessionRepo = manager.getRepository(AttendanceSession);
|
const sessionRepo = manager.getRepository(AttendanceSession);
|
||||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
|
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
|
||||||
const existingRecords = await recordRepo.find({
|
const existingRecords = await recordRepo.find({
|
||||||
where: { attendanceSessionId: existing.id },
|
where: { attendanceSessionId: existing.id },
|
||||||
order: { studentId: 'ASC' },
|
order: { studentId: 'ASC' },
|
||||||
@@ -312,9 +333,8 @@ export class AttendanceService {
|
|||||||
|
|
||||||
const raw = this.selectDingTalkRecordsForLesson(
|
const raw = this.selectDingTalkRecordsForLesson(
|
||||||
rawByStudent.get(record.studentId) ?? [],
|
rawByStudent.get(record.studentId) ?? [],
|
||||||
|
schedule,
|
||||||
lessonDate,
|
lessonDate,
|
||||||
schedule.startTime,
|
|
||||||
schedule.endTime,
|
|
||||||
);
|
);
|
||||||
record.status = this.mapDingTalkStatus(raw, finalize);
|
record.status = this.mapDingTalkStatus(raw, finalize);
|
||||||
Object.assign(record, this.getLessonPunchMetadata(
|
Object.assign(record, this.getLessonPunchMetadata(
|
||||||
@@ -333,9 +353,8 @@ export class AttendanceService {
|
|||||||
if (existingStudentIds.has(classStudent.studentId)) continue;
|
if (existingStudentIds.has(classStudent.studentId)) continue;
|
||||||
const raw = this.selectDingTalkRecordsForLesson(
|
const raw = this.selectDingTalkRecordsForLesson(
|
||||||
rawByStudent.get(classStudent.studentId) ?? [],
|
rawByStudent.get(classStudent.studentId) ?? [],
|
||||||
|
schedule,
|
||||||
lessonDate,
|
lessonDate,
|
||||||
schedule.startTime,
|
|
||||||
schedule.endTime,
|
|
||||||
);
|
);
|
||||||
updatedRecords.push(
|
updatedRecords.push(
|
||||||
recordRepo.create({
|
recordRepo.create({
|
||||||
@@ -377,7 +396,7 @@ export class AttendanceService {
|
|||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const sessionRepo = manager.getRepository(AttendanceSession);
|
const sessionRepo = manager.getRepository(AttendanceSession);
|
||||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
|
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
|
||||||
|
|
||||||
const classStudents = await this.classStudentRepo.find({
|
const classStudents = await this.classStudentRepo.find({
|
||||||
where: { classId: schedule.classId!, status: 'active' },
|
where: { classId: schedule.classId!, status: 'active' },
|
||||||
@@ -421,9 +440,8 @@ export class AttendanceService {
|
|||||||
const records = classStudents.map((classStudent) => {
|
const records = classStudents.map((classStudent) => {
|
||||||
const raw = this.selectDingTalkRecordsForLesson(
|
const raw = this.selectDingTalkRecordsForLesson(
|
||||||
rawByStudent.get(classStudent.studentId) ?? [],
|
rawByStudent.get(classStudent.studentId) ?? [],
|
||||||
|
schedule,
|
||||||
lessonDate,
|
lessonDate,
|
||||||
schedule.startTime,
|
|
||||||
schedule.endTime,
|
|
||||||
);
|
);
|
||||||
return recordRepo.create({
|
return recordRepo.create({
|
||||||
studentId: classStudent.studentId,
|
studentId: classStudent.studentId,
|
||||||
@@ -460,6 +478,7 @@ export class AttendanceService {
|
|||||||
|
|
||||||
private async fetchDingTalkRawByStudent(
|
private async fetchDingTalkRawByStudent(
|
||||||
classId: number,
|
classId: number,
|
||||||
|
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||||
lessonDate: string,
|
lessonDate: string,
|
||||||
): Promise<Map<number, DingAttendanceRaw[]>> {
|
): Promise<Map<number, DingAttendanceRaw[]>> {
|
||||||
const classStudents = await this.classStudentRepo.find({
|
const classStudents = await this.classStudentRepo.find({
|
||||||
@@ -467,9 +486,10 @@ export class AttendanceService {
|
|||||||
});
|
});
|
||||||
if (classStudents.length === 0) return new Map();
|
if (classStudents.length === 0) return new Map();
|
||||||
const studentIds = classStudents.map((cs) => cs.studentId);
|
const studentIds = classStudents.map((cs) => cs.studentId);
|
||||||
|
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
|
||||||
const rawRecords = await this.dingRawRepo.find({
|
const rawRecords = await this.dingRawRepo.find({
|
||||||
where: {
|
where: {
|
||||||
attendanceDate: lessonDate,
|
attendanceDate: Between(window.dateFrom, window.dateTo),
|
||||||
matchedStudentId: In(studentIds),
|
matchedStudentId: In(studentIds),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -651,6 +671,17 @@ export class AttendanceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toMinutes(time: string): number {
|
||||||
|
const [hour, minute] = time.split(':').map(Number);
|
||||||
|
return hour * 60 + 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 mapScheduleTimeToSession(startTime: string): string {
|
private mapScheduleTimeToSession(startTime: string): string {
|
||||||
const hour = parseInt(startTime.slice(0, 2), 10);
|
const hour = parseInt(startTime.slice(0, 2), 10);
|
||||||
if (hour < 8) return 'morning_reading';
|
if (hour < 8) return 'morning_reading';
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
IsIn,
|
IsIn,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
ArrayNotEmpty,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
@@ -44,6 +47,7 @@ export class AttendanceRecordItem {
|
|||||||
|
|
||||||
export class BatchCreateAttendanceDto {
|
export class BatchCreateAttendanceDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => AttendanceRecordItem)
|
@Type(() => AttendanceRecordItem)
|
||||||
records: AttendanceRecordItem[];
|
records: AttendanceRecordItem[];
|
||||||
@@ -96,11 +100,14 @@ export class QueryDingRawDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
@@ -139,11 +146,14 @@ export class QueryAttendanceRecordsDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
@@ -165,6 +175,22 @@ export class UpdateAttendanceRecordDto {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class AttendanceAlertsQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(365)
|
||||||
|
days?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
threshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class AttendanceReportQueryDto {
|
export class AttendanceReportQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Request,
|
Request,
|
||||||
Res,
|
Res,
|
||||||
Req,
|
Req,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, In } from 'typeorm';
|
import { Repository, In } from 'typeorm';
|
||||||
@@ -75,38 +76,38 @@ export class BillsController {
|
|||||||
findAll(
|
findAll(
|
||||||
@Query('periodStart') periodStart?: string,
|
@Query('periodStart') periodStart?: string,
|
||||||
@Query('periodEnd') periodEnd?: string,
|
@Query('periodEnd') periodEnd?: string,
|
||||||
@Query('studentId') studentId?: string,
|
@Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Query('expenseType') expenseType?: string,
|
@Query('expenseType') expenseType?: string,
|
||||||
) {
|
) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
periodStart, periodEnd,
|
periodStart, periodEnd,
|
||||||
studentId: studentId ? +studentId : undefined,
|
studentId,
|
||||||
status, expenseType,
|
status, expenseType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePermission('bill:view')
|
@RequirePermission('bill:view')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.service.findOne(+id);
|
return this.service.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/status')
|
@Put(':id/status')
|
||||||
@RequirePermission('bill:confirm')
|
@RequirePermission('bill:confirm')
|
||||||
async updateStatus(
|
async updateStatus(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateBillStatusDto,
|
@Body() dto: UpdateBillStatusDto,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.updateStatus(+id, dto);
|
const result = await this.service.updateStatus(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '账单管理',
|
module: '账单管理',
|
||||||
action: '确认账单',
|
action: '确认账单',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'bill',
|
targetType: 'bill',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -160,15 +161,15 @@ export class BillsController {
|
|||||||
|
|
||||||
@Post(':id/cancel')
|
@Post(':id/cancel')
|
||||||
@RequirePermission('bill:delete')
|
@RequirePermission('bill:delete')
|
||||||
async cancel(@Param('id') id: string, @Body() dto: CancelBillDto, @Request() req: any) {
|
async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: any) {
|
||||||
const result = await this.service.cancel(+id, dto, req.user?.id);
|
const result = await this.service.cancel(id, dto, req.user?.id);
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '账单管理',
|
module: '账单管理',
|
||||||
action: '取消账单并冲正',
|
action: '取消账单并冲正',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'bill',
|
targetType: 'bill',
|
||||||
detail: dto.reason,
|
detail: dto.reason,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -179,15 +180,15 @@ export class BillsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('bill:delete')
|
@RequirePermission('bill:delete')
|
||||||
async remove(@Param('id') id: string, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.remove(+id);
|
const result = await this.service.remove(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '账单管理',
|
module: '账单管理',
|
||||||
action: '删除账单',
|
action: '删除账单',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'bill',
|
targetType: 'bill',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -217,7 +218,7 @@ export class BillsController {
|
|||||||
async exportExcel(
|
async exportExcel(
|
||||||
@Query('periodStart') periodStart?: string,
|
@Query('periodStart') periodStart?: string,
|
||||||
@Query('periodEnd') periodEnd?: string,
|
@Query('periodEnd') periodEnd?: string,
|
||||||
@Query('studentId') studentId?: string,
|
@Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Res() res?: Response,
|
@Res() res?: Response,
|
||||||
@Req() req?: any,
|
@Req() req?: any,
|
||||||
@@ -236,7 +237,7 @@ export class BillsController {
|
|||||||
{
|
{
|
||||||
periodStart,
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
studentId: studentId ? +studentId : undefined,
|
studentId,
|
||||||
status,
|
status,
|
||||||
},
|
},
|
||||||
res!,
|
res!,
|
||||||
@@ -245,18 +246,18 @@ export class BillsController {
|
|||||||
|
|
||||||
@Get('export/pdf/:id')
|
@Get('export/pdf/:id')
|
||||||
@RequirePermission('bill:export-pdf')
|
@RequirePermission('bill:export-pdf')
|
||||||
async exportPdf(@Param('id') id: string, @Res() res: Response, @Req() req: any) {
|
async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req?.user?.id,
|
userId: req?.user?.id,
|
||||||
username: req?.user?.username,
|
username: req?.user?.username,
|
||||||
module: '账单管理',
|
module: '账单管理',
|
||||||
action: '导出账单',
|
action: '导出账单',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'bill',
|
targetType: 'bill',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
});
|
});
|
||||||
return this.exportService.exportStudentPdf(+id, res);
|
return this.exportService.exportStudentPdf(id, res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PersonalExpense } from '../entities/personal-expense.entity';
|
|||||||
import { Occupancy } from '../entities/occupancy.entity';
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
import { Room } from '../entities/room.entity';
|
import { Room } from '../entities/room.entity';
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
import { BillsService } from './bills.service';
|
import { BillsService } from './bills.service';
|
||||||
import { BillsExportService } from './bills-export.service';
|
import { BillsExportService } from './bills-export.service';
|
||||||
import { BillsController } from './bills.controller';
|
import { BillsController } from './bills.controller';
|
||||||
@@ -23,6 +24,7 @@ import { BillsController } from './bills.controller';
|
|||||||
Occupancy,
|
Occupancy,
|
||||||
Room,
|
Room,
|
||||||
Student,
|
Student,
|
||||||
|
Deposit,
|
||||||
]),
|
]),
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
WalletsModule,
|
WalletsModule,
|
||||||
|
|||||||
@@ -260,7 +260,11 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
const runner = this.dataSource.createQueryRunner();
|
const runner = this.dataSource.createQueryRunner();
|
||||||
await runner.connect();
|
await runner.connect();
|
||||||
try {
|
try {
|
||||||
const tables = await runner.getTables(['attendance_records', 'attendance_sessions']);
|
const tables = await runner.getTables([
|
||||||
|
'class_schedule',
|
||||||
|
'attendance_records',
|
||||||
|
'attendance_sessions',
|
||||||
|
]);
|
||||||
const tableNames = new Set(tables.map((table) => table.name));
|
const tableNames = new Set(tables.map((table) => table.name));
|
||||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
const isMySQL = this.dataSource.options.type === 'mysql';
|
||||||
|
|
||||||
@@ -287,6 +291,17 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tableNames.has('class_schedule')) {
|
||||||
|
const scheduleTable = await runner.getTable('class_schedule');
|
||||||
|
const scheduleColumns = new Set(scheduleTable?.columns.map((column) => column.name) ?? []);
|
||||||
|
if (!scheduleColumns.has('attendance_advance_minutes')) {
|
||||||
|
await runner.query(
|
||||||
|
'ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes INTEGER NOT NULL DEFAULT 30',
|
||||||
|
);
|
||||||
|
this.logger.log('已为排课添加课前签到分钟配置');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const attendanceTable = await runner.getTable('attendance_records');
|
const attendanceTable = await runner.getTable('attendance_records');
|
||||||
const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []);
|
const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []);
|
||||||
if (!columnNames.has('schedule_id')) {
|
if (!columnNames.has('schedule_id')) {
|
||||||
|
|||||||
@@ -209,6 +209,28 @@ describe('DatabaseMigrationsService — course attendance schema', () => {
|
|||||||
expect(runner.release).toHaveBeenCalled();
|
expect(runner.release).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds the configurable attendance window to existing schedules', async () => {
|
||||||
|
const runner = mockRunner({
|
||||||
|
getTables: [
|
||||||
|
{ name: 'class_schedule', columns: [{ name: 'id' }] },
|
||||||
|
{ name: 'attendance_records', columns: [{ name: 'id' }] },
|
||||||
|
{ name: 'attendance_sessions', columns: [{ name: 'id' }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
runner.getTable.mockImplementation(async (name: string) =>
|
||||||
|
name === 'class_schedule'
|
||||||
|
? { name, columns: [{ name: 'id' }] }
|
||||||
|
: { name, columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }] },
|
||||||
|
);
|
||||||
|
await bootstrapCourseAttendance(runner);
|
||||||
|
|
||||||
|
await service.ensureCourseAttendanceSchema();
|
||||||
|
|
||||||
|
expect(runner.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => {
|
it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => {
|
||||||
const runner = mockRunner({
|
const runner = mockRunner({
|
||||||
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],
|
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
@@ -16,7 +17,12 @@ import { Student } from '../entities/student.entity';
|
|||||||
import { DepositsService } from './deposits.service';
|
import { DepositsService } from './deposits.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationType } from '../entities/notification.entity';
|
import { NotificationType } from '../entities/notification.entity';
|
||||||
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
import {
|
||||||
|
CreateDepositDto,
|
||||||
|
CreateDepositInstallmentDto,
|
||||||
|
RefundDepositDto,
|
||||||
|
UpdateDepositInstallmentDto,
|
||||||
|
} from './dto/deposit.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
@@ -40,9 +46,12 @@ export class DepositsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermission('deposit:view')
|
@RequirePermission('deposit:view')
|
||||||
findAll(@Query('studentId') studentId?: string, @Query('status') status?: string) {
|
findAll(
|
||||||
|
@Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
studentId: studentId ? +studentId : undefined,
|
studentId,
|
||||||
status: status || undefined,
|
status: status || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,8 +64,8 @@ export class DepositsController {
|
|||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePermission('deposit:view')
|
@RequirePermission('deposit:view')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.service.findOne(+id);
|
return this.service.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -93,12 +102,12 @@ export class DepositsController {
|
|||||||
@Post(':id/installments')
|
@Post(':id/installments')
|
||||||
@RequirePermission('deposit:edit')
|
@RequirePermission('deposit:edit')
|
||||||
async addInstallment(
|
async addInstallment(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() body: { amount: number; dueDate: string },
|
@Body() body: CreateDepositInstallmentDto,
|
||||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.addInstallment(+id, body.amount, body.dueDate);
|
const result = await this.service.addInstallment(id, body.amount, body.dueDate);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
@@ -116,18 +125,18 @@ export class DepositsController {
|
|||||||
@Put('installments/:installmentId')
|
@Put('installments/:installmentId')
|
||||||
@RequirePermission('deposit:edit')
|
@RequirePermission('deposit:edit')
|
||||||
async updateInstallment(
|
async updateInstallment(
|
||||||
@Param('installmentId') installmentId: string,
|
@Param('installmentId', ParseIntPipe) installmentId: number,
|
||||||
@Body() body: { paidDate?: string; status?: string },
|
@Body() body: UpdateDepositInstallmentDto,
|
||||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.updateInstallment(+installmentId, body);
|
const result = await this.service.updateInstallment(installmentId, body);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '押金管理',
|
module: '押金管理',
|
||||||
action: '更新分期',
|
action: '更新分期',
|
||||||
targetId: +installmentId,
|
targetId: installmentId,
|
||||||
targetType: 'deposit-installment',
|
targetType: 'deposit-installment',
|
||||||
detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
|
detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -139,17 +148,17 @@ export class DepositsController {
|
|||||||
@Delete('installments/:installmentId')
|
@Delete('installments/:installmentId')
|
||||||
@RequirePermission('deposit:delete')
|
@RequirePermission('deposit:delete')
|
||||||
async deleteInstallment(
|
async deleteInstallment(
|
||||||
@Param('installmentId') installmentId: string,
|
@Param('installmentId', ParseIntPipe) installmentId: number,
|
||||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.deleteInstallment(+installmentId);
|
const result = await this.service.deleteInstallment(installmentId);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '押金管理',
|
module: '押金管理',
|
||||||
action: '删除分期',
|
action: '删除分期',
|
||||||
targetId: +installmentId,
|
targetId: installmentId,
|
||||||
targetType: 'deposit-installment',
|
targetType: 'deposit-installment',
|
||||||
detail: `删除分期${installmentId}`,
|
detail: `删除分期${installmentId}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -160,15 +169,15 @@ export class DepositsController {
|
|||||||
|
|
||||||
@Put(':id/refund')
|
@Put(':id/refund')
|
||||||
@RequirePermission('deposit:refund')
|
@RequirePermission('deposit:refund')
|
||||||
async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) {
|
async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.refund(+id, dto, req.user?.id);
|
const result = await this.service.refund(id, dto, req.user?.id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '押金管理',
|
module: '押金管理',
|
||||||
action: '退还押金',
|
action: '退还押金',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'deposit',
|
targetType: 'deposit',
|
||||||
detail: `退还全部可用押金 ¥${result.refundAmount}`,
|
detail: `退还全部可用押金 ¥${result.refundAmount}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -191,15 +200,15 @@ export class DepositsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('deposit:delete')
|
@RequirePermission('deposit:delete')
|
||||||
async remove(@Param('id') id: string, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.remove(+id);
|
const result = await this.service.remove(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '押金管理',
|
module: '押金管理',
|
||||||
action: '删除押金记录',
|
action: '删除押金记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'deposit',
|
targetType: 'deposit',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { DepositsService } from './deposits.service';
|
|||||||
import { Deposit } from '../entities/deposit.entity';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
|
|
||||||
describe('DepositsService — direct refund', () => {
|
describe('DepositsService — direct refund', () => {
|
||||||
it('stores the refund result on the main status and renamed audit fields', async () => {
|
it('refunds the full available balance and stores audit fields', async () => {
|
||||||
const deposit = {
|
const deposit = {
|
||||||
id: 1,
|
id: 1,
|
||||||
amount: 500,
|
amount: 500,
|
||||||
@@ -16,20 +16,16 @@ describe('DepositsService — direct refund', () => {
|
|||||||
|
|
||||||
const result = await service.refund(
|
const result = await service.refund(
|
||||||
1,
|
1,
|
||||||
{
|
{ refundDate: '2026-07-13', notes: '退还剩余押金' },
|
||||||
refundDate: '2026-07-13',
|
|
||||||
deductionAmount: 100,
|
|
||||||
deductionReason: '物品损坏',
|
|
||||||
},
|
|
||||||
42,
|
42,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
refundDate: '2026-07-13',
|
refundDate: '2026-07-13',
|
||||||
refundAmount: 400,
|
amount: 0,
|
||||||
deductionAmount: 100,
|
refundAmount: 500,
|
||||||
deductionReason: '物品损坏',
|
notes: '退还剩余押金',
|
||||||
status: 'partial_refund',
|
status: 'refunded',
|
||||||
refundedBy: 42,
|
refundedBy: 42,
|
||||||
});
|
});
|
||||||
expect(result.refundedAt).toBeInstanceOf(Date);
|
expect(result.refundedAt).toBeInstanceOf(Date);
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { IsInt, IsNumber, IsString, IsOptional } from 'class-validator';
|
import { IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator';
|
||||||
|
|
||||||
export class CreateDepositDto {
|
export class CreateDepositDto {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
studentId: number;
|
studentId: number;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0.01)
|
||||||
amount: number;
|
amount: number;
|
||||||
|
|
||||||
@IsString()
|
@IsDateString()
|
||||||
paidDate: string;
|
paidDate: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -16,10 +17,29 @@ export class CreateDepositDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class RefundDepositDto {
|
export class RefundDepositDto {
|
||||||
@IsString()
|
@IsDateString()
|
||||||
refundDate: string;
|
refundDate: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreateDepositInstallmentDto {
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0.01)
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
dueDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateDepositInstallmentDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
paidDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['pending', 'paid', 'overdue'])
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export class ClassSchedule {
|
|||||||
@Column({ name: 'end_time', length: 5 })
|
@Column({ name: 'end_time', length: 5 })
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
|
||||||
|
/** 课程开始前允许计入签到的分钟数。 */
|
||||||
|
@Column({ name: 'attendance_advance_minutes', type: 'integer', default: 30 })
|
||||||
|
attendanceAdvanceMinutes: number;
|
||||||
|
|
||||||
@Column({ name: 'start_date', type: 'date' })
|
@Column({ name: 'start_date', type: 'date' })
|
||||||
startDate: string;
|
startDate: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { IsIn, IsInt, IsString, IsNumber, IsOptional, Matches, Min } from 'class-validator';
|
import { IsDateString, IsIn, IsInt, IsString, IsNumber, IsOptional, Matches, Min } from 'class-validator';
|
||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class CreateRoomExpenseDto {
|
export class CreateRoomExpenseDto {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -7,13 +9,14 @@ export class CreateRoomExpenseDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
expenseType: string;
|
expenseType: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0.01)
|
||||||
amount: number;
|
amount: number;
|
||||||
|
|
||||||
@IsString()
|
@IsDateString()
|
||||||
periodStart: string;
|
periodStart: string;
|
||||||
|
|
||||||
@IsString()
|
@IsDateString()
|
||||||
periodEnd: string;
|
periodEnd: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -32,10 +35,11 @@ export class CreatePersonalExpenseDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
expenseType: string;
|
expenseType: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0.01)
|
||||||
amount: number;
|
amount: number;
|
||||||
|
|
||||||
@IsString()
|
@IsDateString()
|
||||||
expenseDate: string;
|
expenseDate: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -43,6 +47,33 @@ export class CreatePersonalExpenseDto {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class UpdateRoomExpenseDto extends PartialType(CreateRoomExpenseDto) {}
|
||||||
|
|
||||||
|
export class UpdatePersonalExpenseDto extends PartialType(CreatePersonalExpenseDto) {}
|
||||||
|
|
||||||
|
export class QueryRoomExpenseDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
roomId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
periodStart?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
periodEnd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueryPersonalExpenseDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
studentId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class BatchRoomExpenseDto {
|
export class BatchRoomExpenseDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
periodStart: string;
|
periodStart: string;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Res,
|
Res,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
@@ -21,6 +22,10 @@ import {
|
|||||||
CreatePersonalExpenseDto,
|
CreatePersonalExpenseDto,
|
||||||
BatchRoomExpenseDto,
|
BatchRoomExpenseDto,
|
||||||
CreateStudentUtilityBillDto,
|
CreateStudentUtilityBillDto,
|
||||||
|
QueryPersonalExpenseDto,
|
||||||
|
QueryRoomExpenseDto,
|
||||||
|
UpdatePersonalExpenseDto,
|
||||||
|
UpdateRoomExpenseDto,
|
||||||
} from './dto/expense.dto';
|
} from './dto/expense.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
@@ -136,29 +141,21 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Get('room')
|
@Get('room')
|
||||||
@RequirePermission('expense:view')
|
@RequirePermission('expense:view')
|
||||||
findRoomExpenses(
|
findRoomExpenses(@Query() query: QueryRoomExpenseDto) {
|
||||||
@Query('roomId') roomId?: string,
|
return this.service.findRoomExpenses(query);
|
||||||
@Query('periodStart') periodStart?: string,
|
|
||||||
@Query('periodEnd') periodEnd?: string,
|
|
||||||
) {
|
|
||||||
return this.service.findRoomExpenses({
|
|
||||||
roomId: roomId ? +roomId : undefined,
|
|
||||||
periodStart,
|
|
||||||
periodEnd,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('room/:id')
|
@Delete('room/:id')
|
||||||
@RequirePermission('expense:delete')
|
@RequirePermission('expense:delete')
|
||||||
async deleteRoomExpense(@Param('id') id: string, @Request() req: any) {
|
async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.deleteRoomExpense(+id);
|
const result = await this.service.deleteRoomExpense(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '费用管理',
|
module: '费用管理',
|
||||||
action: '删除费用',
|
action: '删除费用',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'room_expense',
|
targetType: 'room_expense',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -186,18 +183,18 @@ export class ExpensesController {
|
|||||||
@Put('room/:id')
|
@Put('room/:id')
|
||||||
@RequirePermission('expense:edit')
|
@RequirePermission('expense:edit')
|
||||||
async updateRoomExpense(
|
async updateRoomExpense(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: CreateRoomExpenseDto,
|
@Body() dto: UpdateRoomExpenseDto,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.updateRoomExpense(+id, dto);
|
const result = await this.service.updateRoomExpense(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '费用管理',
|
module: '费用管理',
|
||||||
action: '编辑费用',
|
action: '编辑费用',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'room_expense',
|
targetType: 'room_expense',
|
||||||
detail: `¥${dto.amount} ${dto.expenseType}`,
|
detail: `¥${dto.amount} ${dto.expenseType}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -225,21 +222,21 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Get('personal')
|
@Get('personal')
|
||||||
@RequirePermission('expense:view')
|
@RequirePermission('expense:view')
|
||||||
findPersonalExpenses(@Query('studentId') studentId?: string) {
|
findPersonalExpenses(@Query() query: QueryPersonalExpenseDto) {
|
||||||
return this.service.findPersonalExpenses({ studentId: studentId ? +studentId : undefined });
|
return this.service.findPersonalExpenses(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('personal/:id')
|
@Delete('personal/:id')
|
||||||
@RequirePermission('expense:delete')
|
@RequirePermission('expense:delete')
|
||||||
async deletePersonalExpense(@Param('id') id: string, @Request() req: any) {
|
async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.deletePersonalExpense(+id);
|
const result = await this.service.deletePersonalExpense(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '费用管理',
|
module: '费用管理',
|
||||||
action: '删除费用',
|
action: '删除费用',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
});
|
});
|
||||||
@@ -266,18 +263,18 @@ export class ExpensesController {
|
|||||||
@Put('personal/:id')
|
@Put('personal/:id')
|
||||||
@RequirePermission('expense:edit')
|
@RequirePermission('expense:edit')
|
||||||
async updatePersonalExpense(
|
async updatePersonalExpense(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: CreatePersonalExpenseDto,
|
@Body() dto: UpdatePersonalExpenseDto,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.updatePersonalExpense(+id, dto);
|
const result = await this.service.updatePersonalExpense(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '费用管理',
|
module: '费用管理',
|
||||||
action: '编辑费用',
|
action: '编辑费用',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
detail: `¥${dto.amount} ${dto.expenseType}`,
|
detail: `¥${dto.amount} ${dto.expenseType}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ const createSchedule = (notes: string) =>
|
|||||||
notes,
|
notes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('schedule attendance window validation', () => {
|
||||||
|
it('accepts a configurable number of minutes before class', async () => {
|
||||||
|
const dto = createSchedule('');
|
||||||
|
dto.attendanceAdvanceMinutes = 45;
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.some((error) => error.property === 'attendanceAdvanceMinutes')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects values outside 0 to 1440 minutes', async () => {
|
||||||
|
const dto = Object.assign(new UpdateScheduleDto(), { attendanceAdvanceMinutes: 1441 });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.some((error) => error.property === 'attendanceAdvanceMinutes')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('schedule notes validation', () => {
|
describe('schedule notes validation', () => {
|
||||||
it('rejects notes longer than 500 characters when creating', async () => {
|
it('rejects notes longer than 500 characters when creating', async () => {
|
||||||
const errors = await validate(createSchedule('a'.repeat(501)));
|
const errors = await validate(createSchedule('a'.repeat(501)));
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ export class CreateScheduleDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1440)
|
||||||
|
attendanceAdvanceMinutes?: number;
|
||||||
|
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
startDate: string;
|
startDate: string;
|
||||||
@@ -88,6 +94,12 @@ export class UpdateScheduleDto {
|
|||||||
@Matches(/^\d{2}:\d{2}$/)
|
@Matches(/^\d{2}:\d{2}$/)
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1440)
|
||||||
|
attendanceAdvanceMinutes?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
|
|||||||
@@ -110,15 +110,47 @@ describe('SchedulesService — checkConflict', () => {
|
|||||||
).rejects.toThrow(ConflictException);
|
).rejects.toThrow(ConflictException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('same classroom + same weekday + non-overlapping times → no conflict', async () => {
|
it('rejects schedules separated by less than 10 minutes', async () => {
|
||||||
|
const qb = mockQueryBuilder<ClassSchedule>([
|
||||||
|
{ id: 1, subject: '数学', startTime: '08:00', endTime: '10:00' } as ClassSchedule,
|
||||||
|
]);
|
||||||
|
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||||
|
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
|
||||||
|
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.checkConflict(1, 3, '10:09', '12:00', '2026-03-01', '2026-06-30'),
|
||||||
|
).rejects.toThrow('排课之间必须至少间隔 10 分钟');
|
||||||
|
expect(qb.andWhere).toHaveBeenCalledWith('cs.endTime > :bufferedStartTime', {
|
||||||
|
bufferedStartTime: '09:59',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows adjacent schedules when there is exactly a 10-minute gap', async () => {
|
||||||
const qb = mockQueryBuilder<ClassSchedule>([]);
|
const qb = mockQueryBuilder<ClassSchedule>([]);
|
||||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
|
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
|
||||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.checkConflict(1, 3, '10:00', '12:00', '2026-03-01', '2026-06-30'),
|
service.checkConflict(1, 3, '10:10', '12:00', '2026-03-01', '2026-06-30'),
|
||||||
).resolves.toEqual([]);
|
).resolves.toEqual([]);
|
||||||
|
expect(qb.andWhere).toHaveBeenCalledWith('cs.endTime > :bufferedStartTime', {
|
||||||
|
bufferedStartTime: '10:00',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reserves 10 minutes after the new schedule when checking the next schedule', async () => {
|
||||||
|
const qb = mockQueryBuilder<ClassSchedule>([]);
|
||||||
|
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||||
|
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
|
||||||
|
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||||
|
|
||||||
|
await service.checkConflict(1, 3, '08:00', '09:50', '2026-03-01', '2026-06-30');
|
||||||
|
|
||||||
|
expect(qb.andWhere).toHaveBeenCalledWith('cs.startTime < :bufferedEndTime', {
|
||||||
|
bufferedEndTime: '10:00',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('same classroom + same weekday + overlapping times but disjoint date ranges → no conflict', async () => {
|
it('same classroom + same weekday + overlapping times but disjoint date ranges → no conflict', async () => {
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ import {
|
|||||||
WeeklyViewQueryDto,
|
WeeklyViewQueryDto,
|
||||||
} from './dto/schedule.dto';
|
} from './dto/schedule.dto';
|
||||||
|
|
||||||
|
const SCHEDULE_GAP_MINUTES = 10;
|
||||||
|
|
||||||
|
function shiftTime(time: string, minutes: number): string {
|
||||||
|
const [hours, minutePart] = time.split(':').map(Number);
|
||||||
|
const shifted = Math.min(24 * 60, Math.max(0, hours * 60 + minutePart + minutes));
|
||||||
|
const shiftedHours = Math.floor(shifted / 60);
|
||||||
|
const shiftedMinutes = shifted % 60;
|
||||||
|
return `${String(shiftedHours).padStart(2, '0')}:${String(shiftedMinutes).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SchedulesService {
|
export class SchedulesService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -58,6 +68,7 @@ export class SchedulesService {
|
|||||||
weekDay: schedule.weekDay,
|
weekDay: schedule.weekDay,
|
||||||
startTime: schedule.startTime,
|
startTime: schedule.startTime,
|
||||||
endTime: schedule.endTime,
|
endTime: schedule.endTime,
|
||||||
|
attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes,
|
||||||
startDate: schedule.startDate,
|
startDate: schedule.startDate,
|
||||||
endDate: schedule.endDate,
|
endDate: schedule.endDate,
|
||||||
subject: '已占用',
|
subject: '已占用',
|
||||||
@@ -243,13 +254,16 @@ export class SchedulesService {
|
|||||||
endDate: string,
|
endDate: string,
|
||||||
excludeId?: number,
|
excludeId?: number,
|
||||||
) {
|
) {
|
||||||
|
// 为教室换场、整理和人员进出预留时间;恰好间隔 10 分钟允许排课。
|
||||||
|
const bufferedStartTime = shiftTime(startTime, -SCHEDULE_GAP_MINUTES);
|
||||||
|
const bufferedEndTime = shiftTime(endTime, SCHEDULE_GAP_MINUTES);
|
||||||
const qb = this.scheduleRepo
|
const qb = this.scheduleRepo
|
||||||
.createQueryBuilder('cs')
|
.createQueryBuilder('cs')
|
||||||
.where('cs.classroomId = :classroomId', { classroomId })
|
.where('cs.classroomId = :classroomId', { classroomId })
|
||||||
.andWhere('cs.weekDay = :weekDay', { weekDay })
|
.andWhere('cs.weekDay = :weekDay', { weekDay })
|
||||||
.andWhere('cs.status = :status', { status: 'active' })
|
.andWhere('cs.status = :status', { status: 'active' })
|
||||||
.andWhere('cs.startTime < :endTime', { endTime })
|
.andWhere('cs.startTime < :bufferedEndTime', { bufferedEndTime })
|
||||||
.andWhere('cs.endTime > :startTime', { startTime })
|
.andWhere('cs.endTime > :bufferedStartTime', { bufferedStartTime })
|
||||||
.andWhere('cs.startDate <= :endDate', { endDate })
|
.andWhere('cs.startDate <= :endDate', { endDate })
|
||||||
.andWhere('cs.endDate >= :startDate', { startDate });
|
.andWhere('cs.endDate >= :startDate', { startDate });
|
||||||
|
|
||||||
@@ -258,7 +272,7 @@ export class SchedulesService {
|
|||||||
const conflicts = await qb.getMany();
|
const conflicts = await qb.getMany();
|
||||||
if (conflicts.length > 0) {
|
if (conflicts.length > 0) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`该时间段与已有排课冲突: ${conflicts.map((c) => `${c.subject}(${c.startTime}-${c.endTime})`).join(', ')}`,
|
`排课之间必须至少间隔 ${SCHEDULE_GAP_MINUTES} 分钟,与以下排课时间过近: ${conflicts.map((c) => `${c.subject}(${c.startTime}-${c.endTime})`).join(', ')}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsString, IsOptional, IsEnum, IsInt } from 'class-validator';
|
import { IsBoolean, IsIn, IsInt, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
|
||||||
export class CreateStudentDto {
|
export class CreateStudentDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -82,6 +83,31 @@ export class UpdateStudentDto {
|
|||||||
supervisor?: string;
|
supervisor?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(['active', 'graduated', 'withdrawn'])
|
@IsIn(['active', 'graduated', 'withdrawn'])
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class QueryStudentDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['active', 'graduated', 'withdrawn', 'archived'])
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (value === 'true' || value === '1') return true;
|
||||||
|
if (value === 'false' || value === '0') return false;
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
includeArchived?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
organizationId?: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
Inject,
|
Inject,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
@@ -21,7 +22,7 @@ import { ClassTeacher } from '../entities/class-teacher.entity';
|
|||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { StudentsService } from './students.service';
|
import { StudentsService } from './students.service';
|
||||||
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
|
import { CreateStudentDto, QueryStudentDto, UpdateStudentDto } from './dto/student.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
@@ -61,10 +62,7 @@ export class StudentsController {
|
|||||||
@Get()
|
@Get()
|
||||||
@RequirePermission('student:view')
|
@RequirePermission('student:view')
|
||||||
async findAll(
|
async findAll(
|
||||||
@Query('name') name: string | undefined,
|
@Query() query: QueryStudentDto,
|
||||||
@Query('status') status: string | undefined,
|
|
||||||
@Query('includeArchived') includeArchived: string | undefined,
|
|
||||||
@Query('organizationId') organizationId: string | undefined,
|
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const classIds = await this.service.getAccessibleClassIds(
|
const classIds = await this.service.getAccessibleClassIds(
|
||||||
@@ -72,12 +70,7 @@ export class StudentsController {
|
|||||||
this.canManageAllStudents(req),
|
this.canManageAllStudents(req),
|
||||||
);
|
);
|
||||||
return this.service.findAll(
|
return this.service.findAll(
|
||||||
{
|
query,
|
||||||
name,
|
|
||||||
status,
|
|
||||||
includeArchived: includeArchived === 'true',
|
|
||||||
organizationId: organizationId ? +organizationId : undefined,
|
|
||||||
},
|
|
||||||
classIds,
|
classIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -192,8 +185,8 @@ export class StudentsController {
|
|||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePermission('student:view')
|
@RequirePermission('student:view')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.service.findOne(+id);
|
return this.service.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -217,15 +210,15 @@ export class StudentsController {
|
|||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async update(@Param('id') id: string, @Body() dto: UpdateStudentDto, @Request() req: any) {
|
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStudentDto, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.update(+id, dto);
|
const result = await this.service.update(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生管理',
|
module: '学生管理',
|
||||||
action: '编辑学生',
|
action: '编辑学生',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'student',
|
targetType: 'student',
|
||||||
detail: JSON.stringify(dto),
|
detail: JSON.stringify(dto),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -236,15 +229,15 @@ export class StudentsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('student:delete')
|
@RequirePermission('student:delete')
|
||||||
async remove(@Param('id') id: string, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.remove(+id);
|
const result = await this.service.remove(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生管理',
|
module: '学生管理',
|
||||||
action: '删除学生',
|
action: '删除学生',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'student',
|
targetType: 'student',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -271,15 +264,15 @@ export class StudentsController {
|
|||||||
|
|
||||||
@Put(':id/restore')
|
@Put(':id/restore')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async restore(@Param('id') id: string, @Request() req: any) {
|
async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.restore(+id);
|
const result = await this.service.restore(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生管理',
|
module: '学生管理',
|
||||||
action: '恢复学生',
|
action: '恢复学生',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'student',
|
targetType: 'student',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -403,7 +396,7 @@ export class StudentsController {
|
|||||||
|
|
||||||
@Get(':id/compare-classes')
|
@Get(':id/compare-classes')
|
||||||
@RequirePermission('student:view')
|
@RequirePermission('student:view')
|
||||||
compareClasses(@Param('id') id: string) {
|
compareClasses(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.service.compareClasses(+id);
|
return this.service.compareClasses(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -462,3 +462,90 @@ describe('ScheduleSyncService — all shifts fail', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ScheduleSyncService — multiple lessons per student per day', () => {
|
||||||
|
it('combines daily lessons into one DingTalk shift with multiple sections', async () => {
|
||||||
|
const scheduleRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
classId: 10,
|
||||||
|
classroomId: 1,
|
||||||
|
weekDay: 1,
|
||||||
|
startTime: '22:10',
|
||||||
|
endTime: '23:10',
|
||||||
|
startDate: '2026-07-13',
|
||||||
|
endDate: '2026-07-13',
|
||||||
|
status: 'active',
|
||||||
|
} as ClassSchedule,
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
classId: 10,
|
||||||
|
classroomId: 2,
|
||||||
|
weekDay: 1,
|
||||||
|
startTime: '20:00',
|
||||||
|
endTime: '21:00',
|
||||||
|
startDate: '2026-07-13',
|
||||||
|
endDate: '2026-07-13',
|
||||||
|
status: 'active',
|
||||||
|
} as ClassSchedule,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const classStudentRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
|
||||||
|
};
|
||||||
|
const mappingRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
|
||||||
|
};
|
||||||
|
const classRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺一班' }]),
|
||||||
|
};
|
||||||
|
const scheduleUsers = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const upsertShift = jest.fn().mockResolvedValue(2022);
|
||||||
|
const dingTalkService = {
|
||||||
|
queryShifts: jest.fn().mockResolvedValue([]),
|
||||||
|
upsertShift,
|
||||||
|
queryAttendanceGroups: jest.fn().mockResolvedValue([
|
||||||
|
{ group_id: 1495610001, group_name: '排课_冲刺一班', type: 'TURN', member_count: 1 },
|
||||||
|
]),
|
||||||
|
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
|
||||||
|
createAttendanceGroup: jest.fn(),
|
||||||
|
scheduleUsers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new ScheduleSyncService(
|
||||||
|
scheduleRepo as never,
|
||||||
|
classStudentRepo as never,
|
||||||
|
mappingRepo as never,
|
||||||
|
classRepo as never,
|
||||||
|
dingTalkService as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.syncAll('2026-07-13', 1);
|
||||||
|
|
||||||
|
expect(upsertShift).toHaveBeenCalledTimes(1);
|
||||||
|
expect(upsertShift).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
name: '冲刺一班_20:00-21:00+22:10-23:10',
|
||||||
|
sections: [
|
||||||
|
expect.objectContaining({
|
||||||
|
times: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 20:00:00' }),
|
||||||
|
expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 21:00:00' }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
times: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 22:10:00' }),
|
||||||
|
expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 23:10:00' }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
expect(scheduleUsers).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scheduleUsers.mock.calls[0][1]).toEqual([
|
||||||
|
expect.objectContaining({ userid: 'student-1', shift_id: 2022 }),
|
||||||
|
]);
|
||||||
|
expect(result.syncedItems).toBe(1);
|
||||||
|
expect(result.failedBatchCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, In } from 'typeorm';
|
import { Repository, In } from 'typeorm';
|
||||||
import {
|
import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities';
|
||||||
ClassSchedule,
|
|
||||||
ClassStudent,
|
|
||||||
StudentDingMapping,
|
|
||||||
Class,
|
|
||||||
} from '../entities';
|
|
||||||
import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service';
|
import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service';
|
||||||
|
|
||||||
|
interface DailySchedulePeriod {
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
scheduleId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailySchedulePlan {
|
||||||
|
classId: number;
|
||||||
|
date: string;
|
||||||
|
shiftKey: string;
|
||||||
|
periods: DailySchedulePeriod[];
|
||||||
|
}
|
||||||
|
|
||||||
/** 单次排班同步的结果 */
|
/** 单次排班同步的结果 */
|
||||||
export interface ScheduleSyncResult {
|
export interface ScheduleSyncResult {
|
||||||
/** 参与同步的排课记录数 */
|
/** 参与同步的排课记录数 */
|
||||||
@@ -89,9 +97,14 @@ export class ScheduleSyncService {
|
|||||||
const endDate = this.addDays(startDate, days);
|
const endDate = this.addDays(startDate, days);
|
||||||
|
|
||||||
const empty: ScheduleSyncResult = {
|
const empty: ScheduleSyncResult = {
|
||||||
scheduleCount: 0, shiftCount: 0, groupCount: 0,
|
scheduleCount: 0,
|
||||||
syncedItems: 0, skippedNoMapping: 0,
|
shiftCount: 0,
|
||||||
failedBatchCount: 0, failedItems: 0, errors: [],
|
groupCount: 0,
|
||||||
|
syncedItems: 0,
|
||||||
|
skippedNoMapping: 0,
|
||||||
|
failedBatchCount: 0,
|
||||||
|
failedItems: 0,
|
||||||
|
errors: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,63 +123,77 @@ export class ScheduleSyncService {
|
|||||||
const classDingUsers = await this.buildClassDingUserMap(classIds);
|
const classDingUsers = await this.buildClassDingUserMap(classIds);
|
||||||
const classNameMap = await this.loadClassNames(classIds);
|
const classNameMap = await this.loadClassNames(classIds);
|
||||||
|
|
||||||
// ── Step 3: 班次(按时间段去重,班次列表只查一次) ──
|
// ── Step 3: 将每天的多节课合并成一个钉钉班次 ──
|
||||||
const shiftKey = (classId: number, start: string, end: string) =>
|
// 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为
|
||||||
`${classId}|${start}-${end}`;
|
// 同一个班次的多个 sections 写入,不能拆成多条 schedule item。
|
||||||
|
const dailyPlans = this.buildDailySchedulePlans(schedules, startDate, endDate);
|
||||||
const uniqueShifts = new Map<
|
const uniqueShifts = new Map<
|
||||||
string,
|
string,
|
||||||
{ className: string; startTime: string; endTime: string }
|
{ className: string; periods: DailySchedulePeriod[] }
|
||||||
>();
|
>();
|
||||||
const shiftScheduleCount = new Map<string, number>();
|
const shiftPlanCount = new Map<string, number>();
|
||||||
for (const schedule of schedules) {
|
for (const plan of dailyPlans) {
|
||||||
const classId = schedule.classId as number;
|
if (!uniqueShifts.has(plan.shiftKey)) {
|
||||||
const key = shiftKey(classId, schedule.startTime, schedule.endTime);
|
uniqueShifts.set(plan.shiftKey, {
|
||||||
if (!uniqueShifts.has(key)) {
|
className: classNameMap.get(plan.classId) || `班级${plan.classId}`,
|
||||||
uniqueShifts.set(key, {
|
periods: plan.periods,
|
||||||
className: classNameMap.get(classId) || `班级${classId}`,
|
|
||||||
startTime: schedule.startTime,
|
|
||||||
endTime: schedule.endTime,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
shiftScheduleCount.set(key, (shiftScheduleCount.get(key) || 0) + 1);
|
shiftPlanCount.set(plan.shiftKey, (shiftPlanCount.get(plan.shiftKey) || 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingShifts = await this.dingTalkService.queryShifts(opUserId);
|
const existingShifts = await this.dingTalkService.queryShifts(opUserId);
|
||||||
const shiftByName = new Map(existingShifts.map((s) => [s.name, s.id]));
|
const shiftByName = new Map(existingShifts.map((shift) => [shift.name, shift.id]));
|
||||||
const timeToShiftId = new Map<string, number>();
|
const planToShiftId = new Map<string, number>();
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
let failedBatchCount = 0;
|
let failedBatchCount = 0;
|
||||||
let failedItems = 0;
|
let failedItems = 0;
|
||||||
let shiftCount = 0;
|
let shiftCount = 0;
|
||||||
for (const [key, { className, startTime, endTime }] of uniqueShifts) {
|
for (const [key, { className, periods }] of uniqueShifts) {
|
||||||
const shiftName = `${className}_${startTime}-${endTime}`;
|
const periodLabel = periods
|
||||||
|
.map((period) => `${period.startTime}-${period.endTime}`)
|
||||||
|
.join('+');
|
||||||
|
const shiftName = `${className}_${periodLabel}`;
|
||||||
try {
|
try {
|
||||||
let shiftId = shiftByName.get(shiftName);
|
let shiftId = shiftByName.get(shiftName);
|
||||||
const shiftParams = {
|
const shiftParams = {
|
||||||
...(shiftId === undefined ? {} : { id: shiftId }),
|
...(shiftId === undefined ? {} : { id: shiftId }),
|
||||||
name: shiftName,
|
name: shiftName,
|
||||||
owner: opUserId,
|
owner: opUserId,
|
||||||
sections: [{
|
sections: periods.map((period) => ({
|
||||||
times: [
|
times: [
|
||||||
{ check_type: 'OnDuty' as const, across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false },
|
{
|
||||||
{ check_type: 'OffDuty' as const, across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false },
|
check_type: 'OnDuty' as const,
|
||||||
|
across: 0,
|
||||||
|
check_time: `1970-01-01 ${period.startTime}:00`,
|
||||||
|
free_check: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
check_type: 'OffDuty' as const,
|
||||||
|
across: 0,
|
||||||
|
check_time: `1970-01-01 ${period.endTime}:00`,
|
||||||
|
free_check: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}],
|
})),
|
||||||
setting: {
|
setting: {
|
||||||
is_flexible: false,
|
is_flexible: false,
|
||||||
serious_late_minutes: -1,
|
serious_late_minutes: -1,
|
||||||
absenteeism_late_minutes: this.minutesBetween(startTime, endTime),
|
absenteeism_late_minutes: Math.max(
|
||||||
|
...periods.map((period) => this.minutesBetween(period.startTime, period.endTime)),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
shiftId = await this.dingTalkService.upsertShift(shiftParams);
|
shiftId = await this.dingTalkService.upsertShift(shiftParams);
|
||||||
shiftByName.set(shiftName, shiftId);
|
shiftByName.set(shiftName, shiftId);
|
||||||
timeToShiftId.set(key, shiftId);
|
planToShiftId.set(key, shiftId);
|
||||||
shiftCount++;
|
shiftCount++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`;
|
const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`;
|
||||||
this.logger.error(msg);
|
this.logger.error(msg);
|
||||||
errors.push(msg);
|
errors.push(msg);
|
||||||
failedBatchCount++;
|
failedBatchCount++;
|
||||||
failedItems += shiftScheduleCount.get(key) || 0;
|
failedItems += shiftPlanCount.get(key) || 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,10 +203,15 @@ export class ScheduleSyncService {
|
|||||||
|
|
||||||
// ── Step 5: 按班级同步 ──
|
// ── Step 5: 按班级同步 ──
|
||||||
const schedulesByClass = new Map<number, ClassSchedule[]>();
|
const schedulesByClass = new Map<number, ClassSchedule[]>();
|
||||||
for (const s of schedules) {
|
for (const schedule of schedules) {
|
||||||
const cid = s.classId as number;
|
const classId = schedule.classId as number;
|
||||||
if (!schedulesByClass.has(cid)) schedulesByClass.set(cid, []);
|
if (!schedulesByClass.has(classId)) schedulesByClass.set(classId, []);
|
||||||
schedulesByClass.get(cid)!.push(s);
|
schedulesByClass.get(classId)!.push(schedule);
|
||||||
|
}
|
||||||
|
const dailyPlansByClass = new Map<number, DailySchedulePlan[]>();
|
||||||
|
for (const plan of dailyPlans) {
|
||||||
|
if (!dailyPlansByClass.has(plan.classId)) dailyPlansByClass.set(plan.classId, []);
|
||||||
|
dailyPlansByClass.get(plan.classId)!.push(plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
let syncedItems = 0;
|
let syncedItems = 0;
|
||||||
@@ -196,11 +228,12 @@ export class ScheduleSyncService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 该班级用到的班次
|
const classDailyPlans = dailyPlansByClass.get(classId) ?? [];
|
||||||
|
// 该班级在同步日期范围内用到的合并班次
|
||||||
const classShiftIds = new Set<number>();
|
const classShiftIds = new Set<number>();
|
||||||
for (const s of classSchedules) {
|
for (const plan of classDailyPlans) {
|
||||||
const sid = timeToShiftId.get(shiftKey(classId, s.startTime, s.endTime));
|
const shiftId = planToShiftId.get(plan.shiftKey);
|
||||||
if (sid) classShiftIds.add(sid);
|
if (shiftId) classShiftIds.add(shiftId);
|
||||||
}
|
}
|
||||||
if (classShiftIds.size === 0) {
|
if (classShiftIds.size === 0) {
|
||||||
this.logger.warn(`班级 ${className} 无可用班次,跳过(班次创建已计入 failure)`);
|
this.logger.warn(`班级 ${className} 无可用班次,跳过(班次创建已计入 failure)`);
|
||||||
@@ -208,9 +241,7 @@ export class ScheduleSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 先展开排班以计算受影响条数
|
// 先展开排班以计算受影响条数
|
||||||
const items = this.expandSchedules(
|
const items = this.expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId);
|
||||||
classSchedules, dingUserIds, timeToShiftId, startDate, endDate,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`);
|
this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`);
|
||||||
@@ -227,7 +258,11 @@ export class ScheduleSyncService {
|
|||||||
name: groupName,
|
name: groupName,
|
||||||
type: 'TURN' as const,
|
type: 'TURN' as const,
|
||||||
owner: opUserId,
|
owner: opUserId,
|
||||||
members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember' as const, user_id: uid })),
|
members: dingUserIds.map((uid) => ({
|
||||||
|
role: 'Attendance',
|
||||||
|
type: 'StaffMember' as const,
|
||||||
|
user_id: uid,
|
||||||
|
})),
|
||||||
shift_ids: [...classShiftIds],
|
shift_ids: [...classShiftIds],
|
||||||
enable_emp_select_class: true,
|
enable_emp_select_class: true,
|
||||||
disable_check_without_schedule: false,
|
disable_check_without_schedule: false,
|
||||||
@@ -333,54 +368,88 @@ export class ScheduleSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将排课记录展开为每个学生的每日排班数组。
|
* 把本地排课转换为“班级 + 日期”的日排班计划。
|
||||||
* 每条 ClassSchedule(weekDay, startDate-endDate) × 班级每个学生 →
|
* 同一天相同时间段会去重,多节课按开始时间排序并合并为一个钉钉班次。
|
||||||
* 该日期范围内所有 weekDay 对应日期的排班。
|
|
||||||
*/
|
*/
|
||||||
private expandSchedules(
|
private buildDailySchedulePlans(
|
||||||
schedules: ClassSchedule[],
|
schedules: ClassSchedule[],
|
||||||
dingUserIds: string[],
|
|
||||||
timeToShiftId: Map<string, number>,
|
|
||||||
syncFrom: string,
|
syncFrom: string,
|
||||||
syncTo: string,
|
syncTo: string,
|
||||||
): DingTalkScheduleItem[] {
|
): DailySchedulePlan[] {
|
||||||
const seen = new Set<string>();
|
const periodMapByClassDate = new Map<string, Map<string, DailySchedulePeriod>>();
|
||||||
const items: DingTalkScheduleItem[] = [];
|
|
||||||
const fromDate = new Date(syncFrom);
|
const fromDate = new Date(syncFrom);
|
||||||
const toDate = new Date(syncTo);
|
const toDate = new Date(syncTo);
|
||||||
|
|
||||||
// 预计算日期范围内每一天是星期几(周日=7)
|
for (let date = new Date(fromDate); date <= toDate; date.setDate(date.getDate() + 1)) {
|
||||||
const dateWeekDays = new Map<string, number>();
|
const dateStr = date.toISOString().slice(0, 10);
|
||||||
for (let d = new Date(fromDate); d <= toDate; d.setDate(d.getDate() + 1)) {
|
const weekDay = date.getDay() === 0 ? 7 : date.getDay();
|
||||||
const dateStr = d.toISOString().slice(0, 10);
|
|
||||||
dateWeekDays.set(dateStr, d.getDay() === 0 ? 7 : d.getDay());
|
for (const schedule of schedules) {
|
||||||
|
if (schedule.classId == null || schedule.weekDay !== weekDay) continue;
|
||||||
|
if (dateStr < schedule.startDate || dateStr > schedule.endDate) continue;
|
||||||
|
|
||||||
|
const classDateKey = `${schedule.classId}|${dateStr}`;
|
||||||
|
if (!periodMapByClassDate.has(classDateKey)) {
|
||||||
|
periodMapByClassDate.set(classDateKey, new Map());
|
||||||
|
}
|
||||||
|
const periods = periodMapByClassDate.get(classDateKey)!;
|
||||||
|
const periodKey = `${schedule.startTime}-${schedule.endTime}`;
|
||||||
|
const existing = periods.get(periodKey);
|
||||||
|
if (!existing || schedule.id < existing.scheduleId) {
|
||||||
|
periods.set(periodKey, {
|
||||||
|
startTime: schedule.startTime,
|
||||||
|
endTime: schedule.endTime,
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const s of schedules) {
|
const plans: DailySchedulePlan[] = [];
|
||||||
const shiftId = timeToShiftId.get(`${s.classId}|${s.startTime}-${s.endTime}`);
|
for (const [classDateKey, periodMap] of periodMapByClassDate) {
|
||||||
|
const separator = classDateKey.indexOf('|');
|
||||||
|
const classId = Number(classDateKey.slice(0, separator));
|
||||||
|
const date = classDateKey.slice(separator + 1);
|
||||||
|
const periods = [...periodMap.values()].sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.startTime.localeCompare(right.startTime) ||
|
||||||
|
left.endTime.localeCompare(right.endTime) ||
|
||||||
|
left.scheduleId - right.scheduleId,
|
||||||
|
);
|
||||||
|
const periodSignature = periods
|
||||||
|
.map((period) => `${period.startTime}-${period.endTime}`)
|
||||||
|
.join('+');
|
||||||
|
plans.push({
|
||||||
|
classId,
|
||||||
|
date,
|
||||||
|
shiftKey: `${classId}|${periodSignature}`,
|
||||||
|
periods,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return plans.sort(
|
||||||
|
(left, right) => left.date.localeCompare(right.date) || left.classId - right.classId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每个学生每天仅生成一条钉钉排班,shift 内可包含多个课程卡段。 */
|
||||||
|
private expandDailySchedulePlans(
|
||||||
|
plans: DailySchedulePlan[],
|
||||||
|
dingUserIds: string[],
|
||||||
|
planToShiftId: Map<string, number>,
|
||||||
|
): DingTalkScheduleItem[] {
|
||||||
|
const items: DingTalkScheduleItem[] = [];
|
||||||
|
for (const plan of plans) {
|
||||||
|
const shiftId = planToShiftId.get(plan.shiftKey);
|
||||||
if (!shiftId) continue;
|
if (!shiftId) continue;
|
||||||
|
const workDate = new Date(`${plan.date}T00:00:00+08:00`).getTime();
|
||||||
const scheduleStart = s.startDate > syncFrom ? s.startDate : syncFrom;
|
|
||||||
const scheduleEnd = s.endDate < syncTo ? s.endDate : syncTo;
|
|
||||||
|
|
||||||
for (const [dateStr, weekDay] of dateWeekDays) {
|
|
||||||
if (dateStr < scheduleStart || dateStr > scheduleEnd) continue;
|
|
||||||
if (weekDay !== s.weekDay) continue;
|
|
||||||
|
|
||||||
const workDate = new Date(dateStr + 'T00:00:00+08:00').getTime();
|
|
||||||
for (const userid of dingUserIds) {
|
for (const userid of dingUserIds) {
|
||||||
const dedupKey = `${userid}|${workDate}|${shiftId}`;
|
|
||||||
if (seen.has(dedupKey)) continue;
|
|
||||||
seen.add(dedupKey);
|
|
||||||
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false });
|
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private minutesBetween(startTime: string, endTime: string): number {
|
private minutesBetween(startTime: string, endTime: string): number {
|
||||||
const [startHour, startMinute] = startTime.split(':').map(Number);
|
const [startHour, startMinute] = startTime.split(':').map(Number);
|
||||||
const [endHour, endMinute] = endTime.split(':').map(Number);
|
const [endHour, endMinute] = endTime.split(':').map(Number);
|
||||||
|
|||||||
Reference in New Issue
Block a user