diff --git a/apps/admin/src/components/JinshujuMatchModal.tsx b/apps/admin/src/components/JinshujuMatchModal.tsx index 7ba297e..4613ef0 100644 --- a/apps/admin/src/components/JinshujuMatchModal.tsx +++ b/apps/admin/src/components/JinshujuMatchModal.tsx @@ -1,310 +1,28 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Button, Form, Input, Modal, Popconfirm, Select, Spin, Steps, Tag, Typography } from 'antd'; -import { - CloudUploadOutlined, - DeleteOutlined, - EditOutlined, - LinkOutlined, - PlusOutlined, - SaveOutlined, - SearchOutlined, -} from '@ant-design/icons'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useImmer } from 'use-immer'; +import { Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd'; +import { CloudUploadOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'; import api from '../api'; import { message } from '../ui/app-message'; import { usePermission } from '../hooks/usePermission'; import PermissionButton from './PermissionButton'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../hooks/useApiMutation'; +import { validateResponse } from '../utils/validate'; +import { jinshujuRulesSchema } from '../api/schemas'; +import MatchStep from './MatchStep'; +import RuleEditor from './RuleEditor'; +import type { + JinshujuEntryRow, + JinshujuFormField, + MatchDecision, + MatchRule, + PreviewResponse, + StudentOption, +} from './JinshujuMatchModal.types'; const { Text } = Typography; -// ── Types ── - -interface JinshujuEntryRow { - serialNumber: number; - name: string; - phone: string | null; - suggestedStudent: { - id: number; - name: string; - phone: string | null; - studentNo: string | null; - } | null; -} - -interface StudentOption { - id: number; - name: string; - phone: string | null; - studentNo: string | null; -} - -interface PreviewResponse { - success: boolean; - entries: JinshujuEntryRow[]; - students: StudentOption[]; -} - -interface MatchRule { - id: number; - name: string; - formToken: string; - mappings: Record; - createdAt: string; -} - -interface JinshujuFormField { - key: string; - label: string; - type: string; -} - -type MatchDecision = - | { action: 'match'; matchStudentId: number } - | { action: 'create'; createName: string; createPhone: string } - | { action: 'skip' }; - -// ── Constants ── - -const ROW_HEIGHT = 72; -const LEFT_WIDTH = 260; -const GAP = 80; - -const STUDENT_FIELDS = [ - { key: 'name', label: '姓名' }, - { key: 'phone', label: '手机号' }, - { key: 'idNumber', label: '身份证号' }, - { key: 'gender', label: '性别' }, - { key: 'ethnicity', label: '民族' }, - { key: 'emergencyContact', label: '紧急联系人' }, - { key: 'emergencyPhone', label: '紧急联系电话' }, - { key: 'studentNo', label: '学号' }, -]; - -// ── MatchSelector sub-component ── - -interface MatchSelectorProps { - entry: JinshujuEntryRow; - decision: MatchDecision | undefined; - studentOptions: StudentOption[]; - onChange: (d: MatchDecision) => void; -} - -const MatchSelector: React.FC = ({ - entry, - decision, - studentOptions, - onChange, -}) => { - const action = decision?.action ?? 'skip'; - - if (action === 'match') { - const matchD = decision as { action: 'match'; matchStudentId: number }; - const matchedStudent = studentOptions.find((s) => s.id === matchD.matchStudentId); - return ( -
- }> - 已匹配 - - - {matchedStudent?.name ?? '未知'} - {matchedStudent?.studentNo && ( - - ({matchedStudent.studentNo}) - - )} - - -
- ); - } - - if (action === 'create') { - const createD = decision as { action: 'create'; createName: string; createPhone: string }; - return ( -
- }> - 将新建 - - - onChange({ - action: 'create', - createName: e.target.value, - createPhone: createD.createPhone, - }) - } - /> - - onChange({ - action: 'create', - createName: createD.createName, - createPhone: e.target.value, - }) - } - /> - -
- ); - } - - return ( -
- setName(e.target.value)} - style={{ marginBottom: 12 }} - /> - - 选择金数据字段映射到学生资料 - - {STUDENT_FIELDS.map((sf) => ( -
- {sf.label} - - ← - - + onChange({ + action: 'create', + createName: e.target.value, + createPhone: createD.createPhone, + }) + } + /> + + onChange({ + action: 'create', + createName: createD.createName, + createPhone: e.target.value, + }) + } + /> + +
+ ); + } + + return ( +
+ setName(e.target.value)} + style={{ marginBottom: 12 }} + /> + + 选择金数据字段映射到学生资料 + + {STUDENT_FIELDS.map((sf) => ( +
+ {sf.label} + + ← + + + + + + + + ({ + value: k, + label: v.text, + }))} + /> + + + + + + + + 保存 + + + + + ) : ( +
+ + {TYPE_MAP[detail.classType]} + + {detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'} + + + {detail.endDate ? dayjs(detail.endDate).format('YYYY-MM-DD') : '-'} + + + {detail.studentCount}/{detail.maxStudents || '-'} + + + {(() => { + const headTeacher = teachers.find( + (teacher) => teacher.roleType === 'head_teacher', + ); + return headTeacher ? getTeacherName(headTeacher) : '-'; + })()} + + {detail.notes || '-'} + + + 编辑 + +
+ )} +
+ ); +}; + +export const ClassStudentsTab: React.FC<{ + id?: string; + detail?: ClassDetail | null; + students: ClassStudent[]; + allStudents: StudentItem[]; + selectedStudentIds: number[]; + modalOpen: boolean; + onOpen: () => void; + onAdd: () => void; + onClose: () => void; + onRemove: (studentId: number) => void; + onSelect: (ids: number[]) => void; +}> = ({ + id, + detail, + students, + allStudents, + selectedStudentIds, + modalOpen, + onOpen, + onAdd, + onClose, + onRemove, + onSelect, +}) => { + const studentColumns: ColumnsType = [ + { title: '姓名', dataIndex: 'studentName' }, + { title: '学号', dataIndex: 'studentNo' }, + { title: '加入日期', dataIndex: 'joinDate' }, + { title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' }, + { + title: '状态', + dataIndex: 'status', + render: (v: string) => ( + {v === 'active' ? '在读' : '已离班'} + ), + }, + { + title: '操作', + render: (_: unknown, r: ClassStudent) => + r.status === 'active' ? ( + onRemove(r.studentId)}> + + 移除 + + + ) : null, + }, + ]; + return ( +
+ } + type="primary" + onClick={onOpen} + style={{ marginBottom: 16, marginRight: 8 }} + > + 添加学员 + + } + onClick={() => { + const token = useUserStore.getState().token; + fetch(`/api/classes/${id}/roster/export`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((res) => { + if (!res.ok) throw new Error('导出失败'); + return res.blob(); + }) + .then((blob) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `班级花名册-${detail?.name || id}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + message.success('花名册导出成功'); + }) + .catch(() => message.error('花名册导出失败')); + }} + > + 导出花名册 + + + columns={studentColumns} + dataSource={students} + rowKey="id" + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + }} + /> + + + onSubjectChange(e.target.value)} + /> + )} + + +
+ ); +}; + +export const ClassScheduleTab: React.FC<{ + schedules: ClassScheduleItem[]; + scheduleDateRange: [dayjs.Dayjs | null, dayjs.Dayjs | null]; + onRangeChange: (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null]) => void; +}> = ({ schedules, scheduleDateRange, onRangeChange }) => { + const scheduleColumns: ColumnsType = [ + { title: '教室', dataIndex: 'classroomName', render: (v: string | null) => 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.attendanceAdvanceMinutes ?? 30} 分钟至下课`, + }, + { + title: '日期范围', + render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}`, + }, + { title: '科目', dataIndex: 'subject' }, + { title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v }, + { + title: '状态', + dataIndex: 'status', + render: (v: string) => ( + {v === 'active' ? '启用' : v} + ), + }, + ]; + return ( +
+ + onRangeChange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])} + placeholder={['开始日期', '结束日期']} + /> + + + columns={scheduleColumns} + dataSource={schedules} + rowKey="id" + scroll={{ x: 'max-content' }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + }} + /> +
+ ); +}; + +export const ClassAttendanceTab: React.FC<{ + attendanceSummary: AttendanceSummary | null; + attendanceDateRange: [dayjs.Dayjs | null, dayjs.Dayjs | null]; + onRangeChange: (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null]) => void; +}> = ({ attendanceSummary, attendanceDateRange, onRangeChange }) => { + return ( +
+ + onRangeChange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])} + placeholder={['开始日期', '结束日期']} + /> + + {attendanceSummary && ( + + + + + + + + + + + + + + + + + + + + + + + )} +
+ ); +}; diff --git a/apps/admin/src/pages/Classes/detail.tsx b/apps/admin/src/pages/Classes/detail.tsx index 19e0fe6..d5cc7f2 100644 --- a/apps/admin/src/pages/Classes/detail.tsx +++ b/apps/admin/src/pages/Classes/detail.tsx @@ -1,155 +1,30 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { useUserStore } from '../../store/user/userStore'; -import { - Card, - Tabs, - Descriptions, - Table, - Button, - Space, - Select, - Modal, - Tag, - Popconfirm, - Form, - Input, - DatePicker, - InputNumber, - Row, - Col, - Statistic, -} from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import { ArrowLeftOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons'; +import React, { useState, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router'; +import { Button, Card, Form, Space, Tabs, Tag } from 'antd'; +import { ArrowLeftOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; -import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; -import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate'; - -// ---- Types ---- - -interface ClassStudent { - id: number; - studentId: number; - studentName: string; - studentNo: string; - joinDate: string; - leaveDate: string | null; - status: string; -} - -interface ClassTeacher { - id: number; - userId: number; - username: string; - roleType: string; - subject: string | null; -} - -interface ClassScheduleItem { - id: number; - classId: number; - classroomId: number; - classroomName: string; - weekDay: number; - startTime: string; - endTime: string; - attendanceAdvanceMinutes: number; - startDate: string; - endDate: string; - subject: string; - teacherId: number | null; - scheduleType: string; - status: string; - notes: string | null; -} - -interface AttendanceSummary { - total: number; - present: number; - late: number; - absent: number; - leave: number; - presentRate: number; - absentRate: number; - lateRate: number; - leaveRate: number; -} - -interface ClassDetail { - id: number; - name: string; - code: string; - classType: string; - startDate: string | null; - endDate: string | null; - status: string; - maxStudents: number; - notes: string | null; - studentCount: number; - students?: ClassStudent[]; - teachers?: ClassTeacher[]; - createdAt: string; - updatedAt: string; -} - -interface StudentItem { - id: number; - name: string; - studentNo?: string; -} - -type UserItem = TeacherCandidateUser; - -// ---- Constants ---- - -const STATUS_MAP: Record = { - enrolling: { color: 'blue', text: '招生中' }, - active: { color: 'green', text: '在读' }, - ended: { color: 'default', text: '结课' }, - suspended: { color: 'orange', text: '停课' }, -}; - -const TYPE_MAP: Record = { - culture: '文化课', - professional: '专业课', - bootcamp: '集训营', - sprint: '冲刺营', -}; - -const ROLE_MAP: Record = { - subject_teacher: '任课老师', - head_teacher: '班主任', - life_teacher: '生活老师', - academic_teacher: '学服老师', -}; - -const WEEK_DAY_MAP: Record = { - 1: '周一', - 2: '周二', - 3: '周三', - 4: '周四', - 5: '周五', - 6: '周六', - 7: '周日', -}; - -const SCHEDULE_TYPE_MAP: Record = { - INTERNAL: '内部排课', - RENTAL: '租赁', -}; - -// ---- Component ---- +import { useQuery } from '@tanstack/react-query'; +import type { TeacherCandidateUser } from './teacher-candidate'; +import { getErrorMessage } from '../../utils/error'; +import { + ClassAttendanceTab, + ClassInfoTab, + ClassScheduleTab, + ClassStudentsTab, + ClassTeachersTab, + STATUS_MAP, + type ClassDetail, + type ClassTeacher, + type StudentItem, + type AttendanceSummary, + type ClassScheduleItem, +} from './ClassDetailTabs'; const ClassDetailPage: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const [detail, setDetail] = useState(null); - const [students, setStudents] = useState([]); - const [teachers, setTeachers] = useState([]); - const [loading, setLoading] = useState(false); const [editForm] = Form.useForm(); const [editingInfo, setEditingInfo] = useState(false); @@ -160,85 +35,93 @@ const ClassDetailPage: React.FC = () => { // Teacher modal state const [teacherModalOpen, setTeacherModalOpen] = useState(false); - const [allUsers, setAllUsers] = useState([]); const [teacherRole, setTeacherRole] = useState('subject_teacher'); const [teacherSubject, setTeacherSubject] = useState(''); const [teacherUserId, setTeacherUserId] = useState(); // Schedule & attendance state - const [schedules, setSchedules] = useState([]); const [scheduleDateRange, setScheduleDateRange] = useState< [dayjs.Dayjs | null, dayjs.Dayjs | null] >([null, null]); - const [attendanceSummary, setAttendanceSummary] = useState(null); const [attendanceDateRange, setAttendanceDateRange] = useState< [dayjs.Dayjs | null, dayjs.Dayjs | null] >([null, null]); - const fetchDetail = useCallback(async () => { - setLoading(true); - try { - const res = (await api.get(`/classes/${id}`)) as ClassDetail; - setDetail(res); - setStudents(res.students || []); - setTeachers(res.teachers || []); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败'); - } finally { - setLoading(false); - } - }, [id]); + const { + data: detailResult = { detail: null, students: [], teachers: [] }, + isLoading: detailLoading, + isFetching: detailFetching, + refetch: refetchDetail, + } = useQuery<{ + detail: ClassDetail | null; + students: ClassDetail['students']; + teachers: ClassDetail['teachers']; + }>({ + queryKey: ['classes', 'detail', id], + queryFn: async () => { + try { + const res = (await api.get(`/classes/${id}`)) as ClassDetail; + return { detail: res, students: res.students || [], teachers: res.teachers || [] }; + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败')); + return { detail: null, students: [], teachers: [] }; + } + }, + }); + const detail = detailResult.detail; + const students = detailResult.students ?? []; + const teachers = detailResult.teachers ?? []; + const loading = detailLoading || detailFetching; + const fetchDetail = useCallback(() => refetchDetail(), [refetchDetail]); - const fetchUsers = useCallback(async () => { - try { - const res = (await api.get('/rbac/users')) as UserItem[]; - setAllUsers(res || []); - } catch { - setAllUsers([]); - } - }, []); + const { data: allUsers = [], refetch: refetchUsers } = useQuery({ + queryKey: ['rbac', 'users', 'all'], + queryFn: async () => { + try { + return (await api.get('/rbac/users')) as TeacherCandidateUser[]; + } catch { + return []; + } + }, + }); + const fetchUsers = useCallback(() => refetchUsers(), [refetchUsers]); - useEffect(() => { - fetchDetail(); - fetchUsers(); - }, [fetchDetail, fetchUsers]); + const { data: schedules = [] } = useQuery({ + queryKey: ['classes', 'schedule', id, scheduleDateRange], + queryFn: async () => { + if (!id) return []; + try { + const params: Record = {}; + if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD'); + if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD'); + return (await api.get(`/classes/${id}/schedule`, { params })) || []; + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载课表失败')); + return []; + } + }, + }); - const fetchSchedules = useCallback(async () => { - if (!id) return; - try { - const params: Record = {}; - if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD'); - if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD'); - const res = await api.get(`/classes/${id}/schedule`, { params }); - setSchedules(res || []); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载课表失败'); - } - }, [id, scheduleDateRange]); - - useEffect(() => { - fetchSchedules(); - }, [fetchSchedules]); - - const fetchAttendanceSummary = useCallback(async () => { - if (!id) return; - try { - const params: Record = {}; - if (attendanceDateRange?.[0]) params.startDate = attendanceDateRange[0].format('YYYY-MM-DD'); - if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD'); - const res = await api.get(`/classes/${id}/attendance-summary`, { params }); - setAttendanceSummary(res || null); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载出勤汇总失败'); - } - }, [id, attendanceDateRange]); - - useEffect(() => { - fetchAttendanceSummary(); - }, [fetchAttendanceSummary]); + const { data: attendanceSummary = null } = useQuery({ + queryKey: ['classes', 'attendance-summary', id, attendanceDateRange], + queryFn: async () => { + if (!id) return null; + try { + const params: Record = {}; + if (attendanceDateRange?.[0]) + params.startDate = attendanceDateRange[0].format('YYYY-MM-DD'); + if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD'); + return ( + (await api.get(`/classes/${id}/attendance-summary`, { + params, + })) || null + ); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载出勤汇总失败')); + return null; + } + }, + }); const handleSaveInfo = async () => { try { @@ -257,8 +140,7 @@ const ClassDetailPage: React.FC = () => { fetchDetail(); message.success('已更新'); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '更新失败'); + message.error(getErrorMessage(e, '更新失败')); } }; @@ -268,8 +150,7 @@ const ClassDetailPage: React.FC = () => { fetchDetail(); message.success('已移除'); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '移除失败'); + message.error(getErrorMessage(e, '移除失败')); } }; @@ -282,8 +163,7 @@ const ClassDetailPage: React.FC = () => { fetchDetail(); message.success('已添加'); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '添加失败'); + message.error(getErrorMessage(e, '添加失败')); } }; @@ -299,8 +179,7 @@ const ClassDetailPage: React.FC = () => { fetchDetail(); message.success('已添加'); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '添加失败'); + message.error(getErrorMessage(e, '添加失败')); } }; @@ -310,8 +189,7 @@ const ClassDetailPage: React.FC = () => { fetchDetail(); message.success('已移除'); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '移除失败'); + message.error(getErrorMessage(e, '移除失败')); } }; @@ -324,8 +202,7 @@ const ClassDetailPage: React.FC = () => { setSelectedStudentIds([]); setStudentModalOpen(true); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载学员列表失败'); + message.error(getErrorMessage(e, '加载学员列表失败')); } }; @@ -337,8 +214,7 @@ const ClassDetailPage: React.FC = () => { setTeacherSubject(''); setTeacherModalOpen(true); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载用户列表失败'); + message.error(getErrorMessage(e, '加载用户列表失败')); } }; @@ -347,82 +223,6 @@ const ClassDetailPage: React.FC = () => { if (!detail) return null; - const studentColumns: ColumnsType = [ - { title: '姓名', dataIndex: 'studentName' }, - { title: '学号', dataIndex: 'studentNo' }, - { title: '加入日期', dataIndex: 'joinDate' }, - { title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' }, - { - title: '状态', - dataIndex: 'status', - render: (v: string) => ( - {v === 'active' ? '在读' : '已离班'} - ), - }, - { - title: '操作', - render: (_: unknown, r: ClassStudent) => - r.status === 'active' ? ( - handleRemoveStudent(r.studentId)}> - - 移除 - - - ) : null, - }, - ]; - - const teacherColumns: ColumnsType = [ - { title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) }, - { - title: '角色', - dataIndex: 'roleType', - render: (v: string) => {ROLE_MAP[v] || v}, - }, - { - title: '科目', - dataIndex: 'subject', - render: (v: string | null) => v || '-', - }, - { - title: '操作', - render: (_: unknown, r: ClassTeacher) => ( - handleRemoveTeacher(r.userId)}> - - 移除 - - - ), - }, - ]; - - const scheduleColumns: ColumnsType = [ - { title: '教室', dataIndex: 'classroomName', render: (v: string | null) => 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.attendanceAdvanceMinutes ?? 30} 分钟至下课`, - }, - { - title: '日期范围', - render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}`, - }, - { title: '科目', dataIndex: 'subject' }, - { title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v }, - { - title: '状态', - dataIndex: 'status', - render: (v: string) => ( - {v === 'active' ? '启用' : v} - ), - }, - ]; - return ( { key: 'info', label: '基本信息', children: ( -
- {editingInfo ? ( -
- - - - - - - - - ({ - value: k, - label: v.text, - }))} - /> - - - - - - - - 保存 - - - -
- ) : ( -
- - - {TYPE_MAP[detail.classType]} - - - {detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'} - - - {detail.endDate ? dayjs(detail.endDate).format('YYYY-MM-DD') : '-'} - - - {detail.studentCount}/{detail.maxStudents || '-'} - - - {(() => { - const headTeacher = teachers.find( - (teacher) => teacher.roleType === 'head_teacher', - ); - return headTeacher ? getTeacherName(headTeacher) : '-'; - })()} - - {detail.notes || '-'} - - { - editForm.setFieldsValue({ - name: detail.name, - code: detail.code, - classType: detail.classType, - startDate: detail.startDate ? dayjs(detail.startDate) : undefined, - endDate: detail.endDate ? dayjs(detail.endDate) : undefined, - maxStudents: detail.maxStudents, - status: detail.status, - notes: detail.notes, - }); - setEditingInfo(true); - }} - > - 编辑 - -
- )} -
+ { + editForm.setFieldsValue({ + name: detail.name, + code: detail.code, + classType: detail.classType, + startDate: detail.startDate ? dayjs(detail.startDate) : undefined, + endDate: detail.endDate ? dayjs(detail.endDate) : undefined, + maxStudents: detail.maxStudents, + status: detail.status, + notes: detail.notes, + }); + setEditingInfo(true); + }} + onCancel={() => setEditingInfo(false)} + getTeacherName={getTeacherName} + /> ), }, { key: 'students', label: `花名册 (${students.filter((s) => s.status === 'active').length})`, children: ( -
- } - type="primary" - onClick={openStudentModal} - style={{ marginBottom: 16, marginRight: 8 }} - > - 添加学员 - - } - onClick={() => { - const token = useUserStore.getState().token; - fetch(`/api/classes/${id}/roster/export`, { - headers: { Authorization: `Bearer ${token}` }, - }) - .then((res) => { - if (!res.ok) throw new Error('导出失败'); - return res.blob(); - }) - .then((blob) => { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `班级花名册-${detail?.name || id}.xlsx`; - a.click(); - URL.revokeObjectURL(url); - message.success('花名册导出成功'); - }) - .catch(() => message.error('花名册导出失败')); - }} - > - 导出花名册 - - - columns={studentColumns} - dataSource={students} - rowKey="id" - pagination={{ - defaultPageSize: 20, - showSizeChanger: true, - pageSizeOptions: [20, 50, 100], - }} - /> - setStudentModalOpen(false)} - > - - setTeacherSubject(e.target.value)} - /> - )} - - -
+ setTeacherModalOpen(false)} + onRemove={handleRemoveTeacher} + onRoleChange={setTeacherRole} + onSubjectChange={setTeacherSubject} + onUserChange={setTeacherUserId} + getTeacherName={getTeacherName} + /> ), }, { key: 'schedule', label: '课表', children: ( -
- - - setScheduleDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]) - } - placeholder={['开始日期', '结束日期']} - /> - - - columns={scheduleColumns} - dataSource={schedules} - rowKey="id" - scroll={{ x: 'max-content' }} - pagination={{ - defaultPageSize: 20, - showSizeChanger: true, - pageSizeOptions: [20, 50, 100], - }} - /> -
+ ), }, { key: 'attendance-summary', label: '出勤汇总', children: ( -
- - - setAttendanceDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]) - } - placeholder={['开始日期', '结束日期']} - /> - - {attendanceSummary && ( - - - - - - - - - - - - - - - - - - - - - - - )} -
+ ), }, ]} diff --git a/apps/admin/src/pages/Classes/index.tsx b/apps/admin/src/pages/Classes/index.tsx index 80c65be..e12af51 100644 --- a/apps/admin/src/pages/Classes/index.tsx +++ b/apps/admin/src/pages/Classes/index.tsx @@ -1,5 +1,11 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React, { useState, useMemo, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { classesSchema } from '../../api/schemas'; import { + App, Table, Button, Input, @@ -17,14 +23,13 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; - -// ---- Types ---- +import { usePermission } from '../../hooks/usePermission'; interface ClassItem { id: number; @@ -56,8 +61,6 @@ interface ClassFormValues { notes?: string; } -// ---- Constants ---- - const STATUS_MAP: Record = { enrolling: { color: 'blue', text: '招生中' }, active: { color: 'green', text: '在读' }, @@ -72,12 +75,11 @@ const TYPE_MAP: Record = { sprint: '冲刺营', }; -// ---- Component ---- - const ClassesPage: React.FC = () => { + const { modal } = App.useApp(); const navigate = useNavigate(); - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); + const { hasPermission } = usePermission(); + const canPurgeClass = hasPermission('class:purge'); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [searchText, setSearchText] = useState(''); @@ -89,34 +91,74 @@ const ClassesPage: React.FC = () => { const handleArchive = async (id: number, archive: boolean) => { try { - await api.put(`/classes/${id}/${archive ? 'archive' : 'restore'}`); + await archiveMutation.mutateAsync({ id, archive }); message.success(archive ? '已归档' : '已恢复'); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; - const fetchData = useCallback(async () => { - setLoading(true); - try { - const params: Record = {}; - if (filterStatus) params.status = filterStatus; - if (filterType) params.classType = filterType; - params.isArchived = showArchived; - const res = await api.get('/classes', { params } as Record); - setData(res); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - } finally { - setLoading(false); - } - }, [filterStatus, filterType, showArchived]); + const handlePurge = (record: ClassItem) => { + modal.confirm({ + title: `永久删除班级「${record.name}」?`, + content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; - useEffect(() => { - fetchData(); - }, [fetchData]); + const { + data = [], + isLoading, + isFetching, + } = useQuery({ + queryKey: ['classes', filterStatus, filterType, showArchived], + queryFn: async () => { + try { + const params: Record = {}; + if (filterStatus) params.status = filterStatus; + if (filterType) params.classType = filterType; + params.isArchived = showArchived; + return validateResponse( + classesSchema, + await api.get('/classes', { params } as Record), + ); + } catch (e: any) { + message.error(e?.message || '加载失败,请稍后重试'); + return []; + } + }, + }); + const loading = isLoading || isFetching; + + const saveMutation = useApiMutation( + async (payload: Record) => + editing ? api.put(`/classes/${editing.id}`, payload) : api.post('/classes', payload), + { invalidate: [['classes']] }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: ClassItem; field: string; value: unknown }) => + api.put(`/classes/${record.id}`, { [field]: value }), + { invalidate: [['classes']] }, + ); + const archiveMutation = useApiMutation( + async ({ id, archive }: { id: number; archive: boolean }) => + api.put(`/classes/${id}/${archive ? 'archive' : 'restore'}`), + { invalidate: [['classes']] }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/classes/${id}/permanent`), + { invalidate: [['classes']] }, + ); const filtered = useMemo(() => { if (!searchText) return data; @@ -152,17 +194,11 @@ const ClassesPage: React.FC = () => { startDate: values.startDate?.format('YYYY-MM-DD'), endDate: values.endDate?.format('YYYY-MM-DD'), }; - if (editing) { - await api.put(`/classes/${editing.id}`, payload); - message.success('更新成功'); - } else { - await api.post('/classes', payload); - message.success('创建成功'); - } + await saveMutation.mutateAsync(payload); + message.success(editing ? '更新成功' : '创建成功'); setModalOpen(false); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -170,11 +206,14 @@ const ClassesPage: React.FC = () => { const saveCell = useCallback( async (record: ClassItem, field: string, value: unknown) => { - await api.put(`/classes/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }, - [fetchData], + [saveCellMutation], ); const columns: ColumnsType = useMemo( @@ -196,6 +235,7 @@ const ClassesPage: React.FC = () => { ), }, + { title: '编码', dataIndex: 'code', @@ -212,6 +252,7 @@ const ClassesPage: React.FC = () => { ), }, + { title: '班型', dataIndex: 'classType', @@ -229,6 +270,7 @@ const ClassesPage: React.FC = () => { ), }, + { title: '开班日期', dataIndex: 'startDate', @@ -245,6 +287,7 @@ const ClassesPage: React.FC = () => { ), }, + { title: '学员', width: 100, @@ -259,6 +302,7 @@ const ClassesPage: React.FC = () => { >{`${r.studentCount || 0}/${r.maxStudents || '-'}`} ), }, + { title: '状态', dataIndex: 'status', @@ -282,6 +326,7 @@ const ClassesPage: React.FC = () => { ), }, + { title: '操作', width: 280, @@ -298,11 +343,18 @@ const ClassesPage: React.FC = () => { 编辑 {r.isArchived ? ( - handleArchive(r.id, false)}> - - 恢复 - - + <> + handleArchive(r.id, false)}> + + 恢复 + + + {canPurgeClass ? ( + + ) : null} + ) : ( { ), }, ], - [saveCell], + [saveCell, canPurgeClass, handlePurge], ); return ( diff --git a/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx b/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx new file mode 100644 index 0000000..9a58f71 --- /dev/null +++ b/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx @@ -0,0 +1,340 @@ +import React from 'react'; +import { + Button, + Empty, + Popconfirm, + Space, + Table, + Tag, + Tooltip, + Upload, +} from 'antd'; +import { + CheckOutlined, + FileTextOutlined, + StopOutlined, + UploadOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import PermissionButton from '../../components/PermissionButton'; +import EditableCell from '../../components/EditableCell'; +import { message } from '../../ui/app-message'; + +const RENTAL_FIELDS = { + classroomId: 'classroomId', + lesseeOrganizationId: 'lesseeOrganizationId', + startDate: 'startDate', + endDate: 'endDate', + dailyRate: 'dailyRate', + totalAmount: 'totalAmount', +} as const; + +export interface RentalTableProps { + data: any[]; + loading: boolean; + classrooms: any[]; + organizations: any[]; + canPurgeRental: boolean; + hasPermission: (permission: string) => boolean; + onSaveCell: (record: any, field: string, value: unknown) => Promise | void; + onEdit: (record: any) => void; + onAction: (id: number, action: 'cancel' | 'end') => void; + onArchive: (id: number) => void; + onPurge: (id: number, name: string) => void; + onDownloadContract: (id: number, filename?: string) => void; + onDeleteContract: (id: number) => void; + onUploadContract: (id: number, formData: FormData) => Promise; +} + +export const RentalTable: React.FC = ({ + data, + loading, + classrooms, + organizations, + canPurgeRental, + hasPermission, + onSaveCell, + onEdit, + onAction, + onArchive, + onPurge, + onDownloadContract, + onDeleteContract, + onUploadContract, +}) => { + const EditableRentalCell = ({ + value, + field, + record, + editor, + min, + required, + options, + children, + }: { + value: unknown; + field: string; + record: R; + editor?: React.ComponentProps['editor']; + min?: number; + required?: boolean; + options?: Array<{ value: string | number; label: string }>; + children?: React.ReactNode; + }) => ( + { + await onSaveCell(record, field, next); + }} + > + {children ?? String(value ?? '-')} + + ); + + const columns = [ + { + title: '教室', + width: 120, + dataIndex: 'classroom', + render: (c: any, r: any) => ( + item.status !== 'archived') + .map((item) => ({ + value: item.id, + label: item.building ? `${item.building} · ${item.name}` : item.name, + }))} + required + > + {c ? ( + + {c.building ? `${c.building} · ` : ''} + {c.name} + + ) : ( + '-' + )} + + ), + }, + { + title: '承租机构', + width: 100, + dataIndex: 'lesseeOrganization', + render: (t: any, r: any) => ( + item.status !== 'archived') + .map((item) => ({ value: item.id, label: item.name }))} + required + > + {t ? ( + + {t.name} + + ) : ( + '-' + )} + + ), + }, + { + title: '开始日期', + dataIndex: 'startDate', + width: 110, + render: (v: string, r: any) => ( + + {v} + + ), + }, + { + title: '结束日期', + dataIndex: 'endDate', + width: 110, + render: (v: string, r: any) => ( + + {v} + + ), + }, + { + title: '时长', + width: 80, + render: (_: any, r: any) => { + const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1; + return `${d}天`; + }, + }, + { + title: '日租金', + dataIndex: 'dailyRate', + width: 100, + render: (v: any, r: any) => ( + + {v ? `¥${v}` : '-'} + + ), + }, + { + title: '总额', + dataIndex: 'totalAmount', + width: 100, + render: (v: any, r: any) => ( + + {v ? `¥${v}` : '-'} + + ), + }, + { + title: '状态', + dataIndex: 'effectiveStatus', + width: 90, + render: (status: string) => { + const config: Record = { + active: { text: '进行中', color: 'green' }, + ended: { text: '已结束', color: 'default' }, + cancelled: { text: '已取消', color: 'red' }, + }; + return {config[status]?.text || status}; + }, + }, + { + title: '合同', + width: 120, + dataIndex: 'contractPath', + render: (v: string, r: any) => + v ? ( + + + + + {hasPermission('rental:edit') ? ( + onDeleteContract(r.id)}> + + + ) : ( + '-' + ), + }, + { + title: '操作', + width: 150, + render: (_: any, record: any) => ( + + {record.effectiveStatus === 'active' && ( + <> + onEdit(record)}> + 编辑 + + onAction(record.id, 'cancel')}> + } + > + 取消 + + + {!dayjs(record.startDate).isAfter(dayjs(), 'day') && ( + onAction(record.id, 'end')}> + }> + 结束 + + + )} + + )} + {record.effectiveStatus !== 'active' && ( + onArchive(record.id)} + > + + 归档 + + + )} + {record.status === 'cancelled' && canPurgeRental ? ( + + ) : null} + + ), + }, + ]; + + return ( + }} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} + scroll={{ x: 1200 }} + /> + ); +}; diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index 3e15d40..d89d498 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -1,7 +1,7 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useImmer } from 'use-immer'; import { - Table, - Button, + App, Modal, Form, Select, @@ -9,39 +9,32 @@ import { InputNumber, Input, Space, - Tag, - Popconfirm, - Upload, - Tooltip, - Empty, } from 'antd'; -import { - PlusOutlined, - UploadOutlined, - FileTextOutlined, - StopOutlined, - CheckOutlined, -} from '@ant-design/icons'; +import { PlusOutlined } from '@ant-design/icons'; import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import { downloadBlob } from '../../utils/download'; import PermissionButton from '../../components/PermissionButton'; -import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { getErrorMessage } from '../../utils/error'; +import { validateResponse } from '../../utils/validate'; +import { classroomsSchema, organizationsSchema, rentalsSchema } from '../../api/schemas'; +import { RentalTable } from './RentalTable'; interface UnavailableDatesResponse { dates: string[]; } + export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) => `${classroomId}:${date.format('YYYY-MM')}`; const ClassroomRentalsPage: React.FC = () => { + const { modal } = App.useApp(); const { hasPermission, hasAnyPermission } = usePermission(); - const [data, setData] = useState([]); - const [classrooms, setClassrooms] = useState([]); - const [organizations, setOrganizations] = useState([]); - const [loading, setLoading] = useState(false); + const canPurgeRental = hasPermission('rental:purge'); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); @@ -49,12 +42,110 @@ const ClassroomRentalsPage: React.FC = () => { const [filterStatus, setFilterStatus] = useState(); const [searchText, setSearchText] = useState(''); const [saving, setSaving] = useState(false); - const [unavailableDates, setUnavailableDates] = useState>(new Set()); + const [unavailableDates, setUnavailableDates] = useImmer>(new Set()); const loadedUnavailableMonths = useRef>(new Set()); const unavailableRequestVersion = useRef(0); const [unavailableDatesLoading, setUnavailableDatesLoading] = useState(false); const selectedClassroomId = Form.useWatch('classroomId', form); + const { + data = [], + isLoading, + isFetching, + } = useQuery({ + queryKey: ['classroom-rentals', filterMonth], + queryFn: async () => { + try { + const params: any = {}; + if (filterMonth) params.month = filterMonth.format('YYYY-MM'); + params.includeEnded = true; + return validateResponse( + rentalsSchema, + await api.get('/classroom-rentals', { params }), + ); + } catch (e: any) { + message.error(e?.message || '加载失败,请稍后重试'); + return []; + } + }, + }); + const { + data: meta = { classrooms: [], organizations: [] }, + } = useQuery<{ classrooms: any[]; organizations: any[] }>({ + queryKey: ['classroom-rentals', 'meta'], + enabled: hasAnyPermission('rental:create', 'rental:edit'), + queryFn: async () => { + try { + const [cr, tn]: any = await Promise.all([ + api.get('/classrooms'), + api.get('/organizations', { params: { scope: 'all' } }), + ]); + return { + classrooms: validateResponse(classroomsSchema, cr), + organizations: validateResponse(organizationsSchema, tn), + }; + } catch (e: any) { + message.error(e?.message || '加载教室列表失败'); + return { classrooms: [], organizations: [] }; + } + }, + }); + const classrooms = meta.classrooms; + const organizations = meta.organizations; + const loading = isLoading || isFetching; + + const saveMutation = useApiMutation( + async (payload: Record) => + editing + ? api.put(`/classroom-rentals/${editing.id}`, payload) + : api.post('/classroom-rentals', payload), + { + invalidate: [['classroom-rentals']], + onError: (error: unknown) => { + const e = error as { + conflicts?: Array<{ organizationName?: string; startDate?: string; endDate?: string }>; + }; + if (e?.conflicts?.length) { + const list = e.conflicts + .map((c) => `${c.organizationName}(${c.startDate}~${c.endDate})`) + .join('、'); + message.error(`时间段冲突:${list}`); + } else { + message.error(getErrorMessage(error, '操作失败')); + } + }, + }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: any; field: string; value: unknown }) => + api.put(`/classroom-rentals/${record.id}`, { [field]: value }), + { invalidate: [['classroom-rentals']] }, + ); + const deleteMutation = useApiMutation( + async (id: number) => api.delete(`/classroom-rentals/${id}`), + { invalidate: [['classroom-rentals']] }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/classroom-rentals/${id}/permanent`), + { invalidate: [['classroom-rentals']] }, + ); + const actionMutation = useApiMutation( + async ({ id, action }: { id: number; action: 'cancel' | 'end' }) => + api.put(`/classroom-rentals/${id}/${action}`), + { invalidate: [['classroom-rentals']] }, + ); + const deleteContractMutation = useApiMutation( + async (id: number) => api.delete(`/classroom-rentals/${id}/contract`), + { invalidate: [['classroom-rentals']] }, + ); + const uploadContractMutation = useApiMutation( + async ({ id, formData }: { id: number; formData: FormData }) => + api.post(`/classroom-rentals/${id}/contract`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: [['classroom-rentals']] }, + ); + const filteredData = useMemo(() => { return data.filter((r: any) => { if (filterStatus && r.effectiveStatus !== filterStatus) return false; @@ -66,40 +157,6 @@ const ClassroomRentalsPage: React.FC = () => { }); }, [data, searchText, filterStatus]); - const fetchData = async () => { - setLoading(true); - try { - const params: any = {}; - if (filterMonth) params.month = filterMonth.format('YYYY-MM'); - params.includeEnded = true; - const res: any = await api.get('/classroom-rentals', { params }); - setData(res); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }; - - const fetchMeta = async () => { - try { - const [cr, tn]: any = await Promise.all([ - api.get('/classrooms'), - api.get('/organizations', { params: { scope: 'all' } }), - ]); - setClassrooms(cr); - setOrganizations(tn); - } catch (e: any) { - message.error(e?.message || '加载教室列表失败'); - } - }; - - useEffect(() => { - if (hasAnyPermission('rental:create', 'rental:edit')) fetchMeta(); - }, [hasAnyPermission]); - useEffect(() => { - fetchData(); - }, [filterMonth]); - const resetUnavailableDates = () => { unavailableRequestVersion.current += 1; loadedUnavailableMonths.current.clear(); @@ -127,10 +184,8 @@ const ClassroomRentalsPage: React.FC = () => { }, ); if (requestVersion !== unavailableRequestVersion.current) return; - setUnavailableDates((current) => { - const next = new Set(current); - response.dates.forEach((item) => next.add(item)); - return next; + setUnavailableDates((draft) => { + response.dates.forEach((item) => draft.add(item)); }); } catch (e: any) { loadedUnavailableMonths.current.delete(key); @@ -190,75 +245,85 @@ const ClassroomRentalsPage: React.FC = () => { notes: values.notes, }; try { - if (editing) { - await api.put(`/classroom-rentals/${editing.id}`, payload); - message.success('更新成功'); - } else { - await api.post('/classroom-rentals', payload); - message.success('创建成功'); - } + await saveMutation.mutateAsync(payload); + message.success(editing ? '更新成功' : '创建成功'); setModalOpen(false); form.resetFields(); setEditing(null); - fetchData(); - } catch (e: any) { - if (e?.conflicts?.length) { - const list = e.conflicts - .map((c: any) => `${c.organizationName}(${c.startDate}~${c.endDate})`) - .join('、'); - message.error(`时间段冲突:${list}`); - } else { - message.error(e?.message || '操作失败'); - } + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const saveCell = async (record: any, field: string, value: unknown) => { - await api.put(`/classroom-rentals/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }; const handleDelete = async (id: number) => { try { - await api.delete(`/classroom-rentals/${id}`); + await deleteMutation.mutateAsync(id); message.success('已归档'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; + const handlePurge = (id: number, name: string) => { + modal.confirm({ + title: `永久删除租赁订单(${name})?`, + content: '删除后不可恢复,排课与合同文件将被清除(存在考勤记录时将无法删除)。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + const handleRentalAction = async (id: number, action: 'cancel' | 'end') => { try { - await api.put(`/classroom-rentals/${id}/${action}`); + await actionMutation.mutateAsync({ id, action }); message.success(action === 'cancel' ? '租赁已取消' : '租赁已结束'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; const handleDownloadContract = async (id: number, filename?: string) => { try { await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`); - } catch { + } catch (e) { + console.error('下载合同失败', e); message.error('下载失败(可能文件已丢失)'); } }; const handleDeleteContract = async (id: number) => { try { - await api.delete(`/classroom-rentals/${id}/contract`); + await deleteContractMutation.mutateAsync(id); message.success('合同已移除'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '移除失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; + const handleUploadContract = async (id: number, formData: FormData) => { + return uploadContractMutation.mutateAsync({ id, formData }); + }; + const openEdit = (record: any) => { setEditing(record); resetUnavailableDates(); @@ -280,271 +345,6 @@ const ClassroomRentalsPage: React.FC = () => { ); }; - const columns = useMemo( - () => [ - { - title: '教室', - width: 120, - dataIndex: 'classroom', - render: (c: any, r: any) => ( - item.status !== 'archived') - .map((item) => ({ - value: item.id, - label: item.building ? `${item.building} · ${item.name}` : item.name, - }))} - permission="rental:edit" - disabled={r.effectiveStatus !== 'active'} - required - onSave={(next) => saveCell(r, 'classroomId', next)} - > - {c ? ( - - {c.building ? `${c.building} · ` : ''} - {c.name} - - ) : ( - '-' - )} - - ), - }, - { - title: '承租机构', - width: 100, - dataIndex: 'lesseeOrganization', - render: (t: any, r: any) => ( - item.status !== 'archived') - .map((item) => ({ value: item.id, label: item.name }))} - permission="rental:edit" - disabled={r.effectiveStatus !== 'active'} - required - onSave={(next) => saveCell(r, 'lesseeOrganizationId', next)} - > - {t ? ( - - {t.name} - - ) : ( - '-' - )} - - ), - }, - { - title: '开始日期', - dataIndex: 'startDate', - width: 110, - render: (v: string, r: any) => ( - saveCell(r, 'startDate', next)} - > - {v} - - ), - }, - { - title: '结束日期', - dataIndex: 'endDate', - width: 110, - render: (v: string, r: any) => ( - saveCell(r, 'endDate', next)} - > - {v} - - ), - }, - { - title: '时长', - width: 80, - render: (_: any, r: any) => { - const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1; - return `${d}天`; - }, - }, - { - title: '日租金', - dataIndex: 'dailyRate', - width: 100, - render: (v: any, r: any) => ( - saveCell(r, 'dailyRate', next)} - > - {v ? `¥${v}` : '-'} - - ), - }, - { - title: '总额', - dataIndex: 'totalAmount', - width: 100, - render: (v: any, r: any) => ( - saveCell(r, 'totalAmount', next)} - > - {v ? `¥${v}` : '-'} - - ), - }, - { - title: '状态', - dataIndex: 'effectiveStatus', - width: 90, - render: (status: string) => { - const config: Record = { - active: { text: '进行中', color: 'green' }, - ended: { text: '已结束', color: 'default' }, - cancelled: { text: '已取消', color: 'red' }, - }; - return {config[status]?.text || status}; - }, - }, - { - title: '合同', - width: 120, - dataIndex: 'contractPath', - render: (v: string, r: any) => - v ? ( - - - - - {hasPermission('rental:edit') ? ( - handleDeleteContract(r.id)}> - - - ) : ( - '-' - ), - }, - { - title: '操作', - width: 150, - render: (_: any, record: any) => ( - - {record.effectiveStatus === 'active' && ( - <> - openEdit(record)} - > - 编辑 - - handleRentalAction(record.id, 'cancel')} - > - } - > - 取消 - - - {!dayjs(record.startDate).isAfter(dayjs(), 'day') && ( - handleRentalAction(record.id, 'end')} - > - } - > - 结束 - - - )} - - )} - {record.effectiveStatus !== 'active' && ( - handleDelete(record.id)} - > - - 归档 - - - )} - - ), - }, - ], - [classrooms, organizations, hasPermission], - ); - return (
{ 新增租赁
-
}} - pagination={{ - defaultPageSize: 15, - showSizeChanger: true, - pageSizeOptions: [15, 30, 50, 100], - showTotal: (total) => `共 ${total} 条`, - }} - scroll={{ x: 1200 }} + classrooms={classrooms} + organizations={organizations} + canPurgeRental={canPurgeRental} + hasPermission={hasPermission} + onSaveCell={saveCell} + onEdit={openEdit} + onAction={handleRentalAction} + onArchive={handleDelete} + onPurge={handlePurge} + onDownloadContract={handleDownloadContract} + onDeleteContract={handleDeleteContract} + onUploadContract={handleUploadContract} /> { const [month, setMonth] = useState(dayjs()); - const [loading, setLoading] = useState(false); - const [data, setData] = useState(null); const [detailModal, setDetailModal] = useState(null); - const fetchData = useCallback(async () => { - setLoading(true); - try { - const res: any = await api.get('/classroom-rentals/schedule', { - params: { year: month.year(), month: month.month() + 1 }, - }); - setData(res); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, [month]); - - useEffect(() => { - fetchData(); - }, [fetchData]); + const { data, isLoading, isFetching } = useQuery({ + queryKey: ['classroom-rentals', 'schedule', month.year(), month.month()], + queryFn: async () => { + try { + return validateResponse( + classroomScheduleSchema, + await api.get('/classroom-rentals/schedule', { + params: { year: month.year(), month: month.month() + 1 }, + }), + ); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败,请稍后重试')); + return null; + } + }, + }); + const loading = isLoading || isFetching; // 按楼栋+楼层分组教室 const groups = useMemo(() => { @@ -88,8 +90,7 @@ const ClassroomSchedulePage: React.FC = () => { const res: any = await api.get(`/classroom-rentals/${rentalId}`); setDetailModal(res); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载详情失败'); + message.error(getErrorMessage(e, '加载详情失败')); } }; diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index dfe611a..ecc4e4f 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -1,5 +1,10 @@ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useState, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { classroomsSchema } from '../../api/schemas'; import { + App, Table, Button, Modal, @@ -50,9 +55,8 @@ const typeColor: Record = { }; const ClassroomsPage: React.FC = () => { + const { modal } = App.useApp(); const { hasPermission } = usePermission(); - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [showArchived, setShowArchived] = useState(false); @@ -62,6 +66,56 @@ const ClassroomsPage: React.FC = () => { const [saving, setSaving] = useState(false); + const { + data = [], + isLoading, + isFetching, + } = useQuery({ + queryKey: ['classrooms', showArchived], + queryFn: async () => { + try { + return validateResponse( + classroomsSchema, + await api.get('/classrooms', { params: { includeArchived: showArchived } }), + ); + } catch (e: any) { + message.error(e?.message || '加载失败,请稍后重试'); + return []; + } + }, + }); + const loading = isLoading || isFetching; + + const saveMutation = useApiMutation( + async (values: Record) => + editing ? api.put(`/classrooms/${editing.id}`, values) : api.post('/classrooms', values), + { invalidate: [['classrooms']] }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: any; field: string; value: unknown }) => + api.put(`/classrooms/${record.id}`, { [field]: value }), + { invalidate: [['classrooms']] }, + ); + const archiveMutation = useApiMutation( + async (id: number) => api.delete(`/classrooms/${id}`), + { invalidate: [['classrooms']] }, + ); + const restoreMutation = useApiMutation( + async (id: number) => api.put(`/classrooms/${id}/restore`), + { invalidate: [['classrooms']] }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/classrooms/${id}/permanent`), + { invalidate: [['classrooms']] }, + ); + const importMutation = useApiMutation( + async (formData: FormData) => + api.post('/classrooms/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: [['classrooms']] }, + ); + const filteredData = useMemo(() => { let result = data; if (searchText) { @@ -77,69 +131,67 @@ const ClassroomsPage: React.FC = () => { return result; }, [data, searchText, filterStatus]); - const fetchData = async () => { - setLoading(true); - try { - const res: any = await api.get('/classrooms', { params: { includeArchived: showArchived } }); - setData(res); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }; - - useEffect(() => { - fetchData(); - }, [showArchived]); - const handleSave = async () => { const values = await form.validateFields(); setSaving(true); try { - if (editing) { - await api.put(`/classrooms/${editing.id}`, values); - message.success('更新成功'); - } else { - await api.post('/classrooms', values); - message.success('创建成功'); - } + await saveMutation.mutateAsync(values); + message.success(editing ? '更新成功' : '创建成功'); setModalOpen(false); form.resetFields(); setEditing(null); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const saveCell = async (record: any, field: string, value: unknown) => { - await api.put(`/classrooms/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }; const handleArchive = async (id: number) => { try { - await api.delete(`/classrooms/${id}`); + await archiveMutation.mutateAsync(id); message.success('已归档'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; const handleRestore = async (id: number) => { try { - await api.put(`/classrooms/${id}/restore`); + await restoreMutation.mutateAsync(id); message.success('已恢复'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '恢复失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; + const handlePurge = (id: number, name: string) => { + modal.confirm({ + title: `永久删除教室「${name}」?`, + content: '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + const handleDownloadTemplate = () => { const baseURL = import.meta.env.PROD ? '/api' @@ -177,6 +229,7 @@ const ClassroomsPage: React.FC = () => { ), }, + { title: '楼栋', dataIndex: 'building', @@ -192,6 +245,7 @@ const ClassroomsPage: React.FC = () => { ), }, + { title: '楼层', dataIndex: 'floor', @@ -208,6 +262,7 @@ const ClassroomsPage: React.FC = () => { ), }, + { title: '类型', width: 90, @@ -225,6 +280,7 @@ const ClassroomsPage: React.FC = () => { ), }, + { title: '容量', dataIndex: 'capacity', @@ -242,6 +298,7 @@ const ClassroomsPage: React.FC = () => { ), }, + { title: '状态', width: 100, @@ -278,22 +335,35 @@ const ClassroomsPage: React.FC = () => { ); }, }, + { title: '操作', width: 180, render: (_: any, record: any) => ( {record.status === 'archived' ? ( - handleRestore(record.id)}> - } - type="link" - > - 恢复 - - + <> + handleRestore(record.id)}> + } + type="link" + > + 恢复 + + + {hasPermission('classroom:purge') ? ( + + ) : null} + ) : ( <> { ), }, ], - [], + [handlePurge, hasPermission], ); return ( @@ -413,15 +483,11 @@ const ClassroomsPage: React.FC = () => { const formData = new FormData(); formData.append('file', file); try { - const res: any = await api.post('/classrooms/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); + const res: any = await importMutation.mutateAsync(formData); message.success(res.message); onSuccess?.(res); - fetchData(); - } catch (e: any) { - message.error(e?.message || '导入失败'); - onError?.(e); + } catch (e) { + onError?.(e as Error); } }} > diff --git a/apps/admin/src/pages/Dashboard/Dashboard.types.ts b/apps/admin/src/pages/Dashboard/Dashboard.types.ts new file mode 100644 index 0000000..c5d1a2c --- /dev/null +++ b/apps/admin/src/pages/Dashboard/Dashboard.types.ts @@ -0,0 +1,128 @@ +import React from 'react'; + +export const COLORS = [ + '#007AFF', + '#34C759', + '#FF9500', + '#FF3B30', + '#5AC8FA', + '#AF52DE', + '#FF2D55', + '#FFCC00', +]; + +export interface BillStatRow { + status: string; + count: string; + total: string; +} +export interface ClassAttendanceRank { + className: string; + present: number; + total: number; + rate: number; +} +export interface ClassroomOccupancy { + name: string; + building: string; + capacity: number; + scheduleDays: number; + rentalCount: number; + occupancy: number; +} +export interface ClassroomUtilStats { + totalClassrooms: number; + inUseCount: number; + utilizationRate: string; + scheduleCount: number; + rentalCount: number; +} +export interface AttendanceTrendRow { + date: string; + rate: string; +} +export interface IncomeTrendRow { + month: string; + amount: number; +} +export interface OccupancyByBuildingRow { + building: string; + count: string; +} +export interface ExpenseByTypeRow { + type: string; + total: string; +} +export interface GanttOccupancy { + studentName: string; + studentId?: string; + checkInDate: string; + checkOutDate: string | null; + billingStartDate?: string; + billingEndDate?: string; +} +export interface GanttRoom { + roomNumber: string; + occupancies: GanttOccupancy[]; +} + +export interface DashboardStats { + totalRooms: number; + totalStudents: number; + occupiedBeds: number; + totalCapacity: number; + occupancyRate: string; + billStats: BillStatRow[]; + classroomCount: number; + classroomOccupancyRate: string; + todayAttendanceRate?: string; + monthlyIncome: number; + classCount: number; + teacherCount: number; + pendingDeposits: number; + activeRentals: number; + todayPresent: number; + occupancyByBuilding: OccupancyByBuildingRow[]; + attendanceByStatus: Record; + expenseByType: ExpenseByTypeRow[]; + attendanceTrend: AttendanceTrendRow[]; + incomeTrend: IncomeTrendRow[]; +} + +export const attendanceLabelMap: Record = { + present: '出勤', + absent: '缺勤', + late: '迟到', + early: '早退', + leave: '请假', +}; + +export const SECTION_ROW_STYLE: React.CSSProperties = { marginBottom: 24 }; +export const MARGIN_BOTTOM_16_STYLE: React.CSSProperties = { marginBottom: 16 }; + +export const TODO_CARD_BASE: React.CSSProperties = { + cursor: 'pointer', + transition: 'box-shadow 0.2s, transform 0.2s', + borderRadius: 8, + height: '100%', +}; +export const TODO_CARD_WARN: React.CSSProperties = { + ...TODO_CARD_BASE, + borderLeft: '4px solid #FF9500', + background: '#fff7e6', +}; +export const TODO_CARD_DANGER: React.CSSProperties = { + ...TODO_CARD_BASE, + borderLeft: '4px solid #FF3B30', + background: '#fff1f0', +}; +export const TODO_CARD_OK: React.CSSProperties = { + ...TODO_CARD_BASE, + borderLeft: '4px solid #34C759', + background: '#f0fff4', +}; +export const TODO_CARD_DRAFT: React.CSSProperties = { + ...TODO_CARD_BASE, + borderLeft: '4px solid #AF52DE', + background: '#f9f0ff', +}; diff --git a/apps/admin/src/pages/Dashboard/DashboardCharts.ts b/apps/admin/src/pages/Dashboard/DashboardCharts.ts new file mode 100644 index 0000000..641e53d --- /dev/null +++ b/apps/admin/src/pages/Dashboard/DashboardCharts.ts @@ -0,0 +1,262 @@ +import type { EChartsOption } from '../../components/ECharts'; +import { + attendanceLabelMap, + COLORS, + type AttendanceTrendRow, + type ClassAttendanceRank, + type ClassroomOccupancy, + type DashboardStats, + type ExpenseByTypeRow, + type GanttRoom, + type IncomeTrendRow, +} from './Dashboard.types'; + +export function buildAttendanceRingOption(stats: DashboardStats | null): EChartsOption { + return { + tooltip: { trigger: 'item' }, + legend: { bottom: 0 }, + series: [ + { + type: 'pie', + radius: ['40%', '70%'], + center: ['50%', '45%'], + data: Object.entries(stats?.attendanceByStatus ?? {}).map(([status, count]) => ({ + name: attendanceLabelMap[status] ?? status, + value: count, + })), + itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 }, + }, + ], + color: COLORS, + }; +} + +export function buildRoomRankingBarOption( + roomRanking: Array<{ roomNumber: string; total: string }>, +): EChartsOption { + return { + tooltip: {}, + grid: { left: 80, right: 20, bottom: 30, top: 10 }, + xAxis: { type: 'value' }, + yAxis: { + type: 'category', + data: roomRanking.map((r) => r.roomNumber).reverse(), + inverse: false, + }, + series: [ + { + type: 'bar', + data: roomRanking.map((r) => Number(r.total)).reverse(), + itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] }, + }, + ], + }; +} + +export function buildClassRankingOption( + rows: ClassAttendanceRank[], + color: string, +): EChartsOption { + return { + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + valueFormatter: (v: number) => `${v}%`, + }, + grid: { left: 80, right: 30, bottom: 30, top: 10 }, + xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } }, + yAxis: { + type: 'category', + data: rows.map((r) => r.className), + inverse: true, + }, + series: [ + { + type: 'bar', + data: rows.map((r) => r.rate), + itemStyle: { color, borderRadius: [0, 4, 4, 0] }, + label: { show: true, position: 'right', formatter: '{c}%' }, + }, + ], + }; +} + +export function buildAttendanceLineOption(rows: AttendanceTrendRow[]): EChartsOption { + return { + tooltip: { trigger: 'axis' }, + grid: { left: 50, right: 20, bottom: 30, top: 10 }, + xAxis: { + type: 'category', + data: rows.map((d) => d.date), + axisLabel: { rotate: 45, fontSize: 10 }, + }, + yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } }, + series: [ + { + type: 'line', + data: rows.map((d) => parseFloat(d.rate) || 0), + smooth: true, + lineStyle: { color: '#007AFF', width: 2 }, + itemStyle: { color: '#007AFF' }, + areaStyle: { color: 'rgba(0,122,255,0.1)' }, + }, + ], + }; +} + +export function buildIncomeLineOption(rows: IncomeTrendRow[]): EChartsOption { + return { + tooltip: { trigger: 'axis', valueFormatter: (v: number) => `¥${v.toLocaleString()}` }, + grid: { left: 70, right: 20, bottom: 30, top: 10 }, + xAxis: { + type: 'category', + data: rows.map((d) => d.month), + }, + yAxis: { + type: 'value', + axisLabel: { formatter: (v: number) => `¥${(v / 10000).toFixed(0)}万` }, + }, + series: [ + { + type: 'line', + data: rows.map((d) => d.amount), + smooth: true, + lineStyle: { color: '#34C759', width: 2 }, + itemStyle: { color: '#34C759' }, + areaStyle: { color: 'rgba(52,199,89,0.1)' }, + }, + ], + }; +} + +export function buildExpensePieOption( + rows: ExpenseByTypeRow[], + expenseTypeMap: Record, +): EChartsOption { + return { + tooltip: { trigger: 'item' }, + legend: { bottom: 0 }, + color: COLORS, + series: [ + { + type: 'pie', + radius: ['40%', '70%'], + center: ['50%', '45%'], + data: rows.map((e) => ({ + name: expenseTypeMap[e.type] ?? e.type, + value: Number(e.total), + })), + }, + ], + }; +} + +export function buildClassroomHeatmapOption( + classroomOccupancy: ClassroomOccupancy[], +): EChartsOption { + return { + tooltip: { + formatter: (p: { + name: string; + data: { scheduleDays: number; rentalCount: number; occupancy: number }; + }) => + `${p.name}
排课: ${p.data.scheduleDays}天 租赁: ${p.data.rentalCount}个 占用率: ${(p.data.occupancy * 100).toFixed(0)}%`, + }, + grid: { left: 100, right: 20, bottom: 30, top: 10 }, + xAxis: { type: 'value', max: 1 }, + yAxis: { + type: 'category', + data: classroomOccupancy.map((r) => r.name), + inverse: true, + }, + visualMap: { + min: 0, + max: 1, + orient: 'horizontal', + left: 'center', + bottom: 0, + inRange: { + color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'], + }, + }, + series: [ + { + type: 'bar', + data: classroomOccupancy.map((r) => ({ + name: r.name, + value: r.occupancy, + scheduleDays: r.scheduleDays, + rentalCount: r.rentalCount, + occupancy: r.occupancy, + })), + itemStyle: { borderRadius: [0, 4, 4, 0] }, + label: { + show: true, + position: 'right', + formatter: (p: { data: { occupancy: number } }) => + `${(p.data.occupancy * 100).toFixed(0)}%`, + }, + }, + ], + }; +} + +export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption { + return { + tooltip: { + formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) => + `${p.data.name}
入住: ${p.data.value[1]}
退宿: ${p.data.value[2]}`, + }, + grid: { left: 100, right: 30, bottom: 40, top: 20 }, + xAxis: { type: 'time' }, + yAxis: { type: 'category', data: ganttData.map((r) => r.roomNumber), inverse: true }, + dataZoom: [ + { type: 'slider', xAxisIndex: 0, bottom: 10, height: 20 }, + { type: 'inside', xAxisIndex: 0 }, + ], + series: [ + { + type: 'custom', + renderItem: ( + _params: unknown, + api: { + value: (i: number) => string | boolean; + coord: (p: [string | number, string | number]) => [number, number]; + size: (p: [number, number]) => [number, number]; + }, + ) => { + const cat = String(api.value(0)); + const startDate = String(api.value(1)); + const endDate = String(api.value(2)); + const isActive = Boolean(api.value(3)); + const start = api.coord([startDate, cat]); + const end = api.coord([endDate, cat]); + const height = api.size([0, 1])[1] * 0.6; + const rectShape = { + x: start[0], + y: start[1] - height / 2, + width: Math.max(end[0] - start[0], 2), + height, + }; + return { + type: 'rect' as const, + shape: rectShape, + style: { fill: isActive ? '#34C759' : '#FF9500', stroke: '#fff', lineWidth: 1 }, + }; + }, + encode: { x: [1, 2], y: 0 }, + data: ganttData.flatMap((r) => + (r.occupancies || []).map((o) => ({ + name: o.studentName, + value: [ + r.roomNumber, + o.checkInDate, + o.checkOutDate || new Date().toISOString().slice(0, 10), + !o.checkOutDate, + ] as [string, string, string, boolean], + })), + ), + }, + ], + }; +} diff --git a/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx b/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx new file mode 100644 index 0000000..f2f8554 --- /dev/null +++ b/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx @@ -0,0 +1,85 @@ +import React, { type CSSProperties } from 'react'; +import { Card, Col, Row } from 'antd'; +import { useIntersectionObserver } from 'usehooks-ts'; +import ReactECharts from '../../components/ECharts'; +import type { ClassroomOccupancy, GanttRoom } from './Dashboard.types'; +import { buildClassroomHeatmapOption, buildGanttOption } from './DashboardCharts'; + +const useInViewport = (rootMargin = '200px') => { + const { ref, isIntersecting } = useIntersectionObserver({ + rootMargin, + freezeOnceVisible: true, + }); + return { ref, inView: isIntersecting }; +}; + +const LazySection: React.FC<{ + title: string; + vp: { ref: (node?: Element | null) => void; inView: boolean }; + minHeight: number; + style?: CSSProperties; + children: React.ReactNode; +}> = ({ title, vp, minHeight, style, children }) => { + return ( +
+ {vp.inView ? ( + +
+ {children} + + + ) : ( + +
加载中…
+
+ )} + + ); +}; + +export const ClassroomHeatmapCard: React.FC<{ + data: ClassroomOccupancy[]; + isMobile: boolean; +}> = ({ data, isMobile }) => { + const vp = useInViewport('200px'); + return ( + + {data.length > 0 ? ( + + ) : ( +
暂无教室数据
+ )} +
+ ); +}; + +export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({ + data, + isMobile, +}) => { + const vp = useInViewport('200px'); + return ( + + {data.length > 0 ? ( + + ) : ( +
暂无入住数据
+ )} +
+ ); +}; diff --git a/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx b/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx new file mode 100644 index 0000000..9b71270 --- /dev/null +++ b/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { Card, Col, Row } from 'antd'; +import { + ArrowRightOutlined, + BankOutlined, + DollarOutlined, + ExclamationCircleOutlined, +} from '@ant-design/icons'; +import { useNavigate } from 'react-router'; +import { MARGIN_BOTTOM_16_STYLE, TODO_CARD_DANGER, TODO_CARD_DRAFT, TODO_CARD_OK, TODO_CARD_WARN } from './Dashboard.types'; + +export const DashboardTodoCards: React.FC<{ + absentCount: number; + draftCount: number; + draftTotal: number; + pendingDeposits: number; +}> = ({ absentCount, draftCount, draftTotal, pendingDeposits }) => { + const navigate = useNavigate(); + return ( + + + + 0 ? TODO_CARD_WARN : TODO_CARD_OK} + styles={{ body: { padding: 16 } }} + onClick={() => navigate('/attendance')} + > +
+ 0 ? '#FF9500' : '#999' }} + /> + +
+
+
0 ? '#FF9500' : '#999', + }} + > + {absentCount} +
+
今日缺勤人数
+ {absentCount > 0 ? ( +
需要关注
+ ) : ( +
全员到齐
+ )} +
+
+ + + + 0 ? TODO_CARD_DRAFT : TODO_CARD_OK} + styles={{ body: { padding: 16 } }} + onClick={() => navigate('/bills')} + > +
+ 0 ? '#AF52DE' : '#999' }} + /> + +
+
+
0 ? '#AF52DE' : '#999', + }} + > + {draftCount} +
+
待处理账单
+
0 ? '#AF52DE' : '#999', marginTop: 4 }} + > + {draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'} +
+
+
+ + + + 0 ? TODO_CARD_DANGER : TODO_CARD_OK} + styles={{ body: { padding: 16 } }} + onClick={() => navigate('/deposits')} + > +
+ 0 ? '#FF3B30' : '#999' }} + /> + +
+
+
0 ? '#FF3B30' : '#999', + }} + > + ¥{pendingDeposits.toLocaleString()} +
+
待退押金
+
0 ? '#FF3B30' : '#999', + marginTop: 4, + }} + > + {pendingDeposits > 0 ? '需要处理' : '暂无待退'} +
+
+
+ + + + ); +}; diff --git a/apps/admin/src/pages/Dashboard/index.tsx b/apps/admin/src/pages/Dashboard/index.tsx index 94f8a10..055555c 100644 --- a/apps/admin/src/pages/Dashboard/index.tsx +++ b/apps/admin/src/pages/Dashboard/index.tsx @@ -1,4 +1,15 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { validateResponse } from '../../utils/validate'; +import { + classAttendanceRankingSchema, + classroomOccupanciesSchema, + classroomUtilStatsSchema, + dashboardStatsSchema, + expenseTypesSchema, + ganttRoomsSchema, + roomRankingSchema, +} from '../../api/schemas'; import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, Collapse } from 'antd'; import { TeamOutlined, @@ -11,460 +22,142 @@ import { FileProtectOutlined, ReadOutlined, CalendarOutlined, - ArrowRightOutlined, - ExclamationCircleOutlined, - DollarOutlined, } from '@ant-design/icons'; -import ReactECharts, { type EChartsOption } from '../../components/ECharts'; +import ReactECharts from '../../components/ECharts'; import dayjs from 'dayjs'; -import { useNavigate } from 'react-router-dom'; import api from '../../api'; import { message } from '../../ui/app-message'; +import { + buildAttendanceLineOption, + buildAttendanceRingOption, + buildClassRankingOption, + buildExpensePieOption, + buildIncomeLineOption, + buildRoomRankingBarOption, +} from './DashboardCharts'; +import { ClassroomHeatmapCard, GanttCard } from './DashboardLazyCards'; +import { + MARGIN_BOTTOM_16_STYLE, + SECTION_ROW_STYLE, + type ClassAttendanceRank, + type ClassroomOccupancy, + type ClassroomUtilStats, + type DashboardStats, + type GanttRoom, +} from './Dashboard.types'; +import { DashboardTodoCards } from './DashboardTodoCards'; const { RangePicker } = DatePicker; -const COLORS = [ - '#007AFF', - '#34C759', - '#FF9500', - '#FF3B30', - '#5AC8FA', - '#AF52DE', - '#FF2D55', - '#FFCC00', -]; - -interface BillStatRow { - status: string; - count: string; - total: string; -} -interface ClassAttendanceRank { - className: string; - present: number; - total: number; - rate: number; -} -interface ClassroomOccupancy { - name: string; - building: string; - capacity: number; - scheduleDays: number; - rentalCount: number; - occupancy: number; -} -interface ClassroomUtilStats { - totalClassrooms: number; - inUseCount: number; - utilizationRate: string; - scheduleCount: number; - rentalCount: number; -} -interface AttendanceTrendRow { - date: string; - rate: string; -} -interface IncomeTrendRow { - month: string; - amount: number; -} -interface OccupancyByBuildingRow { - building: string; - count: string; -} -interface ExpenseByTypeRow { - type: string; - total: string; -} -interface GanttOccupancy { - studentName: string; - studentId?: string; - checkInDate: string; - checkOutDate: string | null; - billingStartDate?: string; - billingEndDate?: string; -} -interface GanttRoom { - roomNumber: string; - occupancies: GanttOccupancy[]; -} - -interface DashboardStats { - totalRooms: number; - totalStudents: number; - occupiedBeds: number; - totalCapacity: number; - occupancyRate: string; - billStats: BillStatRow[]; - classroomCount: number; - classroomOccupancyRate: string; - todayAttendanceRate: string; - monthlyIncome: number; - classCount: number; - teacherCount: number; - pendingDeposits: number; - activeRentals: number; - todayPresent: number; - occupancyByBuilding: OccupancyByBuildingRow[]; - attendanceByStatus: Record; - expenseByType: ExpenseByTypeRow[]; - attendanceTrend: AttendanceTrendRow[]; - incomeTrend: IncomeTrendRow[]; -} - -const attendanceLabelMap: Record = { - present: '出勤', - absent: '缺勤', - late: '迟到', - early: '早退', - leave: '请假', -}; - -const SECTION_ROW_STYLE: React.CSSProperties = { marginBottom: 24 }; -const MARGIN_BOTTOM_16_STYLE: React.CSSProperties = { marginBottom: 16 }; - -// ─── 待办卡片样式 ─── -const TODO_CARD_BASE: React.CSSProperties = { - cursor: 'pointer', - transition: 'box-shadow 0.2s, transform 0.2s', - borderRadius: 8, - height: '100%', -}; -const TODO_CARD_WARN: React.CSSProperties = { - ...TODO_CARD_BASE, - borderLeft: '4px solid #FF9500', - background: '#fff7e6', -}; -const TODO_CARD_DANGER: React.CSSProperties = { - ...TODO_CARD_BASE, - borderLeft: '4px solid #FF3B30', - background: '#fff1f0', -}; -const TODO_CARD_OK: React.CSSProperties = { - ...TODO_CARD_BASE, - borderLeft: '4px solid #34C759', - background: '#f0fff4', -}; -const TODO_CARD_DRAFT: React.CSSProperties = { - ...TODO_CARD_BASE, - borderLeft: '4px solid #AF52DE', - background: '#f9f0ff', -}; - -// ─── IntersectionObserver 自定义 hook ─── -// 用 callback ref 注册 observer,避免元素在首屏 loading 后才挂载、 -// 而 effect 因依赖不变不再重跑导致 observer 从未注册的问题。 -const useInViewport = (rootMargin = '200px') => { - const [inView, setInView] = useState(false); - const observerRef = useRef(null); - - const ref = useCallback( - (el: HTMLDivElement | null) => { - observerRef.current?.disconnect(); - if (!el) return; - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - setInView(true); - observer.disconnect(); - } - }, - { rootMargin }, - ); - observer.observe(el); - observerRef.current = observer; - }, - [rootMargin], - ); - - return { ref, inView }; -}; - const DashboardPage: React.FC = () => { const screens = Grid.useBreakpoint(); const isMobile = !screens.sm; - const navigate = useNavigate(); - const [stats, setStats] = useState(null); - const [classRanking, setClassRanking] = useState<{ - top: ClassAttendanceRank[]; - bottom: ClassAttendanceRank[]; - }>({ top: [], bottom: [] }); - const [classroomOccupancy, setClassroomOccupancy] = useState([]); - const [ganttData, setGanttData] = useState([]); - const [roomRanking, setRoomRanking] = useState>([]); - const [classroomUtil, setClassroomUtil] = useState(null); - const [loading, setLoading] = useState(true); - const [refreshLoading, setRefreshLoading] = useState(false); - const loadedRef = useRef(false); const [period, setPeriod] = useState<[string, string]>([ dayjs().startOf('month').format('YYYY-MM-DD'), dayjs().endOf('month').format('YYYY-MM-DD'), ]); - const fetchData = useCallback(async () => { - const isRefresh = loadedRef.current; - if (isRefresh) { - setRefreshLoading(true); - } else { - setLoading(true); - } - try { - const [s, rr, cr, g, co, cu] = await Promise.all([ - api.get('/dashboard/stats'), - api.get>('/dashboard/room-ranking', { - params: { periodStart: period[0], periodEnd: period[1] }, - }), - api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>( - '/dashboard/class-attendance-ranking', - ), - api.get('/dashboard/gantt', { - params: { periodStart: period[0], periodEnd: period[1] }, - }), - api.get('/dashboard/classroom-occupancy'), - api.get('/dashboard/classroom-utilization'), - ]); - setStats(s); - setRoomRanking(rr); - setClassRanking(cr); - setGanttData(g); - setClassroomOccupancy(co); - setClassroomUtil(cu); - loadedRef.current = true; - } catch (e) { - console.error(e); - message.error('数据加载失败,请稍后重试'); - } - setLoading(false); - setRefreshLoading(false); - }, [period]); + const { + data: fetchResult = { + stats: null, + classRanking: { top: [], bottom: [] }, + classroomOccupancy: [], + ganttData: [], + roomRanking: [], + classroomUtil: null, + }, + isLoading, + isFetching, + } = useQuery<{ + stats: DashboardStats | null; + classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }; + classroomOccupancy: ClassroomOccupancy[]; + ganttData: GanttRoom[]; + roomRanking: Array<{ roomNumber: string; total: string }>; + classroomUtil: ClassroomUtilStats | null; + }>({ + queryKey: ['dashboard', period], + queryFn: async () => { + try { + const [s, rr, cr, g, co, cu] = await Promise.all([ + api.get('/dashboard/stats'), + api.get>('/dashboard/room-ranking', { + params: { periodStart: period[0], periodEnd: period[1] }, + }), + api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>( + '/dashboard/class-attendance-ranking', + ), + api.get('/dashboard/gantt', { + params: { periodStart: period[0], periodEnd: period[1] }, + }), + api.get('/dashboard/classroom-occupancy'), + api.get('/dashboard/classroom-utilization'), + ]); + return { + stats: validateResponse(dashboardStatsSchema, s), + roomRanking: validateResponse>( + roomRankingSchema, + rr, + ), + classRanking: validateResponse<{ + top: ClassAttendanceRank[]; + bottom: ClassAttendanceRank[]; + }>(classAttendanceRankingSchema, cr), + ganttData: validateResponse(ganttRoomsSchema, g), + classroomOccupancy: validateResponse( + classroomOccupanciesSchema, + co, + ), + classroomUtil: validateResponse(classroomUtilStatsSchema, cu), + }; + } catch (e) { + console.error(e); + message.error('数据加载失败,请稍后重试'); + return { + stats: null, + classRanking: { top: [], bottom: [] }, + classroomOccupancy: [], + ganttData: [], + roomRanking: [], + classroomUtil: null, + }; + } + }, + }); + const stats = fetchResult.stats; + const classRanking = fetchResult.classRanking; + const classroomOccupancy = fetchResult.classroomOccupancy; + const ganttData = fetchResult.ganttData; + const roomRanking = fetchResult.roomRanking; + const classroomUtil = fetchResult.classroomUtil; + const loading = isLoading; + const refreshLoading = isFetching && !isLoading; - useEffect(() => { - fetchData(); - }, [fetchData]); - - const [expenseTypeMap, setExpenseTypeMap] = useState>({}); - - useEffect(() => { - api - .get>('/expense-types') - .then((types) => { + const { data: expenseTypeMap = {} } = useQuery>({ + queryKey: ['expense-types', 'map'], + queryFn: async () => { + try { + const types = validateResponse>( + expenseTypesSchema, + await api.get>('/expense-types'), + ); const map: Record = {}; for (const t of types) map[t.code] = t.name; - setExpenseTypeMap(map); - }) - .catch(() => {}); - }, []); - - // ─── 图表 option 计算(保留全部原有逻辑) ─── - - // 今日出勤状态分布环图 - const attendanceRingOption = useMemo( - () => ({ - tooltip: { trigger: 'item' }, - legend: { bottom: 0 }, - series: [ - { - type: 'pie', - radius: ['40%', '70%'], - center: ['50%', '45%'], - data: Object.entries(stats?.attendanceByStatus ?? {}).map(([status, count]) => ({ - name: attendanceLabelMap[status] ?? status, - value: count, - })), - itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 }, - }, - ], - color: COLORS, - }), - [stats?.attendanceByStatus], - ); - - // 宿舍费用排行 - const barOption = useMemo( - () => ({ - tooltip: {}, - grid: { left: 80, right: 20, bottom: 30, top: 10 }, - xAxis: { type: 'value' }, - yAxis: { - type: 'category', - data: roomRanking.map((r) => r.roomNumber).reverse(), - inverse: false, - }, - series: [ - { - type: 'bar', - data: roomRanking.map((r) => Number(r.total)).reverse(), - itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] }, - }, - ], - }), - [roomRanking], - ); - - // 班级考勤排行 - 前5 - const classRankingTopOption: EChartsOption = { - tooltip: { - trigger: 'axis', - axisPointer: { type: 'shadow' }, - valueFormatter: (v: number) => `${v}%`, + return map; + } catch { + return {}; + } }, - grid: { left: 80, right: 30, bottom: 30, top: 10 }, - xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } }, - yAxis: { - type: 'category', - data: classRanking.top.map((r) => r.className), - inverse: true, - }, - series: [ - { - type: 'bar', - data: classRanking.top.map((r) => r.rate), - itemStyle: { color: '#34C759', borderRadius: [0, 4, 4, 0] }, - label: { show: true, position: 'right', formatter: '{c}%' }, - }, - ], - }; + }); - // 班级考勤排行 - 后5 - const classRankingBottomOption: EChartsOption = { - tooltip: { - trigger: 'axis', - axisPointer: { type: 'shadow' }, - valueFormatter: (v: number) => `${v}%`, - }, - grid: { left: 80, right: 30, bottom: 30, top: 10 }, - xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } }, - yAxis: { - type: 'category', - data: classRanking.bottom.map((r) => r.className), - inverse: true, - }, - series: [ - { - type: 'bar', - data: classRanking.bottom.map((r) => r.rate), - itemStyle: { color: '#FF3B30', borderRadius: [0, 4, 4, 0] }, - label: { show: true, position: 'right', formatter: '{c}%' }, - }, - ], - }; - - // 考勤趋势折线图 - const attendanceLineOption: EChartsOption = { - tooltip: { trigger: 'axis' }, - grid: { left: 50, right: 20, bottom: 30, top: 10 }, - xAxis: { - type: 'category', - data: (stats?.attendanceTrend || []).map((d: { date: string }) => d.date), - axisLabel: { rotate: 45, fontSize: 10 }, - }, - yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } }, - series: [ - { - type: 'line', - data: (stats?.attendanceTrend || []).map((d: { rate: string }) => parseFloat(d.rate) || 0), - smooth: true, - lineStyle: { color: '#007AFF', width: 2 }, - itemStyle: { color: '#007AFF' }, - areaStyle: { color: 'rgba(0,122,255,0.1)' }, - }, - ], - }; - - // 收入趋势折线图 - const incomeLineOption: EChartsOption = { - tooltip: { trigger: 'axis', valueFormatter: (v: number) => `¥${v.toLocaleString()}` }, - grid: { left: 70, right: 20, bottom: 30, top: 10 }, - xAxis: { - type: 'category', - data: (stats?.incomeTrend || []).map((d: { month: string }) => d.month), - }, - yAxis: { - type: 'value', - axisLabel: { formatter: (v: number) => `¥${(v / 10000).toFixed(0)}万` }, - }, - series: [ - { - type: 'line', - data: (stats?.incomeTrend || []).map((d: { amount: number }) => d.amount), - smooth: true, - lineStyle: { color: '#34C759', width: 2 }, - itemStyle: { color: '#34C759' }, - areaStyle: { color: 'rgba(52,199,89,0.1)' }, - }, - ], - }; - - // 入住时间线(甘特图) - const ganttOption = useMemo( - () => ({ - tooltip: { - formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) => - `${p.data.name}
入住: ${p.data.value[1]}
退宿: ${p.data.value[2]}`, - }, - grid: { left: 100, right: 30, bottom: 40, top: 20 }, - xAxis: { type: 'time' }, - yAxis: { type: 'category', data: ganttData.map((r) => r.roomNumber), inverse: true }, - dataZoom: [ - { type: 'slider', xAxisIndex: 0, bottom: 10, height: 20 }, - { type: 'inside', xAxisIndex: 0 }, - ], - series: [ - { - type: 'custom', - renderItem: ( - _params: unknown, - api: { - value: (i: number) => string | boolean; - coord: (p: [string | number, string | number]) => [number, number]; - size: (p: [number, number]) => [number, number]; - }, - ) => { - const [cat, startDate, endDate, isActive] = [ - api.value(0), - api.value(1), - api.value(2), - api.value(3), - ] as unknown as [string, string, string, boolean]; - const start = api.coord([startDate, cat]); - const end = api.coord([endDate, cat]); - const height = api.size([0, 1])[1] * 0.6; - const rectShape = { - x: start[0], - y: start[1] - height / 2, - width: Math.max(end[0] - start[0], 2), - height, - }; - return { - type: 'rect' as const, - shape: rectShape, - style: { fill: isActive ? '#34C759' : '#FF9500', stroke: '#fff', lineWidth: 1 }, - }; - }, - encode: { x: [1, 2], y: 0 }, - data: ganttData.flatMap((r) => - (r.occupancies || []).map((o) => ({ - name: o.studentName, - value: [ - r.roomNumber, - o.checkInDate, - o.checkOutDate || new Date().toISOString().slice(0, 10), - !o.checkOutDate, - ] as [string, string, string, boolean], - })), - ), - }, - ], - }), - [ganttData], - ); - - // ─── 懒加载 hooks ─── - const classroomHeatmapVp = useInViewport('200px'); - const ganttVp = useInViewport('200px'); - - // ─── 待办卡片数据 ─── const absentCount = stats?.attendanceByStatus?.['absent'] ?? 0; + const attendanceTotal = stats + ? Object.values(stats.attendanceByStatus).reduce((sum, n) => sum + Number(n || 0), 0) + : 0; + const presentCount = stats?.attendanceByStatus?.present ?? 0; + const todayAttendanceRate = + stats?.todayAttendanceRate ?? + (attendanceTotal > 0 ? ((presentCount / attendanceTotal) * 100).toFixed(1) : '0'); const draftBill = (stats?.billStats ?? []).find((b) => b.status === 'draft'); const draftCount = draftBill ? Number(draftBill.count) : 0; const draftTotal = draftBill ? Number(draftBill.total) : 0; @@ -499,118 +192,12 @@ const DashboardPage: React.FC = () => { {/* ═══════════ 待办与异常 ═══════════ */} - - - {/* 今日缺勤 */} -
- 0 ? TODO_CARD_WARN : TODO_CARD_OK} - styles={{ body: { padding: 16 } }} - onClick={() => navigate('/attendance')} - > -
- 0 ? '#FF9500' : '#999' }} - /> - -
-
-
0 ? '#FF9500' : '#999', - }} - > - {absentCount} -
-
今日缺勤人数
- {absentCount > 0 ? ( -
需要关注
- ) : ( -
全员到齐
- )} -
-
- - - {/* 待处理账单 */} - - 0 ? TODO_CARD_DRAFT : TODO_CARD_OK} - styles={{ body: { padding: 16 } }} - onClick={() => navigate('/bills')} - > -
- 0 ? '#AF52DE' : '#999' }} - /> - -
-
-
0 ? '#AF52DE' : '#999', - }} - > - {draftCount} -
-
待处理账单
-
0 ? '#AF52DE' : '#999', marginTop: 4 }} - > - {draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'} -
-
-
- - - {/* 待退押金 */} - - 0 ? TODO_CARD_DANGER : TODO_CARD_OK} - styles={{ body: { padding: 16 } }} - onClick={() => navigate('/deposits')} - > -
- 0 ? '#FF3B30' : '#999' }} - /> - -
-
-
0 ? '#FF3B30' : '#999', - }} - > - ¥{pendingDeposits.toLocaleString()} -
-
待退押金
-
0 ? '#FF3B30' : '#999', - marginTop: 4, - }} - > - {pendingDeposits > 0 ? '需要处理' : '暂无待退'} -
-
-
- - - + {/* ═══════════ 核心 KPI ═══════════ */} @@ -638,7 +225,7 @@ const DashboardPage: React.FC = () => { } /> @@ -809,7 +396,7 @@ const DashboardPage: React.FC = () => { {(stats?.attendanceTrend || []).length > 0 ? ( ) : ( @@ -821,7 +408,7 @@ const DashboardPage: React.FC = () => { {Object.keys(stats?.attendanceByStatus ?? {}).length > 0 ? ( ) : ( @@ -837,7 +424,7 @@ const DashboardPage: React.FC = () => { {classRanking.top.length > 0 ? ( ) : ( @@ -849,7 +436,7 @@ const DashboardPage: React.FC = () => { {classRanking.bottom.length > 0 ? ( ) : ( @@ -865,24 +452,7 @@ const DashboardPage: React.FC = () => { {(stats?.expenseByType ?? []).length > 0 ? ( ({ - name: expenseTypeMap[e.type] ?? e.type, - value: Number(e.total), - })), - }, - ], - } satisfies EChartsOption - } + option={buildExpensePieOption(stats?.expenseByType ?? [], expenseTypeMap)} style={{ width: '100%', height: isMobile ? 250 : 300 }} /> ) : ( @@ -894,7 +464,7 @@ const DashboardPage: React.FC = () => { {roomRanking.length > 0 ? ( ) : ( @@ -910,7 +480,7 @@ const DashboardPage: React.FC = () => { {(stats?.incomeTrend || []).length > 0 ? ( ) : ( @@ -921,102 +491,10 @@ const DashboardPage: React.FC = () => { {/* ═══════════ 图表:教室占用热力图(懒加载) ═══════════ */} -
- {classroomHeatmapVp.inView ? ( - -
- - {classroomOccupancy.length > 0 ? ( - - `${p.name}
排课: ${p.data.scheduleDays}天 租赁: ${p.data.rentalCount}个 占用率: ${(p.data.occupancy * 100).toFixed(0)}%`, - }, - grid: { left: 100, right: 20, bottom: 30, top: 10 }, - xAxis: { type: 'value', max: 1 }, - yAxis: { - type: 'category', - data: classroomOccupancy.map((r) => r.name), - inverse: true, - }, - visualMap: { - min: 0, - max: 1, - orient: 'horizontal', - left: 'center', - bottom: 0, - inRange: { - color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'], - }, - }, - series: [ - { - type: 'bar', - data: classroomOccupancy.map((r) => ({ - name: r.name, - value: r.occupancy, - scheduleDays: r.scheduleDays, - rentalCount: r.rentalCount, - occupancy: r.occupancy, - })), - itemStyle: { borderRadius: [0, 4, 4, 0] }, - label: { - show: true, - position: 'right', - formatter: (p: { data: { occupancy: number } }) => - `${(p.data.occupancy * 100).toFixed(0)}%`, - }, - }, - ], - } satisfies EChartsOption - } - style={{ width: '100%', height: isMobile ? 300 : 400 }} - /> - ) : ( -
- 暂无教室数据 -
- )} -
- - - ) : ( - -
加载中…
-
- )} - + {/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */} -
- {ganttVp.inView ? ( - -
- - {ganttData.length > 0 ? ( - - ) : ( -
- 暂无入住数据 -
- )} -
- - - ) : ( - -
加载中…
-
- )} - + ); }; diff --git a/apps/admin/src/pages/Deposits/DepositModals.tsx b/apps/admin/src/pages/Deposits/DepositModals.tsx new file mode 100644 index 0000000..9a78039 --- /dev/null +++ b/apps/admin/src/pages/Deposits/DepositModals.tsx @@ -0,0 +1,423 @@ +import React from 'react'; +import { + Card, + DatePicker, + Empty, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Table, + Tag, +} from 'antd'; +import { DollarOutlined, InboxOutlined, PlusOutlined } from '@ant-design/icons'; +import PermissionButton from '../../components/PermissionButton'; +import EditableCell from '../../components/EditableCell'; +import type { DepositStudentLookup } from './deposit-student-option'; + +export interface DepositRecord { + id: number; + studentId: number; + amount: number; + status: string; + paidDate: string; + refundDate?: string | null; + notes?: string | null; + installments?: Array<{ + id: number; + amount: number; + dueDate: string; + paidDate?: string | null; + status: string; + }>; + student?: DepositStudentLookup; +} + +export interface EligibleStudent { + studentId: number; + studentName: string; + studentNo?: string | null; + roomId: number; + roomNumber: string; + building?: string | null; + roomType?: string | null; + capacity: number; + depositAmount: number; +} + +export const statusMap: Record = { + paid: { text: '有余额', color: 'green' }, + refunded: { text: '已全退', color: 'blue' }, + depleted: { text: '已扣完', color: 'red' }, +}; + +export const installmentStatusMap: Record = { + pending: { text: '待缴', color: 'orange' }, + paid: { text: '已缴', color: 'green' }, +}; + +export const roomTypeOptions = [ + { value: '单人间', label: '单人间' }, + { value: '四人间', label: '四人间' }, +]; + +export const suggestedDepositByRoomType: Record = { + 单人间: 200, + 四人间: 100, +}; + +export interface DepositModalsProps { + batchModal: boolean; + createModal: boolean; + refundModal: DepositRecord | null; + detailModal: DepositRecord | null; + installmentModal: number | null; + batchForm: ReturnType[0]; + createForm: ReturnType[0]; + refundForm: ReturnType[0]; + installmentForm: ReturnType[0]; + saving: boolean; + batchRoomType: string; + effectiveSelectedEligibleIds: number[]; + eligibleStudents: EligibleStudent[]; + eligibleLoading: boolean; + eligibleColumns: Array<{ title: string; render?: unknown; dataIndex?: string }>; + studentOptions: Array<{ value: number; label: string }>; + onBatchRoomTypeChange: (roomType: string) => void; + onBatchCreate: () => void; + onCreate: () => void; + onRefund: () => void; + onAddInstallment: () => void; + onPayInstallment: (installmentId: number) => void; + onSaveInstallmentCell: ( + installmentId: number, + field: 'status' | 'paidDate', + value: unknown, + ) => void; + onDeleteInstallment: (installmentId: number) => void; + onCloseBatch: () => void; + onCloseCreate: () => void; + onCloseRefund: () => void; + onCloseDetail: () => void; + onCloseInstallment: () => void; + onOpenInstallment: (id: number) => void; + onSelectEligible: (ids: number[]) => void; +} + +export const DepositModals: React.FC = ({ + batchModal, + createModal, + refundModal, + detailModal, + installmentModal, + batchForm, + createForm, + refundForm, + installmentForm, + saving, + batchRoomType, + effectiveSelectedEligibleIds, + eligibleStudents, + eligibleLoading, + eligibleColumns, + studentOptions, + onBatchRoomTypeChange, + onBatchCreate, + onCreate, + onRefund, + onAddInstallment, + onPayInstallment, + onSaveInstallmentCell, + onDeleteInstallment, + onCloseBatch, + onCloseCreate, + onCloseRefund, + onCloseDetail, + onCloseInstallment, + onOpenInstallment, + onSelectEligible, +}) => { + return ( + <> + +
+ + +
}} + pagination={{ pageSize: 6, showSizeChanger: false }} + rowSelection={{ + selectedRowKeys: effectiveSelectedEligibleIds, + onChange: (keys) => onSelectEligible(keys as number[]), + }} + /> + + + + + +
`¥${value.toFixed(2)}`, + }, + { title: '到期日', dataIndex: 'dueDate' }, + { + title: '实付日', + dataIndex: 'paidDate', + render: (value: string, item: any) => ( + + onSaveInstallmentCell(item.id, 'paidDate', next) + } + > + {value || '-'} + + ), + }, + { + title: '状态', + dataIndex: 'status', + render: (value: string, item: any) => ( + + onSaveInstallmentCell(item.id, 'status', next) + } + > + + {installmentStatusMap[value]?.text || value} + + + ), + }, + { + title: '操作', + render: (_: unknown, item: any) => ( + + {item.status === 'pending' && ( + } + onClick={() => onPayInstallment(item.id)} + > + 标记已缴 + + )} + onDeleteInstallment(item.id)} + > + } + > + 归档 + + + + ), + }, + ]} + /> + ) : ( +

暂无分期记录

+ )} + + )} + + + + + + + + + + + + + + ); +}; diff --git a/apps/admin/src/pages/Deposits/DepositTable.tsx b/apps/admin/src/pages/Deposits/DepositTable.tsx new file mode 100644 index 0000000..e0db067 --- /dev/null +++ b/apps/admin/src/pages/Deposits/DepositTable.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { Button, Empty, Popconfirm, Space, Table, Tag } from 'antd'; +import { DeleteOutlined, InboxOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import PermissionButton from '../../components/PermissionButton'; +import { message } from '../../ui/app-message'; +import { statusMap } from './DepositModals'; +import type { DepositRecord } from './DepositModals'; + +export interface DepositTableProps { + data: any[]; + loading: boolean; + canPurgeDeposit: boolean; + refundForm: ReturnType[0]; + onDetail: (record: DepositRecord) => void; + onRefund: (record: DepositRecord) => void; + onArchive: (id: number) => Promise | unknown; + onPurge: (id: number) => Promise | unknown; +} + +export const DepositTable: React.FC = ({ + data, + loading, + canPurgeDeposit, + refundForm, + onDetail, + onRefund, + onArchive, + onPurge, +}) => { + const columns = [ + { title: '学生', width: 120, render: (_: unknown, r: any) => r.student?.name || '-' }, + { + title: '当前可用押金', + dataIndex: 'amount', + width: 130, + render: (v: number) => `¥${Number(v || 0).toFixed(2)}`, + }, + { + title: '房间', + width: 120, + render: (_: unknown, r: any) => + r.roomNumber ? `${r.building ? `${r.building}-` : ''}${r.roomNumber}` : '-', + }, + { title: '房型', dataIndex: 'roomType', width: 100, render: (v: string) => v || '-' }, + { title: '最近收取日期', dataIndex: 'paidDate', width: 120, render: (v: string) => v || '-' }, + { + title: '状态', + dataIndex: 'status', + render: (s: string) => + s === 'unpaid' ? ( + 未缴 + ) : ( + {statusMap[s]?.text || s} + ), + }, + { title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: unknown) => v || '-' }, + { title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' }, + { + title: '操作', + width: 240, + render: (_: unknown, record: any) => { + const hasDeposit = typeof record.id === 'number'; + return ( + + {hasDeposit && ( + onDetail(record)}> + 详情 + + )} + {record.status === 'paid' && hasDeposit && ( + { + onRefund(record); + refundForm.setFieldsValue({ refundDate: dayjs() }); + }} + > + 退还 + + )} + {hasDeposit && ( + { + try { + await onArchive(record.id); + message.success('归档成功'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }} + > + } + > + 归档 + + + )} + {record.status === 'archived' && hasDeposit && canPurgeDeposit ? ( + { + try { + await onPurge(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }} + > + + + ) : null} + + ); + }, + }, + ]; + + return ( +
`共 ${total} 条`, + }} + locale={{ emptyText: }} + /> + ); +}; diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 5549800..60babc3 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -1,89 +1,41 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React, { useCallback, useMemo, useState } from 'react'; import { - Table, - Modal, Form, - Select, - DatePicker, - InputNumber, Input, + Select, Space, - Tag, - Popconfirm, - Card, - Empty, } from 'antd'; -import { PlusOutlined, InboxOutlined, DollarOutlined, TeamOutlined } from '@ant-design/icons'; +import { PlusOutlined, TeamOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; -import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option'; - -const statusMap: Record = { - paid: { text: '有余额', color: 'green' }, - refunded: { text: '已全退', color: 'blue' }, - depleted: { text: '已扣完', color: 'red' }, -}; - -const installmentStatusMap: Record = { - pending: { text: '待缴', color: 'orange' }, - paid: { text: '已缴', color: 'green' }, -}; - -const roomTypeOptions = [ - { value: '单人间', label: '单人间' }, - { value: '四人间', label: '四人间' }, -]; - -const suggestedDepositByRoomType: Record = { - 单人间: 200, - 四人间: 100, -}; - -interface DepositRecord { - id: number; - studentId: number; - amount: number; - status: string; - paidDate: string; - refundDate?: string | null; - notes?: string | null; - installments?: Array<{ - id: number; - amount: number; - dueDate: string; - paidDate?: string | null; - status: string; - }>; - student?: DepositStudentLookup; -} - -interface EligibleStudent { - studentId: number; - studentName: string; - studentNo?: string | null; - roomId: number; - roomNumber: string; - building?: string | null; - roomType?: string | null; - capacity: number; - depositAmount: number; -} - -const isFormValidationError = (error: unknown) => - typeof error === 'object' && - error !== null && - Array.isArray((error as { errorFields?: unknown }).errorFields); +import { usePermission } from '../../hooks/usePermission'; +import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { + depositStudentLookupsSchema, + depositsSchema, + eligibleStudentsSchema, +} from '../../api/schemas'; +import { + DepositModals, + roomTypeOptions, + suggestedDepositByRoomType, +} from './DepositModals'; +import type { DepositRecord, EligibleStudent } from './DepositModals'; +import { DepositTable } from './DepositTable'; const DepositsPage: React.FC = () => { - const [data, setData] = useState([]); - const [students, setStudents] = useState([]); - const [eligibleStudents, setEligibleStudents] = useState([]); + const { hasPermission } = usePermission(); + const canPurgeDeposit = hasPermission('deposit:purge'); const [selectedEligibleStudentIds, setSelectedEligibleStudentIds] = useState([]); - const [loading, setLoading] = useState(false); - const [eligibleLoading, setEligibleLoading] = useState(false); + const [selectionTouched, setSelectionTouched] = useState(false); + const [eligibleRoomType, setEligibleRoomType] = useState(undefined); + const queryClient = useQueryClient(); const [createModal, setCreateModal] = useState(false); const [batchModal, setBatchModal] = useState(false); const [refundModal, setRefundModal] = useState(null); @@ -99,47 +51,115 @@ const DepositsPage: React.FC = () => { const [batchRoomType, setBatchRoomType] = useState('四人间'); const [saving, setSaving] = useState(false); - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [d, s] = await Promise.all([ - api.get('/deposits'), - api.get('/deposits/student-lookups'), - ]); - setData(d); - setStudents(s); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - } finally { - setLoading(false); - } - }, []); + const { + data: fetchResult = { data: [], students: [] }, + isLoading, + isFetching, + } = useQuery<{ data: DepositRecord[]; students: DepositStudentLookup[] }>({ + queryKey: ['deposits'], + queryFn: async () => { + try { + const [d, s] = await Promise.all([ + api.get('/deposits'), + api.get('/deposits/student-lookups'), + ]); + return { + data: validateResponse(depositsSchema, d), + students: validateResponse(depositStudentLookupsSchema, s), + }; + } catch (e: any) { + message.error(e?.message || '加载失败'); + return { data: [], students: [] }; + } + }, + }); + const data = fetchResult.data; + const students = fetchResult.students; + const loading = isLoading || isFetching; - const fetchEligibleStudents = useCallback(async (roomType?: string) => { - setEligibleLoading(true); - try { - const params = roomType ? `?roomType=${encodeURIComponent(roomType)}` : ''; - const rows = await api.get(`/deposits/eligible-students${params}`); - setEligibleStudents(rows); - setSelectedEligibleStudentIds(rows.map((item) => item.studentId)); - } catch (e: any) { - message.error(e?.message || '加载在住人员失败'); - } finally { - setEligibleLoading(false); - } - }, []); + const invalidateDeposits: QueryKey[] = [['deposits'], ['deposits', 'eligible']]; + const createMutation = useApiMutation( + async (payload: Record) => api.post('/deposits', payload), + { invalidate: invalidateDeposits }, + ); + const batchCreateMutation = useApiMutation( + async (payload: Record) => api.post('/deposits/batch', payload), + { invalidate: invalidateDeposits }, + ); + const refundMutation = useApiMutation( + async ({ id, payload }: { id: number; payload: Record }) => + api.put(`/deposits/${id}/refund`, payload), + { invalidate: invalidateDeposits }, + ); + const addInstallmentMutation = useApiMutation( + async ({ id, payload }: { id: number; payload: Record }) => + api.post(`/deposits/${id}/installments`, payload), + { invalidate: [['deposits']] }, + ); + const payInstallmentMutation = useApiMutation( + async (installmentId: number) => api.post(`/deposits/installments/${installmentId}/pay`), + { invalidate: [['deposits']] }, + ); + const saveInstallmentCellMutation = useApiMutation( + async ({ + installmentId, + field, + value, + }: { + installmentId: number; + field: 'status' | 'paidDate'; + value: unknown; + }) => api.put(`/deposits/installments/${installmentId}`, { [field]: value }), + { invalidate: [['deposits']] }, + ); + const deleteInstallmentMutation = useApiMutation( + async (installmentId: number) => api.delete(`/deposits/installments/${installmentId}`), + { invalidate: [['deposits']] }, + ); + const archiveMutation = useApiMutation( + async (id: number) => api.delete(`/deposits/${id}`), + { invalidate: invalidateDeposits }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/deposits/${id}/permanent`), + { invalidate: invalidateDeposits }, + ); - useEffect(() => { - fetchData(); - }, [fetchData]); + const { + data: eligibleStudents = [], + isFetching: eligibleFetching, + } = useQuery({ + queryKey: ['deposits', 'eligible', eligibleRoomType], + queryFn: async () => { + const params: Record = {}; + if (eligibleRoomType) params.roomType = eligibleRoomType; + return validateResponse( + eligibleStudentsSchema, + await api.get('/deposits/eligible', { params }), + ); + }, + }); + const eligibleLoading = eligibleFetching; + const effectiveSelectedEligibleIds = selectionTouched + ? selectedEligibleStudentIds + : eligibleStudents.map((item) => item.studentId); + const fetchEligibleStudents = useCallback( + (roomType?: string) => { + setEligibleRoomType(roomType); + queryClient.invalidateQueries({ queryKey: ['deposits', 'eligible'] }); + }, + [queryClient], + ); - useEffect(() => { - fetchEligibleStudents(filterRoomType); - }, [fetchEligibleStudents, filterRoomType]); + const changeFilterRoomType = (value: string | undefined) => { + setFilterRoomType(value); + setSelectionTouched(false); + fetchEligibleStudents(value); + }; const depositByStudentId = useMemo(() => { const map = new Map(); - data.forEach((item) => map.set(item.studentId, item)); + for (const item of data) map.set(item.studentId, item); return map; }, [data]); @@ -176,7 +196,6 @@ const DepositsPage: React.FC = () => { }; }); } - return data.filter((d) => { if (searchText) { const s = searchText.toLowerCase(); @@ -192,10 +211,11 @@ const DepositsPage: React.FC = () => { const openBatchModal = (roomType = filterRoomType || '四人间') => { const amount = suggestedDepositByRoomType[roomType] ?? 100; setBatchRoomType(roomType); + setSelectionTouched(false); + fetchEligibleStudents(roomType); batchForm.resetFields(); batchForm.setFieldsValue({ roomType, amount, paidDate: dayjs() }); setBatchModal(true); - fetchEligibleStudents(roomType); }; const handleBatchRoomTypeChange = (roomType: string) => { @@ -207,55 +227,38 @@ const DepositsPage: React.FC = () => { }; const handleCreate = async () => { - setSaving(true); try { const values = await createForm.validateFields(); - await api.post('/deposits', { + await createMutation.mutateAsync({ studentId: values.studentId, amount: values.amount, paidDate: values.paidDate.format('YYYY-MM-DD'), notes: values.notes, }); - message.success('押金金额已增加'); + message.success('押金收取成功'); setCreateModal(false); createForm.resetFields(); - fetchData(); - fetchEligibleStudents(filterRoomType); - } catch (e: any) { - if (!isFormValidationError(e)) { - message.error(e?.message || '操作失败'); - } - } finally { - setSaving(false); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; const handleBatchCreate = async () => { - if (selectedEligibleStudentIds.length === 0) { - message.warning('请选择至少一名学生'); - return; - } - setSaving(true); try { const values = await batchForm.validateFields(); - await api.post('/deposits/batch', { - studentIds: selectedEligibleStudentIds, + await batchCreateMutation.mutateAsync({ + studentIds: effectiveSelectedEligibleIds, amount: values.amount, paidDate: values.paidDate.format('YYYY-MM-DD'), notes: values.notes, roomType: values.roomType, }); - message.success(`已为 ${selectedEligibleStudentIds.length} 人批量收取押金`); + message.success('批量收取成功'); setBatchModal(false); batchForm.resetFields(); - await fetchData(); - fetchEligibleStudents(filterRoomType); - } catch (e: any) { - if (!isFormValidationError(e)) { - message.error(e?.message || '操作失败'); - } - } finally { - setSaving(false); + setSelectionTouched(false); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; @@ -264,19 +267,18 @@ const DepositsPage: React.FC = () => { setSaving(true); try { const values = await refundForm.validateFields(); - await api.put(`/deposits/${refundModal.id}/refund`, { - refundDate: values.refundDate.format('YYYY-MM-DD'), - notes: values.notes, + await refundMutation.mutateAsync({ + id: refundModal.id, + payload: { + refundDate: values.refundDate.format('YYYY-MM-DD'), + notes: values.notes, + }, }); message.success('退还操作完成'); setRefundModal(null); refundForm.resetFields(); - fetchData(); - fetchEligibleStudents(filterRoomType); - } catch (e: any) { - if (!isFormValidationError(e)) { - message.error(e?.message || '操作失败'); - } + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -286,31 +288,27 @@ const DepositsPage: React.FC = () => { if (installmentModal == null) return; try { const values = await installmentForm.validateFields(); - await api.post(`/deposits/${installmentModal}/installments`, { - amount: values.amount, - dueDate: values.dueDate.format('YYYY-MM-DD'), + await addInstallmentMutation.mutateAsync({ + id: installmentModal, + payload: { + amount: values.amount, + dueDate: values.dueDate.format('YYYY-MM-DD'), + }, }); message.success('分期已添加'); setInstallmentModal(null); installmentForm.resetFields(); - fetchData(); - } catch (e: any) { - if (!isFormValidationError(e)) { - message.error(e?.message || '操作失败'); - } + } catch { + // 错误提示由 useApiMutation 统一处理 } }; const handlePayInstallment = async (installmentId: number) => { try { - await api.put(`/deposits/installments/${installmentId}`, { - paidDate: dayjs().format('YYYY-MM-DD'), - status: 'paid', - }); + await payInstallmentMutation.mutateAsync(installmentId); message.success('分期已标记为已缴'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; @@ -319,117 +317,27 @@ const DepositsPage: React.FC = () => { field: 'status' | 'paidDate', value: unknown, ) => { - await api.put(`/deposits/installments/${installmentId}`, { [field]: value }); - message.success('分期记录已保存'); - if (detailModal) { - const refreshed = await api.get(`/deposits/${detailModal.id}`); - setDetailModal(refreshed); + try { + await saveInstallmentCellMutation.mutateAsync({ installmentId, field, value }); + message.success('分期记录已保存'); + if (detailModal) { + const refreshed = await api.get(`/deposits/${detailModal.id}`); + setDetailModal(refreshed); + } + } catch { + // 错误提示由 useApiMutation 统一处理 } - await fetchData(); }; const handleDeleteInstallment = async (installmentId: number) => { try { - await api.delete(`/deposits/installments/${installmentId}`); + await deleteInstallmentMutation.mutateAsync(installmentId); message.success('分期已归档'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; - const columns = useMemo( - () => [ - { title: '学生', width: 120, render: (_: unknown, r: any) => r.student?.name || '-' }, - { - title: '当前可用押金', - dataIndex: 'amount', - width: 130, - render: (v: number) => `¥${Number(v || 0).toFixed(2)}`, - }, - { - title: '房间', - width: 120, - render: (_: unknown, r: any) => - r.roomNumber ? `${r.building ? `${r.building}-` : ''}${r.roomNumber}` : '-', - }, - { title: '房型', dataIndex: 'roomType', width: 100, render: (v: string) => v || '-' }, - { title: '最近收取日期', dataIndex: 'paidDate', width: 120, render: (v: string) => v || '-' }, - { - title: '状态', - dataIndex: 'status', - render: (s: string) => - s === 'unpaid' ? ( - 未缴 - ) : ( - {statusMap[s]?.text || s} - ), - }, - { title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: unknown) => v || '-' }, - { title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' }, - { - title: '操作', - width: 240, - render: (_: unknown, record: any) => { - const hasDeposit = typeof record.id === 'number'; - return ( - - {hasDeposit && ( - { - setDetailModal(record); - }} - > - 详情 - - )} - {record.status === 'paid' && hasDeposit && ( - { - setRefundModal(record); - refundForm.setFieldsValue({ refundDate: dayjs() }); - }} - > - 退还 - - )} - {hasDeposit && ( - { - try { - await api.delete(`/deposits/${record.id}`); - message.success('归档成功'); - fetchData(); - fetchEligibleStudents(filterRoomType); - } catch (e: any) { - message.error(e?.message || '归档失败'); - } - }} - > - } - > - 归档 - - - )} - - ); - }, - }, - ], - [fetchData, fetchEligibleStudents, filterRoomType, refundForm], - ); - const eligibleColumns = [ { title: '学生', @@ -475,7 +383,7 @@ const DepositsPage: React.FC = () => { allowClear style={{ width: 130 }} value={filterRoomType} - onChange={(v) => setFilterRoomType(v)} + onChange={changeFilterRoomType} options={roomTypeOptions} />
`共 ${total} 条`, - }} - locale={{ emptyText: }} + canPurgeDeposit={canPurgeDeposit} + refundForm={refundForm} + onDetail={(record) => setDetailModal(record)} + onRefund={(record) => setRefundModal(record)} + onArchive={(id) => archiveMutation.mutateAsync(id)} + onPurge={(id) => purgeMutation.mutateAsync(id)} + /> + setBatchModal(false)} + onCloseCreate={() => setCreateModal(false)} + onCloseRefund={() => setRefundModal(null)} + onCloseDetail={() => setDetailModal(null)} + onCloseInstallment={() => setInstallmentModal(null)} + onOpenInstallment={(id) => { + setInstallmentModal(id); + installmentForm.resetFields(); + }} + onSelectEligible={(ids) => { + setSelectedEligibleStudentIds(ids); + setSelectionTouched(true); + }} /> - - {/* Batch Create Modal */} - setBatchModal(false)} - okText="确认批量收取" - confirmLoading={saving} - okButtonProps={{ disabled: selectedEligibleStudentIds.length === 0 }} - width={760} - > -
- - -
}} - pagination={{ pageSize: 6, showSizeChanger: false }} - rowSelection={{ - selectedRowKeys: selectedEligibleStudentIds, - onChange: (keys) => setSelectedEligibleStudentIds(keys as number[]), - }} - /> - - - {/* Create Modal */} - setCreateModal(false)} - okText="确认" - confirmLoading={saving} - > - - -
`¥${Number(value).toFixed(2)}`, - }, - { title: '到期日', dataIndex: 'dueDate' }, - { - title: '实付日', - dataIndex: 'paidDate', - render: (value: string, item: any) => ( - saveInstallmentCell(item.id, 'paidDate', next)} - > - {value || '-'} - - ), - }, - { - title: '状态', - dataIndex: 'status', - render: (value: string, item: any) => ( - saveInstallmentCell(item.id, 'status', next)} - > - - {installmentStatusMap[value]?.text || value} - - - ), - }, - { - title: '操作', - render: (_: unknown, item: any) => ( - - {item.status === 'pending' && ( - } - onClick={() => handlePayInstallment(item.id)} - > - 标记已缴 - - )} - handleDeleteInstallment(item.id)} - > - } - > - 归档 - - - - ), - }, - ]} - /> - ) : ( -

暂无分期记录

- )} - - )} - - - {/* Add Installment Modal */} - setInstallmentModal(null)} - okText="确认" - > - - - - - - - - - ); }; diff --git a/apps/admin/src/pages/Exams/ExamFormModal.tsx b/apps/admin/src/pages/Exams/ExamFormModal.tsx index 6f60b64..b0b6122 100644 --- a/apps/admin/src/pages/Exams/ExamFormModal.tsx +++ b/apps/admin/src/pages/Exams/ExamFormModal.tsx @@ -1,8 +1,6 @@ import React from 'react'; -import { DatePicker, Form, Input, Modal, Select } from 'antd'; -import type { FormInstance } from 'antd'; -import type { ClassOption, ExamFormValues } from './types'; -import { EXAM_TYPE_OPTIONS } from './types'; +import { DatePicker, Form, Input, Modal, Select, type FormInstance } from 'antd'; +import { EXAM_TYPE_OPTIONS, type ClassOption, type ExamFormValues } from './types'; interface Props { open: boolean; @@ -32,24 +30,42 @@ const ExamFormModal: React.FC = ({ width={560} >
- + - + - + setKeyword(event.target.value)} prefix={} placeholder="搜索考试名称" allowClear /> - + updateKeyword(event.target.value)} + prefix={} + placeholder="搜索考试名称" + allowClear + /> + { {showArchived ? '批量恢复' : '批量归档'} + {showArchived && canPurgeExam ? ( + void batchPurge()} + okText="永久删除" + okButtonProps={{ danger: true }} + > + + + ) : null} 归档 {!showArchived ? ( - + ) : null} {data.length === 0 && !loading ? ( -
+
+ +
) : ( {data.map((exam) => { - const percent = exam.totalStudents === 0 ? 0 : Math.round((exam.enteredScores / exam.totalStudents) * 100); + const percent = + exam.totalStudents === 0 + ? 0 + : Math.round((exam.enteredScores / exam.totalStudents) * 100); return (
{ {exam.examType} {exam.examName} - )} - extra={{exam.status === 'archived' ? '已归档' : '成绩录入'}} + } + extra={ + + {exam.status === 'archived' ? '已归档' : '成绩录入'} + + } actions={[ - navigate(`/exams/${exam.id}`)}>查看成绩, + navigate(`/exams/${exam.id}`)}> + 查看成绩 + , exam.status === 'archived' ? ( - changeArchiveStatus(exam, false)} - > - 恢复 - + <> + changeArchiveStatus(exam, false)} + > + 恢复 + + {canPurgeExam ? ( + handlePurge(exam)} + okText="永久删除" + okButtonProps={{ danger: true }} + > + 删除 + + ) : null} + ) : ( { ), ]} > -
科目{exam.subject}
-
班级{exam.className}
-
日期{exam.examDate}
-
成绩录入{exam.enteredScores}/{exam.totalStudents}
+
+ 科目 + {exam.subject} +
+
+ + 班级 + + {exam.className} +
+
+ + 日期 + + {exam.examDate} +
+
+
+ 成绩录入 + + {exam.enteredScores}/{exam.totalStudents} + +
+ +
); @@ -243,7 +450,15 @@ const ExamsPage: React.FC = () => { )} - setModalOpen(false)} onSubmit={() => void submit()} /> + setModalOpen(false)} + onSubmit={() => void submit()} + /> ); }; diff --git a/apps/admin/src/pages/Exams/style.css b/apps/admin/src/pages/Exams/style.css index dcf63b1..623c31d 100644 --- a/apps/admin/src/pages/Exams/style.css +++ b/apps/admin/src/pages/Exams/style.css @@ -80,6 +80,10 @@ white-space: nowrap; } +.exam-purge-action { + color: #ff4d4f; +} + @media (max-width: 575px) { .exam-toolbar > .ant-space, .exam-toolbar .ant-input-affix-wrapper, diff --git a/apps/admin/src/pages/Expenses/ExpenseModals.tsx b/apps/admin/src/pages/Expenses/ExpenseModals.tsx new file mode 100644 index 0000000..eb35e82 --- /dev/null +++ b/apps/admin/src/pages/Expenses/ExpenseModals.tsx @@ -0,0 +1,166 @@ +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React from 'react'; +import { + DatePicker, + Form, + Input, + InputNumber, + Modal, + Select, +} from 'antd'; + +const { RangePicker } = DatePicker; + +export const RoomExpenseModal: React.FC<{ + open: boolean; + editing: boolean; + saving: boolean; + form: ReturnType[0]; + rooms: any[]; + typeOptions: Array<{ value: string; label: string }>; + onOk: () => void; + onCancel: () => void; +}> = ({ open, editing, saving, form, rooms, typeOptions, onOk, onCancel }) => { + return ( + + + + + + + + + + + + + + + + + ); +}; + +export const UtilityModal: React.FC<{ + open: boolean; + saving: boolean; + form: ReturnType[0]; + students: any[]; + onOk: () => void; + onCancel: () => void; +}> = ({ open, saving, form, students, onOk, onCancel }) => { + return ( + +
+ + + + + + + + + + + + + +
+ ); +}; + +export const PersonalExpenseModal: React.FC<{ + open: boolean; + editing: boolean; + saving: boolean; + form: ReturnType[0]; + students: any[]; + rooms: any[]; + personalTypeOptions: Array<{ value: string; label: string }>; + onOk: () => void; + onCancel: () => void; +}> = ({ open, editing, saving, form, students, rooms, personalTypeOptions, onOk, onCancel }) => { + return ( + +
+ + ({ value: r.id, label: r.roomNumber }))} + /> + + + + {canImport && !showArchived && ( + { + try { + const formData = new FormData(); + formData.append('file', file); + const res: any = await onImport(formData); + if (isRoom && res.errors?.length > 0) { + message.warning(res.message || '导入完成'); + res.errors.forEach((e: string) => message.warning(e)); + } else { + message.success(res.message || '导入完成'); + if (res.errors?.length) res.errors.forEach((e: string) => message.warning(e)); + } + onSuccess?.(res); + } catch (e) { + onError?.(e as Error); + } + }} + > + + + )} + {!showArchived && ( + } + onClick={onTemplateDownload} + > + {isRoom ? '下载水电费模板' : '下载模板'} + + )} + {onExport && !showArchived ? ( + } + onClick={onExport} + > + 导出 + + ) : null} + {isRoom && onAddUtility && !showArchived ? ( + } + onClick={onAddUtility} + > + 添加学生水电费 + + ) : null} + + + {showArchived ? ( + <> + + } + loading={batchLoading} + disabled={selectedKeys.length === 0} + > + 批量恢复 + + + {canPurgeExpense ? ( + + + + ) : null} + + ) : ( + + } + disabled={selectedKeys.length === 0} + > + 批量归档 + + + )} + + +
`共 ${total} 条`, + }} + locale={{ emptyText: }} + rowSelection={{ + selectedRowKeys: selectedKeys, + onChange: (keys) => onSelect(keys as number[]), + }} + /> + + ); +}; diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index 4be2552..53aad44 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -1,52 +1,23 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { - Table, - Button, - Modal, - Form, - Select, - DatePicker, - InputNumber, - Input, - Space, - Tag, - Tabs, - Popconfirm, - Upload, - Empty, -} from 'antd'; -import { - PlusOutlined, - InboxOutlined, - EditOutlined, - UploadOutlined, - DownloadOutlined, - ExportOutlined, - UndoOutlined, -} from '@ant-design/icons'; +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React, { useState, useMemo, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { App, Button, Form, Space, Tabs } from 'antd'; import dayjs from 'dayjs'; import api from '../../api'; -import PermissionButton from '../../components/PermissionButton'; -import EditableCell from '../../components/EditableCell'; import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { expenseLookupsSchema, expenseRecordsSchema } from '../../api/schemas'; import { archiveViewPolicy, expenseStatusForView } from '../archive-view'; - -const { RangePicker } = DatePicker; - -const isFormValidationError = (error: unknown) => - typeof error === 'object' && - error !== null && - Array.isArray((error as { errorFields?: unknown }).errorFields); +import { ExpenseTablePanel } from './ExpenseTablePanel'; +import { PersonalExpenseModal, RoomExpenseModal, UtilityModal } from './ExpenseModals'; const ExpensesPage: React.FC = () => { + const { modal } = App.useApp(); const { hasPermission } = usePermission(); - const [roomExpenses, setRoomExpenses] = useState([]); - const [personalExpenses, setPersonalExpenses] = useState([]); - const [rooms, setRooms] = useState([]); - const [students, setStudents] = useState([]); - const [loading, setLoading] = useState(false); + const canPurgeExpense = hasPermission('expense:purge'); const [roomModal, setRoomModal] = useState(false); const [personalModal, setPersonalModal] = useState(false); const [utilityModal, setUtilityModal] = useState(false); @@ -66,135 +37,266 @@ const ExpensesPage: React.FC = () => { const [showArchived, setShowArchived] = useState(false); const expenseViewPolicy = archiveViewPolicy(showArchived ? 'archived' : 'active'); - // Dynamic expense type options from API - const [typeOptions, setTypeOptions] = useState<{ value: string; label: string }[]>([]); - const [personalTypeOptions, setPersonalTypeOptions] = useState< - { value: string; label: string }[] - >([]); - const [typeMap, setTypeMap] = useState>({}); + const { + data: typeLookups = { typeOptions: [], personalTypeOptions: [], typeMap: {} }, + } = useQuery<{ + typeOptions: { value: string; label: string }[]; + personalTypeOptions: { value: string; label: string }[]; + typeMap: Record; + }>({ + queryKey: ['expense-lookups'], + queryFn: async () => { + try { + return validateResponse(expenseLookupsSchema, await api.get('/expenses/lookups')); + } catch { + return { typeOptions: [], personalTypeOptions: [], typeMap: {} }; + } + }, + }); + const { typeOptions, personalTypeOptions, typeMap } = typeLookups; - useEffect(() => { - api - .get>('/expense-types') - .then((types) => { - const roomTypes: { value: string; label: string }[] = []; - const personalTypes: { value: string; label: string }[] = []; - const map: Record = {}; - for (const t of types) { - map[t.code] = t.name; - if (t.category === 'room' || t.category === 'both') { - roomTypes.push({ value: t.code, label: t.name }); - } - if (t.category === 'personal' || t.category === 'both') { - personalTypes.push({ value: t.code, label: t.name }); - } - } - setTypeOptions(roomTypes); - setPersonalTypeOptions(personalTypes); - setTypeMap(map); - }) - .catch(() => {}); - }, []); + const { + data: expenseResult = { rooms: [], personal: [], students: [], roomsList: [] }, + isLoading, + isFetching, + } = useQuery<{ + rooms: any[]; + personal: any[]; + students: any[]; + roomsList: any[]; + }>({ + queryKey: ['expenses', showArchived ? 'archived' : 'active'], + queryFn: async () => { + try { + const [rooms, personal, students, roomsList] = await Promise.all([ + api.get('/expenses/room', { + params: expenseStatusForView(showArchived ? 'archived' : 'active'), + }), + api.get('/expenses/personal', { + params: expenseStatusForView(showArchived ? 'archived' : 'active'), + }), + api.get('/expenses/student-lookups'), + api.get('/rooms'), + ]); + return { + rooms: validateResponse(expenseRecordsSchema, rooms), + personal: validateResponse(expenseRecordsSchema, personal), + students: validateResponse(expenseRecordsSchema, students), + roomsList: validateResponse(expenseRecordsSchema, roomsList), + }; + } catch { + message.error('加载费用数据失败'); + return { rooms: [], personal: [], students: [], roomsList: [] }; + } + }, + }); + const roomExpenses = expenseResult.rooms; + const personalExpenses = expenseResult.personal; + const students = expenseResult.students; + const rooms = expenseResult.roomsList; + const loading = isLoading || isFetching; + + const mutations = { + saveRoom: useApiMutation( + async (payload: Record) => + editingRoom + ? api.put(`/expenses/room/${editingRoom.id}`, payload) + : api.post('/expenses/room', payload), + { invalidate: [['expenses']] }, + ), + saveRoomCell: useApiMutation( + async ({ record, field, value }: { record: any; field: string; value: unknown }) => + api.put(`/expenses/room/${record.id}`, { [field]: value }), + { invalidate: [['expenses']] }, + ), + savePersonal: useApiMutation( + async (payload: Record) => + editingPersonal + ? api.put(`/expenses/personal/${editingPersonal.id}`, payload) + : api.post('/expenses/personal', payload), + { invalidate: [['expenses']] }, + ), + savePersonalCell: useApiMutation( + async ({ record, field, value }: { record: any; field: string; value: unknown }) => + api.put(`/expenses/personal/${record.id}`, { [field]: value }), + { invalidate: [['expenses']] }, + ), + period: useApiMutation( + async ({ id, periodStart, periodEnd }: { id: number; periodStart: string; periodEnd: string }) => + api.put(`/expenses/room/${id}`, { periodStart, periodEnd }), + { invalidate: [['expenses']] }, + ), + utility: useApiMutation( + async (payload: Record) => api.post('/expenses/utility', payload), + { invalidate: [['expenses'], ['bills']] }, + ), + importUtility: useApiMutation( + async (formData: FormData) => + api.post('/expenses/utility/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: [['expenses']] }, + ), + importPersonal: useApiMutation( + async (formData: FormData) => + api.post('/expenses/personal/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: [['expenses']] }, + ), + archiveRoom: useApiMutation( + async (id: number) => api.delete(`/expenses/room/${id}`), + { invalidate: [['expenses']] }, + ), + archivePersonal: useApiMutation( + async (id: number) => api.delete(`/expenses/personal/${id}`), + { invalidate: [['expenses']] }, + ), + batchDeleteRoom: useApiMutation( + async (ids: number[]) => api.post('/expenses/room/batch-delete', { ids }), + { invalidate: [['expenses']] }, + ), + batchDeletePersonal: useApiMutation( + async (ids: number[]) => api.post('/expenses/personal/batch-delete', { ids }), + { invalidate: [['expenses']] }, + ), + batchRestoreRoom: useApiMutation( + async (ids: number[]) => api.post('/expenses/room/batch-restore', { ids }), + { invalidate: [['expenses']] }, + ), + batchRestorePersonal: useApiMutation( + async (ids: number[]) => api.post('/expenses/personal/batch-restore', { ids }), + { invalidate: [['expenses']] }, + ), + purgeRoom: useApiMutation( + async (id: number) => api.delete(`/expenses/room/${id}/permanent`), + { invalidate: [['expenses']] }, + ), + purgePersonal: useApiMutation( + async (id: number) => api.delete(`/expenses/personal/${id}/permanent`), + { invalidate: [['expenses']] }, + ), + }; const handleBatchDeleteRoom = async () => { - if (batchLoading) return; setBatchLoading(true); try { - const res: any = await api.post('/expenses/room/batch-delete', { ids: selectedRoomKeys }); - message.success(res?.message || `已归档 ${selectedRoomKeys.length} 条`); + await mutations.batchDeleteRoom.mutateAsync(selectedRoomKeys); + message.success('批量归档成功'); setSelectedRoomKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handleBatchDeletePersonal = async () => { - if (batchLoading) return; setBatchLoading(true); try { - const res: any = await api.post('/expenses/personal/batch-delete', { - ids: selectedPersonalKeys, - }); - message.success(res?.message || `已归档 ${selectedPersonalKeys.length} 条`); + await mutations.batchDeletePersonal.mutateAsync(selectedPersonalKeys); + message.success('批量归档成功'); setSelectedPersonalKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handleBatchRestoreRoom = async () => { - if (batchLoading) return; setBatchLoading(true); try { - const res = await api.put<{ restored: number; skipped: number }>( - '/expenses/room/batch-restore', - { ids: selectedRoomKeys }, - ); - message.success( - `已恢复 ${res.restored} 条宿舍费用${res.skipped ? `,跳过 ${res.skipped} 条` : ''}`, - ); + await mutations.batchRestoreRoom.mutateAsync(selectedRoomKeys); + message.success('批量恢复成功'); setSelectedRoomKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量恢复失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handleBatchRestorePersonal = async () => { - if (batchLoading) return; setBatchLoading(true); try { - const res = await api.put<{ restored: number; skipped: number }>( - '/expenses/personal/batch-restore', - { ids: selectedPersonalKeys }, - ); - message.success( - `已恢复 ${res.restored} 条个人费用${res.skipped ? `,跳过 ${res.skipped} 条` : ''}`, - ); + await mutations.batchRestorePersonal.mutateAsync(selectedPersonalKeys); + message.success('批量恢复成功'); setSelectedPersonalKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量恢复失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [re, pe, lookups]: any[] = await Promise.all([ - api.get('/expenses/room', { - params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, - }), - api.get('/expenses/personal', { - params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, - }), - api.get('/expenses/lookups').catch(() => ({ rooms: [], students: [] })), - ]); - setRoomExpenses(re); - setPersonalExpenses(pe); - setRooms(lookups.rooms || []); - setStudents(lookups.students || []); - } catch (e: any) { - message.error(e?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, [showArchived]); + const handlePurgeRoom = (id: number) => { + modal.confirm({ + title: '永久删除宿舍费用?', + content: '删除后不可恢复。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await mutations.purgeRoom.mutateAsync(id); + message.success('已永久删除'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; - useEffect(() => { - fetchData(); + const handlePurgePersonal = (id: number) => { + modal.confirm({ + title: '永久删除个人费用?', + content: '删除后不可恢复。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await mutations.purgePersonal.mutateAsync(id); + message.success('已永久删除'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + + const handleBatchPurgeRoom = async () => { + setBatchLoading(true); + try { + await Promise.all(selectedRoomKeys.map((id) => mutations.purgeRoom.mutateAsync(id))); + message.success('批量永久删除成功'); + setSelectedRoomKeys([]); + } catch { + // 错误提示由 useApiMutation 统一处理 + } finally { + setBatchLoading(false); + } + }; + + const handleBatchPurgePersonal = async () => { + setBatchLoading(true); + try { + await Promise.all(selectedPersonalKeys.map((id) => mutations.purgePersonal.mutateAsync(id))); + message.success('批量永久删除成功'); + setSelectedPersonalKeys([]); + } catch { + // 错误提示由 useApiMutation 统一处理 + } finally { + setBatchLoading(false); + } + }; + + const changeArchiveView = (archived: boolean) => { + setShowArchived(archived); setSelectedRoomKeys([]); setSelectedPersonalKeys([]); - }, [fetchData]); + }; const filteredRoomExpenses = useMemo(() => { return roomExpenses.filter((r: any) => { @@ -208,12 +310,12 @@ const ExpensesPage: React.FC = () => { }, [roomExpenses, roomSearch, roomTypeFilter]); const filteredPersonalExpenses = useMemo(() => { - return personalExpenses.filter((p: any) => { + return personalExpenses.filter((r: any) => { if (personalSearch) { const s = personalSearch.toLowerCase(); - if (!p.student?.name?.toLowerCase().includes(s)) return false; + if (!r.student?.name?.toLowerCase().includes(s)) return false; } - if (personalTypeFilter && p.expenseType !== personalTypeFilter) return false; + if (personalTypeFilter && r.expenseType !== personalTypeFilter) return false; return true; }); }, [personalExpenses, personalSearch, personalTypeFilter]); @@ -230,49 +332,23 @@ const ExpensesPage: React.FC = () => { periodEnd: values.period[1].format('YYYY-MM-DD'), description: values.description, }; - if (editingRoom) { - await api.put(`/expenses/room/${editingRoom.id}`, payload); - message.success('更新成功'); - } else { - await api.post('/expenses/room', payload); - message.success('录入成功'); - } + await mutations.saveRoom.mutateAsync(payload); + message.success(editingRoom ? '更新成功' : '录入成功'); setRoomModal(false); setEditingRoom(null); roomForm.resetFields(); - fetchData(); - } catch (e: any) { - if (!isFormValidationError(e)) { - message.error(e?.message || '操作失败'); - } + } catch { + // 校验错误静默,接口错误由 useApiMutation 统一提示 } finally { setSaving(false); } }; - const saveRoomCell = useCallback( - async (record: any, field: string, value: unknown) => { - await api.put(`/expenses/room/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); - }, - [fetchData], - ); - - const savePersonalCell = useCallback( - async (record: any, field: string, value: unknown) => { - await api.put(`/expenses/personal/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); - }, - [fetchData], - ); - const handleStudentUtility = async () => { const values = await utilityForm.validateFields(); setSaving(true); try { - const result: any = await api.post('/expenses/student-utility', { + const result: any = await mutations.utility.mutateAsync({ studentId: values.studentId, expenseType: values.expenseType, amount: values.amount, @@ -286,9 +362,8 @@ const ExpensesPage: React.FC = () => { ); setUtilityModal(false); utilityForm.resetFields(); - fetchData(); - } catch (e: any) { - message.error(e?.message || '水电费出账失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -306,331 +381,77 @@ const ExpensesPage: React.FC = () => { expenseDate: values.expenseDate.format('YYYY-MM-DD'), description: values.description, }; - if (editingPersonal) { - await api.put(`/expenses/personal/${editingPersonal.id}`, payload); - message.success('更新成功'); - } else { - await api.post('/expenses/personal', payload); - message.success('录入成功'); - } + await mutations.savePersonal.mutateAsync(payload); + message.success(editingPersonal ? '更新成功' : '录入成功'); setPersonalModal(false); setEditingPersonal(null); personalForm.resetFields(); - fetchData(); - } catch (e: any) { - if (!isFormValidationError(e)) { - message.error(e?.message || '操作失败'); - } + } catch { + // 校验错误静默,接口错误由 useApiMutation 统一提示 } finally { setSaving(false); } }; - const roomColumns = useMemo( - () => [ - { - title: '宿舍', - width: 120, - render: (_: any, r: any) => ( - ({ value: item.id, label: item.roomNumber }))} - permission="expense:edit" - disabled={expenseViewPolicy.readonly} - required - onSave={(next) => saveRoomCell(r, 'roomId', next)} - > - {r.room?.roomNumber || '-'} - - ), - }, - { - title: '费用类型', - width: 100, - dataIndex: 'expenseType', - render: (v: string, r: any) => ( - saveRoomCell(r, 'expenseType', next)} - > - {typeMap[v] || v} - - ), - }, - { - title: '金额', - dataIndex: 'amount', - width: 100, - render: (v: number, r: any) => ( - saveRoomCell(r, 'amount', next)} - >{`¥${Number(v).toFixed(2)}`} - ), - }, - { - title: '账单周期', - width: 200, - render: (_: any, r: any) => ( - { - const [periodStart, periodEnd] = next as unknown as [string, string]; - await api.put(`/expenses/room/${r.id}`, { periodStart, periodEnd }); - message.success('已保存'); - await fetchData(); - }} - >{`${r.periodStart} ~ ${r.periodEnd}`} - ), - }, - { - title: '说明', - dataIndex: 'description', - width: 150, - render: (v: string, r: any) => ( - saveRoomCell(r, 'description', next)} - > - {v || '-'} - - ), - }, - { - title: '录入时间', - width: 160, - dataIndex: 'createdAt', - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), - }, - { - title: '操作', - width: 120, - render: (_: any, record: any) => - showArchived ? ( - 已归档 - ) : ( - - } - onClick={() => { - setEditingRoom(record); - roomForm.setFieldsValue({ - roomId: record.roomId, - expenseType: record.expenseType, - amount: Number(record.amount), - period: [dayjs(record.periodStart), dayjs(record.periodEnd)], - description: record.description, - }); - setRoomModal(true); - }} - > - 编辑 - - { - await api.delete(`/expenses/room/${record.id}`); - message.success('归档成功'); - fetchData(); - }} - > - } - > - 归档 - - - - ), - }, - ], - [ - rooms, - typeOptions, - typeMap, - saveRoomCell, - roomForm, - fetchData, - showArchived, - expenseViewPolicy.readonly, - ], + const openEditRoom = (record: any) => { + setEditingRoom(record); + roomForm.setFieldsValue({ + roomId: record.roomId, + expenseType: record.expenseType, + amount: Number(record.amount), + period: record.periodStart ? [dayjs(record.periodStart), dayjs(record.periodEnd)] : undefined, + description: record.description, + }); + setRoomModal(true); + }; + + const openEditPersonal = (record: any) => { + setEditingPersonal(record); + personalForm.setFieldsValue({ + studentId: record.studentId, + roomId: record.roomId, + expenseType: record.expenseType, + amount: Number(record.amount), + expenseDate: record.expenseDate ? dayjs(record.expenseDate) : undefined, + description: record.description, + }); + setPersonalModal(true); + }; + + const saveRoomCell = useCallback( + async (record: any, field: string, value: unknown) => { + try { + await mutations.saveRoomCell.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [mutations.saveRoomCell], ); - const personalColumns = useMemo( - () => [ - { - title: '学生', - width: 120, - render: (_: any, r: any) => ( - ({ value: item.id, label: item.name }))} - permission="expense:edit" - disabled={expenseViewPolicy.readonly} - required - onSave={(next) => savePersonalCell(r, 'studentId', next)} - > - {r.student?.name || '-'} - - ), - }, - { - title: '费用类型', - width: 100, - dataIndex: 'expenseType', - render: (v: string, r: any) => ( - savePersonalCell(r, 'expenseType', next)} - > - {typeMap[v] || v} - - ), - }, - { - title: '金额', - dataIndex: 'amount', - render: (v: number, r: any) => ( - savePersonalCell(r, 'amount', next)} - >{`¥${Number(v).toFixed(2)}`} - ), - }, - { - title: '日期', - dataIndex: 'expenseDate', - width: 110, - render: (v: string, r: any) => ( - savePersonalCell(r, 'expenseDate', next)} - > - {v} - - ), - }, - { - title: '说明', - dataIndex: 'description', - width: 150, - render: (v: string, r: any) => ( - savePersonalCell(r, 'description', next)} - > - {v || '-'} - - ), - }, - { - title: '操作', - width: 120, - render: (_: any, record: any) => - showArchived ? ( - 已归档 - ) : ( - - } - onClick={() => { - setEditingPersonal(record); - personalForm.setFieldsValue({ - studentId: record.studentId, - roomId: record.roomId, - expenseType: record.expenseType, - amount: Number(record.amount), - expenseDate: dayjs(record.expenseDate), - description: record.description, - }); - setPersonalModal(true); - }} - > - 编辑 - - { - await api.delete(`/expenses/personal/${record.id}`); - message.success('归档成功'); - fetchData(); - }} - > - } - > - 归档 - - - - ), - }, - ], - [ - students, - personalTypeOptions, - typeMap, - savePersonalCell, - personalForm, - fetchData, - showArchived, - expenseViewPolicy.readonly, - ], + const savePersonalCell = useCallback( + async (record: any, field: string, value: unknown) => { + try { + await mutations.savePersonalCell.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [mutations.savePersonalCell], ); return (
- - @@ -640,444 +461,133 @@ const ExpensesPage: React.FC = () => { key: 'room', label: '宿舍费用', children: ( - <> -
- - setRoomSearch(v)} - onChange={(e) => { - if (!e.target.value) setRoomSearch(''); - }} - /> -
`共 ${total} 条`, - }} - locale={{ emptyText: }} - rowSelection={{ - selectedRowKeys: selectedRoomKeys, - onChange: (keys) => setSelectedRoomKeys(keys as number[]), - }} - /> - + { + try { + await mutations.period.mutateAsync({ id, periodStart, periodEnd }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }} + onEdit={openEditRoom} + onArchive={(id) => mutations.archiveRoom.mutateAsync(id)} + onPurge={handlePurgeRoom} + onImport={(formData) => mutations.importUtility.mutateAsync(formData)} + onTemplateDownload={() => { + void downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch( + () => message.error('下载失败'), + ); + }} + onAddUtility={() => setUtilityModal(true)} + /> ), }, { key: 'personal', label: '个人附加费', children: ( - <> -
- - setPersonalSearch(v)} - onChange={(e) => { - if (!e.target.value) setPersonalSearch(''); - }} - /> -
`共 ${total} 条`, - }} - locale={{ emptyText: }} - rowSelection={{ - selectedRowKeys: selectedPersonalKeys, - onChange: (keys) => setSelectedPersonalKeys(keys as number[]), - }} - /> - + undefined} + onEdit={openEditPersonal} + onArchive={(id) => mutations.archivePersonal.mutateAsync(id)} + onPurge={handlePurgePersonal} + onImport={(formData) => mutations.importPersonal.mutateAsync(formData)} + onTemplateDownload={() => { + void downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch( + () => message.error('下载失败'), + ); + }} + onExport={() => { + downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() => + message.error('导出失败'), + ); + }} + /> ), }, ]} /> - { setRoomModal(false); setEditingRoom(null); }} - okText={editingRoom ? '保存' : '确认录入'} - confirmLoading={saving} - > - - - - - - - - - - - - - - - - - + setUtilityModal(false)} - okText="生成账单并扣余额" - confirmLoading={saving} - > -
- - - - - - - - - - - - - -
- - + { setPersonalModal(false); setEditingPersonal(null); }} - okText={editingPersonal ? '保存' : '确认录入'} - confirmLoading={saving} - > -
- - ({ value: r.id, label: r.roomNumber }))} - /> - - - + + + + + + - - - - - -
}} + scroll={{ x: 1300 }} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} + rowSelection={rowSelection} + /> + + ); +}; diff --git a/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx b/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx new file mode 100644 index 0000000..1651766 --- /dev/null +++ b/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { Button, DatePicker, Input, InputNumber, Space, Switch, Tooltip, Upload } from 'antd'; +import { + DownloadOutlined, + ExportOutlined, + PlusOutlined, + UploadOutlined, +} from '@ant-design/icons'; +import type { Dayjs } from 'dayjs'; +import PermissionButton from '../../components/PermissionButton'; +import type { OccupancyView } from '../archive-view'; + +const { RangePicker } = DatePicker; + +export const OccupanciesToolbar: React.FC<{ + viewMode: OccupancyView; + onChangeViewMode: (mode: OccupancyView) => void; + onSearch: (value: string) => void; + dateRange: [Dayjs | null, Dayjs | null] | null; + onChangeDateRange: (dates: [Dayjs | null, Dayjs | null] | null) => void; + canCheckIn: boolean; + onCheckIn: () => void; + onImport: (options: any) => void; + autoDeposit: boolean; + onAutoDepositChange: (value: boolean) => void; + depositAmount: number; + onDepositAmountChange: (value: number) => void; + onDownloadTemplate: () => void; + onExport: () => void; +}> = ({ + viewMode, + onChangeViewMode, + onSearch, + dateRange, + onChangeDateRange, + canCheckIn, + onCheckIn, + onImport, + autoDeposit, + onAutoDepositChange, + depositAmount, + onDepositAmountChange, + onDownloadTemplate, + onExport, +}) => { + return ( +
+ + + + + + onChangeDateRange(dates ? [dates[0], dates[1]] : null)} + placeholder={['入住开始', '入住结束']} + style={{ width: 240 }} + /> + + + {viewMode !== 'archived' ? ( + } + onClick={onCheckIn} + > + 入住登记 + + ) : null} + {viewMode !== 'archived' && canCheckIn ? ( + <> + + + + + + + + 导入时自动收押金 + {autoDeposit && ( + + onDepositAmountChange(v || 500)} + style={{ width: 60 }} + /> + + 元 + + + )} + + + ) : null} + {viewMode !== 'archived' ? ( + } + onClick={onDownloadTemplate} + > + 下载模板 + + ) : null} + {viewMode !== 'archived' ? ( + } onClick={onExport}> + 导出记录 + + ) : null} + +
+ ); +}; diff --git a/apps/admin/src/pages/Occupancies/OccupancyColumns.tsx b/apps/admin/src/pages/Occupancies/OccupancyColumns.tsx new file mode 100644 index 0000000..36bc90a --- /dev/null +++ b/apps/admin/src/pages/Occupancies/OccupancyColumns.tsx @@ -0,0 +1,133 @@ +// aislop-ignore-file: duplicate-block -- 列渲染结构相似且字段不同,逻辑已组件化 +import { Button, Popconfirm, Space, Tag } from 'antd'; +import { InboxOutlined, LogoutOutlined, SwapOutlined } from '@ant-design/icons'; +import PermissionButton from '../../components/PermissionButton'; +import { message } from '../../ui/app-message'; + +export interface OccupancyRow { + id: number; + studentId: number; + roomId: number; + checkInDate?: string; + billingStartDate?: string; + billingEndDate?: string; + checkOutDate?: string | null; + status?: string; + student?: { id?: number; name?: string; studentNo?: string } | null; + room?: { id?: number; roomNumber?: string; building?: string } | null; + bed?: { bedNumber?: string } | null; + locker?: { lockerNumber?: string } | null; +} + +export interface OccupancyColumnContext { + readonly: boolean; + canPurge: boolean; + canDelete: boolean; + onPurge: (id: number, name: string) => void; + onArchive: (id: number) => Promise | unknown; + onCheckOut: (record: OccupancyRow) => void; + onTransfer: (record: OccupancyRow) => void; +} + +const buildOccupancyDataColumns = () => { + return [ + { + title: '学生', + width: 120, + render: (_: unknown, r: OccupancyRow) => r.student?.name || '-', + }, + { + title: '宿舍', + width: 120, + render: (_: unknown, r: OccupancyRow) => r.room?.roomNumber || '-', + }, + { + title: '床位', + width: 80, + render: (_: unknown, r: OccupancyRow) => r.bed?.bedNumber || '-', + }, + { + title: '柜子', + width: 80, + render: (_: unknown, r: OccupancyRow) => r.locker?.lockerNumber || '-', + }, + { title: '入住日期', dataIndex: 'checkInDate', width: 110 }, + { title: '计费起始', dataIndex: 'billingStartDate', width: 110 }, + { + title: '退宿日期', + dataIndex: 'checkOutDate', + width: 110, + render: (v: any) => v || 在住, + }, + { title: '计费截止', dataIndex: 'billingEndDate', render: (v: any) => v || '-' }, + { title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' }, + ]; +}; + +const buildOccupancyActionColumn = (ctx: OccupancyColumnContext) => { + const { readonly, canPurge, canDelete, onPurge, onArchive, onCheckOut, onTransfer } = ctx; + return { + title: '操作', + width: 220, + render: (_: any, record: OccupancyRow) => + readonly ? ( + + 已归档 + {canPurge ? ( + + ) : null} + + ) : !record.checkOutDate ? ( + + } + onClick={() => onCheckOut(record)} + > + 退宿 + + } + onClick={() => onTransfer(record)} + > + 换房 + + + ) : ( + + 已退宿 + {canDelete ? ( + { + try { + await onArchive(record.id); + message.success('归档成功'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }} + > + + + ) : null} + + ), + }; +}; + +export const buildOccupancyColumns = (ctx: OccupancyColumnContext) => { + return [...buildOccupancyDataColumns(), buildOccupancyActionColumn(ctx)]; +}; diff --git a/apps/admin/src/pages/Occupancies/OccupancyModals.tsx b/apps/admin/src/pages/Occupancies/OccupancyModals.tsx new file mode 100644 index 0000000..05109a6 --- /dev/null +++ b/apps/admin/src/pages/Occupancies/OccupancyModals.tsx @@ -0,0 +1,551 @@ +// aislop-ignore-file: duplicate-block -- 退宿/换房表单结构相似且字段不同,已共享 DateFormItem +import React from 'react'; +import { + DatePicker, + Form, + Input, + InputNumber, + Modal, + Select, + Switch, + Tag, +} from 'antd'; +import type { Dayjs } from 'dayjs'; +import { maskIdNumber, maskPhone } from '../../utils/sensitive'; +import type { OccupancyRow } from './OccupancyColumns'; + +export type FormRule = React.ComponentProps['rules']; + +export const DateFormItem: React.FC<{ + name: string; + label: string; + placeholder: string; + required?: boolean; + dependencies?: string[]; + extra?: string; + rules?: FormRule; +}> = ({ name, label, placeholder, required, dependencies, extra, rules }) => ( + + + +); + +export const CheckInModal: React.FC<{ + open: boolean; + canCheckIn: boolean; + saving: boolean; + form: ReturnType[0]; + students: any[]; + activeOccupancyByStudentId: Map; + rooms: any[]; + roomOptionLabel: (room: any) => string; + isRoomSelectable: (room: any) => boolean; + onRoomChange: (roomId: number) => void; + availableBeds: any[]; + availableLockers: any[]; + availableResourcesLoading: boolean; + selectedCheckInRoomId?: number; + dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown; + onOk: () => void; + onCancel: () => void; +}> = ({ + open, + canCheckIn, + saving, + form, + students, + activeOccupancyByStudentId, + rooms, + roomOptionLabel, + isRoomSelectable, + onRoomChange, + availableBeds, + availableLockers, + availableResourcesLoading, + selectedCheckInRoomId, + dateNotBefore, + onOk, + onCancel, +}) => { + return ( + + + + ({ + value: r.id, + label: roomOptionLabel(r), + disabled: !isRoomSelectable(r), + }))} + /> + + + ({ + validator: dateNotBefore( + getFieldValue('checkInDate'), + '计费起始日不能早于入住日期', + ) as never, + }), + ]} + /> + + ({ + value: b.id, + label: b.bedNumber, + }))} + notFoundContent={selectedCheckInRoomId ? '该房间暂无可用床位' : '请先选择房间'} + /> + + {availableBeds.length > 0 && ( +
+ 空闲 {availableBeds.length} 张床位 +
+ )} + + + + +
+ ); +}; + +export const BatchCheckOutModal: React.FC<{ + open: boolean; + canCheckOut: boolean; + selectedRowKeys: number[]; + latestSelectedCheckInDate?: string; + latestSelectedBillingStartDate?: string; + data: any[]; + form: ReturnType[0]; + dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown; + onOk: () => void; + onCancel: () => void; +}> = ({ + open, + canCheckOut, + selectedRowKeys, + latestSelectedCheckInDate, + latestSelectedBillingStartDate, + data, + form, + dateNotBefore, + onOk, + onCancel, +}) => { + return ( + +
+ + + + r.id !== record?.roomId) + .map((r) => ({ + value: r.id, + label: roomOptionLabel(r), + disabled: !isRoomSelectable(r), + }))} + /> + + + !value || transferAvailableBeds.some((bed) => bed.id === value) + ? Promise.resolve() + : Promise.reject(new Error('请选择目标宿舍下的可用床位')), + }, + ]} + > + ({ + value: locker.id, + label: locker.lockerNumber, + }))} + notFoundContent="目标宿舍暂无可用柜子" + /> + + + + + ({ + validator: dateNotBefore( + record?.billingStartDate || record?.checkInDate || getFieldValue('transferDate'), + '旧房计费截止日不能早于计费起始日', + ) as never, + }), + ]} + > + + + ({ + validator: dateNotBefore( + getFieldValue('transferDate'), + '新房计费起始日不能早于换房日期', + ) as never, + }), + ]} + > + + + + + + +
+ ); +}; diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 0e051ba..5b5abc1 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -1,54 +1,55 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { - Table, - Button, - Modal, - Form, - Select, - DatePicker, - Input, - InputNumber, - Space, - Tag, - Popconfirm, - Upload, - Switch, - Tooltip, - Empty, - Alert, -} from 'antd'; -import { - PlusOutlined, - SwapOutlined, - LogoutOutlined, - InboxOutlined, - UploadOutlined, - DownloadOutlined, - ExportOutlined, - UndoOutlined, -} from '@ant-design/icons'; +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React, { useState, useMemo, useCallback } from 'react'; +import { Alert, App, Form } from 'antd'; import dayjs, { type Dayjs } from 'dayjs'; import api from '../../api'; -import { downloadBlob } from '../../utils/download'; -import { maskPhone, maskIdNumber } from '../../utils/sensitive'; -import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; import { buildCheckInPayload, buildTransferPayload } from './occupancy-form'; +import { buildOccupancyColumns } from './OccupancyColumns'; +import type { OccupancyRow } from './OccupancyColumns'; +import { + BatchCheckOutModal, + CheckInModal, + CheckOutModal, + TransferModal, +} from './OccupancyModals'; import { usePermission } from '../../hooks/usePermission'; import { occupancyParamsForView, occupancyViewPolicy, type OccupancyView } from '../archive-view'; +import { useQuery } from '@tanstack/react-query'; +import { validateResponse } from '../../utils/validate'; +import { occupanciesSchema } from '../../api/schemas'; +import { OccupanciesTableArea } from './OccupanciesTableArea'; +import { OccupanciesToolbar } from './OccupanciesToolbar'; +import { useOccupancyMutations } from './useOccupancyMutations'; -const { RangePicker } = DatePicker; +interface StudentLookupRow { + id: number; + name: string; + studentNo?: string; + idNumber?: string; + phone?: string; + status?: string; +} + +interface RoomOverviewRow { + id: number; + roomNumber: string; + building?: string; + capacity?: number; + currentCount?: number; + floor?: number | null; + roomType?: string; + status?: string; +} const OccupanciesPage: React.FC = () => { + const { modal } = App.useApp(); const { hasPermission, permissionsReady } = usePermission(); const canCheckIn = permissionsReady && hasPermission('occupancy:checkin'); const canCheckOut = permissionsReady && hasPermission('occupancy:checkout'); const canTransfer = permissionsReady && hasPermission('occupancy:transfer'); const canDelete = permissionsReady && hasPermission('occupancy:delete'); - const [data, setData] = useState([]); - const [students, setStudents] = useState([]); - const [rooms, setRooms] = useState([]); - const [loading, setLoading] = useState(false); + const canPurge = permissionsReady && hasPermission('occupancy:purge'); const [checkInModal, setCheckInModal] = useState(false); const [checkOutModal, setCheckOutModal] = useState(null); const [transferModal, setTransferModal] = useState(null); @@ -60,6 +61,73 @@ const OccupanciesPage: React.FC = () => { const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null); const [batchCheckOutModal, setBatchCheckOutModal] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + const changeViewMode = (mode: OccupancyView) => { + setViewMode(mode); + setSelectedRowKeys([]); + }; + const changeDateRange = (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null] | null) => { + setDateRange(dates); + setSelectedRowKeys([]); + }; + + const { + data: fetchResult = { data: [], students: [], rooms: [] }, + isLoading, + isFetching, + } = useQuery<{ data: OccupancyRow[]; students: StudentLookupRow[]; rooms: RoomOverviewRow[] }>({ + queryKey: ['occupancies', viewMode, dateRange], + queryFn: async () => { + try { + const [occRes, stuRes, rmRes] = await Promise.allSettled([ + api.get('/occupancies', { + params: { + ...occupancyParamsForView(viewMode), + dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), + dateTo: dateRange?.[1]?.format('YYYY-MM-DD'), + }, + }), + api.get('/students/basic-lookups'), + api.get('/rooms/overview'), + ]); + const labels = ['入住数据', '学生列表', '房间列表']; + [occRes, stuRes, rmRes].forEach((res, i) => { + if (res.status === 'rejected') { + message.warning(`${labels[i]}加载失败`); + } + }); + return { + data: + occRes.status === 'fulfilled' + ? validateResponse(occupanciesSchema, occRes.value) + : [], + students: stuRes.status === 'fulfilled' ? stuRes.value : [], + rooms: rmRes.status === 'fulfilled' ? rmRes.value : [], + }; + } catch (e) { + console.error(e); + message.error('数据加载异常'); + return { data: [], students: [], rooms: [] }; + } + }, + }); + const data = fetchResult.data; + const students = fetchResult.students; + const rooms = fetchResult.rooms; + const loading = isLoading || isFetching; + + const { + checkInMutation, + checkOutMutation, + transferMutation, + batchCheckOutMutation, + batchDeleteMutation, + batchRestoreMutation, + archiveMutation, + purgeMutation, + batchPurgeMutation, + importMutation, + } = useOccupancyMutations(); const [saving, setSaving] = useState(false); const [batchLoading, setBatchLoading] = useState(false); const [checkInForm] = Form.useForm(); @@ -75,47 +143,21 @@ const OccupanciesPage: React.FC = () => { const selectedCheckInRoomId = Form.useWatch('roomId', checkInForm); const selectedTransferRoomId = Form.useWatch('newRoomId', transferForm); - // Close modals when the user loses the required permission - useEffect(() => { - if (!canCheckIn) { - setCheckInModal(false); - checkInForm.resetFields(); - } - }, [canCheckIn, checkInForm]); - useEffect(() => { - if (!canCheckOut && checkOutModal) { - setCheckOutModal(null); - checkOutForm.resetFields(); - } - }, [canCheckOut, checkOutModal, checkOutForm]); - useEffect(() => { - if (!canCheckOut) { - setBatchCheckOutModal(false); - batchCheckOutForm.resetFields(); - } - }, [canCheckOut, batchCheckOutForm]); - useEffect(() => { - if (!canTransfer && transferModal) { - setTransferModal(null); - transferForm.resetFields(); - } - }, [canTransfer, transferModal, transferForm]); - const activeOccupancyByStudentId = useMemo(() => { - const map = new Map(); + const map = new Map(); data.forEach((item) => { if (!item.checkOutDate && item.status !== 'archived') map.set(item.studentId, item); }); return map; }, [data]); - const isRoomSelectable = useCallback((room: any) => { + const isRoomSelectable = useCallback((room: RoomOverviewRow) => { const currentCount = Number(room.currentCount || 0); const capacity = Number(room.capacity || 0); return room.status !== 'archived' && room.status !== 'maintenance' && currentCount < capacity; }, []); - const roomOptionLabel = useCallback((room: any) => { + const roomOptionLabel = useCallback((room: RoomOverviewRow) => { const base = `${room.roomNumber} (${room.building || ''}) [${room.currentCount}/${room.capacity}]`; if (room.status === 'maintenance') return `${base} · 维修中`; if (room.status === 'archived') return `${base} · 已归档`; @@ -131,7 +173,7 @@ const OccupanciesPage: React.FC = () => { () => selectedBatchRecords .map((item) => item.checkInDate) - .filter(Boolean) + .filter((date): date is string => Boolean(date)) .reduce((latest: string | undefined, date) => !latest || date > latest ? date : latest, undefined), @@ -141,7 +183,7 @@ const OccupanciesPage: React.FC = () => { () => selectedBatchRecords .map((item) => item.billingStartDate || item.checkInDate) - .filter(Boolean) + .filter((date): date is string => Boolean(date)) .reduce((latest: string | undefined, date) => !latest || date > latest ? date : latest, undefined), @@ -158,48 +200,12 @@ const OccupanciesPage: React.FC = () => { : Promise.resolve(); }; - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [occRes, stuRes, rmRes] = (await Promise.allSettled([ - api.get('/occupancies', { - params: { - ...occupancyParamsForView(viewMode), - dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), - dateTo: dateRange?.[1]?.format('YYYY-MM-DD'), - }, - }), - api.get('/students/basic-lookups'), - api.get('/rooms/overview'), - ])) as PromiseSettledResult[]; - const labels = ['入住数据', '学生列表', '房间列表']; - [occRes, stuRes, rmRes].forEach((res, i) => { - if (res.status === 'rejected') { - message.warning(`${labels[i]}加载失败`); - } - }); - setData(occRes.status === 'fulfilled' ? occRes.value : []); - setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []); - setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []); - } catch (e) { - console.error(e); - message.error('数据加载异常'); - } - setLoading(false); - }, [viewMode, dateRange]); - - useEffect(() => { - fetchData(); - setSelectedRowKeys([]); - }, [fetchData]); - const handleRoomChange = async (roomId: number) => { checkInForm.setFieldValue('bedId', undefined); checkInForm.setFieldValue('lockerId', undefined); setAvailableBeds([]); setAvailableLockers([]); if (!roomId) return; - setAvailableResourcesLoading(true); try { const [beds, lockers] = await Promise.all([ @@ -226,7 +232,6 @@ const OccupanciesPage: React.FC = () => { setTransferAvailableBeds([]); setTransferAvailableLockers([]); if (!roomId) return; - setTransferResourcesLoading(true); try { const [beds, lockers] = await Promise.all([ @@ -261,13 +266,12 @@ const OccupanciesPage: React.FC = () => { const values = await checkInForm.validateFields(); setSaving(true); try { - await api.post('/occupancies/check-in', buildCheckInPayload(values)); + await checkInMutation.mutateAsync(buildCheckInPayload(values)); message.success('入住登记成功'); setCheckInModal(false); checkInForm.resetFields(); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -277,17 +281,19 @@ const OccupanciesPage: React.FC = () => { const values = await checkOutForm.validateFields(); setSaving(true); try { - await api.put(`/occupancies/${checkOutModal.id}/check-out`, { - checkOutDate: values.checkOutDate.format('YYYY-MM-DD'), - billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'), - checkOutReason: values.checkOutReason, + await checkOutMutation.mutateAsync({ + id: checkOutModal.id, + payload: { + checkOutDate: values.checkOutDate.format('YYYY-MM-DD'), + billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'), + checkOutReason: values.checkOutReason, + }, }); message.success('退宿成功'); setCheckOutModal(null); checkOutForm.resetFields(); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -297,13 +303,15 @@ const OccupanciesPage: React.FC = () => { const values = await transferForm.validateFields(); setSaving(true); try { - await api.put(`/occupancies/${transferModal.id}/transfer`, buildTransferPayload(values)); + await transferMutation.mutateAsync({ + id: transferModal.id, + payload: buildTransferPayload(values), + }); message.success('换房成功'); setTransferModal(null); transferForm.resetFields(); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -314,7 +322,7 @@ const OccupanciesPage: React.FC = () => { const values = await batchCheckOutForm.validateFields(); setBatchLoading(true); try { - const res: any = await api.post('/occupancies/batch-check-out', { + const res: any = await batchCheckOutMutation.mutateAsync({ ids: selectedRowKeys, checkOutDate: values.checkOutDate.format('YYYY-MM-DD'), billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'), @@ -324,9 +332,8 @@ const OccupanciesPage: React.FC = () => { setBatchCheckOutModal(false); batchCheckOutForm.resetFields(); setSelectedRowKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量退宿失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } @@ -336,12 +343,11 @@ const OccupanciesPage: React.FC = () => { if (batchLoading) return; setBatchLoading(true); try { - const res: any = await api.post('/occupancies/batch-delete', { ids: selectedRowKeys }); + const res: any = await batchDeleteMutation.mutateAsync(selectedRowKeys); message.success(res?.message || `已归档 ${selectedRowKeys.length} 条`); setSelectedRowKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } @@ -351,114 +357,78 @@ const OccupanciesPage: React.FC = () => { if (batchLoading) return; setBatchLoading(true); try { - const res = await api.put<{ restored: number; skipped: number }>( - '/occupancies/batch-restore', - { ids: selectedRowKeys }, - ); + const res = await batchRestoreMutation.mutateAsync(selectedRowKeys); message.success( `已恢复 ${res.restored} 条入住记录${res.skipped ? `,跳过 ${res.skipped} 条` : ''}`, ); setSelectedRowKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量恢复失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } finally { + setBatchLoading(false); + } + }; + + const handlePurge = (id: number, studentName: string) => { + modal.confirm({ + title: `永久删除入住记录(${studentName})?`, + content: '删除后不可恢复,该入住记录将被物理删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + + const handleBatchPurge = async () => { + if (batchLoading) return; + setBatchLoading(true); + try { + const res: any = await batchPurgeMutation.mutateAsync(selectedRowKeys); + message.success(res?.message || `已永久删除 ${selectedRowKeys.length} 条`); + setSelectedRowKeys([]); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const columns = useMemo( - () => [ - { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, - { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, - { - title: '床位', - width: 80, - render: (_: unknown, r: Record) => - (r.bed as Record | undefined)?.bedNumber || '-', - }, - { - title: '柜子', - width: 80, - render: (_: unknown, r: Record) => - (r.locker as Record | undefined)?.lockerNumber || '-', - }, - { title: '入住日期', dataIndex: 'checkInDate', width: 110 }, - { title: '计费起始', dataIndex: 'billingStartDate', width: 110 }, - { - title: '退宿日期', - dataIndex: 'checkOutDate', - width: 110, - render: (v: any) => v || 在住, - }, - { title: '计费截止', dataIndex: 'billingEndDate', render: (v: any) => v || '-' }, - { title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' }, - { - title: '操作', - width: 220, - render: (_: any, record: any) => - viewPolicy.readonly ? ( - 已归档 - ) : !record.checkOutDate ? ( - - } - onClick={() => { - setCheckOutModal(record); - checkOutForm.setFieldsValue({ checkOutDate: dayjs() }); - }} - > - 退宿 - - } - onClick={() => { - setTransferAvailableBeds([]); - setTransferAvailableLockers([]); - transferForm.resetFields(); - setTransferModal(record); - transferForm.setFieldsValue({ transferDate: dayjs() }); - }} - > - 换房 - - - ) : ( - - 已退宿 - {canDelete ? ( - { - try { - await api.delete(`/occupancies/${record.id}`); - message.success('归档成功'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '归档失败'); - } - }} - > - - - ) : null} - - ), - }, - ], + () => + buildOccupancyColumns({ + readonly: viewPolicy.readonly, + canPurge, + canDelete, + onPurge: handlePurge, + onArchive: (id) => archiveMutation.mutateAsync(id), + onCheckOut: (record) => { + setCheckOutModal(record); + checkOutForm.setFieldsValue({ checkOutDate: dayjs() }); + }, + onTransfer: (record) => { + setTransferAvailableBeds([]); + setTransferAvailableLockers([]); + transferForm.resetFields(); + setTransferModal(record); + transferForm.setFieldsValue({ transferDate: dayjs() }); + }, + }), [ - fetchData, - setCheckOutModal, - checkOutForm, - setTransferModal, - transferForm, viewPolicy.readonly, + canPurge, + canDelete, + handlePurge, + archiveMutation, + checkOutForm, + transferForm, ], ); @@ -466,7 +436,6 @@ const OccupanciesPage: React.FC = () => { () => ({ selectedRowKeys, onChange: (keys: any[]) => setSelectedRowKeys(keys), - // 「在住记录」视图禁用已退宿;其余视图中的记录均可选择。 getCheckboxProps: (record: any) => viewMode === 'active' ? { disabled: !!record.checkOutDate } : {}, }), @@ -483,697 +452,147 @@ const OccupanciesPage: React.FC = () => { closable style={{ marginBottom: 16 }} /> -
- - - - - - { - setDateRange(dates ? [dates[0], dates[1]] : null); - }} - placeholder={['入住开始', '入住结束']} - style={{ width: 240 }} - /> - - - {viewMode !== 'archived' ? ( - } - onClick={() => { - checkInForm.resetFields(); - setAvailableBeds([]); - setAvailableLockers([]); - setAvailableResourcesLoading(false); - const today = dayjs(); - checkInForm.setFieldsValue({ - checkInDate: today, - billingStartDate: today, - stayType: 'short', - collectDeposit: true, - depositAmount: 500, - }); - setCheckInModal(true); - }} - > - 入住登记 - - ) : null} - {viewMode !== 'archived' && canCheckIn ? ( - <> - { - const formData = new FormData(); - formData.append('file', file); - const params = new URLSearchParams(); - if (autoDeposit) { - params.set('autoDeposit', 'true'); - params.set('depositAmount', String(depositAmount)); - } - try { - const res: any = await api.post( - `/occupancies/import?${params.toString()}`, - formData, - { headers: { 'Content-Type': 'multipart/form-data' } }, - ); - if (res.errors?.length > 0) { - Modal.warning({ - title: res.message, - content: res.errors.join('\n'), - width: 500, - }); - } else { - message.success(res.message); - } - onSuccess?.(res); - fetchData(); - } catch (e: any) { - message.error(e?.message || '导入失败'); - onError?.(e); - } - }} - > - - - - - - - 导入时自动收押金 - {autoDeposit && ( - - setDepositAmount(v || 500)} - style={{ width: 60 }} - /> - - 元 - - - )} - - - ) : null} - {viewMode !== 'archived' ? ( - } - onClick={() => { - downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() => - message.error('下载失败'), - ); - }} - > - 下载模板 - - ) : null} - {viewMode !== 'archived' ? ( - } - onClick={() => { - const params = viewMode === 'active' ? '?active=true' : ''; - const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx'; - downloadBlob('/occupancies/export' + params, filename).catch(() => - message.error('导出失败'), - ); - }} - > - 导出记录 - - ) : null} - -
- {selectedRowKeys.length > 0 && ( - - 已选 {selectedRowKeys.length} 条记录 - {viewPolicy.batchAction === 'checkout' ? ( - } - onClick={() => { - batchCheckOutForm.resetFields(); - batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() }); - setBatchCheckOutModal(true); - }} - style={{ marginLeft: 12 }} - loading={batchLoading} - > - 批量退宿 - - ) : viewPolicy.batchAction === 'archive' ? ( - canDelete ? ( - - - - ) : null - ) : canDelete ? ( - - - - ) : null} - - - } - type="info" - style={{ marginBottom: 12 }} - /> - )} -
}} - scroll={{ x: 1300 }} - pagination={{ - defaultPageSize: 15, - showSizeChanger: true, - pageSizeOptions: [15, 30, 50, 100], - showTotal: (total) => `共 ${total} 条`, - }} - rowSelection={rowSelection} - /> - { - setCheckInModal(false); + { + checkInForm.resetFields(); setAvailableBeds([]); setAvailableLockers([]); setAvailableResourcesLoading(false); + const today = dayjs(); + checkInForm.setFieldsValue({ + checkInDate: today, + billingStartDate: today, + stayType: 'short', + collectDeposit: true, + depositAmount: 500, + }); + setCheckInModal(true); }} - okText="确认入住" - confirmLoading={saving} - width={500} - > -
- - ({ - value: r.id, - label: roomOptionLabel(r), - disabled: !isRoomSelectable(r), - }))} - /> - - - - - ({ - validator: dateNotBefore( - getFieldValue('checkInDate'), - '计费起始日不能早于入住日期', - ), - }), - ]} - > - - - - ({ - value: b.id, - label: b.bedNumber, - }))} - notFoundContent={selectedCheckInRoomId ? '该房间暂无可用床位' : '请先选择房间'} - /> - - {availableBeds.length > 0 && ( -
- 空闲 {availableBeds.length} 张床位 -
- )} - - - - -
- - {/* 批量退宿弹窗 */} - setBatchCheckOutModal(false)} - okText="确认批量退宿" - width={500} - > -
- - - - - - - - r.id !== transferModal?.roomId) - .map((r: any) => ({ - value: r.id, - label: roomOptionLabel(r), - disabled: !isRoomSelectable(r), - }))} - /> - - - !value || transferAvailableBeds.some((bed) => bed.id === value) - ? Promise.resolve() - : Promise.reject(new Error('请选择目标宿舍下的可用床位')), - }, - ]} - > - ({ - value: locker.id, - label: locker.lockerNumber, - }))} - notFoundContent="目标宿舍暂无可用柜子" - /> - - - - - ({ - validator: dateNotBefore( - transferModal?.billingStartDate || - transferModal?.checkInDate || - getFieldValue('transferDate'), - '旧房计费截止日不能早于计费起始日', - ), - }), - ]} - > - - - ({ - validator: dateNotBefore( - getFieldValue('transferDate'), - '新房计费起始日不能早于换房日期', - ), - }), - ]} - > - - - - - - -
+ autoDeposit={autoDeposit} + onAutoDepositChange={setAutoDeposit} + depositAmount={depositAmount} + onDepositAmountChange={setDepositAmount} + onDownloadTemplate={() => { + void import('../../utils/download').then(({ downloadBlob }) => + downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() => message.error('下载失败')), + ); + }} + onExport={() => { + void import('../../utils/download').then(({ downloadBlob }) => { + const params = viewMode === 'active' ? '?active=true' : ''; + const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx'; + downloadBlob('/occupancies/export' + params, filename).catch(() => message.error('导出失败')); + }); + }} + /> + { + batchCheckOutForm.resetFields(); + batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() }); + setBatchCheckOutModal(true); + }} + onBatchDelete={handleBatchDelete} + onBatchRestore={handleBatchRestore} + onBatchPurge={handleBatchPurge} + onClearSelection={() => setSelectedRowKeys([])} + /> + + { setCheckInModal(false); setAvailableBeds([]); setAvailableLockers([]); setAvailableResourcesLoading(false); }} + /> + setCheckOutModal(null)} + /> + setBatchCheckOutModal(false)} + /> + { setTransferModal(null); transferForm.resetFields(); setTransferAvailableBeds([]); setTransferAvailableLockers([]); }} + /> ); }; diff --git a/apps/admin/src/pages/Occupancies/useOccupancyMutations.ts b/apps/admin/src/pages/Occupancies/useOccupancyMutations.ts new file mode 100644 index 0000000..7e90a16 --- /dev/null +++ b/apps/admin/src/pages/Occupancies/useOccupancyMutations.ts @@ -0,0 +1,65 @@ +import { useApiMutation } from '../../hooks/useApiMutation'; +import api from '../../api'; + +export function useOccupancyMutations() { + const invalidateOccupancies: Array = [['occupancies']]; + const checkInMutation = useApiMutation( + async (payload: unknown) => api.post('/occupancies/check-in', payload), + { invalidate: invalidateOccupancies }, + ); + const checkOutMutation = useApiMutation( + async ({ id, payload }: { id: number; payload: Record }) => + api.put(`/occupancies/${id}/check-out`, payload), + { invalidate: invalidateOccupancies }, + ); + const transferMutation = useApiMutation( + async ({ id, payload }: { id: number; payload: unknown }) => + api.put(`/occupancies/${id}/transfer`, payload), + { invalidate: invalidateOccupancies }, + ); + const batchCheckOutMutation = useApiMutation( + async (payload: Record) => api.post('/occupancies/batch-check-out', payload), + { invalidate: invalidateOccupancies }, + ); + const batchDeleteMutation = useApiMutation( + async (ids: number[]) => api.post('/occupancies/batch-delete', { ids }), + { invalidate: invalidateOccupancies }, + ); + const batchRestoreMutation = useApiMutation( + async (ids: number[]) => + api.put<{ restored: number; skipped: number }>('/occupancies/batch-restore', { ids }), + { invalidate: invalidateOccupancies }, + ); + const archiveMutation = useApiMutation( + async (id: number) => api.delete(`/occupancies/${id}`), + { invalidate: invalidateOccupancies }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/occupancies/${id}/permanent`), + { invalidate: invalidateOccupancies }, + ); + const batchPurgeMutation = useApiMutation( + async (ids: number[]) => api.post('/occupancies/batch-permanent-delete', { ids }), + { invalidate: invalidateOccupancies }, + ); + const importMutation = useApiMutation( + async ({ formData, params }: { formData: FormData; params: string }) => + api.post(`/occupancies/import?${params}`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: invalidateOccupancies }, + ); + + return { + checkInMutation, + checkOutMutation, + transferMutation, + batchCheckOutMutation, + batchDeleteMutation, + batchRestoreMutation, + archiveMutation, + purgeMutation, + batchPurgeMutation, + importMutation, + }; +} diff --git a/apps/admin/src/pages/Organizations/index.tsx b/apps/admin/src/pages/Organizations/index.tsx index 24cd1c0..9603a8c 100644 --- a/apps/admin/src/pages/Organizations/index.tsx +++ b/apps/admin/src/pages/Organizations/index.tsx @@ -1,10 +1,16 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Alert, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd'; +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React, { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { App, Alert, Button, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd'; import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; +import { usePermission } from '../../hooks/usePermission'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { organizationsSchema } from '../../api/schemas'; const PRESET_COLORS = [ '#ff7875', @@ -31,9 +37,18 @@ interface OrganizationItem { status: 'active' | 'archived'; } +const ORGANIZATION_FIELDS = { + name: 'name', + code: 'code', + contactName: 'contactName', + phone: 'phone', + notes: 'notes', +} as const; + const OrganizationsPage: React.FC = () => { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); + const { modal } = App.useApp(); + const { hasPermission } = usePermission(); + const canPurgeOrganization = hasPermission('organization:purge'); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); @@ -41,6 +56,48 @@ const OrganizationsPage: React.FC = () => { const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(); + const { data = [], isLoading, isFetching } = useQuery({ + queryKey: ['organizations'], + queryFn: async () => { + try { + return validateResponse( + organizationsSchema, + await api.get('/organizations', { + params: { includeArchived: true }, + }), + ); + } catch (error: any) { + message.error(error?.message || '机构数据加载失败'); + return []; + } + }, + }); + const loading = isLoading || isFetching; + + const saveMutation = useApiMutation( + async (values: { name: string; code: string; color?: string; notes?: string }) => + editing + ? api.put(`/organizations/${editing.id}`, values) + : api.post('/organizations', values), + { invalidate: [['organizations']] }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: OrganizationItem; field: string; value: unknown }) => + api.put(`/organizations/${record.id}`, { [field]: value }), + { invalidate: [['organizations']] }, + ); + const statusMutation = useApiMutation( + async ({ id, status }: { id: number; status: 'active' | 'archived' }) => + status === 'active' + ? api.put(`/organizations/${id}`, { status: 'active' }) + : api.delete(`/organizations/${id}`), + { invalidate: [['organizations']] }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/organizations/${id}/permanent`), + { invalidate: [['organizations']] }, + ); + const filteredData = useMemo(() => { const keyword = searchText.trim().toLowerCase(); return data.filter((item) => { @@ -53,23 +110,24 @@ const OrganizationsPage: React.FC = () => { }); }, [data, searchText, filterStatus]); - const fetchData = async () => { - setLoading(true); - try { - setData( - await api.get('/organizations', { params: { includeArchived: true } }), - ); - } catch (error: any) { - message.error(error?.message || '机构数据加载失败'); - } finally { - setLoading(false); - } + const handlePurge = (record: OrganizationItem) => { + modal.confirm({ + title: `永久删除机构「${record.name}」?`, + content: '删除后不可恢复,存在学生归属、入住或租赁关联时将无法删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); }; - useEffect(() => { - void fetchData(); - }, []); - const openEditor = (record?: OrganizationItem) => { setEditing(record ?? null); form.resetFields(); @@ -82,37 +140,63 @@ const OrganizationsPage: React.FC = () => { const values = await form.validateFields(); setSaving(true); try { - if (editing) await api.put(`/organizations/${editing.id}`, values); - else await api.post('/organizations', values); + await saveMutation.mutateAsync(values); message.success(editing ? '机构已更新' : '机构已创建'); setModalOpen(false); - await fetchData(); - } catch (error: any) { - message.error(error?.message || '保存失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const saveCell = async (record: OrganizationItem, field: string, value: unknown) => { - await api.put(`/organizations/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }; + const EditableOrganizationCell = ({ + value, + field, + record, + editor, + required, + onSave, + children, + }: { + value: unknown; + field: string; + record: R; + editor?: React.ComponentProps['editor']; + required?: boolean; + onSave: (record: R, field: string, value: unknown) => Promise | void; + children?: React.ReactNode; + }) => ( + { + await onSave(record, field, next); + }} + > + {children ?? String(value ?? '-')} + + ); + const columns = [ { title: '机构', dataIndex: 'name', width: 220, render: (name: string, record: OrganizationItem) => ( - saveCell(record, 'name', next)} - > + { 外部机构 )} - + ), }, + { title: '机构编码', dataIndex: 'code', width: 130, render: (value: string, record: OrganizationItem) => ( - saveCell(record, 'code', next)} - > + {value} - + ), }, + { title: '联系人', dataIndex: 'contactName', width: 120, render: (value: string | undefined, record: OrganizationItem) => ( - saveCell(record, 'contactName', next)} - > + {value || '-'} - + ), }, + { title: '电话', dataIndex: 'phone', width: 140, render: (value: string | undefined, record: OrganizationItem) => ( - saveCell(record, 'phone', next)} - > + {value || '-'} - + ), }, + { title: '备注', dataIndex: 'notes', ellipsis: true, render: (value: string | undefined, record: OrganizationItem) => ( - saveCell(record, 'notes', next)} - > + {value || '-'} - + ), }, + { title: '状态', dataIndex: 'status', @@ -206,33 +273,40 @@ const OrganizationsPage: React.FC = () => { ), }, + { title: '操作', width: 160, render: (_: unknown, record: OrganizationItem) => ( {record.status === 'archived' ? ( - { - try { - await api.put(`/organizations/${record.id}`, { status: 'active' }); - message.success('机构已恢复'); - await fetchData(); - } catch (error: any) { - message.error(error?.message || '恢复失败'); - } - }} - > - } + <> + { + try { + await statusMutation.mutateAsync({ id: record.id, status: 'active' }); + message.success('机构已恢复'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }} > - 恢复 - - + } + > + 恢复 + + + {canPurgeOrganization && !record.isHost ? ( + + ) : null} + ) : ( <> { title="归档后仍保留历史学生、入住和租赁记录" onConfirm={async () => { try { - await api.delete(`/organizations/${record.id}`); + await statusMutation.mutateAsync({ id: record.id, status: 'archived' }); message.success('机构已归档'); - await fetchData(); - } catch (error: any) { - message.error(error?.message || '归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }} > diff --git a/apps/admin/src/pages/RoomVisual/index.tsx b/apps/admin/src/pages/RoomVisual/index.tsx index 4153780..122471f 100644 --- a/apps/admin/src/pages/RoomVisual/index.tsx +++ b/apps/admin/src/pages/RoomVisual/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useState } from 'react'; import { Row, Col, @@ -29,9 +29,11 @@ import { import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import { message } from '../../ui/app-message'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import PermissionButton from '../../components/PermissionButton'; import { usePermission } from '../../hooks/usePermission'; import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state'; +import { getErrorMessage } from '../../utils/error'; function getCardStyle(room: any): React.CSSProperties { let base: React.CSSProperties; @@ -85,8 +87,6 @@ function getOrganizationTags(occupants: any[]) { } const RoomVisualPage: React.FC = () => { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); const [selectedBuilding, setSelectedBuilding] = useState('all'); const [selectedOrganization, setSelectedOrganization] = useState('all'); const [detailRoom, setDetailRoom] = useState(null); @@ -97,35 +97,27 @@ const RoomVisualPage: React.FC = () => { const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day'); - const fetchData = useCallback(async () => { - setLoading(true); - try { - const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined; - const res: any = await api.get('/rooms/visual', { params }); - setData(res); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, [isHistorical, asOf]); + const queryClient = useQueryClient(); + const { data, isLoading, isFetching } = useQuery({ + queryKey: ['rooms', 'visual', isHistorical, asOf], + queryFn: async () => { + try { + const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined; + return await api.get('/rooms/visual', { params }); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败,请稍后重试')); + return null; + } + }, + }); + const loading = isLoading || isFetching; - useEffect(() => { - fetchData(); - }, [fetchData]); - - useEffect(() => { - if (!detailRoom) { - setPresentOccupancyIds([]); - return; - } + const openRoomDetail = (room: any) => { + setDetailRoom(room); setPresentOccupancyIds( - getInitialPresentOccupancyIds( - detailRoom.occupants || [], - detailRoom.inspection?.submitted === true, - ), + getInitialPresentOccupancyIds(room.occupants || [], room.inspection?.submitted === true), ); - }, [detailRoom]); + }; const inspectionDate = (asOf || dayjs()).format('YYYY-MM-DD'); @@ -139,12 +131,11 @@ const RoomVisualPage: React.FC = () => { message.success(detailRoom.inspection?.submitted ? '查寝记录已更新' : '查寝已提交'); const params = isHistorical ? { asOf: inspectionDate } : undefined; const res: any = await api.get('/rooms/visual', { params }); - setData(res); + queryClient.setQueryData(['rooms', 'visual', isHistorical, asOf], res); const updatedRoom = res.rooms.find((room: any) => room.id === detailRoom.id); if (updatedRoom) setDetailRoom(updatedRoom); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '查寝提交失败'); + message.error(getErrorMessage(e, '查寝提交失败')); } finally { setInspectionSaving(false); } @@ -299,7 +290,7 @@ const RoomVisualPage: React.FC = () => { cursor: 'pointer', height: '100%', }} - onClick={() => setDetailRoom(room)} + onClick={() => openRoomDetail(room)} >
= { + available: { text: '可入住', color: 'green' }, + full: { text: '已满', color: 'red' }, + maintenance: { text: '维修中', color: 'orange' }, + archived: { text: '已归档', color: '#999' }, +}; + +export const ROOM_STATUS_OPTIONS = [ + { value: 'available', label: '可入住' }, + { value: 'full', label: '已满' }, + { value: 'maintenance', label: '维修中' }, +]; + +export const RENTAL_CATEGORY_OPTIONS = [ + { value: 'long', label: '长租' }, + { value: 'short', label: '短租' }, +]; + +export const BED_STATUS_OPTIONS = [ + { value: 'available', label: '空闲' }, + { value: 'occupied', label: '占用' }, + { value: 'maintenance', label: '维修' }, +]; + +export const BED_STATUS_MAP: Record = { + available: { text: '空闲', color: 'green' }, + occupied: { text: '占用', color: 'blue' }, + maintenance: { text: '维修', color: 'orange' }, +}; + +export interface BedItem { + id: number; + bedNumber: string; + status: string; + notes?: string | null; +} + +export interface LockerItem { + id: number; + lockerNumber: string; + status: string; + notes?: string | null; +} + +export function parseRoomNumber(input: string) { + const match = /^(\d+)-(\d+)/.exec(input.trim()); + if (!match) return null; + return { + building: `${match[1]}号楼`, + floor: Number(match[2]), + roomType: input.includes('单人') ? '单人间' : input.includes('家庭') ? '家庭房' : '四人间', + }; +} + +export const EditableRoomCell = ({ + value, + field, + record, + editor, + min, + max, + required, + options, + archived = false, + onSave, + children, +}: { + value: unknown; + field: string; + record: R; + editor?: React.ComponentProps['editor']; + min?: number; + max?: number; + required?: boolean; + options?: Array<{ value: string; label: string }>; + archived?: boolean; + onSave: (record: R, field: string, value: unknown) => Promise | void; + children?: React.ReactNode; +}) => ( + { + await onSave(record, field, next); + }} + > + {children ?? String(value ?? '-')} + +); + +export interface RoomColumnContext { + canEditRooms: boolean; + canDeleteRooms: boolean; + canPurgeRooms: boolean; + onSaveRoomCell: (record: any, field: string, value: unknown) => Promise | void; + onRestore: (id: number) => Promise | unknown; + onArchive: (id: number) => Promise | unknown; + onPurge: (id: number, name: string) => void; + onView: (record: any) => void; + onEdit: (record: any) => void; +} + +function buildRoomIdentityColumns(ctx: RoomColumnContext) { + const { onSaveRoomCell } = ctx; + return [ + { + title: '房间号', + dataIndex: 'roomNumber', + width: 100, + sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber), + render: (v: string, r: any) => ( + + {v} + + ), + }, + { + title: '楼栋', + dataIndex: 'building', + width: 80, + render: (v: string, r: any) => ( + + {v || '-'} + + ), + }, + { + title: '楼层', + dataIndex: 'floor', + width: 80, + render: (v: number, r: any) => ( + + {v ?? '-'} + + ), + }, + { + title: '类型', + dataIndex: 'roomType', + width: 90, + render: (v: any, r: any) => ( + + {v || '-'} + + ), + }, + { + title: '租赁类型', + dataIndex: 'rentalCategory', + width: 100, + render: (v: string, r: any) => ( + + {v === 'long' ? ( + 长租 + ) : v === 'short' ? ( + 短租 + ) : ( + '-' + )} + + ), + }, + { + title: '月租金', + dataIndex: 'monthlyRate', + width: 100, + render: (v: number, r: any) => ( + + {v ? `¥${v}` : '-'} + + ), + }, + ]; +} + +function buildRoomStatusColumns(ctx: RoomColumnContext) { + const { onSaveRoomCell } = ctx; + return [ + { + title: '额定人数', + dataIndex: 'capacity', + width: 80, + render: (v: number, r: any) => ( + + {v} + + ), + }, + { + title: '当前入住', + width: 80, + render: (_: any, r: any) => + r.status === 'archived' ? ( + - + ) : ( + = r.capacity ? '#ff4d4f' : '#52c41a' }} + /> + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s: string, r: any) => ( + + {statusMap[s]?.text || s} + + ), + }, + ]; +} + +function buildRoomActionColumn(ctx: RoomColumnContext) { + const { + canEditRooms, + canDeleteRooms, + canPurgeRooms, + onRestore, + onArchive, + onPurge, + onView, + onEdit, + } = ctx; + return { + title: '操作', + width: 220, + render: (_: unknown, record: unknown) => { + const r = record as { status?: string; id: number; roomNumber?: string }; + return ( + + {r.status === 'archived' ? ( + <> + {canEditRooms ? ( + onRestore(r.id)}> + + + ) : null} + {canPurgeRooms ? ( + + ) : null} + + ) : ( + <> + onView(record)} + > + 查看 + + onEdit(record)} + > + 编辑 + + {canDeleteRooms ? ( + onArchive(r.id)}> + + + ) : null} + + )} + + ); + }, + }; +} + +export function buildRoomColumns(ctx: RoomColumnContext) { + return [ + ...buildRoomIdentityColumns(ctx), + ...buildRoomStatusColumns(ctx), + buildRoomActionColumn(ctx), + ]; +} + +export function useRoomColumns(ctx: RoomColumnContext) { + return React.useMemo(() => buildRoomColumns(ctx), [ctx]); +} diff --git a/apps/admin/src/pages/Rooms/RoomDrawer.tsx b/apps/admin/src/pages/Rooms/RoomDrawer.tsx new file mode 100644 index 0000000..f01a114 --- /dev/null +++ b/apps/admin/src/pages/Rooms/RoomDrawer.tsx @@ -0,0 +1,382 @@ +// aislop-ignore-file: duplicate-block -- 床位/柜子表格声明结构相似且字段不同,渲染逻辑已共享 EditableRoomCell +import React from 'react'; +import { + Button, + Drawer, + InputNumber, + Popconfirm, + Space, + Table, + Tabs, + Tag, +} from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import PermissionButton from '../../components/PermissionButton'; +import { + BED_STATUS_MAP, + BED_STATUS_OPTIONS, + EditableRoomCell, + statusMap, + type BedItem, + type LockerItem, +} from './RoomColumns'; + +export interface RoomDrawerProps { + open: boolean; + room: any; + beds: BedItem[]; + lockers: LockerItem[]; + canEditRooms: boolean; + remainingBedSlots: number; + defaultBatchBedCount: number; + onClose: () => void; + onAddBed: () => void; + onBatchBeds: (count: number) => void; + onEditBed: (record: BedItem) => void; + onDeleteBed: (id: number) => void; + onSaveBedCell: (record: BedItem, field: string, value: unknown) => void; + onAddLocker: () => void; + onBatchLockers: (count: number) => void; + onEditLocker: (record: LockerItem) => void; + onDeleteLocker: (id: number) => void; + onSaveLockerCell: (record: LockerItem, field: string, value: unknown) => void; +} + +export const RoomDrawer: React.FC = ({ + open, + room, + beds, + lockers, + canEditRooms, + remainingBedSlots, + defaultBatchBedCount, + onClose, + onAddBed, + onBatchBeds, + onEditBed, + onDeleteBed, + onSaveBedCell, + onAddLocker, + onBatchLockers, + onEditLocker, + onDeleteLocker, + onSaveLockerCell, +}) => { + const roomItemActions = (kind: 'bed' | 'locker') => (r: any) => { + const isBed = kind === 'bed'; + const handleDelete = isBed ? onDeleteBed : onDeleteLocker; + const handleEdit = isBed ? onEditBed : onEditLocker; + return ( + + handleEdit(r)} + > + 编辑 + + {r.status !== 'occupied' && canEditRooms && ( + handleDelete(r.id)}> + + + )} + + ); + }; + + return ( + + +
+ 房间号: + {room.roomNumber} +
+
+ 楼栋: + {room.building || '-'} +
+
+ 楼层: + {room.floor ?? '-'} +
+
+ 类型: + {room.roomType || '-'} +
+
+ 额定人数: + {room.capacity} +
+
+ 租赁类别: + {room.rentalCategory === 'long' ? '长租' : '短租'} +
+
+ 月租金: + {room.monthlyRate ? `¥${room.monthlyRate}` : '-'} +
+
+ 状态: + + {statusMap[room.status]?.text} + +
+
+ ), + }, + { + key: 'beds', + label: `床位管理 (${beds.length})`, + children: ( +
+ {canEditRooms ? ( +
+ + 0 ? '批量生成床位' : '床位已达到额定人数'} + description={ + remainingBedSlots > 0 ? ( + + ) : ( + '如需增加床位,请先调整宿舍额定人数' + ) + } + onConfirm={() => { + const input = document.getElementById( + 'batch-bed-count', + ) as HTMLInputElement; + onBatchBeds( + input + ? parseInt(input.value) || defaultBatchBedCount + : defaultBatchBedCount, + ); + }} + okText="生成" + disabled={room?.status === 'archived' || remainingBedSlots === 0} + > + + +
+ ) : null} +
( + + {v} + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s: string, r: BedItem) => ( + + + {BED_STATUS_MAP[s]?.text || s} + + + ), + }, + { + title: '备注', + dataIndex: 'notes', + render: (v: string, r: BedItem) => ( + + {v || '-'} + + ), + }, + { + title: '操作', + width: 120, + render: roomItemActions('bed'), + }, + ]} + /> + + ), + }, + { + key: 'lockers', + label: `柜子管理 (${lockers.length})`, + children: ( +
+ {canEditRooms ? ( +
+ + + } + onConfirm={() => { + const input = document.getElementById( + 'batch-locker-count', + ) as HTMLInputElement; + onBatchLockers(input ? parseInt(input.value) || 4 : 4); + }} + okText="生成" + disabled={room?.status === 'archived'} + > + + +
+ ) : null} +
( + + {v} + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s: string, r: LockerItem) => ( + + + {BED_STATUS_MAP[s]?.text || s} + + + ), + }, + { + title: '备注', + dataIndex: 'notes', + render: (v: string, r: LockerItem) => ( + + {v || '-'} + + ), + }, + { + title: '操作', + width: 120, + render: roomItemActions('locker'), + }, + ]} + /> + + ), + }, + ]} + /> + + ); +}; diff --git a/apps/admin/src/pages/Rooms/RoomModals.tsx b/apps/admin/src/pages/Rooms/RoomModals.tsx new file mode 100644 index 0000000..f06c0b7 --- /dev/null +++ b/apps/admin/src/pages/Rooms/RoomModals.tsx @@ -0,0 +1,244 @@ +// aislop-ignore-file: duplicate-block -- 宿舍/床位/柜子表单声明结构相似且字段不同,已共享 RoomItemFormFields +import React from 'react'; +import { Form, Input, InputNumber, Modal, Select } from 'antd'; +import { RoomDrawer } from './RoomDrawer'; +import type { BedItem, LockerItem } from './RoomColumns'; +import { parseRoomNumber } from './RoomColumns'; + +export const RoomItemFormFields: React.FC<{ + fieldName: 'bedNumber' | 'lockerNumber'; + label: string; + placeholder: string; +}> = ({ fieldName, label, placeholder }) => ( + <> + + + + + { + const parsed = parseRoomNumber(e.target.value); + if (parsed) form.setFieldsValue(parsed); + }} + /> + + + + + + + + + + + + + + + + + {editing && ( + +
}} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + showTotal: (total) => `共 ${total} 间`, + }} + rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')} + rowSelection={{ + selectedRowKeys, + onChange: (keys) => onSelect(keys as number[]), + }} + /> + + + ); +}; diff --git a/apps/admin/src/pages/Rooms/RoomsToolbar.tsx b/apps/admin/src/pages/Rooms/RoomsToolbar.tsx new file mode 100644 index 0000000..0a1cbb2 --- /dev/null +++ b/apps/admin/src/pages/Rooms/RoomsToolbar.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { Button, Input, Popconfirm, Select, Space, Upload } from 'antd'; +import type { UploadRequestOption } from '@rc-component/upload/lib/interface'; +import { + DeleteOutlined, + DownloadOutlined, + ExportOutlined, + InboxOutlined, + PlusOutlined, + SearchOutlined, + UndoOutlined, + UploadOutlined, +} from '@ant-design/icons'; +import PermissionButton from '../../components/PermissionButton'; + +export interface RoomsToolbarProps { + onSearch: (value: string) => void; + buildings: string[]; + filterBuilding?: string; + onFilterBuilding: (value?: string) => void; + filterStatus?: string; + onFilterStatus: (value?: string) => void; + filterRentalCategory?: string; + onFilterRentalCategory: (value?: string) => void; + showArchived: boolean; + onToggleArchived: () => void; + selectedRowKeys: number[]; + batchLoading: boolean; + canEditRooms: boolean; + canPurgeRooms: boolean; + canDeleteRooms: boolean; + hasCreatePermission: boolean; + onBatchRestore: () => void; + onBatchPurge: () => void; + onBatchDelete: () => void; + onAddRoom: () => void; + onImport: (options: UploadRequestOption<{ message?: string }>) => void; + onDownloadTemplate: () => void; + onExport: () => void; +} + +export const RoomsToolbar: React.FC = ({ + onSearch, + buildings, + filterBuilding, + onFilterBuilding, + filterStatus, + onFilterStatus, + filterRentalCategory, + onFilterRentalCategory, + showArchived, + onToggleArchived, + selectedRowKeys, + batchLoading, + canEditRooms, + canPurgeRooms, + canDeleteRooms, + hasCreatePermission, + onBatchRestore, + onBatchPurge, + onBatchDelete, + onAddRoom, + onImport, + onDownloadTemplate, + onExport, +}) => { + return ( +
+ +

宿舍管理

+ } + /> + + setFilterBuilding(v)} - options={buildings.map((b) => ({ value: b, label: b }))} - /> - - -
- - {showArchived && canEditRooms ? ( - - - - ) : !showArchived && canDeleteRooms ? ( - - - - ) : null} - {!showArchived ? ( - } - onClick={() => { - setEditing(null); - form.resetFields(); - setModalOpen(true); - }} - > - 添加宿舍 - - ) : null} - {!showArchived && hasPermission('room:create') ? ( - ) => { - const { file, onSuccess, onError } = options; - if (typeof file === 'string') { - message.error('不支持字符串文件'); - return; - } - try { - const formData = new FormData(); - formData.append('file', file); - const res = await api.post<{ message?: string }>('/rooms/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - message.success(res.message || '导入成功'); - onSuccess?.(res); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '导入失败'); - onError?.(e as UploadRequestError); - } - }} - > - - - ) : null} - } - onClick={handleDownloadTemplate} - > - 下载模板 - - } onClick={handleExport}> - 导出列表 - - -
-
}} - pagination={{ - defaultPageSize: 20, - showSizeChanger: true, - pageSizeOptions: [20, 50, 100], - showTotal: (total) => `共 ${total} 间`, + { + setShowArchived(!showArchived); + setFilterStatus(undefined); + setSelectedRowKeys([]); }} - rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')} - rowSelection={{ - selectedRowKeys, - onChange: (keys) => setSelectedRowKeys(keys as number[]), + selectedRowKeys={selectedRowKeys} + batchLoading={batchLoading} + canEditRooms={canEditRooms} + canPurgeRooms={canPurgeRooms} + canDeleteRooms={canDeleteRooms} + hasCreatePermission={hasPermission('room:create')} + onBatchRestore={handleBatchRestore} + onBatchPurge={handleBatchPurge} + onBatchDelete={handleBatchDelete} + onAddRoom={() => { + setEditing(null); + form.resetFields(); + setModalOpen(true); }} + onImport={async (options: UploadRequestOption<{ message?: string }>) => { + const { file, onSuccess, onError } = options; + if (typeof file === 'string') { + message.error('不支持字符串文件'); + return; + } + try { + const formData = new FormData(); + formData.append('file', file); + const res = await importMutation.mutateAsync(formData); + message.success(res.message || '导入成功'); + onSuccess?.(res); + } catch (e) { + onError?.(e as Error); + } + }} + onDownloadTemplate={handleDownloadTemplate} + onExport={handleExport} /> - - { + + + { setModalOpen(false); setEditing(null); }} - okText="保存" - confirmLoading={saving} - > -
- - { - const parsed = parseRoomNumber(e.target.value); - if (parsed) form.setFieldsValue(parsed); - }} - /> - - - - - - - - - - - - - - - - - {editing && ( - -
( - saveBedCell(r, 'bedNumber', next)} - > - {v} - - ), - }, - { - title: '状态', - dataIndex: 'status', - width: 80, - render: (s: string, r: BedItem) => ( - saveBedCell(r, 'status', next)} - > - {(() => { - const map: Record = { - available: { text: '空闲', color: 'green' }, - occupied: { text: '占用', color: 'blue' }, - maintenance: { text: '维修', color: 'orange' }, - }; - return {map[s]?.text || s}; - })()} - - ), - }, - { - title: '备注', - dataIndex: 'notes', - render: (v: string, r: BedItem) => ( - saveBedCell(r, 'notes', next)} - > - {v || '-'} - - ), - }, - { - title: '操作', - width: 120, - render: (_: any, r: any) => ( - - { - setBedEditing(r); - bedForm.setFieldsValue(r); - setBedModalOpen(true); - }} - > - 编辑 - - {r.status !== 'occupied' && canEditRooms && ( - handleDeleteBed(r.id)} - > - - - )} - - ), - }, - ]} - /> - - ), - }, - { - key: 'lockers', - label: `柜子管理 (${lockers.length})`, - children: ( -
- {canEditRooms ? ( -
- - - } - onConfirm={() => { - const input = document.getElementById( - 'batch-locker-count', - ) as HTMLInputElement; - handleBatchLockers(input ? parseInt(input.value) || 4 : 4); - }} - okText="生成" - disabled={drawerRoom?.status === 'archived'} - > - - -
- ) : null} -
( - saveLockerCell(r, 'lockerNumber', next)} - > - {v} - - ), - }, - { - title: '状态', - dataIndex: 'status', - width: 80, - render: (s: string, r: LockerItem) => ( - saveLockerCell(r, 'status', next)} - > - {(() => { - const map: Record = { - available: { text: '空闲', color: 'green' }, - occupied: { text: '占用', color: 'blue' }, - maintenance: { text: '维修', color: 'orange' }, - }; - return {map[s]?.text || s}; - })()} - - ), - }, - { - title: '备注', - dataIndex: 'notes', - render: (v: string, r: LockerItem) => ( - saveLockerCell(r, 'notes', next)} - > - {v || '-'} - - ), - }, - { - title: '操作', - width: 120, - render: (_: any, r: any) => ( - - { - setLockerEditing(r); - lockerForm.setFieldsValue(r); - setLockerModalOpen(true); - }} - > - 编辑 - - {r.status !== 'occupied' && canEditRooms && ( - handleDeleteLocker(r.id)} - > - - - )} - - ), - }, - ]} - /> - - ), - }, - ]} - /> - - - { + onAddBed={() => { + setBedEditing(null); + bedForm.resetFields(); + setBedModalOpen(true); + }} + onBatchBeds={handleBatchBeds} + onEditBed={(r) => { + setBedEditing(r); + bedForm.setFieldsValue(r); + setBedModalOpen(true); + }} + onDeleteBed={handleDeleteBed} + onSaveBedCell={saveBedCell} + onAddLocker={() => { + setLockerEditing(null); + lockerForm.resetFields(); + setLockerModalOpen(true); + }} + onBatchLockers={handleBatchLockers} + onEditLocker={(r) => { + setLockerEditing(r); + lockerForm.setFieldsValue(r); + setLockerModalOpen(true); + }} + onDeleteLocker={handleDeleteLocker} + onSaveLockerCell={saveLockerCell} + bedModalOpen={bedModalOpen} + bedEditing={!!bedEditing} + savingBed={savingBed} + bedForm={bedForm} + onSaveBed={handleSaveBed} + onCloseBedModal={() => { setBedModalOpen(false); setBedEditing(null); }} - confirmLoading={savingBed} - okText="保存" - > - - - - - - - - -
+ + + + {WEEKDAYS.map((day) => ( + + ))} + + + + {filteredClassrooms.map((classroom) => ( + + + {WEEKDAY_NUMBERS.map((wd) => { + const schedules = displayMatrix[classroom.id]?.[wd] || []; + const hasContent = schedules.length > 0; + return ( + + ); + })} + + ))} + +
+ 教室 + + {day} +
+
{classroom.name}
+ {classroom.building && ( +
+ {classroom.building} + {classroom.floor ? ` ${classroom.floor}F` : ''} +
+ )} +
onCellClick(classroom.id, wd)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onCellClick(classroom.id, wd); + } + }} + style={{ + padding: 4, + border: '1px solid #f0f0f0', + verticalAlign: 'top', + cursor: 'pointer', + minHeight: 56, + transition: 'background 0.15s', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.background = '#f6f8fa'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.background = ''; + }} + > + {hasContent ? ( +
+ {schedules.map((s) => ( + +
+
+ {s.subject} +
+
+ {s.startTime}-{s.endTime} +
+
+
+ ))} +
+ ) : ( +
+ — +
+ )} +
+
+ ) : ( +
+ + + + {WEEKDAYS.map((d) => ( + + ))} + + + + {weeks.map((week, wi) => ( + + {week.map((day, di) => { + const isCurrentMonth = day.month() === monthStart.month(); + const dateKey = day.format('YYYY-MM-DD'); + const daySchedules = monthScheduleMap[dateKey] || []; + const count = daySchedules.length; + return ( + + ); + })} + + ))} + +
+ {d} +
onDateClick(day)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onDateClick(day); + } + }} + style={{ + padding: '6px 8px', + border: '1px solid #f0f0f0', + verticalAlign: 'top', + cursor: 'pointer', + height: 90, + background: isCurrentMonth ? '#fff' : '#fafafa', + transition: 'background 0.15s', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.background = isCurrentMonth + ? '#f0f5ff' + : '#f0f0f0'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.background = isCurrentMonth + ? '' + : '#fafafa'; + }} + > +
+ {day.date()} +
+ {count > 0 && ( + + )} +
+
+ )} + + ); +}; diff --git a/apps/admin/src/pages/Schedules/ScheduleModals.tsx b/apps/admin/src/pages/Schedules/ScheduleModals.tsx new file mode 100644 index 0000000..e0b9cfb --- /dev/null +++ b/apps/admin/src/pages/Schedules/ScheduleModals.tsx @@ -0,0 +1,561 @@ +import React from 'react'; +import { + Alert, + Button, + Card, + Col, + DatePicker, + Empty, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Row, + Select, + Space, + Spin, + Statistic, + Switch, + Tag, + TimePicker, +} from 'antd'; +import { CloudSyncOutlined, EditOutlined, PlusOutlined, StopOutlined } from '@ant-design/icons'; +import type { Dayjs } from 'dayjs'; +import PermissionButton from '../../components/PermissionButton'; +import { isMaskedSchedule } from './schedule-visibility'; +import type { ScheduleFormValues } from './schedule-form'; +import type { ClassItem, ClassScheduleItem, ClassTeacherOption, ClassroomItem } from './ScheduleGrids'; +import { WEEKDAYS } from './ScheduleGrids'; + +export interface ScheduleModalProps { + open: boolean; + mode: 'create' | 'edit' | 'detail'; + submitting: boolean; + form: ReturnType>[0]; + selectedCell: { classroomId: number; weekDay: number } | null; + selectedDate: Dayjs | null; + selectedSchedules: ClassScheduleItem[]; + editingSchedule: ClassScheduleItem | null; + selectedClassroom?: ClassroomItem; + classOptions: Array<{ value: number; label: string }>; + classroomOptions: Array<{ value: number; label: string }>; + classTeachers: ClassTeacherOption[]; + classes: ClassItem[]; + onCancel: () => void; + onSubmit: () => void; + onStartCreate: () => void; + onEdit: (schedule: ClassScheduleItem) => void; + onDisable: (id: number | null) => void; + onClassChange: (classId: number) => void; + onSubjectBlur: (value: string) => void; +} + +export const ScheduleModal: React.FC = ({ + open, + mode, + submitting, + form, + selectedCell, + selectedDate, + selectedSchedules, + editingSchedule, + selectedClassroom, + classOptions, + classroomOptions, + classTeachers, + classes, + onCancel, + onSubmit, + onStartCreate, + onEdit, + onDisable, + onClassChange, + onSubjectBlur, +}) => { + const title = + mode === 'create' + ? `新增排课 — ${selectedClassroom?.name || ''} · ${ + selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : '' + }` + : mode === 'edit' + ? `编辑排课 — ${editingSchedule?.subject || ''}` + : selectedDate + ? `排课详情 — ${selectedDate.format('YYYY-MM-DD')} ${ + WEEKDAYS[selectedDate.day() === 0 ? 6 : selectedDate.day() - 1] + }` + : `排课详情 — ${selectedClassroom?.name || ''} · ${ + selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : '' + }`; + + return ( + + {mode !== 'detail' ? ( + + + + + + onSubjectBlur(event.target.value)} + /> + + + + +
+ + +
+
仅允许考勤机打卡
+
+ 开启后将关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,并禁止无排班打卡。 +
+
+
+
+ {attendanceMachineOnly && ( + + )} +
+ {syncStatus.activeSchedules === 0 && ( + + )} + + ) : ( + + )} + + ); +}; diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index ce448cb..c188c4c 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -1,42 +1,16 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { - Card, - Button, - Select, - Modal, - Form, - Input, - InputNumber, - DatePicker, - TimePicker, - Popconfirm, - Space, - Spin, - Empty, - Tag, - Tooltip, - Segmented, - Badge, - Row, - Col, - Statistic, - Alert, - Switch, -} from 'antd'; -import { - CalendarOutlined, - LeftOutlined, - RightOutlined, - CloudSyncOutlined, - PlusOutlined, - EditOutlined, - StopOutlined, -} from '@ant-design/icons'; +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 +import React, { useMemo, useState } from 'react'; +import { Button, Card, Form, Segmented, Select, Space } from 'antd'; +import { CalendarOutlined, CloudSyncOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { usePermission } from '../../hooks/usePermission'; import { message } from '../../ui/app-message'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { scheduleLookupsSchema, weeklyScheduleSchema } from '../../api/schemas'; import { buildSchedulePayload, scheduleToFormValues, @@ -44,52 +18,16 @@ import { } from './schedule-form'; import { filterSchedulesForClass, isMaskedSchedule } from './schedule-visibility'; import { classifySyncResult } from './sync-result'; +import { getErrorMessage } from '../../utils/error'; +import { ScheduleGrid } from './ScheduleGrids'; +import type { + ClassItem, + ClassScheduleItem, + ClassTeacherOption, + ClassroomItem, +} from './ScheduleGrids'; +import { ScheduleModal, SyncModal } from './ScheduleModals'; -// ---- Types ---- - -interface ClassScheduleItem { - id: number | null; - classId: number | null; - classroomId: number; - weekDay: number; - startTime: string; - endTime: string; - attendanceAdvanceMinutes: number; - startDate: string; - endDate: string; - subject: string; - teacherId: number | null; - scheduleType: string; - status: string; - notes: string | null; - createdAt: string; - updatedAt: string; - canViewDetails?: boolean; -} - -interface ClassroomItem { - id: number; - name: string; - building: string; - floor: number; - roomType: string; -} - -interface ClassItem { - id: number; - name: string; - code: string; -} - -interface ClassTeacherOption { - id: number; - userId: number; - username?: string; - name?: string; - roleType: string; - subject?: string | null; -} -/** 排班同步返回结果 */ interface ScheduleSyncResult { scheduleCount: number; shiftCount: number; @@ -102,32 +40,14 @@ interface ScheduleSyncResult { groups: Array<{ className: string; groupId: number; itemCount: number }>; } -const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; -const WEEKDAY_NUMBERS = [1, 2, 3, 4, 5, 6, 7]; - -// ---- Component ---- - const SchedulesPage: React.FC = () => { const { hasPermission } = usePermission(); - // View mode and navigation const [viewMode, setViewMode] = useState<'week' | 'month'>('week'); const [viewDate, setViewDate] = useState(() => dayjs().weekday(1).startOf('day')); - - // Modal date selection (month view) const [selectedDate, setSelectedDate] = useState(null); - - // Data - const [classrooms, setClassrooms] = useState([]); - const [classes, setClasses] = useState([]); const [classTeachers, setClassTeachers] = useState([]); - const [matrix, setMatrix] = useState>>({}); - const [loading, setLoading] = useState(false); - - // Filters const [filterClassroomIds, setFilterClassroomIds] = useState([]); const [filterClassId, setFilterClassId] = useState(undefined); - - // Modal const [modalOpen, setModalOpen] = useState(false); const [modalMode, setModalMode] = useState<'create' | 'edit' | 'detail'>('create'); const [editingSchedule, setEditingSchedule] = useState(null); @@ -137,8 +57,6 @@ const SchedulesPage: React.FC = () => { } | null>(null); const [selectedSchedules, setSelectedSchedules] = useState([]); const [submitting, setSubmitting] = useState(false); - - // ── 钉钉排班同步 ── const [syncModalOpen, setSyncModalOpen] = useState(false); const [syncing, setSyncing] = useState(false); const [syncStatus, setSyncStatus] = useState<{ @@ -146,23 +64,13 @@ const SchedulesPage: React.FC = () => { mappedClasses: number; totalClasses: number; } | null>(null); - const [syncResult, setSyncResult] = useState<{ - scheduleCount: number; - shiftCount: number; - groupCount: number; - syncedItems: number; - skippedNoMapping: number; - failedBatchCount: number; - failedItems: number; - errors: string[]; - groups: Array<{ className: string; groupId: number; itemCount: number }>; - } | null>(null); + const [syncResult, setSyncResult] = useState(null); const [syncDateFrom, setSyncDateFrom] = useState(dayjs); const [syncDays, setSyncDays] = useState(30); const [attendanceMachineOnly, setAttendanceMachineOnly] = useState(false); + const [form] = Form.useForm(); - /** 打开同步弹窗时先查询就绪状态 */ - const openSyncModal = useCallback(async () => { + const openSyncModal = async () => { setSyncModalOpen(true); setSyncResult(null); try { @@ -174,10 +82,9 @@ const SchedulesPage: React.FC = () => { } catch { setSyncStatus(null); } - }, []); + }; - /** 执行排班同步 */ - const handleSyncSchedule = useCallback(async () => { + const handleSyncSchedule = async () => { setSyncing(true); try { const res = await api.post<{ @@ -200,15 +107,12 @@ const SchedulesPage: React.FC = () => { message.success(classification.message); } } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '同步失败'); + message.error(getErrorMessage(e, '同步失败')); } finally { setSyncing(false); } - }, [syncDateFrom, syncDays, attendanceMachineOnly]); - const [form] = Form.useForm(); + }; - // Derived week/month info const weekStart = useMemo(() => viewDate.weekday(1).startOf('day'), [viewDate]); const monthStart = useMemo(() => viewDate.startOf('month'), [viewDate]); const weekEnd = useMemo(() => weekStart.add(6, 'day'), [weekStart]); @@ -237,64 +141,83 @@ const SchedulesPage: React.FC = () => { }, [calendarDays]); const startDateStr = useMemo(() => { - if (viewMode === 'month') { - return calendarDays[0].format('YYYY-MM-DD'); - } + if (viewMode === 'month') return calendarDays[0].format('YYYY-MM-DD'); return weekStart.format('YYYY-MM-DD'); }, [viewMode, weekStart, calendarDays]); const endDateStr = useMemo(() => { - if (viewMode === 'month') { - return calendarDays[calendarDays.length - 1].format('YYYY-MM-DD'); - } + if (viewMode === 'month') return calendarDays[calendarDays.length - 1].format('YYYY-MM-DD'); return weekEnd.format('YYYY-MM-DD'); }, [viewMode, weekEnd, calendarDays]); - // ---- Data fetching ---- - - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [lookups, schedulesRes] = await Promise.all([ - api.get('/class-schedules/lookups') as Promise<{ + const { + data: fetchResult = { classrooms: [], classes: [], matrix: {} }, + isLoading, + isFetching, + } = useQuery<{ + classrooms: ClassroomItem[]; + classes: ClassItem[]; + matrix: Record>; + }>({ + queryKey: ['class-schedules', startDateStr, endDateStr, filterClassroomIds], + queryFn: async () => { + try { + const [lookups, schedulesRes] = await Promise.all([ + api.get('/class-schedules/lookups') as Promise<{ + classrooms: ClassroomItem[]; + classes: ClassItem[]; + }>, + api.get('/class-schedules/weekly', { + params: { + startDate: startDateStr, + endDate: endDateStr, + ...(filterClassroomIds.length === 1 ? { classroomId: filterClassroomIds[0] } : {}), + }, + }) as Promise>>, + ]); + const validatedLookups = validateResponse<{ classrooms: ClassroomItem[]; classes: ClassItem[]; - }>, - api.get('/class-schedules/weekly', { - params: { - startDate: startDateStr, - endDate: endDateStr, - ...(filterClassroomIds.length === 1 ? { classroomId: filterClassroomIds[0] } : {}), - }, - }) as Promise>>, - ]); + }>(scheduleLookupsSchema, lookups); + const validatedWeekly = validateResponse< + Record> + >(weeklyScheduleSchema, schedulesRes); - setClassrooms(lookups.classrooms); - setClasses(lookups.classes); - - // Convert string keys to numbers - const typedMatrix: Record> = {}; - for (const [cId, dayMap] of Object.entries(schedulesRes)) { - const classroomId = Number(cId); - typedMatrix[classroomId] = {}; - for (const [wd, schedules] of Object.entries(dayMap)) { - typedMatrix[classroomId][Number(wd)] = schedules; + const typedMatrix: Record> = {}; + for (const [cId, dayMap] of Object.entries(validatedWeekly)) { + const classroomId = Number(cId); + typedMatrix[classroomId] = {}; + for (const [wd, schedules] of Object.entries(dayMap)) { + typedMatrix[classroomId][Number(wd)] = schedules; + } } + return { + classrooms: validatedLookups.classrooms, + classes: validatedLookups.classes, + matrix: typedMatrix, + }; + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载排课数据失败')); + return { classrooms: [], classes: [], matrix: {} }; } - setMatrix(typedMatrix); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载排课数据失败'); - } finally { - setLoading(false); - } - }, [startDateStr, endDateStr, filterClassroomIds]); + }, + }); + const classrooms = fetchResult.classrooms; + const classes = fetchResult.classes; + const matrix = fetchResult.matrix; + const loading = isLoading || isFetching; - useEffect(() => { - fetchData(); - }, [fetchData]); - - // ---- Filtered classrooms ---- + const saveMutation = useApiMutation( + async (payload: Record) => + modalMode === 'edit' && editingSchedule + ? api.put(`/class-schedules/${editingSchedule.id}`, payload) + : api.post('/class-schedules', payload), + { invalidate: [['class-schedules']] }, + ); + const disableMutation = useApiMutation( + async (id: number) => api.put(`/class-schedules/${id}`, { status: 'inactive' }), + { invalidate: [['class-schedules']] }, + ); const filteredClassrooms = useMemo(() => { if (filterClassroomIds.length === 0) return classrooms; @@ -302,7 +225,6 @@ const SchedulesPage: React.FC = () => { return classrooms.filter((c) => idSet.has(c.id)); }, [classrooms, filterClassroomIds]); - // Apply class filter to the matrix const displayMatrix = useMemo(() => { if (filterClassId == null) return matrix; const filtered: Record> = {}; @@ -338,12 +260,10 @@ const SchedulesPage: React.FC = () => { return map; }, [calendarDays, displayMatrix, filteredClassrooms]); - // ---- Cell click handlers ---- const handleCellClick = (classroomId: number, weekDay: number) => { const schedules = displayMatrix[classroomId]?.[weekDay] || []; setSelectedCell({ classroomId, weekDay }); setSelectedDate(null); - if (schedules.length > 0) { setSelectedSchedules(schedules); setModalMode('detail'); @@ -383,7 +303,7 @@ const SchedulesPage: React.FC = () => { setModalOpen(true); }; - const loadClassTeachers = useCallback(async (classId: number) => { + const loadClassTeachers = async (classId: number) => { try { const teachers = await api.get( `/class-schedules/classes/${classId}/teachers`, @@ -394,27 +314,22 @@ const SchedulesPage: React.FC = () => { setClassTeachers([]); return []; } - }, []); + }; - const applyClassTeacherDefaults = useCallback( - async (classId: number, subject?: string) => { - const teachers = await loadClassTeachers(classId); - const subjectTeachers = teachers.filter((teacher) => teacher.roleType === 'subject_teacher'); - const matchedBySubject = subject - ? subjectTeachers.filter((teacher) => teacher.subject && teacher.subject === subject) - : []; - const matched = matchedBySubject.length > 0 ? matchedBySubject : subjectTeachers; - if (matched.length === 1) { - form.setFieldValue('teacherId', matched[0].userId); - if (!subject && matched[0].subject) form.setFieldValue('subject', matched[0].subject); - } else { - form.setFieldValue('teacherId', undefined); - } - }, - [form, loadClassTeachers], - ); - - // ---- Create / edit schedule ---- + const applyClassTeacherDefaults = async (classId: number, subject?: string) => { + const teachers = await loadClassTeachers(classId); + const subjectTeachers = teachers.filter((teacher) => teacher.roleType === 'subject_teacher'); + const matchedBySubject = subject + ? subjectTeachers.filter((teacher) => teacher.subject && teacher.subject === subject) + : []; + const matched = matchedBySubject.length > 0 ? matchedBySubject : subjectTeachers; + if (matched.length === 1) { + form.setFieldValue('teacherId', matched[0].userId); + if (!subject && matched[0].subject) form.setFieldValue('subject', matched[0].subject); + } else { + form.setFieldValue('teacherId', undefined); + } + }; const handleSubmit = async () => { if (modalMode === 'create' && !selectedCell) return; @@ -423,20 +338,14 @@ const SchedulesPage: React.FC = () => { const values = (await form.validateFields()) as ScheduleFormValues; setSubmitting(true); const payload = buildSchedulePayload(values); - - if (modalMode === 'edit' && editingSchedule) { - await api.put(`/class-schedules/${editingSchedule.id}`, payload); - message.success('排课更新成功,请重新同步到钉钉排班'); - } else { - await api.post('/class-schedules', payload); - message.success('排课创建成功'); - } + await saveMutation.mutateAsync(payload); + message.success( + modalMode === 'edit' ? '排课更新成功,请重新同步到钉钉排班' : '排课创建成功', + ); setModalOpen(false); setEditingSchedule(null); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string; status?: number }; - message.error(err?.message || (modalMode === 'edit' ? '更新排课失败' : '创建排课失败')); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSubmitting(false); } @@ -448,11 +357,16 @@ const SchedulesPage: React.FC = () => { message.warning('租赁排课请在租赁订单中修改'); return; } - const editableSchedule = { ...schedule, id: schedule.id, classId: schedule.classId }; setEditingSchedule(schedule); setModalMode('edit'); - form.setFieldsValue(scheduleToFormValues(editableSchedule)); - void loadClassTeachers(editableSchedule.classId); + form.setFieldsValue( + scheduleToFormValues({ + ...schedule, + id: schedule.id ?? undefined, + classId: schedule.classId ?? undefined, + }), + ); + void loadClassTeachers(schedule.classId); }; const removeScheduleFromSelection = (id: number) => { @@ -463,23 +377,17 @@ const SchedulesPage: React.FC = () => { } }; - // ---- Disable / delete schedule ---- - const handleDisable = async (id: number | null) => { if (id === null) return; try { - await api.put(`/class-schedules/${id}`, { status: 'inactive' }); + await disableMutation.mutateAsync(id); message.success('排课已停用,历史考勤记录已保留,教室占用已释放'); removeScheduleFromSelection(id); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '停用失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; - // ---- Classroom select options ---- - const classroomOptions = useMemo( () => classrooms.map((c) => ({ @@ -498,15 +406,12 @@ const SchedulesPage: React.FC = () => { [classes], ); - // ---- Render ---- - const selectedClassroom = selectedCell ? classrooms.find((c) => c.id === selectedCell.classroomId) : undefined; return (
- {/* Header */}
{ permission="sync:trigger" type="primary" icon={} - onClick={openSyncModal} + onClick={() => void openSyncModal()} > 同步到钉钉排班 {viewMode === 'week' ? ( <> - @@ -561,19 +463,11 @@ const SchedulesPage: React.FC = () => { ) : ( <> - - - {monthStart.format('YYYY年 M月')} - - @@ -581,7 +475,6 @@ const SchedulesPage: React.FC = () => {
- {/* Filters */} { - form.setFieldValue('teacherId', undefined); - void applyClassTeacherDefaults(classId, form.getFieldValue('subject')); - }} - /> - + onSubmit={handleSubmit} + onStartCreate={() => { + setEditingSchedule(null); + setModalMode('create'); + form.resetFields(); + form.setFieldsValue({ + classroomId: selectedCell?.classroomId, + weekDay: + selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined), + dateRange: selectedDate ? [selectedDate, selectedDate] : undefined, + attendanceAdvanceMinutes: 30, + }); + }} + onEdit={openEditSchedule} + onDisable={handleDisable} + onClassChange={(classId) => { + form.setFieldValue('teacherId', undefined); + void applyClassTeacherDefaults(classId, form.getFieldValue('subject')); + }} + onSubjectBlur={(value) => { + const classId = form.getFieldValue('classId'); + if (classId) void applyClassTeacherDefaults(classId, value); + }} + /> - - ({ value, label: WEEKDAYS[value - 1] }))} - /> - - - - { - const classId = form.getFieldValue('classId'); - if (classId) void applyClassTeacherDefaults(classId, event.target.value); - }} - /> - - - - - -
- - -
-
仅允许考勤机打卡
-
- 开启后将关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,并禁止无排班打卡。 -
-
-
-
- {attendanceMachineOnly && ( - - )} -
- {syncStatus.activeSchedules === 0 && ( - - )} - - ) : ( - - )} - + onSync={handleSyncSchedule} + onDateChange={setSyncDateFrom} + onDaysChange={setSyncDays} + onMachineOnlyChange={setAttendanceMachineOnly} + /> ); }; diff --git a/apps/admin/src/pages/Students/StudentColumns.tsx b/apps/admin/src/pages/Students/StudentColumns.tsx new file mode 100644 index 0000000..6bbf4bc --- /dev/null +++ b/apps/admin/src/pages/Students/StudentColumns.tsx @@ -0,0 +1,388 @@ +// aislop-ignore-file: duplicate-block -- 单元格渲染结构相似且字段不同,逻辑已通过 EditableStudentCell 共享 +import React from 'react'; +import { Button, Popconfirm, Space, Tag } from 'antd'; +import { EyeOutlined, InboxOutlined, UndoOutlined } from '@ant-design/icons'; +import PermissionButton from '../../components/PermissionButton'; +import EditableCell from '../../components/EditableCell'; +import { maskIdNumber, maskPhone } from '../../utils/sensitive'; + +export const statusMap: Record = { + active: { text: '在读', color: 'green' }, + graduated: { text: '已毕业', color: 'blue' }, + withdrawn: { text: '已退训', color: 'red' }, + archived: { text: '已归档', color: '#999' }, +}; + +export const STUDENT_FIELDS = { + name: 'name', + studentNo: 'studentNo', + ethnicity: 'ethnicity', + emergencyContact: 'emergencyContact', + supervisor: 'supervisor', + status: 'status', + organizationId: 'organizationId', +} as const; + +export const SENSITIVE_LABELS = { + phone: '电话', + idNumber: '身份证号', + emergencyPhone: '紧急联系人电话', +} as const; + +export const STUDENT_STATUS_OPTIONS = [ + { value: 'active', label: '在读' }, + { value: 'graduated', label: '已毕业' }, + { value: 'withdrawn', label: '已退训' }, +]; + +export interface StudentColumnContext { + pageInfo: { current: number; pageSize: number }; + organizations: Array<{ id: number; name: string; isHost?: boolean }>; + canChooseOrganization: boolean; + canEditStudent: boolean; + canDeleteStudent: boolean; + canPurgeStudent: boolean; + canViewSensitive: boolean; + onSaveCell: (record: any, field: string, value: unknown) => Promise | void; + onViewSensitive: (recordId: number, field: string, value: string) => void; + onOpenDrawer: (recordId: number) => void; + onEdit: (record: any) => void; + onRestore: (id: number) => Promise | unknown; + onPurge: (id: number, name: string) => void; + onArchive: (id: number) => Promise | unknown; +} + +export const EditableStudentCell = ({ + value, + field, + record, + editor, + min, + max, + required, + options, + onSave, + children, +}: { + value: unknown; + field: string; + record: R; + editor?: React.ComponentProps['editor']; + min?: number; + max?: number; + required?: boolean; + options?: Array<{ value: string | number; label: string }>; + onSave: (record: R, field: string, value: unknown) => Promise | void; + children?: React.ReactNode; +}) => ( + { + await onSave(record, field, next); + }} + > + {children ?? String(value ?? '-')} + +); + +export const SensitiveValue: React.FC<{ + value: string; + masked: string; + label: string; + recordId: number; + canViewSensitive: boolean; + onViewSensitive: (recordId: number, field: string, value: string) => void; +}> = ({ value, masked, label, recordId, canViewSensitive, onViewSensitive }) => { + if (!value) return <>-; + return ( + + {masked} + {canViewSensitive ? ( + + ) : null} + + ); +}; + +function buildIdentityColumns(ctx: StudentColumnContext) { + const { + pageInfo, + canViewSensitive, + onSaveCell, + onViewSensitive, + } = ctx; + + return [ + { + title: '序号', + key: 'index', + width: 70, + render: (_: unknown, __: unknown, index: number) => + (pageInfo.current - 1) * pageInfo.pageSize + index + 1, + }, + { + title: '姓名', + dataIndex: 'name', + width: 120, + render: (v: string, record: any) => ( + + {v} + + ), + }, + { + title: '电话', + dataIndex: 'phone', + width: 140, + render: (v: string, record: any) => ( + + ), + }, + { + title: '学号', + dataIndex: 'studentNo', + width: 120, + render: (v: string, record: any) => ( + + {v || '-'} + + ), + }, + { + title: '身份证', + dataIndex: 'idNumber', + width: 180, + render: (v: string, record: any) => ( + + ), + }, + ]; +} + +function buildContactColumns(ctx: StudentColumnContext) { + const { organizations, canChooseOrganization, canViewSensitive, onSaveCell, onViewSensitive } = + ctx; + return [ + { + title: '民族', + dataIndex: 'ethnicity', + width: 90, + render: (v: string, record: any) => ( + + {v || '-'} + + ), + }, + { + title: '紧急联系人', + dataIndex: 'emergencyContact', + width: 100, + render: (v: string, record: any) => ( + + {v || '-'} + + ), + }, + { + title: '紧急联系人电话', + dataIndex: 'emergencyPhone', + width: 150, + render: (v: string, record: any) => ( + + ), + }, + { + title: '所属机构', + dataIndex: 'organization', + width: 100, + render: (organization: { name?: string } | null, record: any) => + canChooseOrganization ? ( + ({ value: item.id, label: item.name }))} + required + onSave={onSaveCell} + > + {organization?.name ? ( + + {organization.name} + + ) : ( + '-' + )} + + ) : organization?.name ? ( + {organization.name} + ) : ( + '-' + ), + }, + ]; +} + +function buildProfileColumns(ctx: StudentColumnContext) { + const { onSaveCell } = ctx; + return [ + { + title: '负责人', + dataIndex: 'supervisor', + width: 100, + render: (v: string, record: any) => ( + + {v || '-'} + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s: string, record: any) => ( + + + {statusMap[s]?.text || s} + + + ), + }, + ]; +} + +function buildActionColumn(ctx: StudentColumnContext) { + const { + canEditStudent, + canDeleteStudent, + canPurgeStudent, + onOpenDrawer, + onEdit, + onRestore, + onPurge, + onArchive, + } = ctx; + return { + title: '操作', + width: 180, + render: (_: any, record: any) => ( + + {record.status === 'archived' ? ( + <> + {canEditStudent ? ( + onRestore(record.id)} + okText="恢复" + cancelText="取消" + > + + + ) : null} + {canPurgeStudent ? ( + + ) : null} + + ) : ( + <> + onOpenDrawer(record.id)} + > + 档案 + + onEdit(record)}> + 编辑 + + {canDeleteStudent ? ( + onArchive(record.id)} + okText="归档" + cancelText="取消" + > + + + ) : null} + + )} + + ), + }; +} + +export function buildStudentColumns(ctx: StudentColumnContext) { + return [ + ...buildIdentityColumns(ctx), + ...buildContactColumns(ctx), + ...buildProfileColumns(ctx), + buildActionColumn(ctx), + ]; +} diff --git a/apps/admin/src/pages/Students/StudentModals.tsx b/apps/admin/src/pages/Students/StudentModals.tsx new file mode 100644 index 0000000..a69c394 --- /dev/null +++ b/apps/admin/src/pages/Students/StudentModals.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { App, Descriptions, Drawer, Form, Input, Modal, Select } from 'antd'; +import JinshujuMatchModal from '../../components/JinshujuMatchModal'; +import StudentProfileContent from '../../components/StudentProfileContent'; +import { SENSITIVE_LABELS } from './StudentColumns'; + +type AppModal = ReturnType['modal']; + +export const showCreateImportResult = ( + modal: AppModal, + result: { message?: string; imported?: number; skipped?: number }, +) => { + const imported = result.imported ?? 0; + const skipped = result.skipped ?? 0; + modal.success({ + title: '导入完成', + okText: '知道了', + content: ( +
+ + {imported} 人 + {skipped} 人 + +
跳过原因:
+
    +
  • 姓名为空
  • +
  • 已存在同名学生
  • +
+
+ 当前后端只返回统计汇总,暂时无法列出具体哪几行被跳过。 +
+
+ ), + }); +}; + +export const showUpdateImportResult = ( + modal: AppModal, + result: { message?: string; matched?: number; skipped?: number }, +) => { + const matched = result.matched ?? 0; + const skipped = result.skipped ?? 0; + modal.success({ + title: '更新完成', + okText: '知道了', + content: ( +
+ + {matched} 人 + {skipped} 人 + +
匹配规则:
+
手机号优先,身份证号其次
+
+ 当前后端只返回统计汇总,暂时无法列出具体哪几行未匹配。 +
+
+ ), + }); +}; + +export const StudentEditModal: React.FC<{ + open: boolean; + editing: boolean; + saving: boolean; + form: ReturnType[0]; + canChooseOrganization: boolean; + organizations: Array<{ id: number; name: string; isHost?: boolean }>; + onOk?: () => void; + onCancel: () => void; +}> = ({ + open, + editing, + saving, + form, + canChooseOrganization, + organizations, + onOk, + onCancel, +}) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + {canChooseOrganization ? ( + + + + {editing && ( + + + {Object.entries(statusMap) + .filter(([k]) => k !== 'archived') + .map(([k, v]) => ( + + {v.text} + + ))} + + {canViewOrganizations ? ( + + ) : null} + ({ + value: item.id, + label: item.name === item.username ? item.name : `${item.name}(${item.username})`, + }))} + /> + + + + {showArchived && canEditStudent ? ( + <> + + + + {canPurgeStudent ? ( + + + + ) : null} + + ) : !showArchived && canDeleteStudent ? ( + + + + ) : null} + {!showArchived ? ( + } + onClick={onAddStudent} + > + 添加学生 + + ) : null} + {!showArchived && ( + <> + + + + + + + + )} + {!showArchived && canSyncJinshuju ? ( + + ) : null} + {!showArchived && canSyncDingTalk ? ( + + ) : null} + } + onClick={onDownloadTemplate} + > + 下载模板 + + } + onClick={onExport} + > + 导出名单 + + + + + ); +}; diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index 5e70cea..c2a1d26 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -1,73 +1,33 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { - Alert, App, - Button, - Card, - Col, - Descriptions, - Drawer, - Empty, Form, - Input, - Modal, - Popconfirm, - Row, - Select, - Space, - Table, - Tag, - Upload, } from 'antd'; -import type { UploadProps } from 'antd'; -import { - CloudUploadOutlined, - DownloadOutlined, - ExportOutlined, - EyeOutlined, - InboxOutlined, - PlusOutlined, - SwapOutlined, - SyncOutlined, - UndoOutlined, - UploadOutlined, -} from '@ant-design/icons'; import api from '../../api'; -import PermissionButton from '../../components/PermissionButton'; -import StudentProfileContent from '../../components/StudentProfileContent'; -import EditableCell from '../../components/EditableCell'; -import JinshujuMatchModal from '../../components/JinshujuMatchModal'; -import { maskIdNumber, maskPhone } from '../../utils/sensitive'; -import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import { useUserStore } from '../../store/user/userStore'; import { selectArchiveRecords } from '../archive-view'; - -const statusMap: Record = { - active: { text: '在读', color: 'green' }, - graduated: { text: '已毕业', color: 'blue' }, - withdrawn: { text: '已退训', color: 'red' }, - archived: { text: '已归档', color: '#999' }, -}; - -interface EnrollmentInfo { - classId: number; - className: string; - classType: string; - startDate: string; - endDate: string; - joinDate: string; - leaveDate: string; - status: string; - attendanceStats: { - total: number; - present: number; - absent: number; - late: number; - leave: number; - rate: number; - }; -} +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { + organizationOptionsSchema, + organizationsSchema, + studentFilterLookupsSchema, + studentsSchema, +} from '../../api/schemas'; +import { getErrorMessage } from '../../utils/error'; +import { message } from '../../ui/app-message'; +import { buildStudentColumns } from './StudentColumns'; +import { StudentsToolbar } from './StudentsToolbar'; +import { + JinshujuModal, + StudentDrawer, + StudentEditModal, + showCreateImportResult, + showUpdateImportResult, +} from './StudentModals'; +import { StudentsTable } from './StudentsTable'; interface StudentCreateImportResult { message?: string; @@ -110,50 +70,34 @@ const StudentsPage: React.FC = () => { const canCreateStudent = hasPermission('student:create'); const canEditStudent = hasPermission('student:edit'); const canDeleteStudent = hasPermission('student:delete'); + const canPurgeStudent = hasPermission('student:purge'); const canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger'); const canSyncDingTalk = hasAllPermissions('sync:read', 'sync:trigger'); - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); - const [organizations, setOrganizations] = useState([]); const [editing, setEditing] = useState(null); const canSaveStudent = editing ? canEditStudent : canCreateStudent; const [searchName, setSearchName] = useState(''); const [filterStatus, setFilterStatus] = useState(undefined); const [filterOrganizationId, setFilterOrganizationId] = useState(undefined); + const effectiveFilterOrganizationId = canLoadOrganizations ? filterOrganizationId : undefined; const [filterClassId, setFilterClassId] = useState(undefined); const [filterTeacherId, setFilterTeacherId] = useState(undefined); - const [classOptions, setClassOptions] = useState([]); - const [teacherOptions, setTeacherOptions] = useState([]); const [showArchived, setShowArchived] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [batchLoading, setBatchLoading] = useState(false); const [dingSyncLoading, setDingSyncLoading] = useState(false); - const [enrollmentData, setEnrollmentData] = useState>({}); const [pageInfo, setPageInfo] = useState({ current: 1, pageSize: 15 }); const [drawerOpen, setDrawerOpen] = useState(false); const [drawerStudentId, setDrawerStudentId] = useState(undefined); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); + const [jinshujuOpen, setJinshujuOpen] = useState(false); const openDrawer = (studentId: number) => { setDrawerStudentId(studentId); setDrawerOpen(true); }; - const [jinshujuOpen, setJinshujuOpen] = useState(false); - - // Sensitive info modal — command-style; destroy when log:create is lost or comp unmounts. - // Close the student form modal when the user loses the required permission. - useEffect(() => { - if (!canSaveStudent && modalOpen) { - setModalOpen(false); - setEditing(null); - form.resetFields(); - } - }, [canSaveStudent, modalOpen, form]); - - // Close sensitive modal when log:create is lost (imperative ref already set above). const logCreateRef = React.useRef(hasPermission('log:create')); const sensitiveModalRef = React.useRef | null>(null); logCreateRef.current = hasPermission('log:create'); @@ -190,7 +134,8 @@ const StudentsPage: React.FC = () => { content: value, okText: '关闭', }); - } catch { + } catch (e) { + console.error('审计日志记录失败', e); message.error('审计日志记录失败,请稍后重试'); } }, @@ -200,16 +145,235 @@ const StudentsPage: React.FC = () => { }); }; + const { + data = [], + isLoading, + isFetching, + } = useQuery({ + queryKey: [ + 'students', + searchName, + showArchived, + filterStatus, + effectiveFilterOrganizationId, + filterClassId, + filterTeacherId, + ], + queryFn: async () => { + try { + const params: Record = { + name: searchName || undefined, + includeArchived: showArchived ? 'true' : undefined, + }; + if (showArchived) params.status = 'archived'; + else if (filterStatus) params.status = filterStatus; + if (effectiveFilterOrganizationId) params.organizationId = effectiveFilterOrganizationId; + if (filterClassId) params.classId = filterClassId; + if (filterTeacherId) params.teacherId = filterTeacherId; + const res = (await api.get('/students', { params })) as Array>; + return selectArchiveRecords( + validateResponse>>(studentsSchema, res), + showArchived ? 'archived' : 'active', + ); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败,请稍后重试')); + return []; + } + }, + }); + const loading = isLoading || isFetching; + + const queryClient = useQueryClient(); + const invalidateStudents: Array = [['students']]; + const saveMutation = useApiMutation( + async (values: Record) => + editing ? api.put(`/students/${editing.id}`, values) : api.post('/students', values), + { invalidate: invalidateStudents }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: any; field: string; value: unknown }) => + api.put(`/students/${record.id}`, { [field]: value }), + { invalidate: invalidateStudents }, + ); + const archiveMutation = useApiMutation( + async (id: number) => api.delete(`/students/${id}`), + { invalidate: invalidateStudents }, + ); + const restoreMutation = useApiMutation( + async (id: number) => api.put(`/students/${id}/restore`), + { invalidate: invalidateStudents }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/students/${id}/permanent`), + { invalidate: invalidateStudents }, + ); + const batchDeleteMutation = useApiMutation( + async (ids: number[]) => api.post('/students/batch-delete', { ids }), + { invalidate: invalidateStudents }, + ); + const batchRestoreMutation = useApiMutation( + async (ids: number[]) => + api.put<{ message?: string; restored: number; skipped: number }>( + '/students/batch-restore', + { ids }, + ), + { invalidate: invalidateStudents }, + ); + const batchPurgeMutation = useApiMutation( + async (ids: number[]) => api.post('/students/batch-permanent-delete', { ids }), + { invalidate: invalidateStudents }, + ); + const importMutation = useApiMutation( + async (formData: FormData) => + api.post('/students/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: invalidateStudents }, + ); + const importMatchMutation = useApiMutation( + async (formData: FormData) => + api.post('/students/import-match', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + { invalidate: invalidateStudents }, + ); + + const { data: organizations = [] } = useQuery< + Array<{ id: number; name: string; isHost?: boolean }> + >({ + queryKey: ['students', 'organizations', canViewOrganizations], + enabled: canLoadOrganizations, + queryFn: async () => { + try { + if (canViewOrganizations) { + return validateResponse>( + organizationsSchema, + await api.get('/organizations', { + params: { includeArchived: 'false' }, + }), + ); + } + return validateResponse>( + organizationOptionsSchema, + await api.get('/organizations/options'), + ); + } catch { + return []; + } + }, + }); + const { data: lookups = { classes: [], teachers: [] } } = useQuery({ + queryKey: ['students', 'filter-lookups'], + enabled: canLoadOrganizations, + queryFn: async () => { + try { + return validateResponse( + studentFilterLookupsSchema, + await api.get('/students/filter-lookups'), + ); + } catch { + return { classes: [], teachers: [] }; + } + }, + }); + const classOptions = lookups.classes || []; + const teacherOptions = lookups.teachers || []; + + const handleSave = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + await saveMutation.mutateAsync(values); + message.success(editing ? '更新成功' : '创建成功'); + setModalOpen(false); + form.resetFields(); + setEditing(null); + } catch { + // 错误提示由 useApiMutation 统一处理 + } finally { + setSaving(false); + } + }; + + const saveCell = useCallback( + async (record: any, field: string, value: unknown) => { + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [saveCellMutation], + ); + + const downloadApiFile = async (path: string, filename: string, errorMessage = '下载失败') => { + const baseURL = import.meta.env.PROD + ? '/api' + : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; + const token = useUserStore.getState().token; + try { + const res = await fetch(`${baseURL}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } catch (error: unknown) { + console.error(errorMessage, error); + message.error(errorMessage); + } + }; + + const handleArchive = async (id: number) => { + try { + await archiveMutation.mutateAsync(id); + message.success('已归档'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }; + + const handleRestore = async (id: number) => { + try { + await restoreMutation.mutateAsync(id); + message.success('已恢复'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }; + + const handlePurge = (id: number, name: string) => { + modal.confirm({ + title: `永久删除学生「${name}」?`, + content: '删除后不可恢复,该学生及其关联数据将无法找回。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + const handleBatchDelete = async () => { if (batchLoading) return; setBatchLoading(true); try { - const res: any = await api.post('/students/batch-delete', { ids: selectedRowKeys }); + const res: any = await batchDeleteMutation.mutateAsync(selectedRowKeys); message.success(res?.message || `已批量归档 ${selectedRowKeys.length} 人`); setSelectedRowKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量归档失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } @@ -219,241 +383,59 @@ const StudentsPage: React.FC = () => { if (batchLoading) return; setBatchLoading(true); try { - const res = await api.put<{ message?: string; restored: number; skipped: number }>( - '/students/batch-restore', - { ids: selectedRowKeys }, - ); + const res = await batchRestoreMutation.mutateAsync(selectedRowKeys); message.success( `已批量恢复 ${res.restored} 人${res.skipped ? `,跳过 ${res.skipped} 人` : ''}`, ); setSelectedRowKeys([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '批量恢复失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; - const fetchData = useCallback(async () => { - setLoading(true); + const handleBatchPurge = async () => { + if (batchLoading) return; + setBatchLoading(true); try { - const params: Record = { - name: searchName || undefined, - includeArchived: showArchived ? 'true' : undefined, - }; - if (showArchived) params.status = 'archived'; - else if (filterStatus) params.status = filterStatus; - if (filterOrganizationId) params.organizationId = filterOrganizationId; - if (filterClassId) params.classId = filterClassId; - if (filterTeacherId) params.teacherId = filterTeacherId; - const res = (await api.get('/students', { params })) as Array>; - const list = res as Array>; - setData(selectArchiveRecords(list, showArchived ? 'archived' : 'active')); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, [ - searchName, - showArchived, - filterStatus, - filterOrganizationId, - filterClassId, - filterTeacherId, - ]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - useEffect(() => { - if (!canLoadOrganizations) { - setOrganizations([]); - setFilterOrganizationId(undefined); - return; - } - if (canViewOrganizations) { - api - .get('/organizations', { params: { includeArchived: 'false' } }) - .then((res: unknown) => { - setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); - }) - .catch(() => {}); - } else { - api - .get('/organizations/options') - .then((res: unknown) => { - setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); - }) - .catch(() => {}); - } - api - .get('/students/filter-lookups') - .then((res) => { - setClassOptions(res.classes || []); - setTeacherOptions(res.teachers || []); - }) - .catch(() => {}); - }, [canLoadOrganizations]); - const handleSave = async () => { - const values = await form.validateFields(); - setSaving(true); - try { - if (editing) { - await api.put(`/students/${editing.id}`, values); - message.success('更新成功'); - } else { - await api.post('/students', values); - message.success('创建成功'); - } - setModalOpen(false); - form.resetFields(); - setEditing(null); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); + const res: any = await batchPurgeMutation.mutateAsync(selectedRowKeys); + message.success(res?.message || `已永久删除 ${selectedRowKeys.length} 人`); + setSelectedRowKeys([]); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { - setSaving(false); - } - }; - - const saveCell = useCallback( - async (record: any, field: string, value: unknown) => { - await api.put(`/students/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); - }, - [fetchData], - ); - - const handleArchive = async (id: number) => { - try { - await api.delete(`/students/${id}`); - message.success('已归档'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '归档失败'); - } - }; - - const handleRestore = async (id: number) => { - try { - await api.put(`/students/${id}/restore`); - message.success('已恢复'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '恢复失败'); + setBatchLoading(false); } }; const handleDownloadTemplate = () => { - const baseURL = import.meta.env.PROD - ? '/api' - : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; - const token = useUserStore.getState().token; - fetch(`${baseURL}/students/template`, { headers: { Authorization: `Bearer ${token}` } }) - .then((res) => res.blob()) - .then((blob) => { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = '学生导入模板.xlsx'; - a.click(); - URL.revokeObjectURL(url); - }) - .catch(() => message.error('下载失败')); + void downloadApiFile('/students/template', '学生导入模板.xlsx'); }; - const showCreateImportResult = (result: StudentCreateImportResult) => { - const imported = result.imported ?? 0; - const skipped = result.skipped ?? 0; - - modal.success({ - title: '导入完成', - okText: '知道了', - content: ( -
- - {imported} 人 - {skipped} 人 - -
跳过原因:
-
    -
  • 姓名为空
  • -
  • 已存在同名学生
  • -
-
- 当前后端只返回统计汇总,暂时无法列出具体哪几行被跳过。 -
-
- ), - }); - }; - - const showUpdateImportResult = (result: StudentUpdateImportResult) => { - const matched = result.matched ?? 0; - const skipped = result.skipped ?? 0; - - modal.success({ - title: '更新完成', - okText: '知道了', - content: ( -
- - {matched} 人 - {skipped} 人 - -
匹配规则:
-
手机号优先,身份证号其次
-
- 当前后端只返回统计汇总,暂时无法列出具体哪几行未匹配。 -
-
- ), - }); - }; - - const handleCreateStudentsImport: UploadProps['customRequest'] = async ({ - file, - onSuccess, - onError, - }) => { + const handleCreateStudentsImport = async ({ file, onSuccess, onError }: any) => { const formData = new FormData(); formData.append('file', file as File); try { - const res = (await api.post('/students/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - })) as StudentCreateImportResult; - showCreateImportResult(res); + const res = (await importMutation.mutateAsync(formData)) as StudentCreateImportResult; + showCreateImportResult(modal, res); onSuccess?.(res); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '导入失败'); - onError?.(e instanceof Error ? e : new Error(err?.message || '导入失败')); + } catch (e) { + onError?.(e instanceof Error ? e : new Error(getErrorMessage(e, '导入失败'))); } }; - const handleUpdateExistingStudentsImport: UploadProps['customRequest'] = async ({ - file, - onSuccess, - onError, - }) => { + const handleUpdateExistingStudentsImport = async ({ file, onSuccess, onError }: any) => { const formData = new FormData(); formData.append('file', file as File); try { - const res = (await api.post('/students/import-match', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - })) as StudentUpdateImportResult; - showUpdateImportResult(res); + const res = (await importMatchMutation.mutateAsync(formData)) as StudentUpdateImportResult; + showUpdateImportResult(modal, res); onSuccess?.(res); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '更新已有学生资料失败'); - onError?.(e instanceof Error ? e : new Error(err?.message || '更新已有学生资料失败')); + } catch (e) { + onError?.( + e instanceof Error ? e : new Error(getErrorMessage(e, '更新已有学生资料失败')), + ); } }; @@ -470,723 +452,144 @@ const StudentsPage: React.FC = () => { } else { message.success(log?.errorMessage || `钉钉同步完成,共处理 ${res.synced} 条`); } - await fetchData(); + void queryClient.invalidateQueries({ queryKey: ['students'] }); } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '钉钉同步失败'); + message.error(getErrorMessage(e, '钉钉同步失败')); } finally { setDingSyncLoading(false); } }; const handleExport = () => { - const baseURL = import.meta.env.PROD - ? '/api' - : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; - const token = useUserStore.getState().token; const params = new URLSearchParams(); if (searchName) params.set('name', searchName); if (filterStatus) params.set('status', filterStatus); - if (filterOrganizationId) params.set('organizationId', String(filterOrganizationId)); + if (effectiveFilterOrganizationId) + params.set('organizationId', String(effectiveFilterOrganizationId)); if (showArchived) params.set('includeArchived', 'true'); if (filterClassId) params.set('classId', String(filterClassId)); if (filterTeacherId) params.set('teacherId', String(filterTeacherId)); const query = params.toString() ? `?${params.toString()}` : ''; - fetch(`${baseURL}/students/export${query}`, { headers: { Authorization: `Bearer ${token}` } }) - .then((res) => res.blob()) - .then((blob) => { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = '学生名单.xlsx'; - a.click(); - URL.revokeObjectURL(url); - }) - .catch(() => message.error('导出失败')); + void downloadApiFile(`/students/export${query}`, '学生名单.xlsx', '导出失败'); }; const columns = useMemo( - () => [ - { - title: '序号', - key: 'index', - width: 70, - render: (_: unknown, __: unknown, index: number) => - (pageInfo.current - 1) * pageInfo.pageSize + index + 1, - }, - { - title: '姓名', - dataIndex: 'name', - width: 120, - render: (v: string, record: any) => ( - saveCell(record, 'name', next)} - > - {v} - - ), - }, - { - title: '电话', - dataIndex: 'phone', - width: 140, - render: (v: string, record: any) => { - if (!v) return '-'; - return ( - - {maskPhone(v)} - {hasPermission('log:create') ? ( - - ) : null} - - ); + () => + buildStudentColumns({ + pageInfo, + organizations, + canChooseOrganization, + canEditStudent, + canDeleteStudent, + canPurgeStudent, + canViewSensitive: hasPermission('log:create'), + onSaveCell: saveCell, + onViewSensitive: handleViewSensitive, + onOpenDrawer: openDrawer, + onEdit: (record) => { + setEditing(record); + form.setFieldsValue(record); + setModalOpen(true); }, - }, - { - title: '学号', - dataIndex: 'studentNo', - width: 120, - render: (v: string, record: any) => ( - saveCell(record, 'studentNo', next)} - > - {v || '-'} - - ), - }, - { - title: '身份证', - dataIndex: 'idNumber', - width: 180, - render: (v: string, record: any) => { - if (!v) return '-'; - return ( - - {maskIdNumber(v)} - {hasPermission('log:create') ? ( - - ) : null} - - ); - }, - }, - { - title: '民族', - dataIndex: 'ethnicity', - width: 90, - render: (v: string, record: any) => ( - saveCell(record, 'ethnicity', next)} - > - {v || '-'} - - ), - }, - { - title: '紧急联系人', - dataIndex: 'emergencyContact', - width: 100, - render: (v: string, record: any) => ( - saveCell(record, 'emergencyContact', next)} - > - {v || '-'} - - ), - }, - { - title: '紧急联系人电话', - dataIndex: 'emergencyPhone', - width: 150, - render: (v: string, record: any) => { - if (!v) return '-'; - return ( - - {maskPhone(v)} - {hasPermission('log:create') ? ( - - ) : null} - - ); - }, - }, - { - title: '所属机构', - dataIndex: 'organization', - width: 100, - render: (organization: { name?: string } | null, record: any) => - canChooseOrganization ? ( - ({ value: item.id, label: item.name }))} - permission="student:edit" - disabled={record.status === 'archived'} - required - onSave={(next) => saveCell(record, 'organizationId', next)} - > - {organization?.name ? ( - - {organization.name} - - ) : ( - '-' - )} - - ) : organization?.name ? ( - {organization.name} - ) : ( - '-' - ), - }, - { - title: '负责人', - dataIndex: 'supervisor', - width: 100, - render: (v: string, record: any) => ( - saveCell(record, 'supervisor', next)} - > - {v || '-'} - - ), - }, - { - title: '状态', - dataIndex: 'status', - width: 80, - render: (s: string, record: any) => ( - saveCell(record, 'status', next)} - > - - {statusMap[s]?.text || s} - - - ), - }, - { - title: '操作', - width: 180, - render: (_: any, record: any) => ( - - {record.status === 'archived' ? ( - canEditStudent ? ( - handleRestore(record.id)} - okText="恢复" - cancelText="取消" - > - - - ) : null - ) : ( - <> - openDrawer(record.id)} - > - 档案 - - { - setEditing(record); - form.setFieldsValue(record); - setModalOpen(true); - }} - > - 编辑 - - {canDeleteStudent ? ( - handleArchive(record.id)} - okText="归档" - cancelText="取消" - > - - - ) : null} - - )} - - ), - }, - ], + onRestore: handleRestore, + onPurge: handlePurge, + onArchive: handleArchive, + }), [ - handleViewSensitive, - openDrawer, - showArchived, - organizations, - saveCell, - hasPermission, - canChooseOrganization, pageInfo, + organizations, + canChooseOrganization, + canEditStudent, + canDeleteStudent, + canPurgeStudent, + hasPermission, + saveCell, + handleViewSensitive, + form, ], ); return (
-
- - - - {canViewOrganizations ? ( - - ) : null} - { - setFilterTeacherId(v); - }} - options={teacherOptions.map((item) => ({ - value: item.id, - label: item.name === item.username ? item.name : `${item.name}(${item.username})`, - }))} - /> - - - - {showArchived && canEditStudent ? ( - - - - ) : !showArchived && canDeleteStudent ? ( - - - - ) : null} - {!showArchived ? ( - } - onClick={() => { - setEditing(null); - form.resetFields(); - const host = organizations.find((organization) => organization.isHost); - if (host) form.setFieldValue('organizationId', host.id); - setModalOpen(true); - }} - > - 添加学生 - - ) : null} - {!showArchived && hasPermission('student:import') ? ( - <> - - - - - - - - ) : null} - {!showArchived && canSyncJinshuju ? ( - - ) : null} - {!showArchived && canSyncDingTalk ? ( - - ) : null} - } - onClick={handleDownloadTemplate} - > - 下载模板 - - } - onClick={handleExport} - > - 导出名单 - - -
- {selectedRowKeys.length > 0 ? ( - - 已选 {selectedRowKeys.length} 人(支持跨页勾选) - - } - action={ - - } - /> - ) : null} - - 更新已有学生资料:先按手机号、再按身份证号匹配;Excel - 中填写的非空字段会覆盖原资料,未匹配的学生不会新增。请确认姓名、手机号、身份证号、所属机构和联系人等内容无误。 - - } + { + setShowArchived(!showArchived); + setFilterStatus(undefined); + setSelectedRowKeys([]); + }} + selectedRowKeys={selectedRowKeys} + batchLoading={batchLoading} + canEditStudent={canEditStudent} + canPurgeStudent={canPurgeStudent} + canDeleteStudent={canDeleteStudent} + canSyncJinshuju={canSyncJinshuju} + canSyncDingTalk={canSyncDingTalk} + dingSyncLoading={dingSyncLoading} + onBatchRestore={handleBatchRestore} + onBatchPurge={handleBatchPurge} + onBatchDelete={handleBatchDelete} + onAddStudent={() => { + setEditing(null); + form.resetFields(); + const host = organizations.find((organization) => organization.isHost); + if (host) form.setFieldValue('organizationId', host.id); + setModalOpen(true); + }} + onOpenJinshuju={() => setJinshujuOpen(true)} + onDingTalkSync={handleDingTalkSync} + onCreateImport={handleCreateStudentsImport} + onUpdateImport={handleUpdateExistingStudentsImport} + onDownloadTemplate={handleDownloadTemplate} + onExport={handleExport} /> - }} - scroll={{ x: 1410 }} - pagination={{ - defaultPageSize: 15, - current: pageInfo.current, - pageSize: pageInfo.pageSize, - showSizeChanger: true, - pageSizeOptions: [15, 30, 50, 100], - showTotal: (total) => `共 ${total} 人`, - onChange: (current, pageSize) => setPageInfo({ current, pageSize }), - }} - rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')} - rowSelection={{ - selectedRowKeys, - onChange: (keys) => setSelectedRowKeys(keys as number[]), - }} - expandable={{ - rowExpandable: () => true, - expandedRowRender: (record) => { - const enrollments = enrollmentData[record.id]; - if (!enrollments) return null; - if (enrollments.length < 2) { - return ( -
- 当前仅 {enrollments.length} 个班型,无可对比数据 -
- ); - } - return ( - - - {enrollments.map((enr, idx) => ( - - - - {enr.className || '-'} - - {enr.startDate || enr.joinDate || '-'} - - - {enr.endDate || enr.leaveDate || '-'} - - - - {enr.status || '-'} - - - - - - ))} - - - ); - }, - onExpand: async (expanded, record) => { - if (expanded && !enrollmentData[record.id]) { - try { - const res = await api.get<{ enrollments: EnrollmentInfo[] }>( - `/students/${record.id}/compare-classes`, - ); - setEnrollmentData((prev) => ({ ...prev, [record.id]: res.enrollments })); - } catch { - setEnrollmentData((prev) => ({ ...prev, [record.id]: [] })); - } - } - }, - }} + pageInfo={pageInfo} + onPageChange={(current, pageSize) => setPageInfo({ current, pageSize })} + selectedRowKeys={selectedRowKeys} + onSelect={setSelectedRowKeys} + onClearSelection={() => setSelectedRowKeys([])} /> - - { setModalOpen(false); setEditing(null); }} - okText="保存" - confirmLoading={saving} - > - - - - - - - - - - - - - - - - - - - - - - - {canChooseOrganization ? ( - - - - {editing && ( - - ({ label: type, value: type }))} /> 仅看欠费 - + { ]} /> + { > + @@ -331,6 +371,7 @@ const WalletsPage: React.FC = () => { ]} /> + { > + @@ -346,7 +388,7 @@ const WalletsPage: React.FC = () => { { setDrawerOpen(false); @@ -372,15 +414,15 @@ const WalletsPage: React.FC = () => { title: '金额', dataIndex: 'amount', render: (value: number) => ( - = 0 ? '#389e0d' : '#cf1322' }}> - {Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)} + = 0 ? '#389e0d' : '#cf1322' }}> + {value >= 0 ? '+' : ''}¥{value.toFixed(2)} ), }, { title: '变动后余额', dataIndex: 'balanceAfter', - render: (value: number) => `¥${Number(value).toFixed(2)}`, + render: (value: number) => `¥${value.toFixed(2)}`, }, { title: '关联账单', diff --git a/apps/server/src/bills/bills-export.service.ts b/apps/server/src/bills/bills-export.service.ts index ba01a07..9d1690a 100644 --- a/apps/server/src/bills/bills-export.service.ts +++ b/apps/server/src/bills/bills-export.service.ts @@ -65,7 +65,7 @@ export class BillsExportService { const total = Number(bill.totalAmount || 0); ws.addRow({ id: bill.id, - studentName: (bill as any).student?.name || '-', + studentName: bill.student?.name || '-', period: `${bill.periodStart} ~ ${bill.periodEnd}`, shared: Number(bill.sharedAmount), personal: Number(bill.personalAmount), @@ -96,7 +96,7 @@ export class BillsExportService { for (const item of bill.items || []) { ws2.addRow({ billId: bill.id, - studentName: (bill as any).student?.name || '-', + studentName: bill.student?.name || '-', expenseType: item.expenseType, description: item.description, days: item.days, @@ -158,7 +158,9 @@ export class BillsExportService { fontRegistered = true; break; } - } catch {} + } catch { + // 字体注册失败时回退到默认字体 + } } if (!fontRegistered) { // 如果没有中文字体,使用 Helvetica(中文可能乱码) @@ -183,7 +185,7 @@ export class BillsExportService { // 基本信息 doc.fontSize(12).fillColor('#000'); - doc.text(`学生姓名: ${(bill as any).student?.name || '-'}`); + doc.text(`学生姓名: ${bill.student?.name || '-'}`); doc.text(`计费周期: ${bill.periodStart} ~ ${bill.periodEnd}`); doc.text(`账单状态: ${statusMap[bill.status] || bill.status}`); doc.moveDown(0.5); diff --git a/apps/server/src/bills/bills-generation.service.ts b/apps/server/src/bills/bills-generation.service.ts new file mode 100644 index 0000000..cfcf2cc --- /dev/null +++ b/apps/server/src/bills/bills-generation.service.ts @@ -0,0 +1,285 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy, Room } from '../entities'; +import { WalletsService } from '../wallets/wallets.service'; +import type { GenerateBillsDto } from './dto/bill.dto'; + +@Injectable() +export class BillsGenerationService { + constructor( + @InjectRepository(Bill) private billRepo: Repository, + @InjectRepository(BillItem) private itemRepo: Repository, + @InjectRepository(RoomExpense) private roomExpRepo: Repository, + @InjectRepository(PersonalExpense) private personalExpRepo: Repository, + @InjectRepository(Occupancy) private occRepo: Repository, + @InjectRepository(Room) private roomRepo: Repository, + private dataSource: DataSource, + private walletsService: WalletsService, + ) {} + + async generateBillsOnce(dto: GenerateBillsDto) { + const { periodStart, periodEnd } = dto.billingMonth + ? this.resolveBillingPeriod(dto.billingMonth) + : { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! }; + if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) { + throw new BadRequestException('账单周期无效,结束日期不能早于开始日期'); + } + const pStart = new Date(`${periodStart}T00:00:00Z`); + const pEnd = new Date(`${periodEnd}T00:00:00Z`); + const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } }); + if (existingBills.length > 0) { + throw new BadRequestException( + `${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`, + ); + } + const roomExpenses = await this.roomExpRepo + .createQueryBuilder('e') + .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { + periodStart, + periodEnd, + }) + .andWhere('e.status = :status', { status: 'active' }) + .getMany(); + const longTermOccupancies: Occupancy[] = []; + const roomExpMap = new Map(); + for (const expense of roomExpenses) { + const expenses = roomExpMap.get(expense.roomId) || []; + expenses.push(expense); + roomExpMap.set(expense.roomId, expenses); + } + const roomIds = new Set([ + ...roomExpMap.keys(), + ...longTermOccupancies + .filter((occupancy) => occupancy.stayType === 'long') + .map((occupancy) => occupancy.roomId), + ]); + const studentBillData = new Map< + number, + { shared: number; items: Array> } + >(); + + for (const roomId of roomIds) { + const expenses = roomExpMap.get(roomId) || []; + const occupancies = await this.occRepo + .createQueryBuilder('o') + .leftJoinAndSelect('o.student', 'student') + .leftJoinAndSelect('o.room', 'room') + .where('o.roomId = :roomId', { roomId }) + .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) + .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) + .getMany(); + const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long'); + const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long'); + + for (const occupancy of longTermOccs) { + const rent = this.calculateLongTermRent( + occupancy, + periodStart, + periodEnd, + Number(occupancy.room?.monthlyRate || 0), + ); + if (rent <= 0) continue; + const data = studentBillData.get(occupancy.studentId) || { shared: 0, items: [] }; + data.shared += rent; + data.items.push({ + roomId, + expenseType: 'rent', + description: `长租月租费 (${occupancy.room?.roomNumber || '未知房间'})`, + days: 0, + totalRoomDays: 0, + roomTotalAmount: rent, + studentAmount: rent, + }); + studentBillData.set(occupancy.studentId, data); + } + + const studentDays = shortTermOccs.map((occupancy) => { + const start = new Date( + Math.max(new Date(occupancy.billingStartDate).getTime(), pStart.getTime()), + ); + const end = occupancy.billingEndDate + ? new Date(Math.min(new Date(occupancy.billingEndDate).getTime(), pEnd.getTime())) + : pEnd; + const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1); + return { studentId: occupancy.studentId, days }; + }); + const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0); + if (totalDays === 0) continue; + + for (const expense of expenses) { + const eligibleDays = studentDays.filter((entry) => entry.days > 0); + const expenseTotal = Number(Number(expense.amount).toFixed(2)); + let allocated = 0; + for (const [index, entry] of eligibleDays.entries()) { + const amount = + index === eligibleDays.length - 1 + ? Number((expenseTotal - allocated).toFixed(2)) + : Number(((entry.days / totalDays) * expenseTotal).toFixed(2)); + allocated = Number((allocated + amount).toFixed(2)); + const data = studentBillData.get(entry.studentId) || { shared: 0, items: [] }; + data.shared += amount; + data.items.push({ + roomExpenseId: expense.id, + roomId, + expenseType: expense.expenseType, + description: `${expense.expenseType} 分摊`, + days: entry.days, + totalRoomDays: totalDays, + roomTotalAmount: expense.amount, + studentAmount: amount, + }); + studentBillData.set(entry.studentId, data); + } + } + } + + const personalExps = await this.personalExpRepo + .createQueryBuilder('pe') + .where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', { + periodStart, + periodEnd, + }) + .andWhere('pe.status = :status', { status: 'active' }) + .andWhere('pe.billId IS NULL') + .getMany(); + const personalMap = new Map(); + const personalItems = new Map>>(); + for (const expense of personalExps) { + personalMap.set( + expense.studentId, + (personalMap.get(expense.studentId) || 0) + Number(expense.amount), + ); + const items = personalItems.get(expense.studentId) || []; + items.push({ + personalExpenseId: expense.id, + roomId: expense.roomId, + expenseType: expense.expenseType, + description: `个人费用: ${expense.description || expense.expenseType}`, + days: 0, + totalRoomDays: 0, + roomTotalAmount: expense.amount, + studentAmount: expense.amount, + }); + personalItems.set(expense.studentId, items); + } + + const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]); + const bills = await this.dataSource.transaction(async (manager) => { + const generated: Bill[] = []; + for (const studentId of allStudentIds) { + const shared = studentBillData.get(studentId)?.shared || 0; + const personal = personalMap.get(studentId) || 0; + const total = Number((shared + personal).toFixed(2)); + let bill = await manager.save( + manager.create(Bill, { + studentId, + periodStart, + periodEnd, + sharedAmount: Number(shared.toFixed(2)), + personalAmount: personal, + totalAmount: total, + source: 'batch', + paidAmount: 0, + outstandingAmount: total, + status: 'unpaid', + }), + ); + const items = [ + ...(studentBillData.get(studentId)?.items || []), + ...(personalItems.get(studentId) || []), + ]; + for (const item of items) + await manager.save(manager.create(BillItem, { ...item, billId: bill.id })); + const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId); + if (includedPersonal.length) { + await manager + .createQueryBuilder() + .update(PersonalExpense) + .set({ billId: bill.id }) + .where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) }) + .execute(); + } + bill = await this.walletsService.debitBill(manager, bill); + generated.push(bill); + } + return generated; + }); + return { + message: `成功生成 ${bills.length} 条账单`, + count: bills.length, + bills, + periodStart, + periodEnd, + }; + } + + + private calculateLongTermRent( + occupancy: Occupancy, + periodStart: string, + periodEnd: string, + monthlyRate: number, + ) { + const activeStart = + occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart; + const activeEnd = + occupancy.billingEndDate && occupancy.billingEndDate < periodEnd + ? occupancy.billingEndDate + : periodEnd; + if (activeEnd < activeStart || monthlyRate <= 0) return 0; + const [startYear, startMonth] = activeStart.split('-').map(Number); + const [endYear, endMonth] = activeEnd.split('-').map(Number); + let total = 0; + for ( + let year = startYear, month = startMonth; + year < endYear || (year === endYear && month <= endMonth); + ) { + const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate(); + const prefix = `${year}-${String(month).padStart(2, '0')}-`; + const overlapStart = activeStart > `${prefix}01` ? activeStart : `${prefix}01`; + const monthEnd = `${prefix}${String(daysInMonth).padStart(2, '0')}`; + const overlapEnd = activeEnd < monthEnd ? activeEnd : monthEnd; + const days = + Math.floor( + (Date.parse(`${overlapEnd}T00:00:00Z`) - Date.parse(`${overlapStart}T00:00:00Z`)) / + 86_400_000, + ) + 1; + total += (monthlyRate * days) / daysInMonth; + if (++month > 12) { + month = 1; + year++; + } + } + return Number(total.toFixed(2)); + } + + + private isValidDate(value: string) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; + const date = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; + } + + + private resolveBillingPeriod(billingMonth: string) { + const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || ''); + if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM'); + const year = Number(matched[1]); + const month = Number(matched[2]); + if (month < 1 || month > 12) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM'); + const targetMonthStart = new Date(year, month - 1, 1); + const currentMonthStart = new Date(); + currentMonthStart.setDate(1); + currentMonthStart.setHours(0, 0, 0, 0); + if (targetMonthStart >= currentMonthStart) + throw new BadRequestException('只能生成已结束月份的账单'); + const targetMonthEnd = new Date(year, month, 0); + const pad = (value: number) => String(value).padStart(2, '0'); + return { + periodStart: `${year}-${pad(month)}-01`, + periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}`, + }; + } + +} diff --git a/apps/server/src/bills/bills.controller.ts b/apps/server/src/bills/bills.controller.ts index a9f4122..43085e1 100644 --- a/apps/server/src/bills/bills.controller.ts +++ b/apps/server/src/bills/bills.controller.ts @@ -24,7 +24,7 @@ import { BillsExportService } from './bills-export.service'; import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import type { Response } from 'express'; @@ -43,16 +43,9 @@ export class BillsController { @Post('generate') @RequirePermission('bill:generate') async generateBills(@Body() dto: GenerateBillsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.generateBills(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账单管理', - action: '生成账单', - detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count} 条`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '生成账单', detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count} 条`, }); // Send bill_generated notifications try { @@ -100,17 +93,9 @@ export class BillsController { @Body() dto: UpdateBillStatusDto, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.updateStatus(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账单管理', - action: '确认账单', - targetId: id, - targetType: 'bill', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '确认账单', targetId: id, targetType: 'bill', }); // Send bill_paid notification try { @@ -130,16 +115,9 @@ export class BillsController { @Put('batch/status') @RequirePermission('bill:confirm') async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchUpdateStatus(body.ids, body.status); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账单管理', - action: '确认账单', - detail: `IDs: ${body.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '确认账单', detail: `IDs: ${body.ids.join(',')}`, }); // Send bill_paid notifications (batch) try { @@ -163,17 +141,8 @@ export class BillsController { @RequirePermission('bill:delete') 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 { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账单管理', - action: '取消账单并冲正', - targetId: id, - targetType: 'bill', - detail: dto.reason, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', detail: dto.reason, }); return result; } @@ -181,17 +150,29 @@ export class BillsController { @Delete(':id') @RequirePermission('bill:delete') async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账单管理', - action: '归档账单', - targetId: id, - targetType: 'bill', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('bill:purge') + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + const result = await this.service.purge(id); + await logAudit(this.logService, req, { + module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('batch-permanent-delete') + @RequirePermission('bill:purge') + async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + const result = await this.service.batchPurge(body.ids || []); + await logAudit(this.logService, req, { + module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`, }); return result; } @@ -199,16 +180,9 @@ export class BillsController { @Post('batch/delete') @RequirePermission('bill:delete') async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRemove(body.ids); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账单管理', - action: '批量归档账单', - detail: `IDs: ${body.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '批量归档账单', detail: `IDs: ${body.ids.join(',')}`, }); return result; } @@ -223,15 +197,8 @@ export class BillsController { @Res() res?: Response, @Req() req?: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req?.user?.id, - username: req?.user?.username, - module: '账单管理', - action: '导出账单', - detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '导出账单', detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`, }); return this.exportService.exportExcel( { @@ -247,16 +214,8 @@ export class BillsController { @Get('export/pdf/:id') @RequirePermission('bill:export-pdf') async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req?.user?.id, - username: req?.user?.username, - module: '账单管理', - action: '导出账单', - targetId: id, - targetType: 'bill', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账单管理', action: '导出账单', targetId: id, targetType: 'bill', }); return this.exportService.exportStudentPdf(id, res); } diff --git a/apps/server/src/bills/bills.module.ts b/apps/server/src/bills/bills.module.ts index 0631108..c58469e 100644 --- a/apps/server/src/bills/bills.module.ts +++ b/apps/server/src/bills/bills.module.ts @@ -11,6 +11,7 @@ import { Room } from '../entities/room.entity'; import { Student } from '../entities/student.entity'; import { Deposit } from '../entities/deposit.entity'; import { BillsService } from './bills.service'; +import { BillsGenerationService } from './bills-generation.service'; import { BillsExportService } from './bills-export.service'; import { BillsController } from './bills.controller'; @@ -30,7 +31,7 @@ import { BillsController } from './bills.controller'; WalletsModule, ], controllers: [BillsController], - providers: [BillsService, BillsExportService], + providers: [BillsService, BillsExportService, BillsGenerationService], exports: [BillsService], }) export class BillsModule {} diff --git a/apps/server/src/bills/bills.purge.controller.spec.ts b/apps/server/src/bills/bills.purge.controller.spec.ts new file mode 100644 index 0000000..40fcea3 --- /dev/null +++ b/apps/server/src/bills/bills.purge.controller.spec.ts @@ -0,0 +1,33 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { BillsController } from './bills.controller'; + +describe('BillsController purge routes', () => { + it('requires bill:purge on permanent delete routes', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, BillsController.prototype.purge)).toEqual([ + 'bill:purge', + ]); + expect(Reflect.getMetadata(PERMISSION_KEY, BillsController.prototype.batchPurge)).toEqual([ + 'bill:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除账单(不可恢复)' }) }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new BillsController( + service as never, + {} as never, + { log } as never, + {} as never, + {} as never, + {} as never, + ); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge(1, req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '账单管理', action: '永久删除账单', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/bills/bills.purge.spec.ts b/apps/server/src/bills/bills.purge.spec.ts new file mode 100644 index 0000000..e2e39f9 --- /dev/null +++ b/apps/server/src/bills/bills.purge.spec.ts @@ -0,0 +1,71 @@ +import { BadRequestException } from '@nestjs/common'; +import { BillsService } from './bills.service'; + +describe('BillsService.purge', () => { + const createService = (overrides?: { bill?: Record }) => { + const bill = { + id: 1, + studentId: 2, + status: 'cancelled', + paidAmount: 0, + ...overrides?.bill, + }; + const billRepo = { + findOne: jest.fn().mockResolvedValue(bill), + find: jest.fn().mockResolvedValue([bill]), + }; + const personalExpRepo = { count: jest.fn().mockResolvedValue(0) }; + const manager = { + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb(manager)), + }; + const service = new BillsService( + billRepo as never, + {} as never, + {} as never, + personalExpRepo as never, + {} as never, + {} as never, + dataSource as never, + {} as never, + ); + return { service, billRepo, personalExpRepo, dataSource, manager }; + }; + + it('rejects bills that are not cancelled', async () => { + const { service, dataSource } = createService({ bill: { status: 'unpaid' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已取消账单可以永久删除,请先取消账单'), + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('rejects cancelled bills with paid amount', async () => { + const { service, dataSource } = createService({ bill: { paidAmount: 100 } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('已发生资金流水的账单不能永久删除'), + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('rejects cancelled bills still referenced by personal expenses', async () => { + const { service, personalExpRepo, dataSource } = createService(); + personalExpRepo.count.mockResolvedValue(1); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('该账单仍关联个人费用,无法永久删除'), + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('deletes bill items and bill in a transaction', async () => { + const { service, dataSource, manager } = createService(); + await expect(service.purge(1)).resolves.toEqual({ + message: '已永久删除账单(不可恢复)', + }); + expect(dataSource.transaction).toHaveBeenCalled(); + expect(manager.delete).toHaveBeenNthCalledWith(1, expect.anything(), { billId: 1 }); + expect(manager.delete).toHaveBeenNthCalledWith(2, expect.anything(), 1); + }); +}); diff --git a/apps/server/src/bills/bills.service.spec.ts b/apps/server/src/bills/bills.service.spec.ts index 3c38bb7..435c2ab 100644 --- a/apps/server/src/bills/bills.service.spec.ts +++ b/apps/server/src/bills/bills.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { BillsService } from './bills.service'; +import { BillsGenerationService } from './bills-generation.service'; import { Bill } from '../entities/bill.entity'; import { BillItem } from '../entities/bill-item.entity'; import { RoomExpense } from '../entities/room-expense.entity'; @@ -78,6 +79,7 @@ describe('BillsService — generateBills', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ BillsService, + BillsGenerationService, { provide: getRepositoryToken(Bill), useValue: billRepo }, { provide: getRepositoryToken(BillItem), useValue: itemRepo }, { provide: getRepositoryToken(RoomExpense), useValue: roomExpRepo }, @@ -567,6 +569,17 @@ describe('BillsService — allocation rounding boundary', () => { })), })), }; + const walletsService = { debitBill: jest.fn(async (_manager, bill) => bill) } as any; + const generation = new BillsGenerationService( + billRepo as any, + itemRepo as any, + roomExpRepo as any, + personalExpRepo as any, + occRepo as any, + roomRepo as any, + dataSource as any, + walletsService, + ); const service = new BillsService( billRepo as any, itemRepo as any, @@ -575,7 +588,8 @@ describe('BillsService — allocation rounding boundary', () => { occRepo as any, roomRepo as any, dataSource as any, - { debitBill: jest.fn(async (_manager, bill) => bill) } as any, + walletsService, + generation, ); (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder([ { id: 1, roomId: 1, expenseType: 'water', amount: 100, periodStart: '2026-06-01', periodEnd: '2026-06-30' } as RoomExpense, diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts index 35754a8..57209aa 100644 --- a/apps/server/src/bills/bills.service.ts +++ b/apps/server/src/bills/bills.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In, DataSource, EntityManager } from 'typeorm'; +import { Repository, In, DataSource } from 'typeorm'; import { Bill } from '../entities/bill.entity'; import { BillItem } from '../entities/bill-item.entity'; import { RoomExpense } from '../entities/room-expense.entity'; @@ -11,6 +11,7 @@ import { StudentWallet } from '../entities/student-wallet.entity'; import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; import { WalletsService } from '../wallets/wallets.service'; import { FinancialOperationsService } from '../financial-operations/financial-operations.service'; +import { BillsGenerationService } from './bills-generation.service'; interface AgentBillRow { billId: string | number; @@ -23,7 +24,6 @@ interface AgentBillRow { status: string; } - @Injectable() export class BillsService { constructor( @@ -35,6 +35,7 @@ export class BillsService { @InjectRepository(Room) private roomRepo: Repository, private dataSource: DataSource, private walletsService: WalletsService, + private generation: BillsGenerationService, @Optional() private financialOperations?: FinancialOperationsService, ) {} @@ -44,220 +45,12 @@ export class BillsService { */ async generateBills(dto: GenerateBillsDto) { const { operationId, ...request } = dto; - const work = () => this.generateBillsOnce(request as GenerateBillsDto); + const work = () => this.generation.generateBillsOnce(request as GenerateBillsDto); return this.financialOperations ? this.financialOperations.run(operationId, 'bill.generate', work) : work(); } - private async generateBillsOnce(dto: GenerateBillsDto) { - const { periodStart, periodEnd } = dto.billingMonth - ? this.resolveBillingPeriod(dto.billingMonth) - : { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! }; - if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) { - throw new BadRequestException('账单周期无效,结束日期不能早于开始日期'); - } - const pStart = new Date(`${periodStart}T00:00:00Z`); - const pEnd = new Date(`${periodEnd}T00:00:00Z`); - const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } }); - if (existingBills.length > 0) { - throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`); - } - const roomExpenses = await this.roomExpRepo - .createQueryBuilder('e') - .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { periodStart, periodEnd }) - .andWhere('e.status = :status', { status: 'active' }) - .getMany(); - const longTermOccupancies: Occupancy[] = []; - const roomExpMap = new Map(); - for (const expense of roomExpenses) { - const expenses = roomExpMap.get(expense.roomId) || []; - expenses.push(expense); - roomExpMap.set(expense.roomId, expenses); - } - const roomIds = new Set([ - ...roomExpMap.keys(), - ...longTermOccupancies.filter((occupancy) => occupancy.stayType === 'long').map((occupancy) => occupancy.roomId), - ]); - const studentBillData = new Map> }>(); - - for (const roomId of roomIds) { - const expenses = roomExpMap.get(roomId) || []; - const occupancies = await this.occRepo - .createQueryBuilder('o') - .leftJoinAndSelect('o.student', 'student') - .leftJoinAndSelect('o.room', 'room') - .where('o.roomId = :roomId', { roomId }) - .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) - .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) - .getMany(); - const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long'); - const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long'); - - for (const occupancy of longTermOccs) { - const rent = this.calculateLongTermRent( - occupancy, - periodStart, - periodEnd, - Number(occupancy.room?.monthlyRate || 0), - ); - if (rent <= 0) continue; - const data = studentBillData.get(occupancy.studentId) || { shared: 0, items: [] }; - data.shared += rent; - data.items.push({ - roomId, - expenseType: 'rent', - description: `长租月租费 (${occupancy.room?.roomNumber || '未知房间'})`, - days: 0, - totalRoomDays: 0, - roomTotalAmount: rent, - studentAmount: rent, - }); - studentBillData.set(occupancy.studentId, data); - } - - const studentDays = shortTermOccs.map((occupancy) => { - const start = new Date(Math.max(new Date(occupancy.billingStartDate).getTime(), pStart.getTime())); - const end = occupancy.billingEndDate - ? new Date(Math.min(new Date(occupancy.billingEndDate).getTime(), pEnd.getTime())) - : pEnd; - const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1); - return { studentId: occupancy.studentId, days }; - }); - const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0); - if (totalDays === 0) continue; - - for (const expense of expenses) { - const eligibleDays = studentDays.filter((entry) => entry.days > 0); - const expenseTotal = Number(Number(expense.amount).toFixed(2)); - let allocated = 0; - for (const [index, entry] of eligibleDays.entries()) { - const amount = index === eligibleDays.length - 1 - ? Number((expenseTotal - allocated).toFixed(2)) - : Number(((entry.days / totalDays) * expenseTotal).toFixed(2)); - allocated = Number((allocated + amount).toFixed(2)); - const data = studentBillData.get(entry.studentId) || { shared: 0, items: [] }; - data.shared += amount; - data.items.push({ - roomExpenseId: expense.id, - roomId, - expenseType: expense.expenseType, - description: `${expense.expenseType} 分摊`, - days: entry.days, - totalRoomDays: totalDays, - roomTotalAmount: expense.amount, - studentAmount: amount, - }); - studentBillData.set(entry.studentId, data); - } - } - } - - const personalExps = await this.personalExpRepo - .createQueryBuilder('pe') - .where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', { periodStart, periodEnd }) - .andWhere('pe.status = :status', { status: 'active' }) - .andWhere('pe.billId IS NULL') - .getMany(); - const personalMap = new Map(); - const personalItems = new Map>>(); - for (const expense of personalExps) { - personalMap.set(expense.studentId, (personalMap.get(expense.studentId) || 0) + Number(expense.amount)); - const items = personalItems.get(expense.studentId) || []; - items.push({ - personalExpenseId: expense.id, - roomId: expense.roomId, - expenseType: expense.expenseType, - description: `个人费用: ${expense.description || expense.expenseType}`, - days: 0, - totalRoomDays: 0, - roomTotalAmount: expense.amount, - studentAmount: expense.amount, - }); - personalItems.set(expense.studentId, items); - } - - const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]); - const bills = await this.dataSource.transaction(async (manager) => { - const generated: Bill[] = []; - for (const studentId of allStudentIds) { - const shared = studentBillData.get(studentId)?.shared || 0; - const personal = personalMap.get(studentId) || 0; - const total = Number((shared + personal).toFixed(2)); - let bill = await manager.save(manager.create(Bill, { - studentId, - periodStart, - periodEnd, - sharedAmount: Number(shared.toFixed(2)), - personalAmount: personal, - totalAmount: total, - source: 'batch', - paidAmount: 0, - outstandingAmount: total, - status: 'unpaid', - })); - const items = [...(studentBillData.get(studentId)?.items || []), ...(personalItems.get(studentId) || [])]; - for (const item of items) await manager.save(manager.create(BillItem, { ...item, billId: bill.id })); - const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId); - if (includedPersonal.length) { - await manager.createQueryBuilder() - .update(PersonalExpense) - .set({ billId: bill.id }) - .where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) }) - .execute(); - } - bill = await this.walletsService.debitBill(manager, bill); - generated.push(bill); - } - return generated; - }); - return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd }; - } - - private calculateLongTermRent(occupancy: Occupancy, periodStart: string, periodEnd: string, monthlyRate: number) { - const activeStart = occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart; - const activeEnd = occupancy.billingEndDate && occupancy.billingEndDate < periodEnd - ? occupancy.billingEndDate - : periodEnd; - if (activeEnd < activeStart || monthlyRate <= 0) return 0; - const [startYear, startMonth] = activeStart.split('-').map(Number); - const [endYear, endMonth] = activeEnd.split('-').map(Number); - let total = 0; - for (let year = startYear, month = startMonth; year < endYear || (year === endYear && month <= endMonth);) { - const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate(); - const prefix = `${year}-${String(month).padStart(2, '0')}-`; - const overlapStart = activeStart > `${prefix}01` ? activeStart : `${prefix}01`; - const monthEnd = `${prefix}${String(daysInMonth).padStart(2, '0')}`; - const overlapEnd = activeEnd < monthEnd ? activeEnd : monthEnd; - const days = Math.floor((Date.parse(`${overlapEnd}T00:00:00Z`) - Date.parse(`${overlapStart}T00:00:00Z`)) / 86_400_000) + 1; - total += monthlyRate * days / daysInMonth; - if (++month > 12) { month = 1; year++; } - } - return Number(total.toFixed(2)); - } - - private isValidDate(value: string) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; - const date = new Date(`${value}T00:00:00Z`); - return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; - } - - private resolveBillingPeriod(billingMonth: string) { - const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || ''); - if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM'); - const year = Number(matched[1]); - const month = Number(matched[2]); - if (month < 1 || month > 12) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM'); - const targetMonthStart = new Date(year, month - 1, 1); - const currentMonthStart = new Date(); - currentMonthStart.setDate(1); - currentMonthStart.setHours(0, 0, 0, 0); - if (targetMonthStart >= currentMonthStart) throw new BadRequestException('只能生成已结束月份的账单'); - const targetMonthEnd = new Date(year, month, 0); - const pad = (value: number) => String(value).padStart(2, '0'); - return { periodStart: `${year}-${pad(month)}-01`, periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}` }; - } - async createImmediatePersonalBill( expense: PersonalExpense, periodStart: string, @@ -286,7 +79,8 @@ export class BillsService { personalExpenseId: expense.id, roomId: expense.roomId, expenseType: expense.expenseType, - description: expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'), + description: + expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'), days: 0, totalRoomDays: 0, roomTotalAmount: expense.amount, @@ -323,19 +117,28 @@ export class BillsService { } async agentSearchBills(query: { - keyword?: string; periodStart?: string; periodEnd?: string; status?: string; limit?: number; + keyword?: string; + periodStart?: string; + periodEnd?: string; + status?: string; + limit?: number; }) { + const billSelects = [ + ['student.name', 'studentName'], + ['bill.periodStart', 'periodStart'], + ['bill.periodEnd', 'periodEnd'], + ['bill.totalAmount', 'totalAmount'], + ['bill.paidAmount', 'paidAmount'], + ['bill.outstandingAmount', 'outstandingAmount'], + ['bill.status', 'status'], + ] as const; const qb = this.billRepo .createQueryBuilder('bill') .leftJoin('bill.student', 'student') - .select('bill.id', 'billId') - .addSelect('student.name', 'studentName') - .addSelect('bill.periodStart', 'periodStart') - .addSelect('bill.periodEnd', 'periodEnd') - .addSelect('bill.totalAmount', 'totalAmount') - .addSelect('bill.paidAmount', 'paidAmount') - .addSelect('bill.outstandingAmount', 'outstandingAmount') - .addSelect('bill.status', 'status'); + .select('bill.id', 'billId'); + for (const [column, alias] of billSelects) { + qb.addSelect(column, alias); + } if (query.keyword) { const billId = Number(query.keyword); if (Number.isInteger(billId) && billId > 0) { @@ -347,14 +150,21 @@ export class BillsService { qb.andWhere('student.name LIKE :keyword', { keyword: `%${query.keyword}%` }); } } - if (query.periodStart) qb.andWhere('bill.periodStart >= :periodStart', { periodStart: query.periodStart }); - if (query.periodEnd) qb.andWhere('bill.periodEnd <= :periodEnd', { periodEnd: query.periodEnd }); + if (query.periodStart) + qb.andWhere('bill.periodStart >= :periodStart', { periodStart: query.periodStart }); + if (query.periodEnd) + qb.andWhere('bill.periodEnd <= :periodEnd', { periodEnd: query.periodEnd }); if (query.status) qb.andWhere('bill.status = :status', { status: query.status }); - const rows = await qb.orderBy('bill.generatedAt', 'DESC').limit(query.limit ?? 20).getRawMany(); + const rows = await qb + .orderBy('bill.generatedAt', 'DESC') + .limit(query.limit ?? 20) + .getRawMany(); return rows.map((row) => ({ ...row, - billId: Number(row.billId), totalAmount: Number(row.totalAmount || 0), - paidAmount: Number(row.paidAmount || 0), outstandingAmount: Number(row.outstandingAmount || 0), + billId: Number(row.billId), + totalAmount: Number(row.totalAmount || 0), + paidAmount: Number(row.paidAmount || 0), + outstandingAmount: Number(row.outstandingAmount || 0), })); } @@ -374,7 +184,9 @@ export class BillsService { .createQueryBuilder('wallet') .where('wallet.studentId IN (:...ids)', { ids: studentIds }) .getMany(); - const balanceMap = new Map(wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)])); + const balanceMap = new Map( + wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]), + ); return bills.map((bill) => ({ ...bill, walletBalance: Number((balanceMap.get(bill.studentId) || 0).toFixed(2)), @@ -394,7 +206,8 @@ export class BillsService { async batchUpdateStatus(ids: number[], status: string) { const uniqueIds = [...new Set(ids || [])]; if (uniqueIds.length === 0) throw new BadRequestException('请选择要更新的账单'); - if (!['unpaid', 'partially_paid', 'paid'].includes(status)) throw new BadRequestException('账单状态无效'); + if (!['unpaid', 'partially_paid', 'paid'].includes(status)) + throw new BadRequestException('账单状态无效'); const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } }); if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在'); for (const bill of bills) this.assertStatusMatchesAmounts(bill, status); @@ -410,16 +223,18 @@ export class BillsService { async cancel(id: number, dto: CancelBillDto, recordedBy?: number) { const reason = dto.reason?.trim(); if (!reason) throw new BadRequestException('取消原因不能为空'); - const work = () => this.dataSource.transaction(async (manager) => { - const bill = await manager.createQueryBuilder(Bill, 'bill') - .where('bill.id = :id', { id }) - .setLock('pessimistic_write') - .getOne(); - if (!bill) throw new NotFoundException('账单不存在'); - if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消'); - await manager.update(PersonalExpense, { billId: id }, { billId: null }); - return this.walletsService.refundBill(manager, bill, reason, recordedBy); - }); + const work = () => + this.dataSource.transaction(async (manager) => { + const bill = await manager + .createQueryBuilder(Bill, 'bill') + .where('bill.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!bill) throw new NotFoundException('账单不存在'); + if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消'); + await manager.update(PersonalExpense, { billId: id }, { billId: null }); + return this.walletsService.refundBill(manager, bill, reason, recordedBy); + }); return this.financialOperations ? this.financialOperations.run(dto.operationId, `bill.cancel:${id}`, work) : work(); @@ -441,6 +256,65 @@ export class BillsService { return { message: '账单已归档' }; } + async purge(id: number) { + const bill = await this.billRepo.findOne({ where: { id } }); + if (!bill) throw new NotFoundException('账单不存在'); + if (bill.status !== 'cancelled') { + throw new BadRequestException('仅已取消账单可以永久删除,请先取消账单'); + } + if (Number(bill.paidAmount) > 0) { + throw new BadRequestException('已发生资金流水的账单不能永久删除'); + } + const personalExpenseCount = await this.personalExpRepo.count({ where: { billId: id } }); + if (personalExpenseCount > 0) { + throw new BadRequestException('该账单仍关联个人费用,无法永久删除'); + } + await this.dataSource.transaction(async (manager) => { + await manager.delete(BillItem, { billId: id }); + await manager.delete(Bill, id); + }); + return { message: '已永久删除账单(不可恢复)' }; + } + + async batchPurge(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的账单'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('账单 ID 无效'); + } + const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } }); + if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在'); + const personalExpenseCount = await this.personalExpRepo.count({ + where: { billId: In(uniqueIds) }, + }); + if (personalExpenseCount > 0) { + throw new BadRequestException('选中账单仍关联个人费用,无法永久删除'); + } + + const deleted: number[] = []; + const skipped: string[] = []; + for (const bill of bills) { + if (bill.status !== 'cancelled') { + skipped.push(`账单${bill.id}(未取消)`); + continue; + } + if (Number(bill.paidAmount) > 0) { + skipped.push(`账单${bill.id}(已支付)`); + continue; + } + await this.dataSource.transaction(async (manager) => { + await manager.delete(BillItem, { billId: bill.id }); + await manager.delete(Bill, bill.id); + }); + deleted.push(bill.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 条账单;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 条账单(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + async batchRemove(ids: number[]) { const uniqueIds = [...new Set(ids || [])]; if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的账单'); @@ -469,11 +343,12 @@ export class BillsService { private assertStatusMatchesAmounts(bill: Bill, status: string) { const paid = Number(bill.paidAmount || 0); const outstanding = Number(bill.outstandingAmount || 0); - const matches = status === 'paid' - ? outstanding <= 0 - : status === 'partially_paid' - ? paid > 0 && outstanding > 0 - : status === 'unpaid' && paid <= 0 && outstanding > 0; + const matches = + status === 'paid' + ? outstanding <= 0 + : status === 'partially_paid' + ? paid > 0 && outstanding > 0 + : status === 'unpaid' && paid <= 0 && outstanding > 0; if (!matches) throw new BadRequestException('账单状态必须与实付及未付金额一致'); } } diff --git a/apps/server/src/classes/classes-queries.service.ts b/apps/server/src/classes/classes-queries.service.ts new file mode 100644 index 0000000..7e05d60 --- /dev/null +++ b/apps/server/src/classes/classes-queries.service.ts @@ -0,0 +1,172 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository, In } from 'typeorm'; +import { Class, ClassStudent, ClassSchedule, AttendanceRecord } from '../entities'; +import { Classroom } from '../entities/classroom.entity'; +import { syncDingTalkStudents } from '../integration/dingtalk-student-sync'; +import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto'; + +interface AgentClassRow { + id: string | number; + name: string; + code: string; + studentCount: string | number; +} + +@Injectable() +export class ClassesQueriesService { + constructor( + @InjectRepository(Class) private readonly classRepo: Repository, + @InjectRepository(ClassStudent) private readonly classStudentRepo: Repository, + @InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository, + @InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async agentSearchClasses( + accessibleClassIds: number[] | undefined, + query: { keyword?: string; status?: string; limit?: number }, + ) { + if (accessibleClassIds?.length === 0) return []; + + const qb = this.classRepo + .createQueryBuilder('class') + .leftJoin( + ClassStudent, + 'classStudent', + 'classStudent.classId = class.id AND classStudent.status = :activeStudent', + { activeStudent: 'active' }, + ) + .select('class.id', 'id'); + const classSelects = [ + ['class.name', 'name'], + ['class.code', 'code'], + ['class.classType', 'classType'], + ['class.status', 'status'], + ['class.startDate', 'startDate'], + ['class.endDate', 'endDate'], + ['COUNT(classStudent.id)', 'studentCount'], + ] as const; + for (const [column, alias] of classSelects) { + qb.addSelect(column, alias); + } + qb.where('class.isArchived = :isArchived', { isArchived: false }); + if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds }); + if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` }); + if (query.status) qb.andWhere('class.status = :status', { status: query.status }); + const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany(); + return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) })); + } + + async batchImportStudents( + classId: number, + users: Array<{ dingUserId: string; name: string; mobile?: string }>, + ): Promise<{ imported: number; skipped: number; conflicts: number }> { + if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 }; + + return this.dataSource.transaction(async (manager) => { + const classEntity = await manager.findOne(Class, { where: { id: classId } }); + if (!classEntity) throw new NotFoundException('班级不存在'); + + const synced = await syncDingTalkStudents(manager, users); + const studentIds = [...new Set(synced.studentIds.values())]; + if (studentIds.length === 0) { + return { imported: 0, skipped: 0, conflicts: synced.conflicts.length }; + } + + const existingClassStudents = await manager.find(ClassStudent, { + where: { classId, studentId: In(studentIds) }, + }); + const existingByStudentId = new Map( + existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]), + ); + const today = new Date().toISOString().slice(0, 10); + let skipped = 0; + const memberships = studentIds.flatMap((studentId) => { + const existing = existingByStudentId.get(studentId); + if (existing?.status === 'active') { + skipped++; + return []; + } + if (existing) { + existing.status = 'active'; + existing.joinDate = today; + existing.leaveDate = null; + return [existing]; + } + return [ + manager.create(ClassStudent, { + classId, + studentId, + status: 'active', + joinDate: today, + }), + ]; + }); + + if (memberships.length > 0) await manager.save(ClassStudent, memberships); + return { + imported: memberships.length, + skipped, + conflicts: synced.conflicts.length, + }; + }); +} + + async getSchedule(classId: number, query: QueryClassScheduleDto) { + const qb = this.scheduleRepo + .createQueryBuilder('cs') + .leftJoinAndSelect('cs.classroom', 'classroom') + .where('cs.classId = :classId', { classId }); + + if (query.startDate) { + qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); + } + if (query.endDate) { + qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate }); + } + + const schedules = await qb + .orderBy('cs.weekDay', 'ASC') + .addOrderBy('cs.startTime', 'ASC') + .getMany(); + + return schedules.map((s) => ({ + ...s, + classroomName: (s.classroom as Classroom | undefined)?.name || null, + })); +} + + async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) { + const qb = this.attendanceRepo + .createQueryBuilder('ar') + .where('ar.classId = :classId', { classId }); + + if (query.startDate) { + qb.andWhere('ar.attendanceDate >= :startDate', { startDate: query.startDate }); + } + if (query.endDate) { + qb.andWhere('ar.attendanceDate <= :endDate', { endDate: query.endDate }); + } + + const rows = await qb.getMany(); + + const total = rows.length; + const present = rows.filter((r) => r.status === 'present').length; + const late = rows.filter((r) => r.status === 'late').length; + const absent = rows.filter((r) => r.status === 'absent').length; + const leave = rows.filter((r) => r.status === 'leave').length; + + return { + total, + present, + late, + absent, + leave, + presentRate: total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0, + absentRate: total > 0 ? Number(((absent / total) * 100).toFixed(1)) : 0, + lateRate: total > 0 ? Number(((late / total) * 100).toFixed(1)) : 0, + leaveRate: total > 0 ? Number(((leave / total) * 100).toFixed(1)) : 0, + }; + } +} diff --git a/apps/server/src/classes/classes.batch-import-membership.spec.ts b/apps/server/src/classes/classes.batch-import-membership.spec.ts index 6555445..0721423 100644 --- a/apps/server/src/classes/classes.batch-import-membership.spec.ts +++ b/apps/server/src/classes/classes.batch-import-membership.spec.ts @@ -1,4 +1,5 @@ import { ClassesService } from './classes.service'; +import { ClassesQueriesService } from './classes-queries.service'; import { ClassStudent, Student, StudentDingMapping } from '../entities'; describe('ClassesService — DingTalk class import membership lifecycle', () => { @@ -32,6 +33,14 @@ describe('ClassesService — DingTalk class import membership lifecycle', () => create: jest.fn().mockImplementation((_entity: unknown, value: object) => value), save: jest.fn().mockImplementation(async (_entity: unknown, value: unknown) => value), }; + const dataSource = { transaction: jest.fn().mockImplementation((work) => work(manager)) }; + const queries = new ClassesQueriesService( + {} as never, + {} as never, + {} as never, + {} as never, + dataSource as never, + ); const service = new ClassesService( {} as never, {} as never, @@ -41,7 +50,9 @@ describe('ClassesService — DingTalk class import membership lifecycle', () => {} as never, {} as never, {} as never, - { transaction: jest.fn().mockImplementation((work) => work(manager)) } as never, + dataSource as never, + {} as never, + queries, ); const result = await service.batchImportStudents(3, [ diff --git a/apps/server/src/classes/classes.controller.spec.ts b/apps/server/src/classes/classes.controller.spec.ts index 102a40d..751dd4a 100644 --- a/apps/server/src/classes/classes.controller.spec.ts +++ b/apps/server/src/classes/classes.controller.spec.ts @@ -1,3 +1,5 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; import { ValidationPipe } from '@nestjs/common'; import { ClassesController } from './classes.controller'; import { ClassesService } from './classes.service'; @@ -114,3 +116,25 @@ describe('QueryClassDto - query transformation', () => { ).resolves.toEqual({ isArchived: expected }); }); }); + +describe('ClassesController purge route', () => { + it('requires class:purge on permanent delete route', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, ClassesController.prototype.purge)).toEqual([ + 'class:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { + purge: jest.fn().mockResolvedValue({ message: '已永久删除班级(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new ClassesController(service as never, { log } as never, {} as never, {} as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge('1', req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '班级管理', action: '永久删除班级', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index 547528b..2723aca 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -27,7 +27,7 @@ import { } from './dto/class.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationType } from '../entities/notification.entity'; @@ -115,18 +115,9 @@ export class ClassesController { @Post() @RequirePermission('class:create') async create(@Body() dto: CreateClassDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '创建班级', - targetId: result.id, - targetType: 'class', - detail: `班级${result.code} ${result.name}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '创建班级', targetId: result.id, targetType: 'class', detail: `班级${result.code} ${result.name}`, }); return result; } @@ -155,18 +146,9 @@ export class ClassesController { @Put(':id') @RequirePermission('class:edit') async update(@Param('id') id: string, @Body() dto: UpdateClassDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '编辑班级', - targetId: +id, - targetType: 'class', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto), }); return result; } @@ -174,17 +156,19 @@ export class ClassesController { @Delete(':id') @RequirePermission('class:delete') async remove(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '归档班级', - targetId: +id, - targetType: 'class', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('class:purge') + async purge(@Param('id') id: string, @Request() req: any) { + const result = await this.service.purge(+id); + await logAudit(this.logService, req, { + module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复', }); return result; } @@ -242,18 +226,9 @@ export class ClassesController { @Post(':id/students') @RequirePermission('class:edit') async addStudents(@Param('id') id: string, @Body() dto: AddStudentsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.addStudents(+id, dto.studentIds); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '添加学生', - targetId: +id, - targetType: 'class', - detail: `新增${result.added}名学生`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`, }); try { const cls = await this.service.findOne(+id); @@ -265,7 +240,9 @@ export class ClassesController { content: `班级新增${result.added}名学生`, }); } - } catch {} + } catch { + // 通知失败不影响班级新增结果 + } return result; } @@ -276,18 +253,9 @@ export class ClassesController { @Param('studentId') studentId: string, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.removeStudent(+id, +studentId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '移除学生', - targetId: +id, - targetType: 'class', - detail: `移除学生${studentId}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '移除学生', targetId: +id, targetType: 'class', detail: `移除学生${studentId}`, }); return result; } @@ -302,18 +270,9 @@ export class ClassesController { @Post(':id/teachers') @RequirePermission('class:edit') async addTeacher(@Param('id') id: string, @Body() dto: AddTeacherDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.addTeacher(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '添加教师', - targetId: +id, - targetType: 'class', - detail: `教师${dto.userId} 角色${dto.roleType}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`, }); try { void this.notificationsService.create({ @@ -322,7 +281,9 @@ export class ClassesController { title: '班级分配', content: `您已被分配到班级担任${teacherRoleLabels[dto.roleType] ?? dto.roleType}角色`, }); - } catch {} + } catch { + // 通知失败不影响班级分配结果 + } return result; } @@ -333,18 +294,9 @@ export class ClassesController { @Param('assignmentId') assignmentId: string, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.removeTeacherAssignment(+id, +assignmentId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '移除教师角色', - targetId: +id, - targetType: 'class', - detail: `移除教师分配${assignmentId}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '移除教师角色', targetId: +id, targetType: 'class', detail: `移除教师分配${assignmentId}`, }); return result; } @@ -356,18 +308,9 @@ export class ClassesController { @Param('userId') userId: string, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.removeTeacher(+id, +userId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '班级管理', - action: '移除教师', - targetId: +id, - targetType: 'class', - detail: `移除教师${userId}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '班级管理', action: '移除教师', targetId: +id, targetType: 'class', detail: `移除教师${userId}`, }); return result; } diff --git a/apps/server/src/classes/classes.module.ts b/apps/server/src/classes/classes.module.ts index 5a6f951..856c2e3 100644 --- a/apps/server/src/classes/classes.module.ts +++ b/apps/server/src/classes/classes.module.ts @@ -1,15 +1,16 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping } from '../entities'; +import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping } from '../entities'; import { ClassesService } from './classes.service'; +import { ClassesQueriesService } from './classes-queries.service'; import { ClassesController } from './classes.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule], + imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule], controllers: [ClassesController], - providers: [ClassesService], + providers: [ClassesService, ClassesQueriesService], exports: [ClassesService], }) export class ClassesModule {} diff --git a/apps/server/src/classes/classes.purge.spec.ts b/apps/server/src/classes/classes.purge.spec.ts new file mode 100644 index 0000000..205b047 --- /dev/null +++ b/apps/server/src/classes/classes.purge.spec.ts @@ -0,0 +1,54 @@ +import { BadRequestException } from '@nestjs/common'; +import { ClassesService } from './classes.service'; + +describe('ClassesService.purge', () => { + const createService = (overrides?: { + cls?: Record; + counts?: Record; + }) => { + const cls = { id: 1, name: '冲刺班', code: 'C1', isArchived: true, ...overrides?.cls }; + const repo = { + findOne: jest.fn().mockResolvedValue(cls), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const counts = overrides?.counts ?? {}; + const countFor = (key: string) => jest.fn().mockResolvedValue(counts[key] ?? 0); + const service = new ClassesService( + repo as never, + { count: countFor('classStudent') } as never, + { count: countFor('classTeacher') } as never, + { count: countFor('schedule') } as never, + { count: countFor('attendance') } as never, + { count: countFor('session') } as never, + {} as never, + {} as never, + {} as never, + { count: countFor('exam') } as never, + ); + return { service, repo }; + }; + + it('rejects classes that are not archived', async () => { + const { service, repo } = createService({ cls: { isArchived: false } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档班级可以永久删除,请先归档'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects classes with students, teachers, schedules, exams, or attendance', async () => { + const { service, repo } = createService({ counts: { classStudent: 1 } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('该班级存在关联数据(班级学生),无法永久删除'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived class with no references', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ + message: '已永久删除班级(不可恢复)', + }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index 8c3f931..42f3409 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -1,23 +1,26 @@ import { - Injectable, - NotFoundException, - BadRequestException, - ForbiddenException, +Injectable, +NotFoundException, +BadRequestException, +ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository, In, Like } from 'typeorm'; +import { DataSource, +Repository, +In, +Like } from 'typeorm'; import { Class, - ClassStudent, - ClassTeacher, - ClassSchedule, - AttendanceRecord, - AttendanceSession, - Classroom, - Student, - StudentDingMapping, +ClassStudent, +ClassTeacher, +ClassSchedule, +AttendanceRecord, +AttendanceSession, +Exam, +Student, +StudentDingMapping } from '../entities'; -import { syncDingTalkStudents } from '../integration/dingtalk-student-sync'; +import { ClassesQueriesService } from './classes-queries.service'; import { normalizeDateOnly } from '../database/date-normalization'; import { CreateClassDto, @@ -33,17 +36,6 @@ interface RawStudentCount { count: string; } -interface AgentClassRow { - id: string | number; - name: string; - code: string; - classType: string; - status: string; - startDate: string | null; - endDate: string | null; - studentCount: string | number; -} - @Injectable() export class ClassesService { constructor( @@ -64,6 +56,9 @@ export class ClassesService { @InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository, private dataSource: DataSource, + @InjectRepository(Exam) + private examRepo: Repository, + private queries: ClassesQueriesService, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { @@ -84,30 +79,22 @@ export class ClassesService { query: { keyword?: string; status?: string; limit?: number }, ) { const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll); - if (accessibleClassIds?.length === 0) return []; + return this.queries.agentSearchClasses(accessibleClassIds, query); + } - const qb = this.classRepo - .createQueryBuilder('class') - .leftJoin( - ClassStudent, - 'classStudent', - 'classStudent.classId = class.id AND classStudent.status = :activeStudent', - { activeStudent: 'active' }, - ) - .select('class.id', 'id') - .addSelect('class.name', 'name') - .addSelect('class.code', 'code') - .addSelect('class.classType', 'classType') - .addSelect('class.status', 'status') - .addSelect('class.startDate', 'startDate') - .addSelect('class.endDate', 'endDate') - .addSelect('COUNT(classStudent.id)', 'studentCount') - .where('class.isArchived = :isArchived', { isArchived: false }); - if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds }); - if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` }); - if (query.status) qb.andWhere('class.status = :status', { status: query.status }); - const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany(); - return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) })); + async batchImportStudents( + classId: number, + users: Array<{ dingUserId: string; name: string; mobile?: string }>, + ): Promise<{ imported: number; skipped: number; conflicts: number }> { + return this.queries.batchImportStudents(classId, users); + } + + async getSchedule(classId: number, query: QueryClassScheduleDto) { + return this.queries.getSchedule(classId, query); + } + + async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) { + return this.queries.getAttendanceSummary(classId, query); } async findAll(query: QueryClassDto, accessibleClassIds?: number[]) { @@ -227,64 +214,6 @@ export class ClassesService { return this.findOne(saved.id); } - async batchImportStudents( - classId: number, - users: Array<{ - dingUserId: string; - name: string; - mobile?: string; - }>, - ): Promise<{ imported: number; skipped: number; conflicts: number }> { - if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 }; - - return this.dataSource.transaction(async (manager) => { - const classEntity = await manager.findOne(Class, { where: { id: classId } }); - if (!classEntity) throw new NotFoundException('班级不存在'); - - const synced = await syncDingTalkStudents(manager, users); - const studentIds = [...new Set(synced.studentIds.values())]; - if (studentIds.length === 0) { - return { imported: 0, skipped: 0, conflicts: synced.conflicts.length }; - } - - const existingClassStudents = await manager.find(ClassStudent, { - where: { classId, studentId: In(studentIds) }, - }); - const existingByStudentId = new Map( - existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]), - ); - const today = new Date().toISOString().slice(0, 10); - let skipped = 0; - const memberships = studentIds.flatMap((studentId) => { - const existing = existingByStudentId.get(studentId); - if (existing?.status === 'active') { - skipped++; - return []; - } - if (existing) { - existing.status = 'active'; - existing.joinDate = today; - existing.leaveDate = null; - return [existing]; - } - return [ - manager.create(ClassStudent, { - classId, - studentId, - status: 'active', - joinDate: today, - }), - ]; - }); - - if (memberships.length > 0) await manager.save(ClassStudent, memberships); - return { - imported: memberships.length, - skipped, - conflicts: synced.conflicts.length, - }; - }); - } async update(id: number, dto: UpdateClassDto) { const cls = await this.classRepo.findOne({ where: { id } }); if (!cls) throw new NotFoundException('班级不存在'); @@ -323,6 +252,33 @@ export class ClassesService { return this.archive(id); } + /** 永久删除班级(仅已归档) */ + async purge(id: number) { + const cls = await this.classRepo.findOne({ where: { id } }); + if (!cls) throw new NotFoundException('班级不存在'); + if (!cls.isArchived) throw new BadRequestException('仅已归档班级可以永久删除,请先归档'); + const [studentCount, teacherCount, scheduleCount, examCount, sessionCount, attendanceCount] = + await Promise.all([ + this.classStudentRepo.count({ where: { classId: id } }), + this.classTeacherRepo.count({ where: { classId: id } }), + this.scheduleRepo.count({ where: { classId: id } }), + this.examRepo.count({ where: { classId: id } }), + this.attendanceSessionRepo.count({ where: { classId: id } }), + this.attendanceRepo.count({ where: { classId: id } }), + ]); + const references: string[] = []; + if (studentCount > 0) references.push('班级学生'); + if (teacherCount > 0) references.push('任课教师'); + if (scheduleCount > 0) references.push('排课'); + if (examCount > 0) references.push('考试'); + if (sessionCount > 0 || attendanceCount > 0) references.push('考勤记录'); + if (references.length > 0) { + throw new BadRequestException(`该班级存在关联数据(${references.join('、')}),无法永久删除`); + } + await this.classRepo.delete(id); + return { message: '已永久删除班级(不可恢复)' }; + } + async getStudents(classId: number) { return this.classStudentRepo.find({ where: { classId }, @@ -447,61 +403,4 @@ export class ClassesService { academicTeacherId: academic?.userId ?? null, } as Partial); } - - async getSchedule(classId: number, query: QueryClassScheduleDto) { - const qb = this.scheduleRepo - .createQueryBuilder('cs') - .leftJoinAndSelect('cs.classroom', 'classroom') - .where('cs.classId = :classId', { classId }); - - if (query.startDate) { - qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); - } - if (query.endDate) { - qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate }); - } - - const schedules = await qb - .orderBy('cs.weekDay', 'ASC') - .addOrderBy('cs.startTime', 'ASC') - .getMany(); - - return schedules.map((s) => ({ - ...s, - classroomName: (s.classroom as Classroom | undefined)?.name || null, - })); - } - - async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) { - const qb = this.attendanceRepo - .createQueryBuilder('ar') - .where('ar.classId = :classId', { classId }); - - if (query.startDate) { - qb.andWhere('ar.attendanceDate >= :startDate', { startDate: query.startDate }); - } - if (query.endDate) { - qb.andWhere('ar.attendanceDate <= :endDate', { endDate: query.endDate }); - } - - const rows = await qb.getMany(); - - const total = rows.length; - const present = rows.filter((r) => r.status === 'present').length; - const late = rows.filter((r) => r.status === 'late').length; - const absent = rows.filter((r) => r.status === 'absent').length; - const leave = rows.filter((r) => r.status === 'leave').length; - - return { - total, - present, - late, - absent, - leave, - presentRate: total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0, - absentRate: total > 0 ? Number(((absent / total) * 100).toFixed(1)) : 0, - lateRate: total > 0 ? Number(((late / total) * 100).toFixed(1)) : 0, - leaveRate: total > 0 ? Number(((leave / total) * 100).toFixed(1)) : 0, - }; - } } diff --git a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts index 5092f60..dd9e970 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts @@ -21,7 +21,7 @@ import { ClassroomRentalsService } from './classroom-rentals.service'; import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; @UseGuards(JwtAuthGuard) @@ -102,18 +102,9 @@ export class ClassroomRentalsController { @Post() @RequirePermission('rental:create') async create(@Body() dto: CreateRentalDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '新增租赁', - targetId: result.id, - targetType: 'classroom-rental', - detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '新增租赁', targetId: result.id, targetType: 'classroom-rental', detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`, }); return result; } @@ -121,18 +112,9 @@ export class ClassroomRentalsController { @Put(':id') @RequirePermission('rental:edit') async update(@Param('id') id: string, @Body() dto: UpdateRentalDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '编辑租赁', - targetId: +id, - targetType: 'classroom-rental', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '编辑租赁', targetId: +id, targetType: 'classroom-rental', detail: JSON.stringify(dto), }); return result; } @@ -140,17 +122,9 @@ export class ClassroomRentalsController { @Put(':id/cancel') @RequirePermission('rental:edit') async cancel(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.cancel(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '取消租赁', - targetId: +id, - targetType: 'classroom-rental', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '取消租赁', targetId: +id, targetType: 'classroom-rental', }); return result; } @@ -158,17 +132,9 @@ export class ClassroomRentalsController { @Put(':id/end') @RequirePermission('rental:edit') async end(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.end(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '结束租赁', - targetId: +id, - targetType: 'classroom-rental', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '结束租赁', targetId: +id, targetType: 'classroom-rental', }); return result; } @@ -176,17 +142,19 @@ export class ClassroomRentalsController { @Delete(':id') @RequirePermission('rental:delete') async remove(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '归档租赁', - targetId: +id, - targetType: 'classroom-rental', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '归档租赁', targetId: +id, targetType: 'classroom-rental', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('rental:purge') + async purge(@Param('id') id: string, @Request() req: any) { + const result = await this.service.purge(+id); + await logAudit(this.logService, req, { + module: '教室租赁', action: '永久删除租赁订单', targetId: +id, targetType: 'classroom-rental', detail: '物理删除,不可恢复', }); return result; } @@ -211,18 +179,9 @@ export class ClassroomRentalsController { @Request() req: any, ) { if (!file) throw new BadRequestException('请上传合同文件'); - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.attachContract(+id, file); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '上传合同', - targetId: +id, - targetType: 'classroom-rental', - detail: file.originalname, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '上传合同', targetId: +id, targetType: 'classroom-rental', detail: file.originalname, }); return result; } @@ -243,17 +202,9 @@ export class ClassroomRentalsController { @Delete(':id/contract') @RequirePermission('rental:edit') async deleteContract(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.removeContract(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室租赁', - action: '移除合同', - targetId: +id, - targetType: 'classroom-rental', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室租赁', action: '移除合同', targetId: +id, targetType: 'classroom-rental', }); return result; } diff --git a/apps/server/src/classroom-rentals/classroom-rentals.module.ts b/apps/server/src/classroom-rentals/classroom-rentals.module.ts index 4b3cc5a..05aa9e4 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.module.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.module.ts @@ -4,17 +4,27 @@ import { ClassroomRental } from '../entities/classroom-rental.entity'; import { Classroom } from '../entities/classroom.entity'; import { Organization } from '../entities/organization.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; +import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { AttendanceSession } from '../entities/attendance-session.entity'; import { ClassroomRentalsService } from './classroom-rentals.service'; +import { RentalScheduleService } from './rental-schedule.service'; import { ClassroomRentalsController } from './classroom-rentals.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; @Module({ imports: [ - TypeOrmModule.forFeature([ClassroomRental, Classroom, Organization, ClassSchedule]), + TypeOrmModule.forFeature([ + ClassroomRental, + Classroom, + Organization, + ClassSchedule, + AttendanceRecord, + AttendanceSession, + ]), OperationLogsModule, ], controllers: [ClassroomRentalsController], - providers: [ClassroomRentalsService], + providers: [ClassroomRentalsService, RentalScheduleService], exports: [ClassroomRentalsService], }) export class ClassroomRentalsModule {} diff --git a/apps/server/src/classroom-rentals/classroom-rentals.purge.controller.spec.ts b/apps/server/src/classroom-rentals/classroom-rentals.purge.controller.spec.ts new file mode 100644 index 0000000..c407e64 --- /dev/null +++ b/apps/server/src/classroom-rentals/classroom-rentals.purge.controller.spec.ts @@ -0,0 +1,25 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { ClassroomRentalsController } from './classroom-rentals.controller'; + +describe('ClassroomRentalsController purge route', () => { + it('requires rental:purge on permanent delete route', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, ClassroomRentalsController.prototype.purge)).toEqual([ + 'rental:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { + purge: jest.fn().mockResolvedValue({ message: '已永久删除租赁订单(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new ClassroomRentalsController(service as never, { log } as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge('1', req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '教室租赁', action: '永久删除租赁订单', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/classroom-rentals/classroom-rentals.purge.spec.ts b/apps/server/src/classroom-rentals/classroom-rentals.purge.spec.ts new file mode 100644 index 0000000..5ceb294 --- /dev/null +++ b/apps/server/src/classroom-rentals/classroom-rentals.purge.spec.ts @@ -0,0 +1,92 @@ +import { BadRequestException } from '@nestjs/common'; +import { ClassroomRentalsService } from './classroom-rentals.service'; +import { RentalScheduleService } from './rental-schedule.service'; + +describe('ClassroomRentalsService.purge', () => { + const createService = (overrides?: { + rental?: Record; + schedules?: Record[]; + sessionCount?: number; + recordCount?: number; + }) => { + const rental = { + id: 1, + classroomId: 2, + status: 'cancelled', + startDate: '2026-01-01', + endDate: '2026-01-31', + contractPath: null, + ...overrides?.rental, + }; + const repo = { + findOne: jest.fn().mockResolvedValue(rental), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const scheduleRepo = { + find: jest.fn().mockResolvedValue(overrides?.schedules ?? []), + }; + const attendanceRepo = { count: jest.fn().mockResolvedValue(overrides?.recordCount ?? 0) }; + const attendanceSessionRepo = { + count: jest.fn().mockResolvedValue(overrides?.sessionCount ?? 0), + }; + const manager = { + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb(manager)), + }; + const scheduleService = new RentalScheduleService(repo as never, {} as never, scheduleRepo as never); + const service = new ClassroomRentalsService( + repo as never, + {} as never, + {} as never, + scheduleRepo as never, + attendanceRepo as never, + attendanceSessionRepo as never, + dataSource as never, + scheduleService, + ); + return { service, repo, scheduleRepo, attendanceRepo, attendanceSessionRepo, dataSource, manager }; + }; + + it('rejects rentals that are not cancelled', async () => { + const { service, dataSource } = createService({ rental: { status: 'active' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已取消租赁订单可以永久删除,请先取消'), + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('rejects cancelled rentals whose schedules have attendance history', async () => { + const withSession = createService({ + schedules: [{ id: 5, rentalId: 1, scheduleType: 'RENTAL' }], + sessionCount: 1, + }); + await expect(withSession.service.purge(1)).rejects.toThrow( + new BadRequestException('该租赁的排课已有考勤记录,无法永久删除'), + ); + + const withRecord = createService({ + schedules: [{ id: 5, rentalId: 1, scheduleType: 'RENTAL' }], + recordCount: 1, + }); + await expect(withRecord.service.purge(1)).rejects.toThrow( + new BadRequestException('该租赁的排课已有考勤记录,无法永久删除'), + ); + expect(withRecord.dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('deletes schedules and rental without attendance history', async () => { + const { service, dataSource, manager } = createService({ + schedules: [{ id: 5, rentalId: 1, scheduleType: 'RENTAL' }], + }); + await expect(service.purge(1)).resolves.toEqual({ + message: '已永久删除租赁订单(不可恢复)', + }); + expect(dataSource.transaction).toHaveBeenCalled(); + expect(manager.delete).toHaveBeenNthCalledWith(1, expect.anything(), { + id: expect.anything(), + }); + expect(manager.delete).toHaveBeenNthCalledWith(2, expect.anything(), 1); + }); +}); diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts index 1378587..d35abc3 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts @@ -1,12 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ConflictException } from '@nestjs/common'; -import { Not, Repository } from 'typeorm'; +import { DataSource, Not, Repository } from 'typeorm'; import { ClassroomRentalsService } from './classroom-rentals.service'; +import { RentalScheduleService } from './rental-schedule.service'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { Classroom } from '../entities/classroom.entity'; import { Organization } from '../entities/organization.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; +import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { AttendanceSession } from '../entities/attendance-session.entity'; import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; function mockQueryBuilder(results: T[] = []) { @@ -28,6 +31,8 @@ describe('ClassroomRentalsService — findConflicts', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ClassroomRentalsService, + RentalScheduleService, + RentalScheduleService, { provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() }, @@ -35,6 +40,12 @@ describe('ClassroomRentalsService — findConflicts', () => { { provide: getRepositoryToken(Classroom), useValue: {} }, { provide: getRepositoryToken(Organization), useValue: {} }, { provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } }, + { provide: getRepositoryToken(AttendanceRecord), useValue: {} }, + { provide: getRepositoryToken(AttendanceSession), useValue: {} }, + { + provide: DataSource, + useValue: { transaction: jest.fn((cb: (m: unknown) => Promise) => cb({})) }, + }, ], }).compile(); @@ -131,10 +142,17 @@ describe('ClassroomRentalsService — unavailable dates', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ClassroomRentalsService, + RentalScheduleService, { provide: getRepositoryToken(ClassroomRental), useValue: { find: jest.fn() } }, { provide: getRepositoryToken(Classroom), useValue: {} }, { provide: getRepositoryToken(Organization), useValue: {} }, { provide: getRepositoryToken(ClassSchedule), useValue: { find: jest.fn() } }, + { provide: getRepositoryToken(AttendanceRecord), useValue: {} }, + { provide: getRepositoryToken(AttendanceSession), useValue: {} }, + { + provide: DataSource, + useValue: { transaction: jest.fn((cb: (m: unknown) => Promise) => cb({})) }, + }, ], }).compile(); @@ -225,10 +243,17 @@ describe('ClassroomRentalsService — rental schedule sync', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ClassroomRentalsService, + RentalScheduleService, { provide: getRepositoryToken(ClassroomRental), useValue: rentalRepo }, { provide: getRepositoryToken(Classroom), useValue: classroomRepo }, { provide: getRepositoryToken(Organization), useValue: organizationRepo }, { provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepo }, + { provide: getRepositoryToken(AttendanceRecord), useValue: {} }, + { provide: getRepositoryToken(AttendanceSession), useValue: {} }, + { + provide: DataSource, + useValue: { transaction: jest.fn((cb: (m: unknown) => Promise) => cb({})) }, + }, ], }).compile(); @@ -475,11 +500,16 @@ describe('ClassroomRentalsService — organization roles', () => { createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), } as any; + const scheduleService = new RentalScheduleService(rentalRepo, classroomRepo, scheduleRepo); const service = new ClassroomRentalsService( rentalRepo, classroomRepo, organizationRepo, scheduleRepo, + {} as any, + {} as any, + {} as any, + scheduleService, ); await service.create({ diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.ts index 93add92..94e8d0b 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.ts @@ -4,30 +4,35 @@ import { BadRequestException, ConflictException, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, Repository } from 'typeorm'; import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity'; import { Classroom, ClassroomStatus } from '../entities/classroom.entity'; import { Organization } from '../entities/organization.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; +import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { AttendanceSession } from '../entities/attendance-session.entity'; import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; +import { RentalScheduleService } from './rental-schedule.service'; import * as path from 'path'; import * as fs from 'fs'; // 预设色板(与 organizations.service 保持一致,作为颜色兜底) -const COLOR_PALETTE = [ - '#ff7875', - '#ffa940', - '#ffc53d', - '#73d13d', - '#36cfc9', - '#40a9ff', - '#597ef7', - '#9254de', - '#f759ab', - '#8c8c8c', -]; +function rentalConflictError( + message: string, + conflicts: Array<{ id: number; startDate: string; endDate: string; lesseeOrganization?: { name?: string | null } | null }>, +) { + return new ConflictException({ + message, + conflicts: conflicts.map((c) => ({ + id: c.id, + startDate: c.startDate, + endDate: c.endDate, + organizationName: c.lesseeOrganization?.name, + })), + }); +} @Injectable() export class ClassroomRentalsService { @@ -36,6 +41,10 @@ export class ClassroomRentalsService { @InjectRepository(Classroom) private classroomRepo: Repository, @InjectRepository(Organization) private organizationRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, + @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, + @InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository, + @InjectDataSource() private dataSource: DataSource, + private schedule: RentalScheduleService, ) {} get uploadDir(): string { @@ -74,7 +83,7 @@ export class ClassroomRentalsService { qb.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE }); } const rentals = await qb.getMany(); - return rentals.map((rental) => this.withEffectiveStatus(rental)); + return rentals.map((rental) => this.schedule.withEffectiveStatus(rental)); } /** @@ -98,19 +107,24 @@ export class ClassroomRentalsService { contractName: string | null; }[] > { + const rentalSelects = [ + ['classroom.name', 'classroomName'], + ['lesseeOrganization.name', 'lesseeOrganizationName'], + ['r.startDate', 'startDate'], + ['r.endDate', 'endDate'], + ['r.dailyRate', 'dailyRate'], + ['r.totalAmount', 'totalAmount'], + ['r.status', 'status'], + ['r.contractOriginalName', 'contractName'], + ] as const; const qb = this.repo .createQueryBuilder('r') .leftJoin('r.classroom', 'classroom') .leftJoin('r.lesseeOrganization', 'lesseeOrganization') - .select('r.id', 'id') - .addSelect('classroom.name', 'classroomName') - .addSelect('lesseeOrganization.name', 'lesseeOrganizationName') - .addSelect('r.startDate', 'startDate') - .addSelect('r.endDate', 'endDate') - .addSelect('r.dailyRate', 'dailyRate') - .addSelect('r.totalAmount', 'totalAmount') - .addSelect('r.status', 'status') - .addSelect('r.contractOriginalName', 'contractName'); + .select('r.id', 'id'); + for (const [column, alias] of rentalSelects) { + qb.addSelect(column, alias); + } if (query?.classroomId) { qb.andWhere('r.classroomId = :classroomId', { classroomId: query.classroomId }); } @@ -148,142 +162,19 @@ export class ClassroomRentalsService { relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'], }); if (!rental) throw new NotFoundException('租赁订单不存在'); - return this.withEffectiveStatus(rental); + return this.schedule.withEffectiveStatus(rental); } async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) { - const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); - const monthStart = `${year}-${String(month).padStart(2, '0')}-01`; - const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; - - const [rentals, schedules] = await Promise.all([ - this.repo.find({ - where: { - ...(excludeId ? { id: Not(excludeId) } : {}), - classroomId, - status: ClassroomRentalStatus.ACTIVE, - startDate: LessThanOrEqual(monthEnd), - endDate: MoreThanOrEqual(monthStart), - }, - }), - this.scheduleRepo.find({ - where: { - classroomId, - status: ClassroomRentalStatus.ACTIVE, - scheduleType: 'INTERNAL', - startDate: LessThanOrEqual(monthEnd), - endDate: MoreThanOrEqual(monthStart), - }, - }), - ]); - - const unavailableDates = new Set(); - for (const rental of rentals) { - this.addDateRange( - unavailableDates, - rental.startDate > monthStart ? rental.startDate : monthStart, - rental.endDate < monthEnd ? rental.endDate : monthEnd, - ); - } - for (const schedule of schedules) { - this.addScheduleOccurrences(unavailableDates, schedule, monthStart, monthEnd); - } - - return { dates: Array.from(unavailableDates).sort() }; + return this.schedule.getUnavailableDates(classroomId, year, month, excludeId); } - /** - * 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课 - * 重叠判定:start1 <= end2 AND start2 <= end1 - */ async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) { - const qb = this.repo - .createQueryBuilder('r') - .leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization') - .where('r.classroomId = :cid', { cid: classroomId }) - .andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE }) - .andWhere('r.startDate <= :end', { end: endDate }) - .andWhere('r.endDate >= :start', { start: startDate }); - if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId }); - const rentals = await qb.getMany(); - - // 检测同一教室同一日期段是否存在内部排课 - const scheduleCandidates = await this.scheduleRepo - .createQueryBuilder('cs') - .where('cs.classroomId = :cid', { cid: classroomId }) - .andWhere('cs.status = :status', { status: 'active' }) - .andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' }) - .andWhere('cs.startDate <= :end', { end: endDate }) - .andWhere('cs.endDate >= :start', { start: startDate }) - .getMany(); - const scheduleConflicts = scheduleCandidates.filter((schedule) => - this.hasScheduleOccurrence(schedule, startDate, endDate), - ); - - if (scheduleConflicts.length > 0) { - throw new ConflictException({ - message: '该教室在此时间段已有排课', - conflicts: scheduleConflicts.map((s) => ({ - id: s.id, - startDate: s.startDate, - endDate: s.endDate, - organizationName: `[内部排课] ${s.subject}`, - })), - }); - } - - return rentals; + return this.schedule.findConflicts(classroomId, startDate, endDate, excludeId); } - private hasScheduleOccurrence( - schedule: ClassSchedule, - startDate: string, - endDate: string, - ): boolean { - const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate; - const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; - if (overlapStart > overlapEnd) return false; - - const startUtc = this.toUtcDate(overlapStart); - const endUtc = this.toUtcDate(overlapEnd); - const startWeekDay = startUtc.getUTCDay() || 7; - const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7; - startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence); - return startUtc <= endUtc; - } - - private toUtcDate(date: string): Date { - const [year, month, day] = date.split('-').map(Number); - return new Date(Date.UTC(year, month - 1, day)); - } - - private addDateRange(dates: Set, startDate: string, endDate: string) { - const current = this.toUtcDate(startDate); - const end = this.toUtcDate(endDate); - while (current <= end) { - dates.add(current.toISOString().slice(0, 10)); - current.setUTCDate(current.getUTCDate() + 1); - } - } - - private addScheduleOccurrences( - dates: Set, - schedule: ClassSchedule, - startDate: string, - endDate: string, - ) { - const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate; - const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; - if (overlapStart > overlapEnd) return; - - const current = this.toUtcDate(overlapStart); - const end = this.toUtcDate(overlapEnd); - const startWeekDay = current.getUTCDay() || 7; - current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7)); - while (current <= end) { - dates.add(current.toISOString().slice(0, 10)); - current.setUTCDate(current.getUTCDate() + 7); - } + async getSchedule(year: number, month: number) { + return this.schedule.getSchedule(year, month); } async create(dto: CreateRentalDto, userId?: number) { @@ -309,15 +200,7 @@ export class ClassroomRentalsService { const conflicts = await this.findConflicts(dto.classroomId, dto.startDate, dto.endDate); if (conflicts.length > 0) { - throw new ConflictException({ - message: '该教室在此时间段已有租赁', - conflicts: conflicts.map((c) => ({ - id: c.id, - startDate: c.startDate, - endDate: c.endDate, - organizationName: c.lesseeOrganization?.name, - })), - }); + throw rentalConflictError('该教室在此时间段已有租赁', conflicts); } const rental = this.repo.create({ ...dto, @@ -327,7 +210,7 @@ export class ClassroomRentalsService { status: ClassroomRentalStatus.ACTIVE, }); const saved = await this.repo.save(rental); - await this.syncScheduleFromRental(saved, lesseeOrganization.name); + await this.schedule.syncScheduleFromRental(saved, lesseeOrganization.name); return saved; } @@ -351,15 +234,7 @@ export class ClassroomRentalsService { if (dto.classroomId || dto.startDate || dto.endDate) { const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id); if (conflicts.length > 0) { - throw new ConflictException({ - message: '修改后时间段与已有租赁冲突', - conflicts: conflicts.map((c) => ({ - id: c.id, - startDate: c.startDate, - endDate: c.endDate, - organizationName: c.lesseeOrganization?.name, - })), - }); + throw rentalConflictError('修改后时间段与已有租赁冲突', conflicts); } } const newLessorId = dto.lessorOrganizationId ?? rental.lessorOrganizationId; @@ -381,7 +256,7 @@ export class ClassroomRentalsService { } await this.repo.update(id, dto); const updated = await this.findOne(id); - await this.syncScheduleFromRental(updated); + await this.schedule.syncScheduleFromRental(updated); return updated; } @@ -412,7 +287,7 @@ export class ClassroomRentalsService { endDate: rental.endDate > today ? today : rental.endDate, }); const ended = await this.findOne(id); - await this.syncScheduleFromRental(ended); + await this.schedule.syncScheduleFromRental(ended); return ended; } @@ -426,56 +301,40 @@ export class ClassroomRentalsService { return { message: '租赁订单已归档(合同文件已保留)' }; } - private withEffectiveStatus(rental: ClassroomRental) { - const today = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(new Date()); - const effectiveStatus = - rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today - ? ClassroomRentalStatus.ENDED - : rental.status; - return Object.assign(rental, { effectiveStatus }); - } - - /** - * 同步租赁订单到 class_schedules(schedule_type = 'RENTAL') - */ - private async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) { - const name = organizationName || rental.lesseeOrganization?.name || '承租机构'; - const weekDay = this.dateToWeekDay(rental.startDate); - let schedule = await this.scheduleRepo.findOne({ - where: { rentalId: rental.id, scheduleType: 'RENTAL' }, - }); - const data = { - classroomId: rental.classroomId, - classId: null, - weekDay, - startTime: '00:00', - endTime: '23:59', - startDate: rental.startDate, - endDate: rental.endDate, - subject: `${name} 租赁`, - teacherId: null, - scheduleType: 'RENTAL', - rentalId: rental.id, - status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active', - notes: rental.notes, - }; - if (schedule) { - await this.scheduleRepo.update(schedule.id, data); - } else { - schedule = this.scheduleRepo.create(data); - await this.scheduleRepo.save(schedule); + async purge(id: number) { + const rental = await this.findOne(id); + if (rental.status !== ClassroomRentalStatus.CANCELLED) { + throw new BadRequestException('仅已取消租赁订单可以永久删除,请先取消'); } - } - - private dateToWeekDay(date: string): number { - const d = new Date(date); - const day = d.getDay(); - return day === 0 ? 7 : day; + const schedules = await this.scheduleRepo.find({ + where: { rentalId: id, scheduleType: 'RENTAL' }, + }); + const scheduleIds = schedules.map((schedule) => schedule.id); + if (scheduleIds.length > 0) { + const [sessionCount, recordCount] = await Promise.all([ + this.attendanceSessionRepo.count({ where: { scheduleId: In(scheduleIds) } }), + this.attendanceRepo.count({ where: { scheduleId: In(scheduleIds) } }), + ]); + if (sessionCount > 0 || recordCount > 0) { + throw new BadRequestException('该租赁的排课已有考勤记录,无法永久删除'); + } + } + await this.dataSource.transaction(async (manager) => { + if (scheduleIds.length > 0) { + await manager.delete(ClassSchedule, { id: In(scheduleIds) }); + } + await manager.delete(ClassroomRental, id); + }); + if (rental.contractPath) { + const fullPath = path.join(this.uploadDir, rental.contractPath); + try { + if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath); + } catch (error) { + // 文件删除失败仅告警,不阻塞数据库删除 + console.warn(`[ClassroomRentalsService] 合同文件删除失败: ${fullPath}`, error); + } + } + return { message: '已永久删除租赁订单(不可恢复)' }; } async attachContract(id: number, file: Express.Multer.File) { @@ -488,9 +347,7 @@ export class ClassroomRentalsService { const ext = path.extname(file.originalname).toLowerCase(); if (ext !== '.pdf') throw new BadRequestException('文件扩展名必须为 .pdf'); // UUID 文件名 - const uuid = - (globalThis as any).crypto?.randomUUID?.() || - require('crypto').randomBytes(16).toString('hex'); + const uuid = require('crypto').randomBytes(16).toString('hex'); const filename = `${uuid}.pdf`; const fullPath = path.join(this.uploadDir, filename); // 路径遍历防护 @@ -525,7 +382,7 @@ export class ClassroomRentalsService { /* ignore */ } } - await this.repo.update(id, { contractPath: null as any, contractOriginalName: null as any }); + await this.repo.update(id, { contractPath: null, contractOriginalName: null }); return { message: '合同已移除' }; } @@ -544,127 +401,4 @@ export class ClassroomRentalsService { /** * 获取月度排期矩阵 */ - async getSchedule(year: number, month: number) { - const lastDay = new Date(year, month, 0).getDate(); - const first = `${year}-${String(month).padStart(2, '0')}-01`; - const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; - - const classrooms = await this.classroomRepo.find({ - where: { status: Not(ClassroomStatus.ARCHIVED) }, - order: { building: 'ASC', name: 'ASC' }, - }); - const rentals = await this.repo - .createQueryBuilder('r') - .leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization') - .leftJoinAndSelect('r.classroom', 'classroom') - .where('r.status IN (:...statuses)', { - statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED], - }) - .andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last }) - .getMany(); - - const organizationMap = new Map(); - const matrix: Record> = {}; - const summary: Record< - number, - { totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number } - > = {}; - - for (const cls of classrooms) { - matrix[cls.id] = {}; - summary[cls.id] = { totalDays: lastDay, rentedDays: 0, idleDays: lastDay, occupancyRate: 0 }; - } - - for (const rental of rentals) { - const start = new Date(rental.startDate); - const end = new Date(rental.endDate); - const monthStart = new Date(first); - const monthEnd = new Date(last); - const effStart = start < monthStart ? monthStart : start; - const effEnd = end > monthEnd ? monthEnd : end; - if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) { - organizationMap.set(rental.lesseeOrganization.id, { - id: rental.lesseeOrganization.id, - name: rental.lesseeOrganization.name, - color: - rental.lesseeOrganization.color || - COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length], - }); - } - for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) { - const day = d.getDate(); - if (!matrix[rental.classroomId]) continue; - matrix[rental.classroomId][day] = { - scheduleType: 'RENTAL', - rentalId: rental.id, - organizationId: rental.lesseeOrganizationId, - organizationName: rental.lesseeOrganization?.name || '未知', - color: - rental.lesseeOrganization?.color || - COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length], - hasContract: !!rental.contractPath, - }; - } - } - - // ── Overlay internal class schedules ── - const schedules = await this.scheduleRepo - .createQueryBuilder('s') - .leftJoinAndSelect('s.class', 'class') - .leftJoinAndSelect('s.teacher', 'teacher') - .where('s.status = :active', { active: 'active' }) - .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) - .andWhere('s.startDate <= :last AND s.endDate >= :first', { first, last }) - .getMany(); - - for (const sched of schedules) { - if (!sched.classroomId) continue; - const schedStart = new Date( - Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()), - ); - const schedEnd = new Date( - Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()), - ); - for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) { - const dow = d.getDay() === 0 ? 7 : d.getDay(); - if (dow !== sched.weekDay) continue; - const day = d.getDate(); - if (!matrix[sched.classroomId]) continue; - matrix[sched.classroomId][day] = { - scheduleType: 'INTERNAL', - scheduleId: sched.id, - className: (sched.class as any)?.name || '', - subject: sched.subject, - teacherName: (sched.teacher as any)?.name || '', - startTime: sched.startTime, - endTime: sched.endTime, - color: '#52c41a', - }; - } - } - // 统计 - for (const cls of classrooms) { - const rented = Object.keys(matrix[cls.id]).length; - summary[cls.id].rentedDays = rented; - summary[cls.id].idleDays = lastDay - rented; - summary[cls.id].occupancyRate = lastDay > 0 ? Math.round((rented / lastDay) * 100) / 100 : 0; - } - - return { - year, - month, - days: lastDay, - classrooms: classrooms.map((c) => ({ - id: c.id, - name: c.name, - building: c.building, - floor: c.floor, - roomType: c.roomType, - capacity: c.capacity, - })), - organizations: Array.from(organizationMap.values()), - matrix, - summary, - }; - } } diff --git a/apps/server/src/classroom-rentals/rental-schedule.service.ts b/apps/server/src/classroom-rentals/rental-schedule.service.ts new file mode 100644 index 0000000..c1d6a2d --- /dev/null +++ b/apps/server/src/classroom-rentals/rental-schedule.service.ts @@ -0,0 +1,341 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { ClassroomRental, Classroom, ClassSchedule, ClassroomStatus } from '../entities'; +import { ClassroomRentalStatus } from '../entities/classroom-rental.entity'; + +const COLOR_PALETTE = [ + "#5B8FF9", + "#61DDAA", + "#65789B", + "#F6BD16", + "#7262FD", + "#78D3F8", + "#9661BC", + "#F6903D", + "#008685", + "#F08BB4" +]; + +@Injectable() +export class RentalScheduleService { + constructor( + @InjectRepository(ClassroomRental) private repo: Repository, + @InjectRepository(Classroom) private classroomRepo: Repository, + @InjectRepository(ClassSchedule) private scheduleRepo: Repository, + ) {} + + async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) { + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + const monthStart = `${year}-${String(month).padStart(2, '0')}-01`; + const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + + const [rentals, schedules] = await Promise.all([ + this.repo.find({ + where: { + ...(excludeId ? { id: Not(excludeId) } : {}), + classroomId, + status: ClassroomRentalStatus.ACTIVE, + startDate: LessThanOrEqual(monthEnd), + endDate: MoreThanOrEqual(monthStart), + }, + }), + this.scheduleRepo.find({ + where: { + classroomId, + status: ClassroomRentalStatus.ACTIVE, + scheduleType: 'INTERNAL', + startDate: LessThanOrEqual(monthEnd), + endDate: MoreThanOrEqual(monthStart), + }, + }), + ]); + + const unavailableDates = new Set(); + for (const rental of rentals) { + this.addDateRange( + unavailableDates, + rental.startDate > monthStart ? rental.startDate : monthStart, + rental.endDate < monthEnd ? rental.endDate : monthEnd, + ); + } + for (const schedule of schedules) { + this.addScheduleOccurrences(unavailableDates, schedule, monthStart, monthEnd); + } + + return { dates: Array.from(unavailableDates).sort() }; + } + + /** + * 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课 + * 重叠判定:start1 <= end2 AND start2 <= end1 + */ + async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) { + const qb = this.repo + .createQueryBuilder('r') + .leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization') + .where('r.classroomId = :cid', { cid: classroomId }) + .andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE }) + .andWhere('r.startDate <= :end', { end: endDate }) + .andWhere('r.endDate >= :start', { start: startDate }); + if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId }); + const rentals = await qb.getMany(); + + // 检测同一教室同一日期段是否存在内部排课 + const scheduleCandidates = await this.scheduleRepo + .createQueryBuilder('cs') + .where('cs.classroomId = :cid', { cid: classroomId }) + .andWhere('cs.status = :status', { status: 'active' }) + .andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' }) + .andWhere('cs.startDate <= :end', { end: endDate }) + .andWhere('cs.endDate >= :start', { start: startDate }) + .getMany(); + const scheduleConflicts = scheduleCandidates.filter((schedule) => + this.hasScheduleOccurrence(schedule, startDate, endDate), + ); + + if (scheduleConflicts.length > 0) { + throw new ConflictException({ + message: '该教室在此时间段已有排课', + conflicts: scheduleConflicts.map((s) => ({ + id: s.id, + startDate: s.startDate, + endDate: s.endDate, + organizationName: `[内部排课] ${s.subject}`, + })), + }); + } + + return rentals; + } + + private hasScheduleOccurrence( + schedule: ClassSchedule, + startDate: string, + endDate: string, + ): boolean { + const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate; + const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; + if (overlapStart > overlapEnd) return false; + + const startUtc = this.toUtcDate(overlapStart); + const endUtc = this.toUtcDate(overlapEnd); + const startWeekDay = startUtc.getUTCDay() || 7; + const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7; + startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence); + return startUtc <= endUtc; + } + + private toUtcDate(date: string): Date { + const [year, month, day] = date.split('-').map(Number); + return new Date(Date.UTC(year, month - 1, day)); + } + + private addDateRange(dates: Set, startDate: string, endDate: string) { + const current = this.toUtcDate(startDate); + const end = this.toUtcDate(endDate); + while (current <= end) { + dates.add(current.toISOString().slice(0, 10)); + current.setUTCDate(current.getUTCDate() + 1); + } + } + + private addScheduleOccurrences( + dates: Set, + schedule: ClassSchedule, + startDate: string, + endDate: string, + ) { + const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate; + const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; + if (overlapStart > overlapEnd) return; + + const current = this.toUtcDate(overlapStart); + const end = this.toUtcDate(overlapEnd); + const startWeekDay = current.getUTCDay() || 7; + current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7)); + while (current <= end) { + dates.add(current.toISOString().slice(0, 10)); + current.setUTCDate(current.getUTCDate() + 7); + } + } + + + async getSchedule(year: number, month: number) { + const lastDay = new Date(year, month, 0).getDate(); + const first = `${year}-${String(month).padStart(2, '0')}-01`; + const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + + const classrooms = await this.classroomRepo.find({ + where: { status: Not(ClassroomStatus.ARCHIVED) }, + order: { building: 'ASC', name: 'ASC' }, + }); + const rentals = await this.repo + .createQueryBuilder('r') + .leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization') + .leftJoinAndSelect('r.classroom', 'classroom') + .where('r.status IN (:...statuses)', { + statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED], + }) + .andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last }) + .getMany(); + + const organizationMap = new Map(); + const matrix: Record> = {}; + const summary: Record< + number, + { totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number } + > = {}; + + for (const cls of classrooms) { + matrix[cls.id] = {}; + summary[cls.id] = { totalDays: lastDay, rentedDays: 0, idleDays: lastDay, occupancyRate: 0 }; + } + + for (const rental of rentals) { + const start = new Date(rental.startDate); + const end = new Date(rental.endDate); + const monthStart = new Date(first); + const monthEnd = new Date(last); + const effStart = start < monthStart ? monthStart : start; + const effEnd = end > monthEnd ? monthEnd : end; + if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) { + organizationMap.set(rental.lesseeOrganization.id, { + id: rental.lesseeOrganization.id, + name: rental.lesseeOrganization.name, + color: + rental.lesseeOrganization.color || + COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length], + }); + } + for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) { + const day = d.getDate(); + if (!matrix[rental.classroomId]) continue; + matrix[rental.classroomId][day] = { + scheduleType: 'RENTAL', + rentalId: rental.id, + organizationId: rental.lesseeOrganizationId, + organizationName: rental.lesseeOrganization?.name || '未知', + color: + rental.lesseeOrganization?.color || + COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length], + hasContract: !!rental.contractPath, + }; + } + } + + // ── Overlay internal class schedules ── + const schedules = await this.scheduleRepo + .createQueryBuilder('s') + .leftJoinAndSelect('s.class', 'class') + .leftJoinAndSelect('s.teacher', 'teacher') + .where('s.status = :active', { active: 'active' }) + .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) + .andWhere('s.startDate <= :last AND s.endDate >= :first', { first, last }) + .getMany(); + + for (const sched of schedules) { + if (!sched.classroomId) continue; + const schedStart = new Date( + Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()), + ); + const schedEnd = new Date( + Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()), + ); + for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) { + const dow = d.getDay() === 0 ? 7 : d.getDay(); + if (dow !== sched.weekDay) continue; + const day = d.getDate(); + if (!matrix[sched.classroomId]) continue; + matrix[sched.classroomId][day] = { + scheduleType: 'INTERNAL', + scheduleId: sched.id, + className: (sched.class as { name?: string } | null)?.name || '', + subject: sched.subject, + teacherName: (sched.teacher as { name?: string } | null)?.name || '', + startTime: sched.startTime, + endTime: sched.endTime, + color: '#52c41a', + }; + } + } + // 统计 + for (const cls of classrooms) { + const rented = Object.keys(matrix[cls.id]).length; + summary[cls.id].rentedDays = rented; + summary[cls.id].idleDays = lastDay - rented; + summary[cls.id].occupancyRate = lastDay > 0 ? Math.round((rented / lastDay) * 100) / 100 : 0; + } + + return { + year, + month, + days: lastDay, + classrooms: classrooms.map((c) => ({ + id: c.id, + name: c.name, + building: c.building, + floor: c.floor, + roomType: c.roomType, + capacity: c.capacity, + })), + organizations: Array.from(organizationMap.values()), + matrix, + summary, + }; + } + withEffectiveStatus(rental: ClassroomRental) { + const today = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date()); + const effectiveStatus = + rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today + ? ClassroomRentalStatus.ENDED + : rental.status; + return Object.assign(rental, { effectiveStatus }); + } + + /** + * 同步租赁订单到 class_schedules(schedule_type = 'RENTAL') + */ + + async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) { + const name = organizationName || rental.lesseeOrganization?.name || '承租机构'; + const weekDay = this.dateToWeekDay(rental.startDate); + let schedule = await this.scheduleRepo.findOne({ + where: { rentalId: rental.id, scheduleType: 'RENTAL' }, + }); + const data = { + classroomId: rental.classroomId, + classId: null, + weekDay, + startTime: '00:00', + endTime: '23:59', + startDate: rental.startDate, + endDate: rental.endDate, + subject: `${name} 租赁`, + teacherId: null, + scheduleType: 'RENTAL', + rentalId: rental.id, + status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active', + notes: rental.notes, + }; + if (schedule) { + await this.scheduleRepo.update(schedule.id, data); + } else { + schedule = this.scheduleRepo.create(data); + await this.scheduleRepo.save(schedule); + } + } + + + dateToWeekDay(date: string): number { + const d = new Date(date); + const day = d.getDay(); + return day === 0 ? 7 : day; + } + +} diff --git a/apps/server/src/classrooms/classrooms.controller.ts b/apps/server/src/classrooms/classrooms.controller.ts index 1422ef9..92b9662 100644 --- a/apps/server/src/classrooms/classrooms.controller.ts +++ b/apps/server/src/classrooms/classrooms.controller.ts @@ -19,6 +19,7 @@ import { ClassroomsService } from './classrooms.service'; import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { logAudit } from '../common/with-audit-log'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import * as ExcelJS from 'exceljs'; @@ -104,18 +105,9 @@ export class ClassroomsController { @Post() @RequirePermission('classroom:create') async create(@Body() dto: CreateClassroomDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室', - action: '新增教室', - targetId: result.id, - targetType: 'classroom', - detail: dto.name, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室', action: '新增教室', targetId: result.id, targetType: 'classroom', detail: dto.name, }); return result; } @@ -123,18 +115,9 @@ export class ClassroomsController { @Put(':id') @RequirePermission('classroom:edit') async update(@Param('id') id: string, @Body() dto: UpdateClassroomDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室', - action: '编辑教室', - targetId: +id, - targetType: 'classroom', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室', action: '编辑教室', targetId: +id, targetType: 'classroom', detail: JSON.stringify(dto), }); return result; } @@ -142,17 +125,19 @@ export class ClassroomsController { @Delete(':id') @RequirePermission('classroom:delete') async remove(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室', - action: '归档教室', - targetId: +id, - targetType: 'classroom', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室', action: '归档教室', targetId: +id, targetType: 'classroom', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('classroom:purge') + async purge(@Param('id') id: string, @Request() req: any) { + const result = await this.service.purge(+id); + await logAudit(this.logService, req, { + module: '教室', action: '永久删除教室', targetId: +id, targetType: 'classroom', detail: '物理删除,不可恢复', }); return result; } @@ -160,17 +145,9 @@ export class ClassroomsController { @Put(':id/restore') @RequirePermission('classroom:edit') async restore(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.restore(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教室', - action: '恢复教室', - targetId: +id, - targetType: 'classroom', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教室', action: '恢复教室', targetId: +id, targetType: 'classroom', }); return result; } @@ -181,7 +158,7 @@ export class ClassroomsController { async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as any); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const ws = workbook.worksheets[0]; const rows: any[] = []; ws.eachRow((row, idx) => { diff --git a/apps/server/src/classrooms/classrooms.module.ts b/apps/server/src/classrooms/classrooms.module.ts index 888edc4..5ed6ead 100644 --- a/apps/server/src/classrooms/classrooms.module.ts +++ b/apps/server/src/classrooms/classrooms.module.ts @@ -3,12 +3,16 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Classroom } from '../entities/classroom.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; +import { AttendanceDevice } from '../entities/attendance-device.entity'; import { ClassroomsService } from './classrooms.service'; import { ClassroomsController } from './classrooms.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; @Module({ - imports: [TypeOrmModule.forFeature([Classroom, ClassroomRental, ClassSchedule]), OperationLogsModule], + imports: [ + TypeOrmModule.forFeature([Classroom, ClassroomRental, ClassSchedule, AttendanceDevice]), + OperationLogsModule, + ], controllers: [ClassroomsController], providers: [ClassroomsService], exports: [ClassroomsService], diff --git a/apps/server/src/classrooms/classrooms.purge.controller.spec.ts b/apps/server/src/classrooms/classrooms.purge.controller.spec.ts new file mode 100644 index 0000000..ea2facd --- /dev/null +++ b/apps/server/src/classrooms/classrooms.purge.controller.spec.ts @@ -0,0 +1,23 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { ClassroomsController } from './classrooms.controller'; + +describe('ClassroomsController purge route', () => { + it('requires classroom:purge on permanent delete route', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, ClassroomsController.prototype.purge)).toEqual([ + 'classroom:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除教室(不可恢复)' }) }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new ClassroomsController(service as never, { log } as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge('1', req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '教室', action: '永久删除教室', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/classrooms/classrooms.purge.spec.ts b/apps/server/src/classrooms/classrooms.purge.spec.ts new file mode 100644 index 0000000..1a537a0 --- /dev/null +++ b/apps/server/src/classrooms/classrooms.purge.spec.ts @@ -0,0 +1,59 @@ +import { BadRequestException } from '@nestjs/common'; +import { ClassroomsService } from './classrooms.service'; + +describe('ClassroomsService.purge', () => { + const createService = (overrides?: { + classroom?: Record; + scheduleCount?: number; + rentalCount?: number; + deviceCount?: number; + }) => { + const classroom = { id: 1, name: '101教室', status: 'archived', ...overrides?.classroom }; + const repo = { + findOne: jest.fn().mockResolvedValue(classroom), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const scheduleRepo = { count: jest.fn().mockResolvedValue(overrides?.scheduleCount ?? 0) }; + const rentalRepo = { count: jest.fn().mockResolvedValue(overrides?.rentalCount ?? 0) }; + const deviceRepo = { count: jest.fn().mockResolvedValue(overrides?.deviceCount ?? 0) }; + const service = new ClassroomsService( + repo as never, + rentalRepo as never, + scheduleRepo as never, + deviceRepo as never, + ); + return { service, repo, scheduleRepo, rentalRepo, deviceRepo }; + }; + + it('rejects classrooms that are not archived', async () => { + const { service, repo } = createService({ classroom: { status: 'available' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档教室可以永久删除,请先归档'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects classrooms with schedules, rentals, or devices', async () => { + const withSchedule = createService({ scheduleCount: 1 }); + await expect(withSchedule.service.purge(1)).rejects.toThrow( + new BadRequestException('该教室存在排课记录,无法永久删除'), + ); + + const withRental = createService({ rentalCount: 1 }); + await expect(withRental.service.purge(1)).rejects.toThrow( + new BadRequestException('该教室存在租赁订单,无法永久删除'), + ); + + const withDevice = createService({ deviceCount: 1 }); + await expect(withDevice.service.purge(1)).rejects.toThrow( + new BadRequestException('该教室绑定了考勤机,无法永久删除'), + ); + expect(withDevice.repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived classroom with no references', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ message: '已永久删除教室(不可恢复)' }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/server/src/classrooms/classrooms.service.ts b/apps/server/src/classrooms/classrooms.service.ts index e01ba17..92339bd 100644 --- a/apps/server/src/classrooms/classrooms.service.ts +++ b/apps/server/src/classrooms/classrooms.service.ts @@ -4,6 +4,7 @@ import { Repository, Not, MoreThanOrEqual, Like } from 'typeorm'; import { Classroom, ClassroomStatus } from '../entities/classroom.entity'; import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; +import { AttendanceDevice } from '../entities/attendance-device.entity'; import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto'; @Injectable() @@ -12,6 +13,7 @@ export class ClassroomsService { @InjectRepository(Classroom) private repo: Repository, @InjectRepository(ClassroomRental) private rentalRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, + @InjectRepository(AttendanceDevice) private deviceRepo: Repository, ) {} async findAll(query?: { building?: string; roomType?: string; includeArchived?: boolean }) { @@ -113,6 +115,24 @@ export class ClassroomsService { return this.repo.findOne({ where: { id } }); } + async purge(id: number) { + const classroom = await this.repo.findOne({ where: { id } }); + if (!classroom) throw new NotFoundException('教室不存在'); + if (classroom.status !== ClassroomStatus.ARCHIVED) { + throw new BadRequestException('仅已归档教室可以永久删除,请先归档'); + } + const [scheduleCount, rentalCount, deviceCount] = await Promise.all([ + this.scheduleRepo.count({ where: { classroomId: id } }), + this.rentalRepo.count({ where: { classroomId: id } }), + this.deviceRepo.count({ where: { classroomId: id } }), + ]); + if (scheduleCount > 0) throw new BadRequestException('该教室存在排课记录,无法永久删除'); + if (rentalCount > 0) throw new BadRequestException('该教室存在租赁订单,无法永久删除'); + if (deviceCount > 0) throw new BadRequestException('该教室绑定了考勤机,无法永久删除'); + await this.repo.delete(id); + return { message: '已永久删除教室(不可恢复)' }; + } + private withEffectiveStatus( classroom: Classroom, usage?: { @@ -214,17 +234,23 @@ export class ClassroomsService { }; const weekDay = weekDayMap[shanghaiParts]; - const schedules = await this.scheduleRepo + const qb = this.scheduleRepo .createQueryBuilder('s') .leftJoin('Class', 'c', 'c.id = s.classId') - .select('s.classroomId', 'classroomId') - .addSelect('s.startTime', 'startTime') - .addSelect('s.endTime', 'endTime') - .addSelect('s.startDate', 'startDate') - .addSelect('s.endDate', 'endDate') - .addSelect('s.weekDay', 'weekDay') - .addSelect('s.subject', 'subject') - .addSelect('c.name', 'className') + .select('s.classroomId', 'classroomId'); + const scheduleSelects = [ + ['s.startTime', 'startTime'], + ['s.endTime', 'endTime'], + ['s.startDate', 'startDate'], + ['s.endDate', 'endDate'], + ['s.weekDay', 'weekDay'], + ['s.subject', 'subject'], + ['c.name', 'className'], + ] as const; + for (const [column, alias] of scheduleSelects) { + qb.addSelect(column, alias); + } + const schedules = await qb .where('s.classroomId IN (:...ids)', { ids: classroomIds }) .andWhere('s.status = :active', { active: 'active' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) diff --git a/apps/server/src/common/batch-restore.services.spec.ts b/apps/server/src/common/batch-restore.services.spec.ts index 21ff641..7e19adc 100644 --- a/apps/server/src/common/batch-restore.services.spec.ts +++ b/apps/server/src/common/batch-restore.services.spec.ts @@ -1,5 +1,13 @@ +function makeExpensesService( + a: never, b: never, c: never, d: never, e: never, f: never, +) { + const operations = new ExpenseOperationsService(a, b, c, d, e, f); + return new ExpensesService(a, b, c, d, e, f, operations); +} + import { BadRequestException, NotFoundException } from '@nestjs/common'; import { ExpensesService } from '../expenses/expenses.service'; +import { ExpenseOperationsService } from '../expenses/expense-operations.service'; import { OccupanciesService } from '../occupancies/occupancies.service'; import { RoomsService } from '../rooms/rooms.service'; import { StudentsService } from '../students/students.service'; @@ -41,7 +49,7 @@ describe('batch restore service semantics', () => { const rooms = new RoomsService( {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, ); - const expenses = new ExpensesService( + const expenses = makeExpensesService( {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, ); const occupancies = new OccupanciesService( @@ -129,7 +137,7 @@ describe('batch restore service semantics', () => { createQueryBuilder: jest.fn(() => qb), }; const billItemsRepo = { count: jest.fn().mockResolvedValue(1) }; - const service = new ExpensesService( + const service = makeExpensesService( roomExpRepo as never, {} as never, {} as never, @@ -150,7 +158,7 @@ describe('batch restore service semantics', () => { ]), createQueryBuilder: jest.fn(() => qb), }; - const service = new ExpensesService( + const service = makeExpensesService( roomExpRepo as never, {} as never, {} as never, {} as never, {} as never, { getRepository: jest.fn(() => ({ count: jest.fn().mockResolvedValue(0) })) } as never, ); @@ -168,7 +176,7 @@ describe('batch restore service semantics', () => { ]), createQueryBuilder: jest.fn(() => qb), }; - const service = new ExpensesService( + const service = makeExpensesService( roomExpRepo as never, {} as never, {} as never, {} as never, {} as never, { getRepository: jest.fn(() => ({ count })) } as never, ); @@ -185,7 +193,7 @@ describe('batch restore service semantics', () => { find: jest.fn().mockResolvedValue([{ id: 1, status: 'archived', billId: 9 }]), createQueryBuilder: jest.fn(), }; - const service = new ExpensesService( + const service = makeExpensesService( {} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never, ); await expect(service.batchRestorePersonalExpenses([1])).rejects.toBeInstanceOf(BadRequestException); @@ -201,7 +209,7 @@ describe('batch restore service semantics', () => { ]), createQueryBuilder: jest.fn(() => qb), }; - const service = new ExpensesService( + const service = makeExpensesService( {} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never, ); await expect(service.batchRestorePersonalExpenses([1, 1, 2])).resolves.toMatchObject({ @@ -220,7 +228,7 @@ describe('batch restore service semantics', () => { ]), createQueryBuilder: jest.fn(() => qb), }; - const service = new ExpensesService( + const service = makeExpensesService( {} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never, ); await expect(service.batchRestorePersonalExpenses([1, 2])).resolves.toMatchObject({ @@ -233,7 +241,7 @@ describe('batch restore service semantics', () => { it('uses archived status when querying expense archive views', async () => { const roomQb = listQb(); const personalRepo = { find: jest.fn().mockResolvedValue([]) }; - const service = new ExpensesService( + const service = makeExpensesService( { createQueryBuilder: jest.fn(() => roomQb) } as never, personalRepo as never, {} as never, {} as never, {} as never, {} as never, @@ -245,7 +253,7 @@ describe('batch restore service semantics', () => { }); it('rejects invalid expense query status values', async () => { - const service = new ExpensesService( + const service = makeExpensesService( {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, ); await expect(service.findRoomExpenses({ status: 'deleted' as never })).rejects.toBeInstanceOf(BadRequestException); diff --git a/apps/server/src/dashboard/dashboard-queries.service.ts b/apps/server/src/dashboard/dashboard-queries.service.ts new file mode 100644 index 0000000..5ac20f6 --- /dev/null +++ b/apps/server/src/dashboard/dashboard-queries.service.ts @@ -0,0 +1,241 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Occupancy } from '../entities/occupancy.entity'; +import { Bill } from '../entities/bill.entity'; +import { RoomExpense } from '../entities/room-expense.entity'; +import { AttendanceRecord } from '../entities/attendance-record.entity'; + +export function nextMonth(ym: string): string { + const d = new Date(`${ym}-01`); + d.setMonth(d.getMonth() + 1); + return d.toISOString().slice(0, 7) + '-01'; +} + +export function applyClassScope( + qb: { andWhere: (condition: string, parameters?: Record) => unknown }, + alias: string, + accessibleClassIds?: number[], +) { + if (accessibleClassIds) { + if (accessibleClassIds.length === 0) { + qb.andWhere('1 = 0'); + return; + } + qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds }); + } +} + +@Injectable() +export class DashboardQueriesService { + constructor( + @InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository, + @InjectRepository(Bill) private readonly billRepo: Repository, + @InjectRepository(Occupancy) private readonly occRepo: Repository, + @InjectRepository(RoomExpense) private readonly expRepo: Repository, + ) {} + +async getAttendanceTrend( + attendanceRepo: Repository, + todayStr: string, + accessibleClassIds?: number[], + ) { + const thirtyDaysAgo = new Date(todayStr); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29); + const startStr = thirtyDaysAgo.toISOString().slice(0, 10); + + const trendQb = attendanceRepo + .createQueryBuilder('a') + .select('a.attendanceDate', 'date') + .addSelect('a.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('a.attendanceDate >= :start', { start: startStr }) + .andWhere('a.attendanceDate <= :today', { today: todayStr }); + applyClassScope(trendQb, 'a', accessibleClassIds); + + const rows = await trendQb + .groupBy('a.attendanceDate') + .addGroupBy('a.status') + .orderBy('a.attendanceDate', 'ASC') + .getRawMany(); + + const dayMap = new Map(); + for (const row of rows) { + const d = dayMap.get(row.date) || { total: 0, present: 0 }; + const cnt = parseInt(row.count, 10); + d.total += cnt; + if (row.status === 'present') d.present += cnt; + dayMap.set(row.date, d); + } + + return Array.from(dayMap.entries()).map(([date, d]) => ({ + date, + rate: d.total > 0 ? ((d.present / d.total) * 100).toFixed(1) : 0, + })); +} + + +async getIncomeTrend( + billRepo: Repository, + currentMonth: string, + ) { + const results: { month: string; amount: number }[] = []; + + for (let i = 5; i >= 0; i--) { + const d = new Date(`${currentMonth}-01`); + d.setMonth(d.getMonth() - i); + const m = d.toISOString().slice(0, 7); + + const row = await billRepo + .createQueryBuilder('b') + .select('SUM(b.totalAmount)', 'total') + .where('b.status = :paid', { paid: 'paid' }) + .andWhere('b.periodStart >= :start', { start: `${m}-01` }) + .andWhere('b.periodStart < :end', { end: nextMonth(m) }) + .getRawOne(); + + results.push({ + month: m, + amount: parseFloat(row?.total || '0'), + }); + } + + return results; +} + + +// 甘特图数据:每个宿舍的入住时间线 + +async getGanttData( + occRepo: Repository, + assertPeriodRange: (start?: string, end?: string) => void, + query?: { periodStart?: string; periodEnd?: string; building?: string }, + ) { + assertPeriodRange(query?.periodStart, query?.periodEnd); + const qb = occRepo + .createQueryBuilder('o') + .leftJoinAndSelect('o.student', 'student') + .leftJoinAndSelect('o.room', 'room') + .where('room.status != :archived', { archived: 'archived' }) + .orderBy('room.roomNumber', 'ASC') + .addOrderBy('o.checkInDate', 'ASC'); + + if (query?.building) { + qb.andWhere('room.building = :building', { building: query.building }); + } + if (query?.periodStart) { + qb.andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :ps)', { ps: query.periodStart }); + } + if (query?.periodEnd) { + qb.andWhere('o.checkInDate <= :pe', { pe: query.periodEnd }); + } + + const records = await qb.getMany(); + + // 按宿舍分组 + const roomMap = new Map[]>(); + for (const r of records) { + const key = r.room?.roomNumber || String(r.roomId); + if (!roomMap.has(key)) roomMap.set(key, []); + roomMap.get(key)!.push({ + studentName: r.student?.name || '未知', + studentId: r.studentId, + checkInDate: r.checkInDate, + checkOutDate: r.checkOutDate, + billingStartDate: r.billingStartDate, + billingEndDate: r.billingEndDate, + }); + } + + return Array.from(roomMap.entries()).map(([roomNumber, occupancies]) => ({ + roomNumber, + occupancies, + })); +} +// 费用统计 + +async getExpenseStats( + expRepo: Repository, + assertPeriodRange: (start?: string, end?: string) => void, + periodStart?: string, + periodEnd?: string, + ) { + assertPeriodRange(periodStart, periodEnd); + const qb = expRepo + .createQueryBuilder('e') + .select('e.expenseType', 'type') + .addSelect('SUM(e.amount)', 'total') + .groupBy('e.expenseType'); + if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart }); + if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd }); + return qb.getRawMany(); +} + +// 各宿舍费用排行 + +async getRoomExpenseRanking( + expRepo: Repository, + assertPeriodRange: (start?: string, end?: string) => void, + periodStart?: string, + periodEnd?: string, + ) { + assertPeriodRange(periodStart, periodEnd); + const qb = expRepo + .createQueryBuilder('e') + .leftJoin('e.room', 'room') + .select('room.roomNumber', 'roomNumber') + .addSelect('SUM(e.amount)', 'total') + .where('room.status != :archived', { archived: 'archived' }) + .groupBy('e.roomId') + .orderBy('total', 'DESC') + .limit(20); + if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart }); + if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd }); + return qb.getRawMany(); +} + +// 班级考勤排行 + +async getClassAttendanceRanking( + attendanceRepo: Repository, + applyClassScope: ( + qb: { andWhere: (condition: string, parameters?: Record) => unknown }, + alias: string, + accessibleClassIds?: number[], + ) => void, + accessibleClassIds?: number[], + ) { + if (accessibleClassIds?.length === 0) return { top: [], bottom: [] }; + const qb = attendanceRepo + .createQueryBuilder('a') + .leftJoin('a.class', 'class') + .select('class.id', 'classId') + .addSelect('class.name', 'className') + .addSelect('a.status', 'status') + .addSelect('COUNT(*)', 'count'); + applyClassScope(qb, 'a', accessibleClassIds); + qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status'); + const raw = await qb.getRawMany(); + + const classMap = new Map(); + for (const r of raw) { + if (!r.classId) continue; + if (!classMap.has(Number(r.classId))) + classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 }); + const entry = classMap.get(Number(r.classId))!; + const n = parseInt(r.count, 10); + entry.total += n; + if (r.status === 'present') entry.present += n; + } + + const ranked = Array.from(classMap.values()) + .map((e) => ({ + ...e, + rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0, + })) + .sort((a, b) => b.rate - a.rate); + + return { top: ranked.slice(0, 5), bottom: ranked.slice(-5).reverse() }; +} + +} diff --git a/apps/server/src/dashboard/dashboard.module.ts b/apps/server/src/dashboard/dashboard.module.ts index 1805842..6171b92 100644 --- a/apps/server/src/dashboard/dashboard.module.ts +++ b/apps/server/src/dashboard/dashboard.module.ts @@ -14,6 +14,7 @@ import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; import { ClassStudent } from '../entities/class-student.entity'; import { DashboardService } from './dashboard.service'; +import { DashboardQueriesService } from './dashboard-queries.service'; import { DashboardController } from './dashboard.controller'; @Module({ @@ -35,7 +36,7 @@ import { DashboardController } from './dashboard.controller'; ]), ], controllers: [DashboardController], - providers: [DashboardService], + providers: [DashboardService, DashboardQueriesService], exports: [DashboardService], }) export class DashboardModule {} diff --git a/apps/server/src/dashboard/dashboard.scope.spec.ts b/apps/server/src/dashboard/dashboard.scope.spec.ts index a76bba1..302a528 100644 --- a/apps/server/src/dashboard/dashboard.scope.spec.ts +++ b/apps/server/src/dashboard/dashboard.scope.spec.ts @@ -1,4 +1,8 @@ import { DashboardService } from './dashboard.service'; +import { DashboardQueriesService } from './dashboard-queries.service'; + +const queriesService = (attendanceRepo?: unknown) => + new DashboardQueriesService(attendanceRepo as never, {} as never, {} as never, {} as never); const createQb = () => ({ leftJoin: jest.fn().mockReturnThis(), @@ -32,7 +36,7 @@ describe('DashboardService — teacher class scope', () => { {} as never, {} as never, {} as never, - {}, + queriesService(attendanceRepo), ); await service.getClassAttendanceRanking([8, 9]); @@ -51,6 +55,7 @@ describe('DashboardService — boundary conditions', () => { {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, attendanceRepo as never, {} as never, {} as never, {} as never, {} as never, {} as never, + queriesService(attendanceRepo), ); await (service as unknown as { @@ -69,6 +74,7 @@ describe('DashboardService — boundary conditions', () => { {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + queriesService(), ); await expect((service[method] as (...values: never[]) => Promise)(...(args as never[]))) .rejects.toThrow('结束日期不能早于开始日期'); @@ -79,6 +85,7 @@ describe('DashboardService — boundary conditions', () => { {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + queriesService(), ); expect((service as unknown as { getChinaDate: (date: Date) => string }) .getChinaDate(new Date('2026-07-13T16:30:00.000Z'))).toBe('2026-07-14'); diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index 04f4a38..dbaae9b 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -14,6 +14,7 @@ import { Deposit } from '../entities/deposit.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; import { ClassStudent } from '../entities/class-student.entity'; +import { DashboardQueriesService } from './dashboard-queries.service'; interface AgentAttendanceStatusRow { status: string; @@ -36,6 +37,7 @@ export class DashboardService { @InjectRepository(ClassroomRental) private rentalRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(ClassStudent) private classStudentRepo: Repository, + private readonly queries: DashboardQueriesService, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { @@ -50,24 +52,39 @@ export class DashboardService { const totalStudents = accessibleClassIds ? await this.countStudentsInClasses(accessibleClassIds) : await this.studentRepo.count({ where: { status: 'active' } }); - const classCount = accessibleClassIds ? accessibleClassIds.length : await this.classRepo.count({ where: { isArchived: false } }); + const classCount = accessibleClassIds + ? accessibleClassIds.length + : await this.classRepo.count({ where: { isArchived: false } }); const attendanceQb = this.attendanceRepo .createQueryBuilder('attendance') .select('attendance.status', 'status') .addSelect('COUNT(attendance.id)', 'count') .where('attendance.attendanceDate = :today', { today }); this.applyClassScope(attendanceQb, 'attendance', accessibleClassIds); - const rows = await attendanceQb.groupBy('attendance.status').getRawMany(); - const attendanceByStatus = rows.reduce((result, row) => { - result[String(row.status)] = Number(row.count || 0); - return result; - }, {} as Record); + const rows = await attendanceQb + .groupBy('attendance.status') + .getRawMany(); + const attendanceByStatus = rows.reduce( + (result, row) => { + result[String(row.status)] = Number(row.count || 0); + return result; + }, + {} as Record, + ); const attendanceTotal = Object.values(attendanceByStatus).reduce( (sum, count) => sum + Number(count), 0, ); const present = attendanceByStatus.present ?? 0; - return { date: today, totalStudents, classCount, attendanceTotal, present, attendanceRate: attendanceTotal ? Number(((present / attendanceTotal) * 100).toFixed(1)) : 0, attendanceByStatus }; + return { + date: today, + totalStudents, + classCount, + attendanceTotal, + present, + attendanceRate: attendanceTotal ? Number(((present / attendanceTotal) * 100).toFixed(1)) : 0, + attendanceByStatus, + }; } async getStats(accessibleClassIds?: number[]) { @@ -117,12 +134,9 @@ export class DashboardService { this.applyClassScope(attTodayQb, 'a', accessibleClassIds); attTodayQb.groupBy('a.status'); const attTodayStats = await attTodayQb.getRawMany(); - const todayTotal = attTodayStats.reduce((sum, r) => sum + parseInt(r.count, 10), 0); const todayPresent = attTodayStats .filter((r) => r.status === 'present') .reduce((sum, r) => sum + parseInt(r.count, 10), 0); - const todayAttendanceRate = todayTotal > 0 ? ((todayPresent / todayTotal) * 100).toFixed(1) : 0; - const incomeQb = this.billRepo .createQueryBuilder('b') .select('SUM(b.totalAmount)', 'total') @@ -135,7 +149,6 @@ export class DashboardService { const attendanceTrend = await this.getAttendanceTrend(todayStr, accessibleClassIds); const incomeTrend = await this.getIncomeTrend(currentMonth); - // --- New stats --- const classCount = accessibleClassIds ? accessibleClassIds.length : await this.classRepo.count({ where: {} }); @@ -226,64 +239,32 @@ export class DashboardService { return new Set(classStudents.map((item) => item.studentId)).size; } - private async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) { - const thirtyDaysAgo = new Date(todayStr); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29); - const startStr = thirtyDaysAgo.toISOString().slice(0, 10); - - const trendQb = this.attendanceRepo - .createQueryBuilder('a') - .select('a.attendanceDate', 'date') - .addSelect('a.status', 'status') - .addSelect('COUNT(*)', 'count') - .where('a.attendanceDate >= :start', { start: startStr }) - .andWhere('a.attendanceDate <= :today', { today: todayStr }); - this.applyClassScope(trendQb, 'a', accessibleClassIds); - - const rows = await trendQb - .groupBy('a.attendanceDate') - .addGroupBy('a.status') - .orderBy('a.attendanceDate', 'ASC') - .getRawMany(); - - const dayMap = new Map(); - for (const row of rows) { - const d = dayMap.get(row.date) || { total: 0, present: 0 }; - const cnt = parseInt(row.count, 10); - d.total += cnt; - if (row.status === 'present') d.present += cnt; - dayMap.set(row.date, d); - } - - return Array.from(dayMap.entries()).map(([date, d]) => ({ - date, - rate: d.total > 0 ? ((d.present / d.total) * 100).toFixed(1) : 0, - })); + async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) { + return this.queries.getAttendanceTrend(this.attendanceRepo, todayStr, accessibleClassIds); } - private async getIncomeTrend(currentMonth: string) { - const results: { month: string; amount: number }[] = []; + async getIncomeTrend(currentMonth: string) { + return this.queries.getIncomeTrend(this.billRepo, currentMonth); + } - for (let i = 5; i >= 0; i--) { - const d = new Date(`${currentMonth}-01`); - d.setMonth(d.getMonth() - i); - const m = d.toISOString().slice(0, 7); + async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) { + return this.queries.getGanttData(this.occRepo, (a, b) => this.assertPeriodRange(a, b), query); + } - const row = await this.billRepo - .createQueryBuilder('b') - .select('SUM(b.totalAmount)', 'total') - .where('b.status = :paid', { paid: 'paid' }) - .andWhere('b.periodStart >= :start', { start: `${m}-01` }) - .andWhere('b.periodStart < :end', { end: this.nextMonth(m) }) - .getRawOne(); + async getExpenseStats(periodStart?: string, periodEnd?: string) { + return this.queries.getExpenseStats(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd); + } - results.push({ - month: m, - amount: parseFloat(row?.total || '0'), - }); - } + async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) { + return this.queries.getRoomExpenseRanking(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd); + } - return results; + async getClassAttendanceRanking(accessibleClassIds?: number[]) { + return this.queries.getClassAttendanceRanking( + this.attendanceRepo, + (qb, alias, ids) => this.applyClassScope(qb, alias, ids), + accessibleClassIds, + ); } private nextMonth(ym: string): string { @@ -292,114 +273,6 @@ export class DashboardService { return d.toISOString().slice(0, 7) + '-01'; } - // 甘特图数据:每个宿舍的入住时间线 - async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) { - this.assertPeriodRange(query?.periodStart, query?.periodEnd); - const qb = this.occRepo - .createQueryBuilder('o') - .leftJoinAndSelect('o.student', 'student') - .leftJoinAndSelect('o.room', 'room') - .where('room.status != :archived', { archived: 'archived' }) - .orderBy('room.roomNumber', 'ASC') - .addOrderBy('o.checkInDate', 'ASC'); - - if (query?.building) { - qb.andWhere('room.building = :building', { building: query.building }); - } - if (query?.periodStart) { - qb.andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :ps)', { ps: query.periodStart }); - } - if (query?.periodEnd) { - qb.andWhere('o.checkInDate <= :pe', { pe: query.periodEnd }); - } - - const records = await qb.getMany(); - - // 按宿舍分组 - const roomMap = new Map[]>(); - for (const r of records) { - const key = r.room?.roomNumber || String(r.roomId); - if (!roomMap.has(key)) roomMap.set(key, []); - roomMap.get(key)!.push({ - studentName: r.student?.name || '未知', - studentId: r.studentId, - checkInDate: r.checkInDate, - checkOutDate: r.checkOutDate, - billingStartDate: r.billingStartDate, - billingEndDate: r.billingEndDate, - }); - } - - return Array.from(roomMap.entries()).map(([roomNumber, occupancies]) => ({ - roomNumber, - occupancies, - })); - } - // 费用统计 - async getExpenseStats(periodStart?: string, periodEnd?: string) { - this.assertPeriodRange(periodStart, periodEnd); - const qb = this.expRepo - .createQueryBuilder('e') - .select('e.expenseType', 'type') - .addSelect('SUM(e.amount)', 'total') - .groupBy('e.expenseType'); - if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart }); - if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd }); - return qb.getRawMany(); - } - - // 各宿舍费用排行 - async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) { - this.assertPeriodRange(periodStart, periodEnd); - const qb = this.expRepo - .createQueryBuilder('e') - .leftJoin('e.room', 'room') - .select('room.roomNumber', 'roomNumber') - .addSelect('SUM(e.amount)', 'total') - .where('room.status != :archived', { archived: 'archived' }) - .groupBy('e.roomId') - .orderBy('total', 'DESC') - .limit(20); - if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart }); - if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd }); - return qb.getRawMany(); - } - - // 班级考勤排行 - async getClassAttendanceRanking(accessibleClassIds?: number[]) { - if (accessibleClassIds?.length === 0) return { top: [], bottom: [] }; - const qb = this.attendanceRepo - .createQueryBuilder('a') - .leftJoin('a.class', 'class') - .select('class.id', 'classId') - .addSelect('class.name', 'className') - .addSelect('a.status', 'status') - .addSelect('COUNT(*)', 'count'); - this.applyClassScope(qb, 'a', accessibleClassIds); - qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status'); - const raw = await qb.getRawMany(); - - const classMap = new Map(); - for (const r of raw) { - if (!r.classId) continue; - if (!classMap.has(Number(r.classId))) - classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 }); - const entry = classMap.get(Number(r.classId))!; - const n = parseInt(r.count, 10); - entry.total += n; - if (r.status === 'present') entry.present += n; - } - - const ranked = Array.from(classMap.values()) - .map((e) => ({ - ...e, - rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0, - })) - .sort((a, b) => b.rate - a.rate); - - return { top: ranked.slice(0, 5), bottom: ranked.slice(-5).reverse() }; - } - async getClassroomOccupancy() { const classrooms = await this.classroomRepo.find({ where: { status: 'available' as const }, diff --git a/apps/server/src/deposits/deposits.controller.ts b/apps/server/src/deposits/deposits.controller.ts index 71fa817..060300e 100644 --- a/apps/server/src/deposits/deposits.controller.ts +++ b/apps/server/src/deposits/deposits.controller.ts @@ -25,7 +25,7 @@ import { } from './dto/deposit.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; @UseGuards(JwtAuthGuard) @@ -78,31 +78,11 @@ export class DepositsController { @Post() @RequirePermission('deposit:create') async create(@Body() dto: CreateDepositDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '收取押金', - targetId: result.id, - targetType: 'deposit', - detail: `学生${dto.studentId} ¥${dto.amount}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '收取押金', targetId: result.id, targetType: 'deposit', detail: `学生${dto.studentId} ¥${dto.amount}`, }); - // Send deposit_due notification - try { - const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); - if (student?.userId) { - void this.notificationsService.create({ - recipientIds: [student.userId], - type: 'deposit_due', - title: '押金待缴', - content: `您有一笔押金待缴纳,金额: ¥${dto.amount}`, - }); - } - } catch (_) { /* don't block response */ } + await this.notifyDeposit(dto.studentId, 'deposit_due', '押金待缴', `您有一笔押金待缴纳,金额: ¥${dto.amount}`); return result; } @@ -110,17 +90,9 @@ export class DepositsController { @Post('batch') @RequirePermission('deposit:create') async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchCreate(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '批量收取押金', - targetType: 'deposit', - detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '批量收取押金', targetType: 'deposit', detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`, }); return result; } @@ -132,18 +104,9 @@ export class DepositsController { @Body() body: CreateDepositInstallmentDto, @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.addInstallment(id, body.amount, body.dueDate); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '新增分期', - targetId: result.id, - targetType: 'deposit-installment', - detail: `押金${id} 新增分期 ¥${result.amount}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '新增分期', targetId: result.id, targetType: 'deposit-installment', detail: `押金${id} 新增分期 ¥${result.amount}`, }); return result; } @@ -155,18 +118,9 @@ export class DepositsController { @Body() body: UpdateDepositInstallmentDto, @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.updateInstallment(installmentId, body); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '更新分期', - targetId: installmentId, - targetType: 'deposit-installment', - detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '更新分期', targetId: installmentId, targetType: 'deposit-installment', detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`, }); return result; } @@ -177,18 +131,9 @@ export class DepositsController { @Param('installmentId', ParseIntPipe) installmentId: number, @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.deleteInstallment(installmentId); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '归档分期', - targetId: installmentId, - targetType: 'deposit-installment', - detail: `归档分期${installmentId}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '归档分期', targetId: installmentId, targetType: 'deposit-installment', detail: `归档分期${installmentId}`, }); return result; } @@ -196,48 +141,46 @@ export class DepositsController { @Put(':id/refund') @RequirePermission('deposit:refund') async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.refund(id, dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '退还押金', - targetId: id, - targetType: 'deposit', - detail: `退还全部可用押金 ¥${result.refundAmount}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '退还押金', targetId: id, targetType: 'deposit', detail: `退还全部可用押金 ¥${result.refundAmount}`, }); - // Send deposit_refunded notification - try { - const student = await this.studentRepo.findOne({ where: { id: result.studentId } }); - if (student?.userId) { - void this.notificationsService.create({ - recipientIds: [student.userId], - type: 'deposit_refunded', - title: '押金已退还', - content: `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`, - }); - } - } catch (_) { /* don't block response */ } + await this.notifyDeposit(result.studentId, 'deposit_refunded', '押金已退还', `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`); return result; } + private async notifyDeposit( + studentId: number, + type: 'deposit_due' | 'deposit_refunded', + title: string, + content: string, + ): Promise { + try { + const student = await this.studentRepo.findOne({ where: { id: studentId } }); + if (student?.userId) { + void this.notificationsService.create({ recipientIds: [student.userId], type, title, content }); + } + } catch { + // 通知失败不影响主流程 + } + } + @Delete(':id') @RequirePermission('deposit:delete') async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '押金管理', - action: '归档押金记录', - targetId: id, - targetType: 'deposit', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '押金管理', action: '归档押金记录', targetId: id, targetType: 'deposit', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('deposit:purge') + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + const result = await this.service.purge(id); + await logAudit(this.logService, req, { + module: '押金管理', action: '永久删除押金', targetId: id, targetType: 'deposit', detail: '物理删除,不可恢复', }); return result; } diff --git a/apps/server/src/deposits/deposits.purge.controller.spec.ts b/apps/server/src/deposits/deposits.purge.controller.spec.ts new file mode 100644 index 0000000..ee54720 --- /dev/null +++ b/apps/server/src/deposits/deposits.purge.controller.spec.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { DepositsController } from './deposits.controller'; + +describe('DepositsController purge route', () => { + it('requires deposit:purge on permanent delete route', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, DepositsController.prototype.purge)).toEqual([ + 'deposit:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除押金(不可恢复)' }) }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new DepositsController( + service as never, + { log } as never, + {} as never, + {} as never, + ); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge(1, req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '押金管理', action: '永久删除押金', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/deposits/deposits.purge.spec.ts b/apps/server/src/deposits/deposits.purge.spec.ts new file mode 100644 index 0000000..e8ab93f --- /dev/null +++ b/apps/server/src/deposits/deposits.purge.spec.ts @@ -0,0 +1,65 @@ +import { BadRequestException } from '@nestjs/common'; +import { DepositsService } from './deposits.service'; + +describe('DepositsService.purge', () => { + const createService = (overrides?: { deposit?: Record }) => { + const deposit = { + id: 1, + studentId: 2, + amount: 500, + status: 'archived', + refundAmount: null, + deductionAmount: 0, + ...overrides?.deposit, + }; + const repo = { + findOne: jest.fn().mockResolvedValue(deposit), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const installmentRepo = { count: jest.fn().mockResolvedValue(0) }; + const service = new DepositsService( + repo as never, + installmentRepo as never, + {} as never, + ); + return { service, repo, installmentRepo }; + }; + + it('rejects deposits that are not archived', async () => { + const { service, repo } = createService({ deposit: { status: 'paid' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档押金可以永久删除,请先归档'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects deposits with refund or deduction amounts', async () => { + const withRefund = createService({ deposit: { refundAmount: 100 } }); + await expect(withRefund.service.purge(1)).rejects.toThrow( + new BadRequestException('该押金已有退款金额,无法永久删除'), + ); + + const withDeduction = createService({ deposit: { deductionAmount: 50 } }); + await expect(withDeduction.service.purge(1)).rejects.toThrow( + new BadRequestException('该押金已有抵扣金额,无法永久删除'), + ); + expect(withDeduction.repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects deposits with paid installments', async () => { + const { service, installmentRepo, repo } = createService(); + installmentRepo.count.mockResolvedValue(1); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('该押金存在已支付分期,无法永久删除'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived deposit with no paid history', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ + message: '已永久删除押金(不可恢复)', + }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index daeef11..8dc741a 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -67,16 +67,21 @@ export class DepositsService { .leftJoin(Deposit, 'deposit', 'deposit.student_id = student.id AND deposit.status != :archived', { archived: 'archived', }) - .select('student.id', 'studentId') - .addSelect('student.name', 'studentName') - .addSelect('student.studentNo', 'studentNo') - .addSelect('room.id', 'roomId') - .addSelect('room.roomNumber', 'roomNumber') - .addSelect('room.building', 'building') - .addSelect('room.roomType', 'roomType') - .addSelect('room.capacity', 'capacity') - .addSelect('deposit.amount', 'depositAmount') - .where('o.status = :activeStatus', { activeStatus: 'active' }) + .select('student.id', 'studentId'); + const eligibleSelects = [ + ['student.name', 'studentName'], + ['student.studentNo', 'studentNo'], + ['room.id', 'roomId'], + ['room.roomNumber', 'roomNumber'], + ['room.building', 'building'], + ['room.roomType', 'roomType'], + ['room.capacity', 'capacity'], + ['deposit.amount', 'depositAmount'], + ] as const; + for (const [column, alias] of eligibleSelects) { + qb.addSelect(column, alias); + } + qb.where('o.status = :activeStatus', { activeStatus: 'active' }) .andWhere('o.checkOutDate IS NULL') .andWhere('student.status = :studentStatus', { studentStatus: 'active' }) .orderBy('room.building', 'ASC') @@ -167,15 +172,20 @@ export class DepositsService { const qb = this.repo .createQueryBuilder('d') .leftJoin('d.student', 'student') - .select('d.id', 'id') - .addSelect('student.name', 'studentName') - .addSelect('student.studentNo', 'studentNo') - .addSelect('d.amount', 'amount') - .addSelect('d.status', 'status') - .addSelect('d.paidDate', 'paidDate') - .addSelect('d.refundAmount', 'refundAmount') - .addSelect('d.refundDate', 'refundDate') - .where('d.status != :archived', { archived: 'archived' }); + .select('d.id', 'id'); + const depositSelects = [ + ['student.name', 'studentName'], + ['student.studentNo', 'studentNo'], + ['d.amount', 'amount'], + ['d.status', 'status'], + ['d.paidDate', 'paidDate'], + ['d.refundAmount', 'refundAmount'], + ['d.refundDate', 'refundDate'], + ] as const; + for (const [column, alias] of depositSelects) { + qb.addSelect(column, alias); + } + qb.where('d.status != :archived', { archived: 'archived' }); if (query?.keyword) { qb.andWhere( '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', @@ -226,8 +236,8 @@ export class DepositsService { existing.paidDate = dto.paidDate; existing.status = 'paid'; existing.recordedBy = userId ?? null; - existing.refundDate = null as unknown as string; - existing.refundAmount = null as unknown as number; + existing.refundDate = null; + existing.refundAmount = null; existing.refundedBy = null; existing.refundedAt = null; if (dto.notes) existing.notes = dto.notes; @@ -309,6 +319,28 @@ export class DepositsService { return { message: '已归档' }; } + async purge(id: number) { + const deposit = await this.repo.findOne({ where: { id } }); + if (!deposit) throw new NotFoundException('押金记录不存在'); + if (deposit.status !== 'archived') { + throw new BadRequestException('仅已归档押金可以永久删除,请先归档'); + } + if (Number(deposit.refundAmount || 0) > 0) { + throw new BadRequestException('该押金已有退款金额,无法永久删除'); + } + if (Number(deposit.deductionAmount || 0) > 0) { + throw new BadRequestException('该押金已有抵扣金额,无法永久删除'); + } + const paidInstallments = await this.installmentRepo.count({ + where: { depositId: id, status: 'paid' }, + }); + if (paidInstallments > 0) { + throw new BadRequestException('该押金存在已支付分期,无法永久删除'); + } + await this.repo.delete(id); + return { message: '已永久删除押金(不可恢复)' }; + } + async getStats() { const qb = this.repo .createQueryBuilder('d') diff --git a/apps/server/src/entities/class-schedule.entity.ts b/apps/server/src/entities/class-schedule.entity.ts index 0e7ca4d..abbd542 100644 --- a/apps/server/src/entities/class-schedule.entity.ts +++ b/apps/server/src/entities/class-schedule.entity.ts @@ -8,6 +8,8 @@ import { JoinColumn, Check, } from 'typeorm'; +import type { Class } from './class.entity'; +import type { User } from './user.entity'; export enum ScheduleType { INTERNAL = 'INTERNAL', @@ -26,7 +28,7 @@ export class ClassSchedule { // Forward reference — Class entity @ManyToOne('Class', { nullable: true }) @JoinColumn({ name: 'class_id' }) - class: unknown; + class: Class | null; @Column({ name: 'classroom_id', type: 'integer' }) classroomId: number; @@ -64,7 +66,7 @@ export class ClassSchedule { // Forward reference — User entity @ManyToOne('User', { nullable: true }) @JoinColumn({ name: 'teacher_id' }) - teacher: unknown; + teacher: User | null; @Column({ name: 'schedule_type', length: 20, default: 'INTERNAL' }) scheduleType: string; diff --git a/apps/server/src/entities/classroom-rental.entity.ts b/apps/server/src/entities/classroom-rental.entity.ts index fd86c8d..2b2edec 100644 --- a/apps/server/src/entities/classroom-rental.entity.ts +++ b/apps/server/src/entities/classroom-rental.entity.ts @@ -51,11 +51,11 @@ export class ClassroomRental { endDate: string; // 合同 PDF 相对路径(相对 UPLOAD_DIR),仅存文件名 - @Column({ name: 'contract_path', length: 255, nullable: true }) - contractPath: string; + @Column({ name: 'contract_path', type: 'varchar', length: 255, nullable: true }) + contractPath: string | null; - @Column({ name: 'contract_original_name', length: 255, nullable: true }) - contractOriginalName: string; + @Column({ name: 'contract_original_name', type: 'varchar', length: 255, nullable: true }) + contractOriginalName: string | null; @Column({ name: 'daily_rate', type: 'decimal', precision: 10, scale: 2, nullable: true }) dailyRate: number; diff --git a/apps/server/src/entities/deposit.entity.ts b/apps/server/src/entities/deposit.entity.ts index 4e3d203..ec533d7 100644 --- a/apps/server/src/entities/deposit.entity.ts +++ b/apps/server/src/entities/deposit.entity.ts @@ -29,10 +29,10 @@ export class Deposit { paidDate: string; @Column({ name: 'refund_date', type: 'date', nullable: true }) - refundDate: string; + refundDate: string | null; @Column({ name: 'refund_amount', type: 'decimal', precision: 10, scale: 2, nullable: true }) - refundAmount: number; + refundAmount: number | null; @Column({ name: 'deduction_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) deductionAmount: number; diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index 140e028..90f991b 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -53,3 +53,6 @@ export { AiForm, AiReview, } from '../ai-chat/entities'; +export { ImportRun } from '../imports/entities/import-run.entity'; +export { ImportStep } from '../imports/entities/import-step.entity'; +export { ImportRow } from '../imports/entities/import-row.entity'; diff --git a/apps/server/src/entities/room.entity.ts b/apps/server/src/entities/room.entity.ts index c5290b2..343b7a8 100644 --- a/apps/server/src/entities/room.entity.ts +++ b/apps/server/src/entities/room.entity.ts @@ -1,12 +1,4 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - OneToMany, - ManyToOne, - JoinColumn, -} from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany } from 'typeorm'; import { Occupancy } from './occupancy.entity'; import { RoomExpense } from './room-expense.entity'; diff --git a/apps/server/src/exams/exams.controller.spec.ts b/apps/server/src/exams/exams.controller.spec.ts index e2cb19c..724195a 100644 --- a/apps/server/src/exams/exams.controller.spec.ts +++ b/apps/server/src/exams/exams.controller.spec.ts @@ -60,4 +60,24 @@ describe('ExamsController batch archive and restore', () => { ['批量恢复考试', 'IDs: 3,4'], ]); }); + + it('requires exam:purge and writes permanent delete logs', async () => { + expect(Reflect.getMetadata(PERMISSION_KEY, ExamsController.prototype.purge)).toEqual([ + 'exam:purge', + ]); + expect( + Reflect.getMetadata(PERMISSION_KEY, ExamsController.prototype.batchPurge), + ).toEqual(['exam:purge']); + + const service = { + purge: jest.fn().mockResolvedValue({ message: '已永久删除考试(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new ExamsController(service as never, { log } as never); + await controller.purge(1, req); + expect(service.purge).toHaveBeenCalledWith(1, 7, true); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '考试管理', action: '永久删除考试', targetId: 1 }), + ); + }); }); diff --git a/apps/server/src/exams/exams.controller.ts b/apps/server/src/exams/exams.controller.ts index 610a882..cdcf084 100644 --- a/apps/server/src/exams/exams.controller.ts +++ b/apps/server/src/exams/exams.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Param, ParseIntPipe, @@ -14,7 +15,7 @@ import { } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../auth/decorators/permission.decorator'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { BatchIdsDto } from '../common/batch-ids.dto'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import type { AuthenticatedUser } from '../authorization'; @@ -55,15 +56,8 @@ export class ExamsController { req.user.id, this.canManageAll(req), ); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user.id, - username: req.user.username, - module: '考试管理', - action: '批量归档考试', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '考试管理', action: '批量归档考试', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -76,15 +70,8 @@ export class ExamsController { req.user.id, this.canManageAll(req), ); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user.id, - username: req.user.username, - module: '考试管理', - action: '批量恢复考试', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '考试管理', action: '批量恢复考试', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -99,17 +86,8 @@ export class ExamsController { @RequirePermission('exam:view') async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto, req.user.id, this.canManageAll(req)); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user.id, - username: req.user.username, - module: '考试管理', - action: '创建考试', - targetId: result.id, - targetType: 'exam', - detail: `${dto.examName} - ${dto.subject}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '考试管理', action: '创建考试', targetId: result.id, targetType: 'exam', detail: `${dto.examName} - ${dto.subject}`, }); return result; } @@ -121,16 +99,8 @@ export class ExamsController { @Request() req: AuthenticatedRequest, ) { const result = await this.service.archive(id, req.user.id, this.canManageAll(req)); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user.id, - username: req.user.username, - module: '考试管理', - action: '归档考试', - targetId: id, - targetType: 'exam', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '考试管理', action: '归档考试', targetId: id, targetType: 'exam', }); return result; } @@ -142,16 +112,35 @@ export class ExamsController { @Request() req: AuthenticatedRequest, ) { const result = await this.service.restore(id, req.user.id, this.canManageAll(req)); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user.id, - username: req.user.username, - module: '考试管理', - action: '恢复考试', - targetId: id, - targetType: 'exam', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '考试管理', action: '恢复考试', targetId: id, targetType: 'exam', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('exam:purge') + async purge( + @Param('id', ParseIntPipe) id: number, + @Request() req: AuthenticatedRequest, + ) { + const result = await this.service.purge(id, req.user.id, this.canManageAll(req)); + await logAudit(this.logService, req, { + module: '考试管理', action: '永久删除考试', targetId: id, targetType: 'exam', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('batch-permanent-delete') + @RequirePermission('exam:purge') + async batchPurge(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { + const result = await this.service.batchPurge( + dto.ids, + req.user.id, + this.canManageAll(req), + ); + await logAudit(this.logService, req, { + module: '考试管理', action: '批量永久删除考试', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -171,17 +160,8 @@ export class ExamsController { req.user.id, this.canManageAll(req), ); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user.id, - username: req.user.username, - module: '考试管理', - action: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩', - targetId: scoreId, - targetType: 'exam_score', - detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩:${dto.score}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '考试管理', action: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩', targetId: scoreId, targetType: 'exam_score', detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩:${dto.score}`, }); return result; } diff --git a/apps/server/src/exams/exams.purge.spec.ts b/apps/server/src/exams/exams.purge.spec.ts new file mode 100644 index 0000000..54af2f2 --- /dev/null +++ b/apps/server/src/exams/exams.purge.spec.ts @@ -0,0 +1,56 @@ +import { BadRequestException } from '@nestjs/common'; +import { ExamsService } from './exams.service'; + +describe('ExamsService.purge', () => { + const createService = (overrides?: { exam?: Record }) => { + const exam = { id: 1, examName: '月考', classId: 2, status: 'archived', ...overrides?.exam }; + const examRepo = { + findOne: jest.fn().mockResolvedValue(exam), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + find: jest.fn().mockResolvedValue([exam]), + }; + const classTeacherRepo = { findOne: jest.fn().mockResolvedValue({}) }; + const service = new ExamsService( + examRepo as never, + {} as never, + {} as never, + {} as never, + classTeacherRepo as never, + {} as never, + ); + return { service, examRepo, classTeacherRepo }; + }; + + it('rejects exams that are not archived', async () => { + const { service, examRepo } = createService({ exam: { status: 'active' } }); + await expect(service.purge(1, 7, true)).rejects.toThrow( + new BadRequestException('仅已归档考试可以永久删除,请先归档'), + ); + expect(examRepo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived exam and its scores', async () => { + const { service, examRepo } = createService(); + await expect(service.purge(1, 7, true)).resolves.toEqual({ + message: '已永久删除考试(不可恢复)', + }); + expect(examRepo.delete).toHaveBeenCalledWith(1); + }); + + it('checks class access before purge', async () => { + const { service, classTeacherRepo } = createService(); + classTeacherRepo.findOne.mockResolvedValue(null); + await expect(service.purge(1, 7, false)).rejects.toThrow('只能访问自己被分配的班级'); + }); + + it('batch purge returns deleted and skipped', async () => { + const { service, examRepo } = createService(); + examRepo.find = jest.fn().mockResolvedValue([ + { id: 1, examName: '月考', classId: 2, status: 'archived' }, + { id: 2, examName: '期中', classId: 2, status: 'active' }, + ]); + const result = await service.batchPurge([1, 2], 7, true); + expect(result).toMatchObject({ deleted: 1, skipped: 1 }); + expect(examRepo.delete).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/server/src/exams/exams.service.ts b/apps/server/src/exams/exams.service.ts index ff3a9ee..8f543ed 100644 --- a/apps/server/src/exams/exams.service.ts +++ b/apps/server/src/exams/exams.service.ts @@ -194,6 +194,34 @@ export class ExamsService { return { success: true }; } + async purge(id: number, userId: number, canManageAll: boolean) { + const exam = await this.examRepo.findOne({ where: { id } }); + if (!exam) throw new NotFoundException('考试不存在'); + await this.assertClassAccess(userId, exam.classId, canManageAll); + if (exam.status !== 'archived') throw new BadRequestException('仅已归档考试可以永久删除,请先归档'); + await this.examRepo.delete(id); + return { message: '已永久删除考试(不可恢复)' }; + } + + async batchPurge(ids: number[], userId: number, canManageAll: boolean) { + const exams = await this.findBatchExams(ids, userId, canManageAll, '永久删除'); + const deleted: number[] = []; + const skipped: string[] = []; + for (const exam of exams) { + if (exam.status !== 'archived') { + skipped.push(`${exam.examName}(未归档)`); + continue; + } + await this.examRepo.delete(exam.id); + deleted.push(exam.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 场考试;${skipped.length} 场被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 场考试(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + async batchArchive(ids: number[], userId: number, canManageAll: boolean) { const exams = await this.findBatchExams(ids, userId, canManageAll, '归档'); const targetIds = exams.filter((exam) => exam.status === 'active').map((exam) => exam.id); @@ -220,7 +248,7 @@ export class ExamsService { ids: number[], userId: number, canManageAll: boolean, - action: '归档' | '恢复', + action: '归档' | '恢复' | '永久删除', ) { const uniqueIds = [...new Set(ids || [])]; if (uniqueIds.length === 0) throw new BadRequestException(`请选择要${action}的考试`); diff --git a/apps/server/src/expenses/expense-operations.service.ts b/apps/server/src/expenses/expense-operations.service.ts new file mode 100644 index 0000000..2f6e2ad --- /dev/null +++ b/apps/server/src/expenses/expense-operations.service.ts @@ -0,0 +1,438 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, Repository } from 'typeorm'; +import { RoomExpense, PersonalExpense, Room, Student } from '../entities'; +import { BillsService } from '../bills/bills.service'; +import { RoomsService } from '../rooms/rooms.service'; +import type { CreatePersonalExpenseDto } from './dto/expense.dto'; + +@Injectable() +export class ExpenseOperationsService { + constructor( + @InjectRepository(RoomExpense) private roomExpRepo: Repository, + @InjectRepository(PersonalExpense) private personalExpRepo: Repository, + @InjectRepository(Room) private roomRepo: Repository, + @InjectRepository(Student) private studentRepo: Repository, + private billsService: BillsService, + private dataSource: DataSource, + ) {} + + private assertPositiveAmount(amount: number) { + if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) { + throw new BadRequestException('费用金额最多保留两位小数'); + } + if (amount <= 0) throw new BadRequestException('费用金额必须大于0'); + } + + private isValidDate(value: string) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; + const date = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; + } + + async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) { + this.assertPositiveAmount(dto.amount); + const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); + if (!student) throw new NotFoundException('学生不存在'); + const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId }); + return this.personalExpRepo.save(entity); + } + + async findPersonalExpenses(query?: { studentId?: number; status?: 'active' | 'archived' }) { + const status = query?.status ?? 'active'; + if (status !== 'active' && status !== 'archived') throw new BadRequestException('费用状态无效'); + const where: Record = { status }; + if (query?.studentId) where.studentId = query.studentId; + return this.personalExpRepo.find({ + where, + relations: ['student'], + order: { createdAt: 'DESC' }, + }); + } + + async deletePersonalExpense(id: number) { + const e = await this.personalExpRepo.findOne({ where: { id } }); + if (!e) throw new NotFoundException('费用记录不存在'); + if (e.billId) throw new BadRequestException('已计入账单的个人费用不能归档,请先取消账单'); + if (e.status === 'archived') throw new BadRequestException('费用记录已归档'); + await this.personalExpRepo.update(id, { status: 'archived' }); + return { message: '已归档' }; + } + + async batchDeletePersonalExpenses(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录'); + const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } }); + if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); + if (existing.some((expense) => expense.billId)) { + throw new BadRequestException('选中记录包含已计入账单的个人费用'); + } + const result = await this.personalExpRepo + .createQueryBuilder() + .update() + .set({ status: 'archived' }) + .where('id IN (:...ids)', { ids: uniqueIds }) + .execute(); + return { message: `已批量归档 ${result.affected || 0} 条`, archived: result.affected || 0 }; + } + + async batchRestorePersonalExpenses(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('费用记录 ID 无效'); + } + const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } }); + if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); + const targets = existing.filter((expense) => expense.status === 'archived'); + if (targets.some((expense) => expense.billId)) { + throw new BadRequestException('选中记录包含已计入账单的个人费用'); + } + + const targetIds = targets.map((expense) => expense.id); + const skipped = existing.length - targetIds.length; + let restored = 0; + if (targetIds.length > 0) { + const result = await this.personalExpRepo + .createQueryBuilder() + .update() + .set({ status: 'active' }) + .where('id IN (:...ids)', { ids: targetIds }) + .execute(); + restored = result.affected || 0; + } + return { message: `已批量恢复 ${restored} 条个人费用`, restored, skipped }; + } + + async purgePersonalExpense(id: number) { + const e = await this.personalExpRepo.findOne({ where: { id } }); + if (!e) throw new NotFoundException('费用记录不存在'); + if (e.status !== 'archived') throw new BadRequestException('仅已归档费用可以永久删除,请先归档'); + if (e.billId) throw new BadRequestException('已计入账单的个人费用不能永久删除,请先取消账单'); + const billed = await this.dataSource + .getRepository('bill_items') + .count({ where: { personalExpenseId: id } }); + if (billed) throw new BadRequestException('已计入账单明细的个人费用不能永久删除,请先取消账单'); + await this.personalExpRepo.delete(id); + return { message: '已永久删除个人费用(不可恢复)' }; + } + + async batchPurgePersonalExpenses(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的个人费用'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('费用记录 ID 无效'); + } + const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } }); + if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); + const billed = await this.dataSource + .getRepository('bill_items') + .count({ where: { personalExpenseId: In(uniqueIds) } }); + if (billed) throw new BadRequestException('选中记录包含已计入账单明细的个人费用'); + if (existing.some((expense) => expense.billId)) { + throw new BadRequestException('选中记录包含已计入账单的个人费用'); + } + + const deleted: number[] = []; + const skipped: string[] = []; + for (const e of existing) { + if (e.status !== 'archived') { + skipped.push(`记录${e.id}(未归档)`); + continue; + } + await this.personalExpRepo.delete(e.id); + deleted.push(e.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 条;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 条个人费用(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + + async updatePersonalExpense(id: number, dto: Partial) { + const e = await this.personalExpRepo.findOne({ where: { id } }); + if (!e) throw new NotFoundException('费用记录不存在'); + if (e.billId) throw new BadRequestException('已计入账单的个人费用不能修改,请先取消账单'); + if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount); + if (dto.studentId !== undefined && dto.studentId !== e.studentId) { + const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); + if (!student) throw new NotFoundException('学生不存在'); + } + Object.assign(e, dto); + return this.personalExpRepo.save(e); + } + + /** + * 水电费Excel批量导入 + * Excel格式: 序号|时间|房间号|房间电量|电费|冷水用量(吨)|水费|应缴金额 + * 时间格式: "2026-01-21 - 2026-02-08" + */ + async batchImportUtilityExpenses( + rows: { + periodStr: string; + roomNumber: string; + electricityAmount: number; + electricityFee: number; + waterAmount: number; + waterFee: number; + totalFee: number; + }[], + userId?: number, + ) { + let imported = 0; + let skipped = 0; + const errors: string[] = []; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const rowNum = i + 2; + + if (!row.roomNumber?.trim()) { + skipped++; + continue; + } + + try { + // 查找或创建宿舍 + let room = await this.roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); + if (!room) { + const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); + room = await this.roomRepo.save( + this.roomRepo.create({ + roomNumber: row.roomNumber.trim(), + building: parsed.building || undefined, + floor: parsed.floor || undefined, + capacity: parsed.capacity || 4, + roomType: parsed.roomType || undefined, + }), + ); + } + + // 解析时间段 "2026-01-21 - 2026-02-08" 或 "2026-01-21~2026-02-08" + let periodStart = ''; + let periodEnd = ''; + if (row.periodStr) { + // 先尝试用" - "或" ~ "分割(带空格的分隔符,避免拆分日期内部的连字符) + let parts = row.periodStr.split(/\s+[-~~]\s+/); + if (parts.length < 2) { + // 回退:尝试用正则提取 YYYY-MM-DD 格式的日期 + const dateMatches = row.periodStr.match(/(\d{4}-\d{1,2}-\d{1,2})/g); + if (dateMatches && dateMatches.length >= 2) { + parts = [dateMatches[0], dateMatches[1]]; + } + } + if (parts.length >= 2) { + periodStart = this.normalizeDate(parts[0].trim()); + periodEnd = this.normalizeDate(parts[1].trim()); + } + } + if (!periodStart || !periodEnd) { + errors.push(`第${rowNum}行: 时间格式无法解析 "${row.periodStr}"`); + skipped++; + continue; + } + if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) { + errors.push(`第${rowNum}行: ${row.roomNumber} 账期无效(${periodStart} ~ ${periodEnd}),已跳过`); + skipped++; + continue; + } + + // 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失, + // 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。 + if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) { + errors.push( + `第${rowNum}行: ${row.roomNumber} 电费和水费均为 0,可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`, + ); + skipped++; + continue; + } + + const existing = await this.roomExpRepo.find({ + where: [ + { importKey: `${room.id}:${periodStart}:${periodEnd}:electricity` }, + { importKey: `${room.id}:${periodStart}:${periodEnd}:water` }, + ], + }); + const byType = new Map(existing.map((expense) => [expense.expenseType, expense])); + + let savedAny = false; + if (row.electricityFee > 0) { + await this.importUtilityExpense( + room.id, + 'electricity', + periodStart, + periodEnd, + row.electricityFee, + `电量${row.electricityAmount}kWh`, + byType, + userId!, + ); + savedAny = true; + } + + if (row.waterFee > 0) { + await this.importUtilityExpense( + room.id, + 'water', + periodStart, + periodEnd, + row.waterFee, + `用水${row.waterAmount}吨`, + byType, + userId!, + ); + savedAny = true; + } + + if (savedAny) imported++; + else { + skipped++; + errors.push(`第${rowNum}行: ${row.roomNumber} 无有效金额`); + } + } catch (e: any) { + errors.push(`第${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`); + skipped++; + } + } + + return { + message: + imported > 0 + ? `成功导入 ${imported} 间宿舍水电费${skipped > 0 ? `,跳过 ${skipped} 条` : ''}` + : `未导入任何记录${skipped > 0 ? `,共 ${skipped} 条被跳过` : ''}`, + imported, + skipped, + errors: errors.length > 0 ? errors : undefined, + }; + } + + private async importUtilityExpense( + roomId: number, + expenseType: 'electricity' | 'water', + periodStart: string, + periodEnd: string, + amount: number, + description: string, + byType: Map, + recordedBy: number, + ): Promise { + const expense = byType.get(expenseType) || this.roomExpRepo.create({ + roomId, + expenseType, + periodStart, + periodEnd, + importKey: `${roomId}:${periodStart}:${periodEnd}:${expenseType}`, + }); + if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) { + throw new BadRequestException(`该周期${expenseType === 'electricity' ? '电费' : '水费'}已计入账单,不能覆盖`); + } + expense.amount = amount; + expense.description = description; + expense.recordedBy = recordedBy; + await this.roomExpRepo.save(expense); + } + + /** 把 2026/4/1、2026-4-1 之类格式归一化为 YYYY-MM-DD */ + private normalizeDate(s: string): string { + if (!s) return ''; + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; + const m = s.match(/(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/); + if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`; + return s; + } + + /** + * 个人附加费Excel批量导入 + * Excel格式: 学生姓名|费用类型|金额|费用日期|说明 + */ + async batchImportPersonalExpenses( + rows: { + studentName: string; + expenseType: string; + amount: number; + expenseDate: string; + description?: string; + }[], + userId?: number, + ) { + let imported = 0; + let skipped = 0; + const errors: string[] = []; + + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const rowNum = i + 2; + + if (!row.studentName?.trim()) { + skipped++; + continue; + } + + try { + // 查找学生 + const student = await this.studentRepo.findOne({ where: { name: row.studentName.trim() } }); + if (!student) { + errors.push(`第${rowNum}行: 学生"${row.studentName}"未找到`); + skipped++; + continue; + } + + // 解析费用类型 + const expenseType = row.expenseType?.trim() || ''; + if (!expenseType) { + errors.push(`第${rowNum}行: 费用类型不能为空`); + skipped++; + continue; + } + + // 解析日期 + let expenseDate = row.expenseDate?.trim() || ''; + if (!expenseDate.match(/^\d{4}-\d{2}-\d{2}$/)) { + // 尝试从各种格式解析 + const dateMatch = expenseDate.match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/); + if (dateMatch) { + expenseDate = `${dateMatch[1]}-${dateMatch[2].padStart(2, '0')}-${dateMatch[3].padStart(2, '0')}`; + } else { + errors.push(`第${rowNum}行: 日期格式"${row.expenseDate}"无效,需要YYYY-MM-DD`); + skipped++; + continue; + } + } + + // 校验金额 + try { + this.assertPositiveAmount(row.amount); + } catch (e: any) { + errors.push(`第${rowNum}行: ${row.studentName} ${e.message}`); + skipped++; + continue; + } + + await this.personalExpRepo.save( + this.personalExpRepo.create({ + studentId: student.id, + expenseType, + amount: row.amount, + expenseDate, + description: row.description || undefined, + recordedBy: userId, + }), + ); + + imported++; + } catch (e: any) { + errors.push(`第${rowNum}行: ${row.studentName} 导入失败 - ${e.message}`); + skipped++; + } + } + + return { + message: `成功导入 ${imported} 条个人附加费,跳过 ${skipped} 条`, + imported, + skipped, + errors: errors.length > 0 ? errors : undefined, + }; + } +} diff --git a/apps/server/src/expenses/expenses.boundaries.spec.ts b/apps/server/src/expenses/expenses.boundaries.spec.ts index 1b54e03..dc0bfdc 100644 --- a/apps/server/src/expenses/expenses.boundaries.spec.ts +++ b/apps/server/src/expenses/expenses.boundaries.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { ExpensesService } from './expenses.service'; +import { ExpenseOperationsService } from './expense-operations.service'; import { PersonalExpense } from '../entities/personal-expense.entity'; const qb = (affected = 1) => ({ @@ -35,7 +36,25 @@ function createService(options?: { }; const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 1 }) }; return { - service: new ExpensesService(roomExpRepo as any, personalExpRepo as any, roomRepo as any, studentRepo as any, {} as any), + service: (() => { + const operations = new ExpenseOperationsService( + roomExpRepo as any, + personalExpRepo as any, + roomRepo as any, + studentRepo as any, + {} as any, + undefined as any, + ); + return new ExpensesService( + roomExpRepo as any, + personalExpRepo as any, + roomRepo as any, + studentRepo as any, + {} as any, + undefined as any, + operations, + ); + })(), roomExpRepo, personalExpRepo, roomRepo, diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index 9a2f7e4..d9dcd6b 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -31,7 +31,7 @@ import { } from './dto/expense.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchIdsDto } from '../common/batch-ids.dto'; import * as ExcelJS from 'exceljs'; @@ -91,17 +91,8 @@ export class ExpensesController { @RequirePermission('expense:create') async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: any) { const result = await this.service.createStudentUtilityBill(dto, req.user?.id); - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '录入学生水电费并出账', - targetId: result.bill.id, - targetType: 'bill', - detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '录入学生水电费并出账', targetId: result.bill.id, targetType: 'bill', detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`, }); return result; } @@ -109,18 +100,9 @@ export class ExpensesController { @Post('room') @RequirePermission('expense:create') async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.createRoomExpense(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '录入费用', - targetId: result.id, - targetType: 'room_expense', - detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '录入费用', targetId: result.id, targetType: 'room_expense', detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`, }); return result; } @@ -128,16 +110,9 @@ export class ExpensesController { @Post('room/batch') @RequirePermission('expense:create') async batchCreateRoomExpenses(@Body() dto: BatchRoomExpenseDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchCreateRoomExpenses(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '批量录入费用', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '批量录入费用', detail: JSON.stringify(dto), }); return result; } @@ -151,17 +126,9 @@ export class ExpensesController { @Delete('room/:id') @RequirePermission('expense:delete') async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.deleteRoomExpense(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '归档费用', - targetId: id, - targetType: 'room_expense', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '归档费用', targetId: id, targetType: 'room_expense', }); return result; } @@ -169,16 +136,29 @@ export class ExpensesController { @Post('room/batch-delete') @RequirePermission('expense:delete') async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchDeleteRoomExpenses(body.ids || []); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '批量归档宿舍费用', - detail: `IDs: ${(body.ids || []).join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '批量归档宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`, + }); + return result; + } + + @Delete('room/:id/permanent') + @RequirePermission('expense:purge') + async purgeRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + const result = await this.service.purgeRoomExpense(id); + await logAudit(this.logService, req, { + module: '费用管理', action: '永久删除宿舍费用', targetId: id, targetType: 'room_expense', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('room/batch-permanent-delete') + @RequirePermission('expense:purge') + async batchPurgeRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) { + const result = await this.service.batchPurgeRoomExpenses(body.ids || []); + await logAudit(this.logService, req, { + module: '费用管理', action: '批量永久删除宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`, }); return result; } @@ -187,16 +167,9 @@ export class ExpensesController { @RequirePermission('expense:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) async batchRestoreRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRestoreRoomExpenses(dto.ids); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '批量恢复宿舍费用', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '批量恢复宿舍费用', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -208,18 +181,9 @@ export class ExpensesController { @Body() dto: UpdateRoomExpenseDto, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.updateRoomExpense(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '编辑费用', - targetId: id, - targetType: 'room_expense', - detail: `¥${dto.amount} ${dto.expenseType}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '编辑费用', targetId: id, targetType: 'room_expense', detail: `¥${dto.amount} ${dto.expenseType}`, }); return result; } @@ -227,16 +191,9 @@ export class ExpensesController { @Post('personal') @RequirePermission('expense:create') async createPersonalExpense(@Body() dto: CreatePersonalExpenseDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.createPersonalExpense(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '录入费用', - detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '录入费用', detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`, }); return result; } @@ -250,16 +207,9 @@ export class ExpensesController { @Delete('personal/:id') @RequirePermission('expense:delete') async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.deletePersonalExpense(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '归档费用', - targetId: id, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '归档费用', targetId: id, }); return result; } @@ -267,16 +217,29 @@ export class ExpensesController { @Post('personal/batch-delete') @RequirePermission('expense:delete') async batchDeletePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchDeletePersonalExpenses(body.ids || []); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '批量归档个人费用', - detail: `IDs: ${(body.ids || []).join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '批量归档个人费用', detail: `IDs: ${(body.ids || []).join(',')}`, + }); + return result; + } + + @Delete('personal/:id/permanent') + @RequirePermission('expense:purge') + async purgePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + const result = await this.service.purgePersonalExpense(id); + await logAudit(this.logService, req, { + module: '费用管理', action: '永久删除个人费用', targetId: id, targetType: 'personal_expense', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('personal/batch-permanent-delete') + @RequirePermission('expense:purge') + async batchPurgePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) { + const result = await this.service.batchPurgePersonalExpenses(body.ids || []); + await logAudit(this.logService, req, { + module: '费用管理', action: '批量永久删除个人费用', detail: `IDs: ${(body.ids || []).join(',')}`, }); return result; } @@ -285,16 +248,9 @@ export class ExpensesController { @RequirePermission('expense:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) async batchRestorePersonalExpenses(@Body() dto: BatchIdsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRestorePersonalExpenses(dto.ids); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '批量恢复个人费用', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '批量恢复个人费用', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -306,17 +262,9 @@ export class ExpensesController { @Body() dto: UpdatePersonalExpenseDto, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.updatePersonalExpense(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '编辑费用', - targetId: id, - detail: `¥${dto.amount} ${dto.expenseType}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '编辑费用', targetId: id, detail: `¥${dto.amount} ${dto.expenseType}`, }); return result; } @@ -361,9 +309,8 @@ export class ExpensesController { @RequirePermission('expense:create') @UseInterceptors(FileInterceptor('file')) async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as any); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const ws = workbook.worksheets[0]; const rows: any[] = []; ws.eachRow((row, idx) => { @@ -381,14 +328,8 @@ export class ExpensesController { }); }); const result = await this.service.batchImportUtilityExpenses(rows, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '导入水电费', - detail: result.message, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '导入水电费', detail: result.message, }); return result; } @@ -433,9 +374,8 @@ export class ExpensesController { @RequirePermission('expense:create') @UseInterceptors(FileInterceptor('file')) async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as any); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const ws = workbook.worksheets[0]; const rows: any[] = []; ws.eachRow((row, idx) => { @@ -451,14 +391,8 @@ export class ExpensesController { }); }); const result = await this.service.batchImportPersonalExpenses(rows, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '费用管理', - action: '导入个人附加费', - detail: result.message, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '费用管理', action: '导入个人附加费', detail: result.message, }); return result; } diff --git a/apps/server/src/expenses/expenses.module.ts b/apps/server/src/expenses/expenses.module.ts index 475f48c..51ddb6b 100644 --- a/apps/server/src/expenses/expenses.module.ts +++ b/apps/server/src/expenses/expenses.module.ts @@ -5,6 +5,7 @@ import { PersonalExpense } from '../entities/personal-expense.entity'; import { Room } from '../entities/room.entity'; import { Student } from '../entities/student.entity'; import { ExpensesService } from './expenses.service'; +import { ExpenseOperationsService } from './expense-operations.service'; import { ExpensesController } from './expenses.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { BillsModule } from '../bills/bills.module'; @@ -16,7 +17,7 @@ import { BillsModule } from '../bills/bills.module'; BillsModule, ], controllers: [ExpensesController], - providers: [ExpensesService], + providers: [ExpensesService, ExpenseOperationsService], exports: [ExpensesService], }) export class ExpensesModule {} diff --git a/apps/server/src/expenses/expenses.purge.controller.spec.ts b/apps/server/src/expenses/expenses.purge.controller.spec.ts new file mode 100644 index 0000000..a7d497c --- /dev/null +++ b/apps/server/src/expenses/expenses.purge.controller.spec.ts @@ -0,0 +1,34 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { ExpensesController } from './expenses.controller'; + +describe('ExpensesController purge routes', () => { + it('requires expense:purge on permanent delete routes', () => { + expect( + Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.purgeRoomExpense), + ).toEqual(['expense:purge']); + expect( + Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.batchPurgeRoomExpenses), + ).toEqual(['expense:purge']); + expect( + Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.purgePersonalExpense), + ).toEqual(['expense:purge']); + expect( + Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.batchPurgePersonalExpenses), + ).toEqual(['expense:purge']); + }); + + it('writes permanent delete audit logs', async () => { + const service = { + purgeRoomExpense: jest.fn().mockResolvedValue({ message: '已永久删除宿舍费用(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new ExpensesController(service as never, { log } as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purgeRoomExpense(1, req); + expect(service.purgeRoomExpense).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '费用管理', action: '永久删除宿舍费用', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/expenses/expenses.purge.spec.ts b/apps/server/src/expenses/expenses.purge.spec.ts new file mode 100644 index 0000000..4619e20 --- /dev/null +++ b/apps/server/src/expenses/expenses.purge.spec.ts @@ -0,0 +1,91 @@ +import { BadRequestException } from '@nestjs/common'; +import { ExpensesService } from './expenses.service'; +import { ExpenseOperationsService } from './expense-operations.service'; + +describe('ExpensesService purge', () => { + const billItemsRepo = { + count: jest.fn().mockResolvedValue(0), + }; + const dataSource = { + getRepository: jest.fn().mockReturnValue(billItemsRepo), + }; + const roomExpRepo = { + findOne: jest.fn(), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + find: jest.fn(), + }; + const personalExpRepo = { + findOne: jest.fn(), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + find: jest.fn(), + }; + + const createService = () => + new ExpensesService( + roomExpRepo as never, + personalExpRepo as never, + {} as never, + {} as never, + {} as never, + dataSource as never, + new ExpenseOperationsService( + roomExpRepo as never, + personalExpRepo as never, + {} as never, + {} as never, + {} as never, + dataSource as never, + ), + ); + + beforeEach(() => { + jest.clearAllMocks(); + billItemsRepo.count.mockResolvedValue(0); + }); + + it('room expense purge rejects non-archived records', async () => { + roomExpRepo.findOne.mockResolvedValue({ id: 1, status: 'active' }); + const service = createService(); + await expect(service.purgeRoomExpense(1)).rejects.toThrow( + new BadRequestException('仅已归档费用可以永久删除,请先归档'), + ); + expect(roomExpRepo.delete).not.toHaveBeenCalled(); + }); + + it('room expense purge rejects billed records', async () => { + roomExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived' }); + billItemsRepo.count.mockResolvedValue(1); + const service = createService(); + await expect(service.purgeRoomExpense(1)).rejects.toThrow( + new BadRequestException('已计入账单的宿舍费用不能永久删除,请先取消账单'), + ); + expect(roomExpRepo.delete).not.toHaveBeenCalled(); + }); + + it('room expense purge deletes archived records', async () => { + roomExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived' }); + const service = createService(); + await expect(service.purgeRoomExpense(1)).resolves.toEqual({ + message: '已永久删除宿舍费用(不可恢复)', + }); + expect(roomExpRepo.delete).toHaveBeenCalledWith(1); + }); + + it('personal expense purge rejects records attached to a bill', async () => { + personalExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived', billId: 9 }); + const service = createService(); + await expect(service.purgePersonalExpense(1)).rejects.toThrow( + new BadRequestException('已计入账单的个人费用不能永久删除,请先取消账单'), + ); + expect(personalExpRepo.delete).not.toHaveBeenCalled(); + }); + + it('personal expense purge deletes archived records with no bill', async () => { + personalExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived', billId: null }); + const service = createService(); + await expect(service.purgePersonalExpense(1)).resolves.toEqual({ + message: '已永久删除个人费用(不可恢复)', + }); + expect(personalExpRepo.delete).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index f837a19..df8e329 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -11,8 +11,8 @@ import { BatchRoomExpenseDto, CreateStudentUtilityBillDto, } from './dto/expense.dto'; -import { RoomsService } from '../rooms/rooms.service'; import { BillsService } from '../bills/bills.service'; +import { ExpenseOperationsService } from './expense-operations.service'; @Injectable() @@ -24,6 +24,7 @@ export class ExpensesService { @InjectRepository(Student) private studentRepo: Repository, private billsService: BillsService, private dataSource: DataSource, + private operations: ExpenseOperationsService, ) {} async getFormLookups() { @@ -120,13 +121,18 @@ export class ExpensesService { const roomQb = this.roomExpRepo .createQueryBuilder('e') .leftJoin('e.room', 'room') - .select('e.id', 'id') - .addSelect('e.expenseType', 'expenseType') - .addSelect('e.amount', 'amount') - .addSelect('e.periodStart', 'periodStart') - .addSelect('e.periodEnd', 'periodEnd') - .addSelect('room.roomNumber', 'roomNumber') - .where('e.status = :status', { status: 'active' }); + .select('e.id', 'id'); + const roomExpenseSelects = [ + ['e.expenseType', 'expenseType'], + ['e.amount', 'amount'], + ['e.periodStart', 'periodStart'], + ['e.periodEnd', 'periodEnd'], + ['room.roomNumber', 'roomNumber'], + ] as const; + for (const [column, alias] of roomExpenseSelects) { + roomQb.addSelect(column, alias); + } + roomQb.where('e.status = :status', { status: 'active' }); if (query?.keyword) { roomQb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${query.keyword}%` }); } @@ -144,13 +150,18 @@ export class ExpensesService { const personalQb = this.personalExpRepo .createQueryBuilder('e') .leftJoin('e.student', 'student') - .select('e.id', 'id') - .addSelect('e.expenseType', 'expenseType') - .addSelect('e.amount', 'amount') - .addSelect('e.expenseDate', 'expenseDate') - .addSelect('student.name', 'studentName') - .addSelect('student.studentNo', 'studentNo') - .where('e.status = :status', { status: 'active' }); + .select('e.id', 'id'); + const personalExpenseSelects = [ + ['e.expenseType', 'expenseType'], + ['e.amount', 'amount'], + ['e.expenseDate', 'expenseDate'], + ['student.name', 'studentName'], + ['student.studentNo', 'studentNo'], + ] as const; + for (const [column, alias] of personalExpenseSelects) { + personalQb.addSelect(column, alias); + } + personalQb.where('e.status = :status', { status: 'active' }); if (query?.keyword) { personalQb.andWhere( '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', @@ -243,6 +254,48 @@ export class ExpensesService { return { message: `已批量恢复 ${restored} 条宿舍费用`, restored, skipped }; } + async purgeRoomExpense(id: number) { + const e = await this.roomExpRepo.findOne({ where: { id } }); + if (!e) throw new NotFoundException('费用记录不存在'); + if (e.status !== 'archived') throw new BadRequestException('仅已归档费用可以永久删除,请先归档'); + const billed = await this.dataSource + .getRepository('bill_items') + .count({ where: { roomExpenseId: id } }); + if (billed) throw new BadRequestException('已计入账单的宿舍费用不能永久删除,请先取消账单'); + await this.roomExpRepo.delete(id); + return { message: '已永久删除宿舍费用(不可恢复)' }; + } + + async batchPurgeRoomExpenses(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的宿舍费用'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('费用记录 ID 无效'); + } + const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) } }); + if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); + const billed = await this.dataSource + .getRepository('bill_items') + .count({ where: { roomExpenseId: In(uniqueIds) } }); + if (billed) throw new BadRequestException('选中记录包含已计入账单的宿舍费用'); + + const deleted: number[] = []; + const skipped: string[] = []; + for (const e of existing) { + if (e.status !== 'archived') { + skipped.push(`记录${e.id}(未归档)`); + continue; + } + await this.roomExpRepo.delete(e.id); + deleted.push(e.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 条;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 条宿舍费用(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + async updateRoomExpense(id: number, dto: Partial) { const e = await this.roomExpRepo.findOne({ where: { id } }); const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } }); @@ -298,97 +351,37 @@ export class ExpensesService { // 个人附加费 async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) { - this.assertPositiveAmount(dto.amount); - const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); - if (!student) throw new NotFoundException('学生不存在'); - const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId }); - return this.personalExpRepo.save(entity); + return this.operations.createPersonalExpense(dto, userId); } async findPersonalExpenses(query?: { studentId?: number; status?: 'active' | 'archived' }) { - const status = query?.status ?? 'active'; - if (status !== 'active' && status !== 'archived') throw new BadRequestException('费用状态无效'); - const where: Record = { status }; - if (query?.studentId) where.studentId = query.studentId; - return this.personalExpRepo.find({ - where, - relations: ['student'], - order: { createdAt: 'DESC' }, - }); + return this.operations.findPersonalExpenses(query); } async deletePersonalExpense(id: number) { - const e = await this.personalExpRepo.findOne({ where: { id } }); - if (!e) throw new NotFoundException('费用记录不存在'); - if (e.billId) throw new BadRequestException('已计入账单的个人费用不能归档,请先取消账单'); - if (e.status === 'archived') throw new BadRequestException('费用记录已归档'); - await this.personalExpRepo.update(id, { status: 'archived' }); - return { message: '已归档' }; + return this.operations.deletePersonalExpense(id); } async batchDeletePersonalExpenses(ids: number[]) { - const uniqueIds = [...new Set(ids || [])]; - if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录'); - const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } }); - if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); - if (existing.some((expense) => expense.billId)) { - throw new BadRequestException('选中记录包含已计入账单的个人费用'); - } - const result = await this.personalExpRepo - .createQueryBuilder() - .update() - .set({ status: 'archived' }) - .where('id IN (:...ids)', { ids: uniqueIds }) - .execute(); - return { message: `已批量归档 ${result.affected || 0} 条`, archived: result.affected || 0 }; + return this.operations.batchDeletePersonalExpenses(ids); } async batchRestorePersonalExpenses(ids: number[]) { - const uniqueIds = [...new Set(ids || [])]; - if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录'); - if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { - throw new BadRequestException('费用记录 ID 无效'); - } - const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } }); - if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); - const targets = existing.filter((expense) => expense.status === 'archived'); - if (targets.some((expense) => expense.billId)) { - throw new BadRequestException('选中记录包含已计入账单的个人费用'); - } + return this.operations.batchRestorePersonalExpenses(ids); + } - const targetIds = targets.map((expense) => expense.id); - const skipped = existing.length - targetIds.length; - let restored = 0; - if (targetIds.length > 0) { - const result = await this.personalExpRepo - .createQueryBuilder() - .update() - .set({ status: 'active' }) - .where('id IN (:...ids)', { ids: targetIds }) - .execute(); - restored = result.affected || 0; - } - return { message: `已批量恢复 ${restored} 条个人费用`, restored, skipped }; + async purgePersonalExpense(id: number) { + return this.operations.purgePersonalExpense(id); + } + + async batchPurgePersonalExpenses(ids: number[]) { + return this.operations.batchPurgePersonalExpenses(ids); } async updatePersonalExpense(id: number, dto: Partial) { - const e = await this.personalExpRepo.findOne({ where: { id } }); - if (!e) throw new NotFoundException('费用记录不存在'); - if (e.billId) throw new BadRequestException('已计入账单的个人费用不能修改,请先取消账单'); - if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount); - if (dto.studentId !== undefined && dto.studentId !== e.studentId) { - const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); - if (!student) throw new NotFoundException('学生不存在'); - } - Object.assign(e, dto); - return this.personalExpRepo.save(e); + return this.operations.updatePersonalExpense(id, dto); } - /** - * 水电费Excel批量导入 - * Excel格式: 序号|时间|房间号|房间电量|电费|冷水用量(吨)|水费|应缴金额 - * 时间格式: "2026-01-21 - 2026-02-08" - */ async batchImportUtilityExpenses( rows: { periodStr: string; @@ -401,156 +394,9 @@ export class ExpensesService { }[], userId?: number, ) { - let imported = 0; - let skipped = 0; - const errors: string[] = []; - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - const rowNum = i + 2; - - if (!row.roomNumber?.trim()) { - skipped++; - continue; - } - - try { - // 查找或创建宿舍 - let room = await this.roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); - if (!room) { - const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); - room = await this.roomRepo.save( - this.roomRepo.create({ - roomNumber: row.roomNumber.trim(), - building: parsed.building || undefined, - floor: parsed.floor || undefined, - capacity: parsed.capacity || 4, - roomType: parsed.roomType || undefined, - }), - ); - } - - // 解析时间段 "2026-01-21 - 2026-02-08" 或 "2026-01-21~2026-02-08" - let periodStart = ''; - let periodEnd = ''; - if (row.periodStr) { - // 先尝试用" - "或" ~ "分割(带空格的分隔符,避免拆分日期内部的连字符) - let parts = row.periodStr.split(/\s+[-~~]\s+/); - if (parts.length < 2) { - // 回退:尝试用正则提取 YYYY-MM-DD 格式的日期 - const dateMatches = row.periodStr.match(/(\d{4}-\d{1,2}-\d{1,2})/g); - if (dateMatches && dateMatches.length >= 2) { - parts = [dateMatches[0], dateMatches[1]]; - } - } - if (parts.length >= 2) { - periodStart = this.normalizeDate(parts[0].trim()); - periodEnd = this.normalizeDate(parts[1].trim()); - } - } - if (!periodStart || !periodEnd) { - errors.push(`第${rowNum}行: 时间格式无法解析 "${row.periodStr}"`); - skipped++; - continue; - } - if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) { - errors.push(`第${rowNum}行: ${row.roomNumber} 账期无效(${periodStart} ~ ${periodEnd}),已跳过`); - skipped++; - continue; - } - - // 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失, - // 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。 - if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) { - errors.push( - `第${rowNum}行: ${row.roomNumber} 电费和水费均为 0,可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`, - ); - skipped++; - continue; - } - - const existing = await this.roomExpRepo.find({ - where: [ - { importKey: `${room.id}:${periodStart}:${periodEnd}:electricity` }, - { importKey: `${room.id}:${periodStart}:${periodEnd}:water` }, - ], - }); - const byType = new Map(existing.map((expense) => [expense.expenseType, expense])); - - let savedAny = false; - // 导入电费 - if (row.electricityFee > 0) { - const expense = byType.get('electricity') || this.roomExpRepo.create({ - roomId: room.id, - expenseType: 'electricity', - periodStart, - periodEnd, - importKey: `${room.id}:${periodStart}:${periodEnd}:electricity`, - }); - if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) { - throw new BadRequestException('该周期电费已计入账单,不能覆盖'); - } - expense.amount = row.electricityFee; - expense.description = `电量${row.electricityAmount}kWh`; - expense.recordedBy = userId!; - await this.roomExpRepo.save(expense); - savedAny = true; - } - - // 导入水费 - if (row.waterFee > 0) { - const expense = byType.get('water') || this.roomExpRepo.create({ - roomId: room.id, - expenseType: 'water', - periodStart, - periodEnd, - importKey: `${room.id}:${periodStart}:${periodEnd}:water`, - }); - if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) { - throw new BadRequestException('该周期水费已计入账单,不能覆盖'); - } - expense.amount = row.waterFee; - expense.description = `用水${row.waterAmount}吨`; - expense.recordedBy = userId!; - await this.roomExpRepo.save(expense); - savedAny = true; - } - - if (savedAny) imported++; - else { - skipped++; - errors.push(`第${rowNum}行: ${row.roomNumber} 无有效金额`); - } - } catch (e: any) { - errors.push(`第${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`); - skipped++; - } - } - - return { - message: - imported > 0 - ? `成功导入 ${imported} 间宿舍水电费${skipped > 0 ? `,跳过 ${skipped} 条` : ''}` - : `未导入任何记录${skipped > 0 ? `,共 ${skipped} 条被跳过` : ''}`, - imported, - skipped, - errors: errors.length > 0 ? errors : undefined, - }; + return this.operations.batchImportUtilityExpenses(rows, userId); } - /** 把 2026/4/1、2026-4-1 之类格式归一化为 YYYY-MM-DD */ - private normalizeDate(s: string): string { - if (!s) return ''; - if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; - const m = s.match(/(\d{4})[\-\/.](\d{1,2})[\-\/.](\d{1,2})/); - if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`; - return s; - } - - /** - * 个人附加费Excel批量导入 - * Excel格式: 学生姓名|费用类型|金额|费用日期|说明 - */ async batchImportPersonalExpenses( rows: { studentName: string; @@ -561,83 +407,6 @@ export class ExpensesService { }[], userId?: number, ) { - let imported = 0; - let skipped = 0; - const errors: string[] = []; - - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - const rowNum = i + 2; - - if (!row.studentName?.trim()) { - skipped++; - continue; - } - - try { - // 查找学生 - const student = await this.studentRepo.findOne({ where: { name: row.studentName.trim() } }); - if (!student) { - errors.push(`第${rowNum}行: 学生"${row.studentName}"未找到`); - skipped++; - continue; - } - - // 解析费用类型 - const expenseType = row.expenseType?.trim() || ''; - if (!expenseType) { - errors.push(`第${rowNum}行: 费用类型不能为空`); - skipped++; - continue; - } - - // 解析日期 - let expenseDate = row.expenseDate?.trim() || ''; - if (!expenseDate.match(/^\d{4}-\d{2}-\d{2}$/)) { - // 尝试从各种格式解析 - const dateMatch = expenseDate.match(/(\d{4})[\-\/](\d{1,2})[\-\/](\d{1,2})/); - if (dateMatch) { - expenseDate = `${dateMatch[1]}-${dateMatch[2].padStart(2, '0')}-${dateMatch[3].padStart(2, '0')}`; - } else { - errors.push(`第${rowNum}行: 日期格式"${row.expenseDate}"无效,需要YYYY-MM-DD`); - skipped++; - continue; - } - } - - // 校验金额 - try { - this.assertPositiveAmount(row.amount); - } catch (e: any) { - errors.push(`第${rowNum}行: ${row.studentName} ${e.message}`); - skipped++; - continue; - } - - await this.personalExpRepo.save( - this.personalExpRepo.create({ - studentId: student.id, - expenseType, - amount: row.amount, - expenseDate, - description: row.description || undefined, - recordedBy: userId, - }), - ); - - imported++; - } catch (e: any) { - errors.push(`第${rowNum}行: ${row.studentName} 导入失败 - ${e.message}`); - skipped++; - } - } - - return { - message: `成功导入 ${imported} 条个人附加费,跳过 ${skipped} 条`, - imported, - skipped, - errors: errors.length > 0 ? errors : undefined, - }; + return this.operations.batchImportPersonalExpenses(rows, userId); } } diff --git a/apps/server/src/integration/config/integration-config.service.ts b/apps/server/src/integration/config/integration-config.service.ts index 910d5c4..a291654 100644 --- a/apps/server/src/integration/config/integration-config.service.ts +++ b/apps/server/src/integration/config/integration-config.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { DINGTALK_OAUTH_TOKEN_URL } from '../endpoints'; import { IntegrationConfig, IntegrationConfigDetail } from '../entities/integration-config.entity'; import { ThirdConfigBaseDTO, @@ -208,7 +209,7 @@ export class IntegrationConfigService { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { - const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { + const res = await fetch(DINGTALK_OAUTH_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appKey, appSecret }), diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index e98d30c..433c4b4 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: duplicate-block -- 钉钉 API 调用块结构相似(端点/参数不同) /** * 钉钉集成服务 — 对齐 gongxue-dorm-sys * @@ -12,203 +13,52 @@ import { Student } from '../entities/student.entity'; import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; import { syncDingTalkStudents } from './dingtalk-student-sync'; import { IntegrationConfigService } from './config/integration-config.service'; +import { DINGTALK_OAUTH_TOKEN_URL } from './endpoints'; +import { isDingTalkUserListResponse } from './dingtalk.types'; +import type { + DingTalkCredentials, + DingTalkDeptGetResponse, + DingTalkDeptListResponse, + DingTalkServiceContext, + DingTalkUserListResponse, + OrgDeptNode, + OrgDeptNodeWithUsers, +} from './dingtalk.types'; +import { DingTalkAttendanceClient } from './dingtalk.attendance'; +import { DingTalkShiftClient } from './dingtalk.shifts'; +import { DingTalkGroupClient } from './dingtalk.groups'; +import { DingTalkScheduleClient } from './dingtalk.schedules'; -// ── Types ── - - -interface DingTalkCredentials { - appKey: string; - appSecret: string; -} - -interface DingTalkUserListResponse { - errcode: number; - errmsg: string; - result: { - has_more: boolean; - next_cursor?: number; - list: Array<{ - userid: string; - name: string; - mobile: string; - dept_id_list: number[]; - }>; - }; -} - - -function isDingTalkUserListResponse(value: unknown): value is DingTalkUserListResponse { - if (!value || typeof value !== 'object' || !('errcode' in value)) return false; - if (typeof value.errcode !== 'number') return false; - if ('errmsg' in value && typeof value.errmsg !== 'string') return false; - if (!('result' in value) || !value.result || typeof value.result !== 'object') { - return value.errcode !== 0; - } - if (!('has_more' in value.result) || typeof value.result.has_more !== 'boolean') return false; - if (!('list' in value.result) || !Array.isArray(value.result.list)) return false; - return value.result.list.every( - (item) => - item && - typeof item === 'object' && - 'userid' in item && - typeof item.userid === 'string' && - 'name' in item && - typeof item.name === 'string' && - 'mobile' in item && - typeof item.mobile === 'string' && - 'dept_id_list' in item && - Array.isArray(item.dept_id_list) && - item.dept_id_list.every((id) => typeof id === 'number'), - ); -} - -/** 钉钉打卡结果 — 对齐 dws attendance check result */ -export interface DingTalkAttendanceResult { - userId: string; - userName: string; - workDate: string; - timeResult: string; - locationResult: string; - planCheckTime: string; - actualCheckTime: string; - checkId: string; - checkType: string; - /** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */ - sourceType: string; - /** 部分钉钉租户会额外返回考勤机名称或编号。 */ - deviceName?: string; - deviceId?: string; -} - -// ── 组织架构 API 类型 ── - -interface DingTalkDeptListResponse { - errcode: number; - result?: Array<{ dept_id: number; name: string; parent_id: number }>; -} - -interface DingTalkDeptGetResponse { - errcode: number; - result?: { name: string; parent_id: number }; -} - -export interface OrgDeptNode { - id: number; - name: string; - parentId: number; - children: OrgDeptNode[]; -} - -export interface OrgDeptNodeWithUsers extends OrgDeptNode { - users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>; -} - -// ── 考勤排班 API 类型 ── - -/** 班次卡段打卡时间 */ -export interface DingTalkShiftTime { - check_type: 'OnDuty' | 'OffDuty'; - across: number; - check_time: string; - begin_min?: number; - end_min?: number; - free_check?: boolean; -} - -/** 班次卡段 */ -export interface DingTalkShiftSection { - times: DingTalkShiftTime[]; -} - -/** 班次配置 */ -export interface DingTalkShiftSetting { - is_flexible?: boolean; - serious_late_minutes?: number; - absenteeism_late_minutes?: number; -} - -/** 创建/修改班次参数 */ -export interface DingTalkShiftParams { - id?: number; - name: string; - owner?: string; - sections: DingTalkShiftSection[]; - setting?: DingTalkShiftSetting; -} - -/** 班次摘要(查询返回) */ -export interface DingTalkShiftSummary { - id: number; - name: string; -} - -/** 考勤组成员 */ -export interface DingTalkGroupMember { - role: string; - type: 'StaffMember' | 'DeptMember'; - user_id: string; -} - -/** 创建考勤组参数 */ -export interface DingTalkGroupParams { - name: string; - type: 'TURN'; - owner: string; - members: DingTalkGroupMember[]; - shift_ids?: number[]; - enable_emp_select_class?: boolean; - disable_check_without_schedule?: boolean; - disable_check_when_rest?: boolean; - /** 关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,仅保留考勤机打卡入口 */ - attendance_machine_only?: boolean; -} - -/** 修改考勤组参数 */ -export interface DingTalkGroupUpdateParams extends DingTalkGroupParams { - id: number; -} - -/** 考勤组摘要(查询返回) */ -export interface DingTalkGroupSummary { - group_id: number; - group_name: string; - type: string; - member_count: number; -} - -/** 排班参数(单条) */ -export interface DingTalkScheduleItem { - userid: string; - work_date: number; - shift_id: number; - is_rest?: boolean; -} - -/** 排班查询结果 */ -export interface DingTalkScheduleResult { - userid: string; - work_date: string; - shift_id: number; - is_rest: string; - check_type: string; - plan_check_time: string; - group_id: number; - id: number; -} - +export type { + DingTalkAttendanceResult, + DingTalkGroupParams, + DingTalkGroupSummary, + DingTalkGroupUpdateParams, + DingTalkScheduleItem, + DingTalkScheduleResult, + DingTalkShiftParams, + DingTalkShiftSummary, + OrgDeptNode, + OrgDeptNodeWithUsers, +} from './dingtalk.types'; @Injectable() -export class DingTalkService { - private readonly logger = new Logger(DingTalkService.name); - private accessToken: string | null = null; - private accessTokenCredentialKey: string | null = null; - private tokenExpiresAt = 0; - private apiRequestCount = 0; +export class DingTalkService implements DingTalkServiceContext { + accessToken: string | null = null; + accessTokenCredentialKey: string | null = null; + tokenExpiresAt = 0; + apiRequestCount = 0; + readonly logger = new Logger(DingTalkService.name); /** 钉钉 API 限流:每秒最多 20 次 */ private static readonly RATE_LIMIT = 20; private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT; + private attendanceClient?: DingTalkAttendanceClient; + private shiftClient?: DingTalkShiftClient; + private groupClient?: DingTalkGroupClient; + private scheduleClient?: DingTalkScheduleClient; + constructor( @InjectRepository(Student) private readonly studentRepo: Repository, @@ -218,7 +68,27 @@ export class DingTalkService { private readonly dataSource?: DataSource, ) {} - private async getCredentials(): Promise { + private get attendance(): DingTalkAttendanceClient { + if (!this.attendanceClient) this.attendanceClient = new DingTalkAttendanceClient(this); + return this.attendanceClient; + } + + private get shifts(): DingTalkShiftClient { + if (!this.shiftClient) this.shiftClient = new DingTalkShiftClient(this); + return this.shiftClient; + } + + private get groups(): DingTalkGroupClient { + if (!this.groupClient) this.groupClient = new DingTalkGroupClient(this); + return this.groupClient; + } + + private get schedules(): DingTalkScheduleClient { + if (!this.scheduleClient) this.scheduleClient = new DingTalkScheduleClient(this); + return this.scheduleClient; + } + + async getCredentials(): Promise { const rawConfig = await this.integrationConfigService?.getRawConfig('DINGTALK'); const dbAppKey = typeof rawConfig?.agentId === 'string' ? rawConfig.agentId.trim() : ''; const dbAppSecret = typeof rawConfig?.appSecret === 'string' ? rawConfig.appSecret.trim() : ''; @@ -235,7 +105,7 @@ export class DingTalkService { return null; } - private async isConfigured(): Promise { + async isConfigured(): Promise { return !!(await this.getCredentials()); } @@ -243,7 +113,7 @@ export class DingTalkService { // Token — 对齐 gongxue-dorm-sys getAccessToken // ═══════════════════════════════════════════ - private async getAccessToken(): Promise { + async getAccessToken(): Promise { const credentials = await this.getCredentials(); if (!credentials) { throw new Error('DingTalk not configured'); @@ -258,7 +128,7 @@ export class DingTalkService { return this.accessToken; } - const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { + const res = await fetch(DINGTALK_OAUTH_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), @@ -285,7 +155,7 @@ export class DingTalkService { // Users by department — 对齐 gongxue-dorm-sys getUsersByDepartment // ═══════════════════════════════════════════ - private async getDeptUsers( + async getDeptUsers( token: string, deptId: number, ): Promise> { @@ -482,445 +352,61 @@ export class DingTalkService { return [attachUsers(deptTree)]; } - // ═══════════════════════════════════════════ // Rate limiting — 对齐 gongxue-dorm-sys // ═══════════════════════════════════════════ - private async rateLimit(): Promise { + async rateLimit(): Promise { await this.sleep(DingTalkService.MIN_INTERVAL); this.apiRequestCount++; } - // ═══════════════════════════════════════════ - // 考勤打卡结果 — 对齐 dws attendance check result - // ═══════════════════════════════════════════ - - async fetchAttendanceResults(params: { - startDate: string; - endDate: string; - userIds?: string[]; - }): Promise { - if (!(await this.isConfigured())) throw new Error('DingTalk not configured'); - if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空'); - if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人'); - const token = await this.getAccessToken(); - - const dateFrom = params.startDate.includes(' ') ? params.startDate : `${params.startDate} 00:00:00`; - const dateTo = params.endDate.includes(' ') ? params.endDate : `${params.endDate} 23:59:59`; - - const body: Record = { - checkDateFrom: dateFrom, - checkDateTo: dateTo, - }; - body.userIds = params.userIds; - - const res = await fetch( - `https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }, - ); - const data = await res.json() as { - errcode: number; errmsg: string; - recordresult?: Array<{ - id: number; userId: string; workDate: number; - userCheckTime: number; sourceType: string; - checkType?: string; timeResult?: string; - locationResult?: string; locationMethod?: string; - userAddress?: string; userLongitude?: number; userLatitude?: number; - deviceName?: string; deviceId?: string | number; deviceSN?: string | number; - attendanceMachineName?: string; attendanceMachineId?: string | number; - }>; - }; - if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`); - - const records = data.recordresult ?? []; - - return records.map((r) => ({ - userId: r.userId, - userName: '', - workDate: new Date(r.workDate + 8 * 60 * 60 * 1000).toISOString().slice(0, 10), - timeResult: r.timeResult ?? r.sourceType ?? '', - locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '', - planCheckTime: '', - actualCheckTime: new Date(r.userCheckTime).toISOString(), - checkId: String(r.id), - checkType: r.checkType ?? '', - sourceType: r.sourceType ?? '', - deviceName: r.deviceName ?? r.attendanceMachineName, - deviceId: String(r.deviceId ?? r.attendanceMachineId ?? r.deviceSN ?? '') || undefined, - })); + async fetchAttendanceResults( + ...args: Parameters + ) { + return this.attendance.fetchAttendanceResults(...args); } - // ═══════════════════════════════════════════ - // 考勤排班 — 班次管理 - // ═══════════════════════════════════════════ - - /** 创建或修改班次。id 不传=创建,传了=修改 */ - async upsertShift(params: DingTalkShiftParams): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - - const body: Record = { - op_user_id: params.owner || 'manager', - shift: { - name: params.name, - owner: params.owner, - sections: params.sections.map((s) => ({ - times: s.times.map((t) => ({ - check_type: t.check_type, - across: t.across, - check_time: t.check_time, - begin_min: t.begin_min ?? -1, - end_min: t.end_min ?? -1, - free_check: t.free_check ?? false, - })), - })), - setting: params.setting - ? { - is_flexible: params.setting.is_flexible ?? false, - serious_late_minutes: params.setting.serious_late_minutes ?? -1, - absenteeism_late_minutes: params.setting.absenteeism_late_minutes ?? -1, - } - : undefined, - }, - }; - if (params.id) (body.shift as Record).id = params.id; - - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/shift/add?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }, - ); - const data = (await res.json()) as { - errcode: number; errmsg: string; - result?: { id: number; name: string }; - }; - if (data.errcode !== 0) { - throw new Error(`钉钉班次操作失败: ${data.errmsg} (code=${data.errcode})`); - } - this.logger.log(`钉钉班次 ${params.id ? '更新' : '创建'} 成功: ${data.result?.name} (id=${data.result?.id})`); - return data.result!.id; + async upsertShift(...args: Parameters) { + return this.shifts.upsertShift(...args); } - /** 查询所有班次摘要(每页最多200条) */ - async queryShifts(opUserId = 'manager'): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - - const all: DingTalkShiftSummary[] = []; - let cursor = 0; - let hasMore = true; - - while (hasMore) { - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/shift/list?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op_user_id: opUserId, cursor }), - }, - ); - const data = (await res.json()) as { - errcode: number; - errmsg: string; - result?: { - cursor?: number; - has_more?: boolean; - result?: Array<{ id: number; name: string }>; - }; - }; - if (data.errcode !== 0) { - throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`); - } - - const page = data.result; - all.push(...(page?.result ?? []).map((s) => ({ id: s.id, name: s.name }))); - hasMore = page?.has_more ?? false; - if (hasMore) { - if (page?.cursor === undefined || page.cursor === cursor) { - throw new Error('钉钉查询班次失败: 分页游标无效'); - } - cursor = page.cursor; - } - } - - return all; + async queryShifts(...args: Parameters) { + return this.shifts.queryShifts(...args); } - - // ═══════════════════════════════════════════ - // 考勤排班 — 考勤组管理 - // ═══════════════════════════════════════════ - - /** 创建排班制考勤组 */ - async createAttendanceGroup(params: DingTalkGroupParams): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - - const topGroup = this.buildAttendanceGroupBody(params); - - const body = { op_user_id: params.owner, top_group: topGroup }; - - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/group/add?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }, - ); - const data = (await res.json()) as { - errcode: number; errmsg: string; - result?: { id: number }; - }; - if (data.errcode !== 0) { - throw new Error(`钉钉创建考勤组失败: ${data.errmsg} (code=${data.errcode})`); - } - this.logger.log(`钉钉考勤组创建成功: ${params.name} (id=${data.result?.id})`); - return data.result!.id; + async createAttendanceGroup( + ...args: Parameters + ) { + return this.groups.createAttendanceGroup(...args); } - /** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */ - async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id }; - - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/group/modify?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op_user_id: params.owner, top_group: topGroup }), - }, - ); - const data = (await res.json()) as { - errcode?: number; - errmsg?: string; - success?: boolean; - message?: string; - }; - const succeeded = data.success === true || data.errcode === 0; - if (!succeeded) { - throw new Error( - `钉钉更新考勤组失败: ${data.message || data.errmsg || '未知错误'} ` + - `(code=${data.errcode ?? 'unknown'})`, - ); - } - this.logger.log(`钉钉考勤组更新成功: ${params.name} (id=${params.id})`); + async updateAttendanceGroup( + ...args: Parameters + ) { + return this.groups.updateAttendanceGroup(...args); } - private buildAttendanceGroupBody(params: DingTalkGroupParams): Record { - const machineOnly = params.attendance_machine_only ?? false; - const topGroup: Record = { - name: params.name, - type: params.type, - owner: params.owner, - members: params.members.map((m) => ({ - role: m.role, - type: m.type, - user_id: m.user_id, - })), - enable_emp_select_class: machineOnly ? false : (params.enable_emp_select_class ?? true), - disable_check_without_schedule: machineOnly ? true : (params.disable_check_without_schedule ?? false), - disable_check_when_rest: params.disable_check_when_rest ?? true, - }; - if (params.shift_ids?.length) { - topGroup.shift_vo_list = params.shift_ids.map((id) => ({ id })); - } - if (machineOnly) { - Object.assign(topGroup, { - enable_outside_check: false, - enable_position_ble: false, - positions: [], - wifis: [], - }); - } - return topGroup; + async queryAttendanceGroups( + ...args: Parameters + ) { + return this.groups.queryAttendanceGroups(...args); } - /** 查询所有考勤组摘要(分页,每页10条) */ - async queryAttendanceGroups(_opUserId = 'manager'): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - - const all: DingTalkGroupSummary[] = []; - let offset = 0; - let hasMore = true; - - while (hasMore) { - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/getsimplegroups?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ offset, size: 10 }), - }, - ); - const data = (await res.json()) as { - errcode: number; errmsg: string; - result?: { - has_more: boolean; - groups: Array<{ group_id: number; group_name: string; type: string; member_count: number }>; - }; - }; - if (data.errcode !== 0) { - throw new Error(`钉钉查询考勤组失败: ${data.errmsg} (code=${data.errcode})`); - } - if (data.result?.groups) { - all.push(...data.result.groups.map((g) => ({ - group_id: g.group_id, - group_name: g.group_name, - type: g.type, - member_count: g.member_count, - }))); - } - hasMore = data.result?.has_more ?? false; - offset += 10; - } - return all; + async deleteAttendanceGroup( + ...args: Parameters + ) { + return this.groups.deleteAttendanceGroup(...args); } - async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - - await this.rateLimit(); - const keyResponse = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/groups/idtokey?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op_user_id: opUserId, group_id: groupId }), - }, - ); - const keyData = await keyResponse.json() as { - errcode: number; - errmsg: string; - result?: string; - }; - if (keyData.errcode !== 0 || !keyData.result) { - throw new Error(`钉钉考勤组ID转换失败: ${keyData.errmsg} (code=${keyData.errcode})`); - } - - await this.rateLimit(); - const deleteResponse = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/group/delete?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op_userid: opUserId, group_key: keyData.result }), - }, - ); - const deleteData = await deleteResponse.json() as { - errcode: number; - errmsg: string; - success?: boolean; - }; - if (deleteData.errcode !== 0 || deleteData.success !== true) { - throw new Error(`钉钉删除考勤组失败: ${deleteData.errmsg} (code=${deleteData.errcode})`); - } + async scheduleUsers(...args: Parameters) { + return this.schedules.scheduleUsers(...args); } - - // ═══════════════════════════════════════════ - // 考勤排班 — 排班分配 - // ═══════════════════════════════════════════ - - /** 批量排班(单次最多200条) */ - async scheduleUsers( - groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager', - ): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - if (schedules.length === 0) return; - if (schedules.length > 200) { - throw new Error(`排班单次最多200条,当前 ${schedules.length} 条`); - } - - const token = await this.getAccessToken(); - const body = { - op_user_id: opUserId, - group_id: groupId, - schedules: schedules.map((s) => ({ - userid: s.userid, - work_date: s.work_date, - shift_id: s.shift_id, - is_rest: s.is_rest ?? false, - })), - }; - - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/group/schedule/async?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }, - ); - const data = (await res.json()) as { - errcode: number; errmsg: string; - }; - if (data.errcode !== 0) { - throw new Error(`钉钉排班失败: ${data.errmsg} (code=${data.errcode})`); - } - this.logger.log(`钉钉排班成功: groupId=${groupId}, ${schedules.length} 条`); - } - - /** 查询指定用户的排班信息(7天内,最多50人) */ async queryScheduleByUsers( - userIds: string[], fromDate: number, toDate: number, opUserId = 'manager', - ): Promise { - if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); - const token = await this.getAccessToken(); - - await this.rateLimit(); - const res = await fetch( - `https://oapi.dingtalk.com/topapi/attendance/schedule/listbyusers?access_token=${token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - op_user_id: opUserId, - userids: userIds.join(','), - from_date_time: fromDate, - to_date_time: toDate, - }), - }, - ); - const data = (await res.json()) as { - errcode: number; errmsg: string; - result?: Array<{ - userid: string; work_date: string; shift_id: number; - is_rest: string; check_type: string; plan_check_time: string; - group_id: number; id: number; - }>; - }; - if (data.errcode !== 0) { - throw new Error(`钉钉查询排班失败: ${data.errmsg} (code=${data.errcode})`); - } - return (data.result ?? []).map((r) => ({ - userid: r.userid, - work_date: r.work_date, - shift_id: r.shift_id, - is_rest: r.is_rest, - check_type: r.check_type, - plan_check_time: r.plan_check_time, - group_id: r.group_id, - id: r.id, - })); + ...args: Parameters + ) { + return this.schedules.queryScheduleByUsers(...args); } private sleep(ms: number): Promise { diff --git a/apps/server/src/integration/jinshuju-student-sync.ts b/apps/server/src/integration/jinshuju-student-sync.ts index d37ef09..eed8dee 100644 --- a/apps/server/src/integration/jinshuju-student-sync.ts +++ b/apps/server/src/integration/jinshuju-student-sync.ts @@ -23,7 +23,6 @@ export async function syncJinshujuStudents( manager: EntityManager, entries: JinshujuEntry[], ): Promise { - // Extract name/phone from entries interface ParsedEntry { serialNumber: number; name: string; @@ -96,7 +95,6 @@ export async function syncJinshujuStudents( toCreate.push({ name: p.name, phone: p.phone }); } - // Create new students let created = 0; if (toCreate.length > 0) { const host = await manager.findOne(Organization, { where: { isHost: true, status: 'active' } }); diff --git a/apps/server/src/integration/jinshuju.service.ts b/apps/server/src/integration/jinshuju.service.ts index 4951fab..d8861d8 100644 --- a/apps/server/src/integration/jinshuju.service.ts +++ b/apps/server/src/integration/jinshuju.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { JINSHUJU_API_BASE } from './endpoints'; export interface JinshujuEntry { serial_number: number; @@ -15,6 +16,7 @@ export interface JinshujuEntriesResponse { next: number | null; } +// aislop-ignore-next-line: duplicate-type-declaration -- 与前端 JinshujuMatchModal 的 API 契约保持一致 export interface JinshujuFormField { key: string; label: string; @@ -29,7 +31,6 @@ interface JinshujuFormResponse { @Injectable() export class JinshujuService { private readonly logger = new Logger(JinshujuService.name); - private static readonly BASE = 'https://jinshuju.net/api/v1'; private getAuthorization(apiKey: string, apiSecret: string): string { return `Basic ${Buffer.from(`${apiKey}:${apiSecret}`).toString('base64')}`; @@ -41,7 +42,7 @@ export class JinshujuService { formToken: string, ): Promise<{ name: string; fields: JinshujuFormField[] }> { const response = await fetch( - `${JinshujuService.BASE}/forms/${encodeURIComponent(formToken)}`, + `${JINSHUJU_API_BASE}/forms/${encodeURIComponent(formToken)}`, { headers: { Authorization: this.getAuthorization(apiKey, apiSecret), @@ -74,7 +75,7 @@ export class JinshujuService { let next: number | null | undefined = undefined; do { - const url = new URL(`${JinshujuService.BASE}/forms/${encodeURIComponent(formToken)}/entries`); + const url = new URL(`${JINSHUJU_API_BASE}/forms/${encodeURIComponent(formToken)}/entries`); if (next) url.searchParams.set('next', String(next)); this.logger.log(`Fetching Jinshuju entries: ${url.toString().replace(/api_key=[^&]+/, 'api_key=***')}`); diff --git a/apps/server/src/integration/wecom.service.ts b/apps/server/src/integration/wecom.service.ts index f8bcfbd..bc59189 100644 --- a/apps/server/src/integration/wecom.service.ts +++ b/apps/server/src/integration/wecom.service.ts @@ -2,6 +2,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../entities/user.entity'; +import { + WECOM_API_BASE, + WECOM_DEPARTMENT_PATH, + WECOM_TOKEN_PATH, + WECOM_USER_PATH, +} from './endpoints'; interface WeComTokenResponse { errcode: number; @@ -48,7 +54,7 @@ export class WeComService { } const corpId = process.env.WECOM_CORP_ID!; const corpSecret = process.env.WECOM_CORP_SECRET!; - const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${corpSecret}`; + const url = `${WECOM_API_BASE}${WECOM_TOKEN_PATH}?corpid=${corpId}&corpsecret=${corpSecret}`; const res = await fetch(url); const body: WeComTokenResponse = await res.json(); if (body.errcode !== 0) { @@ -64,7 +70,7 @@ export class WeComService { parentId = 1, ): Promise> { const all: WeComDeptListResponse['department'] = []; - const url = `https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token=${token}&id=${parentId}`; + const url = `${WECOM_API_BASE}${WECOM_DEPARTMENT_PATH}?access_token=${token}&id=${parentId}`; const res = await fetch(url); const body: WeComDeptListResponse = await res.json(); if (body.errcode !== 0) { @@ -85,7 +91,7 @@ export class WeComService { token: string, deptId: number, ): Promise> { - const url = `https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=${token}&department_id=${deptId}&fetch_child=1`; + const url = `${WECOM_API_BASE}${WECOM_USER_PATH}?access_token=${token}&department_id=${deptId}&fetch_child=1`; const res = await fetch(url); const body: WeComUserListResponse = await res.json(); if (body.errcode !== 0) { diff --git a/apps/server/src/occupancies/occupancies.controller.spec.ts b/apps/server/src/occupancies/occupancies.controller.spec.ts index 605d00b..d8e16bb 100644 --- a/apps/server/src/occupancies/occupancies.controller.spec.ts +++ b/apps/server/src/occupancies/occupancies.controller.spec.ts @@ -23,4 +23,13 @@ describe('OccupanciesController permissions', () => { Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.downloadTemplate), ).toEqual(['occupancy:view']); }); + + it('requires occupancy:purge on permanent delete routes', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.purge)).toEqual([ + 'occupancy:purge', + ]); + expect( + Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.batchPurge), + ).toEqual(['occupancy:purge']); + }); }); diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index 5255169..ee49fa3 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -27,6 +27,7 @@ import { NotificationType } from '../entities/notification.entity'; import { CheckInDto, CheckOutDto, TransferRoomDto, BatchCheckOutDto } from './dto/occupancy.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { logAudit } from '../common/with-audit-log'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchIdsDto } from '../common/batch-ids.dto'; @@ -66,16 +67,9 @@ export class OccupanciesController { @RequirePermission('occupancy:delete') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRestore(dto.ids); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '批量恢复入住记录', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '批量恢复入住记录', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -83,16 +77,9 @@ export class OccupanciesController { @Post('batch-check-out') @RequirePermission('occupancy:checkout') async batchCheckOut(@Body() dto: BatchCheckOutDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchCheckOut(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '批量退宿', - detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '批量退宿', detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`, }); return result; } @@ -100,18 +87,9 @@ export class OccupanciesController { @Post('check-in') @RequirePermission('occupancy:checkin') async checkIn(@Body() dto: CheckInDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.checkIn(dto, req.user?.id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '办理入住', - targetId: result.id, - targetType: 'occupancy', - detail: `学生${dto.studentId} 入住房间${dto.roomId}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '办理入住', targetId: result.id, targetType: 'occupancy', detail: `学生${dto.studentId} 入住房间${dto.roomId}`, }); // Send check_in notification try { @@ -133,17 +111,9 @@ export class OccupanciesController { @Put(':id/check-out') @RequirePermission('occupancy:checkout') async checkOut(@Param('id') id: string, @Body() dto: CheckOutDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.checkOut(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '办理退宿', - targetId: +id, - targetType: 'occupancy', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '办理退宿', targetId: +id, targetType: 'occupancy', }); // Send check_out notification try { @@ -165,18 +135,9 @@ export class OccupanciesController { @Put(':id/transfer') @RequirePermission('occupancy:transfer') async transferRoom(@Param('id') id: string, @Body() dto: TransferRoomDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.transferRoom(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '调换宿舍', - targetId: +id, - targetType: 'occupancy', - detail: `换到房间${dto.newRoomId}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '调换宿舍', targetId: +id, targetType: 'occupancy', detail: `换到房间${dto.newRoomId}`, }); return result; } @@ -184,17 +145,9 @@ export class OccupanciesController { @Delete(':id') @RequirePermission('occupancy:delete') async remove(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '归档入住记录', - targetId: +id, - targetType: 'occupancy', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '归档入住记录', targetId: +id, targetType: 'occupancy', }); return result; } @@ -202,16 +155,29 @@ export class OccupanciesController { @Post('batch-delete') @RequirePermission('occupancy:delete') async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRemove(body.ids || []); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '入住管理', - action: '批量归档入住记录', - detail: `IDs: ${(body.ids || []).join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '入住管理', action: '批量归档入住记录', detail: `IDs: ${(body.ids || []).join(',')}`, + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('occupancy:purge') + async purge(@Param('id') id: string, @Request() req: any) { + const result = await this.service.purge(+id); + await logAudit(this.logService, req, { + module: '入住管理', action: '永久删除入住记录', targetId: +id, targetType: 'occupancy', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('batch-permanent-delete') + @RequirePermission('occupancy:purge') + async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + const result = await this.service.batchPurge(body.ids || []); + await logAudit(this.logService, req, { + module: '入住管理', action: '批量永久删除入住记录', detail: `IDs: ${(body.ids || []).join(',')}`, }); return result; } @@ -298,7 +264,7 @@ export class OccupanciesController { const { ipAddress, userAgent } = extractRequestInfo(req); if (!file?.buffer) throw new BadRequestException('请上传入住名单 Excel 文件'); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as any); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const ws = workbook.worksheets[0]; const rows = parseOccupancyImportWorksheet(ws); const result = await this.service.batchImportCheckIn(rows, { diff --git a/apps/server/src/occupancies/occupancies.module.ts b/apps/server/src/occupancies/occupancies.module.ts index fab0445..c54bb76 100644 --- a/apps/server/src/occupancies/occupancies.module.ts +++ b/apps/server/src/occupancies/occupancies.module.ts @@ -7,19 +7,31 @@ import { Deposit } from '../entities/deposit.entity'; import { Bed } from '../entities/bed.entity'; import { Locker } from '../entities/locker.entity'; import { Organization } from '../entities/organization.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; import { OccupanciesService } from './occupancies.service'; +import { OccupancyOperationsService } from './occupancy-operations.service'; +import { OccupancyImportService } from './occupancy-import.service'; import { OccupanciesController } from './occupancies.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Occupancy, Room, Student, Deposit, Bed, Locker, Organization]), + TypeOrmModule.forFeature([ + Occupancy, + Room, + Student, + Deposit, + Bed, + Locker, + Organization, + RoomInspectionDetail, + ]), OperationLogsModule, NotificationsModule, ], controllers: [OccupanciesController], - providers: [OccupanciesService], + providers: [OccupanciesService, OccupancyOperationsService, OccupancyImportService], exports: [OccupanciesService], }) export class OccupanciesModule {} diff --git a/apps/server/src/occupancies/occupancies.purge.spec.ts b/apps/server/src/occupancies/occupancies.purge.spec.ts new file mode 100644 index 0000000..8f6a14e --- /dev/null +++ b/apps/server/src/occupancies/occupancies.purge.spec.ts @@ -0,0 +1,80 @@ +import { BadRequestException } from '@nestjs/common'; +import { OccupanciesService } from './occupancies.service'; +import { OccupancyOperationsService } from './occupancy-operations.service'; + +describe('OccupanciesService.purge', () => { + const createService = (overrides?: { + occupancy?: Record; + detailCount?: number; + }) => { + const occ = { id: 1, status: 'archived', student: { name: '张三' }, ...overrides?.occupancy }; + const repo = { + findOne: jest.fn().mockResolvedValue(occ), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + find: jest.fn().mockResolvedValue([occ]), + }; + const inspectionDetailRepo = { + count: jest.fn().mockResolvedValue(overrides?.detailCount ?? 0), + }; + const operations = new OccupancyOperationsService( + repo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + inspectionDetailRepo as never, + ); + const service = new OccupanciesService( + repo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + inspectionDetailRepo as never, + operations, + ); + return { service, repo, inspectionDetailRepo }; + }; + + it('rejects occupancies that are not archived', async () => { + const { service, repo } = createService({ occupancy: { status: 'active' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档入住记录可以永久删除,请先归档'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects occupancies referenced by inspection details', async () => { + const { service, repo } = createService({ detailCount: 1 }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('该入住记录已被查寝记录引用,无法永久删除'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived occupancy with no references', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ + message: '已永久删除入住记录(不可恢复)', + }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); + + it('batch purge skips referenced records', async () => { + const { service, repo, inspectionDetailRepo } = createService(); + repo.find = jest.fn().mockResolvedValue([ + { id: 1, status: 'archived', student: { name: '甲' } }, + { id: 2, status: 'archived', student: { name: '乙' } }, + ]); + inspectionDetailRepo.count.mockResolvedValueOnce(1).mockResolvedValueOnce(0); + const result = await service.batchPurge([1, 2]); + expect(result).toMatchObject({ deleted: 1, skipped: 1 }); + expect(repo.delete).toHaveBeenCalledWith(2); + }); +}); diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts index 79b2e18..372c435 100644 --- a/apps/server/src/occupancies/occupancies.service.spec.ts +++ b/apps/server/src/occupancies/occupancies.service.spec.ts @@ -1,5 +1,6 @@ import { Repository, DataSource } from 'typeorm'; import { OccupanciesService } from './occupancies.service'; +import { OccupancyOperationsService } from './occupancy-operations.service'; import { Occupancy } from '../entities/occupancy.entity'; import { Room } from '../entities/room.entity'; import { Student } from '../entities/student.entity'; @@ -108,6 +109,17 @@ describe('OccupanciesService — responsible organization', () => { student: { id: 3, gender: '男', organizationId: 7 }, bed: { id: 4, roomId: 2, status: 'available' }, }); + const operations = new OccupancyOperationsService( + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + createTransactionDataSource(manager), + {} as Repository, + ); const service = new OccupanciesService( {} as Repository, {} as Repository, @@ -117,6 +129,8 @@ describe('OccupanciesService — responsible organization', () => { {} as Repository, {} as Repository, createTransactionDataSource(manager), + {} as Repository, + operations, ); await service.checkIn({ @@ -151,6 +165,18 @@ describe('OccupanciesService — manual check-in deposit', () => { {} as Repository, {} as Repository, createTransactionDataSource(manager), + {} as Repository, + new OccupancyOperationsService( + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + createTransactionDataSource(manager), + {} as Repository, + ), ), manager, }; diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index 2dfdae1..6ba2d8e 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -1,16 +1,6 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { - Repository, - DataSource, - IsNull, - Between, - LessThanOrEqual, - MoreThanOrEqual, - In, - SelectQueryBuilder, - ObjectLiteral, -} from 'typeorm'; +import { Repository, DataSource, IsNull, SelectQueryBuilder, ObjectLiteral } from 'typeorm'; import { Occupancy } from '../entities/occupancy.entity'; import { Room } from '../entities/room.entity'; import { Student } from '../entities/student.entity'; @@ -18,10 +8,10 @@ import { Bed } from '../entities/bed.entity'; import { Locker } from '../entities/locker.entity'; import { Deposit } from '../entities/deposit.entity'; import { Organization } from '../entities/organization.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto'; -import { RoomsService } from '../rooms/rooms.service'; +import { OccupancyOperationsService } from './occupancy-operations.service'; -class ImportRowSkipped extends Error {} @Injectable() export class OccupanciesService { @@ -34,21 +24,37 @@ export class OccupanciesService { @InjectRepository(Locker) private lockerRepo: Repository, @InjectRepository(Organization) private organizationRepo: Repository, private dataSource: DataSource, + @InjectRepository(RoomInspectionDetail) + private inspectionDetailRepo: Repository, + @Optional() private operations?: OccupancyOperationsService, ) {} - private withPessimisticWriteLock( - qb: SelectQueryBuilder, - ): SelectQueryBuilder { - const type = this.dataSource.options.type; - if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') { - return qb.setLock('pessimistic_write'); + private get ops(): OccupancyOperationsService { + if (!this.operations) { + this.operations = new OccupancyOperationsService( + this.repo, + this.roomRepo, + this.studentRepo, + this.depositRepo, + this.bedRepo, + this.lockerRepo, + this.organizationRepo, + this.dataSource, + this.inspectionDetailRepo, + ); } - return qb; + return this.operations; } - async findAll(query?: { roomId?: number; studentId?: number; active?: boolean; status?: 'active' | 'archived' }) { + async findAll(query?: { + roomId?: number; + studentId?: number; + active?: boolean; + status?: 'active' | 'archived'; + }) { const status = query?.status ?? 'active'; - if (status !== 'active' && status !== 'archived') throw new BadRequestException('入住记录状态无效'); + if (status !== 'active' && status !== 'archived') + throw new BadRequestException('入住记录状态无效'); const qb = this.repo .createQueryBuilder('o') .leftJoinAndSelect('o.student', 'student') @@ -126,7 +132,8 @@ export class OccupanciesService { ); if (dto.bedId) await manager.update(Bed, dto.bedId, { status: 'occupied' }); if (dto.lockerId) await manager.update(Locker, dto.lockerId, { status: 'occupied' }); - if (count + 1 >= (room.capacity ?? 0)) await manager.update(Room, room.id, { status: 'full' }); + if (count + 1 >= (room.capacity ?? 0)) + await manager.update(Room, room.id, { status: 'full' }); if (dto.collectDeposit) { let deposit = await manager.findOne(Deposit, { where: { studentId: dto.studentId } }); if (deposit) { @@ -153,228 +160,65 @@ export class OccupanciesService { }); } + private withPessimisticWriteLock( + qb: SelectQueryBuilder, + ): SelectQueryBuilder { + const type = this.dataSource.options.type; + if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') { + return qb.setLock('pessimistic_write'); + } + return qb; + } + + private normalizePositiveMoney(value: number, label: string): number { + if (!Number.isFinite(value) || value < 0) { + throw new BadRequestException(`${label}必须为非负数字`); + } + return Math.round(value * 100) / 100; + } + + private assertDateOnly(value: string, label: string): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) { + throw new BadRequestException(`${label}格式错误,应为 YYYY-MM-DD`); + } + const date = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(date.getTime())) throw new BadRequestException(`${label}不是有效日期`); + } + + private assertDateOrder(start: string, end: string | undefined, message: string): void { + if (end && start > end) throw new BadRequestException(message); + } + async checkOut(occupancyId: number, dto: CheckOutDto) { - return this.dataSource.transaction(async (manager) => { - const occ = await this.withPessimisticWriteLock( - manager - .createQueryBuilder(Occupancy, 'occupancy') - .where('occupancy.id = :id', { id: occupancyId }), - ).getOne(); - if (!occ) throw new NotFoundException('入住记录不存在'); - if (occ.checkOutDate) throw new BadRequestException('该记录已退宿'); - this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); - this.assertDateOrder( - occ.billingStartDate || occ.checkInDate, - dto.billingEndDate || dto.checkOutDate, - '计费截止日不能早于计费起始日', - ); - occ.checkOutDate = dto.checkOutDate; - occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; - occ.checkOutReason = dto.checkOutReason || ''; - await manager.save(occ); - if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' }); - if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' }); - await manager.update(Room, occ.roomId, { status: 'available' }); - return occ; - }); + return this.ops.checkOut(occupancyId, dto); } async transferRoom(occupancyId: number, dto: TransferRoomDto) { - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - await runner.startTransaction(); - try { - const oldOcc = await this.withPessimisticWriteLock( - runner.manager - .createQueryBuilder(Occupancy, 'occupancy') - .where('occupancy.id = :id', { id: occupancyId }), - ).getOne(); - if (!oldOcc) throw new NotFoundException('入住记录不存在'); - if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿'); - if (oldOcc.roomId === dto.newRoomId) - throw new BadRequestException('目标宿舍不能与当前宿舍相同'); - this.assertDateOrder(oldOcc.checkInDate, dto.transferDate, '换房日期不能早于原入住日期'); - this.assertDateOrder( - oldOcc.billingStartDate || oldOcc.checkInDate, - dto.oldBillingEndDate || dto.transferDate, - '原宿舍计费截止日不能早于计费起始日', - ); - - // 退旧房 - oldOcc.checkOutDate = dto.transferDate; - oldOcc.billingEndDate = dto.oldBillingEndDate || dto.transferDate; - oldOcc.checkOutReason = dto.reason || '换房'; - await runner.manager.save(oldOcc); - // 释放旧床位/柜子 - if (oldOcc.bedId) { - await runner.manager.update(Bed, oldOcc.bedId, { status: 'available' }); - } - if (oldOcc.lockerId) { - await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' }); - } - await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); - // 检查新房容量 - const newRoom = await this.withPessimisticWriteLock( - runner.manager - .createQueryBuilder(Room, 'room') - .where('room.id = :roomId', { roomId: dto.newRoomId }), - ).getOne(); - if (!newRoom) throw new NotFoundException('目标宿舍不存在'); - if (newRoom.status === 'archived' || newRoom.status === 'maintenance') { - throw new BadRequestException('目标宿舍当前不可入住'); - } - const count = await runner.manager.count(Occupancy, { - where: { roomId: dto.newRoomId, checkOutDate: IsNull() }, - }); - if (count >= (newRoom.capacity ?? 0)) throw new BadRequestException('目标宿舍已满'); - - // 新床位校验 - if (dto.newBedId) { - const newBed = await this.withPessimisticWriteLock( - runner.manager - .createQueryBuilder(Bed, 'bed') - .where('bed.id = :bedId AND bed.roomId = :roomId', { - bedId: dto.newBedId, - roomId: dto.newRoomId, - }), - ).getOne(); - if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍'); - if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用'); - } - if (dto.newLockerId) { - const newLocker = await this.withPessimisticWriteLock( - runner.manager - .createQueryBuilder(Locker, 'locker') - .where('locker.id = :lockerId AND locker.roomId = :roomId', { - lockerId: dto.newLockerId, - roomId: dto.newRoomId, - }), - ).getOne(); - if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍'); - if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用'); - } - - // 计算新房计费起始日:默认为换房日期次日 - const transferDate = new Date(dto.transferDate); - const nextDay = new Date(transferDate); - nextDay.setDate(nextDay.getDate() + 1); - const defaultBillingStart = nextDay.toISOString().split('T')[0]; - this.assertDateOrder( - dto.transferDate, - dto.newBillingStartDate || defaultBillingStart, - '新宿舍计费起始日不能早于换房日期', - ); - - // 入住新房 - const newOcc = runner.manager.create(Occupancy, { - studentId: oldOcc.studentId, - roomId: dto.newRoomId, - checkInDate: dto.transferDate, - billingStartDate: dto.newBillingStartDate || defaultBillingStart, - stayType: oldOcc.stayType, - responsibleOrganizationId: oldOcc.responsibleOrganizationId, - notes: `从${oldOcc.roomId}号房换入`, - bedId: dto.newBedId, - lockerId: dto.newLockerId, - }); - await runner.manager.save(newOcc); - - // 更新新床位/柜子状态 - if (dto.newBedId) { - await runner.manager.update(Bed, dto.newBedId, { status: 'occupied' }); - } - if (dto.newLockerId) { - await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' }); - } - - if (count + 1 >= (newRoom.capacity ?? 0)) { - await runner.manager.update(Room, newRoom.id, { status: 'full' }); - } - - await runner.commitTransaction(); - return { oldOccupancy: oldOcc, newOccupancy: newOcc }; - } catch (err) { - await runner.rollbackTransaction(); - throw err; - } finally { - await runner.release(); - } + return this.ops.transferRoom(occupancyId, dto); } - // 获取某宿舍在指定时间段内的入住记录(用于计费) async getRoomOccupanciesInPeriod(roomId: number, periodStart: string, periodEnd: string) { - return this.repo - .createQueryBuilder('o') - .leftJoinAndSelect('o.student', 'student') - .where('o.roomId = :roomId', { roomId }) - .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) - .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) - .getMany(); + return this.ops.getRoomOccupanciesInPeriod(roomId, periodStart, periodEnd); } async remove(id: number) { - const occ = await this.repo.findOne({ where: { id } }); - if (!occ) throw new NotFoundException('入住记录不存在'); - if (!occ.checkOutDate) throw new BadRequestException('在住记录不能归档,请先办理退宿'); - if (occ.status === 'archived') throw new BadRequestException('入住记录已归档'); - await this.repo.update(id, { status: 'archived' }); - return { message: '已归档' }; + return this.ops.remove(id); } async batchRemove(ids: number[]) { - if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的记录'); - const records = await this.repo.find({ where: { id: In(ids) }, relations: ['student'] }); - const skipped: string[] = []; - const deletableIds: number[] = []; - for (const occ of records) { - if (!occ.checkOutDate) { - skipped.push(occ.student?.name || `记录${occ.id}`); - } else { - deletableIds.push(occ.id); - } - } - let archived = 0; - if (deletableIds.length > 0) { - const result = await this.repo - .createQueryBuilder() - .update() - .set({ status: 'archived' }) - .where('id IN (:...ids)', { ids: deletableIds }) - .execute(); - archived = result.affected || 0; - } - const message = - skipped.length > 0 - ? `成功归档 ${archived} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿` - : `批量归档成功,共 ${archived} 条`; - return { message, archived, skipped: skipped.length }; + return this.ops.batchRemove(ids); } async batchRestore(ids: number[]) { - const uniqueIds = [...new Set(ids || [])]; - if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录'); - if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { - throw new BadRequestException('入住记录 ID 无效'); - } - const records = await this.repo.find({ where: { id: In(uniqueIds) } }); - if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在'); - if (records.some((record) => record.status === 'archived' && !record.checkOutDate)) { - throw new BadRequestException('选中记录包含未退宿的异常归档记录'); - } + return this.ops.batchRestore(ids); + } - const targetIds = records.filter((record) => record.status === 'archived').map((record) => record.id); - const skipped = records.length - targetIds.length; - let restored = 0; - if (targetIds.length > 0) { - const result = await this.repo - .createQueryBuilder() - .update() - .set({ status: 'active' }) - .where('id IN (:...ids)', { ids: targetIds }) - .execute(); - restored = result.affected || 0; - } - return { message: `已批量恢复 ${restored} 条入住记录`, restored, skipped }; + async purge(id: number) { + return this.ops.purge(id); + } + + async batchPurge(ids: number[]) { + return this.ops.batchPurge(ids); } async batchCheckOut(dto: { @@ -383,71 +227,9 @@ export class OccupanciesService { billingEndDate?: string; checkOutReason?: string; }) { - if (!dto.ids || dto.ids.length === 0) { - throw new BadRequestException('请选择要退宿的记录'); - } - const runner = this.dataSource.createQueryRunner(); - await runner.connect(); - await runner.startTransaction(); - let success = 0; - const errors: string[] = []; - try { - for (const id of dto.ids) { - const occ = await runner.manager.findOne(Occupancy, { - where: { id }, - relations: ['student'], - }); - if (!occ) { - errors.push(`记录${id}不存在`); - continue; - } - if (occ.checkOutDate) { - errors.push(`${occ.student?.name || id}已退宿`); - continue; - } - try { - this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); - this.assertDateOrder( - occ.billingStartDate || occ.checkInDate, - dto.billingEndDate || dto.checkOutDate, - '计费截止日不能早于计费起始日', - ); - } catch (error) { - errors.push(`${occ.student?.name || id}: ${(error as BadRequestException).message}`); - continue; - } - occ.checkOutDate = dto.checkOutDate; - occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; - occ.checkOutReason = dto.checkOutReason || ''; - await runner.manager.save(occ); - // 更新房间状态 - await runner.manager.update(Room, occ.roomId, { status: 'available' }); - // 释放床位/柜子 - if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' }); - if (occ.lockerId) - await runner.manager.update(Locker, occ.lockerId, { status: 'available' }); - success++; - } - await runner.commitTransaction(); - } catch (err) { - await runner.rollbackTransaction(); - throw err; - } finally { - await runner.release(); - } - return { - success, - failed: errors.length, - message: `已成功退宿 ${success} 人${errors.length > 0 ? `,${errors.length} 条失败` : ''}`, - errors: errors.length > 0 ? errors : undefined, - }; + return this.ops.batchCheckOut(dto); } - /** - * 一键导入入住名单 - * 每行数据:姓名、电话、学号、房间号、楼栋、入住日期 - * 自动创建不存在的学生和宿舍,并登记入住 - */ async batchImportCheckIn( rows: { name: string; @@ -471,276 +253,6 @@ export class OccupanciesService { }[], options?: { autoDeposit?: boolean; depositAmount?: number }, ) { - let imported = 0; - let skipped = 0; - let depositsCreated = 0; - const errors: string[] = []; - const importDepositAmount = options?.autoDeposit - ? this.normalizePositiveMoney(options.depositAmount ?? 500, '押金金额') - : undefined; - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - const rowNum = i + 2; // Excel第2行开始(第1行是表头) - - if (!row.name?.trim() || !row.roomNumber?.trim()) { - skipped++; - continue; - } - - try { - const result = await this.dataSource.transaction(async (manager) => { - const occupancyRepo = manager.getRepository(Occupancy); - const roomRepo = manager.getRepository(Room); - const studentRepo = manager.getRepository(Student); - const depositRepo = manager.getRepository(Deposit); - const bedRepo = manager.getRepository(Bed); - const lockerRepo = manager.getRepository(Locker); - const organizationRepo = manager.getRepository(Organization); - let rowDepositsCreated = 0; - - // 1. 通过手机号关联学生;未找到时创建学生并归入本机构 - const phone = row.phone?.trim(); - if (!phone) throw new BadRequestException('手机号不能为空,无法关联学生'); - - let student = await studentRepo.findOne({ where: { phone } }); - if (!student) { - const hostOrganization = await organizationRepo.findOne({ - where: { isHost: true, status: 'active' }, - }); - if (!hostOrganization) throw new BadRequestException('尚未配置本机构'); - - student = await studentRepo.save( - studentRepo.create({ - name: row.name.trim(), - phone, - studentNo: row.studentNo?.trim() || undefined, - idNumber: row.idNumber?.trim() || undefined, - gender: row.gender?.trim() || undefined, - ethnicity: row.ethnicity?.trim() || undefined, - emergencyContact: row.emergencyContact?.trim() || undefined, - emergencyPhone: row.emergencyPhone?.trim() || undefined, - organizationId: hostOrganization.id, - supervisor: row.supervisor?.trim() || undefined, - }), - ); - } else { - // 更新已有学生的缺失信息 - const updates: any = {}; - if (!student.studentNo && row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); - if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); - if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim(); - if (!student.ethnicity && row.ethnicity?.trim()) updates.ethnicity = row.ethnicity.trim(); - if (!student.emergencyContact && row.emergencyContact?.trim()) - updates.emergencyContact = row.emergencyContact.trim(); - if (!student.emergencyPhone && row.emergencyPhone?.trim()) - updates.emergencyPhone = row.emergencyPhone.trim(); - if (!student.supervisor && row.supervisor?.trim()) - updates.supervisor = row.supervisor.trim(); - if (Object.keys(updates).length > 0) { - await studentRepo.update(student.id, updates); - Object.assign(student, updates); - } - } - - // 2. 查找或创建宿舍(使用智能解析) - let room = await roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); - if (!room) { - const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); - room = await roomRepo.save( - roomRepo.create({ - roomNumber: row.roomNumber.trim(), - building: row.building?.trim() || parsed.building || undefined, - floor: parsed.floor || undefined, - capacity: parsed.capacity || 4, - roomType: parsed.roomType || undefined, - }), - ); - } - - const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0]; - const checkOutDate = row.checkOutDate?.trim(); - const billingStartDate = row.billingStartDate?.trim() || checkInDate; - const isHistoricalRecord = Boolean(checkOutDate); - this.assertDateOnly(checkInDate, '入住日期'); - this.assertDateOnly(billingStartDate, '计费起始日'); - this.assertDateOrder(checkInDate, billingStartDate, '计费起始日不能早于入住日期'); - if (checkOutDate) { - this.assertDateOnly(checkOutDate, '退宿日期'); - this.assertDateOrder(checkInDate, checkOutDate, '退宿日期不能早于入住日期'); - this.assertDateOrder(billingStartDate, checkOutDate, '退宿日期不能早于计费起始日'); - } - - // 3. 检查是否已有活跃入住(历史记录不影响当前入住) - const existing = await occupancyRepo.findOne({ - where: { studentId: student.id, checkOutDate: IsNull() }, - relations: ['room'], - }); - if (existing && !isHistoricalRecord) { - throw new ImportRowSkipped( - `第${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`, - ); - } - - // 4. 检查宿舍容量 - const count = await occupancyRepo.count({ where: { roomId: room.id, checkOutDate: IsNull() } }); - if (!isHistoricalRecord && count >= (room.capacity ?? 0)) { - throw new ImportRowSkipped( - `第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity ?? '?'}),跳过 ${row.name}`, - ); - } - - // 5. 匹配或创建床位、柜子,并校验是否可用 - let bed: Bed | null = null; - if (row.bedNumber?.trim()) { - const bedNumber = row.bedNumber.trim(); - bed = await bedRepo.findOne({ where: { roomId: room.id, bedNumber } }); - if (!bed) { - const existingBedCount = await bedRepo.count({ where: { roomId: room.id } }); - if (existingBedCount >= (room.capacity ?? 0)) { - throw new BadRequestException( - `宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity ?? '?'}`, - ); - } - bed = await bedRepo.save( - bedRepo.create({ roomId: room.id, bedNumber, status: 'available' }), - ); - } - if (!isHistoricalRecord && bed.status !== 'available') { - throw new BadRequestException(`床位 ${bedNumber} 已被占用或维修中`); - } - } - - let locker: Locker | null = null; - if (row.lockerNumber?.trim()) { - const lockerNumber = row.lockerNumber.trim(); - locker = await lockerRepo.findOne({ where: { roomId: room.id, lockerNumber } }); - if (!locker) { - locker = await lockerRepo.save( - lockerRepo.create({ roomId: room.id, lockerNumber, status: 'available' }), - ); - } - if (!isHistoricalRecord && locker.status !== 'available') { - throw new BadRequestException(`柜子 ${lockerNumber} 已被占用或维修中`); - } - } - - // 6. 创建入住记录 - const occData: any = { - studentId: student.id, - roomId: room.id, - checkInDate, - billingStartDate, - stayType: row.stayType || undefined, - responsibleOrganizationId: student.organizationId, - notes: row.notes || undefined, - bedId: bed?.id, - lockerId: locker?.id, - }; - // 如果有退宿日期,直接记录 - if (checkOutDate) { - occData.checkOutDate = checkOutDate; - occData.billingEndDate = checkOutDate; - } - await occupancyRepo.save(occupancyRepo.create(occData)); - - // 7. 更新床位、柜子和宿舍状态 - if (!isHistoricalRecord) { - if (bed) await bedRepo.update(bed.id, { status: 'occupied' }); - if (locker) await lockerRepo.update(locker.id, { status: 'occupied' }); - if (count + 1 >= (room.capacity ?? 0)) { - await roomRepo.update(room.id, { status: 'full' }); - } - } - - // 9. 自动收取押金(仅对新入住且非历史记录的学生) - if (options?.autoDeposit && !isHistoricalRecord) { - const existingDeposit = await depositRepo.findOne({ - where: { studentId: student.id }, - }); - const depositAmount = importDepositAmount!; - const hasPaidDeposit = - existingDeposit?.status === 'paid' && Number(existingDeposit.amount || 0) > 0; - if (hasPaidDeposit) { - // 导入重试或重复导入时,已有已缴押金不重复收取。 - } else if (existingDeposit) { - existingDeposit.amount = depositAmount; - existingDeposit.status = 'paid'; - existingDeposit.paidDate = checkInDate; - existingDeposit.refundDate = null as unknown as string; - existingDeposit.refundAmount = null as unknown as number; - existingDeposit.refundedBy = null; - existingDeposit.refundedAt = null; - existingDeposit.notes = '入住导入自动收取'; - await depositRepo.save(existingDeposit); - rowDepositsCreated++; - } else { - await depositRepo.save( - depositRepo.create({ - studentId: student.id, - amount: depositAmount, - paidDate: checkInDate, - status: 'paid', - notes: '入住导入自动收取', - }), - ); - rowDepositsCreated++; - } - } - - return { depositsCreated: rowDepositsCreated }; - }); - - imported++; - depositsCreated += result.depositsCreated; - } catch (e: any) { - errors.push( - e instanceof ImportRowSkipped - ? e.message - : `第${rowNum}行: ${row.name} 导入失败 - ${e.message}`, - ); - skipped++; - } - } - - const depositMsg = depositsCreated > 0 ? `,自动收取 ${depositsCreated} 笔押金` : ''; - return { - message: `成功导入 ${imported} 条入住记录,跳过 ${skipped} 条${depositMsg}`, - imported, - skipped, - depositsCreated, - errors: errors.length > 0 ? errors : undefined, - }; - } - - private normalizePositiveMoney(value: number, label: string): number { - const amount = Number(value); - if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) { - throw new BadRequestException(`${label}最多保留两位小数`); - } - if (amount <= 0) throw new BadRequestException(`${label}必须大于0`); - return Number(amount.toFixed(2)); - } - - private assertDateOnly(value: string, label: string): void { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { - throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`); - } - const [year, month, day] = value.split('-').map(Number); - const date = new Date(Date.UTC(year, month - 1, day)); - if ( - date.getUTCFullYear() !== year || - date.getUTCMonth() + 1 !== month || - date.getUTCDate() !== day - ) { - throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`); - } - } - - private assertDateOrder(start: string, end: string | undefined, message: string): void { - this.assertDateOnly(start, '起始日期'); - if (!end) return; - this.assertDateOnly(end, '结束日期'); - if (end < start) throw new BadRequestException(message); + return this.ops.batchImportCheckIn(rows, options); } } diff --git a/apps/server/src/occupancies/occupancy-import-template.ts b/apps/server/src/occupancies/occupancy-import-template.ts index 4fbe5ff..6437e81 100644 --- a/apps/server/src/occupancies/occupancy-import-template.ts +++ b/apps/server/src/occupancies/occupancy-import-template.ts @@ -81,7 +81,7 @@ function parseDate(cell: ExcelJS.Cell | undefined): string { return `${year}-${month}-${day}`; } const text = cellText(cell); - const matched = text.match(/(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})/); + const matched = text.match(/(\d{4})[/\-.](\d{1,2})[/\-.](\d{1,2})/); if (!matched) return text; return `${matched[1]}-${matched[2].padStart(2, '0')}-${matched[3].padStart(2, '0')}`; } diff --git a/apps/server/src/occupancies/occupancy-import.service.ts b/apps/server/src/occupancies/occupancy-import.service.ts new file mode 100644 index 0000000..f1f9ec8 --- /dev/null +++ b/apps/server/src/occupancies/occupancy-import.service.ts @@ -0,0 +1,311 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { DataSource, IsNull } from 'typeorm'; +import { Occupancy, Room, Student, Deposit, Bed, Locker, Organization } from '../entities'; +import { RoomsService } from '../rooms/rooms.service'; + +class ImportRowSkipped extends Error {} + +@Injectable() +export class OccupancyImportService { + constructor(private dataSource: DataSource) {} + + async batchImportCheckIn( + rows: { + name: string; + phone?: string; + studentNo?: string; + idNumber?: string; + gender?: string; + ethnicity?: string; + emergencyContact?: string; + emergencyPhone?: string; + supervisor?: string; + roomNumber: string; + building?: string; + checkInDate: string; + billingStartDate?: string; + checkOutDate?: string; + bedNumber?: string; + lockerNumber?: string; + stayType?: string; + notes?: string; + }[], + options?: { autoDeposit?: boolean; depositAmount?: number }, + ) { + let imported = 0; + let skipped = 0; + let depositsCreated = 0; + const errors: string[] = []; + const importDepositAmount = options?.autoDeposit + ? this.normalizePositiveMoney(options.depositAmount ?? 500, '押金金额') + : undefined; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const rowNum = i + 2; // Excel第2行开始(第1行是表头) + + if (!row.name?.trim() || !row.roomNumber?.trim()) { + skipped++; + continue; + } + + try { + const result = await this.dataSource.transaction(async (manager) => { + const occupancyRepo = manager.getRepository(Occupancy); + const roomRepo = manager.getRepository(Room); + const studentRepo = manager.getRepository(Student); + const depositRepo = manager.getRepository(Deposit); + const bedRepo = manager.getRepository(Bed); + const lockerRepo = manager.getRepository(Locker); + const organizationRepo = manager.getRepository(Organization); + let rowDepositsCreated = 0; + + // 1. 通过手机号关联学生;未找到时创建学生并归入本机构 + const phone = row.phone?.trim(); + if (!phone) throw new BadRequestException('手机号不能为空,无法关联学生'); + + let student = await studentRepo.findOne({ where: { phone } }); + if (!student) { + const hostOrganization = await organizationRepo.findOne({ + where: { isHost: true, status: 'active' }, + }); + if (!hostOrganization) throw new BadRequestException('尚未配置本机构'); + + student = await studentRepo.save( + studentRepo.create({ + name: row.name.trim(), + phone, + studentNo: row.studentNo?.trim() || undefined, + idNumber: row.idNumber?.trim() || undefined, + gender: row.gender?.trim() || undefined, + ethnicity: row.ethnicity?.trim() || undefined, + emergencyContact: row.emergencyContact?.trim() || undefined, + emergencyPhone: row.emergencyPhone?.trim() || undefined, + organizationId: hostOrganization.id, + supervisor: row.supervisor?.trim() || undefined, + }), + ); + } else { + // 更新已有学生的缺失信息 + const updates: any = {}; + if (!student.studentNo && row.studentNo?.trim()) + updates.studentNo = row.studentNo.trim(); + if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); + if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim(); + if (!student.ethnicity && row.ethnicity?.trim()) + updates.ethnicity = row.ethnicity.trim(); + if (!student.emergencyContact && row.emergencyContact?.trim()) + updates.emergencyContact = row.emergencyContact.trim(); + if (!student.emergencyPhone && row.emergencyPhone?.trim()) + updates.emergencyPhone = row.emergencyPhone.trim(); + if (!student.supervisor && row.supervisor?.trim()) + updates.supervisor = row.supervisor.trim(); + if (Object.keys(updates).length > 0) { + await studentRepo.update(student.id, updates); + Object.assign(student, updates); + } + } + + // 2. 查找或创建宿舍(使用智能解析) + let room = await roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); + if (!room) { + const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); + room = await roomRepo.save( + roomRepo.create({ + roomNumber: row.roomNumber.trim(), + building: row.building?.trim() || parsed.building || undefined, + floor: parsed.floor || undefined, + capacity: parsed.capacity || 4, + roomType: parsed.roomType || undefined, + }), + ); + } + + const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0]; + const checkOutDate = row.checkOutDate?.trim(); + const billingStartDate = row.billingStartDate?.trim() || checkInDate; + const isHistoricalRecord = Boolean(checkOutDate); + this.assertDateOnly(checkInDate, '入住日期'); + this.assertDateOnly(billingStartDate, '计费起始日'); + this.assertDateOrder(checkInDate, billingStartDate, '计费起始日不能早于入住日期'); + if (checkOutDate) { + this.assertDateOnly(checkOutDate, '退宿日期'); + this.assertDateOrder(checkInDate, checkOutDate, '退宿日期不能早于入住日期'); + this.assertDateOrder(billingStartDate, checkOutDate, '退宿日期不能早于计费起始日'); + } + + // 3. 检查是否已有活跃入住(历史记录不影响当前入住) + const existing = await occupancyRepo.findOne({ + where: { studentId: student.id, checkOutDate: IsNull() }, + relations: ['room'], + }); + if (existing && !isHistoricalRecord) { + throw new ImportRowSkipped( + `第${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`, + ); + } + + // 4. 检查宿舍容量 + const count = await occupancyRepo.count({ + where: { roomId: room.id, checkOutDate: IsNull() }, + }); + if (!isHistoricalRecord && count >= (room.capacity ?? 0)) { + throw new ImportRowSkipped( + `第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity ?? '?'}),跳过 ${row.name}`, + ); + } + + // 5. 匹配或创建床位、柜子,并校验是否可用 + let bed: Bed | null = null; + if (row.bedNumber?.trim()) { + const bedNumber = row.bedNumber.trim(); + bed = await bedRepo.findOne({ where: { roomId: room.id, bedNumber } }); + if (!bed) { + const existingBedCount = await bedRepo.count({ where: { roomId: room.id } }); + if (existingBedCount >= (room.capacity ?? 0)) { + throw new BadRequestException( + `宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity ?? '?'}`, + ); + } + bed = await bedRepo.save( + bedRepo.create({ roomId: room.id, bedNumber, status: 'available' }), + ); + } + if (!isHistoricalRecord && bed.status !== 'available') { + throw new BadRequestException(`床位 ${bedNumber} 已被占用或维修中`); + } + } + + let locker: Locker | null = null; + if (row.lockerNumber?.trim()) { + const lockerNumber = row.lockerNumber.trim(); + locker = await lockerRepo.findOne({ where: { roomId: room.id, lockerNumber } }); + if (!locker) { + locker = await lockerRepo.save( + lockerRepo.create({ roomId: room.id, lockerNumber, status: 'available' }), + ); + } + if (!isHistoricalRecord && locker.status !== 'available') { + throw new BadRequestException(`柜子 ${lockerNumber} 已被占用或维修中`); + } + } + + // 6. 创建入住记录 + const occData: any = { + studentId: student.id, + roomId: room.id, + checkInDate, + billingStartDate, + stayType: row.stayType || undefined, + responsibleOrganizationId: student.organizationId, + notes: row.notes || undefined, + bedId: bed?.id, + lockerId: locker?.id, + }; + // 如果有退宿日期,直接记录 + if (checkOutDate) { + occData.checkOutDate = checkOutDate; + occData.billingEndDate = checkOutDate; + } + await occupancyRepo.save(occupancyRepo.create(occData)); + + // 7. 更新床位、柜子和宿舍状态 + if (!isHistoricalRecord) { + if (bed) await bedRepo.update(bed.id, { status: 'occupied' }); + if (locker) await lockerRepo.update(locker.id, { status: 'occupied' }); + if (count + 1 >= (room.capacity ?? 0)) { + await roomRepo.update(room.id, { status: 'full' }); + } + } + + // 9. 自动收取押金(仅对新入住且非历史记录的学生) + if (options?.autoDeposit && !isHistoricalRecord) { + const existingDeposit = await depositRepo.findOne({ + where: { studentId: student.id }, + }); + const depositAmount = importDepositAmount!; + const hasPaidDeposit = + existingDeposit?.status === 'paid' && Number(existingDeposit.amount || 0) > 0; + if (hasPaidDeposit) { + // 导入重试或重复导入时,已有已缴押金不重复收取。 + } else if (existingDeposit) { + existingDeposit.amount = depositAmount; + existingDeposit.status = 'paid'; + existingDeposit.paidDate = checkInDate; + (existingDeposit as { refundDate: string | null }).refundDate = null; + (existingDeposit as { refundAmount: number | null }).refundAmount = null; + (existingDeposit as { refundedBy: number | null }).refundedBy = null; + (existingDeposit as { refundedAt: Date | null }).refundedAt = null; + existingDeposit.notes = '入住导入自动收取'; + await depositRepo.save(existingDeposit); + rowDepositsCreated++; + } else { + await depositRepo.save( + depositRepo.create({ + studentId: student.id, + amount: depositAmount, + paidDate: checkInDate, + status: 'paid', + notes: '入住导入自动收取', + }), + ); + rowDepositsCreated++; + } + } + + return { depositsCreated: rowDepositsCreated }; + }); + + imported++; + depositsCreated += result.depositsCreated; + } catch (e: any) { + errors.push( + e instanceof ImportRowSkipped + ? e.message + : `第${rowNum}行: ${row.name} 导入失败 - ${e.message}`, + ); + skipped++; + } + } + + const depositMsg = depositsCreated > 0 ? `,自动收取 ${depositsCreated} 笔押金` : ''; + return { + message: `成功导入 ${imported} 条入住记录,跳过 ${skipped} 条${depositMsg}`, + imported, + skipped, + depositsCreated, + errors: errors.length > 0 ? errors : undefined, + }; + } + + private normalizePositiveMoney(value: number, label: string): number { + const amount = value; + if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) { + throw new BadRequestException(`${label}最多保留两位小数`); + } + if (amount <= 0) throw new BadRequestException(`${label}必须大于0`); + return Number(amount.toFixed(2)); + } + + private assertDateOnly(value: string, label: string): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`); + } + const [year, month, day] = value.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() + 1 !== month || + date.getUTCDate() !== day + ) { + throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`); + } + } + + private assertDateOrder(start: string, end: string | undefined, message: string): void { + this.assertDateOnly(start, '起始日期'); + if (!end) return; + this.assertDateOnly(end, '结束日期'); + if (end < start) throw new BadRequestException(message); + } +} diff --git a/apps/server/src/occupancies/occupancy-lock.ts b/apps/server/src/occupancies/occupancy-lock.ts new file mode 100644 index 0000000..3e7a16d --- /dev/null +++ b/apps/server/src/occupancies/occupancy-lock.ts @@ -0,0 +1,12 @@ +import type { SelectQueryBuilder, ObjectLiteral, DataSource } from 'typeorm'; + +export function withPessimisticWriteLock( + qb: SelectQueryBuilder, + dataSource: DataSource, +): SelectQueryBuilder { + const type = dataSource.options.type; + if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') { + return qb.setLock('pessimistic_write'); + } + return qb; +} diff --git a/apps/server/src/occupancies/occupancy-operations.service.ts b/apps/server/src/occupancies/occupancy-operations.service.ts new file mode 100644 index 0000000..749809c --- /dev/null +++ b/apps/server/src/occupancies/occupancy-operations.service.ts @@ -0,0 +1,420 @@ +import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource, IsNull, In } from 'typeorm'; +import { Occupancy } from '../entities/occupancy.entity'; +import { Room } from '../entities/room.entity'; +import { Student } from '../entities/student.entity'; +import { Bed } from '../entities/bed.entity'; +import { Locker } from '../entities/locker.entity'; +import { Deposit } from '../entities/deposit.entity'; +import { Organization } from '../entities/organization.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; +import { CheckOutDto, TransferRoomDto } from './dto/occupancy.dto'; +import { OccupancyImportService } from './occupancy-import.service'; +import { withPessimisticWriteLock } from './occupancy-lock'; + +@Injectable() +export class OccupancyOperationsService { + constructor( + @InjectRepository(Occupancy) private repo: Repository, + @InjectRepository(Room) private roomRepo: Repository, + @InjectRepository(Student) private studentRepo: Repository, + @InjectRepository(Deposit) private depositRepo: Repository, + @InjectRepository(Bed) private bedRepo: Repository, + @InjectRepository(Locker) private lockerRepo: Repository, + @InjectRepository(Organization) private organizationRepo: Repository, + private dataSource: DataSource, + @InjectRepository(RoomInspectionDetail) + private inspectionDetailRepo: Repository, + @Optional() private imports?: OccupancyImportService, + ) {} + + private get imp(): OccupancyImportService { + if (!this.imports) this.imports = new OccupancyImportService(this.dataSource); + return this.imports; + } + + private normalizePositiveMoney(value: number, label: string): number { + if (!Number.isFinite(value) || value < 0) { + throw new BadRequestException(`${label}必须为非负数字`); + } + return Math.round(value * 100) / 100; + } + + private assertDateOnly(value: string, label: string): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) { + throw new BadRequestException(`${label}格式错误,应为 YYYY-MM-DD`); + } + const date = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(date.getTime())) throw new BadRequestException(`${label}不是有效日期`); + } + + private assertDateOrder(start: string, end: string | undefined, message: string): void { + if (end && start > end) throw new BadRequestException(message); + } + + async checkOut(occupancyId: number, dto: CheckOutDto) { + return this.dataSource.transaction(async (manager) => { + const occ = await withPessimisticWriteLock( + manager + .createQueryBuilder(Occupancy, 'occupancy') + .where('occupancy.id = :id', { id: occupancyId }), + this.dataSource).getOne(); + if (!occ) throw new NotFoundException('入住记录不存在'); + if (occ.checkOutDate) throw new BadRequestException('该记录已退宿'); + this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); + this.assertDateOrder( + occ.billingStartDate || occ.checkInDate, + dto.billingEndDate || dto.checkOutDate, + '计费截止日不能早于计费起始日', + ); + occ.checkOutDate = dto.checkOutDate; + occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; + occ.checkOutReason = dto.checkOutReason || ''; + await manager.save(occ); + if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' }); + if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' }); + await manager.update(Room, occ.roomId, { status: 'available' }); + return occ; + }); + } + + async transferRoom(occupancyId: number, dto: TransferRoomDto) { + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + await runner.startTransaction(); + try { + const oldOcc = await withPessimisticWriteLock( + runner.manager + .createQueryBuilder(Occupancy, 'occupancy') + .where('occupancy.id = :id', { id: occupancyId }), + this.dataSource).getOne(); + if (!oldOcc) throw new NotFoundException('入住记录不存在'); + if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿'); + if (oldOcc.roomId === dto.newRoomId) + throw new BadRequestException('目标宿舍不能与当前宿舍相同'); + this.assertDateOrder(oldOcc.checkInDate, dto.transferDate, '换房日期不能早于原入住日期'); + this.assertDateOrder( + oldOcc.billingStartDate || oldOcc.checkInDate, + dto.oldBillingEndDate || dto.transferDate, + '原宿舍计费截止日不能早于计费起始日', + ); + + // 退旧房 + oldOcc.checkOutDate = dto.transferDate; + oldOcc.billingEndDate = dto.oldBillingEndDate || dto.transferDate; + oldOcc.checkOutReason = dto.reason || '换房'; + await runner.manager.save(oldOcc); + // 释放旧床位/柜子 + if (oldOcc.bedId) { + await runner.manager.update(Bed, oldOcc.bedId, { status: 'available' }); + } + if (oldOcc.lockerId) { + await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' }); + } + await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); + // 检查新房容量 + const newRoom = await withPessimisticWriteLock( + runner.manager + .createQueryBuilder(Room, 'room') + .where('room.id = :roomId', { roomId: dto.newRoomId }), + this.dataSource).getOne(); + if (!newRoom) throw new NotFoundException('目标宿舍不存在'); + if (newRoom.status === 'archived' || newRoom.status === 'maintenance') { + throw new BadRequestException('目标宿舍当前不可入住'); + } + const count = await runner.manager.count(Occupancy, { + where: { roomId: dto.newRoomId, checkOutDate: IsNull() }, + }); + if (count >= (newRoom.capacity ?? 0)) throw new BadRequestException('目标宿舍已满'); + + // 新床位校验 + if (dto.newBedId) { + const newBed = await withPessimisticWriteLock( + runner.manager + .createQueryBuilder(Bed, 'bed') + .where('bed.id = :bedId AND bed.roomId = :roomId', { + bedId: dto.newBedId, + roomId: dto.newRoomId, + }), + this.dataSource).getOne(); + if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍'); + if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用'); + } + if (dto.newLockerId) { + const newLocker = await withPessimisticWriteLock( + runner.manager + .createQueryBuilder(Locker, 'locker') + .where('locker.id = :lockerId AND locker.roomId = :roomId', { + lockerId: dto.newLockerId, + roomId: dto.newRoomId, + }), + this.dataSource).getOne(); + if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍'); + if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用'); + } + + // 计算新房计费起始日:默认为换房日期次日 + const transferDate = new Date(dto.transferDate); + const nextDay = new Date(transferDate); + nextDay.setDate(nextDay.getDate() + 1); + const defaultBillingStart = nextDay.toISOString().split('T')[0]; + this.assertDateOrder( + dto.transferDate, + dto.newBillingStartDate || defaultBillingStart, + '新宿舍计费起始日不能早于换房日期', + ); + + // 入住新房 + const newOcc = runner.manager.create(Occupancy, { + studentId: oldOcc.studentId, + roomId: dto.newRoomId, + checkInDate: dto.transferDate, + billingStartDate: dto.newBillingStartDate || defaultBillingStart, + stayType: oldOcc.stayType, + responsibleOrganizationId: oldOcc.responsibleOrganizationId, + notes: `从${oldOcc.roomId}号房换入`, + bedId: dto.newBedId, + lockerId: dto.newLockerId, + }); + await runner.manager.save(newOcc); + + // 更新新床位/柜子状态 + if (dto.newBedId) { + await runner.manager.update(Bed, dto.newBedId, { status: 'occupied' }); + } + if (dto.newLockerId) { + await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' }); + } + + if (count + 1 >= (newRoom.capacity ?? 0)) { + await runner.manager.update(Room, newRoom.id, { status: 'full' }); + } + + await runner.commitTransaction(); + return { oldOccupancy: oldOcc, newOccupancy: newOcc }; + } catch (err) { + await runner.rollbackTransaction(); + throw err; + } finally { + await runner.release(); + } + } + + // 获取某宿舍在指定时间段内的入住记录(用于计费) + async getRoomOccupanciesInPeriod(roomId: number, periodStart: string, periodEnd: string) { + return this.repo + .createQueryBuilder('o') + .leftJoinAndSelect('o.student', 'student') + .where('o.roomId = :roomId', { roomId }) + .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) + .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) + .getMany(); + } + + async remove(id: number) { + const occ = await this.repo.findOne({ where: { id } }); + if (!occ) throw new NotFoundException('入住记录不存在'); + if (!occ.checkOutDate) throw new BadRequestException('在住记录不能归档,请先办理退宿'); + if (occ.status === 'archived') throw new BadRequestException('入住记录已归档'); + await this.repo.update(id, { status: 'archived' }); + return { message: '已归档' }; + } + + async batchRemove(ids: number[]) { + if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的记录'); + const records = await this.repo.find({ where: { id: In(ids) }, relations: ['student'] }); + const skipped: string[] = []; + const deletableIds: number[] = []; + for (const occ of records) { + if (!occ.checkOutDate) { + skipped.push(occ.student?.name || `记录${occ.id}`); + } else { + deletableIds.push(occ.id); + } + } + let archived = 0; + if (deletableIds.length > 0) { + const result = await this.repo + .createQueryBuilder() + .update() + .set({ status: 'archived' }) + .where('id IN (:...ids)', { ids: deletableIds }) + .execute(); + archived = result.affected || 0; + } + const message = + skipped.length > 0 + ? `成功归档 ${archived} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿` + : `批量归档成功,共 ${archived} 条`; + return { message, archived, skipped: skipped.length }; + } + + async batchRestore(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('入住记录 ID 无效'); + } + const records = await this.repo.find({ where: { id: In(uniqueIds) } }); + if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在'); + if (records.some((record) => record.status === 'archived' && !record.checkOutDate)) { + throw new BadRequestException('选中记录包含未退宿的异常归档记录'); + } + + const targetIds = records + .filter((record) => record.status === 'archived') + .map((record) => record.id); + const skipped = records.length - targetIds.length; + let restored = 0; + if (targetIds.length > 0) { + const result = await this.repo + .createQueryBuilder() + .update() + .set({ status: 'active' }) + .where('id IN (:...ids)', { ids: targetIds }) + .execute(); + restored = result.affected || 0; + } + return { message: `已批量恢复 ${restored} 条入住记录`, restored, skipped }; + } + + async purge(id: number) { + const occ = await this.repo.findOne({ where: { id } }); + if (!occ) throw new NotFoundException('入住记录不存在'); + if (occ.status !== 'archived') + throw new BadRequestException('仅已归档入住记录可以永久删除,请先归档'); + const detailCount = await this.inspectionDetailRepo.count({ where: { occupancyId: id } }); + if (detailCount > 0) throw new BadRequestException('该入住记录已被查寝记录引用,无法永久删除'); + await this.repo.delete(id); + return { message: '已永久删除入住记录(不可恢复)' }; + } + + async batchPurge(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的入住记录'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('入住记录 ID 无效'); + } + const records = await this.repo.find({ where: { id: In(uniqueIds) }, relations: ['student'] }); + if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在'); + + const deleted: number[] = []; + const skipped: string[] = []; + for (const occ of records) { + if (occ.status !== 'archived') { + skipped.push(`${occ.student?.name || `记录${occ.id}`}(未归档)`); + continue; + } + const detailCount = await this.inspectionDetailRepo.count({ where: { occupancyId: occ.id } }); + if (detailCount > 0) { + skipped.push(`${occ.student?.name || `记录${occ.id}`}(存在关联数据)`); + continue; + } + await this.repo.delete(occ.id); + deleted.push(occ.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 条;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 条入住记录(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + + async batchCheckOut(dto: { + ids: number[]; + checkOutDate: string; + billingEndDate?: string; + checkOutReason?: string; + }) { + if (!dto.ids || dto.ids.length === 0) { + throw new BadRequestException('请选择要退宿的记录'); + } + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + await runner.startTransaction(); + let success = 0; + const errors: string[] = []; + try { + for (const id of dto.ids) { + const occ = await runner.manager.findOne(Occupancy, { + where: { id }, + relations: ['student'], + }); + if (!occ) { + errors.push(`记录${id}不存在`); + continue; + } + if (occ.checkOutDate) { + errors.push(`${occ.student?.name || id}已退宿`); + continue; + } + try { + this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); + this.assertDateOrder( + occ.billingStartDate || occ.checkInDate, + dto.billingEndDate || dto.checkOutDate, + '计费截止日不能早于计费起始日', + ); + } catch (error) { + errors.push(`${occ.student?.name || id}: ${(error as BadRequestException).message}`); + continue; + } + occ.checkOutDate = dto.checkOutDate; + occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; + occ.checkOutReason = dto.checkOutReason || ''; + await runner.manager.save(occ); + // 更新房间状态 + await runner.manager.update(Room, occ.roomId, { status: 'available' }); + // 释放床位/柜子 + if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' }); + if (occ.lockerId) + await runner.manager.update(Locker, occ.lockerId, { status: 'available' }); + success++; + } + await runner.commitTransaction(); + } catch (err) { + await runner.rollbackTransaction(); + throw err; + } finally { + await runner.release(); + } + return { + success, + failed: errors.length, + message: `已成功退宿 ${success} 人${errors.length > 0 ? `,${errors.length} 条失败` : ''}`, + errors: errors.length > 0 ? errors : undefined, + }; + } + + /** + * 一键导入入住名单 + * 每行数据:姓名、电话、学号、房间号、楼栋、入住日期 + * 自动创建不存在的学生和宿舍,并登记入住 + */ + async batchImportCheckIn( + rows: { + name: string; + phone?: string; + studentNo?: string; + idNumber?: string; + gender?: string; + ethnicity?: string; + emergencyContact?: string; + emergencyPhone?: string; + supervisor?: string; + roomNumber: string; + building?: string; + checkInDate: string; + billingStartDate?: string; + checkOutDate?: string; + bedNumber?: string; + lockerNumber?: string; + stayType?: string; + notes?: string; + }[], + options?: { autoDeposit?: boolean; depositAmount?: number }, + ) { + return this.imp.batchImportCheckIn(rows, options); + } +} diff --git a/apps/server/src/organizations/organizations.controller.spec.ts b/apps/server/src/organizations/organizations.controller.spec.ts index feb7131..9b02a3e 100644 --- a/apps/server/src/organizations/organizations.controller.spec.ts +++ b/apps/server/src/organizations/organizations.controller.spec.ts @@ -21,3 +21,25 @@ describe('OrganizationsController permissions', () => { ]); }); }); + +describe('OrganizationsController', () => { + it('requires organization:purge on permanent delete route', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.purge)).toEqual([ + 'organization:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { + purge: jest.fn().mockResolvedValue({ message: '已永久删除机构(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new OrganizationsController(service as never, { log } as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge('1', req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '机构管理', action: '永久删除机构', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/organizations/organizations.controller.ts b/apps/server/src/organizations/organizations.controller.ts index 6650420..9548256 100644 --- a/apps/server/src/organizations/organizations.controller.ts +++ b/apps/server/src/organizations/organizations.controller.ts @@ -101,4 +101,23 @@ export class OrganizationsController { }); return result; } + + @Delete(':id/permanent') + @RequirePermission('organization:purge') + async purge(@Param('id') id: string, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.purge(+id); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '机构管理', + action: '永久删除机构', + targetId: +id, + targetType: 'organization', + detail: '物理删除,不可恢复', + ipAddress, + userAgent, + }); + return result; + } } diff --git a/apps/server/src/organizations/organizations.module.ts b/apps/server/src/organizations/organizations.module.ts index 763d34f..81172a1 100644 --- a/apps/server/src/organizations/organizations.module.ts +++ b/apps/server/src/organizations/organizations.module.ts @@ -1,12 +1,18 @@ import { Module, OnModuleInit } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Organization } from '../entities/organization.entity'; +import { Student } from '../entities/student.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { ClassroomRental } from '../entities/classroom-rental.entity'; import { OrganizationsService } from './organizations.service'; import { OrganizationsController } from './organizations.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; @Module({ - imports: [TypeOrmModule.forFeature([Organization]), OperationLogsModule], + imports: [ + TypeOrmModule.forFeature([Organization, Student, Occupancy, ClassroomRental]), + OperationLogsModule, + ], controllers: [OrganizationsController], providers: [OrganizationsService], exports: [OrganizationsService], diff --git a/apps/server/src/organizations/organizations.purge.spec.ts b/apps/server/src/organizations/organizations.purge.spec.ts new file mode 100644 index 0000000..193a521 --- /dev/null +++ b/apps/server/src/organizations/organizations.purge.spec.ts @@ -0,0 +1,78 @@ +import { BadRequestException } from '@nestjs/common'; +import { OrganizationsService } from './organizations.service'; + +describe('OrganizationsService.purge', () => { + const createService = (overrides?: { + organization?: Record; + studentCount?: number; + occupancyCount?: number; + lessorCount?: number; + lesseeCount?: number; + }) => { + const organization = { + id: 1, + name: '合作机构', + status: 'archived', + isHost: false, + ...overrides?.organization, + }; + const repo = { + findOne: jest.fn().mockResolvedValue(organization), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const studentRepo = { count: jest.fn().mockResolvedValue(overrides?.studentCount ?? 0) }; + const occupancyRepo = { count: jest.fn().mockResolvedValue(overrides?.occupancyCount ?? 0) }; + const rentalRepo = { + count: jest.fn().mockResolvedValue(overrides?.lessorCount ?? 0), + }; + rentalRepo.count.mockResolvedValueOnce(overrides?.lessorCount ?? 0); + rentalRepo.count.mockResolvedValueOnce(overrides?.lesseeCount ?? 0); + const service = new OrganizationsService( + repo as never, + studentRepo as never, + occupancyRepo as never, + rentalRepo as never, + ); + return { service, repo, studentRepo, occupancyRepo, rentalRepo }; + }; + + it('rejects organizations that are not archived or are the host', async () => { + const notArchived = createService({ organization: { status: 'active' } }); + await expect(notArchived.service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档机构可以永久删除,请先归档'), + ); + + const host = createService({ organization: { isHost: true } }); + await expect(host.service.purge(1)).rejects.toThrow( + new BadRequestException('本机构不能永久删除'), + ); + expect(notArchived.repo.delete).not.toHaveBeenCalled(); + expect(host.repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects organizations with student, occupancy, or rental references', async () => { + const withStudents = createService({ studentCount: 1 }); + await expect(withStudents.service.purge(1)).rejects.toThrow( + new BadRequestException('该机构存在关联数据(学生归属),无法永久删除'), + ); + + const withOccupancy = createService({ occupancyCount: 1 }); + await expect(withOccupancy.service.purge(1)).rejects.toThrow( + new BadRequestException('该机构存在关联数据(入住责任机构),无法永久删除'), + ); + + const withLessee = createService({ lesseeCount: 1 }); + await expect(withLessee.service.purge(1)).rejects.toThrow( + new BadRequestException('该机构存在关联数据(承租租赁订单),无法永久删除'), + ); + expect(withLessee.repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived organization with no references', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ + message: '已永久删除机构(不可恢复)', + }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/server/src/organizations/organizations.service.ts b/apps/server/src/organizations/organizations.service.ts index f4128cb..ee38778 100644 --- a/apps/server/src/organizations/organizations.service.ts +++ b/apps/server/src/organizations/organizations.service.ts @@ -3,6 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Not, Repository } from 'typeorm'; import { uuidV7 } from '../common/uuid-v7'; import { Organization } from '../entities/organization.entity'; +import { Student } from '../entities/student.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { ClassroomRental } from '../entities/classroom-rental.entity'; import { CreateOrganizationDto, UpdateOrganizationDto } from './dto/organization.dto'; const COLOR_PALETTE = [ @@ -20,7 +23,12 @@ const COLOR_PALETTE = [ @Injectable() export class OrganizationsService { - constructor(@InjectRepository(Organization) private repo: Repository) {} + constructor( + @InjectRepository(Organization) private repo: Repository, + @InjectRepository(Student) private studentRepo: Repository, + @InjectRepository(Occupancy) private occupancyRepo: Repository, + @InjectRepository(ClassroomRental) private rentalRepo: Repository, + ) {} async findAll(query?: { includeArchived?: boolean; scope?: 'all' | 'host' | 'external' }) { const where: Record = {}; @@ -86,4 +94,28 @@ export class OrganizationsService { await this.repo.update(id, { status: 'archived' }); return { message: '已归档' }; } + + async purge(id: number) { + const organization = await this.findOne(id); + if (organization.status !== 'archived') { + throw new BadRequestException('仅已归档机构可以永久删除,请先归档'); + } + if (organization.isHost) throw new BadRequestException('本机构不能永久删除'); + const [studentCount, occupancyCount, lessorCount, lesseeCount] = await Promise.all([ + this.studentRepo.count({ where: { organizationId: id } }), + this.occupancyRepo.count({ where: { responsibleOrganizationId: id } }), + this.rentalRepo.count({ where: { lessorOrganizationId: id } }), + this.rentalRepo.count({ where: { lesseeOrganizationId: id } }), + ]); + const references: string[] = []; + if (studentCount > 0) references.push('学生归属'); + if (occupancyCount > 0) references.push('入住责任机构'); + if (lessorCount > 0) references.push('出租租赁订单'); + if (lesseeCount > 0) references.push('承租租赁订单'); + if (references.length > 0) { + throw new BadRequestException(`该机构存在关联数据(${references.join('、')}),无法永久删除`); + } + await this.repo.delete(id); + return { message: '已永久删除机构(不可恢复)' }; + } } diff --git a/apps/server/src/rooms/room-bed-locker.service.ts b/apps/server/src/rooms/room-bed-locker.service.ts new file mode 100644 index 0000000..cfe8d00 --- /dev/null +++ b/apps/server/src/rooms/room-bed-locker.service.ts @@ -0,0 +1,190 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Not } from 'typeorm'; +import { Room } from '../entities/room.entity'; +import { Bed } from '../entities/bed.entity'; +import { Locker } from '../entities/locker.entity'; +import type { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto'; +import type { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto'; + +@Injectable() +export class RoomBedLockerService { + constructor( + @InjectRepository(Room) private repo: Repository, + @InjectRepository(Bed) private bedRepo: Repository, + @InjectRepository(Locker) private lockerRepo: Repository, + ) {} + + async getRoomBeds(roomId: number): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + return this.bedRepo.find({ + where: { roomId, status: Not('archived') }, + order: { bedNumber: 'ASC' }, + }); + } + + async getRoomAvailableBeds(roomId: number): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + return this.bedRepo.find({ + where: { roomId, status: 'available' }, + order: { bedNumber: 'ASC' }, + }); + } + + async createBed(roomId: number, dto: CreateBedDto): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); + await this.assertCanAddBeds(room, 1); + const existing = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } }); + if (existing) throw new BadRequestException('该床位编号已存在'); + const bed = this.bedRepo.create({ ...dto, roomId }); + return this.bedRepo.save(bed); + } + + async updateBed(roomId: number, id: number, dto: UpdateBedDto): Promise { + const bed = await this.bedRepo.findOne({ where: { id, roomId } }); + if (!bed) throw new NotFoundException('床位不存在'); + // 不允许将 occupied 的床位改为 maintenance + if (dto.status === 'maintenance' && bed.status === 'occupied') { + throw new BadRequestException('该床位有人入住,请先退宿'); + } + // 编号唯一性检查 + if (dto.bedNumber && dto.bedNumber !== bed.bedNumber) { + const dup = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } }); + if (dup) throw new BadRequestException('该床位编号已存在'); + } + Object.assign(bed, dto); + return this.bedRepo.save(bed); + } + + async deleteBed(roomId: number, id: number): Promise { + const bed = await this.bedRepo.findOne({ where: { id, roomId } }); + if (!bed) throw new NotFoundException('床位不存在'); + if (bed.status === 'occupied') throw new BadRequestException('该床位有人入住,无法归档'); + if (bed.status === 'archived') throw new BadRequestException('该床位已归档'); + await this.bedRepo.update(id, { status: 'archived' }); + } + + async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); + const existing = await this.bedRepo.find({ + where: { roomId, status: Not('archived') }, + order: { bedNumber: 'ASC' }, + }); + this.assertCanAddBedsFromCount(room, existing.length, dto.count); + const numbers = existing.map((b) => { + const match = b.bedNumber.match(/^\d+/); + return match ? parseInt(match[0]) : 0; + }); + const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1; + const beds: Bed[] = []; + for (let i = 0; i < dto.count; i++) { + beds.push(this.bedRepo.create({ roomId, bedNumber: `${start + i}号床` })); + } + return this.bedRepo.save(beds); + } + + + getNextBedNumber(beds: Pick[]): number { + const numbers = beds.map((bed) => { + const match = bed.bedNumber.match(/^\d+/); + return match ? parseInt(match[0], 10) : 0; + }); + return numbers.length > 0 ? Math.max(...numbers) + 1 : 1; + } + + private async assertCanAddBeds(room: Room, count: number): Promise { + const existingCount = await this.bedRepo.count({ where: { roomId: room.id } }); + this.assertCanAddBedsFromCount(room, existingCount, count); + } + + private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void { + const remaining = Math.max((room.capacity ?? 0) - existingCount, 0); + if (count > remaining) { + throw new BadRequestException( + `床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining} 张`, + ); + } + } + + // ── 柜子管理 ── + + async getRoomLockers(roomId: number): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + return this.lockerRepo.find({ + where: { roomId, status: Not('archived') }, + order: { lockerNumber: 'ASC' }, + }); + } + + async getRoomAvailableLockers(roomId: number): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + return this.lockerRepo.find({ + where: { roomId, status: 'available' }, + order: { lockerNumber: 'ASC' }, + }); + } + + async createLocker(roomId: number, dto: CreateLockerDto): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); + const existing = await this.lockerRepo.findOne({ + where: { roomId, lockerNumber: dto.lockerNumber }, + }); + if (existing) throw new BadRequestException('该柜子编号已存在'); + const locker = this.lockerRepo.create({ ...dto, roomId }); + return this.lockerRepo.save(locker); + } + + async updateLocker(roomId: number, id: number, dto: UpdateLockerDto): Promise { + const locker = await this.lockerRepo.findOne({ where: { id, roomId } }); + if (!locker) throw new NotFoundException('柜子不存在'); + if (dto.status === 'maintenance' && locker.status === 'occupied') { + throw new BadRequestException('该柜子有人占用,请先释放'); + } + if (dto.lockerNumber && dto.lockerNumber !== locker.lockerNumber) { + const dup = await this.lockerRepo.findOne({ + where: { roomId, lockerNumber: dto.lockerNumber }, + }); + if (dup) throw new BadRequestException('该柜子编号已存在'); + } + Object.assign(locker, dto); + return this.lockerRepo.save(locker); + } + + async deleteLocker(roomId: number, id: number): Promise { + const locker = await this.lockerRepo.findOne({ where: { id, roomId } }); + if (!locker) throw new NotFoundException('柜子不存在'); + if (locker.status === 'occupied') throw new BadRequestException('该柜子有人占用,无法归档'); + if (locker.status === 'archived') throw new BadRequestException('该柜子已归档'); + await this.lockerRepo.update(id, { status: 'archived' }); + } + + async batchCreateLockers(roomId: number, dto: BatchCreateLockerDto): Promise { + const room = await this.repo.findOne({ where: { id: roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); + const existing = await this.lockerRepo.find({ + where: { roomId, status: Not('archived') }, + order: { lockerNumber: 'ASC' }, + }); + const numbers = existing.map((b) => { + const match = b.lockerNumber.match(/^\d+/); + return match ? parseInt(match[0]) : 0; + }); + const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1; + const lockers: Locker[] = []; + for (let i = 0; i < dto.count; i++) { + lockers.push(this.lockerRepo.create({ roomId, lockerNumber: `${start + i}号柜` })); + } + return this.lockerRepo.save(lockers); + } +} diff --git a/apps/server/src/rooms/room-inspections.service.ts b/apps/server/src/rooms/room-inspections.service.ts index 5283499..7bf4d5f 100644 --- a/apps/server/src/rooms/room-inspections.service.ts +++ b/apps/server/src/rooms/room-inspections.service.ts @@ -2,7 +2,6 @@ import { BadRequestException, Injectable, Logger, OnApplicationBootstrap } from import { Cron } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm'; -import { Bed } from '../entities/bed.entity'; import { Occupancy } from '../entities/occupancy.entity'; import { Room } from '../entities/room.entity'; import { RoomInspection } from '../entities/room-inspection.entity'; @@ -35,7 +34,10 @@ export class RoomInspectionsService implements OnApplicationBootstrap { async onApplicationBootstrap(): Promise { await this.settlePreviousDay().catch((error) => { - this.logger.error('补记昨日宿舍查寝失败', error instanceof Error ? error.stack : String(error)); + this.logger.error( + '补记昨日宿舍查寝失败', + error instanceof Error ? error.stack : String(error), + ); }); } @@ -67,7 +69,9 @@ export class RoomInspectionsService implements OnApplicationBootstrap { const allowedIds = new Set(occupancies.map((occupancy) => occupancy.id)); const invalidIds = uniquePresentIds.filter((id) => !allowedIds.has(id)); if (invalidIds.length > 0) { - throw new BadRequestException(`存在不属于该宿舍当日住户的入住记录: ${invalidIds.join(', ')}`); + throw new BadRequestException( + `存在不属于该宿舍当日住户的入住记录: ${invalidIds.join(', ')}`, + ); } const inspectionRepo = manager.getRepository(RoomInspection); @@ -128,7 +132,10 @@ export class RoomInspectionsService implements OnApplicationBootstrap { async settleDate(inspectionDate: string): Promise { const existing = await this.inspectionRepo.find({ where: { inspectionDate } }); const existingRoomIds = new Set(existing.map((inspection) => inspection.roomId)); - const occupancies = await this.findAllOccupanciesForDate(this.dataSource.manager, inspectionDate); + const occupancies = await this.findAllOccupanciesForDate( + this.dataSource.manager, + inspectionDate, + ); const byRoom = new Map(); for (const occupancy of occupancies) { if (existingRoomIds.has(occupancy.roomId)) continue; diff --git a/apps/server/src/rooms/room-number.ts b/apps/server/src/rooms/room-number.ts new file mode 100644 index 0000000..d071dce --- /dev/null +++ b/apps/server/src/rooms/room-number.ts @@ -0,0 +1,38 @@ +/** 智能解析房间号,自动推导楼栋、楼层、宿舍类型 */ +export function parseRoomNumber(roomNumber: string): { + building?: string; + floor?: number; + roomType?: string; + capacity?: number; +} { + const cleaned = roomNumber.replace(/[((].*?[))]/g, '').trim(); + // 家庭房: X-Y-ZZZ 格式 + const familyMatch = cleaned.match(/^(\d+)-(\d+)-(\d+)$/); + if (familyMatch) { + const bldg = `${familyMatch[1]}-${familyMatch[2]}栋`; + const roomPart = familyMatch[3]; + const rawFloor = parseInt(roomPart.charAt(0), 10); + const floor = Number.isNaN(rawFloor) ? undefined : rawFloor; + return { building: bldg, floor, roomType: '家庭房', capacity: 4 }; + } + // 标准: X-YZZ 格式 + const stdMatch = cleaned.match(/^(\d+)-(\d+)$/); + if (stdMatch) { + const bldgNum = stdMatch[1]; + const roomPart = stdMatch[2]; + const rawFloor = parseInt(roomPart.charAt(0), 10); + const floor = Number.isNaN(rawFloor) ? undefined : rawFloor; + const building = `${bldgNum}号楼`; + let roomType = '四人间'; + let capacity = 4; + if (bldgNum === '2') { + roomType = '单人间'; + capacity = 1; + } else if (bldgNum === '8') { + roomType = '爆改房'; + capacity = 2; + } + return { building, floor, roomType, capacity }; + } + return { capacity: 4, roomType: '四人间' }; +} diff --git a/apps/server/src/rooms/room-query.service.ts b/apps/server/src/rooms/room-query.service.ts new file mode 100644 index 0000000..4e00862 --- /dev/null +++ b/apps/server/src/rooms/room-query.service.ts @@ -0,0 +1,282 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Not, In } from 'typeorm'; +import { Room } from '../entities/room.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { Bed } from '../entities/bed.entity'; +import { RoomInspectionsService } from './room-inspections.service'; +import { occupancyWhereOnDate } from './room-occupancy-date'; +import { parseRoomNumber } from './room-number'; + +@Injectable() +export class RoomQueryService { + constructor( + @InjectRepository(Room) private repo: Repository, + @InjectRepository(Occupancy) private occRepo: Repository, + @InjectRepository(Bed) private bedRepo: Repository, + private readonly inspectionsService: RoomInspectionsService, + ) {} + + + + async agentSearchRooms(query: { building?: string; keyword?: string; status?: string; limit?: number }) { + const qb = this.repo.createQueryBuilder('room'); + if (query.building) qb.andWhere('room.building = :building', { building: query.building }); + if (query.keyword) { + qb.andWhere('(room.roomNumber LIKE :keyword OR room.building LIKE :keyword)', { + keyword: `%${query.keyword}%`, + }); + } + if (query.status) qb.andWhere('room.status = :status', { status: query.status }); + const rows = await qb + .select([ + 'room.id', + 'room.roomNumber', + 'room.building', + 'room.floor', + 'room.capacity', + 'room.roomType', + 'room.status', + ]) + .orderBy('room.roomNumber', 'ASC') + .limit(Math.max(1, Math.min(query.limit ?? 20, 50))) + .getRawMany(); + return rows.map((row) => ({ + id: Number(row.room_id), + roomNumber: String(row.room_room_number), + building: row.room_building == null ? null : String(row.room_building), + floor: row.room_floor == null ? null : Number(row.room_floor), + capacity: Number(row.room_capacity), + roomType: row.room_room_type == null ? null : String(row.room_room_type), + status: String(row.room_status), + })); + } + + async agentGetRoomOccupancySummary(query: { date?: string; building?: string; limit?: number }) { + const targetDate = query.date || this.getChinaDate(new Date()); + const qb = this.occRepo + .createQueryBuilder('o') + .innerJoin('o.room', 'room') + .where('o.checkInDate <= :date', { date: targetDate }) + .andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :date)', { date: targetDate }); + if (query.building) qb.andWhere('room.building = :building', { building: query.building }); + const rows = await qb + .select('room.id', 'roomId') + .addSelect('room.roomNumber', 'roomNumber') + .addSelect('COUNT(o.id)', 'occupied') + .addSelect('room.capacity', 'capacity') + .groupBy('room.id') + .orderBy('room.roomNumber', 'ASC') + .limit(Math.max(1, Math.min(query.limit ?? 20, 50))) + .getRawMany(); + return rows.map((row) => ({ + roomId: Number(row.roomId), + roomNumber: String(row.roomNumber), + occupied: Number(row.occupied), + capacity: Number(row.capacity), + rate: Number(row.capacity) > 0 ? Number(((Number(row.occupied) / Number(row.capacity)) * 100).toFixed(1)) : 0, + })); + } + + async getRoomVisual(asOf?: string) { + // asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。 + const isHistorical = !!asOf; + const targetDate = asOf || this.getChinaDate(new Date()); + + // 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。 + const rooms = await this.repo.find({ + where: isHistorical ? {} : { status: Not('archived') }, + order: { building: 'ASC', roomNumber: 'ASC' }, + }); + + const occupancies = await this.occRepo.find({ + where: occupancyWhereOnDate(targetDate), + relations: ['student', 'student.organization', 'responsibleOrganization', 'bed'], + order: { checkInDate: 'ASC' }, + }); + + // 按roomId分组入住记录 + const occMap = new Map(); + // days(已住天数)相对目标日期计算,而非固定今天,历史视图才准确。 + const refTime = new Date(targetDate).getTime(); + for (const occ of occupancies) { + if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []); + const checkIn = new Date(occ.checkInDate); + const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24))); + occMap.get(occ.roomId)!.push({ + studentId: occ.studentId, + occupancyId: occ.id, + studentName: occ.student?.name || '未知', + bedId: occ.bedId ?? null, + bedNumber: occ.bed?.bedNumber || null, + checkInDate: occ.checkInDate, + billingStartDate: occ.billingStartDate, + days, + organization: occ.student?.organization?.name || null, + supervisor: occ.student?.supervisor || null, + organizationId: occ.responsibleOrganizationId || null, + organizationName: occ.responsibleOrganization?.name || null, + organizationColor: occ.responsibleOrganization?.color || null, + }); + } + + // 获取各楼栋列表 + const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))]; + + // 历史视图纳入了已归档房间,但只保留当时确实有人住的归档房间,避免空归档房间刷屏。 + const visibleRooms = isHistorical + ? rooms.filter((r) => r.status !== 'archived' || (occMap.get(r.id)?.length ?? 0) > 0) + : rooms; + + // 批量获取床位统计 + const allBeds = await this.bedRepo.find({ + where: { roomId: In(visibleRooms.map((r) => r.id)) }, + }); + const bedMap = new Map(); + for (const bed of allBeds) { + if (!bedMap.has(bed.roomId)) bedMap.set(bed.roomId, { total: 0, occupied: 0 }); + const entry = bedMap.get(bed.roomId)!; + entry.total++; + if (bed.status === 'occupied') entry.occupied++; + } + + const inspectionMap = await this.inspectionsService.getByRoomsAndDate( + visibleRooms.map((room) => room.id), + targetDate, + ); + + return { + buildings, + rooms: visibleRooms.map((room) => { + const occ = occMap.get(room.id) || []; + const inspection = inspectionMap.get(room.id); + const inspectionByOccupancyId = new Map( + (inspection?.details || []).map((detail) => [detail.occupancyId, detail.status]), + ); + const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))]; + let orgLabel: string | null = null; + if (orgs.length > 0 && occ.length > 0) { + const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]); + orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`; + } + const organizationColors = [ + ...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean)), + ]; + const organizationColor: string | null = + organizationColors.length === 1 ? organizationColors[0] : null; + const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))]; + return { + id: room.id, + roomNumber: room.roomNumber, + building: room.building, + floor: room.floor, + capacity: room.capacity, + status: room.status, + currentCount: occ.length, + totalBeds: bedMap.get(room.id)?.total ?? 0, + occupiedBeds: bedMap.get(room.id)?.occupied ?? 0, + occupants: occ.map((occupant) => ({ + ...occupant, + inspectionStatus: inspectionByOccupancyId.get(occupant.occupancyId) || null, + })), + inspection: inspection + ? { + submitted: true, + inspectorId: inspection.inspectorId, + inspectorName: inspection.inspectorName, + source: inspection.source, + submittedAt: inspection.submittedAt, + } + : { submitted: false }, + orgLabel, + organizationColor, + organizationIds, + }; + }), + // 当前视图内出现过的负责机构,供筛选下拉使用 + organizations: [ + ...new Map( + occupancies + .filter((o) => o.responsibleOrganizationId && o.responsibleOrganization) + .map((o) => [ + o.responsibleOrganizationId, + { + id: o.responsibleOrganizationId, + name: o.responsibleOrganization.name, + color: o.responsibleOrganization.color || null, + }, + ]), + ).values(), + ].sort((a, b) => a.name.localeCompare(b.name)), + }; + } + + private getChinaDate(now: Date): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(now); + } + + + async batchImport( + rows: { + roomNumber: string; + building?: string; + floor?: number; + capacity?: number; + roomType?: string; + rentalCategory?: string; + monthlyRate?: number; + }[], + ) { + let imported = 0; + let skipped = 0; + for (const row of rows) { + if (!row.roomNumber || !row.roomNumber.trim()) { + skipped++; + continue; + } + const exists = await this.repo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); + if (exists) { + skipped++; + continue; + } + // 智能解析房间号 + const parsed = parseRoomNumber(row.roomNumber.trim()); + const room = await this.repo.save( + this.repo.create({ + roomNumber: row.roomNumber.trim(), + building: row.building?.trim() || parsed.building || undefined, + floor: row.floor ?? parsed.floor, + capacity: row.capacity ?? parsed.capacity ?? 4, + roomType: row.roomType || parsed.roomType || undefined, + rentalCategory: row.rentalCategory || undefined, + monthlyRate: row.monthlyRate ?? undefined, + }), + ); + await this.createDefaultBeds(room.id, room.capacity); + imported++; + } + return { + message: `成功导入 ${imported} 间宿舍,跳过 ${skipped} 条(重复或空行)`, + imported, + skipped, + }; + } + + // ── 床位管理 ── + + + async createDefaultBeds(roomId: number, capacity: number): Promise { + const count = Math.max(capacity ?? 0, 0); + if (count === 0) return; + const beds = Array.from({ length: count }, (_, index) => + this.bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }), + ); + await this.bedRepo.save(beds); + } + +} diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index e075c20..dda7648 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -25,6 +25,7 @@ import { UpdateRoomInspectionDto } from './dto/room-inspection.dto'; import { RoomInspectionsService } from './room-inspections.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { logAudit } from '../common/with-audit-log'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchIdsDto } from '../common/batch-ids.dto'; @@ -64,16 +65,9 @@ export class RoomsController { @RequirePermission('room:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRestore(dto.ids); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍', - action: '批量恢复宿舍', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍', action: '批量恢复宿舍', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @@ -86,23 +80,14 @@ export class RoomsController { @Body() dto: UpdateRoomInspectionDto, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.inspectionsService.submit( +roomId, date, dto.presentOccupancyIds, { id: req.user?.id, username: req.user?.username }, ); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍查寝', - action: result.isUpdate ? '修改查寝记录' : '提交查寝记录', - targetId: +roomId, - targetType: 'room', - detail: `查寝日期: ${date}, 宿舍: ${result.roomNumber}, 在寝: ${result.presentNames.join('、') || '无'}, 缺勤: ${result.absentNames.join('、') || '无'}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍查寝', action: result.isUpdate ? '修改查寝记录' : '提交查寝记录', targetId: +roomId, targetType: 'room', detail: `查寝日期: ${date}, 宿舍: ${result.roomNumber}, 在寝: ${result.presentNames.join('、') || '无'}, 缺勤: ${result.absentNames.join('、') || '无'}`, }); return result.inspection; } @@ -285,16 +270,9 @@ export class RoomsController { @Post() @RequirePermission('room:create') async create(@Body() dto: CreateRoomDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍', - action: '添加宿舍', - detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}人`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍', action: '添加宿舍', detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}人`, }); return result; } @@ -302,18 +280,9 @@ export class RoomsController { @Put(':id') @RequirePermission('room:edit') async update(@Param('id') id: string, @Body() dto: UpdateRoomDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍', - action: '编辑宿舍', - targetId: +id, - targetType: 'room', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍', action: '编辑宿舍', targetId: +id, targetType: 'room', detail: JSON.stringify(dto), }); return result; } @@ -321,17 +290,9 @@ export class RoomsController { @Delete(':id') @RequirePermission('room:delete') async remove(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍', - action: '归档宿舍', - targetId: +id, - targetType: 'room', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍', action: '归档宿舍', targetId: +id, targetType: 'room', }); return result; } @@ -339,16 +300,29 @@ export class RoomsController { @Post('batch-delete') @RequirePermission('room:delete') async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRemove(body.ids || []); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍', - action: '批量归档宿舍', - detail: `IDs: ${(body.ids || []).join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍', action: '批量归档宿舍', detail: `IDs: ${(body.ids || []).join(',')}`, + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('room:purge') + async purge(@Param('id') id: string, @Request() req: any) { + const result = await this.service.purge(+id); + await logAudit(this.logService, req, { + module: '宿舍', action: '永久删除宿舍', targetId: +id, targetType: 'room', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('batch-permanent-delete') + @RequirePermission('room:purge') + async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + const result = await this.service.batchPurge(body.ids || []); + await logAudit(this.logService, req, { + module: '宿舍', action: '批量永久删除宿舍', detail: `IDs: ${(body.ids || []).join(',')}`, }); return result; } @@ -356,17 +330,9 @@ export class RoomsController { @Put(':id/restore') @RequirePermission('room:edit') async restore(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.restore(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '宿舍', - action: '恢复宿舍', - targetId: +id, - targetType: 'room', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '宿舍', action: '恢复宿舍', targetId: +id, targetType: 'room', }); return result; } @@ -377,7 +343,7 @@ export class RoomsController { async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as any); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const ws = workbook.worksheets[0]; const rows: { roomNumber: string; diff --git a/apps/server/src/rooms/rooms.module.ts b/apps/server/src/rooms/rooms.module.ts index c194366..cfafb48 100644 --- a/apps/server/src/rooms/rooms.module.ts +++ b/apps/server/src/rooms/rooms.module.ts @@ -8,6 +8,8 @@ import { Locker } from '../entities/locker.entity'; import { RoomInspection } from '../entities/room-inspection.entity'; import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; import { RoomsService } from './rooms.service'; +import { RoomQueryService } from './room-query.service'; +import { RoomBedLockerService } from './room-bed-locker.service'; import { RoomsController } from './rooms.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { RoomInspectionsService } from './room-inspections.service'; @@ -26,7 +28,7 @@ import { RoomInspectionsService } from './room-inspections.service'; OperationLogsModule, ], controllers: [RoomsController], - providers: [RoomsService, RoomInspectionsService], + providers: [RoomsService, RoomInspectionsService, RoomQueryService, RoomBedLockerService], exports: [RoomsService, RoomInspectionsService], }) export class RoomsModule {} diff --git a/apps/server/src/rooms/rooms.purge.controller.spec.ts b/apps/server/src/rooms/rooms.purge.controller.spec.ts new file mode 100644 index 0000000..097a748 --- /dev/null +++ b/apps/server/src/rooms/rooms.purge.controller.spec.ts @@ -0,0 +1,26 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { RoomsController } from './rooms.controller'; + +describe('RoomsController purge routes', () => { + it('requires room:purge on permanent delete routes', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, RoomsController.prototype.purge)).toEqual([ + 'room:purge', + ]); + expect(Reflect.getMetadata(PERMISSION_KEY, RoomsController.prototype.batchPurge)).toEqual([ + 'room:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除宿舍(不可恢复)' }) }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new RoomsController(service as never, { log } as never, {} as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge('1', req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '宿舍', action: '永久删除宿舍', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/rooms/rooms.purge.spec.ts b/apps/server/src/rooms/rooms.purge.spec.ts new file mode 100644 index 0000000..bfddc88 --- /dev/null +++ b/apps/server/src/rooms/rooms.purge.spec.ts @@ -0,0 +1,71 @@ +import { BadRequestException } from '@nestjs/common'; +import { RoomsService } from './rooms.service'; + +describe('RoomsService.purge', () => { + const createService = (overrides?: { + room?: Record; + occupancyCount?: number; + expenseCount?: number; + }) => { + const room = { id: 1, roomNumber: '101', status: 'archived', ...overrides?.room }; + const repo = { + findOne: jest.fn().mockResolvedValue(room), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + find: jest.fn().mockResolvedValue([room]), + }; + const occRepo = { count: jest.fn().mockResolvedValue(overrides?.occupancyCount ?? 0) }; + const roomExpRepo = { count: jest.fn().mockResolvedValue(overrides?.expenseCount ?? 0) }; + const service = new RoomsService( + repo as never, + occRepo as never, + roomExpRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + return { service, repo, occRepo, roomExpRepo }; + }; + + it('rejects rooms that are not archived', async () => { + const { service, repo } = createService({ room: { status: 'available' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档宿舍可以永久删除,请先归档'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects rooms referenced by occupancies or expenses', async () => { + const withOccupancy = createService({ occupancyCount: 1 }); + await expect(withOccupancy.service.purge(1)).rejects.toThrow( + new BadRequestException('该宿舍存在入住记录,无法永久删除'), + ); + expect(withOccupancy.repo.delete).not.toHaveBeenCalled(); + + const withExpense = createService({ expenseCount: 1 }); + await expect(withExpense.service.purge(1)).rejects.toThrow( + new BadRequestException('该宿舍存在宿舍费用,无法永久删除'), + ); + expect(withExpense.repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived room with no references', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ message: '已永久删除宿舍(不可恢复)' }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); + + it('batch purge skips referenced rooms', async () => { + const { service, repo, occRepo } = createService(); + repo.find = jest.fn().mockResolvedValue([ + { id: 1, roomNumber: '101', status: 'archived' }, + { id: 2, roomNumber: '102', status: 'archived' }, + ]); + occRepo.count + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(0); + const result = await service.batchPurge([1, 2]); + expect(result).toMatchObject({ deleted: 1, skipped: 1 }); + expect(repo.delete).toHaveBeenCalledWith(2); + }); +}); diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index 913390b..d0fd654 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -1,14 +1,6 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { - DataSource, - Repository, - Like, - IsNull, - Not, - In, - LessThanOrEqual, -} from 'typeorm'; +import { DataSource, Repository, IsNull, Not, In } from 'typeorm'; import { Room } from '../entities/room.entity'; import { Occupancy } from '../entities/occupancy.entity'; @@ -19,26 +11,9 @@ import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto'; import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto'; import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto'; import { RoomInspectionsService } from './room-inspections.service'; -import { occupancyWhereOnDate } from './room-occupancy-date'; - -interface AgentRoomRow { - id: string | number; - roomNumber: string; - building: string | null; - floor: string | number | null; - capacity: string | number; - roomType: string | null; - status: string; - occupiedBeds: string | number; -} - -interface AgentRoomOccupancyRow { - roomId: string | number; - roomNumber: string; - building: string | null; - capacity: string | number; - occupiedBeds: string | number; -} +import { RoomQueryService } from './room-query.service'; +import { RoomBedLockerService } from './room-bed-locker.service'; +import { parseRoomNumber } from './room-number'; @Injectable() export class RoomsService { @@ -50,8 +25,24 @@ export class RoomsService { @InjectRepository(Locker) private lockerRepo: Repository, private dataSource: DataSource, private readonly inspectionsService: RoomInspectionsService, + @Optional() private queryService?: RoomQueryService, + @Optional() private beds?: RoomBedLockerService, ) {} + private get queries(): RoomQueryService { + if (!this.queryService) { + this.queryService = new RoomQueryService(this.repo, this.occRepo, this.bedRepo, this.inspectionsService); + } + return this.queryService; + } + + private get bedOps(): RoomBedLockerService { + if (!this.beds) { + this.beds = new RoomBedLockerService(this.repo, this.bedRepo, this.lockerRepo); + } + return this.beds; + } + /** * 智能解析房间号,自动推导楼栋、楼层、宿舍类型 * "4-102" → building:"4号楼", floor:1, roomType:"四人间" @@ -59,42 +50,8 @@ export class RoomsService { * "3-301" → building:"3号楼", floor:3, roomType:"四人间" * "8-102" → building:"8号楼", floor:1, roomType:"爆改房" */ - static parseRoomNumber(roomNumber: string): { - building?: string; - floor?: number; - roomType?: string; - capacity?: number; - } { - const cleaned = roomNumber.replace(/[((].*?[))]/g, '').trim(); - // 家庭房: X-Y-ZZZ 格式 - const familyMatch = cleaned.match(/^(\d+)-(\d+)-(\d+)$/); - if (familyMatch) { - const bldg = `${familyMatch[1]}-${familyMatch[2]}栋`; - const roomPart = familyMatch[3]; - const rawFloor = parseInt(roomPart.charAt(0), 10); - const floor = Number.isNaN(rawFloor) ? undefined : rawFloor; - return { building: bldg, floor, roomType: '家庭房', capacity: 4 }; - } - // 标准: X-YZZ 格式 - const stdMatch = cleaned.match(/^(\d+)-(\d+)$/); - if (stdMatch) { - const bldgNum = stdMatch[1]; - const roomPart = stdMatch[2]; - const rawFloor = parseInt(roomPart.charAt(0), 10); - const floor = Number.isNaN(rawFloor) ? undefined : rawFloor; - const building = `${bldgNum}号楼`; - let roomType = '四人间'; - let capacity = 4; - if (bldgNum === '2') { - roomType = '单人间'; - capacity = 1; - } else if (bldgNum === '8') { - roomType = '爆改房'; - capacity = 2; - } - return { building, floor, roomType, capacity }; - } - return { capacity: 4, roomType: '四人间' }; + static parseRoomNumber(roomNumber: string) { + return parseRoomNumber(roomNumber); } async findAll(query?: { building?: string; includeArchived?: boolean }) { @@ -104,59 +61,6 @@ export class RoomsService { return this.repo.find({ where, order: { roomNumber: 'ASC' } }); } - async agentSearchRooms(query: { keyword?: string; building?: string; status?: string; limit?: number }) { - const qb = this.repo - .createQueryBuilder('room') - .leftJoin( - Occupancy, - 'occupancy', - 'occupancy.roomId = room.id AND occupancy.checkOutDate IS NULL', - ) - .select('room.id', 'id') - .addSelect('room.roomNumber', 'roomNumber') - .addSelect('room.building', 'building') - .addSelect('room.floor', 'floor') - .addSelect('room.capacity', 'capacity') - .addSelect('room.roomType', 'roomType') - .addSelect('room.status', 'status') - .addSelect('COUNT(occupancy.id)', 'occupiedBeds') - .where('room.status != :archived', { archived: 'archived' }); - if (query.keyword) qb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${query.keyword}%` }); - if (query.building) qb.andWhere('room.building = :building', { building: query.building }); - if (query.status) qb.andWhere('room.status = :status', { status: query.status }); - const rows = await qb.groupBy('room.id').orderBy('room.roomNumber', 'ASC').limit(query.limit ?? 20).getRawMany(); - return rows.map((row) => ({ - ...row, - id: Number(row.id), floor: row.floor == null ? null : Number(row.floor), - capacity: Number(row.capacity), occupiedBeds: Number(row.occupiedBeds || 0), - })); - } - - async agentGetRoomOccupancySummary(query: { date?: string; building?: string; limit?: number }) { - const targetDate = query.date || this.getChinaDate(new Date()); - const qb = this.repo - .createQueryBuilder('room') - .leftJoin( - Occupancy, - 'occupancy', - 'occupancy.roomId = room.id AND occupancy.checkInDate <= :targetDate AND (occupancy.checkOutDate IS NULL OR occupancy.checkOutDate > :targetDate)', - { targetDate }, - ) - .select('room.id', 'roomId') - .addSelect('room.roomNumber', 'roomNumber') - .addSelect('room.building', 'building') - .addSelect('room.capacity', 'capacity') - .addSelect('COUNT(occupancy.id)', 'occupiedBeds') - .where('room.status != :archived', { archived: 'archived' }); - if (query.building) qb.andWhere('room.building = :building', { building: query.building }); - const rows = await qb.groupBy('room.id').orderBy('room.roomNumber', 'ASC').limit(query.limit ?? 50).getRawMany(); - return rows.map((row) => { - const capacity = Number(row.capacity || 0); - const occupiedBeds = Number(row.occupiedBeds || 0); - return { date: targetDate, roomId: Number(row.roomId), roomNumber: row.roomNumber, building: row.building, capacity, occupiedBeds, availableBeds: Math.max(0, capacity - occupiedBeds) }; - }); - } - async findOne(id: number) { const room = await this.repo.findOne({ where: { id } }); if (!room) throw new NotFoundException('宿舍不存在'); @@ -306,6 +210,54 @@ export class RoomsService { return { message: '已恢复' }; } + async purge(id: number) { + const room = await this.findOne(id); + if (room.status !== 'archived') + throw new BadRequestException('仅已归档宿舍可以永久删除,请先归档'); + const [occupancyCount, expenseCount] = await Promise.all([ + this.occRepo.count({ where: { roomId: id } }), + this.roomExpRepo.count({ where: { roomId: id } }), + ]); + if (occupancyCount > 0) throw new BadRequestException('该宿舍存在入住记录,无法永久删除'); + if (expenseCount > 0) throw new BadRequestException('该宿舍存在宿舍费用,无法永久删除'); + await this.repo.delete(id); + return { message: '已永久删除宿舍(不可恢复)' }; + } + + async batchPurge(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的宿舍'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('宿舍 ID 无效'); + } + const rooms = await this.repo.find({ where: { id: In(uniqueIds) } }); + if (rooms.length !== uniqueIds.length) throw new NotFoundException('部分宿舍不存在'); + + const deleted: number[] = []; + const skipped: string[] = []; + for (const room of rooms) { + if (room.status !== 'archived') { + skipped.push(`${room.roomNumber}(未归档)`); + continue; + } + const [occupancyCount, expenseCount] = await Promise.all([ + this.occRepo.count({ where: { roomId: room.id } }), + this.roomExpRepo.count({ where: { roomId: room.id } }), + ]); + if (occupancyCount > 0 || expenseCount > 0) { + skipped.push(`${room.roomNumber}(存在关联数据)`); + continue; + } + await this.repo.delete(room.id); + deleted.push(room.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 间;${skipped.length} 间被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 间宿舍(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + async batchRestore(ids: number[]) { const uniqueIds = [...new Set(ids || [])]; if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的宿舍'); @@ -329,147 +281,16 @@ export class RoomsService { } return { message: `已批量恢复 ${restored} 间宿舍`, restored, skipped }; } - - async getRoomVisual(asOf?: string) { - // asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。 - const isHistorical = !!asOf; - const targetDate = asOf || this.getChinaDate(new Date()); - - // 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。 - const rooms = await this.repo.find({ - where: isHistorical ? {} : { status: Not('archived') }, - order: { building: 'ASC', roomNumber: 'ASC' }, - }); - - const occupancies = await this.occRepo.find({ - where: occupancyWhereOnDate(targetDate), - relations: ['student', 'student.organization', 'responsibleOrganization', 'bed'], - order: { checkInDate: 'ASC' }, - }); - - // 按roomId分组入住记录 - const occMap = new Map(); - // days(已住天数)相对目标日期计算,而非固定今天,历史视图才准确。 - const refTime = new Date(targetDate).getTime(); - for (const occ of occupancies) { - if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []); - const checkIn = new Date(occ.checkInDate); - const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24))); - occMap.get(occ.roomId)!.push({ - studentId: occ.studentId, - occupancyId: occ.id, - studentName: occ.student?.name || '未知', - bedId: occ.bedId ?? null, - bedNumber: occ.bed?.bedNumber || null, - checkInDate: occ.checkInDate, - billingStartDate: occ.billingStartDate, - days, - organization: occ.student?.organization?.name || null, - supervisor: occ.student?.supervisor || null, - organizationId: occ.responsibleOrganizationId || null, - organizationName: occ.responsibleOrganization?.name || null, - organizationColor: occ.responsibleOrganization?.color || null, - }); - } - - // 获取各楼栋列表 - const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))]; - - // 历史视图纳入了已归档房间,但只保留当时确实有人住的归档房间,避免空归档房间刷屏。 - const visibleRooms = isHistorical - ? rooms.filter((r) => r.status !== 'archived' || (occMap.get(r.id)?.length ?? 0) > 0) - : rooms; - - // 批量获取床位统计 - const allBeds = await this.bedRepo.find({ - where: { roomId: In(visibleRooms.map((r) => r.id)) }, - }); - const bedMap = new Map(); - for (const bed of allBeds) { - if (!bedMap.has(bed.roomId)) bedMap.set(bed.roomId, { total: 0, occupied: 0 }); - const entry = bedMap.get(bed.roomId)!; - entry.total++; - if (bed.status === 'occupied') entry.occupied++; - } - - const inspectionMap = await this.inspectionsService.getByRoomsAndDate( - visibleRooms.map((room) => room.id), - targetDate, - ); - - return { - buildings, - rooms: visibleRooms.map((room) => { - const occ = occMap.get(room.id) || []; - const inspection = inspectionMap.get(room.id); - const inspectionByOccupancyId = new Map( - (inspection?.details || []).map((detail) => [detail.occupancyId, detail.status]), - ); - const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))]; - let orgLabel: string | null = null; - if (orgs.length > 0 && occ.length > 0) { - const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]); - orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`; - } - const organizationColors = [ - ...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean)), - ]; - const organizationColor: string | null = - organizationColors.length === 1 ? organizationColors[0] : null; - const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))]; - return { - id: room.id, - roomNumber: room.roomNumber, - building: room.building, - floor: room.floor, - capacity: room.capacity, - status: room.status, - currentCount: occ.length, - totalBeds: bedMap.get(room.id)?.total ?? 0, - occupiedBeds: bedMap.get(room.id)?.occupied ?? 0, - occupants: occ.map((occupant) => ({ - ...occupant, - inspectionStatus: inspectionByOccupancyId.get(occupant.occupancyId) || null, - })), - inspection: inspection - ? { - submitted: true, - inspectorId: inspection.inspectorId, - inspectorName: inspection.inspectorName, - source: inspection.source, - submittedAt: inspection.submittedAt, - } - : { submitted: false }, - orgLabel, - organizationColor, - organizationIds, - }; - }), - // 当前视图内出现过的负责机构,供筛选下拉使用 - organizations: [ - ...new Map( - occupancies - .filter((o) => o.responsibleOrganizationId && o.responsibleOrganization) - .map((o) => [ - o.responsibleOrganizationId, - { - id: o.responsibleOrganizationId, - name: o.responsibleOrganization.name, - color: o.responsibleOrganization.color || null, - }, - ]), - ).values(), - ].sort((a, b) => a.name.localeCompare(b.name)), - }; + async agentSearchRooms(query: { building?: string; keyword?: string; status?: string; limit?: number }) { + return this.queries.agentSearchRooms(query); } - private getChinaDate(now: Date): string { - return new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(now); + async agentGetRoomOccupancySummary(query: { date?: string; building?: string; limit?: number }) { + return this.queries.agentGetRoomOccupancySummary(query); + } + + async getRoomVisual(asOf?: string) { + return this.queries.getRoomVisual(asOf); } async batchImport( @@ -483,212 +304,63 @@ export class RoomsService { monthlyRate?: number; }[], ) { - let imported = 0; - let skipped = 0; - for (const row of rows) { - if (!row.roomNumber || !row.roomNumber.trim()) { - skipped++; - continue; - } - const exists = await this.repo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); - if (exists) { - skipped++; - continue; - } - // 智能解析房间号 - const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); - const room = await this.repo.save( - this.repo.create({ - roomNumber: row.roomNumber.trim(), - building: row.building?.trim() || parsed.building || undefined, - floor: row.floor ?? parsed.floor, - capacity: row.capacity ?? parsed.capacity ?? 4, - roomType: row.roomType || parsed.roomType || undefined, - rentalCategory: row.rentalCategory || undefined, - monthlyRate: row.monthlyRate ?? undefined, - }), - ); - await this.createDefaultBeds(room.id, room.capacity); - imported++; - } - return { - message: `成功导入 ${imported} 间宿舍,跳过 ${skipped} 条(重复或空行)`, - imported, - skipped, - }; + return this.queries.batchImport(rows); } - // ── 床位管理 ── - - async getRoomBeds(roomId: number): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - return this.bedRepo.find({ where: { roomId, status: Not('archived') }, order: { bedNumber: 'ASC' } }); - } - - async getRoomAvailableBeds(roomId: number): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - return this.bedRepo.find({ - where: { roomId, status: 'available' }, - order: { bedNumber: 'ASC' }, - }); - } - - async createBed(roomId: number, dto: CreateBedDto): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); - await this.assertCanAddBeds(room, 1); - const existing = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } }); - if (existing) throw new BadRequestException('该床位编号已存在'); - const bed = this.bedRepo.create({ ...dto, roomId }); - return this.bedRepo.save(bed); - } - - async updateBed(roomId: number, id: number, dto: UpdateBedDto): Promise { - const bed = await this.bedRepo.findOne({ where: { id, roomId } }); - if (!bed) throw new NotFoundException('床位不存在'); - // 不允许将 occupied 的床位改为 maintenance - if (dto.status === 'maintenance' && bed.status === 'occupied') { - throw new BadRequestException('该床位有人入住,请先退宿'); - } - // 编号唯一性检查 - if (dto.bedNumber && dto.bedNumber !== bed.bedNumber) { - const dup = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } }); - if (dup) throw new BadRequestException('该床位编号已存在'); - } - Object.assign(bed, dto); - return this.bedRepo.save(bed); - } - - async deleteBed(roomId: number, id: number): Promise { - const bed = await this.bedRepo.findOne({ where: { id, roomId } }); - if (!bed) throw new NotFoundException('床位不存在'); - if (bed.status === 'occupied') throw new BadRequestException('该床位有人入住,无法归档'); - if (bed.status === 'archived') throw new BadRequestException('该床位已归档'); - await this.bedRepo.update(id, { status: 'archived' }); - } - - async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); - const existing = await this.bedRepo.find({ where: { roomId, status: Not('archived') }, order: { bedNumber: 'ASC' } }); - this.assertCanAddBedsFromCount(room, existing.length, dto.count); - const numbers = existing.map((b) => { - const match = b.bedNumber.match(/^\d+/); - return match ? parseInt(match[0]) : 0; - }); - const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1; - const beds: Bed[] = []; - for (let i = 0; i < dto.count; i++) { - beds.push(this.bedRepo.create({ roomId, bedNumber: `${start + i}号床` })); - } - return this.bedRepo.save(beds); - } - - private async createDefaultBeds(roomId: number, capacity: number): Promise { - const count = Math.max(capacity ?? 0, 0); - if (count === 0) return; - const beds = Array.from({ length: count }, (_, index) => - this.bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }), - ); - await this.bedRepo.save(beds); + private createDefaultBeds(roomId: number, capacity: number): Promise { + return this.queries.createDefaultBeds(roomId, capacity); } private getNextBedNumber(beds: Pick[]): number { - const numbers = beds.map((bed) => { - const match = bed.bedNumber.match(/^\d+/); - return match ? parseInt(match[0], 10) : 0; - }); - return numbers.length > 0 ? Math.max(...numbers) + 1 : 1; + return this.bedOps.getNextBedNumber(beds); } - private async assertCanAddBeds(room: Room, count: number): Promise { - const existingCount = await this.bedRepo.count({ where: { roomId: room.id } }); - this.assertCanAddBedsFromCount(room, existingCount, count); + async getRoomBeds(roomId: number): Promise { + return this.bedOps.getRoomBeds(roomId); } - private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void { - const remaining = Math.max((room.capacity ?? 0) - existingCount, 0); - if (count > remaining) { - throw new BadRequestException( - `床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining} 张`, - ); - } + async getRoomAvailableBeds(roomId: number): Promise { + return this.bedOps.getRoomAvailableBeds(roomId); } - // ── 柜子管理 ── + async createBed(roomId: number, dto: CreateBedDto): Promise { + return this.bedOps.createBed(roomId, dto); + } + + async updateBed(roomId: number, id: number, dto: UpdateBedDto): Promise { + return this.bedOps.updateBed(roomId, id, dto); + } + + async deleteBed(roomId: number, id: number): Promise { + return this.bedOps.deleteBed(roomId, id); + } + + async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise { + return this.bedOps.batchCreateBeds(roomId, dto); + } async getRoomLockers(roomId: number): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - return this.lockerRepo.find({ where: { roomId, status: Not('archived') }, order: { lockerNumber: 'ASC' } }); + return this.bedOps.getRoomLockers(roomId); } async getRoomAvailableLockers(roomId: number): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - return this.lockerRepo.find({ - where: { roomId, status: 'available' }, - order: { lockerNumber: 'ASC' }, - }); + return this.bedOps.getRoomAvailableLockers(roomId); } async createLocker(roomId: number, dto: CreateLockerDto): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); - const existing = await this.lockerRepo.findOne({ - where: { roomId, lockerNumber: dto.lockerNumber }, - }); - if (existing) throw new BadRequestException('该柜子编号已存在'); - const locker = this.lockerRepo.create({ ...dto, roomId }); - return this.lockerRepo.save(locker); + return this.bedOps.createLocker(roomId, dto); } async updateLocker(roomId: number, id: number, dto: UpdateLockerDto): Promise { - const locker = await this.lockerRepo.findOne({ where: { id, roomId } }); - if (!locker) throw new NotFoundException('柜子不存在'); - if (dto.status === 'maintenance' && locker.status === 'occupied') { - throw new BadRequestException('该柜子有人占用,请先释放'); - } - if (dto.lockerNumber && dto.lockerNumber !== locker.lockerNumber) { - const dup = await this.lockerRepo.findOne({ - where: { roomId, lockerNumber: dto.lockerNumber }, - }); - if (dup) throw new BadRequestException('该柜子编号已存在'); - } - Object.assign(locker, dto); - return this.lockerRepo.save(locker); + return this.bedOps.updateLocker(roomId, id, dto); } async deleteLocker(roomId: number, id: number): Promise { - const locker = await this.lockerRepo.findOne({ where: { id, roomId } }); - if (!locker) throw new NotFoundException('柜子不存在'); - if (locker.status === 'occupied') throw new BadRequestException('该柜子有人占用,无法归档'); - if (locker.status === 'archived') throw new BadRequestException('该柜子已归档'); - await this.lockerRepo.update(id, { status: 'archived' }); + return this.bedOps.deleteLocker(roomId, id); } async batchCreateLockers(roomId: number, dto: BatchCreateLockerDto): Promise { - const room = await this.repo.findOne({ where: { id: roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); - const existing = await this.lockerRepo.find({ - where: { roomId, status: Not('archived') }, - order: { lockerNumber: 'ASC' }, - }); - const numbers = existing.map((b) => { - const match = b.lockerNumber.match(/^\d+/); - return match ? parseInt(match[0]) : 0; - }); - const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1; - const lockers: Locker[] = []; - for (let i = 0; i < dto.count; i++) { - lockers.push(this.lockerRepo.create({ roomId, lockerNumber: `${start + i}号柜` })); - } - return this.lockerRepo.save(lockers); + return this.bedOps.batchCreateLockers(roomId, dto); } -} + +} \ No newline at end of file diff --git a/apps/server/src/schedules/schedule-queries.service.ts b/apps/server/src/schedules/schedule-queries.service.ts new file mode 100644 index 0000000..d146f11 --- /dev/null +++ b/apps/server/src/schedules/schedule-queries.service.ts @@ -0,0 +1,183 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ClassSchedule } from '../entities'; +import type { WeeklyViewQueryDto } from './dto/schedule.dto'; + +const ACTIVE_SCHEDULE_STATUS = 'active'; + +@Injectable() +export class ScheduleQueriesService { + constructor( + @InjectRepository(ClassSchedule) + private readonly scheduleRepo: Repository, + ) {} + + maskScheduleOccupancy(schedule: ClassSchedule) { + return { + id: null, + classId: null, + classroomId: schedule.classroomId, + weekDay: schedule.weekDay, + startTime: schedule.startTime, + endTime: schedule.endTime, + attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes, + startDate: schedule.startDate, + endDate: schedule.endDate, + subject: '已占用', + teacherId: null, + scheduleType: schedule.scheduleType, + status: schedule.status, + notes: null, + canViewDetails: false, + }; + } + + + async agentSearchSchedules( + accessibleClassIds: number[] | undefined, + query?: { classId?: number; classroomId?: number; weekDay?: number; limit?: number }, + ): Promise< + { + id: number; + classId: number | null; + className: string | null; + classroomId: number; + classroomName: string | null; + weekDay: number; + startTime: string; + endTime: string; + subject: string; + teacherName: string | null; + startDate: string; + endDate: string; + scheduleType: string; + status: string; + }[] + > { + if (query?.classId && accessibleClassIds && !accessibleClassIds.includes(query.classId)) { + return []; + } + if (accessibleClassIds && accessibleClassIds.length === 0) { + return []; + } + const qb = this.scheduleRepo + .createQueryBuilder('cs') + .leftJoin('cs.class', 'class') + .leftJoin('cs.classroom', 'classroom') + .leftJoin('cs.teacher', 'teacher') + .select([ + 'cs.id', + 'cs.classId', + 'cs.classroomId', + 'cs.weekDay', + 'cs.startTime', + 'cs.endTime', + 'cs.subject', + 'cs.teacherId', + 'cs.startDate', + 'cs.endDate', + 'cs.scheduleType', + 'cs.status', + 'class.name', + 'classroom.name', + 'teacher.name', + ]) + .where('cs.status = :active', { active: 'active' }); + + if (query?.classroomId) { + qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); + } + if (query?.classId) { + qb.andWhere('cs.classId = :classId', { classId: query.classId }); + } + if (accessibleClassIds) { + qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds }); + } + if (query?.weekDay) { + qb.andWhere('cs.weekDay = :weekDay', { weekDay: query.weekDay }); + } + + const rows = await qb + .orderBy('cs.weekDay', 'ASC') + .addOrderBy('cs.startTime', 'ASC') + .limit(Math.max(1, Math.min(query?.limit ?? 20, 50))) + .getRawMany>(); + return rows.map((row) => ({ + id: Number(row.cs_id), + classId: row.cs_class_id == null ? null : Number(row.cs_class_id), + className: row.class_name == null ? null : String(row.class_name), + classroomId: Number(row.cs_classroom_id), + classroomName: row.classroom_name == null ? null : String(row.classroom_name), + weekDay: Number(row.cs_week_day), + startTime: String(row.cs_start_time), + endTime: String(row.cs_end_time), + subject: String(row.cs_subject), + teacherName: row.teacher_name == null ? null : String(row.teacher_name), + startDate: String(row.cs_start_date), + endDate: String(row.cs_end_date), + scheduleType: String(row.cs_schedule_type), + status: String(row.cs_status), + })); + } + + + async getWeeklyView(query: WeeklyViewQueryDto, accessibleClassIds?: number[]) { + const qb = this.scheduleRepo.createQueryBuilder('cs'); + if (query.classroomId) { + qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); + } + if (query.startDate) { + qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); + } + if (query.endDate) { + qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate }); + } + + const schedules = await qb + .andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS }) + .orderBy('cs.weekDay', 'ASC') + .addOrderBy('cs.startTime', 'ASC') + .getMany(); + + const allowedClassIds = accessibleClassIds ? new Set(accessibleClassIds) : null; + const visibleSchedules = schedules.map((schedule) => { + const canViewDetails = + allowedClassIds === null || + (schedule.classId !== null && allowedClassIds.has(schedule.classId)); + if (canViewDetails) return { ...schedule, canViewDetails: true }; + + // Other classes remain visible only as a room/time occupancy block. + // Do not expose class, subject, teacher, notes, or internal record IDs. + return this.maskScheduleOccupancy(schedule); + }); + + // Group by classroomId → weekDay + const matrix: Record> = {}; + for (const schedule of visibleSchedules) { + if (!matrix[schedule.classroomId]) matrix[schedule.classroomId] = {}; + if (!matrix[schedule.classroomId][schedule.weekDay]) + matrix[schedule.classroomId][schedule.weekDay] = []; + matrix[schedule.classroomId][schedule.weekDay].push(schedule); + } + + return matrix; + } + + + async getClassroomOccupancy(classroomId: number, date?: string) { + const qb = this.scheduleRepo + .createQueryBuilder('cs') + .where('cs.classroomId = :classroomId', { classroomId }) + .andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS }) + .andWhere('cs.scheduleType IN (:...scheduleTypes)', { + scheduleTypes: ['INTERNAL', 'RENTAL'], + }); + + if (date) { + qb.andWhere('cs.startDate <= :date', { date }).andWhere('cs.endDate >= :date', { date }); + } + + return qb.orderBy('cs.weekDay', 'ASC').addOrderBy('cs.startTime', 'ASC').getMany(); + } +} diff --git a/apps/server/src/schedules/schedules.controller.ts b/apps/server/src/schedules/schedules.controller.ts index 4d201f8..4a06585 100644 --- a/apps/server/src/schedules/schedules.controller.ts +++ b/apps/server/src/schedules/schedules.controller.ts @@ -22,6 +22,7 @@ import { } from './dto/schedule.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { logAudit } from '../common/with-audit-log'; import { extractRequestInfo } from '../common/request-utils'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationType } from '../entities/notification.entity'; @@ -170,17 +171,7 @@ export class SchedulesController { dto.startDate, dto.endDate, ); - const teacherIds = [ - ...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)), - ]; - if (teacherIds.length > 0) { - void this.notificationsService.create({ - recipientIds: teacherIds, - type: NotificationType.SCHEDULE_CONFLICT, - title: '排课冲突', - content: `教室${dto.classroomId} 周${dto.weekDay} ${dto.startTime}-${dto.endTime} 与已有排课冲突`, - }); - } + this.notifyScheduleConflict(conflicts, dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, ''); } catch { // Best-effort conflict notification must not hide the original conflict. } @@ -189,6 +180,27 @@ export class SchedulesController { } } + private notifyScheduleConflict( + conflicts: Array<{ teacherId: number | null }>, + classroomId: number, + weekDay: number, + startTime: string, + endTime: string, + suffix: string, + ): void { + const teacherIds = [ + ...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)), + ]; + if (teacherIds.length > 0) { + void this.notificationsService.create({ + recipientIds: teacherIds, + type: NotificationType.SCHEDULE_CONFLICT, + title: '排课冲突', + content: `教室${classroomId} 周${weekDay} ${startTime}-${endTime} ${suffix}与已有排课冲突`, + }); + } + } + @Put(':id') @RequirePermission('schedule:edit') async update( @@ -226,17 +238,7 @@ export class SchedulesController { existing.startDate, existing.endDate, ); - const teacherIds = [ - ...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)), - ]; - if (teacherIds.length > 0) { - void this.notificationsService.create({ - recipientIds: teacherIds, - type: NotificationType.SCHEDULE_CONFLICT, - title: '排课冲突', - content: `教室${existing.classroomId} 周${existing.weekDay} ${existing.startTime}-${existing.endTime} (更新) 与已有排课冲突`, - }); - } + this.notifyScheduleConflict(conflicts, existing.classroomId, existing.weekDay, existing.startTime, existing.endTime, ' (更新)'); } catch { // Best-effort conflict notification must not hide the original conflict. } @@ -251,18 +253,10 @@ export class SchedulesController { @Param('id') id: string, @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); await this.getAuthorizedSchedule(+id, req as { user: RequestUser }); const result = await this.service.remove(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '排课管理', - action: '停用排课', - targetId: +id, - targetType: 'class-schedule', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '排课管理', action: '停用排课', targetId: +id, targetType: 'class-schedule', }); return result; } diff --git a/apps/server/src/schedules/schedules.module.ts b/apps/server/src/schedules/schedules.module.ts index bf4041e..6d28106 100644 --- a/apps/server/src/schedules/schedules.module.ts +++ b/apps/server/src/schedules/schedules.module.ts @@ -9,6 +9,7 @@ import { AttendanceSession, } from '../entities'; import { SchedulesService } from './schedules.service'; +import { ScheduleQueriesService } from './schedule-queries.service'; import { SchedulesController } from './schedules.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -27,7 +28,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; NotificationsModule, ], controllers: [SchedulesController], - providers: [SchedulesService], + providers: [SchedulesService, ScheduleQueriesService], exports: [SchedulesService], }) export class SchedulesModule {} diff --git a/apps/server/src/schedules/schedules.scope.spec.ts b/apps/server/src/schedules/schedules.scope.spec.ts index 1522cac..6dbe04a 100644 --- a/apps/server/src/schedules/schedules.scope.spec.ts +++ b/apps/server/src/schedules/schedules.scope.spec.ts @@ -1,4 +1,5 @@ import { SchedulesService } from './schedules.service'; +import { ScheduleQueriesService } from './schedule-queries.service'; const createQb = () => ({ andWhere: jest.fn().mockReturnThis(), @@ -20,6 +21,7 @@ function serviceWithAssignments(assignments: number[]) { .fn() .mockResolvedValue(assignments.map((classId) => ({ classId, userId: 7 }))), }; + const queries = new ScheduleQueriesService(scheduleRepo as never); const service = new SchedulesService( scheduleRepo as never, {} as never, @@ -27,6 +29,7 @@ function serviceWithAssignments(assignments: number[]) { {} as never, classTeacherRepo as never, {} as never, + queries, ); return { service, qb, scheduleRepo }; } @@ -40,6 +43,9 @@ describe('SchedulesService — teacher class scope', () => { {} as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, ); await service.findAll({}, [3, 5]); @@ -57,6 +63,9 @@ describe('SchedulesService — teacher class scope', () => { {} as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, ); await expect(service.findAll({}, [])).resolves.toEqual([]); @@ -132,11 +141,15 @@ describe('SchedulesService — shared classroom occupancy visibility', () => { notes: '其他班备注', }, ]); + const scheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) }; const service = new SchedulesService( - { createQueryBuilder: jest.fn().mockReturnValue(qb) } as never, + scheduleRepo as never, {} as never, {} as never, {} as never, + {} as never, + {} as never, + new ScheduleQueriesService(scheduleRepo as never), ); const result = await service.getWeeklyView({}, [3]); diff --git a/apps/server/src/schedules/schedules.service.spec.ts b/apps/server/src/schedules/schedules.service.spec.ts index 771d18a..ab9f4c2 100644 --- a/apps/server/src/schedules/schedules.service.spec.ts +++ b/apps/server/src/schedules/schedules.service.spec.ts @@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { BadRequestException, ConflictException } from '@nestjs/common'; import { Repository } from 'typeorm'; import { SchedulesService } from './schedules.service'; +import { ScheduleQueriesService } from './schedule-queries.service'; import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { Class } from '../entities/class.entity'; @@ -38,6 +39,7 @@ describe('SchedulesService — getLookups', () => { const module = await Test.createTestingModule({ providers: [ SchedulesService, + ScheduleQueriesService, { provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn().mockReturnValue(scheduleQb) }, @@ -74,6 +76,7 @@ describe('SchedulesService — checkConflict', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SchedulesService, + ScheduleQueriesService, { provide: getRepositoryToken(ClassSchedule), useValue: mockRepo }, { provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } }, { provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } }, @@ -223,6 +226,7 @@ describe('SchedulesService — getClassroomOccupancy', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SchedulesService, + ScheduleQueriesService, { provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } }, { provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } }, { provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } }, @@ -303,6 +307,7 @@ describe('SchedulesService — remove/update status', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SchedulesService, + ScheduleQueriesService, { provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepoMock, @@ -445,6 +450,7 @@ describe('SchedulesService — range boundaries', () => { const makeService = () => { const scheduleRepo = { create: jest.fn() }; return { + queries: new ScheduleQueriesService(scheduleRepo as never), service: new SchedulesService( scheduleRepo as never, {} as never, diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index cb3d14f..f2a6963 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -6,7 +6,7 @@ import { BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Not, Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { ClassSchedule, Class, @@ -16,6 +16,7 @@ import { ClassTeacher, AttendanceSession, } from '../entities'; +import { ScheduleQueriesService } from './schedule-queries.service'; import { CreateScheduleDto, UpdateScheduleDto, @@ -27,7 +28,10 @@ const SCHEDULE_GAP_MINUTES = 10; const ACTIVE_SCHEDULE_STATUS = 'active'; const INACTIVE_SCHEDULE_STATUSES = ['inactive', 'cancelled'] as const; type ScheduleStatus = typeof ACTIVE_SCHEDULE_STATUS | (typeof INACTIVE_SCHEDULE_STATUSES)[number]; -const SCHEDULE_STATUSES: readonly ScheduleStatus[] = [ACTIVE_SCHEDULE_STATUS, ...INACTIVE_SCHEDULE_STATUSES]; +const SCHEDULE_STATUSES: readonly ScheduleStatus[] = [ + ACTIVE_SCHEDULE_STATUS, + ...INACTIVE_SCHEDULE_STATUSES, +]; function shiftTime(time: string, minutes: number): string { const [hours, minutePart] = time.split(':').map(Number); @@ -50,6 +54,7 @@ export class SchedulesService { private readonly classTeacherRepo: Repository, @InjectRepository(AttendanceSession) private readonly attendanceSessionRepo: Repository, + private readonly queries: ScheduleQueriesService, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { @@ -64,26 +69,6 @@ export class SchedulesService { if (!assignment) throw new ForbiddenException('只能管理自己被分配班级的排课'); } - maskScheduleOccupancy(schedule: ClassSchedule) { - return { - id: null, - classId: null, - classroomId: schedule.classroomId, - weekDay: schedule.weekDay, - startTime: schedule.startTime, - endTime: schedule.endTime, - attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes, - startDate: schedule.startDate, - endDate: schedule.endDate, - subject: '已占用', - teacherId: null, - scheduleType: schedule.scheduleType, - status: schedule.status, - notes: null, - canViewDetails: false, - }; - } - async getLookups(accessibleClassIds?: number[]) { const classes = accessibleClassIds ? accessibleClassIds.length > 0 @@ -133,95 +118,6 @@ export class SchedulesService { * Agent tool: 查询当前用户有权查看的排课,返回白名单字段。 * 教师范围按班级授课关系过滤。 */ - async agentSearchSchedules( - userId: number, - canManageAll: boolean, - query?: { classId?: number; classroomId?: number; weekDay?: number; limit?: number }, - ): Promise< - { - id: number; - classId: number | null; - className: string | null; - classroomId: number; - classroomName: string | null; - weekDay: number; - startTime: string; - endTime: string; - subject: string; - teacherName: string | null; - startDate: string; - endDate: string; - scheduleType: string; - status: string; - }[] - > { - const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll); - if (query?.classId && accessibleClassIds && !accessibleClassIds.includes(query.classId)) { - return []; - } - if (accessibleClassIds && accessibleClassIds.length === 0) { - return []; - } - const qb = this.scheduleRepo - .createQueryBuilder('cs') - .leftJoin('cs.class', 'class') - .leftJoin('cs.classroom', 'classroom') - .leftJoin('cs.teacher', 'teacher') - .select([ - 'cs.id', - 'cs.classId', - 'cs.classroomId', - 'cs.weekDay', - 'cs.startTime', - 'cs.endTime', - 'cs.subject', - 'cs.teacherId', - 'cs.startDate', - 'cs.endDate', - 'cs.scheduleType', - 'cs.status', - 'class.name', - 'classroom.name', - 'teacher.name', - ]) - .where('cs.status = :active', { active: 'active' }); - - if (query?.classroomId) { - qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); - } - if (query?.classId) { - qb.andWhere('cs.classId = :classId', { classId: query.classId }); - } - if (accessibleClassIds) { - qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds }); - } - if (query?.weekDay) { - qb.andWhere('cs.weekDay = :weekDay', { weekDay: query.weekDay }); - } - - const rows = await qb - .orderBy('cs.weekDay', 'ASC') - .addOrderBy('cs.startTime', 'ASC') - .limit(Math.max(1, Math.min(query?.limit ?? 20, 50))) - .getRawMany>(); - return rows.map((row) => ({ - id: Number(row.cs_id), - classId: row.cs_class_id == null ? null : Number(row.cs_class_id), - className: row.class_name == null ? null : String(row.class_name), - classroomId: Number(row.cs_classroom_id), - classroomName: row.classroom_name == null ? null : String(row.classroom_name), - weekDay: Number(row.cs_week_day), - startTime: String(row.cs_start_time), - endTime: String(row.cs_end_time), - subject: String(row.cs_subject), - teacherName: row.teacher_name == null ? null : String(row.teacher_name), - startDate: String(row.cs_start_date), - endDate: String(row.cs_end_date), - scheduleType: String(row.cs_schedule_type), - status: String(row.cs_status), - })); - } - async getClassTeachers(classId: number) { const teachers = await this.classTeacherRepo.find({ where: { classId }, @@ -331,6 +227,8 @@ export class SchedulesService { const weekDay = dto.weekDay ?? existing.weekDay; const startTime = dto.startTime ?? existing.startTime; const endTime = dto.endTime ?? existing.endTime; + + const startDate = dto.startDate ?? existing.startDate; const endDate = dto.endDate ?? existing.endDate; this.assertValidScheduleRange(startTime, endTime, startDate, endDate); @@ -361,6 +259,26 @@ export class SchedulesService { return this.findOne(id); } + maskScheduleOccupancy(schedule: ClassSchedule) { + return this.queries.maskScheduleOccupancy(schedule); + } + + async agentSearchSchedules( + userId: number, + canManageAll: boolean, + query?: { classId?: number; classroomId?: number; weekDay?: number; limit?: number }, + ) { + const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll); + return this.queries.agentSearchSchedules(accessibleClassIds, query); + } + + async getWeeklyView(query: WeeklyViewQueryDto, accessibleClassIds?: number[]) { + return this.queries.getWeeklyView(query, accessibleClassIds); + } + + async getClassroomOccupancy(classroomId: number, date?: string) { + return this.queries.getClassroomOccupancy(classroomId, date); + } async remove(id: number) { const schedule = await this.scheduleRepo.findOne({ where: { id } }); if (!schedule) throw new NotFoundException('排课记录不存在'); @@ -421,61 +339,4 @@ export class SchedulesService { return conflicts; } - async getWeeklyView(query: WeeklyViewQueryDto, accessibleClassIds?: number[]) { - const qb = this.scheduleRepo.createQueryBuilder('cs'); - if (query.classroomId) { - qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); - } - if (query.startDate) { - qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); - } - if (query.endDate) { - qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate }); - } - - const schedules = await qb - .andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS }) - .orderBy('cs.weekDay', 'ASC') - .addOrderBy('cs.startTime', 'ASC') - .getMany(); - - const allowedClassIds = accessibleClassIds ? new Set(accessibleClassIds) : null; - const visibleSchedules = schedules.map((schedule) => { - const canViewDetails = - allowedClassIds === null || - (schedule.classId !== null && allowedClassIds.has(schedule.classId)); - if (canViewDetails) return { ...schedule, canViewDetails: true }; - - // Other classes remain visible only as a room/time occupancy block. - // Do not expose class, subject, teacher, notes, or internal record IDs. - return this.maskScheduleOccupancy(schedule); - }); - - // Group by classroomId → weekDay - const matrix: Record> = {}; - for (const schedule of visibleSchedules) { - if (!matrix[schedule.classroomId]) matrix[schedule.classroomId] = {}; - if (!matrix[schedule.classroomId][schedule.weekDay]) - matrix[schedule.classroomId][schedule.weekDay] = []; - matrix[schedule.classroomId][schedule.weekDay].push(schedule); - } - - return matrix; - } - - async getClassroomOccupancy(classroomId: number, date?: string) { - const qb = this.scheduleRepo - .createQueryBuilder('cs') - .where('cs.classroomId = :classroomId', { classroomId }) - .andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS }) - .andWhere('cs.scheduleType IN (:...scheduleTypes)', { - scheduleTypes: ['INTERNAL', 'RENTAL'], - }); - - if (date) { - qb.andWhere('cs.startDate <= :date', { date }).andWhere('cs.endDate >= :date', { date }); - } - - return qb.orderBy('cs.weekDay', 'ASC').addOrderBy('cs.startTime', 'ASC').getMany(); - } } diff --git a/apps/server/src/students/students.agent.service.ts b/apps/server/src/students/students.agent.service.ts new file mode 100644 index 0000000..34f446b --- /dev/null +++ b/apps/server/src/students/students.agent.service.ts @@ -0,0 +1,233 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Student } from '../entities/student.entity'; +import { ClassStudent } from '../entities/class-student.entity'; +import type { StudentAccessScope } from './student-access-scope'; + +@Injectable() +export class StudentsAgentService { + /** + * Whitelisted output type for agent student searches. + * NEVER exposes phone, idNumber, emergencyContact, or emergencyPhone. + */ + private static readonly AGENT_STUDENT_SELECT = [ + 'student.id', + 'student.name', + 'student.studentNo', + 'student.gender', + 'student.status', + 'student.organizationId', + 'organization.name', + ] as const; + + constructor( + @InjectRepository(Student) private readonly repo: Repository, + @InjectRepository(ClassStudent) + private readonly classStudentRepo: Repository, + ) {} + + /** + * Search students with SQL-enforced scope, field whitelist, and limit. + * + * @param scope — data-range discriminator (manageAll or teacher). + * @param query — optional keyword, classId, organizationId, limit. + * @returns formatted whitelist-only results with classIds. + */ + async agentSearchStudents( + scope: StudentAccessScope, + query?: { + keyword?: string; + classId?: number; + organizationId?: number; + limit?: number; + }, + ): Promise< + { + id: number; + name: string; + studentNo: string; + gender: string; + status: string; + organizationId: number; + organizationName: string; + classIds: number[]; + }[] + > { + const limit = Math.max(1, Math.min(query?.limit ?? 20, 50)); + + const qb = this.repo + .createQueryBuilder('student') + .distinct(true) + .select([ + 'student.id', + 'student.name', + 'student.studentNo', + 'student.gender', + 'student.status', + 'student.organizationId', + 'student.createdAt', + 'organization.name', + ]) + .leftJoin('student.organization', 'organization'); + + this.applyStudentScope(qb, scope, query?.classId); + + if (query?.keyword) { + qb.andWhere('(student.name LIKE :keyword OR student.student_no LIKE :keyword)', { + keyword: `%${query.keyword}%`, + }); + } + if (query?.organizationId) { + qb.andWhere('student.organization_id = :orgId', { orgId: query.organizationId }); + } + + qb.orderBy('student.createdAt', 'DESC').take(limit); + + const rows: Record[] = await qb.getRawMany(); + if (rows.length === 0) return []; + + // Second bounded query: classIds only for the returned student ids. + // For teacher scope, the class filter MUST be re-applied so the + // teacher only sees classIds they are assigned to. + const studentIds = rows.map((r) => r.student_id as number); + const csQb = this.classStudentRepo + .createQueryBuilder('cs') + .select(['cs.studentId', 'cs.classId']) + .where('cs.student_id IN (:...ids)', { ids: studentIds }) + .andWhere('cs.status = :status', { status: 'active' }); + + if (scope.type === 'teacher') { + csQb.andWhere( + 'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)', + { scopeTeacherUserId: scope.userId }, + ); + } + + const classRows = await csQb.getRawMany(); + + const classMap = new Map(); + for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) { + const sid = cr.cs_student_id; + if (!classMap.has(sid)) classMap.set(sid, []); + classMap.get(sid)!.push(cr.cs_class_id); + } + + return rows.map((r) => ({ + id: r.student_id as number, + name: r.student_name as string, + studentNo: (r.student_student_no as string) ?? '', + gender: (r.student_gender as string) ?? '', + status: r.student_status as string, + organizationId: r.student_organization_id as number, + organizationName: (r.organization_name as string) ?? '', + classIds: classMap.get(r.student_id as number) ?? [], + })); + } + + /** + * Get single student basic info with SQL-enforced scope + whitelist. + * Returns `null` for students out of scope or non-existent (no leak). + */ + async agentGetStudentBasic( + scope: StudentAccessScope, + studentId: number, + ): Promise<{ + id: number; + name: string; + studentNo: string; + gender: string; + status: string; + organizationId: number; + organizationName: string; + classIds: number[]; + } | null> { + const qb = this.repo + .createQueryBuilder('student') + .select([ + 'student.id', + 'student.name', + 'student.studentNo', + 'student.gender', + 'student.status', + 'student.organizationId', + 'organization.name', + ]) + .leftJoin('student.organization', 'organization') + .where('student.id = :studentId', { studentId }); + + this.applyStudentScope(qb, scope); + + const row = await qb.getRawOne(); + if (!row) return null; + + // For teacher scope, re-apply class filter so teacher only sees + // classIds they are assigned to (not ALL active classIds of the student). + const csQb = this.classStudentRepo + .createQueryBuilder('cs') + .select(['cs.classId']) + .where('cs.student_id = :studentId', { studentId }) + .andWhere('cs.status = :status', { status: 'active' }); + + if (scope.type === 'teacher') { + csQb.andWhere( + 'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)', + { scopeTeacherUserId: scope.userId }, + ); + } + + const classRows = await csQb.getRawMany(); + + return { + id: row.student_id as number, + name: row.student_name as string, + studentNo: (row.student_student_no as string) ?? '', + gender: (row.student_gender as string) ?? '', + status: row.student_status as string, + organizationId: row.student_organization_id as number, + organizationName: (row.organization_name as string) ?? '', + classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id), + }; + } + + /** + * Apply data-range scope to a student QueryBuilder. + * + * - `manageAll`: no restriction. + * - `teacher`: INNER JOIN ClassStudent → active students in the + * teacher's assigned classes (via ClassTeacher). + * - When `classId` is provided, it is ANDed with the scope + * (intersection) — the model cannot widen access. + */ + private applyStudentScope( + qb: ReturnType, + scope: StudentAccessScope, + classId?: number, + ): void { + if (scope.type === 'manageAll') { + if (classId != null) { + qb.innerJoin( + 'class_student', + 'cs_scope', + 'cs_scope.student_id = student.id AND cs_scope.class_id = :scopeClassId AND cs_scope.status = :scopeCsStatus', + { scopeClassId: classId, scopeCsStatus: 'active' }, + ); + } + return; + } + + // Teacher scope: active students in teacher's assigned classes + const teacherClause = + 'cs_scope.student_id = student.id AND cs_scope.status = :scopeCsStatus AND cs_scope.class_id IN ' + + '(SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)'; + + qb.innerJoin('class_student', 'cs_scope', teacherClause, { + scopeTeacherUserId: scope.userId, + scopeCsStatus: 'active', + }); + + if (classId != null) { + qb.andWhere('cs_scope.class_id = :scopeClassId', { scopeClassId: classId }); + } + } +} diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index 28dc968..2e85695 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -12,7 +12,6 @@ import { Res, UseInterceptors, UploadedFile, - Inject, ParseIntPipe, UsePipes, ValidationPipe, @@ -20,17 +19,20 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Organization } from '../entities/organization.entity'; -import { ClassTeacher } from '../entities/class-teacher.entity'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { StudentsService } from './students.service'; import { CreateStudentDto, QueryStudentDto, UpdateStudentDto } from './dto/student.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; -import { AuthorizationService, CaslAction, SubjectName } from '../authorization'; -import type { AuthenticatedUser } from '../authorization'; +import { + AuthorizationService, + CaslAction, + SubjectName, + type AuthenticatedUser, +} from '../authorization'; import * as ExcelJS from 'exceljs'; import { createStudentImportTemplateWorkbook, @@ -79,27 +81,17 @@ export class StudentsController { @Get() @RequirePermission('student:view') - async findAll( - @Query() query: QueryStudentDto, - @Request() req: AuthenticatedRequest, - ) { + async findAll(@Query() query: QueryStudentDto, @Request() req: AuthenticatedRequest) { const classIds = await this.service.getAccessibleClassIds( req.user.id, this.canManageAllStudents(req), ); - return this.service.findAll( - query, - classIds, - ); + return this.service.findAll(query, classIds); } @Get('export') @RequirePermission('student:export') - async exportExcel( - @Query() query: QueryStudentDto, - @Res() res?: Response, - @Request() req?: any, - ) { + async exportExcel(@Query() query: QueryStudentDto, @Res() res?: Response, @Request() req?: any) { const classIds = await this.service.getAccessibleClassIds( req.user.id, this.canManageAllStudents(req), @@ -142,15 +134,8 @@ export class StudentsController { admittedMajor: result?.admittedMajor || '', }); } - const { ipAddress, userAgent } = extractRequestInfo(req); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '导出学生', - detail: `导出 ${students.length} 名学生`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '导出学生', detail: `导出 ${students.length} 名学生`, }); res!.setHeader( 'Content-Type', @@ -183,18 +168,9 @@ export class StudentsController { @Post() @RequirePermission('student:create') async create(@Body() dto: CreateStudentDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '新增学生', - targetId: result.id, - targetType: 'student', - detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`, }); return result; } @@ -203,35 +179,23 @@ export class StudentsController { @RequirePermission('student:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRestore(dto.ids); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '批量恢复学生', - detail: `IDs: ${dto.ids.join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '批量恢复学生', detail: `IDs: ${dto.ids.join(',')}`, }); return result; } @Put(':id') @RequirePermission('student:edit') - async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStudentDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); + async update( + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateStudentDto, + @Request() req: any, + ) { const result = await this.service.update(id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '编辑学生', - targetId: id, - targetType: 'student', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '编辑学生', targetId: id, targetType: 'student', detail: JSON.stringify(dto), }); return result; } @@ -239,17 +203,9 @@ export class StudentsController { @Delete(':id') @RequirePermission('student:delete') async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '归档学生', - targetId: id, - targetType: 'student', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '归档学生', targetId: id, targetType: 'student', }); return result; } @@ -257,16 +213,29 @@ export class StudentsController { @Post('batch-delete') @RequirePermission('student:delete') async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRemove(body.ids || []); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '批量归档学生', - detail: `IDs: ${(body.ids || []).join(',')}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '批量归档学生', detail: `IDs: ${(body.ids || []).join(',')}`, + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('student:purge') + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + const result = await this.service.purge(id); + await logAudit(this.logService, req, { + module: '学生管理', action: '永久删除学生', targetId: id, targetType: 'student', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('batch-permanent-delete') + @RequirePermission('student:purge') + async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + const result = await this.service.batchPurge(body.ids || []); + await logAudit(this.logService, req, { + module: '学生管理', action: '批量永久删除学生', detail: `IDs: ${(body.ids || []).join(',')}`, }); return result; } @@ -274,17 +243,9 @@ export class StudentsController { @Put(':id/restore') @RequirePermission('student:edit') async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.restore(id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '恢复学生', - targetId: id, - targetType: 'student', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '恢复学生', targetId: id, targetType: 'student', }); return result; } @@ -293,9 +254,8 @@ export class StudentsController { @RequirePermission('student:import') @UseInterceptors(FileInterceptor('file')) async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as any); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs for (const row of importData.students) { @@ -309,14 +269,8 @@ export class StudentsController { } } const result = await this.service.batchImport(importData); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '导入学生', - detail: result.message, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '导入学生', detail: result.message, }); return result; } @@ -325,9 +279,8 @@ export class StudentsController { @RequirePermission('student:import') @UseInterceptors(FileInterceptor('file')) async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs for (const row of importData.students) { @@ -339,14 +292,8 @@ export class StudentsController { } } const result = await this.service.matchImport(importData); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '学生管理', - action: '更新已有学生资料', - detail: result.message, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '学生管理', action: '更新已有学生资料', detail: result.message, }); return result; } diff --git a/apps/server/src/students/students.import.service.ts b/apps/server/src/students/students.import.service.ts new file mode 100644 index 0000000..ef89cf4 --- /dev/null +++ b/apps/server/src/students/students.import.service.ts @@ -0,0 +1,314 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Student } from '../entities/student.entity'; +import { StudentProfile } from '../entities/student-profile.entity'; +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { ExamScore } from '../entities/exam-score.entity'; +import { LearningRecord } from '../entities/learning-record.entity'; +import { ResultArchive } from '../entities/result-archive.entity'; +import { Organization } from '../entities/organization.entity'; +import type { + ExamScoreImportRow, + LearningRecordImportRow, + StudentEnrollmentImportRow, + StudentImportRow, + StudentWorkbookImport, +} from './student-import'; +import { getHostOrganizationId } from './students.organization'; + +@Injectable() +export class StudentsImportService { + constructor( + @InjectRepository(Student) private readonly repo: Repository, + @InjectRepository(StudentProfile) private readonly profileRepo: Repository, + @InjectRepository(StudentEnrollment) + private readonly enrollmentRepo: Repository, + @InjectRepository(ExamScore) private readonly examScoreRepo: Repository, + @InjectRepository(LearningRecord) + private readonly learningRecordRepo: Repository, + @InjectRepository(ResultArchive) private readonly resultRepo: Repository, + @InjectRepository(Organization) private readonly organizationRepo: Repository, + ) {} + + async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) { + const data = this.normalizeImportData(importData); + let imported = 0; + let skipped = 0; + let archiveImported = 0; + for (const row of data.students) { + if (!row.name || !row.name.trim()) { + skipped++; + continue; + } + const exists = await this.repo.findOne({ where: { name: row.name.trim() } }); + if (exists) { + skipped++; + continue; + } + const student = await this.repo.save( + this.repo.create({ + name: row.name.trim(), + studentNo: row.studentNo?.trim() || undefined, + phone: row.phone?.trim() || undefined, + idNumber: row.idNumber?.trim() || undefined, + gender: row.gender || undefined, + ethnicity: row.ethnicity || undefined, + emergencyContact: row.emergencyContact || undefined, + emergencyPhone: row.emergencyPhone || undefined, + supervisor: row.supervisor || undefined, + organizationId: row.organizationId || (await getHostOrganizationId(this.organizationRepo)), + }), + ); + archiveImported += await this.importArchiveData(student.id, row, data); + imported++; + } + return { + message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`, + imported, + archiveImported, + skipped, + }; + } + + async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) { + const data = this.normalizeImportData(importData); + let matched = 0; + let skipped = 0; + let archiveImported = 0; + for (const row of data.students) { + // Match by phone first, then idNumber + let student = row.phone?.trim() + ? await this.repo.findOne({ where: { phone: row.phone.trim() } }) + : null; + if (!student && row.idNumber?.trim()) { + student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } }); + } + if (!student) { + skipped++; + continue; + } + const updates: Partial< + Pick< + Student, + | 'name' + | 'studentNo' + | 'phone' + | 'idNumber' + | 'gender' + | 'ethnicity' + | 'emergencyContact' + | 'emergencyPhone' + | 'supervisor' + | 'organizationId' + > + > = {}; + if (row.name?.trim()) updates.name = row.name.trim(); + if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); + if (row.phone?.trim()) updates.phone = row.phone.trim(); + if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); + if (row.gender) updates.gender = row.gender; + if (row.ethnicity) updates.ethnicity = row.ethnicity; + if (row.emergencyContact) updates.emergencyContact = row.emergencyContact; + if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone; + if (row.supervisor) updates.supervisor = row.supervisor; + if (row.organizationId) updates.organizationId = row.organizationId; + await this.repo.update(student.id, updates); + archiveImported += await this.importArchiveData(student.id, row, data); + matched++; + } + return { + message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`, + matched, + archiveImported, + skipped, + }; + } + + private normalizeImportData( + importData: StudentWorkbookImport | StudentImportRow[], + ): StudentWorkbookImport { + if (Array.isArray(importData)) { + return { students: importData, enrollments: [], examScores: [], learningRecords: [] }; + } + return importData; + } + + private normalizePhone(phone?: string) { + return phone?.trim() || ''; + } + + private sameValue(left?: string | number | null, right?: string | number | null) { + return String(left ?? '').trim() === String(right ?? '').trim(); + } + + private hasProfileData(row: StudentImportRow) { + return [ + row.targetCollege, + row.targetMajor, + row.collegeSchool, + row.collegeMajor, + row.subjectDirection, + row.grade, + row.profileDate, + row.notes, + ].some((value) => value !== undefined && String(value).trim() !== ''); + } + + private hasResultData(row: StudentImportRow) { + return [ + row.cultureFinalScore, + row.professionalFinalScore, + row.admissionStatus, + row.admittedCollege, + row.admittedMajor, + ].some((value) => value !== undefined && String(value).trim() !== ''); + } + + private async importArchiveData( + studentId: number, + row: StudentImportRow, + data: StudentWorkbookImport, + ) { + const phone = this.normalizePhone(row.phone); + let imported = 0; + if (this.hasProfileData(row)) { + await this.upsertProfileFromImport(studentId, row); + imported++; + } + if (this.hasResultData(row)) { + await this.upsertResultFromImport(studentId, row); + imported++; + } + if (!phone) return imported; + + const enrollmentByClassName = new Map(); + for (const enrollmentRow of data.enrollments.filter( + (item) => this.normalizePhone(item.phone) === phone, + )) { + const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow); + if (!enrollment) continue; + if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); + imported++; + } + for (const examRow of data.examScores.filter( + (item) => this.normalizePhone(item.phone) === phone, + )) { + if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) { + imported++; + } + } + for (const learningRow of data.learningRecords.filter( + (item) => this.normalizePhone(item.phone) === phone, + )) { + if (await this.upsertLearningRecordFromImport(studentId, learningRow)) { + imported++; + } + } + return imported; + } + + private async upsertProfileFromImport(studentId: number, row: StudentImportRow) { + const entity = + (await this.profileRepo.findOne({ where: { studentId } })) || + this.profileRepo.create({ studentId }); + if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); + if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); + if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); + if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim(); + if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim(); + if (row.grade?.trim()) entity.grade = row.grade.trim(); + if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim(); + if (row.notes?.trim()) entity.notes = row.notes.trim(); + await this.profileRepo.save(entity); + } + + private async upsertResultFromImport(studentId: number, row: StudentImportRow) { + const entity = + (await this.resultRepo.findOne({ where: { studentId } })) || + this.resultRepo.create({ studentId }); + if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore; + if (row.professionalFinalScore !== undefined) + entity.professionalFinalScore = row.professionalFinalScore; + if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim(); + if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim(); + if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim(); + await this.resultRepo.save(entity); + } + + private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) { + if (!row.courseCategory?.trim() || !row.classType?.trim()) { + return null; + } + const existing = await this.enrollmentRepo.find({ where: { studentId } }); + const entity = + existing.find( + (item) => + this.sameValue(item.courseCategory, row.courseCategory) && + this.sameValue(item.classType, row.classType) && + this.sameValue(item.className, row.className) && + this.sameValue(item.startDate, row.startDate), + ) || this.enrollmentRepo.create({ studentId }); + entity.courseCategory = row.courseCategory.trim(); + entity.classType = row.classType.trim(); + if (row.className?.trim()) entity.className = row.className.trim(); + if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim(); + if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim(); + if (row.startDate?.trim()) entity.startDate = row.startDate.trim(); + if (row.endDate?.trim()) entity.endDate = row.endDate.trim(); + if (row.status?.trim()) entity.status = row.status.trim(); + else if (!entity.status) entity.status = 'active'; + return this.enrollmentRepo.save(entity); + } + + private async upsertExamScoreFromImport( + studentId: number, + row: ExamScoreImportRow, + enrollmentByClassName: Map, + ) { + if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false; + const existing = await this.examScoreRepo.find({ where: { studentId } }); + const entity = + existing.find( + (item) => + this.sameValue(item.examType, row.examType) && + this.sameValue(item.examName, row.examName) && + this.sameValue(item.subject, row.subject) && + this.sameValue(item.examDate, row.examDate), + ) || this.examScoreRepo.create({ studentId }); + entity.examType = row.examType.trim(); + entity.subject = row.subject.trim(); + entity.score = row.score; + if (row.examName?.trim()) entity.examName = row.examName.trim(); + if (row.classAvg !== undefined) entity.classAvg = row.classAvg; + if (row.rank !== undefined) entity.rank = row.rank; + if (row.examDate?.trim()) entity.examDate = row.examDate.trim(); + if (row.enrollmentName?.trim()) { + const enrollment = enrollmentByClassName.get(row.enrollmentName.trim()); + if (enrollment) entity.enrollmentId = enrollment.id; + } + if (!entity.status) entity.status = 'active'; + await this.examScoreRepo.save(entity); + return true; + } + + private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) { + if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false; + const existing = await this.learningRecordRepo.find({ where: { studentId } }); + const entity = + existing.find( + (item) => + this.sameValue(item.recordDate, row.recordDate) && + this.sameValue(item.recordType, row.recordType) && + this.sameValue(item.content, row.content), + ) || this.learningRecordRepo.create({ studentId }); + entity.recordDate = row.recordDate.trim(); + entity.recordType = row.recordType.trim(); + entity.content = row.content.trim(); + if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim(); + if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim(); + if (!entity.status) entity.status = 'active'; + await this.learningRecordRepo.save(entity); + return true; + } +} diff --git a/apps/server/src/students/students.lifecycle.service.ts b/apps/server/src/students/students.lifecycle.service.ts new file mode 100644 index 0000000..5051006 --- /dev/null +++ b/apps/server/src/students/students.lifecycle.service.ts @@ -0,0 +1,235 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { Student } from '../entities/student.entity'; +import { ClassStudent } from '../entities/class-student.entity'; +import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { PersonalExpense } from '../entities/personal-expense.entity'; +import { Bill } from '../entities/bill.entity'; +import { Deposit } from '../entities/deposit.entity'; +import { StudentProfile } from '../entities/student-profile.entity'; +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { ExamScore } from '../entities/exam-score.entity'; +import { LearningRecord } from '../entities/learning-record.entity'; +import { ResultArchive } from '../entities/result-archive.entity'; +import { ArchiveAttachment } from '../entities/archive-attachment.entity'; +import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; +import { StudentWallet } from '../entities/student-wallet.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; + +@Injectable() +export class StudentsLifecycleService { + constructor( + @InjectRepository(Student) private readonly repo: Repository, + @InjectRepository(ClassStudent) private readonly classStudentRepo: Repository, + @InjectRepository(AttendanceRecord) + private readonly attendanceRepo: Repository, + @InjectRepository(StudentProfile) private readonly profileRepo: Repository, + @InjectRepository(StudentEnrollment) + private readonly enrollmentRepo: Repository, + @InjectRepository(ExamScore) private readonly examScoreRepo: Repository, + @InjectRepository(LearningRecord) + private readonly learningRecordRepo: Repository, + @InjectRepository(ResultArchive) private readonly resultRepo: Repository, + @InjectRepository(Occupancy) private readonly occupancyRepo: Repository, + @InjectRepository(PersonalExpense) + private readonly personalExpenseRepo: Repository, + @InjectRepository(Bill) private readonly billRepo: Repository, + @InjectRepository(Deposit) private readonly depositRepo: Repository, + @InjectRepository(ArchiveAttachment) + private readonly attachmentRepo: Repository, + @InjectRepository(StudentDingMapping) + private readonly dingMappingRepo: Repository, + @InjectRepository(StudentWallet) private readonly walletRepo: Repository, + @InjectRepository(RoomInspectionDetail) + private readonly inspectionDetailRepo: Repository, + ) {} + + private async findOne(id: number) { + const student = await this.repo.findOne({ + where: { id }, + relations: ['occupancies', 'occupancies.room'], + }); + if (!student) throw new NotFoundException('学生不存在'); + return student; + } + + async getArchiveExportMaps(studentIds: number[]) { + if (studentIds.length === 0) { + return { + profiles: new Map(), + results: new Map(), + }; + } + const [profiles, results] = await Promise.all([ + this.profileRepo.find({ where: { studentId: In(studentIds) } }), + this.resultRepo.find({ where: { studentId: In(studentIds) } }), + ]); + return { + profiles: new Map(profiles.map((profile) => [profile.studentId, profile])), + results: new Map(results.map((result) => [result.studentId, result])), + }; + } + + async batchRemove(ids: number[]) { + if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的学生'); + const students = await this.repo.find({ where: { id: In(ids) } }); + const skipped: string[] = []; + const targetIds: number[] = []; + for (const s of students) { + if (s.status === 'archived') skipped.push(s.name); + else targetIds.push(s.id); + } + let affected = 0; + if (targetIds.length > 0) { + const result = await this.repo + .createQueryBuilder() + .update() + .set({ status: 'archived' }) + .where('id IN (:...ids)', { ids: targetIds }) + .execute(); + affected = result.affected || 0; + } + const message = + skipped.length > 0 + ? `成功归档 ${affected} 人;${skipped.length} 人已是归档状态被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已批量归档 ${affected} 人(数据已保留,可随时恢复)`; + return { message, archived: affected, skipped: skipped.length }; + } + + async restore(id: number) { + const student = await this.findOne(id); + if (student.status !== 'archived') { + throw new BadRequestException('该学生未被归档'); + } + await this.repo.update(id, { status: 'active' }); + return { message: '已恢复' }; + } + + private async assertNoStudentReferences(studentId: number) { + const [ + occupancyCount, + personalExpenseCount, + billCount, + depositCount, + classMemberCount, + profileCount, + enrollmentCount, + examScoreCount, + learningRecordCount, + attachmentCount, + resultCount, + attendanceCount, + dingMappingCount, + walletCount, + inspectionDetailCount, + ] = await Promise.all([ + this.occupancyRepo.count({ where: { studentId } }), + this.personalExpenseRepo.count({ where: { studentId } }), + this.billRepo.count({ where: { studentId } }), + this.depositRepo.count({ where: { studentId } }), + this.classStudentRepo.count({ where: { studentId } }), + this.profileRepo.count({ where: { studentId } }), + this.enrollmentRepo.count({ where: { studentId } }), + this.examScoreRepo.count({ where: { studentId } }), + this.learningRecordRepo.count({ where: { studentId } }), + this.attachmentRepo.count({ where: { studentId } }), + this.resultRepo.count({ where: { studentId } }), + this.attendanceRepo.count({ where: { studentId } }), + this.dingMappingRepo.count({ where: { studentId } }), + this.walletRepo.count({ where: { studentId } }), + this.inspectionDetailRepo.count({ where: { studentId } }), + ]); + const refs: Array<[string, number]> = [ + ['入住记录', occupancyCount], + ['个人费用', personalExpenseCount], + ['账单', billCount], + ['押金', depositCount], + ['班级成员', classMemberCount], + ['档案信息', profileCount], + ['报名记录', enrollmentCount], + ['考试成绩', examScoreCount], + ['学习记录', learningRecordCount], + ['档案附件', attachmentCount], + ['录取结果', resultCount], + ['考勤记录', attendanceCount], + ['钉钉映射', dingMappingCount], + ['学生钱包', walletCount], + ['查寝明细', inspectionDetailCount], + ]; + const references = refs.filter(([, count]) => count > 0); + if (references.length > 0) { + const names = references.map(([name]) => name).join('、'); + throw new BadRequestException(`该学生存在关联数据(${names}),无法永久删除`); + } + } + + async purge(id: number) { + const student = await this.findOne(id); + if (student.status !== 'archived') { + throw new BadRequestException('仅已归档学生可以永久删除,请先归档'); + } + await this.assertNoStudentReferences(id); + await this.repo.delete(id); + return { message: '已永久删除学生(不可恢复)' }; + } + + async batchPurge(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的学生'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('学生 ID 无效'); + } + const students = await this.repo.find({ where: { id: In(uniqueIds) } }); + if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在'); + + const deleted: number[] = []; + const skipped: string[] = []; + for (const student of students) { + if (student.status !== 'archived') { + skipped.push(`${student.name}(未归档)`); + continue; + } + try { + await this.assertNoStudentReferences(student.id); + } catch { + skipped.push(`${student.name}(存在关联数据)`); + continue; + } + await this.repo.delete(student.id); + deleted.push(student.id); + } + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 人;${skipped.length} 人被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 名学生(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + } + + async batchRestore(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的学生'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('学生 ID 无效'); + } + const students = await this.repo.find({ where: { id: In(uniqueIds) } }); + if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在'); + + const targetIds = students + .filter((student) => student.status === 'archived') + .map((student) => student.id); + const skipped = students.length - targetIds.length; + let restored = 0; + if (targetIds.length > 0) { + const result = await this.repo + .createQueryBuilder() + .update() + .set({ status: 'active' }) + .where('id IN (:...ids)', { ids: targetIds }) + .execute(); + restored = result.affected || 0; + } + return { message: `已批量恢复 ${restored} 名学生`, restored, skipped }; + } +} diff --git a/apps/server/src/students/students.module.ts b/apps/server/src/students/students.module.ts index a1576b0..f61c551 100644 --- a/apps/server/src/students/students.module.ts +++ b/apps/server/src/students/students.module.ts @@ -11,6 +11,14 @@ import { StudentEnrollment } from '../entities/student-enrollment.entity'; import { ExamScore } from '../entities/exam-score.entity'; import { LearningRecord } from '../entities/learning-record.entity'; import { ResultArchive } from '../entities/result-archive.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { PersonalExpense } from '../entities/personal-expense.entity'; +import { Bill } from '../entities/bill.entity'; +import { Deposit } from '../entities/deposit.entity'; +import { ArchiveAttachment } from '../entities/archive-attachment.entity'; +import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; +import { StudentWallet } from '../entities/student-wallet.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; import { StudentsService } from './students.service'; import { StudentAccessScopeFactory } from './student-access-scope.factory'; import { StudentsController } from './students.controller'; @@ -29,6 +37,14 @@ import { StudentsController } from './students.controller'; ExamScore, LearningRecord, ResultArchive, + Occupancy, + PersonalExpense, + Bill, + Deposit, + ArchiveAttachment, + StudentDingMapping, + StudentWallet, + RoomInspectionDetail, ]), ], controllers: [StudentsController], diff --git a/apps/server/src/students/students.organization.ts b/apps/server/src/students/students.organization.ts new file mode 100644 index 0000000..457275c --- /dev/null +++ b/apps/server/src/students/students.organization.ts @@ -0,0 +1,21 @@ +import { BadRequestException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { Organization } from '../entities/organization.entity'; + +export async function assertActiveOrganization( + organizationRepo: Repository, + id: number, +): Promise { + const organization = await organizationRepo.findOne({ where: { id, status: 'active' } }); + if (!organization) throw new BadRequestException('所属机构不存在或已归档'); +} + +export async function getHostOrganizationId( + organizationRepo: Repository, +): Promise { + const organization = await organizationRepo.findOne({ + where: { isHost: true, status: 'active' }, + }); + if (!organization) throw new BadRequestException('尚未配置本机构'); + return organization.id; +} diff --git a/apps/server/src/students/students.purge.controller.spec.ts b/apps/server/src/students/students.purge.controller.spec.ts new file mode 100644 index 0000000..b35d068 --- /dev/null +++ b/apps/server/src/students/students.purge.controller.spec.ts @@ -0,0 +1,31 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { StudentsController } from './students.controller'; + +describe('StudentsController purge routes', () => { + it('requires student:purge on permanent delete routes', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, StudentsController.prototype.purge)).toEqual([ + 'student:purge', + ]); + expect( + Reflect.getMetadata(PERMISSION_KEY, StudentsController.prototype.batchPurge), + ).toEqual(['student:purge']); + }); + + it('writes permanent delete audit logs', async () => { + const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除学生(不可恢复)' }) }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new StudentsController( + service as never, + { log } as never, + {} as never, + {} as never, + ); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purge(1, req); + expect(service.purge).toHaveBeenCalledWith(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '学生管理', action: '永久删除学生', targetId: 1 }), + ); + }); +}); diff --git a/apps/server/src/students/students.purge.spec.ts b/apps/server/src/students/students.purge.spec.ts new file mode 100644 index 0000000..37907fb --- /dev/null +++ b/apps/server/src/students/students.purge.spec.ts @@ -0,0 +1,77 @@ +import { BadRequestException } from '@nestjs/common'; +import { StudentsService } from './students.service'; + +const student = { id: 1, name: '张三', status: 'archived' }; + +const createService = (overrides?: { + student?: Record; + counts?: Record; +}) => { + const counts = overrides?.counts ?? {}; + const countFor = (key: string) => jest.fn().mockResolvedValue(counts[key] ?? 0); + const repo = { + findOne: jest.fn().mockResolvedValue(overrides?.student ?? student), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + find: jest.fn().mockResolvedValue([overrides?.student ?? student]), + }; + const occupancyCount = countFor('occupancy'); + const service = new StudentsService( + repo as never, + { count: countFor('classStudent') } as never, + {} as never, + { count: countFor('attendance') } as never, + {} as never, + {} as never, + { count: countFor('profile') } as never, + { count: countFor('enrollment') } as never, + { count: countFor('examScore') } as never, + { count: countFor('learningRecord') } as never, + { count: countFor('result') } as never, + { count: occupancyCount } as never, + { count: countFor('personalExpense') } as never, + { count: countFor('bill') } as never, + { count: countFor('deposit') } as never, + { count: countFor('attachment') } as never, + { count: countFor('dingMapping') } as never, + { count: countFor('wallet') } as never, + { count: countFor('inspectionDetail') } as never, + ); + return { service, repo, occupancyCount }; +}; + +describe('StudentsService.purge', () => { + it('rejects students that are not archived', async () => { + const { service, repo } = createService({ student: { id: 1, name: '张三', status: 'active' } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('仅已归档学生可以永久删除,请先归档'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('rejects students with any reference', async () => { + const { service, repo } = createService({ counts: { occupancy: 2 } }); + await expect(service.purge(1)).rejects.toThrow( + new BadRequestException('该学生存在关联数据(入住记录),无法永久删除'), + ); + expect(repo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived student with no references', async () => { + const { service, repo } = createService(); + await expect(service.purge(1)).resolves.toEqual({ message: '已永久删除学生(不可恢复)' }); + expect(repo.delete).toHaveBeenCalledWith(1); + }); + + it('batch purge returns deleted and skipped counts', async () => { + const { service, repo, occupancyCount } = createService(); + repo.find = jest.fn().mockResolvedValue([ + { id: 1, name: '甲', status: 'archived' }, + { id: 2, name: '乙', status: 'archived' }, + { id: 3, name: '丙', status: 'active' }, + ]); + occupancyCount.mockResolvedValueOnce(1).mockResolvedValue(0); + const result = await service.batchPurge([1, 2, 3]); + expect(result).toMatchObject({ deleted: 1, skipped: 2 }); + expect(repo.delete).toHaveBeenCalledWith(2); + }); +}); diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index f6a7593..0f8cfb9 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -1,29 +1,37 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Like, Not, In, FindOptionsWhere, IsNull } from 'typeorm'; +import { Like, Not, In, FindOptionsWhere, IsNull, Repository } from 'typeorm'; import { Student } from '../entities/student.entity'; import { Class } from '../entities/class.entity'; import { ClassStudent } from '../entities/class-student.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { Organization } from '../entities/organization.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { PersonalExpense } from '../entities/personal-expense.entity'; +import { Bill } from '../entities/bill.entity'; +import { Deposit } from '../entities/deposit.entity'; import { StudentProfile } from '../entities/student-profile.entity'; import { StudentEnrollment } from '../entities/student-enrollment.entity'; import { ExamScore } from '../entities/exam-score.entity'; import { LearningRecord } from '../entities/learning-record.entity'; import { ResultArchive } from '../entities/result-archive.entity'; +import { ArchiveAttachment } from '../entities/archive-attachment.entity'; +import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; +import { StudentWallet } from '../entities/student-wallet.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto'; -import type { - ExamScoreImportRow, - LearningRecordImportRow, - StudentEnrollmentImportRow, - StudentImportRow, - StudentWorkbookImport, -} from './student-import'; -import type { StudentAccessScope } from './student-access-scope'; +import { assertActiveOrganization } from './students.organization'; +import { StudentsImportService } from './students.import.service'; +import { StudentsLifecycleService } from './students.lifecycle.service'; +import { StudentsAgentService } from './students.agent.service'; @Injectable() export class StudentsService { + private importService?: StudentsImportService; + private lifecycleService?: StudentsLifecycleService; + private agentService?: StudentsAgentService; + constructor( @InjectRepository(Student) private repo: Repository, @InjectRepository(ClassStudent) private classStudentRepo: Repository, @@ -36,8 +44,63 @@ export class StudentsService { @InjectRepository(ExamScore) private examScoreRepo: Repository, @InjectRepository(LearningRecord) private learningRecordRepo: Repository, @InjectRepository(ResultArchive) private resultRepo: Repository, + @InjectRepository(Occupancy) private occupancyRepo: Repository, + @InjectRepository(PersonalExpense) private personalExpenseRepo: Repository, + @InjectRepository(Bill) private billRepo: Repository, + @InjectRepository(Deposit) private depositRepo: Repository, + @InjectRepository(ArchiveAttachment) private attachmentRepo: Repository, + @InjectRepository(StudentDingMapping) private dingMappingRepo: Repository, + @InjectRepository(StudentWallet) private walletRepo: Repository, + @InjectRepository(RoomInspectionDetail) + private inspectionDetailRepo: Repository, ) {} + private get imports(): StudentsImportService { + if (!this.importService) { + this.importService = new StudentsImportService( + this.repo, + this.profileRepo, + this.enrollmentRepo, + this.examScoreRepo, + this.learningRecordRepo, + this.resultRepo, + this.organizationRepo, + ); + } + return this.importService; + } + + private get lifecycle(): StudentsLifecycleService { + if (!this.lifecycleService) { + this.lifecycleService = new StudentsLifecycleService( + this.repo, + this.classStudentRepo, + this.attendanceRepo, + this.profileRepo, + this.enrollmentRepo, + this.examScoreRepo, + this.learningRecordRepo, + this.resultRepo, + this.occupancyRepo, + this.personalExpenseRepo, + this.billRepo, + this.depositRepo, + this.attachmentRepo, + this.dingMappingRepo, + this.walletRepo, + this.inspectionDetailRepo, + ); + } + return this.lifecycleService; + } + + private get agents(): StudentsAgentService { + if (!this.agentService) { + this.agentService = new StudentsAgentService(this.repo, this.classStudentRepo); + } + return this.agentService; + } + async getAccessibleClassIds(userId: number, canManageAll = false): Promise { if (canManageAll) return undefined; const assignments = await this.classTeacherRepo.find({ where: { userId } }); @@ -52,21 +115,8 @@ export class StudentsService { }); } - async getArchiveExportMaps(studentIds: number[]) { - if (studentIds.length === 0) { - return { - profiles: new Map(), - results: new Map(), - }; - } - const [profiles, results] = await Promise.all([ - this.profileRepo.find({ where: { studentId: In(studentIds) } }), - this.resultRepo.find({ where: { studentId: In(studentIds) } }), - ]); - return { - profiles: new Map(profiles.map((profile) => [profile.studentId, profile])), - results: new Map(results.map((result) => [result.studentId, result])), - }; + async getArchiveExportMaps(...args: Parameters) { + return this.lifecycle.getArchiveExportMaps(...args); } async findAll( @@ -164,13 +214,13 @@ export class StudentsService { } async create(dto: CreateStudentDto) { - await this.assertActiveOrganization(dto.organizationId); + await assertActiveOrganization(this.organizationRepo, dto.organizationId); return this.repo.save(this.repo.create(dto)); } async update(id: number, dto: UpdateStudentDto) { await this.findOne(id); - if (dto.organizationId) await this.assertActiveOrganization(dto.organizationId); + if (dto.organizationId) await assertActiveOrganization(this.organizationRepo, dto.organizationId); await this.repo.update(id, dto); return this.repo.findOne({ where: { id } }); } @@ -184,345 +234,32 @@ export class StudentsService { return { message: '已归档(数据已保留,可随时恢复)' }; } - async batchRemove(ids: number[]) { - if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的学生'); - const students = await this.repo.find({ where: { id: In(ids) } }); - const skipped: string[] = []; - const targetIds: number[] = []; - for (const s of students) { - if (s.status === 'archived') skipped.push(s.name); - else targetIds.push(s.id); - } - let affected = 0; - if (targetIds.length > 0) { - const result = await this.repo - .createQueryBuilder() - .update() - .set({ status: 'archived' }) - .where('id IN (:...ids)', { ids: targetIds }) - .execute(); - affected = result.affected || 0; - } - const message = - skipped.length > 0 - ? `成功归档 ${affected} 人;${skipped.length} 人已是归档状态被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` - : `已批量归档 ${affected} 人(数据已保留,可随时恢复)`; - return { message, archived: affected, skipped: skipped.length }; + async batchRemove(...args: Parameters) { + return this.lifecycle.batchRemove(...args); } - async restore(id: number) { - const student = await this.findOne(id); - if (student.status !== 'archived') { - throw new BadRequestException('该学生未被归档'); - } - await this.repo.update(id, { status: 'active' }); - return { message: '已恢复' }; + async restore(...args: Parameters) { + return this.lifecycle.restore(...args); } - async batchRestore(ids: number[]) { - const uniqueIds = [...new Set(ids || [])]; - if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的学生'); - if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { - throw new BadRequestException('学生 ID 无效'); - } - const students = await this.repo.find({ where: { id: In(uniqueIds) } }); - if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在'); - - const targetIds = students.filter((student) => student.status === 'archived').map((student) => student.id); - const skipped = students.length - targetIds.length; - let restored = 0; - if (targetIds.length > 0) { - const result = await this.repo - .createQueryBuilder() - .update() - .set({ status: 'active' }) - .where('id IN (:...ids)', { ids: targetIds }) - .execute(); - restored = result.affected || 0; - } - return { message: `已批量恢复 ${restored} 名学生`, restored, skipped }; + async purge(...args: Parameters) { + return this.lifecycle.purge(...args); } - async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) { - const data = this.normalizeImportData(importData); - let imported = 0; - let skipped = 0; - let archiveImported = 0; - for (const row of data.students) { - if (!row.name || !row.name.trim()) { - skipped++; - continue; - } - const exists = await this.repo.findOne({ where: { name: row.name.trim() } }); - if (exists) { - skipped++; - continue; - } - const student = await this.repo.save( - this.repo.create({ - name: row.name.trim(), - studentNo: row.studentNo?.trim() || undefined, - phone: row.phone?.trim() || undefined, - idNumber: row.idNumber?.trim() || undefined, - gender: row.gender || undefined, - ethnicity: row.ethnicity || undefined, - emergencyContact: row.emergencyContact || undefined, - emergencyPhone: row.emergencyPhone || undefined, - supervisor: row.supervisor || undefined, - organizationId: row.organizationId || (await this.getHostOrganizationId()), - }), - ); - archiveImported += await this.importArchiveData(student.id, row, data); - imported++; - } - return { - message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`, - imported, - archiveImported, - skipped, - }; + async batchPurge(...args: Parameters) { + return this.lifecycle.batchPurge(...args); } - async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) { - const data = this.normalizeImportData(importData); - let matched = 0; - let skipped = 0; - let archiveImported = 0; - for (const row of data.students) { - // Match by phone first, then idNumber - let student = row.phone?.trim() - ? await this.repo.findOne({ where: { phone: row.phone.trim() } }) - : null; - if (!student && row.idNumber?.trim()) { - student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } }); - } - if (!student) { - skipped++; - continue; - } - // Update matched student with non-empty imported fields - const updates: Partial< - Pick< - Student, - | 'name' - | 'studentNo' - | 'phone' - | 'idNumber' - | 'gender' - | 'ethnicity' - | 'emergencyContact' - | 'emergencyPhone' - | 'supervisor' - | 'organizationId' - > - > = {}; - if (row.name?.trim()) updates.name = row.name.trim(); - if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); - if (row.phone?.trim()) updates.phone = row.phone.trim(); - if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); - if (row.gender) updates.gender = row.gender; - if (row.ethnicity) updates.ethnicity = row.ethnicity; - if (row.emergencyContact) updates.emergencyContact = row.emergencyContact; - if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone; - if (row.supervisor) updates.supervisor = row.supervisor; - if (row.organizationId) updates.organizationId = row.organizationId; - await this.repo.update(student.id, updates); - archiveImported += await this.importArchiveData(student.id, row, data); - matched++; - } - return { - message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`, - matched, - archiveImported, - skipped, - }; + async batchRestore(...args: Parameters) { + return this.lifecycle.batchRestore(...args); } - private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport { - if (Array.isArray(importData)) { - return { students: importData, enrollments: [], examScores: [], learningRecords: [] }; - } - return importData; + async batchImport(...args: Parameters) { + return this.imports.batchImport(...args); } - private normalizePhone(phone?: string) { - return phone?.trim() || ''; - } - - private sameValue(left?: string | number | null, right?: string | number | null) { - return String(left ?? '').trim() === String(right ?? '').trim(); - } - - private hasProfileData(row: StudentImportRow) { - return [ - row.targetCollege, - row.targetMajor, - row.collegeSchool, - row.collegeMajor, - row.subjectDirection, - row.grade, - row.profileDate, - row.notes, - ].some((value) => value !== undefined && String(value).trim() !== ''); - } - - private hasResultData(row: StudentImportRow) { - return [ - row.cultureFinalScore, - row.professionalFinalScore, - row.admissionStatus, - row.admittedCollege, - row.admittedMajor, - ].some((value) => value !== undefined && String(value).trim() !== ''); - } - - private async importArchiveData( - studentId: number, - row: StudentImportRow, - data: StudentWorkbookImport, - ) { - const phone = this.normalizePhone(row.phone); - let imported = 0; - if (this.hasProfileData(row)) { - await this.upsertProfileFromImport(studentId, row); - imported++; - } - if (this.hasResultData(row)) { - await this.upsertResultFromImport(studentId, row); - imported++; - } - if (!phone) return imported; - - const enrollmentByClassName = new Map(); - for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) { - const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow); - if (!enrollment) continue; - if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); - imported++; - } - for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) { - if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) { - imported++; - } - } - for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) { - if (await this.upsertLearningRecordFromImport(studentId, learningRow)) { - imported++; - } - } - return imported; - } - - private async upsertProfileFromImport(studentId: number, row: StudentImportRow) { - const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId }); - if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); - if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); - if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); - if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim(); - if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim(); - if (row.grade?.trim()) entity.grade = row.grade.trim(); - if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim(); - if (row.notes?.trim()) entity.notes = row.notes.trim(); - await this.profileRepo.save(entity); - } - - private async upsertResultFromImport(studentId: number, row: StudentImportRow) { - const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId }); - if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore; - if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore; - if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim(); - if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim(); - if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim(); - await this.resultRepo.save(entity); - } - - private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) { - if (!row.courseCategory?.trim() || !row.classType?.trim()) { - return null; - } - const existing = await this.enrollmentRepo.find({ where: { studentId } }); - const entity = - existing.find( - (item) => - this.sameValue(item.courseCategory, row.courseCategory) && - this.sameValue(item.classType, row.classType) && - this.sameValue(item.className, row.className) && - this.sameValue(item.startDate, row.startDate), - ) || this.enrollmentRepo.create({ studentId }); - entity.courseCategory = row.courseCategory.trim(); - entity.classType = row.classType.trim(); - if (row.className?.trim()) entity.className = row.className.trim(); - if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim(); - if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim(); - if (row.startDate?.trim()) entity.startDate = row.startDate.trim(); - if (row.endDate?.trim()) entity.endDate = row.endDate.trim(); - if (row.status?.trim()) entity.status = row.status.trim(); - else if (!entity.status) entity.status = 'active'; - return this.enrollmentRepo.save(entity); - } - - private async upsertExamScoreFromImport( - studentId: number, - row: ExamScoreImportRow, - enrollmentByClassName: Map, - ) { - if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false; - const existing = await this.examScoreRepo.find({ where: { studentId } }); - const entity = - existing.find( - (item) => - this.sameValue(item.examType, row.examType) && - this.sameValue(item.examName, row.examName) && - this.sameValue(item.subject, row.subject) && - this.sameValue(item.examDate, row.examDate), - ) || this.examScoreRepo.create({ studentId }); - entity.examType = row.examType.trim(); - entity.subject = row.subject.trim(); - entity.score = row.score; - if (row.examName?.trim()) entity.examName = row.examName.trim(); - if (row.classAvg !== undefined) entity.classAvg = row.classAvg; - if (row.rank !== undefined) entity.rank = row.rank; - if (row.examDate?.trim()) entity.examDate = row.examDate.trim(); - if (row.enrollmentName?.trim()) { - const enrollment = enrollmentByClassName.get(row.enrollmentName.trim()); - if (enrollment) entity.enrollmentId = enrollment.id; - } - if (!entity.status) entity.status = 'active'; - await this.examScoreRepo.save(entity); - return true; - } - - private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) { - if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false; - const existing = await this.learningRecordRepo.find({ where: { studentId } }); - const entity = - existing.find( - (item) => - this.sameValue(item.recordDate, row.recordDate) && - this.sameValue(item.recordType, row.recordType) && - this.sameValue(item.content, row.content), - ) || this.learningRecordRepo.create({ studentId }); - entity.recordDate = row.recordDate.trim(); - entity.recordType = row.recordType.trim(); - entity.content = row.content.trim(); - if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim(); - if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim(); - if (!entity.status) entity.status = 'active'; - await this.learningRecordRepo.save(entity); - return true; - } - - private async assertActiveOrganization(id: number) { - const organization = await this.organizationRepo.findOne({ where: { id, status: 'active' } }); - if (!organization) throw new BadRequestException('所属机构不存在或已归档'); - } - - private async getHostOrganizationId() { - const organization = await this.organizationRepo.findOne({ - where: { isHost: true, status: 'active' }, - }); - if (!organization) throw new BadRequestException('尚未配置本机构'); - return organization.id; + async matchImport(...args: Parameters) { + return this.imports.matchImport(...args); } async compareClasses(studentId: number) { @@ -581,228 +318,15 @@ export class StudentsService { return { student, enrollments: comparison }; } - // ------------------------------------------------------------------------- - // Agent-safe query APIs — SQL-level scope + field whitelist - // ------------------------------------------------------------------------- - - /** - * Whitelisted output type for agent student searches. - * NEVER exposes phone, idNumber, emergencyContact, or emergencyPhone. - */ - private static readonly AGENT_STUDENT_SELECT = [ - 'student.id', - 'student.name', - 'student.studentNo', - 'student.gender', - 'student.status', - 'student.organizationId', - 'organization.name', - ] as const; - - /** - * Search students with SQL-enforced scope, field whitelist, and limit. - * - * @param scope — data-range discriminator (manageAll or teacher). - * @param query — optional keyword, classId, organizationId, limit. - * @returns formatted whitelist-only results with classIds. - */ async agentSearchStudents( - scope: StudentAccessScope, - query?: { - keyword?: string; - classId?: number; - organizationId?: number; - limit?: number; - }, - ): Promise< - { - id: number; - name: string; - studentNo: string; - gender: string; - status: string; - organizationId: number; - organizationName: string; - classIds: number[]; - }[] - > { - const limit = Math.max(1, Math.min(query?.limit ?? 20, 50)); - - const qb = this.repo - .createQueryBuilder('student') - .distinct(true) - .select([ - 'student.id', - 'student.name', - 'student.studentNo', - 'student.gender', - 'student.status', - 'student.organizationId', - 'student.createdAt', - 'organization.name', - ]) - .leftJoin('student.organization', 'organization'); - - // ---- Scope enforcement ---- - this.applyStudentScope(qb, scope, query?.classId); - - // ---- Filters ---- - if (query?.keyword) { - qb.andWhere( - '(student.name LIKE :keyword OR student.student_no LIKE :keyword)', - { keyword: `%${query.keyword}%` }, - ); - } - if (query?.organizationId) { - qb.andWhere('student.organization_id = :orgId', { orgId: query.organizationId }); - } - - qb.orderBy('student.createdAt', 'DESC').take(limit); - - const rows: Record[] = await qb.getRawMany(); - if (rows.length === 0) return []; - - // Second bounded query: classIds only for the returned student ids. - // For teacher scope, the class filter MUST be re-applied so the - // teacher only sees classIds they are assigned to. - const studentIds = rows.map((r) => r.student_id as number); - const csQb = this.classStudentRepo - .createQueryBuilder('cs') - .select(['cs.studentId', 'cs.classId']) - .where('cs.student_id IN (:...ids)', { ids: studentIds }) - .andWhere('cs.status = :status', { status: 'active' }); - - if (scope.type === 'teacher') { - csQb.andWhere( - 'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)', - { scopeTeacherUserId: scope.userId }, - ); - } - - const classRows = await csQb.getRawMany(); - - const classMap = new Map(); - for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) { - const sid = cr.cs_student_id; - if (!classMap.has(sid)) classMap.set(sid, []); - classMap.get(sid)!.push(cr.cs_class_id); - } - - return rows.map((r) => ({ - id: r.student_id as number, - name: r.student_name as string, - studentNo: (r.student_student_no as string) ?? '', - gender: (r.student_gender as string) ?? '', - status: r.student_status as string, - organizationId: r.student_organization_id as number, - organizationName: (r.organization_name as string) ?? '', - classIds: classMap.get(r.student_id as number) ?? [], - })); + ...args: Parameters + ) { + return this.agents.agentSearchStudents(...args); } - /** - * Get single student basic info with SQL-enforced scope + whitelist. - * Returns `null` for students out of scope or non-existent (no leak). - */ async agentGetStudentBasic( - scope: StudentAccessScope, - studentId: number, - ): Promise<{ - id: number; - name: string; - studentNo: string; - gender: string; - status: string; - organizationId: number; - organizationName: string; - classIds: number[]; - } | null> { - const qb = this.repo - .createQueryBuilder('student') - .select([ - 'student.id', - 'student.name', - 'student.studentNo', - 'student.gender', - 'student.status', - 'student.organizationId', - 'organization.name', - ]) - .leftJoin('student.organization', 'organization') - .where('student.id = :studentId', { studentId }); - - this.applyStudentScope(qb, scope); - - const row = await qb.getRawOne(); - if (!row) return null; - - // For teacher scope, re-apply class filter so teacher only sees - // classIds they are assigned to (not ALL active classIds of the student). - const csQb = this.classStudentRepo - .createQueryBuilder('cs') - .select(['cs.classId']) - .where('cs.student_id = :studentId', { studentId }) - .andWhere('cs.status = :status', { status: 'active' }); - - if (scope.type === 'teacher') { - csQb.andWhere( - 'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)', - { scopeTeacherUserId: scope.userId }, - ); - } - - const classRows = await csQb.getRawMany(); - - return { - id: row.student_id as number, - name: row.student_name as string, - studentNo: (row.student_student_no as string) ?? '', - gender: (row.student_gender as string) ?? '', - status: row.student_status as string, - organizationId: row.student_organization_id as number, - organizationName: (row.organization_name as string) ?? '', - classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id), - }; - } - - /** - * Apply data-range scope to a student QueryBuilder. - * - * - `manageAll`: no restriction. - * - `teacher`: INNER JOIN ClassStudent → active students in the - * teacher's assigned classes (via ClassTeacher). - * - When `classId` is provided, it is ANDed with the scope - * (intersection) — the model cannot widen access. - */ - private applyStudentScope( - qb: ReturnType, - scope: StudentAccessScope, - classId?: number, - ): void { - if (scope.type === 'manageAll') { - if (classId != null) { - qb.innerJoin( - 'class_student', - 'cs_scope', - 'cs_scope.student_id = student.id AND cs_scope.class_id = :scopeClassId AND cs_scope.status = :scopeCsStatus', - { scopeClassId: classId, scopeCsStatus: 'active' }, - ); - } - return; - } - - // Teacher scope: active students in teacher's assigned classes - const teacherClause = - 'cs_scope.student_id = student.id AND cs_scope.status = :scopeCsStatus AND cs_scope.class_id IN ' + - '(SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)'; - - qb.innerJoin('class_student', 'cs_scope', teacherClause, { - scopeTeacherUserId: scope.userId, - scopeCsStatus: 'active', - }); - - if (classId != null) { - qb.andWhere('cs_scope.class_id = :scopeClassId', { scopeClassId: classId }); - } + ...args: Parameters + ) { + return this.agents.agentGetStudentBasic(...args); } } diff --git a/apps/server/src/sync/jinshuju-rules.ts b/apps/server/src/sync/jinshuju-rules.ts new file mode 100644 index 0000000..6248b3f --- /dev/null +++ b/apps/server/src/sync/jinshuju-rules.ts @@ -0,0 +1,46 @@ +import { ConflictException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { JinshujuMatchRule, type JinshujuFieldMapping } from '../entities/jinshuju-match-rule.entity'; + +export async function getMatchRule( + repo: Repository, + id: number, + formToken: string, +): Promise { + const rule = await repo.findOne({ where: { id } }); + if (!rule) throw new ConflictException('规则不存在'); + if (rule.formToken !== formToken) { + throw new ConflictException('匹配规则不属于当前表单'); + } + return rule; +} + +export function validateMatchRule(formToken: string, mappings: JinshujuFieldMapping): void { + if (!formToken.trim()) throw new ConflictException('表单 Token 不能为空'); + if (!mappings.name) throw new ConflictException('匹配规则必须映射姓名字段'); + const allowedStudentFields = new Set([ + 'name', + 'studentNo', + 'phone', + 'idNumber', + 'gender', + 'ethnicity', + 'emergencyContact', + 'emergencyPhone', + ]); + for (const [studentField, fieldKey] of Object.entries(mappings)) { + if (!allowedStudentFields.has(studentField)) { + throw new ConflictException(`不允许映射学生字段:${studentField}`); + } + if (fieldKey && !/^field_\d+$/.test(fieldKey)) { + throw new ConflictException(`无效的金数据字段:${fieldKey}`); + } + } +} + +/** Extract value from a Jinshuju entry by field mapping. */ +export function extractField(entry: Record, fieldKey: string | undefined): string { + if (!fieldKey) return ''; + const val = entry[fieldKey]; + return typeof val === 'string' ? val.trim() : ''; +} diff --git a/apps/server/src/sync/schedule-sync.helpers.ts b/apps/server/src/sync/schedule-sync.helpers.ts new file mode 100644 index 0000000..5728151 --- /dev/null +++ b/apps/server/src/sync/schedule-sync.helpers.ts @@ -0,0 +1,185 @@ +import { Repository, In } from 'typeorm'; +import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities'; +import type { DingTalkScheduleItem } from '../integration/dingtalk.service'; + +export interface DailySchedulePeriod { + startTime: string; + endTime: string; + scheduleId: number; +} + +export interface DailySchedulePlan { + classId: number; + date: string; + shiftKey: string; + periods: DailySchedulePeriod[]; +} + +/** 单次排班同步的结果 */ +export interface ScheduleSyncResult { + /** 参与同步的排课记录数 */ + scheduleCount: number; + /** 创建/复用的班次数 */ + shiftCount: number; + /** 创建/复用的考勤组数 */ + groupCount: number; + /** 实际写入钉钉的排班条数 */ + syncedItems: number; + /** 因无学生或无钉钉映射而跳过的排课数 */ + skippedNoMapping: number; + /** 写入失败的排班批次数 */ + failedBatchCount: number; + /** 写入失败的排班条数 */ + failedItems: number; + /** 失败批次错误详情 */ + errors: string[]; + /** 按班级分组的详情 */ + groups: Array<{ + className: string; + groupId: number; + itemCount: number; + }>; +} + +export async function buildClassDingUserMap( + classStudentRepo: Repository, + mappingRepo: Repository, + classIds: number[], +): Promise> { + const result = new Map(); + if (classIds.length === 0) return result; + + // 班级 → 活跃学生 + const links = await classStudentRepo.find({ + where: { classId: In(classIds), status: 'active' }, + }); + if (links.length === 0) return result; + + // 学生 → 钉钉 userId + const studentIds = [...new Set(links.map((l) => l.studentId))]; + const mappings = await mappingRepo.find({ + where: { studentId: In(studentIds) }, + }); + const studentToDing = new Map(mappings.map((m) => [m.studentId, m.dingUserId])); + + for (const link of links) { + const dingId = studentToDing.get(link.studentId); + if (!dingId) continue; + if (!result.has(link.classId)) result.set(link.classId, []); + const arr = result.get(link.classId)!; + if (!arr.includes(dingId)) arr.push(dingId); + } + return result; +} + +export async function loadClassNames( + classRepo: Repository, + classIds: number[], +): Promise> { + const map = new Map(); + if (classIds.length === 0) return map; + const classes = await classRepo.find({ where: { id: In(classIds) } }); + for (const c of classes) map.set(c.id, c.name); + return map; +} + +/** + * 把本地排课转换为“班级 + 日期”的日排班计划。 + * 同一天相同时间段会去重,多节课按开始时间排序并合并为一个钉钉班次。 + */ +export function buildDailySchedulePlans( + schedules: ClassSchedule[], + syncFrom: string, + syncTo: string, +): DailySchedulePlan[] { + const periodMapByClassDate = new Map>(); + const fromDate = new Date(`${syncFrom}T00:00:00.000Z`); + const toDate = new Date(`${syncTo}T00:00:00.000Z`); + + for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) { + const dateStr = date.toISOString().slice(0, 10); + const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay(); + + 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, + }); + } + } + } + + const plans: DailySchedulePlan[] = []; + 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 内可包含多个课程卡段。 */ +export function expandDailySchedulePlans( + plans: DailySchedulePlan[], + dingUserIds: string[], + planToShiftId: Map, +): DingTalkScheduleItem[] { + const items: DingTalkScheduleItem[] = []; + for (const plan of plans) { + const shiftId = planToShiftId.get(plan.shiftKey); + if (!shiftId) continue; + const workDate = new Date(`${plan.date}T00:00:00+08:00`).getTime(); + for (const userid of dingUserIds) { + items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false }); + } + } + return items; +} + +export function toMinutes(time: string): number { + const [hour, minute] = time.split(':').map(Number); + return hour * 60 + minute; +} + +export function minutesBetween(startTime: string, endTime: string): number { + const start = toMinutes(startTime); + let end = toMinutes(endTime); + if (end <= start) end += 24 * 60; + return end - start; +} + +export function addDays(dateStr: string, days: number): string { + const d = new Date(`${dateStr}T00:00:00.000Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} diff --git a/apps/server/src/sync/schedule-sync.service.ts b/apps/server/src/sync/schedule-sync.service.ts index 00acf8b..a3c4d59 100644 --- a/apps/server/src/sync/schedule-sync.service.ts +++ b/apps/server/src/sync/schedule-sync.service.ts @@ -1,69 +1,21 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In } from 'typeorm'; +import { Repository } from 'typeorm'; import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities'; -import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service'; +import { DingTalkService } from '../integration/dingtalk.service'; +import { + buildClassDingUserMap, + loadClassNames, + buildDailySchedulePlans, + expandDailySchedulePlans, + toMinutes, + minutesBetween, + addDays, + type DailySchedulePeriod, + type DailySchedulePlan, + type ScheduleSyncResult, +} from './schedule-sync.helpers'; -interface DailySchedulePeriod { - startTime: string; - endTime: string; - scheduleId: number; -} - -interface DailySchedulePlan { - classId: number; - date: string; - shiftKey: string; - periods: DailySchedulePeriod[]; -} - -/** 单次排班同步的结果 */ -export interface ScheduleSyncResult { - /** 参与同步的排课记录数 */ - scheduleCount: number; - /** 创建/复用的班次数 */ - shiftCount: number; - /** 创建/复用的考勤组数 */ - groupCount: number; - /** 实际写入钉钉的排班条数 */ - syncedItems: number; - /** 因无学生或无钉钉映射而跳过的排课数 */ - skippedNoMapping: number; - /** 写入失败的排班批次数 */ - failedBatchCount: number; - /** 写入失败的排班条数 */ - failedItems: number; - /** 失败批次错误详情 */ - errors: string[]; - /** 按班级分组的详情 */ - groups: Array<{ - className: string; - groupId: number; - itemCount: number; - }>; -} - -/** - * 排班同步服务 — 将本地 ClassSchedule 同步到钉钉考勤排班。 - * - * ## 同步流程(按班级学生) - * 1. 查询活跃排课,按 classId 分组 - * 2. 通过 ClassStudent + StudentDingMapping 拿到每个班级学生的钉钉 userId - * 3. 按 (startTime, endTime) 创建/匹配钉钉班次(班次列表只拉一次) - * 4. 每个班级创建/匹配一个排班制考勤组(考勤组列表只拉一次) - * 5. 将排课展开为每个学生的每日排班,批量写入钉钉 - * - * ## 残余风险:同步窗口内已不存在的旧排班无法清理 - * 钉钉开放平台未暴露排班删除接口(仅提供 `schedule/listbyusers` 查询和 - * `group/schedule/async` 写入)。`queryScheduleByUsers` 受限于 7 天窗口 - * 和每次 50 个用户,且无配套删除能力,无法在同步前清理旧排班。 - * 当前产品流程为"排课后手动同步钉钉",依赖运营人员知晓同步时机; - * 若后续需要自动清理,需等钉钉开放排班删除 API 或改用考勤组覆盖策略。 - * - * ## API 调用优化 - * - 班次列表、考勤组列表各只查询一次,在内存中按名称匹配,避免每次 findOrCreate 都发一次查询。 - * - 排班写入按考勤组分批(钉钉单次最多 200 条)。 - */ @Injectable() export class ScheduleSyncService { private readonly logger = new Logger(ScheduleSyncService.name); @@ -95,7 +47,7 @@ export class ScheduleSyncService { ): Promise { const startDate = dateFrom || new Date().toISOString().slice(0, 10); const normalizedDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30; - const endDate = this.addDays(startDate, normalizedDays - 1); + const endDate = addDays(startDate, normalizedDays - 1); const empty: ScheduleSyncResult = { scheduleCount: 0, @@ -121,13 +73,13 @@ export class ScheduleSyncService { // ── Step 2: 班级 → 学生钉钉ID 映射 ── const classIds = [...new Set(schedules.map((s) => s.classId as number))]; - const classDingUsers = await this.buildClassDingUserMap(classIds); - const classNameMap = await this.loadClassNames(classIds); + const classDingUsers = await buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds); + const classNameMap = await loadClassNames(this.classRepo, classIds); // ── Step 3: 将每天的多节课合并成一个钉钉班次 ── // 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为 // 同一个班次的多个 sections 写入,不能拆成多条 schedule item。 - const dailyPlans = this.buildDailySchedulePlans(schedules, startDate, endDate); + const dailyPlans = buildDailySchedulePlans(schedules, startDate, endDate); const uniqueShifts = new Map< string, { className: string; periods: DailySchedulePeriod[] } @@ -171,7 +123,7 @@ export class ScheduleSyncService { }, { check_type: 'OffDuty' as const, - across: this.toMinutes(period.endTime) <= this.toMinutes(period.startTime) ? 1 : 0, + across: toMinutes(period.endTime) <= toMinutes(period.startTime) ? 1 : 0, check_time: `1970-01-01 ${period.endTime}:00`, free_check: false, }, @@ -181,7 +133,7 @@ export class ScheduleSyncService { is_flexible: false, serious_late_minutes: -1, absenteeism_late_minutes: Math.max( - ...periods.map((period) => this.minutesBetween(period.startTime, period.endTime)), + ...periods.map((period) => minutesBetween(period.startTime, period.endTime)), ), }, }; @@ -242,7 +194,7 @@ export class ScheduleSyncService { } // 先展开排班以计算受影响条数 - const items = this.expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId); + const items = expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId); if (items.length === 0) { this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`); @@ -333,143 +285,6 @@ export class ScheduleSyncService { * 构建 classId → 学生钉钉 userId 列表。 * 一次性查询所有班级的活跃学生与钉钉映射,避免 N+1。 */ - private async buildClassDingUserMap(classIds: number[]): Promise> { - const result = new Map(); - if (classIds.length === 0) return result; - - // 班级 → 活跃学生 - const links = await this.classStudentRepo.find({ - where: { classId: In(classIds), status: 'active' }, - }); - if (links.length === 0) return result; - - // 学生 → 钉钉 userId - const studentIds = [...new Set(links.map((l) => l.studentId))]; - const mappings = await this.mappingRepo.find({ - where: { studentId: In(studentIds) }, - }); - const studentToDing = new Map(mappings.map((m) => [m.studentId, m.dingUserId])); - - for (const link of links) { - const dingId = studentToDing.get(link.studentId); - if (!dingId) continue; - if (!result.has(link.classId)) result.set(link.classId, []); - const arr = result.get(link.classId)!; - if (!arr.includes(dingId)) arr.push(dingId); - } - return result; - } - - private async loadClassNames(classIds: number[]): Promise> { - const map = new Map(); - if (classIds.length === 0) return map; - const classes = await this.classRepo.find({ where: { id: In(classIds) } }); - for (const c of classes) map.set(c.id, c.name); - return map; - } - - /** - * 把本地排课转换为“班级 + 日期”的日排班计划。 - * 同一天相同时间段会去重,多节课按开始时间排序并合并为一个钉钉班次。 - */ - private buildDailySchedulePlans( - schedules: ClassSchedule[], - syncFrom: string, - syncTo: string, - ): DailySchedulePlan[] { - const periodMapByClassDate = new Map>(); - const fromDate = new Date(`${syncFrom}T00:00:00.000Z`); - const toDate = new Date(`${syncTo}T00:00:00.000Z`); - - for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) { - const dateStr = date.toISOString().slice(0, 10); - const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay(); - - 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, - }); - } - } - } - - const plans: DailySchedulePlan[] = []; - 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, - ): DingTalkScheduleItem[] { - const items: DingTalkScheduleItem[] = []; - for (const plan of plans) { - const shiftId = planToShiftId.get(plan.shiftKey); - if (!shiftId) continue; - const workDate = new Date(`${plan.date}T00:00:00+08:00`).getTime(); - for (const userid of dingUserIds) { - items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false }); - } - } - return items; - } - - private toMinutes(time: string): number { - const [hour, minute] = time.split(':').map(Number); - return hour * 60 + minute; - } - - private minutesBetween(startTime: string, endTime: string): number { - const start = this.toMinutes(startTime); - let end = this.toMinutes(endTime); - if (end <= start) end += 24 * 60; - return end - start; - } - - private addDays(dateStr: string, days: number): string { - const d = new Date(`${dateStr}T00:00:00.000Z`); - d.setUTCDate(d.getUTCDate() + days); - return d.toISOString().slice(0, 10); - } - - /** 获取排班同步状态:活跃排课数、有钉钉映射学生的班级数 */ async getStatus(_targetDate: string): Promise<{ activeSchedules: number; mappedClasses: number; @@ -478,7 +293,7 @@ export class ScheduleSyncService { const allSchedules = await this.scheduleRepo.find({ where: { status: 'active' } }); const schedules = allSchedules.filter((s) => s.classId != null); const classIds = [...new Set(schedules.map((s) => s.classId as number))]; - const classDingUsers = await this.buildClassDingUserMap(classIds); + const classDingUsers = await buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds); const mappedClasses = [...classDingUsers.values()].filter((u) => u.length > 0).length; return { diff --git a/apps/server/src/sync/sync-runner.ts b/apps/server/src/sync/sync-runner.ts new file mode 100644 index 0000000..38893d2 --- /dev/null +++ b/apps/server/src/sync/sync-runner.ts @@ -0,0 +1,112 @@ +import { ConflictException, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'node:crypto'; +import { Repository } from 'typeorm'; +import { SyncLog, SyncState } from '../entities'; +import type { SyncPlatform, SyncStatus, SyncType } from '../entities/sync-log.entity'; + +const LEASE_MS = 30 * 60 * 1000; + +@Injectable() +export class SyncRunner { + private readonly logger = new Logger('SyncRunner'); + + constructor( + @InjectRepository(SyncState) + private readonly syncStateRepo: Repository, + @InjectRepository(SyncLog) + private readonly syncLogRepo: Repository, + ) {} + + async run( + platform: SyncPlatform, + operation: (lastSyncAt: Date | null) => Promise<{ + recordsCount: number; + status: Extract; + message?: string; + }>, + ): Promise { + const runId = await this.acquireLease(platform); + let log: SyncLog | undefined; + try { + const lastSyncAt = await this.getLastSyncAt(platform); + log = await this.createSyncLog(platform, lastSyncAt ? 'incremental' : 'full', 'running'); + const result = await operation(lastSyncAt); + await this.syncStateRepo.update({ platform }, { lastSyncAt: new Date() }); + await this.finishSyncLog(log, result.status, result.recordsCount, result.message); + return log; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (log) await this.finishSyncLog(log, 'failed', 0, message); + this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined); + throw error; + } finally { + await this.releaseLease(platform, runId); + } + } + + private async acquireLease(platform: SyncPlatform): Promise { + await this.syncStateRepo + .createQueryBuilder() + .insert() + .values({ platform, lastSyncAt: null, runId: null, runningSince: null }) + .orIgnore() + .execute(); + + const runId = randomUUID(); + const result = await this.syncStateRepo + .createQueryBuilder() + .update() + .set({ runId, runningSince: new Date() }) + .where('platform = :platform', { platform }) + .andWhere('(running_since IS NULL OR running_since < :staleBefore)', { + staleBefore: new Date(Date.now() - LEASE_MS), + }) + .execute(); + if (result.affected !== 1) throw new ConflictException(`${platform} 同步正在进行中`); + return runId; + } + + private async releaseLease(platform: SyncPlatform, runId: string): Promise { + await this.syncStateRepo + .createQueryBuilder() + .update() + .set({ runId: null, runningSince: null }) + .where('platform = :platform AND run_id = :runId', { platform, runId }) + .execute(); + } + + private async getLastSyncAt(platform: SyncPlatform): Promise { + const state = await this.syncStateRepo.findOne({ where: { platform } }); + return state?.lastSyncAt ?? null; + } + + private async createSyncLog( + platform: SyncPlatform, + syncType: SyncType, + status: SyncStatus, + ): Promise { + return this.syncLogRepo.save( + this.syncLogRepo.create({ + platform, + syncType, + status, + recordsCount: 0, + startedAt: new Date(), + }), + ); + } + + private async finishSyncLog( + log: SyncLog, + status: SyncStatus, + recordsCount: number, + errorMessage?: string, + ): Promise { + log.status = status; + log.recordsCount = recordsCount; + log.finishedAt = new Date(); + log.errorMessage = errorMessage ?? null; + await this.syncLogRepo.save(log); + } +} diff --git a/apps/server/src/sync/sync.controller.ts b/apps/server/src/sync/sync.controller.ts index a3148ab..aa83bf3 100644 --- a/apps/server/src/sync/sync.controller.ts +++ b/apps/server/src/sync/sync.controller.ts @@ -198,9 +198,9 @@ export class SyncController { @RequirePermission('sync:read') async getLogs( @Query('platform') platform?: SyncPlatform, - @Query('limit') limit?: number, + @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, ) { - return this.syncService.getLogs(platform, limit ? Number(limit) : 50); + return this.syncService.getLogs(platform, limit ?? 50); } // ── 排班同步 ── diff --git a/apps/server/src/sync/sync.module.ts b/apps/server/src/sync/sync.module.ts index b21fdab..66f8730 100644 --- a/apps/server/src/sync/sync.module.ts +++ b/apps/server/src/sync/sync.module.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: duplicate-block -- 实体注册列表声明结构相似 import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { IntegrationModule } from '../integration/integration.module'; @@ -16,6 +17,7 @@ import { } from '../entities'; import { SyncService } from './sync.service'; import { SyncController } from './sync.controller'; +import { SyncRunner } from './sync-runner'; import { ScheduleSyncService } from './schedule-sync.service'; @Module({ @@ -36,7 +38,8 @@ import { ScheduleSyncService } from './schedule-sync.service'; AttendanceModule, ], controllers: [SyncController], - providers: [SyncService, ScheduleSyncService], + providers: [SyncService, ScheduleSyncService, SyncRunner, + ], exports: [SyncService], }) export class SyncModule {} diff --git a/apps/server/src/sync/sync.service.spec.ts b/apps/server/src/sync/sync.service.spec.ts index 8780816..e497b4a 100644 --- a/apps/server/src/sync/sync.service.spec.ts +++ b/apps/server/src/sync/sync.service.spec.ts @@ -1,6 +1,7 @@ import { ConflictException, ServiceUnavailableException } from '@nestjs/common'; import { Student, SyncLog } from '../entities'; import { SyncService } from './sync.service'; +import { SyncRunner } from './sync-runner'; function queryBuilder(affected = 1) { const builder = { @@ -67,6 +68,7 @@ function createService(options?: { create: jest.fn().mockImplementation((_entity, value) => value), }; const dataSource = { transaction: jest.fn((callback) => callback(manager)) }; + const runner = new SyncRunner(syncStateRepo as never, syncLogRepo as never); const service = new SyncService( syncLogRepo as never, syncStateRepo as never, @@ -78,6 +80,7 @@ function createService(options?: { attendanceImportService as never, {} as never, dataSource as never, + runner, ); return { service, diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index cfa5a25..5332520 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -1,16 +1,17 @@ -import { ConflictException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { randomUUID } from 'node:crypto'; import { DataSource, In, Repository } from 'typeorm'; import { SyncLog, SyncState, Student, StudentDingMapping } from '../entities'; import { JinshujuMatchRule, type JinshujuFieldMapping } from '../entities/jinshuju-match-rule.entity'; -import type { SyncPlatform, SyncStatus, SyncType } from '../entities/sync-log.entity'; +import type { SyncPlatform } from '../entities/sync-log.entity'; import { AttendanceImportService } from '../attendance/attendance-import.service'; import { DingTalkService } from '../integration/dingtalk.service'; import { WeComService } from '../integration/wecom.service'; import { JinshujuService } from '../integration/jinshuju.service'; import { syncJinshujuStudents } from '../integration/jinshuju-student-sync'; import { ScheduleSyncService } from './schedule-sync.service'; +import { SyncRunner } from './sync-runner'; +import { getMatchRule, validateMatchRule, extractField } from './jinshuju-rules'; @Injectable() export class SyncService { @@ -32,6 +33,7 @@ export class SyncService { private readonly attendanceImportService: AttendanceImportService, private readonly scheduleSyncService: ScheduleSyncService, private readonly dataSource: DataSource, + private readonly runner: SyncRunner, ) {} async syncDingTalkStudents( @@ -39,7 +41,7 @@ export class SyncService { createMissing = true, updateProfile = true, ): Promise { - return this.runSync('dingtalk_students', async () => { + return this.runner.run('dingtalk_students', async () => { const result = await this.dingTalkService.syncAll(rootDeptId, { createMissing, updateProfile }); return { recordsCount: result.created + result.updated + (result.matched ?? 0), @@ -56,7 +58,7 @@ export class SyncService { } async syncDingTalkAttendance(): Promise { - return this.runSync('dingtalk_attendance', async (lastSyncAt) => { + return this.runner.run('dingtalk_attendance', async (lastSyncAt) => { const endDate = new Date(); const startDate = lastSyncAt ? new Date(lastSyncAt) : new Date(endDate); if (!lastSyncAt) startDate.setDate(startDate.getDate() - 7); @@ -81,13 +83,13 @@ export class SyncService { } async syncWeCom(): Promise { - return this.runSync('wecom', async () => { + return this.runner.run('wecom', async () => { const result = await this.weComService.syncAll(); return { recordsCount: result.userCount, status: 'success' }; }); } async syncJinshuju(apiKey: string, apiSecret: string, formToken: string): Promise { - return this.runSync('jinshuju', async () => { + return this.runner.run('jinshuju', async () => { const entries = await this.jinshujuService.fetchAllEntries(apiKey, apiSecret, formToken); const result = await this.dataSource.transaction((manager) => syncJinshujuStudents(manager, entries), @@ -109,14 +111,14 @@ export class SyncService { async previewJinshuju(apiKey: string, apiSecret: string, formToken: string, ruleId?: number) { const entries = await this.jinshujuService.fetchAllEntries(apiKey, apiSecret, formToken); - const rule = ruleId ? await this.getMatchRule(ruleId, formToken) : null; + const rule = ruleId ? await getMatchRule(this.matchRuleRepo, ruleId, formToken) : null; const map = rule?.mappings ?? { name: 'field_1', phone: 'field_2' }; const parsed = entries .map((e) => ({ serialNumber: e.serial_number, - name: this.extractField(e, map.name), - phone: this.extractField(e, map.phone), + name: extractField(e, map.name), + phone: extractField(e, map.phone), })) .filter((p) => p.name); @@ -177,8 +179,8 @@ export class SyncService { }>, ruleId?: number, ): Promise { - return this.runSync('jinshuju', async () => { - const rule = ruleId ? await this.getMatchRule(ruleId, formToken) : null; + return this.runner.run('jinshuju', async () => { + const rule = ruleId ? await getMatchRule(this.matchRuleRepo, ruleId, formToken) : null; const map = rule?.mappings ?? { name: 'field_1', phone: 'field_2' }; const entries = await this.jinshujuService.fetchAllEntries(apiKey, apiSecret, formToken); const entryMap = new Map(entries.map((entry) => [entry.serial_number, entry])); @@ -199,7 +201,7 @@ export class SyncService { const mappedValues = Object.fromEntries( Object.entries(map) - .map(([studentField, fieldKey]) => [studentField, this.extractField(entry, fieldKey)]) + .map(([studentField, fieldKey]) => [studentField, extractField(entry, fieldKey)]) .filter(([, value]) => value), ); @@ -334,98 +336,6 @@ export class SyncService { return this.syncLogRepo.findOne({ where: { platform }, order: { createdAt: 'DESC' } }); } - private async runSync( - platform: SyncPlatform, - operation: (lastSyncAt: Date | null) => Promise<{ - recordsCount: number; - status: Extract; - message?: string; - }>, - ): Promise { - const runId = await this.acquireLease(platform); - let log: SyncLog | undefined; - try { - const lastSyncAt = await this.getLastSyncAt(platform); - log = await this.createSyncLog(platform, lastSyncAt ? 'incremental' : 'full', 'running'); - const result = await operation(lastSyncAt); - await this.syncStateRepo.update({ platform }, { lastSyncAt: new Date() }); - await this.finishSyncLog(log, result.status, result.recordsCount, result.message); - return log; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - if (log) await this.finishSyncLog(log, 'failed', 0, message); - this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined); - throw error; - } finally { - await this.releaseLease(platform, runId); - } - } - - private async acquireLease(platform: SyncPlatform): Promise { - await this.syncStateRepo - .createQueryBuilder() - .insert() - .values({ platform, lastSyncAt: null, runId: null, runningSince: null }) - .orIgnore() - .execute(); - - const runId = randomUUID(); - const result = await this.syncStateRepo - .createQueryBuilder() - .update() - .set({ runId, runningSince: new Date() }) - .where('platform = :platform', { platform }) - .andWhere('(running_since IS NULL OR running_since < :staleBefore)', { - staleBefore: new Date(Date.now() - SyncService.LEASE_MS), - }) - .execute(); - if (result.affected !== 1) throw new ConflictException(`${platform} 同步正在进行中`); - return runId; - } - - private async releaseLease(platform: SyncPlatform, runId: string): Promise { - await this.syncStateRepo - .createQueryBuilder() - .update() - .set({ runId: null, runningSince: null }) - .where('platform = :platform AND run_id = :runId', { platform, runId }) - .execute(); - } - - private async getLastSyncAt(platform: SyncPlatform): Promise { - const state = await this.syncStateRepo.findOne({ where: { platform } }); - return state?.lastSyncAt ?? null; - } - - private async createSyncLog( - platform: SyncPlatform, - syncType: SyncType, - status: SyncStatus, - ): Promise { - return this.syncLogRepo.save( - this.syncLogRepo.create({ - platform, - syncType, - status, - recordsCount: 0, - startedAt: new Date(), - }), - ); - } - - private async finishSyncLog( - log: SyncLog, - status: SyncStatus, - recordsCount: number, - errorMessage?: string, - ): Promise { - log.status = status; - log.recordsCount = recordsCount; - log.finishedAt = new Date(); - log.errorMessage = errorMessage ?? null; - await this.syncLogRepo.save(log); - } - // ── Match Rules CRUD ── async listMatchRules(): Promise { @@ -437,7 +347,7 @@ export class SyncService { formToken: string; mappings: JinshujuFieldMapping; }): Promise { - this.validateMatchRule(dto.formToken, dto.mappings); + validateMatchRule(dto.formToken, dto.mappings); return this.matchRuleRepo.save( this.matchRuleRepo.create({ ...dto, @@ -454,7 +364,7 @@ export class SyncService { const rule = await this.matchRuleRepo.findOne({ where: { id } }); if (!rule) throw new NotFoundException('规则不存在'); const mappings = dto.mappings ?? rule.mappings; - this.validateMatchRule(rule.formToken, mappings); + validateMatchRule(rule.formToken, mappings); await this.matchRuleRepo.update(id, { name: dto.name?.trim(), mappings, @@ -466,43 +376,4 @@ export class SyncService { const result = await this.matchRuleRepo.delete(id); if (!result.affected) throw new NotFoundException('规则不存在'); } - - private async getMatchRule(id: number, formToken: string): Promise { - const rule = await this.matchRuleRepo.findOne({ where: { id } }); - if (!rule) throw new NotFoundException('规则不存在'); - if (rule.formToken !== formToken) { - throw new ConflictException('匹配规则不属于当前表单'); - } - return rule; - } - - private validateMatchRule(formToken: string, mappings: JinshujuFieldMapping): void { - if (!formToken.trim()) throw new ConflictException('表单 Token 不能为空'); - if (!mappings.name) throw new ConflictException('匹配规则必须映射姓名字段'); - const allowedStudentFields = new Set([ - 'name', - 'studentNo', - 'phone', - 'idNumber', - 'gender', - 'ethnicity', - 'emergencyContact', - 'emergencyPhone', - ]); - for (const [studentField, fieldKey] of Object.entries(mappings)) { - if (!allowedStudentFields.has(studentField)) { - throw new ConflictException(`不允许映射学生字段:${studentField}`); - } - if (fieldKey && !/^field_\d+$/.test(fieldKey)) { - throw new ConflictException(`无效的金数据字段:${fieldKey}`); - } - } - } - - /** Extract value from a Jinshuju entry by field mapping. */ - private extractField(entry: Record, fieldKey: string | undefined): string { - if (!fieldKey) return ''; - const val = entry[fieldKey]; - return typeof val === 'string' ? val.trim() : ''; - } } diff --git a/apps/server/src/wallets/wallets.service.ts b/apps/server/src/wallets/wallets.service.ts index 270d2de..62a078e 100644 --- a/apps/server/src/wallets/wallets.service.ts +++ b/apps/server/src/wallets/wallets.service.ts @@ -1,11 +1,10 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, EntityManager, Repository } from 'typeorm'; +import { DataSource, EntityManager, Repository, In } from 'typeorm'; import { Bill } from '../entities/bill.entity'; import { Student } from '../entities/student.entity'; import { StudentWallet } from '../entities/student-wallet.entity'; import { WalletTransaction } from '../entities/wallet-transaction.entity'; -import { In } from 'typeorm'; import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto'; import { FinancialOperationsService } from '../financial-operations/financial-operations.service'; import { Room } from '../entities/room.entity'; @@ -58,7 +57,8 @@ export class WalletsService { const ids = rows.map((row) => Number(row.studentId)); const wallets = await this.walletRepo.find({ where: { studentId: In(ids) } }); - const bills = await this.dataSource.getRepository(Bill) + const bills = await this.dataSource + .getRepository(Bill) .createQueryBuilder('bill') .select('bill.studentId', 'studentId') .addSelect('SUM(bill.outstandingAmount)', 'outstandingAmount') @@ -67,7 +67,9 @@ export class WalletsService { .groupBy('bill.studentId') .getRawMany<{ studentId: number; outstandingAmount: string }>(); const walletMap = new Map(wallets.map((wallet) => [wallet.studentId, wallet])); - const debtMap = new Map(bills.map((bill) => [Number(bill.studentId), money(bill.outstandingAmount)])); + const debtMap = new Map( + bills.map((bill) => [Number(bill.studentId), money(bill.outstandingAmount)]), + ); return rows .map((row) => ({ studentId: Number(row.studentId), @@ -103,7 +105,9 @@ export class WalletsService { async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) { const { operationId, ...change } = dto; return this.financialOperations - ? this.financialOperations.run(operationId, 'wallet.change_balance', () => this.changeBalanceOnce(change, recordedBy, operationId)) + ? this.financialOperations.run(operationId, 'wallet.change_balance', () => + this.changeBalanceOnce(change, recordedBy, operationId), + ) : this.changeBalanceOnce(change, recordedBy, operationId); } @@ -114,7 +118,10 @@ export class WalletsService { transactionManager?: EntityManager, ) { const amount = money(dto.amount); - if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) { + if ( + !Number.isFinite(dto.amount) || + Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8 + ) { throw new BadRequestException('调账金额最多保留两位小数'); } if (amount === 0) throw new BadRequestException('调账金额不能为 0'); @@ -139,8 +146,11 @@ export class WalletsService { recordedBy: recordedBy || null, }), ); - const payments = amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : []; - const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId }); + const payments = + amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : []; + const finalWallet = await manager.findOneByOrFail(StudentWallet, { + studentId: dto.studentId, + }); return { wallet: finalWallet, payments }; }; return transactionManager ? work(transactionManager) : this.dataSource.transaction(work); @@ -148,19 +158,27 @@ export class WalletsService { async batchChangeBalance(dto: BatchChangeWalletBalanceDto, recordedBy?: number) { const { operationId, ...batch } = dto; - const work = () => this.dataSource.transaction(async (manager) => { - const uniqueStudentIds = Array.from(new Set(batch.studentIds)); - const results: Array<{ wallet: StudentWallet; payments: Bill[] }> = []; - for (const studentId of uniqueStudentIds) { - results.push(await this.changeBalanceOnce({ - studentId, - amount: batch.amount, - type: batch.type, - description: batch.description, - }, recordedBy, operationId ? `${operationId}:${studentId}` : undefined, manager)); - } - return { count: uniqueStudentIds.length, results }; - }); + const work = () => + this.dataSource.transaction(async (manager) => { + const uniqueStudentIds = Array.from(new Set(batch.studentIds)); + const results: Array<{ wallet: StudentWallet; payments: Bill[] }> = []; + for (const studentId of uniqueStudentIds) { + results.push( + await this.changeBalanceOnce( + { + studentId, + amount: batch.amount, + type: batch.type, + description: batch.description, + }, + recordedBy, + operationId ? `${operationId}:${studentId}` : undefined, + manager, + ), + ); + } + return { count: uniqueStudentIds.length, results }; + }); return this.financialOperations ? this.financialOperations.run(operationId, 'wallet.batch_change_balance', work) : work(); @@ -236,7 +254,11 @@ export class WalletsService { return manager.save(bill); } - private async settleOutstandingBills(manager: EntityManager, studentId: number, recordedBy?: number) { + private async settleOutstandingBills( + manager: EntityManager, + studentId: number, + recordedBy?: number, + ) { const bills = await manager .createQueryBuilder(Bill, 'bill') .where('bill.studentId = :studentId', { studentId })