39 Commits

Author SHA1 Message Date
fa086e4c1c Merge pull request '修复考勤服务测试 mock' (#17) 2026-07-16 03:01:59 +00:00
4fbcde48b1 test: update attendance service mocks 2026-07-16 11:01:24 +08:00
c93ac986aa Merge pull request '移除删除账号功能' (#15) 2026-07-16 02:55:22 +00:00
ab7f7c725a Merge pull request '添加考勤设备 SN 教室绑定' (#16) 2026-07-16 02:49:30 +00:00
200d2e423b 调整归档操作入口 2026-07-16 10:44:21 +08:00
559b8a56e9 feat: add attendance device SN classroom bindings 2026-07-16 10:22:23 +08:00
d037787346 feat: support batch wallet balance changes 2026-07-16 10:22:23 +08:00
8ba51f48c0 fix: map expense type sort order column 2026-07-16 10:22:23 +08:00
7a62d8962a chore: update production deployment config 2026-07-16 10:21:40 +08:00
1737563516 移除删除账号功能 2026-07-16 10:16:56 +08:00
6a43f33f7a Merge pull request '优化管理端移动端适配与班级教师候选人' (#14)
Merge PR #14
2026-07-15 05:57:32 +00:00
d582d641c0 feat: improve admin responsive pages and teacher candidates 2026-07-15 13:56:58 +08:00
fb9697bd05 Merge pull request '完善全业务边界校验与回归测试' (#12)
Merge PR #12
2026-07-15 05:53:46 +00:00
bc49d1016a feat: filter rooms by rental category 2026-07-15 13:52:39 +08:00
8bd445df5a fix student import and profile labels 2026-07-15 10:52:43 +08:00
fcde6caaaa fix: defer attendance settlement until lesson end 2026-07-15 09:39:21 +08:00
b1f35f9d1a test: harden business boundary conditions 2026-07-15 00:03:55 +08:00
17a5046ea0 Merge pull request '完善考勤排课与接口校验' (#11) from wangziqi/gongxue-base:codex/wzq into main
完善考勤排课、钉钉同步与接口参数校验
2026-07-14 15:13:12 +00:00
e45da7f998 feat: improve attendance scheduling and API validation 2026-07-14 23:12:14 +08:00
c75a08affe feat: add student wallet utility billing 2026-07-14 20:42:21 +08:00
b480070e69 Merge pull request '修复账单生成时未计算分摊费用' (#10) from xiongyuxing/gongxue-base:main into main
Reviewed-on: wangziqi/gongxue-base#10
2026-07-14 08:41:13 +00:00
598b4e8acd feat: link deposits to bill payment 2026-07-14 16:38:38 +08:00
eac336a54a fix: include contained room expenses in bill generation 2026-07-14 14:53:07 +08:00
ce5fd1c6cb fix: 修复批量更新账单状态功能 2026-07-14 14:24:19 +08:00
05a936bbc2 Merge pull request 'feat: 优化入住办理与名单导入' (#9) from codex/wzq into main 2026-07-14 04:26:53 +00:00
3adf4933d8 Merge pull request '修复pdf导出' (#8) from xiongyuxing/gongxue-base:main into main 2026-07-14 04:26:34 +00:00
d84f37e98f feat: streamline occupancy check-in and imports 2026-07-14 12:23:53 +08:00
16b56ffcd5 merge upstream 2026-07-14 04:09:42 +00:00
718c58589f Merge PR #7: improve attendance and occupancy workflows 2026-07-14 03:22:19 +00:00
aaf49d5580 fix: guard bill generation submission 2026-07-14 11:20:42 +08:00
5cf6aede1e feat: improve occupancy import template 2026-07-14 11:20:41 +08:00
811e7ce826 feat: show DingTalk punch device details 2026-07-14 11:20:41 +08:00
79fa472b78 merge upstream 2026-07-14 03:20:00 +00:00
029af37f3a fix: render bill pdf from frontend print view 2026-07-14 11:18:49 +08:00
d572e984d2 Merge PR #6: keep room capacity and beds consistent 2026-07-14 02:58:01 +00:00
a93ba657a8 fix: keep room capacity and beds consistent 2026-07-14 10:57:13 +08:00
77714642a5 Merge PR #5: main 2026-07-14 02:56:04 +00:00
e7aa202603 fix: 修改 PDFDocument 导入方式以符合 ES6 模块规范 2026-07-14 10:45:27 +08:00
xyx
013b3f4afe fix: 押金页添加批量构建学生选项的功能以简化学生数据处理 2026-07-13 17:18:35 +08:00
155 changed files with 7969 additions and 1433 deletions

View File

@@ -8,11 +8,15 @@ MYSQL_ROOT_PASSWORD=change-me-to-a-strong-password
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=3306 DB_PORT=3306
DB_USERNAME=root DB_USERNAME=root
DB_DATABASE=gongxue DB_DATABASE=dorm_billing_v2
DB_SYNCHRONIZE=false DB_SYNCHRONIZE=false
JWT_SECRET=change-me-to-a-random-string-at-least-32-chars JWT_SECRET=change-me-to-a-random-string-at-least-32-chars
JWT_EXPIRES_IN=24h JWT_EXPIRES_IN=24h
PORT=3000
# 初始管理员 admin 密码(仅首次创建 admin 用户时生效)
ADMIN_PASSWORD=change-me-admin-password
PORT=3002
# ---- AI 模型配置 ---- # ---- AI 模型配置 ----
# AES-256-GCM 加密主密钥,用于加密存储 API Key # AES-256-GCM 加密主密钥,用于加密存储 API Key

View File

@@ -14,6 +14,7 @@ const RoomsPage = lazy(() => import('./pages/Rooms'));
const OccupanciesPage = lazy(() => import('./pages/Occupancies')); const OccupanciesPage = lazy(() => import('./pages/Occupancies'));
const ExpensesPage = lazy(() => import('./pages/Expenses')); const ExpensesPage = lazy(() => import('./pages/Expenses'));
const BillsPage = lazy(() => import('./pages/Bills')); const BillsPage = lazy(() => import('./pages/Bills'));
const WalletsPage = lazy(() => import('./pages/Wallets'));
const RoomVisualPage = lazy(() => import('./pages/RoomVisual')); const RoomVisualPage = lazy(() => import('./pages/RoomVisual'));
const OperationLogsPage = lazy(() => import('./pages/OperationLogs')); const OperationLogsPage = lazy(() => import('./pages/OperationLogs'));
const UsersPage = lazy(() => import('./pages/Users')); const UsersPage = lazy(() => import('./pages/Users'));
@@ -30,6 +31,7 @@ const SchedulesPage = lazy(() => import('./pages/Schedules'));
const RolesPage = lazy(() => import('./pages/Roles')); const RolesPage = lazy(() => import('./pages/Roles'));
const PermissionsPage = lazy(() => import('./pages/Permissions')); const PermissionsPage = lazy(() => import('./pages/Permissions'));
const AttendancePage = lazy(() => import('./pages/Attendance')); const AttendancePage = lazy(() => import('./pages/Attendance'));
const AttendanceDevicesPage = lazy(() => import('./pages/AttendanceDevices'));
const TeacherWorkspacePage = lazy(() => import('./pages/TeacherWorkspace')); const TeacherWorkspacePage = lazy(() => import('./pages/TeacherWorkspace'));
const NotificationsPage = lazy(() => import('./pages/Notifications')); const NotificationsPage = lazy(() => import('./pages/Notifications'));
const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig')); const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig'));
@@ -134,6 +136,14 @@ const App: React.FC = () => {
</PermissionRoute> </PermissionRoute>
} }
/> />
<Route
path="wallets"
element={
<PermissionRoute permission="wallet:view">
<WalletsPage />
</PermissionRoute>
}
/>
<Route <Route
path="bills" path="bills"
element={ element={
@@ -231,6 +241,16 @@ const App: React.FC = () => {
} }
/> />
<Route
path="attendance-devices"
element={
<PermissionRoute permission="classroom:view">
<AttendanceDevicesPage />
</PermissionRoute>
}
/>
<Route <Route
path="attendance" path="attendance"
element={ element={

View File

@@ -73,6 +73,7 @@ const SECTIONS: MenuSection[] = [
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' }, { key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' }, { key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' }, { key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
{ key: '/wallets', label: '学生余额', icon: 'wallet', permission: 'wallet:view' },
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' }, { key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
], ],
}, },
@@ -84,6 +85,7 @@ const SECTIONS: MenuSection[] = [
children: [ children: [
{ key: '/classroom-schedule', label: '教室排期', icon: 'calendar', permission: 'rental:view' }, { key: '/classroom-schedule', label: '教室排期', icon: 'calendar', permission: 'rental:view' },
{ key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' }, { key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' },
{ key: '/attendance-devices', label: '考勤机绑定', icon: 'attendance', permission: 'classroom:view' },
{ key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' }, { key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' },
{ key: '/organizations', label: '机构管理', icon: 'organization', permission: 'organization:view' }, { key: '/organizations', label: '机构管理', icon: 'organization', permission: 'organization:view' },
], ],
@@ -111,8 +113,9 @@ export function getRoleDomains(roles: readonly string[], permissions: readonly s
normalized.add('academic'); normalized.add('academic');
} }
if ( if (
permissions.includes('room:view') && (permissions.includes('room:view') &&
(permissions.includes('occupancy:view') || permissions.includes('expense:view')) (permissions.includes('occupancy:view') || permissions.includes('expense:view'))) ||
permissions.includes('wallet:view')
) { ) {
normalized.add('accommodation'); normalized.add('accommodation');
} }

View File

@@ -20,6 +20,7 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [
{ path: '/schedules', permission: 'schedule:view' }, { path: '/schedules', permission: 'schedule:view' },
{ path: '/classroom-schedule', permission: 'rental:view' }, { path: '/classroom-schedule', permission: 'rental:view' },
{ path: '/classrooms', permission: 'classroom:view' }, { path: '/classrooms', permission: 'classroom:view' },
{ path: '/attendance-devices', permission: 'classroom:view' },
{ path: '/classroom-rentals', permission: 'rental:view' }, { path: '/classroom-rentals', permission: 'rental:view' },
{ path: '/organizations', permission: 'organization:view' }, { path: '/organizations', permission: 'organization:view' },
{ path: '/expenses', permission: 'expense:view' }, { path: '/expenses', permission: 'expense:view' },

View File

@@ -105,6 +105,20 @@ interface AttachmentRecord {
fileSize: number; fileSize: number;
} }
interface AttendanceRecordItem {
id: number;
attendanceDate: string;
session: string;
status: string;
source?: string;
remark?: string | null;
punchTime?: string | null;
punchDeviceName?: string | null;
punchDeviceId?: string | null;
schedule?: { subject?: string } | null;
class?: { name?: string } | null;
}
interface StudentProfileAggregate { interface StudentProfileAggregate {
student: StudentInfo; student: StudentInfo;
profile: ProfileData | null; profile: ProfileData | null;
@@ -113,6 +127,7 @@ interface StudentProfileAggregate {
learningRecords: LearningRecord[]; learningRecords: LearningRecord[];
result: ResultData | null; result: ResultData | null;
attachments: AttachmentRecord[]; attachments: AttachmentRecord[];
attendances: AttendanceRecordItem[];
} }
export interface StudentProfileContentProps { export interface StudentProfileContentProps {
@@ -147,6 +162,19 @@ const RECORD_TYPE_OPTIONS = [
{ value: 'other', label: '其他' }, { value: 'other', label: '其他' },
]; ];
const STUDENT_STATUS_MAP: Record<string, { text: string; color: string }> = {
active: { text: '在读', color: 'green' },
graduated: { text: '已毕业', color: 'blue' },
withdrawn: { text: '已退训', color: 'red' },
archived: { text: '已归档', color: '#999' },
};
const ENROLLMENT_STATUS_MAP: Record<string, { text: string; color: string }> = {
active: { text: '报读中', color: 'green' },
completed: { text: '已结课', color: 'blue' },
withdrawn: { text: '已退训', color: 'red' },
};
const COURSE_CATEGORY_OPTIONS = [ const COURSE_CATEGORY_OPTIONS = [
{ value: 'culture', label: '文化课' }, { value: 'culture', label: '文化课' },
{ value: 'professional', label: '专业课' }, { value: 'professional', label: '专业课' },
@@ -161,6 +189,34 @@ const CLASS_TYPE_OPTIONS = [
{ value: 'offline', label: '线下' }, { value: 'offline', label: '线下' },
]; ];
const getOptionLabel = (
options: Array<{ value: string; label: string }>,
value?: string | null,
): string => {
if (!value) return '-';
return options.find((option) => option.value === value)?.label || value;
};
const getCourseCategoryLabel = (value?: string | null): string =>
getOptionLabel(COURSE_CATEGORY_OPTIONS, value);
const getClassTypeLabel = (value?: string | null): string =>
getOptionLabel(CLASS_TYPE_OPTIONS, value);
const getEnrollmentStatus = (value?: string | null): { text: string; color: string } => {
if (!value) return { text: '-', color: 'default' };
return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' };
};
const getStudentStatus = (value?: string | null): { text: string; color: string } => {
if (!value) return { text: '-', color: 'default' };
return STUDENT_STATUS_MAP[value] || { text: value, color: 'default' };
};
const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string =>
enrollment.className ||
(enrollment.courseCategory ? getCourseCategoryLabel(enrollment.courseCategory) : String(enrollment.id));
const ATTACHMENT_CATEGORY_OPTIONS = [ const ATTACHMENT_CATEGORY_OPTIONS = [
{ value: 'id_card', label: '身份证' }, { value: 'id_card', label: '身份证' },
{ value: 'transcript', label: '成绩单' }, { value: 'transcript', label: '成绩单' },
@@ -178,6 +234,58 @@ const formatFileSize = (bytes: number): string => {
// ---- Tab Components ---- // ---- Tab Components ----
const ATTENDANCE_STATUS_MAP: Record<string, { text: string; color: string }> = {
present: { text: '出勤', color: 'green' },
late: { text: '迟到', color: 'orange' },
absent: { text: '缺勤', color: 'red' },
leave: { text: '请假', color: 'blue' },
pending: { text: '待确认', color: 'default' },
};
const SESSION_LABELS: Record<string, string> = {
morning_reading: '早自习',
morning: '上午',
afternoon: '下午',
evening_study: '晚自习',
night_check: '晚寝',
};
const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => {
const columns: ColumnsType<AttendanceRecordItem> = [
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
{ title: '课程', render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤' },
{ title: '时段', dataIndex: 'session', width: 100, render: (value: string) => SESSION_LABELS[value] || value || '-' },
{
title: '结果', dataIndex: 'status', width: 90,
render: (value: string) => {
const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' };
return <Tag color={meta.color}>{meta.text}</Tag>;
},
},
{ title: '打卡时间', dataIndex: 'punchTime', width: 170, render: (value?: string | null) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-' },
{
title: '打卡设备',
render: (_: unknown, record) => {
const name = record.punchDeviceName?.trim();
const id = record.punchDeviceId?.trim();
if (name && id && name !== id) return `${name}${id}`;
return name || id || (record.source === 'manual' ? '老师手动标记' : '-');
},
},
{ title: '备注', dataIndex: 'remark', render: (value?: string | null) => value || '-' },
];
return data.length > 0 ? (
<Table<AttendanceRecordItem>
columns={columns}
dataSource={data}
rowKey="id"
scroll={{ x: 900 }}
pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }}
/>
) : <Empty description="暂无出勤记录" />;
};
interface TabProps { interface TabProps {
studentId: number; studentId: number;
onRefresh: () => void; onRefresh: () => void;
@@ -283,8 +391,8 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
}; };
const columns: ColumnsType<EnrollmentRecord> = [ const columns: ColumnsType<EnrollmentRecord> = [
{ title: '课程类别', dataIndex: 'courseCategory', render: (v: string) => v || '-' }, { title: '课程类别', dataIndex: 'courseCategory', render: getCourseCategoryLabel },
{ title: '班型', dataIndex: 'classType', render: (v: string) => v || '-' }, { title: '班型', dataIndex: 'classType', render: getClassTypeLabel },
{ title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' }, { title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' },
{ title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' }, { title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' },
{ title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' }, { title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' },
@@ -294,12 +402,8 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
render: (v: string) => { render: (v: string) => {
const colorMap: Record<string, string> = { const status = getEnrollmentStatus(v);
active: 'green', return <Tag color={status.color}>{status.text}</Tag>;
completed: 'blue',
withdrawn: 'red',
};
return <Tag color={colorMap[v] || 'default'}>{v || '-'}</Tag>;
}, },
}, },
]; ];
@@ -410,7 +514,7 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
render: (v: number | undefined) => { render: (v: number | undefined) => {
if (v === undefined) return '-'; if (v === undefined) return '-';
const enr = enrollments.find((e) => e.id === v); const enr = enrollments.find((e) => e.id === v);
return enr ? `${enr.className || enr.courseCategory || v}` : String(v); return enr ? formatEnrollmentDisplayName(enr) : String(v);
}, },
}, },
]; ];
@@ -473,7 +577,7 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
placeholder="选择关联的报读记录" placeholder="选择关联的报读记录"
options={enrollments.map((e) => ({ options={enrollments.map((e) => ({
value: e.id, value: e.id,
label: `${e.className || e.courseCategory || e.id} (${e.classType})`, label: `${formatEnrollmentDisplayName(e)}${getClassTypeLabel(e.classType)}`,
}))} }))}
/> />
</Form.Item> </Form.Item>
@@ -779,7 +883,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
const tabItems = useMemo(() => { const tabItems = useMemo(() => {
if (!aggregateData) return []; if (!aggregateData) return [];
const { profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData; const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } = aggregateData;
return [ return [
{ {
key: 'profile', key: 'profile',
@@ -807,8 +911,8 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
}, },
{ {
key: 'attendance', key: 'attendance',
label: '出勤记录', label: `出勤记录 (${attendances.length})`,
children: <Empty description="暂无出勤记录" />, children: <AttendanceTab data={attendances} />,
}, },
{ {
key: 'learning', key: 'learning',
@@ -909,7 +1013,10 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
) : '-'} ) : '-'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
<Tag>{student.status || '-'}</Tag> {(() => {
const status = getStudentStatus(student.status);
return <Tag color={status.color}>{status.text}</Tag>;
})()}
</Descriptions.Item> </Descriptions.Item>
{profile?.targetCollege && ( {profile?.targetCollege && (
<Descriptions.Item label="目标院校">{profile.targetCollege}</Descriptions.Item> <Descriptions.Item label="目标院校">{profile.targetCollege}</Descriptions.Item>

View File

@@ -4,7 +4,15 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {
min-width: 320px;
background: #f5f5f7;
}
body { body {
min-width: 320px;
overflow-x: hidden;
background: #f5f5f7;
font-family: font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -15,10 +23,55 @@ body {
min-height: 100vh; min-height: 100vh;
} }
img,
svg,
canvas {
max-width: 100%;
}
.app-shell,
.app-main,
.app-content {
min-width: 0;
}
.app-header {
position: sticky;
top: 0;
z-index: 100;
}
/* Shared responsive toolbar: add these classes to page filter/action rows. */
.responsive-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.responsive-toolbar__group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ant-drawer-content-wrapper {
max-width: 100vw !important;
}
.ant-pagination {
row-gap: 8px;
}
/* === 通用:表格容器横向滚动(防双重滚动条) === */ /* === 通用:表格容器横向滚动(防双重滚动条) === */
.ant-table-wrapper { .ant-table-wrapper {
max-width: 100%;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
overscroll-behavior-inline: contain;
-webkit-overflow-scrolling: touch;
} }
/* ── 表格单元格省略号截断(按需启用) ── /* ── 表格单元格省略号截断(按需启用) ──
@@ -54,6 +107,65 @@ body {
/* === 手机 (< 576px) === */ /* === 手机 (< 576px) === */
@media (max-width: 575px) { @media (max-width: 575px) {
.app-header {
height: 56px;
padding-inline: 8px !important;
line-height: 56px;
}
.app-header .ant-btn {
width: 40px;
min-height: 40px;
padding-inline: 0;
}
.app-content {
min-height: calc(100dvh - 72px);
margin: 8px !important;
padding: 12px !important;
border-radius: 10px !important;
}
.app-navigation-drawer .ant-drawer-header {
padding-inline: 16px;
}
.responsive-toolbar {
align-items: stretch;
flex-direction: column;
margin-bottom: 12px;
}
.responsive-toolbar__group,
.responsive-toolbar > .ant-space {
width: 100%;
}
.responsive-toolbar__group > .ant-space-item,
.responsive-toolbar > .ant-space > .ant-space-item {
flex: 1 1 140px;
max-width: 100%;
}
.responsive-toolbar .ant-input,
.responsive-toolbar .ant-input-affix-wrapper,
.responsive-toolbar .ant-input-search,
.responsive-toolbar .ant-picker,
.responsive-toolbar .ant-select,
.responsive-toolbar .ant-upload,
.responsive-toolbar .ant-upload-wrapper,
.responsive-toolbar .ant-btn {
width: 100% !important;
}
.ant-btn:not(.ant-btn-sm),
.ant-input-affix-wrapper,
.ant-input-search-button,
.ant-picker,
.ant-select-single .ant-select-selector {
min-height: 40px;
}
.ant-table { .ant-table {
font-size: 13px; font-size: 13px;
} }
@@ -65,12 +177,48 @@ body {
font-size: 13px; font-size: 13px;
} }
.ant-modal { .ant-modal {
max-width: calc(100vw - 24px) !important; top: 12px;
margin: 12px auto !important; max-width: calc(100vw - 16px) !important;
margin: 0 auto !important;
padding-bottom: 12px;
}
.ant-modal .ant-modal-content {
padding: 16px;
} }
.ant-modal .ant-modal-body { .ant-modal .ant-modal-body {
max-height: 60vh; max-height: calc(100dvh - 180px);
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
}
.ant-modal .ant-modal-footer {
display: flex;
}
.ant-modal .ant-modal-footer .ant-btn {
flex: 1;
min-height: 40px;
}
.ant-drawer .ant-drawer-header {
padding: 14px 16px;
}
.ant-drawer .ant-drawer-body {
padding: 16px;
overscroll-behavior: contain;
}
.ant-popover,
.ant-picker-dropdown {
max-width: calc(100vw - 16px);
}
.ant-pagination {
justify-content: center;
}
.ant-pagination .ant-pagination-options {
margin-inline-start: 0;
}
.ant-alert {
padding: 10px 12px;
}
h1 {
font-size: 22px !important;
} }
h2 { h2 {
font-size: 18px !important; font-size: 18px !important;
@@ -78,9 +226,8 @@ body {
.ant-card { .ant-card {
margin-bottom: 8px; margin-bottom: 8px;
} }
.ant-space-item .ant-btn { .ant-card .ant-card-body {
padding: 2px 6px; padding: 14px;
font-size: 12px;
} }
} }
@@ -90,3 +237,82 @@ body {
max-width: calc(100vw - 48px) !important; max-width: calc(100vw - 48px) !important;
} }
} }
.notifications-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.notifications-filter {
width: 100%;
margin-bottom: 12px;
}
@media (max-width: 575px) {
.notifications-layout,
.notifications-content {
min-width: 0;
}
.notifications-header {
margin-bottom: 12px;
}
.notifications-content .ant-list-item {
align-items: flex-start;
padding: 14px 6px !important;
}
.notifications-content .ant-list-item-meta-avatar {
margin-inline-end: 10px;
}
.notifications-content .ant-list-item-meta-title {
margin-bottom: 4px;
}
}
@media (max-width: 575px) {
.ant-table-wrapper::before {
content: '表格可左右滑动查看';
display: block;
margin-bottom: 6px;
color: #8c8c8c;
font-size: 11px;
text-align: right;
}
.ant-table-wrapper .ant-table-content {
scrollbar-width: thin;
}
.ant-table-wrapper .ant-table-cell-fix-left,
.ant-table-wrapper .ant-table-cell-fix-right {
box-shadow: 2px 0 5px rgb(0 0 0 / 5%);
}
}
.responsive-toolbar--single {
justify-content: flex-start;
}
@media (max-width: 575px) {
/* Input.Search is an internal compact group. Keep the text field flexible and
the search icon button fixed instead of applying toolbar full-width rules
to both children. */
.responsive-toolbar .ant-input-search > .ant-input-affix-wrapper {
flex: 1 1 auto;
width: auto !important;
min-width: 0;
}
.responsive-toolbar .ant-input-search > .ant-input-search-btn {
flex: 0 0 40px;
width: 40px !important;
min-width: 40px;
padding-inline: 0;
}
}

View File

@@ -51,6 +51,7 @@ const iconMap: Record<string, React.ReactNode> = {
expense: <DollarOutlined />, expense: <DollarOutlined />,
bill: <FileTextOutlined />, bill: <FileTextOutlined />,
deposit: <WalletOutlined />, deposit: <WalletOutlined />,
wallet: <WalletOutlined />,
classroom: <ReadOutlined />, classroom: <ReadOutlined />,
rental: <FileProtectOutlined />, rental: <FileProtectOutlined />,
organization: <TagsOutlined />, organization: <TagsOutlined />,
@@ -176,13 +177,14 @@ const MainLayout: React.FC = () => {
), [selectedKeys, openKeys, menuItems, handleMenuClick]); ), [selectedKeys, openKeys, menuItems, handleMenuClick]);
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout className="app-shell" style={{ minHeight: '100vh' }}>
{!isMobile && ( {!isMobile && (
<Sider <Sider
trigger={null} trigger={null}
collapsible collapsible
collapsed={isTablet ? true : collapsed} collapsed={isTablet ? true : collapsed}
theme="light" theme="light"
className="app-sidebar"
style={{ background: '#fff', borderRight: '1px solid #e5e5e7' }} style={{ background: '#fff', borderRight: '1px solid #e5e5e7' }}
> >
<div <div
@@ -209,13 +211,15 @@ const MainLayout: React.FC = () => {
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
size={240} size={240}
styles={{ body: { padding: 0 } }} styles={{ body: { padding: 0 } }}
className="app-navigation-drawer"
title="恭学教育基地" title="恭学教育基地"
> >
{menuContent} {menuContent}
</Drawer> </Drawer>
)} )}
<Layout style={{ background: '#f5f5f7' }}> <Layout className="app-main" style={{ background: '#f5f5f7' }}>
<Header <Header
className="app-header"
style={{ style={{
padding: '0 16px', padding: '0 16px',
background: '#fff', background: '#fff',
@@ -267,8 +271,9 @@ const MainLayout: React.FC = () => {
</div> </div>
</Header> </Header>
<Content <Content
className="app-content"
style={{ style={{
margin: isMobile ? 12 : isTablet ? 16 : 24, margin: isMobile ? 8 : isTablet ? 16 : 24,
padding: isMobile ? 12 : isTablet ? 16 : 24, padding: isMobile ? 12 : isTablet ? 16 : 24,
background: '#fff', background: '#fff',
borderRadius: 12, borderRadius: 12,

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
canPullAttendance, canPullAttendance,
filterLessonAttendanceRecords,
getAttendanceExperience, getAttendanceExperience,
getPunchDisplayInfo,
getSchedulePhase, getSchedulePhase,
summarizeAttendance, summarizeAttendance,
summarizeLessonCheckins, summarizeLessonCheckins,
@@ -65,3 +67,55 @@ describe('lesson check-in summary', () => {
).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 }); ).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 });
}); });
}); });
describe('lesson attendance filters', () => {
const records = [
{ id: 1, student: { name: '张三' }, status: 'present' },
{ id: 2, student: { name: '李四' }, status: 'late' },
{ id: 3, student: { name: '王五' }, status: 'pending' },
{ id: 4, student: { name: '赵六' }, status: 'absent' },
];
it('searches students by name and ignores surrounding whitespace', () => {
expect(filterLessonAttendanceRecords(records, ' 张 ', 'all').map((item) => item.id)).toEqual([1]);
});
it('groups present and late as checked in', () => {
expect(filterLessonAttendanceRecords(records, '', 'checked_in').map((item) => item.id)).toEqual([1, 2]);
});
it('groups pending and absent as not checked in and combines with search', () => {
expect(filterLessonAttendanceRecords(records, '王', 'not_checked_in').map((item) => item.id)).toEqual([3]);
});
});
describe('lesson punch device display', () => {
it('labels attendance machine punches with the machine name and id', () => {
expect(
getPunchDisplayInfo({
status: 'present',
source: 'dingtalk',
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
punchTime: '2026-07-11T00:55:00.000Z',
}),
).toEqual({
label: '考勤机打卡',
machine: true,
detail: '东门考勤机ATM-01',
time: '2026-07-11T00:55:00.000Z',
});
});
it('distinguishes mobile punches and manual teacher markings', () => {
expect(
getPunchDisplayInfo({ status: 'present', source: 'dingtalk', punchSource: 'USER' }),
).toEqual({ label: '手机打卡', machine: false, detail: undefined, time: undefined });
expect(getPunchDisplayInfo({ status: 'present', source: 'manual' })).toEqual({
label: '老师手动标记',
machine: false,
});
});
});

View File

@@ -82,3 +82,80 @@ export function summarizeLessonCheckins(
notCheckedIn: records.length - checkedIn, notCheckedIn: records.length - checkedIn,
}; };
} }
export type LessonAttendanceFilter = 'all' | 'checked_in' | 'not_checked_in';
export interface LessonAttendanceFilterRecord {
student: { name: string };
status: string;
}
export function filterLessonAttendanceRecords<T extends LessonAttendanceFilterRecord>(
records: readonly T[],
keyword: string,
filter: LessonAttendanceFilter,
): T[] {
const normalizedKeyword = keyword.trim().toLocaleLowerCase('zh-CN');
return records.filter((record) => {
const matchesKeyword =
!normalizedKeyword ||
record.student.name.toLocaleLowerCase('zh-CN').includes(normalizedKeyword);
if (!matchesKeyword || filter === 'all') return matchesKeyword;
const checkedIn = record.status === 'present' || record.status === 'late';
return filter === 'checked_in' ? checkedIn : !checkedIn;
});
}
export interface PunchDisplayRecord {
status: string;
source?: string;
punchTime?: string | null;
punchSource?: string | null;
punchDeviceName?: string | null;
punchDeviceId?: string | null;
}
export interface PunchDisplayInfo {
label: string;
machine: boolean;
detail?: string;
time?: string;
}
export function getPunchDisplayInfo(record: PunchDisplayRecord): PunchDisplayInfo | null {
if (record.status !== 'present' && record.status !== 'late') return null;
if (record.source === 'manual') return { label: '老师手动标记', machine: false };
const source = (record.punchSource || '').trim().toUpperCase();
const machine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some(
(value) => source === value || source.includes(value),
);
const label = machine
? '考勤机打卡'
: source === 'USER'
? '手机打卡'
: source.includes('BEACON') || source.includes('BLE')
? '蓝牙打卡'
: source.includes('WIFI')
? 'Wi-Fi 打卡'
: source.includes('APPROVE')
? '审批补卡'
: source
? `其他打卡(${record.punchSource}`
: '打卡来源未知';
const device = record.punchDeviceName?.trim();
const deviceId = record.punchDeviceId?.trim();
const detail = device
? deviceId && deviceId !== device
? `${device}${deviceId}`
: device
: deviceId || undefined;
return {
label,
machine,
detail,
time: record.punchTime || undefined,
};
}

View File

@@ -286,6 +286,32 @@
.is-leave { color: #2874c6 !important; background: #edf5ff; } .is-leave { color: #2874c6 !important; background: #edf5ff; }
.is-pending { color: #667085 !important; background: #f1f3f6; } .is-pending { color: #667085 !important; background: #f1f3f6; }
.lesson-record-filters {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 14px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: #f8fafc;
}
.lesson-record-search {
width: 260px;
}
.lesson-record-filter-select {
width: 130px;
}
.lesson-record-filter-count {
margin-left: auto;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.attendance-status { .attendance-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -414,6 +440,22 @@
color: #a1a9b5; color: #a1a9b5;
} }
@media (max-width: 640px) {
.lesson-record-filters {
align-items: stretch;
flex-direction: column;
}
.lesson-record-search,
.lesson-record-filter-select {
width: 100%;
}
.lesson-record-filter-count {
margin-left: 0;
}
}
@media (max-width: 900px) { @media (max-width: 900px) {
.attendance-hero, .attendance-hero,
.archive-toolbar { .archive-toolbar {
@@ -502,3 +544,25 @@
.attendance-marking-actions .ant-btn { .attendance-marking-actions .ant-btn {
min-width: 54px; min-width: 54px;
} }
.punch-device-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.punch-device-cell .ant-tag {
margin-inline-end: 0;
}
.punch-device-cell strong {
color: #1f2937;
font-size: 13px;
}
.punch-device-cell span {
color: #8c8c8c;
font-size: 12px;
}

View File

@@ -40,10 +40,13 @@ import { usePermission } from '../../hooks/usePermission';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { import {
canPullAttendance, canPullAttendance,
filterLessonAttendanceRecords,
getAttendanceExperience, getAttendanceExperience,
getPunchDisplayInfo,
getSchedulePhase, getSchedulePhase,
summarizeLessonCheckins, summarizeLessonCheckins,
type AttendanceSummary, type AttendanceSummary,
type LessonAttendanceFilter,
type SchedulePhase, type SchedulePhase,
} from './attendance-workspace'; } from './attendance-workspace';
import './attendance.css'; import './attendance.css';
@@ -96,6 +99,10 @@ interface AttendanceRecordItem {
class: { id: number; name: string } | null; class: { id: number; name: string } | null;
scheduleId?: number | null; scheduleId?: number | null;
attendanceSessionId?: number | null; attendanceSessionId?: number | null;
punchTime?: string | null;
punchSource?: string | null;
punchDeviceName?: string | null;
punchDeviceId?: string | null;
} }
interface AssignedClass { interface AssignedClass {
@@ -256,6 +263,8 @@ const TeacherAttendanceWorkspace: React.FC = () => {
const [lessonRecords, setLessonRecords] = useState<AttendanceRecordItem[]>([]); const [lessonRecords, setLessonRecords] = useState<AttendanceRecordItem[]>([]);
const [recordLoading, setRecordLoading] = useState(false); const [recordLoading, setRecordLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [studentKeyword, setStudentKeyword] = useState('');
const [checkinFilter, setCheckinFilter] = useState<LessonAttendanceFilter>('all');
const loadWorkspace = useCallback(async () => { const loadWorkspace = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -279,6 +288,8 @@ const TeacherAttendanceWorkspace: React.FC = () => {
const openAttendance = useCallback(async (schedule: TodaySchedule) => { const openAttendance = useCallback(async (schedule: TodaySchedule) => {
setStudentKeyword('');
setCheckinFilter('all');
setSelectedSchedule(schedule); setSelectedSchedule(schedule);
setDrawerOpen(true); setDrawerOpen(true);
setRecordLoading(true); setRecordLoading(true);
@@ -288,6 +299,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
`/attendance-lessons/schedules/${schedule.id}/pull`, `/attendance-lessons/schedules/${schedule.id}/pull`,
{ date: today }, { date: today },
); );
setSelectedSchedule(data.schedule);
setLessonSession(data.session); setLessonSession(data.session);
setLessonRecords(data.records); setLessonRecords(data.records);
message.success('钉钉打卡已更新;课程截止后系统将自动结算'); message.success('钉钉打卡已更新;课程截止后系统将自动结算');
@@ -328,6 +340,10 @@ const TeacherAttendanceWorkspace: React.FC = () => {
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended', (item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
); );
const isAttendanceCompleted = lessonSession?.status === 'completed'; const isAttendanceCompleted = lessonSession?.status === 'completed';
const filteredLessonRecords = useMemo(
() => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter),
[lessonRecords, studentKeyword, checkinFilter],
);
return ( return (
<div className="attendance-page teacher-attendance"> <div className="attendance-page teacher-attendance">
@@ -386,7 +402,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
)} )}
</Spin> </Spin>
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={820} title={null} className="attendance-drawer"> <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
<div className="lesson-record-header"> <div className="lesson-record-header">
<span className="attendance-eyebrow">LESSON ATTENDANCE</span> <span className="attendance-eyebrow">LESSON ATTENDANCE</span>
<h2>{selectedSchedule?.subject || '课程考勤'}</h2> <h2>{selectedSchedule?.subject || '课程考勤'}</h2>
@@ -401,12 +417,39 @@ const TeacherAttendanceWorkspace: React.FC = () => {
/> />
)} )}
<LessonCheckinSummaryStrip records={lessonRecords} /> <LessonCheckinSummaryStrip records={lessonRecords} />
<div className="lesson-record-filters">
<Input.Search
allowClear
value={studentKeyword}
placeholder="搜索学生姓名"
onChange={(event) => setStudentKeyword(event.target.value)}
className="lesson-record-search"
/>
<Select<LessonAttendanceFilter>
value={checkinFilter}
onChange={setCheckinFilter}
options={[
{ value: 'all', label: '全部学生' },
{ value: 'checked_in', label: '已打卡' },
{ value: 'not_checked_in', label: '未打卡' },
]}
className="lesson-record-filter-select"
/>
<span className="lesson-record-filter-count">
{filteredLessonRecords.length} / {lessonRecords.length}
</span>
</div>
<Table<AttendanceRecordItem> <Table<AttendanceRecordItem>
rowKey="id" rowKey="id"
loading={recordLoading} loading={recordLoading}
dataSource={lessonRecords} dataSource={filteredLessonRecords}
pagination={false} pagination={false}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本节课尚未开始点名" /> }} locale={{
emptyText: <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
/>,
}}
columns={[ columns={[
{ {
title: '学生', dataIndex: ['student', 'name'], title: '学生', dataIndex: ['student', 'name'],
@@ -438,6 +481,21 @@ const TeacherAttendanceWorkspace: React.FC = () => {
}, },
}, },
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> }, { title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
{
title: '打卡设备',
width: 220,
render: (_: unknown, record: AttendanceRecordItem) => {
const info = getPunchDisplayInfo(record);
if (!info) return <span className="muted-text"></span>;
return (
<div className="punch-device-cell">
<Tag color={info.machine ? 'green' : 'blue'}>{info.label}</Tag>
{info.detail && <strong>{info.detail}</strong>}
{info.time && <span>{dayjs(info.time).format('HH:mm:ss')}</span>}
</div>
);
},
},
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text"></span> }, { title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text"></span> },
]} ]}
/> />
@@ -625,6 +683,21 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
width: 110, width: 110,
render: (value: string) => (value === 'dingtalk' ? '钉钉同步' : value === 'schedule' ? '课程生成' : value === 'lesson' ? '课堂点名' : '人工记录'), render: (value: string) => (value === 'dingtalk' ? '钉钉同步' : value === 'schedule' ? '课程生成' : value === 'lesson' ? '课堂点名' : '人工记录'),
}, },
{
title: '打卡设备',
width: 220,
render: (_: unknown, record: AttendanceRecordItem) => {
const info = getPunchDisplayInfo(record);
if (!info) return <span className="muted-text"></span>;
return (
<div className="punch-device-cell">
<Tag color={info.machine ? 'green' : 'blue'}>{info.label}</Tag>
{info.detail && <strong>{info.detail}</strong>}
{info.time && <span>{dayjs(info.time).format('HH:mm:ss')}</span>}
</div>
);
},
},
{ {
title: '备注', title: '备注',
dataIndex: 'remark', dataIndex: 'remark',

View File

@@ -0,0 +1,215 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined } from '@ant-design/icons';
import api from '../api';
import PermissionButton from '../components/PermissionButton';
import { message } from '../ui/app-message';
interface ClassroomOption {
id: number;
name: string;
building?: string | null;
}
interface AttendanceDeviceRow {
id: number;
deviceSn: string;
deviceName: string;
classroomId: number;
classroom?: ClassroomOption | null;
status: 'active' | 'disabled';
location?: string | null;
notes?: string | null;
}
const statusMeta = {
active: { text: '启用', color: 'green' },
disabled: { text: '停用', color: 'default' },
} as const;
const AttendanceDevicesPage: React.FC = () => {
const [data, setData] = useState<AttendanceDeviceRow[]>([]);
const [classrooms, setClassrooms] = useState<ClassroomOption[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
const [saving, setSaving] = useState(false);
const [keyword, setKeyword] = useState('');
const [form] = Form.useForm();
const loadData = async () => {
setLoading(true);
try {
const [devices, classroomList] = await Promise.all([
api.get<AttendanceDeviceRow[]>('/attendance-devices'),
api.get<ClassroomOption[]>('/classrooms'),
]);
setData(devices);
setClassrooms(classroomList.filter((item: any) => item.status !== 'archived'));
} catch (error: any) {
message.error(error?.message || '加载考勤机绑定失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadData();
}, []);
const classroomOptions = useMemo(
() => classrooms.map((item) => ({
value: item.id,
label: item.building ? `${item.name}${item.building}` : item.name,
})),
[classrooms],
);
const filteredData = useMemo(() => {
const text = keyword.trim().toLocaleLowerCase('zh-CN');
if (!text) return data;
return data.filter((item) => [
item.deviceSn,
item.deviceName,
item.classroom?.name,
item.location,
].some((value) => (value || '').toLocaleLowerCase('zh-CN').includes(text)));
}, [data, keyword]);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ status: 'active' });
setModalOpen(true);
};
const openEdit = (record: AttendanceDeviceRow) => {
setEditing(record);
form.setFieldsValue({
deviceSn: record.deviceSn,
deviceName: record.deviceName,
classroomId: record.classroomId,
status: record.status,
location: record.location,
notes: record.notes,
});
setModalOpen(true);
};
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await api.put(`/attendance-devices/${editing.id}`, values);
message.success('考勤机绑定已更新');
} else {
await api.post('/attendance-devices', values);
message.success('考勤机绑定已创建');
}
setModalOpen(false);
setEditing(null);
form.resetFields();
await loadData();
} catch (error: any) {
message.error(error?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/attendance-devices/${id}`);
message.success('已删除绑定');
await loadData();
} catch (error: any) {
message.error(error?.message || '删除失败');
}
};
const columns: ColumnsType<AttendanceDeviceRow> = [
{ title: '设备名称', dataIndex: 'deviceName', width: 180 },
{ title: 'SN 码', dataIndex: 'deviceSn', width: 220, render: (value) => <span style={{ fontFamily: 'monospace' }}>{value}</span> },
{ title: '绑定教室', dataIndex: ['classroom', 'name'], width: 160, render: (_value, record) => record.classroom?.name || `教室 ${record.classroomId}` },
{ title: '位置', dataIndex: 'location', render: (value) => value || <span style={{ color: '#999' }}></span> },
{ title: '状态', dataIndex: 'status', width: 90, render: (value: keyof typeof statusMeta) => <Tag color={statusMeta[value]?.color}>{statusMeta[value]?.text || value}</Tag> },
{ title: '备注', dataIndex: 'notes', ellipsis: true, render: (value) => value || <span style={{ color: '#999' }}></span> },
{
title: '操作',
width: 150,
render: (_, record) => (
<Space>
<PermissionButton permission="classroom:edit" size="small" type="link" onClick={() => openEdit(record)}>
</PermissionButton>
<Popconfirm title="确定删除此考勤机绑定?" onConfirm={() => handleDelete(record.id)}>
<PermissionButton permission="classroom:edit" size="small" danger>
</PermissionButton>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Input.Search
allowClear
placeholder="搜索设备/SN/教室"
style={{ width: 260 }}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<PermissionButton permission="classroom:edit" type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</PermissionButton>
</div>
<Table<AttendanceDeviceRow>
rowKey="id"
columns={columns}
dataSource={filteredData}
loading={loading}
locale={{ emptyText: <Empty description="暂无考勤机绑定" /> }}
pagination={{ defaultPageSize: 20, showSizeChanger: true }}
/>
<Modal
title={editing ? '编辑考勤机绑定' : '添加考勤机绑定'}
open={modalOpen}
onOk={handleSave}
onCancel={() => {
setModalOpen(false);
setEditing(null);
}}
confirmLoading={saving}
okText="保存"
>
<Form form={form} layout="vertical">
<Form.Item name="deviceName" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
<Input placeholder="如彼岸游境_N1604" />
</Form.Item>
<Form.Item name="deviceSn" label="SN 码" rules={[{ required: true, message: '请输入钉钉返回的 deviceSN' }]}>
<Input placeholder="如300419260325WN1604" />
</Form.Item>
<Form.Item name="classroomId" label="绑定教室" rules={[{ required: true, message: '请选择绑定教室' }]}>
<Select showSearch optionFilterProp="label" options={classroomOptions} placeholder="选择教室" />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '停用' }]} />
</Form.Item>
<Form.Item name="location" label="位置">
<Input placeholder="如:教学楼一楼东侧" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AttendanceDevicesPage;

View File

@@ -10,7 +10,6 @@ import {
Popconfirm, Popconfirm,
Input, Input,
Select, Select,
Tooltip,
Spin, Spin,
Empty, Empty,
} from 'antd'; } from 'antd';
@@ -26,12 +25,12 @@ import PermissionButton from '../../components/PermissionButton';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
const { RangePicker } = DatePicker;
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
draft: { text: '草稿', color: 'default' }, unpaid: { text: '待支付', color: 'orange' },
confirmed: { text: '已确认', color: 'blue' }, partially_paid: { text: '部分支付', color: 'gold' },
paid: { text: '已支付', color: 'green' }, paid: { text: '已支付', color: 'green' },
cancelled: { text: '已取消', color: 'default' },
}; };
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
@@ -93,8 +92,7 @@ const BillsPage: React.FC = () => {
const values = await generateForm.validateFields(); const values = await generateForm.validateFields();
try { try {
const res: any = await api.post('/bills/generate', { const res: any = await api.post('/bills/generate', {
periodStart: values.period[0].format('YYYY-MM-DD'), billingMonth: values.billingMonth.format('YYYY-MM'),
periodEnd: values.period[1].format('YYYY-MM-DD'),
}); });
message.success(res.message || '生成成功'); message.success(res.message || '生成成功');
setGenerateModal(false); setGenerateModal(false);
@@ -119,43 +117,29 @@ const BillsPage: React.FC = () => {
} }
}; };
const updateStatus = async (id: number, status: string) => {
try {
await api.put(`/bills/${id}/status`, { status });
message.success('状态更新成功');
fetchData();
if (detailModal?.id === id) {
setDetailModal({ ...detailModal, status });
}
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const batchUpdateStatus = async (status: string) => {
if (selectedRows.length === 0) return message.warning('请先选择账单'); const handleCancel = async (id: number) => {
if (batchLoading) return; let reason = '';
setBatchLoading(true); Modal.confirm({
try { title: '取消账单并退回已扣余额',
await api.put('/bills/batch/status', { ids: selectedRows, status }); content: <Input.TextArea placeholder="请输入取消原因" maxLength={300} onChange={(event) => { reason = event.target.value; }} />,
message.success(`已批量更新 ${selectedRows.length} 条账单`); okText: '确认取消', cancelText: '返回',
setSelectedRows([]); onOk: async () => {
if (!reason.trim()) { message.error('请输入取消原因'); throw new Error('reason required'); }
await api.post(`/bills/${id}/cancel`, { reason: reason.trim() });
message.success('账单已取消,已扣余额已冲正退回');
fetchData(); fetchData();
} catch (e: any) { },
message.error(e?.message || '操作失败'); });
} finally {
setBatchLoading(false);
}
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
await api.delete(`/bills/${id}`); await api.delete(`/bills/${id}`);
message.success('账单已删除'); message.success('删除成功');
fetchData(); fetchData();
} catch (e: any) { } catch (error: any) { message.error(error?.message || '删除失败'); }
message.error(e?.message || '删除失败');
}
}; };
const batchDelete = async () => { const batchDelete = async () => {
@@ -212,31 +196,16 @@ const BillsPage: React.FC = () => {
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>, render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
}, },
{ {
title: '可用押金', title: '已扣余额', dataIndex: 'paidAmount', width: 110,
dataIndex: 'availableDeposit', render: (value: number) => <span style={{ color: '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</span>,
width: 120,
render: (v: number) =>
v > 0 ? (
<span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
) : (
<span style={{ color: '#999' }}>-</span>
),
}, },
{ {
title: '抵扣后应付', title: '待补缴', dataIndex: 'outstandingAmount', width: 110,
dataIndex: 'amountAfterDeposit', render: (value: number) => <strong style={{ color: Number(value) > 0 ? '#cf1322' : '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</strong>,
width: 130,
render: (v: number, r: any) => {
const has = Number(r.availableDeposit || 0) > 0;
if (!has) return <span style={{ color: '#999' }}>-</span>;
const after = Number(v ?? r.totalAmount).toFixed(2);
const applied = Number(r.depositApplied || 0).toFixed(2);
return (
<Tooltip title={`已抵扣押金 ¥${applied}`}>
<strong style={{ color: '#fa541c' }}>¥{after}</strong>
</Tooltip>
);
}, },
{
title: '钱包余额', dataIndex: 'walletBalance', width: 110,
render: (value: number) => `¥${Number(value || 0).toFixed(2)}`,
}, },
{ {
title: '状态', title: '状态',
@@ -263,25 +232,6 @@ const BillsPage: React.FC = () => {
> >
</PermissionButton> </PermissionButton>
{record.status === 'draft' && (
<PermissionButton
permission="bill:confirm"
size="small"
onClick={() => updateStatus(record.id, 'confirmed')}
>
</PermissionButton>
)}
{record.status === 'confirmed' && (
<PermissionButton
permission="bill:confirm"
size="small"
type="primary"
onClick={() => updateStatus(record.id, 'paid')}
>
</PermissionButton>
)}
<PermissionButton <PermissionButton
permission="bill:export-pdf" permission="bill:export-pdf"
size="small" size="small"
@@ -290,33 +240,25 @@ const BillsPage: React.FC = () => {
> >
PDF PDF
</PermissionButton> </PermissionButton>
<Popconfirm {record.status !== 'cancelled' && (
title="确定删除此账单?" <PermissionButton permission="bill:delete" size="small" danger onClick={() => handleCancel(record.id)}>
onConfirm={() => handleDelete(record.id)}
okText="删除"
cancelText="取消"
>
<PermissionButton permission="bill:delete" size="small" danger icon={<DeleteOutlined />}>
</PermissionButton> </PermissionButton>
)}
{Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && (
<Popconfirm title="确定删除此未支付账单?" onConfirm={() => handleDelete(record.id)} okText="删除" cancelText="取消">
<PermissionButton permission="bill:delete" size="small" danger icon={<DeleteOutlined />}></PermissionButton>
</Popconfirm> </Popconfirm>
)}
</Space> </Space>
), ),
}, },
], [showDetail, updateStatus, handleDelete, handleExportPdf]); ], [showDetail, handleDelete, handleCancel, handleExportPdf]);
return ( return (
<div> <div>
<div <div className="responsive-toolbar">
style={{ <Space wrap className="responsive-toolbar__group">
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search <Input.Search
placeholder="搜索学生姓名或账单周期" placeholder="搜索学生姓名或账单周期"
allowClear allowClear
@@ -333,28 +275,14 @@ const BillsPage: React.FC = () => {
value={filterStatus} value={filterStatus}
onChange={(v) => setFilterStatus(v)} onChange={(v) => setFilterStatus(v)}
options={[ options={[
{ value: 'draft', label: '草稿' }, { value: 'unpaid', label: '待支付' },
{ value: 'confirmed', label: '已确认' }, { value: 'partially_paid', label: '部分支付' },
{ value: 'paid', label: '已支付' }, { value: 'paid', label: '已支付' },
{ value: 'cancelled', label: '已取消' },
]} ]}
/> />
<Select placeholder="费用类型" allowClear style={{ width: 120 }} value={filterExpenseType} onChange={setFilterExpenseType} <Select placeholder="费用类型" allowClear style={{ width: 120 }} value={filterExpenseType} onChange={setFilterExpenseType}
options={[{value:'water',label:'水费'},{value:'electricity',label:'电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} /> options={[{value:'water',label:'水费'},{value:'electricity',label:'电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} />
<PermissionButton
permission="bill:confirm"
onClick={() => batchUpdateStatus('confirmed')}
disabled={selectedRows.length === 0}
>
</PermissionButton>
<PermissionButton
permission="bill:confirm"
type="primary"
onClick={() => batchUpdateStatus('paid')}
disabled={selectedRows.length === 0}
>
</PermissionButton>
<Popconfirm <Popconfirm
title={`确定删除选中的 ${selectedRows.length} 条账单?`} title={`确定删除选中的 ${selectedRows.length} 条账单?`}
onConfirm={batchDelete} onConfirm={batchDelete}
@@ -372,7 +300,7 @@ const BillsPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</Popconfirm> </Popconfirm>
</Space> </Space>
<Space> <Space wrap className="responsive-toolbar__group">
<PermissionButton <PermissionButton
permission="bill:generate" permission="bill:generate"
type="primary" type="primary"
@@ -399,12 +327,7 @@ const BillsPage: React.FC = () => {
dataSource={filteredBills} dataSource={filteredBills}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
pagination={{ pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }} locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{ rowSelection={{
selectedRowKeys: selectedRows, selectedRowKeys: selectedRows,
@@ -422,15 +345,17 @@ const BillsPage: React.FC = () => {
> >
<Form form={generateForm} layout="vertical"> <Form form={generateForm} layout="vertical">
<Form.Item <Form.Item
name="period" name="billingMonth"
label="账单周期" label="账单月份"
rules={[{ required: true, message: '请选择账单周期' }]} rules={[{ required: true, message: '请选择账单月份' }]}
extra="选择费用对应的时间段,系统将自动计算每个学生的分摊费用" extra="只能选择已结束月份,每个月只能生成一次账单"
> >
<RangePicker <DatePicker
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']} picker="month"
format="YYYY-MM-DD" placeholder="选择月份"
format="YYYY-MM"
disabledDate={(current) => !!current && !current.endOf('month').isBefore(dayjs(), 'day')}
/> />
</Form.Item> </Form.Item>
</Form> </Form>
@@ -470,42 +395,11 @@ const BillsPage: React.FC = () => {
</strong> </strong>
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
{Number(detailModal.availableDeposit || 0) > 0 && ( <Descriptions bordered size="small" column={3} style={{ marginBottom: 16 }}>
<div <Descriptions.Item label="已扣余额">¥{Number(detailModal.paidAmount || 0).toFixed(2)}</Descriptions.Item>
style={{ <Descriptions.Item label="待补缴">¥{Number(detailModal.outstandingAmount || 0).toFixed(2)}</Descriptions.Item>
marginBottom: 16, <Descriptions.Item label="当前钱包余额">¥{Number(detailModal.walletBalance || 0).toFixed(2)}</Descriptions.Item>
padding: 12, </Descriptions>
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 8,
}}
>
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>
</div>
<Space size={24} wrap>
<span>
<strong style={{ color: '#52c41a' }}>
¥{Number(detailModal.availableDeposit).toFixed(2)}
</strong>
</span>
<span>
<strong style={{ color: '#fa8c16' }}>
-¥{Number(detailModal.depositApplied || 0).toFixed(2)}
</strong>
</span>
<span>
<strong style={{ color: '#fa541c', fontSize: 16 }}>
¥
{Number(detailModal.amountAfterDeposit ?? detailModal.totalAmount).toFixed(2)}
</strong>
</span>
</Space>
</div>
)}
<h4></h4> <h4></h4>
<Table <Table
scroll={{ x: 700 }} scroll={{ x: 700 }}

View File

@@ -10,6 +10,7 @@ import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
// ---- Types ---- // ---- Types ----
@@ -39,6 +40,7 @@ interface ClassScheduleItem {
weekDay: number; weekDay: number;
startTime: string; startTime: string;
endTime: string; endTime: string;
attendanceAdvanceMinutes: number;
startDate: string; startDate: string;
endDate: string; endDate: string;
subject: string; subject: string;
@@ -83,10 +85,7 @@ interface StudentItem {
studentNo?: string; studentNo?: string;
} }
interface UserItem { type UserItem = TeacherCandidateUser;
id: number;
username: string;
}
// ---- Constants ---- // ---- Constants ----
@@ -353,6 +352,7 @@ const ClassDetailPage: React.FC = () => {
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' }, { title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v }, { title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
{ title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` }, { title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` },
{ title: '签到窗口', render: (_: unknown, r: ClassScheduleItem) => `课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课` },
{ title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` }, { title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` },
{ title: '科目', dataIndex: 'subject' }, { title: '科目', dataIndex: 'subject' },
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v }, { title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },
@@ -593,16 +593,13 @@ const ClassDetailPage: React.FC = () => {
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
<Select <Select
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder="选择教师" showSearch
optionFilterProp="label"
placeholder="搜索姓名、用户名、角色或学科"
value={teacherUserId} value={teacherUserId}
onChange={setTeacherUserId} onChange={setTeacherUserId}
options={allUsers.map((u) => ({ options={buildTeacherCandidateOptions(allUsers)}
value: u.id, notFoundContent="没有可分配的工作人员账号"
label: u.username,
}))}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
/> />
<Select <Select
style={{ width: '100%' }} style={{ width: '100%' }}

View File

@@ -154,16 +154,6 @@ const ClassesPage: React.FC = () => {
} }
}; };
const handleDelete = async (id: number) => {
try {
await api.delete(`/classes/${id}`);
message.success('已删除');
fetchData();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const columns: ColumnsType<ClassItem> = useMemo(() => [ const columns: ColumnsType<ClassItem> = useMemo(() => [
{ {
title: '班级名称', dataIndex: 'name', width: 120, title: '班级名称', dataIndex: 'name', width: 120,
@@ -208,11 +198,6 @@ const ClassesPage: React.FC = () => {
<PermissionButton permission="class:edit" size="small"></PermissionButton> <PermissionButton permission="class:edit" size="small"></PermissionButton>
</Popconfirm> </Popconfirm>
)} )}
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
<PermissionButton permission="class:delete" size="small" danger>
</PermissionButton>
</Popconfirm>
</Space> </Space>
), ),
}, },
@@ -220,7 +205,7 @@ const ClassesPage: React.FC = () => {
return ( return (
<Card> <Card>
<Space style={{ marginBottom: 16 }} wrap> <Space style={{ marginBottom: 16 }} wrap className="responsive-toolbar responsive-toolbar--single">
<Input <Input
placeholder="搜索名称/编码" placeholder="搜索名称/编码"
prefix={<SearchOutlined />} prefix={<SearchOutlined />}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
buildTeacherCandidateLabel,
buildTeacherCandidateOptions,
isTeacherCandidate,
type TeacherCandidateUser,
} from './teacher-candidate';
const baseUser = (overrides: Partial<TeacherCandidateUser> = {}): TeacherCandidateUser => ({
id: 1,
username: 'teacher',
name: '测试老师',
isActive: true,
isArchived: false,
studentStatus: null,
roles: [{ code: 'teacher', name: '任课老师' }],
profile: { subjects: ['数学', '物理'] },
...overrides,
});
describe('class teacher candidates', () => {
it('keeps non-teacher staff roles because class duty is selected separately', () => {
expect(
isTeacherCandidate(baseUser({ roles: [{ code: 'academic', name: '教务管理员' }] })),
).toBe(true);
});
it('keeps staff-linked accounts so they can serve as head or life teachers', () => {
expect(isTeacherCandidate(baseUser({ studentStatus: 'staff' }))).toBe(true);
});
it('excludes active students, disabled, archived, and super-admin accounts', () => {
const users = [
baseUser({ id: 1, studentStatus: 'active' }),
baseUser({ id: 2, isActive: false }),
baseUser({ id: 3, isArchived: true }),
baseUser({
id: 4,
roles: [{ code: 'super_admin', name: '超级管理员' }],
}),
];
expect(buildTeacherCandidateOptions(users)).toEqual([]);
});
it('shows real name, username, system role, and teaching subjects', () => {
expect(buildTeacherCandidateLabel(baseUser())).toBe(
'测试老师teacher · 任课老师 · 数学/物理',
);
});
});

View File

@@ -0,0 +1,44 @@
export interface TeacherCandidateRole {
code?: string | null;
name: string;
}
export interface TeacherCandidateUser {
id: number;
username: string;
name?: string | null;
isActive: boolean;
isArchived: boolean;
studentStatus?: string | null;
roles?: TeacherCandidateRole[];
profile?: { subjects?: string[] } | null;
}
const isSuperAdminRole = (role: TeacherCandidateRole) =>
role.code === 'super_admin' || role.name === '超级管理员' || role.name === '超管';
export const isTeacherCandidate = (user: TeacherCandidateUser) => {
if (!user.isActive || user.isArchived) return false;
if (user.studentStatus && user.studentStatus !== 'staff') return false;
return !(user.roles || []).some(isSuperAdminRole);
};
export const buildTeacherCandidateLabel = (user: TeacherCandidateUser) => {
const displayName = user.name?.trim();
const identity =
displayName && displayName !== user.username
? `${displayName}${user.username}`
: user.username;
const roleNames = [...new Set((user.roles || []).map((role) => role.name).filter(Boolean))];
const subjects = [
...new Set((user.profile?.subjects || []).map((subject) => subject.trim()).filter(Boolean)),
];
return [identity, roleNames.join('/'), subjects.join('/')].filter(Boolean).join(' · ');
};
export const buildTeacherCandidateOptions = (users: TeacherCandidateUser[]) =>
users.filter(isTeacherCandidate).map((user) => ({
value: user.id,
label: buildTeacherCandidateLabel(user),
}));

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildDepositStudentOption } from './deposit-student-option'; import { buildDepositStudentOption, buildDepositStudentOptions } from './deposit-student-option';
describe('deposit student option', () => { describe('deposit student option', () => {
it('uses the student number as the non-sensitive identifier', () => { it('uses the student number as the non-sensitive identifier', () => {
@@ -17,4 +17,13 @@ describe('deposit student option', () => {
label: '张三 (#23)', label: '张三 (#23)',
}); });
}); });
it('uses lookup rows without requiring a status field', () => {
expect(buildDepositStudentOptions([{ id: 23, name: '张三', studentNo: 'S2026001' }])).toEqual([
{
value: 23,
label: '张三 (S2026001)',
},
]);
});
}); });

View File

@@ -8,3 +8,6 @@ export const buildDepositStudentOption = (student: DepositStudentLookup) => ({
value: student.id, value: student.id,
label: `${student.name} (${student.studentNo || `#${student.id}`})`, label: `${student.name} (${student.studentNo || `#${student.id}`})`,
}); });
export const buildDepositStudentOptions = (students: DepositStudentLookup[]) =>
students.map(buildDepositStudentOption);

View File

@@ -19,13 +19,12 @@ import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { buildDepositStudentOption } from './deposit-student-option'; import { buildDepositStudentOptions } from './deposit-student-option';
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' }, paid: { text: '有余额', color: 'green' },
refunded: { text: '已全退', color: 'blue' }, refunded: { text: '已全退', color: 'blue' },
partial_refund: { text: '部分退还', color: 'orange' }, depleted: { text: '已扣完', color: 'red' },
deducted: { text: '已全扣', color: 'red' },
}; };
const installmentStatusMap: Record<string, { text: string; color: string }> = { const installmentStatusMap: Record<string, { text: string; color: string }> = {
@@ -86,10 +85,7 @@ const DepositsPage: React.FC = () => {
}, [data, searchText, filterStatus]); }, [data, searchText, filterStatus]);
const studentOptions = useMemo( const studentOptions = useMemo(
() => () => buildDepositStudentOptions(students),
students
.filter((s: any) => s.status === 'active')
.map(buildDepositStudentOption),
[students], [students],
); );
@@ -103,7 +99,7 @@ const DepositsPage: React.FC = () => {
paidDate: values.paidDate.format('YYYY-MM-DD'), paidDate: values.paidDate.format('YYYY-MM-DD'),
notes: values.notes, notes: values.notes,
}); });
message.success('押金记录已创建'); message.success('押金金额已增加');
setCreateModal(false); setCreateModal(false);
createForm.resetFields(); createForm.resetFields();
fetchData(); fetchData();
@@ -122,8 +118,6 @@ const DepositsPage: React.FC = () => {
const values = await refundForm.validateFields(); const values = await refundForm.validateFields();
await api.put(`/deposits/${refundModal.id}/refund`, { await api.put(`/deposits/${refundModal.id}/refund`, {
refundDate: values.refundDate.format('YYYY-MM-DD'), refundDate: values.refundDate.format('YYYY-MM-DD'),
deductionAmount: values.deductionAmount || 0,
deductionReason: values.deductionReason,
notes: values.notes, notes: values.notes,
}); });
message.success('退还操作完成'); message.success('退还操作完成');
@@ -183,24 +177,13 @@ const DepositsPage: React.FC = () => {
const columns = useMemo(() => [ const columns = useMemo(() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
{ title: '押金金额', dataIndex: 'amount', width: 110, render: (v: number) => `¥${Number(v).toFixed(2)}` }, { title: '当前可用押金', dataIndex: 'amount', width: 130, render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '缴纳日期', dataIndex: 'paidDate', width: 110 }, { title: '最近收取日期', dataIndex: 'paidDate', width: 120 },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>, render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
}, },
{
title: '退还金额',
dataIndex: 'refundAmount',
render: (v: any) => (v != null ? `¥${Number(v).toFixed(2)}` : '-'),
},
{
title: '扣除金额',
dataIndex: 'deductionAmount',
render: (v: any) => (v > 0 ? `¥${Number(v).toFixed(2)}` : '-'),
},
{ title: '扣除原因', dataIndex: 'deductionReason', width: 120, render: (v: any) => v || '-' },
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: any) => v || '-' }, { title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: any) => v || '-' },
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: any) => v || '-' }, { title: '备注', dataIndex: 'notes', width: 120, render: (v: any) => v || '-' },
{ {
@@ -225,7 +208,7 @@ const DepositsPage: React.FC = () => {
type="primary" type="primary"
onClick={() => { onClick={() => {
setRefundModal(record); setRefundModal(record);
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 }); refundForm.setFieldsValue({ refundDate: dayjs() });
}} }}
> >
退 退
@@ -288,10 +271,9 @@ const DepositsPage: React.FC = () => {
value={filterStatus} value={filterStatus}
onChange={(v) => setFilterStatus(v)} onChange={(v) => setFilterStatus(v)}
options={[ options={[
{ value: 'paid', label: '已缴' }, { value: 'paid', label: '有余额' },
{ value: 'refunded', label: '已全退' }, { value: 'refunded', label: '已全退' },
{ value: 'partial_refund', label: '部分退还' }, { value: 'depleted', label: '已扣完' },
{ value: 'deducted', label: '已全扣' },
]} ]}
/> />
</Space> </Space>
@@ -345,11 +327,11 @@ const DepositsPage: React.FC = () => {
options={studentOptions} options={studentOptions}
/> />
</Form.Item> </Form.Item>
<Form.Item name="amount" label="押金金额(元)" rules={[{ required: true }]}> <Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} /> <InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item name="paidDate" label="缴纳日期" rules={[{ required: true }]}> <Form.Item name="paidDate" label="收取日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择缴纳日期" format="YYYY-MM-DD" /> <DatePicker style={{ width: '100%' }} placeholder="选择收取日期" format="YYYY-MM-DD" />
</Form.Item> </Form.Item>
<Form.Item name="notes" label="备注"> <Form.Item name="notes" label="备注">
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
@@ -368,22 +350,11 @@ const DepositsPage: React.FC = () => {
> >
<Form form={refundForm} layout="vertical"> <Form form={refundForm} layout="vertical">
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}> <div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong> : <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
</div> </div>
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}> <Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" /> <DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
</Form.Item> </Form.Item>
<Form.Item name="deductionAmount" label="扣除金额(元)" extra="如无扣除填0">
<InputNumber
min={0}
max={Number(refundModal?.amount || 500)}
precision={2}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="deductionReason" label="扣除原因">
<Input placeholder="如:房间损坏赔偿" />
</Form.Item>
<Form.Item name="notes" label="备注"> <Form.Item name="notes" label="备注">
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
</Form.Item> </Form.Item>
@@ -401,8 +372,8 @@ const DepositsPage: React.FC = () => {
{detailModal && ( {detailModal && (
<div> <div>
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
<p><strong>:</strong> ¥{Number(detailModal.amount).toFixed(2)}</p> <p><strong>:</strong> ¥{Number(detailModal.amount).toFixed(2)}</p>
<p><strong>:</strong> {detailModal.paidDate}</p> <p><strong>:</strong> {detailModal.paidDate}</p>
<p> <p>
<strong>:</strong>{' '} <strong>:</strong>{' '}
<Tag color={statusMap[detailModal.status]?.color}> <Tag color={statusMap[detailModal.status]?.color}>

View File

@@ -46,10 +46,12 @@ const ExpensesPage: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [roomModal, setRoomModal] = useState(false); const [roomModal, setRoomModal] = useState(false);
const [personalModal, setPersonalModal] = useState(false); const [personalModal, setPersonalModal] = useState(false);
const [utilityModal, setUtilityModal] = useState(false);
const [editingRoom, setEditingRoom] = useState<any>(null); const [editingRoom, setEditingRoom] = useState<any>(null);
const [editingPersonal, setEditingPersonal] = useState<any>(null); const [editingPersonal, setEditingPersonal] = useState<any>(null);
const [roomForm] = Form.useForm(); const [roomForm] = Form.useForm();
const [personalForm] = Form.useForm(); const [personalForm] = Form.useForm();
const [utilityForm] = Form.useForm();
const [roomSearch, setRoomSearch] = useState(''); const [roomSearch, setRoomSearch] = useState('');
const [roomTypeFilter, setRoomTypeFilter] = useState<string | undefined>(undefined); const [roomTypeFilter, setRoomTypeFilter] = useState<string | undefined>(undefined);
const [personalSearch, setPersonalSearch] = useState(''); const [personalSearch, setPersonalSearch] = useState('');
@@ -192,6 +194,27 @@ const ExpensesPage: React.FC = () => {
} }
}; };
const handleStudentUtility = async () => {
const values = await utilityForm.validateFields();
setSaving(true);
try {
const result: any = await api.post('/expenses/student-utility', {
studentId: values.studentId,
expenseType: values.expenseType,
amount: values.amount,
periodStart: values.period[0].format('YYYY-MM-DD'),
periodEnd: values.period[1].format('YYYY-MM-DD'),
description: values.description,
});
const bill = result.bill;
message.success(`账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`);
setUtilityModal(false);
utilityForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '水电费出账失败'); }
finally { setSaving(false); }
};
const handlePersonalExpense = async () => { const handlePersonalExpense = async () => {
setSaving(true); setSaving(true);
try { try {
@@ -560,6 +583,13 @@ const ExpensesPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</Popconfirm> </Popconfirm>
<PermissionButton
permission="expense:create"
icon={<PlusOutlined />}
onClick={() => { utilityForm.resetFields(); setUtilityModal(true); }}
>
</PermissionButton>
<PermissionButton <PermissionButton
permission="expense:create" permission="expense:create"
type="primary" type="primary"
@@ -639,6 +669,19 @@ const ExpensesPage: React.FC = () => {
</Form> </Form>
</Modal> </Modal>
<Modal title="添加学生水电费并立即出账" open={utilityModal} onOk={handleStudentUtility} onCancel={() => setUtilityModal(false)} okText="生成账单并扣余额" confirmLoading={saving}>
<Form form={utilityForm} layout="vertical">
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="label" options={students.map((student: any) => ({ value: student.id, label: `${student.name} (${student.studentNo || `#${student.id}`})` }))} />
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}><Select options={[{ value: 'water', label: '水费' }, { value: 'electricity', label: '电费' }]} /></Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}><RangePicker style={{ width: '100%' }} format="YYYY-MM-DD" /></Form.Item>
<Form.Item name="description" label="说明"><Input.TextArea rows={2} maxLength={300} /></Form.Item>
</Form>
</Modal>
<Modal <Modal
title={editingPersonal ? '编辑个人费用' : '录入个人附加费'} title={editingPersonal ? '编辑个人费用' : '录入个人附加费'}
open={personalModal} open={personalModal}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react'; import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { import {
Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider, Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider,
Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber, Drawer, Tree, Select, TreeSelect, Modal, DatePicker,
Row, Col, List, Row, Col, List,
} from 'antd'; } from 'antd';
import { import {
@@ -59,7 +59,6 @@ interface ClassItem {
classType?: string; classType?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
maxStudents?: number;
notes?: string; notes?: string;
} }
@@ -477,9 +476,6 @@ const IntegrationConfigPage: React.FC = () => {
<Form.Item name="endDate" label="结束日期"> <Form.Item name="endDate" label="结束日期">
<DatePicker style={{ width: '100%' }} /> <DatePicker style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item name="maxStudents" label="最大人数">
<InputNumber min={0} style={{ width: '100%' }} placeholder="0=不限制" />
</Form.Item>
<Form.Item name="notes" label="备注"> <Form.Item name="notes" label="备注">
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
</Form.Item> </Form.Item>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { List, Typography, Menu, Layout, Button, Empty, Spin, Space } from 'antd'; import { List, Typography, Menu, Layout, Button, Empty, Spin, Space, Grid, Select } from 'antd';
import { import {
BellOutlined, BellOutlined,
DollarOutlined, DollarOutlined,
@@ -13,6 +13,7 @@ import { message } from '../../ui/app-message';
import { formatNotificationText } from '../../utils/notification-display'; import { formatNotificationText } from '../../utils/notification-display';
const { Sider, Content } = Layout; const { Sider, Content } = Layout;
const { useBreakpoint } = Grid;
interface NotificationItem { interface NotificationItem {
id: number; id: number;
@@ -49,6 +50,8 @@ function timeAgo(dateStr: string): string {
} }
const NotificationsPage: React.FC = () => { const NotificationsPage: React.FC = () => {
const screens = useBreakpoint();
const isMobile = !screens.sm;
const [notifications, setNotifications] = useState<NotificationItem[]>([]); const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [filter, setFilter] = useState('all'); const [filter, setFilter] = useState('all');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -101,27 +104,39 @@ const NotificationsPage: React.FC = () => {
? notifications ? notifications
: notifications.filter((n) => n.type === filter); : notifications.filter((n) => n.type === filter);
return ( const filterItems = [
<Layout style={{ minHeight: '100%', background: '#fff' }}>
<Sider width={180} style={{ background: '#fff', borderRight: '1px solid #f0f0f0' }}>
<Menu
mode="inline"
selectedKeys={[filter]}
onClick={({ key }) => setFilter(key)}
items={[
{ key: 'all', icon: <BellOutlined />, label: '全部' }, { key: 'all', icon: <BellOutlined />, label: '全部' },
{ key: 'bill_generated', icon: <DollarOutlined />, label: '账单' }, { key: 'bill_generated', icon: <DollarOutlined />, label: '账单' },
{ key: 'check_in', icon: <HomeOutlined />, label: '入住' }, { key: 'check_in', icon: <HomeOutlined />, label: '入住' },
{ key: 'class_change', icon: <TeamOutlined />, label: '班级' }, { key: 'class_change', icon: <TeamOutlined />, label: '班级' },
{ key: 'announcement', icon: <SettingOutlined />, label: '公告' }, { key: 'announcement', icon: <SettingOutlined />, label: '公告' },
]} ];
return (
<Layout className="notifications-layout" style={{ minHeight: '100%', background: '#fff' }}>
{!isMobile && (
<Sider width={180} style={{ background: '#fff', borderRight: '1px solid #f0f0f0' }}>
<Menu
mode="inline"
selectedKeys={[filter]}
onClick={({ key }) => setFilter(key)}
items={filterItems}
/> />
</Sider> </Sider>
<Content style={{ padding: 24 }}> )}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <Content className="notifications-content" style={{ padding: isMobile ? 0 : 24 }}>
<div className="notifications-header">
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button onClick={handleMarkAll}></Button> <Button onClick={handleMarkAll}></Button>
</div> </div>
{isMobile && (
<Select
value={filter}
onChange={setFilter}
options={filterItems.map((item) => ({ value: item.key, label: item.label }))}
className="notifications-filter"
/>
)}
<Spin spinning={loading}> <Spin spinning={loading}>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<Empty description="暂无通知" /> <Empty description="暂无通知" />
@@ -165,7 +180,7 @@ const NotificationsPage: React.FC = () => {
</div> </div>
} }
title={ title={
<Space> <Space wrap size={[8, 2]}>
<Typography.Text <Typography.Text
strong={!item.isRead} strong={!item.isRead}
style={{ fontSize: 15 }} style={{ fontSize: 15 }}

View File

@@ -40,7 +40,6 @@ const OccupanciesPage: React.FC = () => {
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]); const [students, setStudents] = useState<any[]>([]);
const [rooms, setRooms] = useState<any[]>([]); const [rooms, setRooms] = useState<any[]>([]);
const [organizations, setOrganizations] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [checkInModal, setCheckInModal] = useState(false); const [checkInModal, setCheckInModal] = useState(false);
const [checkOutModal, setCheckOutModal] = useState<any>(null); const [checkOutModal, setCheckOutModal] = useState<any>(null);
@@ -66,14 +65,13 @@ const OccupanciesPage: React.FC = () => {
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const [occRes, stuRes, rmRes, tnRes] = (await Promise.allSettled([ const [occRes, stuRes, rmRes] = (await Promise.allSettled([
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }), api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
api.get('/students/basic-lookups'), api.get('/students/basic-lookups'),
api.get('/rooms/overview'), api.get('/rooms/overview'),
api.get('/organizations'),
])) as PromiseSettledResult<any>[]; ])) as PromiseSettledResult<any>[];
const labels = ['入住数据', '学生列表', '房间列表', '机构列表']; const labels = ['入住数据', '学生列表', '房间列表'];
[occRes, stuRes, rmRes, tnRes].forEach((res, i) => { [occRes, stuRes, rmRes].forEach((res, i) => {
if (res.status === 'rejected') { if (res.status === 'rejected') {
message.warning(`${labels[i]}加载失败`); message.warning(`${labels[i]}加载失败`);
} }
@@ -81,7 +79,6 @@ const OccupanciesPage: React.FC = () => {
setData(occRes.status === 'fulfilled' ? occRes.value : []); setData(occRes.status === 'fulfilled' ? occRes.value : []);
setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []); setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []);
setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []); setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []);
setOrganizations(tnRes.status === 'fulfilled' ? tnRes.value : []);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
message.error('数据加载异常'); message.error('数据加载异常');
@@ -157,7 +154,8 @@ const OccupanciesPage: React.FC = () => {
checkInDate: values.checkInDate.format('YYYY-MM-DD'), checkInDate: values.checkInDate.format('YYYY-MM-DD'),
billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'), billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'),
stayType: values.stayType, stayType: values.stayType,
responsibleOrganizationId: values.responsibleOrganizationId, collectDeposit: values.collectDeposit,
depositAmount: values.collectDeposit ? values.depositAmount : undefined,
notes: values.notes, notes: values.notes,
bedId: values.bedId, bedId: values.bedId,
lockerId: values.lockerId || undefined, lockerId: values.lockerId || undefined,
@@ -330,22 +328,14 @@ const OccupanciesPage: React.FC = () => {
<div> <div>
<Alert <Alert
title="一站式导入" title="一站式导入"
description="导入入住名单时会自动创建学生和宿舍,无需单独在「学生管理」或「宿舍管理」中手动添加。后续仅需在此页面处理换房/退宿等日常操作即可。" description="导入入住名单时会优先按手机号关联已有学生,所属机构自动取学生档案;未找到学生或宿舍时会自动创建。后续仅需在此页面处理换房/退宿等日常操作即可。"
type="info" type="info"
showIcon showIcon
closable closable
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
<div <div className="responsive-toolbar">
style={{ <Space wrap className="responsive-toolbar__group">
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Button type={showActive ? 'primary' : 'default'} onClick={() => setShowActive(true)}> <Button type={showActive ? 'primary' : 'default'} onClick={() => setShowActive(true)}>
</Button> </Button>
@@ -360,14 +350,14 @@ const OccupanciesPage: React.FC = () => {
/> />
<RangePicker value={dateRange} onChange={(dates) => { setDateRange(dates ? [dates[0], dates[1]] : null); }} placeholder={['入住开始', '入住结束']} style={{ width: 240 }} /> <RangePicker value={dateRange} onChange={(dates) => { setDateRange(dates ? [dates[0], dates[1]] : null); }} placeholder={['入住开始', '入住结束']} style={{ width: 240 }} />
</Space> </Space>
<Space wrap> <Space wrap className="responsive-toolbar__group">
<PermissionButton <PermissionButton
permission="occupancy:checkin" permission="occupancy:checkin"
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => { onClick={() => {
checkInForm.resetFields(); checkInForm.resetFields();
checkInForm.setFieldsValue({ checkInDate: dayjs() }); checkInForm.setFieldsValue({ checkInDate: dayjs(), collectDeposit: true, depositAmount: 500 });
setCheckInModal(true); setCheckInModal(true);
}} }}
> >
@@ -407,7 +397,7 @@ const OccupanciesPage: React.FC = () => {
} }
}} }}
> >
<Tooltip title="导入时自动创建学生、宿舍和入住记录"> <Tooltip title="按手机号关联学生,并自动创建缺失的学生、宿舍和入住记录">
<Button type="primary" ghost icon={<UploadOutlined />}> <Button type="primary" ghost icon={<UploadOutlined />}>
</Button> </Button>
@@ -598,18 +588,6 @@ const OccupanciesPage: React.FC = () => {
placeholder="默认为短租" placeholder="默认为短租"
/> />
</Form.Item> </Form.Item>
<Form.Item name="responsibleOrganizationId" label="负责机构">
<Select
showSearch
allowClear
optionFilterProp="label"
placeholder="默认取学生所属机构"
options={organizations.map((t: { id: number; name: string }) => ({
value: t.id,
label: t.name,
}))}
/>
</Form.Item>
<Form.Item <Form.Item
name="bedId" name="bedId"
label="床位" label="床位"
@@ -630,7 +608,10 @@ const OccupanciesPage: React.FC = () => {
{availableBeds.length} {availableBeds.length}
</div> </div>
)} )}
<Form.Item name="lockerId" label="柜子(可选)"> <Form.Item
name="lockerId"
label="柜子(可选)"
>
<Select <Select
allowClear allowClear
placeholder="可选分配柜子" placeholder="可选分配柜子"
@@ -641,6 +622,32 @@ const OccupanciesPage: React.FC = () => {
}))} }))}
/> />
</Form.Item> </Form.Item>
<Form.Item
name="collectDeposit"
label="押金缴纳"
valuePropName="checked"
extra="开启后,确认入住时同步生成已缴押金记录;已有已缴押金时不会重复创建"
>
<Switch checkedChildren="已缴" unCheckedChildren="不缴" />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, current) => prev.collectDeposit !== current.collectDeposit}>
{({ getFieldValue }) =>
getFieldValue('collectDeposit') ? (
<Form.Item
name="depositAmount"
label="押金金额"
rules={[{ required: true, message: '请输入押金金额' }]}
>
<InputNumber
min={0.01}
precision={2}
addonAfter="元"
style={{ width: '100%' }}
/>
</Form.Item>
) : null
}
</Form.Item>
<Form.Item name="notes" label="备注"> <Form.Item name="notes" label="备注">
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
</Form.Item> </Form.Item>

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd'; import { Alert, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
import { BankOutlined, InboxOutlined, PlusOutlined } from '@ant-design/icons'; import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
@@ -148,6 +148,30 @@ const OrganizationsPage: React.FC = () => {
width: 160, width: 160,
render: (_: unknown, record: OrganizationItem) => ( render: (_: unknown, record: OrganizationItem) => (
<Space> <Space>
{record.status === 'archived' ? (
<Popconfirm
title="确定恢复此机构?"
onConfirm={async () => {
try {
await api.put(`/organizations/${record.id}`, { status: 'active' });
message.success('机构已恢复');
await fetchData();
} catch (error: any) {
message.error(error?.message || '恢复失败');
}
}}
>
<PermissionButton
permission="organization:edit"
size="small"
type="link"
icon={<UndoOutlined />}
>
</PermissionButton>
</Popconfirm>
) : (
<>
<PermissionButton <PermissionButton
permission="organization:edit" permission="organization:edit"
size="small" size="small"
@@ -155,7 +179,7 @@ const OrganizationsPage: React.FC = () => {
> >
</PermissionButton> </PermissionButton>
{!record.isHost && record.status === 'active' ? ( {!record.isHost ? (
<Popconfirm <Popconfirm
title="归档后仍保留历史学生、入住和租赁记录" title="归档后仍保留历史学生、入住和租赁记录"
onConfirm={async () => { onConfirm={async () => {
@@ -177,6 +201,8 @@ const OrganizationsPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</Popconfirm> </Popconfirm>
) : null} ) : null}
</>
)}
</Space> </Space>
), ),
}, },

View File

@@ -88,6 +88,7 @@ const RoomsPage: React.FC = () => {
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [filterBuilding, setFilterBuilding] = useState<string | undefined>(undefined); const [filterBuilding, setFilterBuilding] = useState<string | undefined>(undefined);
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined); const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterRentalCategory, setFilterRentalCategory] = useState<string | undefined>(undefined);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]); const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -154,8 +155,11 @@ const RoomsPage: React.FC = () => {
} }
if (filterBuilding) result = result.filter((r: Record<string, unknown>) => r.building === filterBuilding); if (filterBuilding) result = result.filter((r: Record<string, unknown>) => r.building === filterBuilding);
if (filterStatus) result = result.filter((r: Record<string, unknown>) => r.status === filterStatus); if (filterStatus) result = result.filter((r: Record<string, unknown>) => r.status === filterStatus);
if (filterRentalCategory) {
result = result.filter((r: Record<string, unknown>) => r.rentalCategory === filterRentalCategory);
}
return result; return result;
}, [data, searchText, filterBuilding, filterStatus]); }, [data, searchText, filterBuilding, filterStatus, filterRentalCategory]);
const remainingBedSlots = useMemo(() => { const remainingBedSlots = useMemo(() => {
const capacity = Number(drawerRoom?.capacity) || 0; const capacity = Number(drawerRoom?.capacity) || 0;
return Math.max(capacity - beds.length, 0); return Math.max(capacity - beds.length, 0);
@@ -407,16 +411,8 @@ const RoomsPage: React.FC = () => {
return ( return (
<div> <div>
<div <div className="responsive-toolbar">
style={{ <Space wrap className="responsive-toolbar__group">
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<h3 style={{ margin: 0 }}>宿</h3> <h3 style={{ margin: 0 }}>宿</h3>
<Input.Search <Input.Search
placeholder="搜索房间号" placeholder="搜索房间号"
@@ -434,6 +430,17 @@ const RoomsPage: React.FC = () => {
/> />
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus} <Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus}
options={[{value:'available',label:'可入住'},{value:'full',label:'已满'},{value:'maintenance',label:'维护中'}]} /> options={[{value:'available',label:'可入住'},{value:'full',label:'已满'},{value:'maintenance',label:'维护中'}]} />
<Select
placeholder="租赁类型"
allowClear
style={{ width: 120 }}
value={filterRentalCategory}
onChange={setFilterRentalCategory}
options={[
{ value: 'long', label: '长租' },
{ value: 'short', label: '短租' },
]}
/>
<Button <Button
type={showArchived ? 'primary' : 'default'} type={showArchived ? 'primary' : 'default'}
onClick={() => setShowArchived(!showArchived)} onClick={() => setShowArchived(!showArchived)}
@@ -443,7 +450,7 @@ const RoomsPage: React.FC = () => {
: `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`} : `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
</Button> </Button>
</Space> </Space>
<Space wrap> <Space wrap className="responsive-toolbar__group">
<Popconfirm <Popconfirm
title={`确定批量归档选中的 ${selectedRowKeys.length} 间宿舍?(有在住人员的会跳过)`} title={`确定批量归档选中的 ${selectedRowKeys.length} 间宿舍?(有在住人员的会跳过)`}
onConfirm={handleBatchDelete} onConfirm={handleBatchDelete}

View File

@@ -6,6 +6,7 @@ import {
Modal, Modal,
Form, Form,
Input, Input,
InputNumber,
DatePicker, DatePicker,
TimePicker, TimePicker,
Popconfirm, Popconfirm,
@@ -53,6 +54,7 @@ interface ClassScheduleItem {
weekDay: number; weekDay: number;
startTime: string; startTime: string;
endTime: string; endTime: string;
attendanceAdvanceMinutes: number;
startDate: string; startDate: string;
endDate: string; endDate: string;
subject: string; subject: string;
@@ -351,7 +353,7 @@ const SchedulesPage: React.FC = () => {
setEditingSchedule(null); setEditingSchedule(null);
setModalMode('create'); setModalMode('create');
form.resetFields(); form.resetFields();
form.setFieldsValue({ classroomId, weekDay }); form.setFieldsValue({ classroomId, weekDay, attendanceAdvanceMinutes: 30 });
setModalOpen(true); setModalOpen(true);
} }
}; };
@@ -960,9 +962,28 @@ const SchedulesPage: React.FC = () => {
/> />
</Form.Item> </Form.Item>
<Form.Item
name="attendanceAdvanceMinutes"
label="课前签到时间"
tooltip="从上课前指定分钟开始,到下课时间结束;期间任意上班或下班打卡都计为出勤"
initialValue={30}
rules={[{ required: true, message: '请设置课前签到时间' }]}
>
<InputNumber
min={0}
max={1440}
step={5}
addonAfter="分钟"
style={{ width: '100%' }}
placeholder="例如 30"
/>
</Form.Item>
<Form.Item <Form.Item
name="timeRange" name="timeRange"
label="上课时段" label="上课时段"
tooltip="同一教室的前后两节排课必须至少间隔10分钟"
extra="系统按10分钟选择时间并为相邻排课强制预留至少10分钟。"
rules={[{ required: true, message: '请选择时段' }]} rules={[{ required: true, message: '请选择时段' }]}
> >
<TimePicker.RangePicker <TimePicker.RangePicker
@@ -1008,6 +1029,7 @@ const SchedulesPage: React.FC = () => {
weekDay: weekDay:
selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined), selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined),
dateRange: selectedDate ? [selectedDate, selectedDate] : undefined, dateRange: selectedDate ? [selectedDate, selectedDate] : undefined,
attendanceAdvanceMinutes: 30,
}); });
}} }}
> >
@@ -1054,6 +1076,12 @@ const SchedulesPage: React.FC = () => {
<strong></strong> <strong></strong>
{s.startTime} ~ {s.endTime} {s.startTime} ~ {s.endTime}
</div> </div>
{!isMaskedSchedule(s) && (
<div>
<strong></strong>
{s.attendanceAdvanceMinutes ?? 30}
</div>
)}
<div> <div>
<strong></strong> <strong></strong>
{s.startDate} ~ {s.endDate} {s.startDate} ~ {s.endDate}

View File

@@ -16,11 +16,13 @@ describe('schedule edit form mapping', () => {
startDate: '2026-07-01', startDate: '2026-07-01',
endDate: '2026-07-31', endDate: '2026-07-31',
notes: '需要投影设备', notes: '需要投影设备',
attendanceAdvanceMinutes: 45,
}); });
expect(values.classroomId).toBe(1); expect(values.classroomId).toBe(1);
expect(values.weekDay).toBe(5); expect(values.weekDay).toBe(5);
expect(values.notes).toBe('需要投影设备'); expect(values.notes).toBe('需要投影设备');
expect(values.attendanceAdvanceMinutes).toBe(45);
expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']); expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([ expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
'2026-07-01', '2026-07-01',
@@ -39,6 +41,7 @@ describe('schedule edit form mapping', () => {
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')], timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')], dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
notes: ' 临时调整教室 ', notes: ' 临时调整教室 ',
attendanceAdvanceMinutes: 20,
}), }),
).toEqual({ ).toEqual({
classId: 1, classId: 1,
@@ -51,6 +54,7 @@ describe('schedule edit form mapping', () => {
startDate: '2026-08-01', startDate: '2026-08-01',
endDate: '2026-08-31', endDate: '2026-08-31',
notes: '临时调整教室', notes: '临时调整教室',
attendanceAdvanceMinutes: 20,
}); });
}); });
}); });
@@ -67,6 +71,7 @@ describe('schedule notes normalization', () => {
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')], timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')], dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
notes: ' ', notes: ' ',
attendanceAdvanceMinutes: 30,
}).notes, }).notes,
).toBeUndefined(); ).toBeUndefined();
}); });

View File

@@ -7,6 +7,7 @@ export interface ScheduleFormValues {
subject: string; subject: string;
teacherId?: number; teacherId?: number;
notes?: string; notes?: string;
attendanceAdvanceMinutes: number;
timeRange: [Dayjs, Dayjs]; timeRange: [Dayjs, Dayjs];
dateRange: [Dayjs, Dayjs]; dateRange: [Dayjs, Dayjs];
} }
@@ -19,6 +20,7 @@ export interface EditableSchedule {
subject: string; subject: string;
teacherId: number | null; teacherId: number | null;
notes?: string | null; notes?: string | null;
attendanceAdvanceMinutes?: number | null;
startTime: string; startTime: string;
endTime: string; endTime: string;
startDate: string; startDate: string;
@@ -32,6 +34,7 @@ export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormVa
subject: schedule.subject, subject: schedule.subject,
teacherId: schedule.teacherId ?? undefined, teacherId: schedule.teacherId ?? undefined,
notes: schedule.notes ?? undefined, notes: schedule.notes ?? undefined,
attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes ?? 30,
timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)], timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)], dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
}); });
@@ -43,6 +46,7 @@ export const buildSchedulePayload = (values: ScheduleFormValues) => ({
subject: values.subject, subject: values.subject,
teacherId: values.teacherId, teacherId: values.teacherId,
notes: values.notes?.trim() || undefined, notes: values.notes?.trim() || undefined,
attendanceAdvanceMinutes: values.attendanceAdvanceMinutes,
startTime: values.timeRange[0].format('HH:mm'), startTime: values.timeRange[0].format('HH:mm'),
endTime: values.timeRange[1].format('HH:mm'), endTime: values.timeRange[1].format('HH:mm'),
startDate: values.dateRange[0].format('YYYY-MM-DD'), startDate: values.dateRange[0].format('YYYY-MM-DD'),

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Alert,
App, App,
Button, Button,
Card, Card,
@@ -62,6 +63,19 @@ interface EnrollmentInfo {
}; };
} }
interface StudentCreateImportResult {
message?: string;
imported?: number;
skipped?: number;
}
interface StudentUpdateImportResult {
message?: string;
matched?: number;
skipped?: number;
}
const StudentsPage: React.FC = () => { const StudentsPage: React.FC = () => {
const { modal } = App.useApp(); const { modal } = App.useApp();
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<any[]>([]);
@@ -221,20 +235,94 @@ const StudentsPage: React.FC = () => {
.catch(() => message.error('下载失败')); .catch(() => message.error('下载失败'));
}; };
const handleMatchImport: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => { const showCreateImportResult = (result: StudentCreateImportResult) => {
const imported = result.imported ?? 0;
const skipped = result.skipped ?? 0;
modal.success({
title: '导入完成',
okText: '知道了',
content: (
<div>
<Descriptions column={1} size="small">
<Descriptions.Item label="成功新增">{imported} </Descriptions.Item>
<Descriptions.Item label="跳过">{skipped} </Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 12, fontWeight: 600 }}></div>
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
<li></li>
<li></li>
</ul>
<div style={{ marginTop: 8, color: '#8c8c8c', fontSize: 12 }}>
</div>
</div>
),
});
};
const showUpdateImportResult = (result: StudentUpdateImportResult) => {
const matched = result.matched ?? 0;
const skipped = result.skipped ?? 0;
modal.success({
title: '更新完成',
okText: '知道了',
content: (
<div>
<Descriptions column={1} size="small">
<Descriptions.Item label="成功更新">{matched} </Descriptions.Item>
<Descriptions.Item label="未匹配">{skipped} </Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 12, fontWeight: 600 }}></div>
<div></div>
<div style={{ marginTop: 8, color: '#8c8c8c', fontSize: 12 }}>
</div>
</div>
),
});
};
const handleCreateStudentsImport: UploadProps['customRequest'] = async ({
file,
onSuccess,
onError,
}) => {
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);
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 || '导入失败'));
}
};
const handleUpdateExistingStudentsImport: UploadProps['customRequest'] = async ({
file,
onSuccess,
onError,
}) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file as File); formData.append('file', file as File);
try { try {
const res = (await api.post('/students/import-match', formData, { const res = (await api.post('/students/import-match', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
})) as { message: string }; })) as StudentUpdateImportResult;
message.success(res.message); showUpdateImportResult(res);
onSuccess?.(res); onSuccess?.(res);
fetchData(); fetchData();
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string }; const err = e as { message?: string };
message.error(err?.message || '匹配导入失败'); message.error(err?.message || '更新已有学生资料失败');
onError?.(e instanceof Error ? e : new Error(err?.message || '匹配导入失败')); onError?.(e instanceof Error ? e : new Error(err?.message || '更新已有学生资料失败'));
} }
}; };
@@ -277,12 +365,12 @@ const StudentsPage: React.FC = () => {
render: (v: string, record: any) => { render: (v: string, record: any) => {
if (!v) return '-'; if (!v) return '-';
return ( return (
<span> <span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span> <span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
<Button <Button
type="link" type="link"
size="small" size="small"
style={{ padding: '8px 4px' }} style={{ padding: '8px 4px', flex: 'none' }}
onClick={() => handleViewSensitive(record.id, '电话', v)} onClick={() => handleViewSensitive(record.id, '电话', v)}
title="点击查看完整号码" title="点击查看完整号码"
> >
@@ -305,12 +393,12 @@ const StudentsPage: React.FC = () => {
render: (v: string, record: any) => { render: (v: string, record: any) => {
if (!v) return '-'; if (!v) return '-';
return ( return (
<span> <span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span> <span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
<Button <Button
type="link" type="link"
size="small" size="small"
style={{ padding: '8px 4px' }} style={{ padding: '8px 4px', flex: 'none' }}
onClick={() => handleViewSensitive(record.id, '身份证号', v)} onClick={() => handleViewSensitive(record.id, '身份证号', v)}
title="点击查看完整号码" title="点击查看完整号码"
> >
@@ -329,12 +417,12 @@ const StudentsPage: React.FC = () => {
render: (v: string, record: any) => { render: (v: string, record: any) => {
if (!v) return '-'; if (!v) return '-';
return ( return (
<span> <span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span> <span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
<Button <Button
type="link" type="link"
size="small" size="small"
style={{ padding: '8px 4px' }} style={{ padding: '8px 4px', flex: 'none' }}
onClick={() => handleViewSensitive(record.id, '紧急联系人电话', v)} onClick={() => handleViewSensitive(record.id, '紧急联系人电话', v)}
title="点击查看完整号码" title="点击查看完整号码"
> >
@@ -441,16 +529,8 @@ const StudentsPage: React.FC = () => {
return ( return (
<div> <div>
<div <div className="responsive-toolbar">
style={{ <Space wrap className="responsive-toolbar__group">
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search <Input.Search
placeholder="搜索学生姓名" placeholder="搜索学生姓名"
onSearch={setSearchName} onSearch={setSearchName}
@@ -498,7 +578,7 @@ const StudentsPage: React.FC = () => {
: `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`} : `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
</Button> </Button>
</Space> </Space>
<Space wrap> <Space wrap className="responsive-toolbar__group">
<Popconfirm <Popconfirm
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`} title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
onConfirm={handleBatchDelete} onConfirm={handleBatchDelete}
@@ -530,29 +610,15 @@ const StudentsPage: React.FC = () => {
> >
</PermissionButton> </PermissionButton>
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={handleCreateStudentsImport}>
<Button icon={<UploadOutlined />}>Excel</Button>
</Upload>
<Upload <Upload
accept=".xlsx,.xls" accept=".xlsx,.xls"
showUploadList={false} showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => { customRequest={handleUpdateExistingStudentsImport}
const formData = new FormData();
formData.append('file', file);
try {
const res: any = await api.post('/students/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success(res.message);
onSuccess?.(res);
fetchData();
} catch (e: any) {
message.error(e?.message || '导入失败');
onError?.(e instanceof Error ? e : new Error(e?.message || '导入失败'));
}
}}
> >
<Button icon={<UploadOutlined />}>Excel</Button> <Button icon={<SwapOutlined />}></Button>
</Upload>
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={handleMatchImport}>
<Button icon={<SwapOutlined />}></Button>
</Upload> </Upload>
<PermissionButton <PermissionButton
permission="student:view" permission="student:view"
@@ -570,6 +636,17 @@ const StudentsPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</Space> </Space>
</div> </div>
<Alert
showIcon
type="warning"
style={{ marginBottom: 12 }}
message={
<span>
<strong></strong>Excel
</span>
}
/>
<Table <Table
columns={columns} columns={columns}
dataSource={data} dataSource={data}

View File

@@ -181,7 +181,7 @@ const TeachersPage: React.FC = () => {
return ( return (
<div> <div>
<h2 style={{ marginBottom: 16 }}></h2> <h2 style={{ marginBottom: 16 }}></h2>
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }} wrap className="responsive-toolbar responsive-toolbar--single">
<Input.Search <Input.Search
placeholder="搜索姓名/用户名" placeholder="搜索姓名/用户名"
allowClear allowClear

View File

@@ -10,7 +10,7 @@ import {
Tag, Tag,
Popconfirm, Popconfirm,
} from 'antd'; } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, IdcardOutlined, InboxOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, KeyOutlined, IdcardOutlined, InboxOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
@@ -141,18 +141,6 @@ const UsersPage: React.FC = () => {
} }
}; };
const handleDelete = async (id: number) => {
try {
await api.delete(`/rbac/users/${id}`);
message.success('已删除');
fetchData();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '删除失败');
}
};
const handleResetPwd = (record: any) => { const handleResetPwd = (record: any) => {
setResetTarget(record); setResetTarget(record);
pwdForm.resetFields(); pwdForm.resetFields();
@@ -253,13 +241,6 @@ const UsersPage: React.FC = () => {
<PermissionButton permission="user:edit" type="link" size="small"></PermissionButton> <PermissionButton permission="user:edit" type="link" size="small"></PermissionButton>
</Popconfirm> </Popconfirm>
)} )}
{record.username !== 'admin' && (
<Popconfirm title="确认删除?需先归档" onConfirm={() => handleDelete(record.id)}>
<PermissionButton permission="user:delete" type="link" size="small" danger icon={<DeleteOutlined />}>
</PermissionButton>
</Popconfirm>
)}
</Space> </Space>
), ),
}, },

View File

@@ -0,0 +1,165 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Drawer, Form, Input, InputNumber, Modal, Radio, Space, Switch, Table, Tag } from 'antd';
import { HistoryOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
interface WalletRow {
studentId: number;
studentName: string;
studentNo?: string;
balance: number;
outstandingAmount: number;
}
const transactionNames: Record<string, string> = {
recharge: '充值', adjustment: '调账', bill_payment: '账单扣款', bill_refund: '账单冲正',
};
const WalletsPage: React.FC = () => {
const [rows, setRows] = useState<WalletRow[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [debtOnly, setDebtOnly] = useState(false);
const [selected, setSelected] = useState<WalletRow | null>(null);
const [transactions, setTransactions] = useState<any[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [form] = Form.useForm();
const [batchForm] = Form.useForm();
const [saving, setSaving] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchModalOpen, setBatchModalOpen] = useState(false);
const fetchRows = useCallback(async () => {
setLoading(true);
try {
const data = await api.get('/wallets', { params: { keyword: keyword || undefined, debtOnly } });
setRows(data as WalletRow[]);
} catch (error: any) {
message.error(error?.message || '加载学生余额失败');
} finally { setLoading(false); }
}, [keyword, debtOnly]);
useEffect(() => { void fetchRows(); }, [fetchRows]);
const openChange = (row: WalletRow) => {
setSelected(row);
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
};
const openBatchChange = () => {
batchForm.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
setBatchModalOpen(true);
};
const submitChange = async () => {
if (!selected) return;
const values = await form.validateFields();
setSaving(true);
try {
const result: any = await api.post('/wallets/change-balance', { studentId: selected.studentId, ...values });
const paid = (result.payments || []).reduce((sum: number, bill: any) => sum + Number(bill.paidAmount || 0), 0);
message.success(paid > 0 ? `余额已更新,并自动补扣历史账单` : '余额已更新');
setSelected(null);
await fetchRows();
} catch (error: any) { message.error(error?.message || '余额操作失败'); }
finally { setSaving(false); }
};
const submitBatchChange = async () => {
const values = await batchForm.validateFields();
setSaving(true);
try {
const result: any = await api.post('/wallets/batch-change-balance', {
studentIds: selectedRowKeys,
...values,
});
const paid = (result.results || []).reduce((sum: number, item: any) => {
return sum + (item.payments || []).reduce((paymentSum: number, bill: any) => paymentSum + Number(bill.paidAmount || 0), 0);
}, 0);
message.success(
paid > 0
? `已批量更新 ${selectedRowKeys.length} 名学生余额,并自动补扣历史账单`
: `已批量更新 ${selectedRowKeys.length} 名学生余额`,
);
setBatchModalOpen(false);
setSelectedRowKeys([]);
batchForm.resetFields();
await fetchRows();
} catch (error: any) { message.error(error?.message || '批量余额操作失败'); }
finally { setSaving(false); }
};
const showTransactions = async (row: WalletRow) => {
setSelected(row); setDrawerOpen(true);
try { setTransactions(await api.get('/wallets/transactions', { params: { studentId: row.studentId } }) as any[]); }
catch (error: any) { message.error(error?.message || '加载流水失败'); }
};
const columns = useMemo(() => [
{ title: '学生', render: (_: unknown, row: WalletRow) => <><strong>{row.studentName}</strong><div style={{ color: '#999' }}>{row.studentNo || `#${row.studentId}`}</div></> },
{ title: '可用余额', dataIndex: 'balance', render: (value: number) => <strong style={{ color: Number(value) > 0 ? '#1677ff' : undefined }}>¥{Number(value).toFixed(2)}</strong> },
{ title: '未付账单', dataIndex: 'outstandingAmount', render: (value: number) => Number(value) > 0 ? <Tag color="red">¥{Number(value).toFixed(2)}</Tag> : <Tag color="green"></Tag> },
{ title: '操作', render: (_: unknown, row: WalletRow) => <Space><PermissionButton permission="wallet:edit" type="primary" size="small" icon={<PlusOutlined />} onClick={() => openChange(row)}>/</PermissionButton><Button size="small" icon={<HistoryOutlined />} onClick={() => showTransactions(row)}></Button></Space> },
], []);
return <div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Space wrap><Input.Search allowClear placeholder="搜索姓名或学号" style={{ width: 240 }} onSearch={setKeyword} onChange={(event) => !event.target.value && setKeyword('')} /><span></span><Switch checked={debtOnly} onChange={setDebtOnly} /></Space>
<Space wrap>
<PermissionButton permission="wallet:edit" type="primary" icon={<PlusOutlined />} disabled={selectedRowKeys.length === 0} onClick={openBatchChange}>/</PermissionButton>
<Button icon={<ReloadOutlined />} onClick={fetchRows}></Button>
</Space>
</div>
<Table
rowKey="studentId"
loading={loading}
dataSource={rows}
columns={columns}
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
/>
<Modal title={`${selected?.studentName || ''} - 余额操作`} open={!!selected && !drawerOpen} onCancel={() => setSelected(null)} onOk={submitChange} confirmLoading={saving} okText="确认">
<Form form={form} layout="vertical">
<Form.Item name="type" label="操作类型" rules={[{ required: true }]}><Radio.Group options={[{ label: '充值', value: 'recharge' }, { label: '调账', value: 'adjustment' }]} /></Form.Item>
<Form.Item name="amount" label="变动金额" extra="充值填正数;调减余额时填写负数。充值后会按最早账单优先自动补扣。" rules={[{ required: true, message: '请输入金额' }]}><InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" /></Form.Item>
<Form.Item name="description" label="备注"><Input.TextArea maxLength={300} /></Form.Item>
</Form>
</Modal>
<Modal
title={`批量余额操作(${selectedRowKeys.length} 人)`}
open={batchModalOpen}
onCancel={() => setBatchModalOpen(false)}
onOk={submitBatchChange}
confirmLoading={saving}
okText="确认批量修改"
>
<Form form={batchForm} layout="vertical">
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
<strong>{selectedRowKeys.length}</strong>
</div>
<Form.Item name="type" label="操作类型" rules={[{ required: true }]}>
<Radio.Group options={[{ label: '充值', value: 'recharge' }, { label: '调账', value: 'adjustment' }]} />
</Form.Item>
<Form.Item name="amount" label="变动金额(元/人)" extra="充值填正数;调减余额时填写负数。充值后会按最早账单优先自动补扣。" rules={[{ required: true, message: '请输入金额' }]}>
<InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" />
</Form.Item>
<Form.Item name="description" label="备注"><Input.TextArea maxLength={300} /></Form.Item>
</Form>
</Modal>
<Drawer title={`${selected?.studentName || ''} - 余额流水`} width={680} open={drawerOpen} onClose={() => { setDrawerOpen(false); setSelected(null); }}>
<Table rowKey="id" dataSource={transactions} pagination={{ pageSize: 10 }} columns={[
{ title: '时间', dataIndex: 'createdAt', render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm') },
{ title: '类型', dataIndex: 'type', render: (value: string) => transactionNames[value] || value },
{ title: '金额', dataIndex: 'amount', render: (value: number) => <span style={{ color: Number(value) >= 0 ? '#389e0d' : '#cf1322' }}>{Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)}</span> },
{ title: '变动后余额', dataIndex: 'balanceAfter', render: (value: number) => `¥${Number(value).toFixed(2)}` },
{ title: '关联账单', dataIndex: 'billId', render: (value: number) => value ? `#${value}` : '-' },
{ title: '说明', dataIndex: 'description' },
]} />
</Drawer>
</div>;
};
export default WalletsPage;

View File

@@ -220,6 +220,6 @@ export const PERMISSION_NODES = [
'report:generate', 'report:generate',
'log:view', 'log:view',
'role:view', 'role:add', 'role:update', 'role:delete', 'role:view', 'role:add', 'role:update', 'role:delete',
'user:view', 'user:add', 'user:update', 'user:delete', 'user:view', 'user:add', 'user:update',
'dashboard:view', 'dashboard:view',
] as const; ] as const;

View File

@@ -22,7 +22,7 @@ JWT_EXPIRES_IN=24h
ADMIN_PASSWORD=请替换为强密码 ADMIN_PASSWORD=请替换为强密码
# ---- 服务端口 ---- # ---- 服务端口 ----
PORT=3000 PORT=3002
# ---- 文件上传 ---- # ---- 文件上传 ----
# 合同 PDF 存储根目录(相对或绝对) # 合同 PDF 存储根目录(相对或绝对)

View File

@@ -30,6 +30,7 @@ import {
ClassSchedule, ClassSchedule,
AttendanceRecord, AttendanceRecord,
AttendanceSession, AttendanceSession,
AttendanceDevice,
DingAttendanceRaw, DingAttendanceRaw,
SyncLog, SyncLog,
SyncState, SyncState,
@@ -43,6 +44,8 @@ import {
ArchiveAttachment, ArchiveAttachment,
StudentDingMapping, StudentDingMapping,
AiConfig, AiConfig,
StudentWallet,
WalletTransaction,
} from './entities'; } from './entities';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { AuthorizationModule } from './authorization'; import { AuthorizationModule } from './authorization';
@@ -62,6 +65,7 @@ import { ClassroomsModule } from './classrooms/classrooms.module';
import { ClassesModule } from './classes/classes.module'; import { ClassesModule } from './classes/classes.module';
import { OrganizationsModule } from './organizations/organizations.module'; import { OrganizationsModule } from './organizations/organizations.module';
import { AttendanceModule } from './attendance/attendance.module'; import { AttendanceModule } from './attendance/attendance.module';
import { AttendanceDevicesModule } from './attendance-devices/attendance-devices.module';
import { SchedulesModule } from './schedules/schedules.module'; import { SchedulesModule } from './schedules/schedules.module';
import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module'; import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module';
import { SyncModule } from './sync/sync.module'; import { SyncModule } from './sync/sync.module';
@@ -71,6 +75,7 @@ import { ExpenseTypesModule } from './expense-types/expense-types.module';
import { DatabaseMigrationsModule } from './database/database-migrations.module'; import { DatabaseMigrationsModule } from './database/database-migrations.module';
import { AgentToolsModule } from './agent-tools'; import { AgentToolsModule } from './agent-tools';
import { AiConfigModule } from './ai-config/ai-config.module'; import { AiConfigModule } from './ai-config/ai-config.module';
import { WalletsModule } from './wallets/wallets.module';
import { import {
IntegrationConfig, IntegrationConfig,
@@ -120,6 +125,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
ClassSchedule, ClassSchedule,
AttendanceRecord, AttendanceRecord,
AttendanceSession, AttendanceSession,
AttendanceDevice,
DingAttendanceRaw, DingAttendanceRaw,
Notification, Notification,
StudentProfile, StudentProfile,
@@ -135,6 +141,8 @@ import { IntegrationConfigModule } from './integration/config/config.module';
IntegrationConfig, IntegrationConfig,
IntegrationConfigDetail, IntegrationConfigDetail,
AiConfig, AiConfig,
StudentWallet,
WalletTransaction,
]; ];
if (dbType === 'mysql') { if (dbType === 'mysql') {
return { return {
@@ -168,8 +176,10 @@ import { IntegrationConfigModule } from './integration/config/config.module';
DashboardModule, DashboardModule,
OperationLogsModule, OperationLogsModule,
DepositsModule, DepositsModule,
WalletsModule,
ClassroomsModule, ClassroomsModule,
AttendanceModule, AttendanceModule,
AttendanceDevicesModule,
ClassesModule, ClassesModule,
OrganizationsModule, OrganizationsModule,
SchedulesModule, SchedulesModule,

View File

@@ -0,0 +1,102 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ArchiveService } from './archive.service';
function createService(repos: Partial<Record<string, Record<string, jest.Mock>>> = {}) {
return new ArchiveService(
(repos.student ?? {}) as never,
(repos.profile ?? {}) as never,
(repos.enrollment ?? {}) as never,
(repos.exam ?? {}) as never,
(repos.learning ?? {}) as never,
(repos.result ?? {}) as never,
(repos.attachment ?? {}) as never,
(repos.attendance ?? {}) as never,
{} as never,
);
}
describe('ArchiveService — resource and relationship boundaries', () => {
it('rejects adding archive records for a missing student', async () => {
const student = { findOne: jest.fn().mockResolvedValue(null) };
const service = createService({ student });
await expect(
service.addEnrollment(404, { courseCategory: '文化', classType: '冲刺' }),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
service.addLearningRecord(404, {
recordDate: '2026-07-14',
recordType: '沟通',
content: '内容',
}),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects linking an exam score to another student enrollment', async () => {
const exam = { create: jest.fn(), save: jest.fn() };
const service = createService({
student: { findOne: jest.fn().mockResolvedValue({ id: 7 }) },
enrollment: { findOne: jest.fn().mockResolvedValue(null) },
exam,
});
await expect(
service.addExamScore(7, {
examType: '月考',
subject: '语文',
score: 90,
enrollmentId: 99,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(exam.save).not.toHaveBeenCalled();
});
it('rejects moving an existing exam score to another student enrollment', async () => {
const exam = {
findOne: jest.fn().mockResolvedValue({ id: 3, studentId: 7, enrollmentId: 1 }),
save: jest.fn(),
};
const service = createService({
enrollment: { findOne: jest.fn().mockResolvedValue(null) },
exam,
});
await expect(service.updateExamScore(3, { enrollmentId: 99 })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(exam.save).not.toHaveBeenCalled();
});
it('rejects a missing attachment upload before writing to disk', async () => {
const service = createService({ student: { findOne: jest.fn() } });
await expect(service.addAttachment(7, undefined as never, 'other')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('rejects attachment path traversal', async () => {
const service = createService({
attachment: {
findOne: jest.fn().mockResolvedValue({
id: 1,
studentId: 7,
filePath: '../../etc/passwd',
}),
},
});
await expect(service.getAttachmentFile(7, 1)).rejects.toBeInstanceOf(BadRequestException);
});
it('returns not found for update/delete of absent child records', async () => {
const service = createService({
enrollment: { findOne: jest.fn().mockResolvedValue(null) },
exam: { findOne: jest.fn().mockResolvedValue(null) },
learning: { findOne: jest.fn().mockResolvedValue(null) },
attachment: { findOne: jest.fn().mockResolvedValue(null) },
});
await expect(service.updateEnrollment(1, {})).rejects.toBeInstanceOf(NotFoundException);
await expect(service.deleteExamScore(1)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.deleteLearningRecord(1)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.deleteAttachment(1)).rejects.toBeInstanceOf(NotFoundException);
});
});

View File

@@ -11,6 +11,7 @@ import {
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
Res, Res,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import type { Request as ExpressRequest, Response } from 'express'; import type { Request as ExpressRequest, Response } from 'express';
@@ -47,15 +48,15 @@ export class ArchiveController {
@Get(':studentId') @Get(':studentId')
@RequirePermission('student:view') @RequirePermission('student:view')
async getProfile(@Param('studentId') studentId: string, @Request() req: AuthenticatedRequest) { async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.getProfile(+studentId); const result = await this.archiveService.getProfile(studentId);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '查看档案', action: '查看档案',
targetId: +studentId, targetId: studentId,
targetType: 'archive', targetType: 'archive',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -66,18 +67,18 @@ export class ArchiveController {
@Put(':studentId/profile') @Put(':studentId/profile')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async upsertProfile( async upsertProfile(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Body() dto: UpsertProfileDto, @Body() dto: UpsertProfileDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.upsertProfile(+studentId, dto); const result = await this.archiveService.upsertProfile(studentId, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '更新档案信息', action: '更新档案信息',
targetId: +studentId, targetId: studentId,
targetType: 'student_profile', targetType: 'student_profile',
detail: JSON.stringify(dto), detail: JSON.stringify(dto),
ipAddress, ipAddress,
@@ -89,12 +90,12 @@ export class ArchiveController {
@Post(':studentId/enrollments') @Post(':studentId/enrollments')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async addEnrollment( async addEnrollment(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Body() dto: CreateEnrollmentDto, @Body() dto: CreateEnrollmentDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.addEnrollment(+studentId, dto); const result = await this.archiveService.addEnrollment(studentId, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
@@ -112,18 +113,18 @@ export class ArchiveController {
@Put('enrollments/:id') @Put('enrollments/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async updateEnrollment( async updateEnrollment(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateEnrollmentDto, @Body() dto: UpdateEnrollmentDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.updateEnrollment(+id, dto); const result = await this.archiveService.updateEnrollment(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '编辑报名记录', action: '编辑报名记录',
targetId: +id, targetId: id,
targetType: 'student_enrollment', targetType: 'student_enrollment',
detail: JSON.stringify(dto), detail: JSON.stringify(dto),
ipAddress, ipAddress,
@@ -134,15 +135,15 @@ export class ArchiveController {
@Delete('enrollments/:id') @Delete('enrollments/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async deleteEnrollment(@Param('id') id: string, @Request() req: AuthenticatedRequest) { async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.deleteEnrollment(+id); const result = await this.archiveService.deleteEnrollment(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '删除报名记录', action: '删除报名记录',
targetId: +id, targetId: id,
targetType: 'student_enrollment', targetType: 'student_enrollment',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -153,12 +154,12 @@ export class ArchiveController {
@Post(':studentId/exam-scores') @Post(':studentId/exam-scores')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async addExamScore( async addExamScore(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Body() dto: CreateExamScoreDto, @Body() dto: CreateExamScoreDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.addExamScore(+studentId, dto); const result = await this.archiveService.addExamScore(studentId, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
@@ -176,18 +177,18 @@ export class ArchiveController {
@Put('exam-scores/:id') @Put('exam-scores/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async updateExamScore( async updateExamScore(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateExamScoreDto, @Body() dto: UpdateExamScoreDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.updateExamScore(+id, dto); const result = await this.archiveService.updateExamScore(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '编辑考试成绩', action: '编辑考试成绩',
targetId: +id, targetId: id,
targetType: 'exam_score', targetType: 'exam_score',
detail: JSON.stringify(dto), detail: JSON.stringify(dto),
ipAddress, ipAddress,
@@ -198,15 +199,15 @@ export class ArchiveController {
@Delete('exam-scores/:id') @Delete('exam-scores/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async deleteExamScore(@Param('id') id: string, @Request() req: AuthenticatedRequest) { async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.deleteExamScore(+id); const result = await this.archiveService.deleteExamScore(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '删除考试成绩', action: '删除考试成绩',
targetId: +id, targetId: id,
targetType: 'exam_score', targetType: 'exam_score',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -217,12 +218,12 @@ export class ArchiveController {
@Post(':studentId/learning-records') @Post(':studentId/learning-records')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async addLearningRecord( async addLearningRecord(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Body() dto: CreateLearningRecordDto, @Body() dto: CreateLearningRecordDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.addLearningRecord(+studentId, dto); const result = await this.archiveService.addLearningRecord(studentId, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
@@ -240,18 +241,18 @@ export class ArchiveController {
@Put('learning-records/:id') @Put('learning-records/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async updateLearningRecord( async updateLearningRecord(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateLearningRecordDto, @Body() dto: UpdateLearningRecordDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.updateLearningRecord(+id, dto); const result = await this.archiveService.updateLearningRecord(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '编辑学习记录', action: '编辑学习记录',
targetId: +id, targetId: id,
targetType: 'learning_record', targetType: 'learning_record',
detail: JSON.stringify(dto), detail: JSON.stringify(dto),
ipAddress, ipAddress,
@@ -262,15 +263,15 @@ export class ArchiveController {
@Delete('learning-records/:id') @Delete('learning-records/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async deleteLearningRecord(@Param('id') id: string, @Request() req: AuthenticatedRequest) { async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.deleteLearningRecord(+id); const result = await this.archiveService.deleteLearningRecord(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '删除学习记录', action: '删除学习记录',
targetId: +id, targetId: id,
targetType: 'learning_record', targetType: 'learning_record',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -281,18 +282,18 @@ export class ArchiveController {
@Put(':studentId/result') @Put(':studentId/result')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async upsertResult( async upsertResult(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Body() dto: UpsertResultDto, @Body() dto: UpsertResultDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.upsertResult(+studentId, dto); const result = await this.archiveService.upsertResult(studentId, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '更新录取结果', action: '更新录取结果',
targetId: +studentId, targetId: studentId,
targetType: 'result_archive', targetType: 'result_archive',
detail: JSON.stringify(dto), detail: JSON.stringify(dto),
ipAddress, ipAddress,
@@ -305,13 +306,13 @@ export class ArchiveController {
@RequirePermission('student:edit') @RequirePermission('student:edit')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
async uploadAttachment( async uploadAttachment(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@Body('category') category: string, @Body('category') category: string,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.addAttachment(+studentId, file, category || 'other'); const result = await this.archiveService.addAttachment(studentId, file, category || 'other');
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
@@ -329,13 +330,13 @@ export class ArchiveController {
@Get(':studentId/attachments/:id') @Get(':studentId/attachments/:id')
@RequirePermission('student:view') @RequirePermission('student:view')
async downloadAttachment( async downloadAttachment(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Res() res: Response, @Res() res: Response,
) { ) {
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile( const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(
+studentId, studentId,
+id, id,
); );
res.setHeader('Content-Type', mimeType); res.setHeader('Content-Type', mimeType);
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`); res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`);
@@ -345,15 +346,15 @@ export class ArchiveController {
@Delete('attachments/:id') @Delete('attachments/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async deleteAttachment(@Param('id') id: string, @Request() req: AuthenticatedRequest) { async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.deleteAttachment(+id); const result = await this.archiveService.deleteAttachment(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '删除附件', action: '删除附件',
targetId: +id, targetId: id,
targetType: 'archive_attachment', targetType: 'archive_attachment',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -364,7 +365,7 @@ export class ArchiveController {
@Get(':studentId/report-html') @Get(':studentId/report-html')
@RequirePermission('student:view') @RequirePermission('student:view')
async generateReportHtml( async generateReportHtml(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
@@ -373,12 +374,12 @@ export class ArchiveController {
username: req.user?.username, username: req.user?.username,
module: 'archive', module: 'archive',
action: 'generate_report_html', action: 'generate_report_html',
targetId: +studentId, targetId: studentId,
targetType: 'student', targetType: 'student',
ipAddress, ipAddress,
userAgent, userAgent,
}); });
const html = await this.reportService.generateReportHtml(+studentId); const html = await this.reportService.generateReportHtml(studentId);
return { html }; return { html };
} }
} }

View File

@@ -17,6 +17,7 @@ describe('ArchiveService.getProfile', () => {
const learningRecordRepo = { find: jest.fn().mockResolvedValue([]) }; const learningRecordRepo = { find: jest.fn().mockResolvedValue([]) };
const resultRepo = { findOne: jest.fn().mockResolvedValue(result) }; const resultRepo = { findOne: jest.fn().mockResolvedValue(result) };
const attachmentRepo = { find: jest.fn().mockResolvedValue([]) }; const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
const attendanceRepo = { find: jest.fn().mockResolvedValue([]) };
const service = new ArchiveService( const service = new ArchiveService(
studentRepo as never, studentRepo as never,
@@ -26,12 +27,13 @@ describe('ArchiveService.getProfile', () => {
learningRecordRepo as never, learningRecordRepo as never,
resultRepo as never, resultRepo as never,
attachmentRepo as never, attachmentRepo as never,
attendanceRepo as never,
{} as never, {} as never,
); );
const response = await service.getProfile(7); const response = await service.getProfile(7);
expect(response).toMatchObject({ student, result }); expect(response).toMatchObject({ student, result, attendances: [] });
expect(response).not.toHaveProperty('resultArchive'); expect(response).not.toHaveProperty('resultArchive');
}); });
}); });

View File

@@ -12,6 +12,7 @@ import { ExamScore } from '../entities/exam-score.entity';
import { LearningRecord } from '../entities/learning-record.entity'; import { LearningRecord } from '../entities/learning-record.entity';
import { ResultArchive } from '../entities/result-archive.entity'; import { ResultArchive } from '../entities/result-archive.entity';
import { ArchiveAttachment } from '../entities/archive-attachment.entity'; import { ArchiveAttachment } from '../entities/archive-attachment.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { import {
UpsertProfileDto, UpsertProfileDto,
CreateEnrollmentDto, CreateEnrollmentDto,
@@ -33,6 +34,7 @@ export class ArchiveService {
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>, @InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>, @InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>, @InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
private readonly notificationsService: NotificationsService, private readonly notificationsService: NotificationsService,
) {} ) {}
@@ -59,14 +61,26 @@ export class ArchiveService {
const student = await this.studentRepo.findOne({ where: { id: studentId } }); const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments] = const [
await Promise.all([ profileRaw,
enrollments,
examScores,
learningRecords,
resultArchive,
attachments,
attendances,
] = await Promise.all([
this.profileRepo.findOne({ where: { studentId } }), this.profileRepo.findOne({ where: { studentId } }),
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }), this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }), this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
this.resultRepo.findOne({ where: { studentId } }), this.resultRepo.findOne({ where: { studentId } }),
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
this.attendanceRepo.find({
where: { studentId },
relations: ['schedule', 'class'],
order: { attendanceDate: 'DESC', punchTime: 'DESC' },
}),
]); ]);
return { return {
@@ -77,6 +91,7 @@ export class ArchiveService {
learningRecords, learningRecords,
result: resultArchive, result: resultArchive,
attachments, attachments,
attendances,
}; };
} }
@@ -115,9 +130,18 @@ export class ArchiveService {
return { message: '已删除' }; return { message: '已删除' };
} }
private async assertEnrollmentBelongsToStudent(studentId: number, enrollmentId?: number) {
if (enrollmentId === undefined) return;
const enrollment = await this.enrollmentRepo.findOne({
where: { id: enrollmentId, studentId },
});
if (!enrollment) throw new BadRequestException('报名记录不属于该学生');
}
async addExamScore(studentId: number, dto: CreateExamScoreDto) { async addExamScore(studentId: number, dto: CreateExamScoreDto) {
const student = await this.studentRepo.findOne({ where: { id: studentId } }); const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
await this.assertEnrollmentBelongsToStudent(studentId, dto.enrollmentId);
const entity = this.examScoreRepo.create({ ...dto, studentId }); const entity = this.examScoreRepo.create({ ...dto, studentId });
return this.examScoreRepo.save(entity); return this.examScoreRepo.save(entity);
@@ -126,6 +150,7 @@ export class ArchiveService {
async updateExamScore(id: number, dto: UpdateExamScoreDto) { async updateExamScore(id: number, dto: UpdateExamScoreDto) {
const entity = await this.examScoreRepo.findOne({ where: { id } }); const entity = await this.examScoreRepo.findOne({ where: { id } });
if (!entity) throw new NotFoundException('考试成绩不存在'); if (!entity) throw new NotFoundException('考试成绩不存在');
await this.assertEnrollmentBelongsToStudent(entity.studentId, dto.enrollmentId);
Object.assign(entity, dto); Object.assign(entity, dto);
return this.examScoreRepo.save(entity); return this.examScoreRepo.save(entity);
} }
@@ -173,6 +198,8 @@ export class ArchiveService {
} }
async addAttachment(studentId: number, file: Express.Multer.File, category: string) { async addAttachment(studentId: number, file: Express.Multer.File, category: string) {
if (!file?.buffer || !file.originalname) throw new BadRequestException('请选择附件文件');
const student = await this.studentRepo.findOne({ where: { id: studentId } }); const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');

View File

@@ -1,5 +1,5 @@
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator'; import { IsOptional, IsString, IsNumber, IsDateString, IsNotEmpty, Min } from 'class-validator';
export class UpsertProfileDto { export class UpsertProfileDto {
@IsOptional() @IsString() targetCollege?: string; @IsOptional() @IsString() targetCollege?: string;
@@ -11,8 +11,8 @@ export class UpsertProfileDto {
} }
export class CreateEnrollmentDto { export class CreateEnrollmentDto {
@IsString() courseCategory: string; @IsString() @IsNotEmpty() courseCategory: string;
@IsString() classType: string; @IsString() @IsNotEmpty() classType: string;
@IsOptional() @IsString() className?: string; @IsOptional() @IsString() className?: string;
@IsOptional() @IsString() headTeacher?: string; @IsOptional() @IsString() headTeacher?: string;
@IsOptional() @IsString() subjectTeacher?: string; @IsOptional() @IsString() subjectTeacher?: string;
@@ -24,12 +24,12 @@ export class CreateEnrollmentDto {
export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {} export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {}
export class CreateExamScoreDto { export class CreateExamScoreDto {
@IsString() examType: string; @IsString() @IsNotEmpty() examType: string;
@IsOptional() @IsString() examName?: string; @IsOptional() @IsString() examName?: string;
@IsString() subject: string; @IsString() @IsNotEmpty() subject: string;
@IsNumber() score: number; @IsNumber() @Min(0) score: number;
@IsOptional() @IsNumber() classAvg?: number; @IsOptional() @IsNumber() @Min(0) classAvg?: number;
@IsOptional() @IsNumber() rank?: number; @IsOptional() @IsNumber() @Min(1) rank?: number;
@IsOptional() @IsDateString() examDate?: string; @IsOptional() @IsDateString() examDate?: string;
@IsOptional() @IsNumber() enrollmentId?: number; @IsOptional() @IsNumber() enrollmentId?: number;
} }
@@ -38,8 +38,8 @@ export class UpdateExamScoreDto extends PartialType(CreateExamScoreDto) {}
export class CreateLearningRecordDto { export class CreateLearningRecordDto {
@IsDateString() recordDate: string; @IsDateString() recordDate: string;
@IsString() recordType: string; @IsString() @IsNotEmpty() recordType: string;
@IsString() content: string; @IsString() @IsNotEmpty() content: string;
@IsOptional() @IsString() followUpMethod?: string; @IsOptional() @IsString() followUpMethod?: string;
@IsOptional() @IsString() nextStep?: string; @IsOptional() @IsString() nextStep?: string;
} }
@@ -47,8 +47,8 @@ export class CreateLearningRecordDto {
export class UpdateLearningRecordDto extends PartialType(CreateLearningRecordDto) {} export class UpdateLearningRecordDto extends PartialType(CreateLearningRecordDto) {}
export class UpsertResultDto { export class UpsertResultDto {
@IsOptional() @IsNumber() cultureFinalScore?: number; @IsOptional() @IsNumber() @Min(0) cultureFinalScore?: number;
@IsOptional() @IsNumber() professionalFinalScore?: number; @IsOptional() @IsNumber() @Min(0) professionalFinalScore?: number;
@IsOptional() @IsString() admissionStatus?: string; @IsOptional() @IsString() admissionStatus?: string;
@IsOptional() @IsString() admittedCollege?: string; @IsOptional() @IsString() admittedCollege?: string;
@IsOptional() @IsString() admittedMajor?: string; @IsOptional() @IsString() admittedMajor?: string;

View File

@@ -0,0 +1,85 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query, Request, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { AttendanceDevicesService } from './attendance-devices.service';
import { CreateAttendanceDeviceDto, UpdateAttendanceDeviceDto } from './dto/attendance-device.dto';
import { AttendanceDeviceStatus } from '../entities';
@UseGuards(JwtAuthGuard)
@Controller('attendance-devices')
export class AttendanceDevicesController {
constructor(
private readonly service: AttendanceDevicesService,
private readonly logService: OperationLogsService,
) {}
@Get()
@RequirePermission('classroom:view')
findAll(
@Query('classroomId') classroomId?: string,
@Query('status') status?: AttendanceDeviceStatus | 'active' | 'disabled',
) {
return this.service.findAll({
classroomId: classroomId ? Number(classroomId) : undefined,
status,
});
}
@Post()
@RequirePermission('classroom:edit')
async create(@Body() dto: CreateAttendanceDeviceDto, @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: 'attendanceDevice',
detail: `${result.deviceSn} -> ${result.classroom?.name || result.classroomId}`,
ipAddress,
userAgent,
});
return result;
}
@Put(':id')
@RequirePermission('classroom:edit')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateAttendanceDeviceDto, @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: 'attendanceDevice',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
});
return result;
}
@Delete(':id')
@RequirePermission('classroom:edit')
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: 'attendanceDevice',
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AttendanceDevice, Classroom } from '../entities';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { AttendanceDevicesController } from './attendance-devices.controller';
import { AttendanceDevicesService } from './attendance-devices.service';
@Module({
imports: [TypeOrmModule.forFeature([AttendanceDevice, Classroom]), OperationLogsModule],
controllers: [AttendanceDevicesController],
providers: [AttendanceDevicesService],
exports: [AttendanceDevicesService],
})
export class AttendanceDevicesModule {}

View File

@@ -0,0 +1,105 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { AttendanceDevice, AttendanceDeviceStatus, Classroom } from '../entities';
import { CreateAttendanceDeviceDto, UpdateAttendanceDeviceDto } from './dto/attendance-device.dto';
@Injectable()
export class AttendanceDevicesService {
constructor(
@InjectRepository(AttendanceDevice)
private readonly repo: Repository<AttendanceDevice>,
@InjectRepository(Classroom)
private readonly classroomRepo: Repository<Classroom>,
) {}
private normalizeSn(sn: string): string {
return sn.trim();
}
private async assertClassroomExists(classroomId: number): Promise<void> {
const exists = await this.classroomRepo.exist({ where: { id: classroomId } });
if (!exists) throw new BadRequestException('绑定教室不存在');
}
async findAll(query?: { classroomId?: number; status?: AttendanceDeviceStatus | 'active' | 'disabled' }) {
const where: Record<string, unknown> = {};
if (query?.classroomId) where.classroomId = query.classroomId;
if (query?.status) where.status = query.status;
return this.repo.find({
where,
relations: ['classroom'],
order: { classroomId: 'ASC', deviceName: 'ASC' },
});
}
async findOne(id: number) {
const device = await this.repo.findOne({ where: { id }, relations: ['classroom'] });
if (!device) throw new NotFoundException('考勤机不存在');
return device;
}
async create(dto: CreateAttendanceDeviceDto) {
const deviceSn = this.normalizeSn(dto.deviceSn);
await this.assertClassroomExists(dto.classroomId);
const exists = await this.repo.findOne({ where: { deviceSn } });
if (exists) throw new BadRequestException(`SN ${deviceSn} 已绑定教室`);
const saved = await this.repo.save(
this.repo.create({
...dto,
deviceSn,
deviceName: dto.deviceName.trim(),
status: dto.status ?? AttendanceDeviceStatus.ACTIVE,
}),
);
return this.findOne(saved.id);
}
async update(id: number, dto: UpdateAttendanceDeviceDto) {
const device = await this.repo.findOne({ where: { id } });
if (!device) throw new NotFoundException('考勤机不存在');
const patch: Partial<AttendanceDevice> = { ...dto };
if (dto.classroomId != null) await this.assertClassroomExists(dto.classroomId);
if (dto.deviceSn != null) {
const deviceSn = this.normalizeSn(dto.deviceSn);
const exists = await this.repo.findOne({ where: { deviceSn } });
if (exists && exists.id !== id) throw new BadRequestException(`SN ${deviceSn} 已绑定教室`);
patch.deviceSn = deviceSn;
}
if (dto.deviceName != null) patch.deviceName = dto.deviceName.trim();
await this.repo.update(id, patch);
return this.findOne(id);
}
async remove(id: number) {
const device = await this.repo.findOne({ where: { id } });
if (!device) throw new NotFoundException('考勤机不存在');
await this.repo.delete(id);
return { message: '已删除' };
}
async findActiveBySn(deviceSns: string[]) {
const sns = [...new Set(deviceSns.map((sn) => this.normalizeSn(sn)).filter(Boolean))];
if (sns.length === 0) return new Map<string, AttendanceDevice>();
const devices = await this.repo.find({
where: { deviceSn: In(sns), status: AttendanceDeviceStatus.ACTIVE },
relations: ['classroom'],
});
return new Map(devices.map((device) => [device.deviceSn, device]));
}
async findActiveByClassroomIds(classroomIds: number[]) {
const ids = [...new Set(classroomIds.filter((id) => Number.isFinite(id)))];
if (ids.length === 0) return new Map<number, AttendanceDevice>();
const devices = await this.repo.find({
where: { classroomId: In(ids), status: AttendanceDeviceStatus.ACTIVE },
relations: ['classroom'],
order: { id: 'ASC' },
});
const result = new Map<number, AttendanceDevice>();
for (const device of devices) {
if (!result.has(device.classroomId)) result.set(device.classroomId, device);
}
return result;
}
}

View File

@@ -0,0 +1,61 @@
import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { AttendanceDeviceStatus } from '../../entities/attendance-device.entity';
export class CreateAttendanceDeviceDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
deviceSn: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
deviceName: string;
@IsInt()
classroomId: number;
@IsOptional()
@IsEnum(AttendanceDeviceStatus)
status?: AttendanceDeviceStatus;
@IsOptional()
@IsString()
@MaxLength(200)
location?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateAttendanceDeviceDto {
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(100)
deviceSn?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(100)
deviceName?: string;
@IsOptional()
@IsInt()
classroomId?: number;
@IsOptional()
@IsEnum(AttendanceDeviceStatus)
status?: AttendanceDeviceStatus;
@IsOptional()
@IsString()
@MaxLength(200)
location?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -93,6 +93,31 @@ describe('AttendanceImportService', () => {
expect(entity.userName).toBe('张三'); expect(entity.userName).toBe('张三');
}); });
it('stores DingTalk punch source and attendance machine metadata', async () => {
const entity = await (service as any).mapToEntity({
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-1',
checkType: 'OnDuty',
sourceType: 'ATM',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
});
expect(entity).toEqual(
expect.objectContaining({
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
}),
);
});
it('fills the student name from the DingTalk mapping when saving an imported record', async () => { it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([ dingTalkService.fetchAttendanceResults.mockResolvedValue([
{ {
@@ -138,9 +163,19 @@ describe('AttendanceImportService', () => {
actualCheckTime: '2026-07-01T08:00:00.000Z', actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-1', checkId: 'check-1',
checkType: 'OnDuty', checkType: 'OnDuty',
sourceType: 'ATM',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
}, },
]); ]);
dingRawRepo.find.mockResolvedValue([{ dingId: 'check-1' }]); dingRawRepo.find.mockResolvedValue([{
dingId: 'check-1',
punchSource: null,
punchDeviceName: null,
punchDeviceId: null,
rawData: '',
}]);
dingRawRepo.save.mockImplementation(async (entities) => entities);
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 }); attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
const result = await service.importFromDingTalk({ const result = await service.importFromDingTalk({
@@ -150,10 +185,57 @@ describe('AttendanceImportService', () => {
autoMatch: true, autoMatch: true,
}); });
expect(dingRawRepo.save).toHaveBeenCalledWith(
[expect.objectContaining({
dingId: 'check-1',
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
})],
{ chunk: 50 },
);
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled(); expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
expect(result.matched).toBe(1); expect(result.matched).toBe(1);
}); });
it('preserves existing device metadata when a duplicate response omits it', async () => {
const existing = {
dingId: 'check-keep-device',
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
rawData: '{}',
};
dingTalkService.fetchAttendanceResults.mockResolvedValue([{
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-keep-device',
checkType: 'OnDuty',
sourceType: '',
}]);
dingRawRepo.find.mockResolvedValue([existing]);
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 0, total: 1 });
await service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
autoMatch: true,
});
expect(existing).toEqual(expect.objectContaining({
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
}));
expect(dingRawRepo.save).not.toHaveBeenCalled();
});
it('scopes SSE progress events to the importing user', async () => { it('scopes SSE progress events to the importing user', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([ dingTalkService.fetchAttendanceResults.mockResolvedValue([
{ {

View File

@@ -104,8 +104,10 @@ export class AttendanceImportService {
// Stage 2: Parse & deduplicate // Stage 2: Parse & deduplicate
this.emit('parsing', 0, total, `Parsing ${total} records...`); this.emit('parsing', 0, total, `Parsing ${total} records...`);
const existingDingIds = await this.getExistingDingIds(rawResults); const existingByDingId = await this.getExistingRecordsByDingId(rawResults);
const newRecords = rawResults.filter((r) => !existingDingIds.has(r.checkId)); const newRecords = rawResults.filter((r) => !existingByDingId.has(r.checkId));
const duplicateRecords = rawResults.filter((r) => existingByDingId.has(r.checkId));
await this.refreshDuplicatePunchMetadata(duplicateRecords, existingByDingId);
skipped = rawResults.length - newRecords.length; skipped = rawResults.length - newRecords.length;
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`); this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
@@ -246,17 +248,45 @@ export class AttendanceImportService {
/** /**
* Query which dingIds already exist to skip duplicates. * Query which dingIds already exist to skip duplicates.
*/ */
private async getExistingDingIds( private async getExistingRecordsByDingId(
results: DingTalkAttendanceResult[], results: DingTalkAttendanceResult[],
): Promise<Set<string>> { ): Promise<Map<string, DingAttendanceRaw>> {
const dingIds = results.map((r) => r.checkId).filter(Boolean); const dingIds = results.map((r) => r.checkId).filter(Boolean);
if (dingIds.length === 0) return new Set(); if (dingIds.length === 0) return new Map();
const existing = await this.dingRawRepo.find({ const existing = await this.dingRawRepo.find({
where: { dingId: In(dingIds) }, where: { dingId: In(dingIds) },
select: ['dingId'],
}); });
return new Set(existing.map((e) => e.dingId)); return new Map(existing.map((entity) => [entity.dingId, entity]));
}
private async refreshDuplicatePunchMetadata(
results: DingTalkAttendanceResult[],
existingByDingId: Map<string, DingAttendanceRaw>,
): Promise<void> {
const changed: DingAttendanceRaw[] = [];
for (const result of results) {
const entity = existingByDingId.get(result.checkId);
if (!entity) continue;
const punchSource = result.sourceType || entity.punchSource || null;
const punchDeviceName = result.deviceName || entity.punchDeviceName || null;
const punchDeviceId = result.deviceId || entity.punchDeviceId || null;
if (
entity.punchSource === punchSource &&
entity.punchDeviceName === punchDeviceName &&
entity.punchDeviceId === punchDeviceId
) {
continue;
}
entity.punchSource = punchSource;
entity.punchDeviceName = punchDeviceName;
entity.punchDeviceId = punchDeviceId;
entity.rawData = JSON.stringify(result);
changed.push(entity);
}
if (changed.length > 0) {
await this.dingRawRepo.save(changed, { chunk: 50 });
}
} }
/** /**
@@ -271,6 +301,9 @@ export class AttendanceImportService {
entity.attendanceType = r.checkType || 'OnDuty'; entity.attendanceType = r.checkType || 'OnDuty';
entity.timeResult = r.timeResult; entity.timeResult = r.timeResult;
entity.locationResult = r.locationResult || ''; entity.locationResult = r.locationResult || '';
entity.punchSource = r.sourceType || null;
entity.punchDeviceName = r.deviceName || null;
entity.punchDeviceId = r.deviceId || null;
// Parse check-in/out times // Parse check-in/out times
if (r.actualCheckTime) { if (r.actualCheckTime) {

View File

@@ -20,6 +20,14 @@ const createService = () => {
update: jest.fn().mockResolvedValue({ affected: 1 }), update: jest.fn().mockResolvedValue({ affected: 1 }),
}; };
const attendanceService = { const attendanceService = {
getLessonAttendanceImportDateRange: jest.fn().mockImplementation((targetSchedule, lessonDate: string) => {
if (targetSchedule.endTime > targetSchedule.startTime) {
return { startDate: lessonDate, endDate: lessonDate };
}
const next = new Date(`${lessonDate}T00:00:00.000Z`);
next.setUTCDate(next.getUTCDate() + 1);
return { startDate: lessonDate, endDate: next.toISOString().slice(0, 10) };
}),
getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']), getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']),
createLessonAttendanceFromDingTalk: jest.fn().mockImplementation( createLessonAttendanceFromDingTalk: jest.fn().mockImplementation(
async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({ async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({
@@ -72,6 +80,49 @@ describe('AttendanceSettlementService', () => {
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
}); });
it('does not settle an in-progress lesson before its end time', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([
{
id: 90,
scheduleId: 2,
lessonDate: '2026-07-13',
status: 'in_progress',
schedule,
},
]);
await service.settleEndedLessons(new Date('2026-07-13T09:30:00+08:00'));
expect(importService.importFromDingTalk).not.toHaveBeenCalled();
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
});
it('settles an in-progress lesson when its end time is reached', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([
{
id: 90,
scheduleId: 2,
lessonDate: '2026-07-13',
status: 'in_progress',
schedule,
},
]);
await service.settleEndedLessons(new Date('2026-07-13T10:00:00+08:00'));
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledTimes(1);
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
2,
'2026-07-13',
21,
true,
);
});
it('continues with the next lesson when one settlement fails', async () => { it('continues with the next lesson when one settlement fails', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([schedule, { ...schedule, id: 3 }]); scheduleRepo.find.mockResolvedValue([schedule, { ...schedule, id: 3 }]);
@@ -155,6 +206,32 @@ describe('AttendanceSettlementService', () => {
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
}); });
it('does not settle an in-progress overnight lesson before its next-day end time', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
const overnightSchedule = {
...schedule,
id: 4,
weekDay: 7,
startTime: '22:00',
endTime: '01:00',
};
scheduleRepo.find.mockResolvedValue([]);
sessionRepo.find.mockResolvedValue([
{
id: 91,
scheduleId: 4,
lessonDate: '2026-07-12',
status: 'in_progress',
schedule: overnightSchedule,
},
]);
await service.settleEndedLessons(new Date('2026-07-13T00:30:00+08:00'));
expect(importService.importFromDingTalk).not.toHaveBeenCalled();
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
});
it('settles an overnight lesson after its next-day end time', async () => { it('settles an overnight lesson after its next-day end time', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService } = createService(); const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
scheduleRepo.find.mockResolvedValue([ scheduleRepo.find.mockResolvedValue([

View File

@@ -61,7 +61,11 @@ export class AttendanceSettlementService {
} }
} }
for (const session of sessions) { for (const session of sessions) {
if (session.status === 'in_progress' && session.schedule) { if (
session.status === 'in_progress' &&
session.schedule &&
this.hasOccurrenceEnded(session.schedule, session.lessonDate, clock)
) {
candidates.set(`${session.scheduleId}|${session.lessonDate}`, { candidates.set(`${session.scheduleId}|${session.lessonDate}`, {
schedule: session.schedule, schedule: session.schedule,
lessonDate: session.lessonDate, lessonDate: session.lessonDate,
@@ -110,9 +114,12 @@ export class AttendanceSettlementService {
schedule.teacherId, schedule.teacherId,
schedule.classId, schedule.classId,
); );
const importRange = this.attendanceService.getLessonAttendanceImportDateRange(
schedule,
lessonDate,
);
const imported = await this.importService.importFromDingTalk({ const imported = await this.importService.importFromDingTalk({
startDate: lessonDate, ...importRange,
endDate: this.isOvernight(schedule) ? this.shiftDate(lessonDate, 1) : lessonDate,
userIds, userIds,
autoMatch: true, autoMatch: true,
userId: schedule.teacherId, userId: schedule.teacherId,
@@ -160,6 +167,18 @@ export class AttendanceSettlementService {
return null; return null;
} }
private hasOccurrenceEnded(
schedule: ClassSchedule,
lessonDate: string,
clock: { date: string; minutes: number },
): boolean {
const occurrenceEndDate = this.isOvernight(schedule)
? this.shiftDate(lessonDate, 1)
: lessonDate;
if (clock.date !== occurrenceEndDate) return clock.date > occurrenceEndDate;
return clock.minutes >= this.toMinutes(schedule.endTime);
}
private isOvernight(schedule: ClassSchedule): boolean { private isOvernight(schedule: ClassSchedule): boolean {
return this.toMinutes(schedule.endTime) <= this.toMinutes(schedule.startTime); return this.toMinutes(schedule.endTime) <= this.toMinutes(schedule.startTime);
} }

View File

@@ -162,6 +162,9 @@ describe('AttendanceController — write data scope', () => {
assertClassAccess: jest.fn(), assertClassAccess: jest.fn(),
getAccessibleClassIds: jest.fn(), getAccessibleClassIds: jest.fn(),
getTeacherClassDingUserIds: jest.fn(), getTeacherClassDingUserIds: jest.fn(),
getLessonAttendanceImportDateRange: jest.fn().mockImplementation(
(_schedule, lessonDate: string) => ({ startDate: lessonDate, endDate: lessonDate }),
),
batchCreate: jest.fn(), batchCreate: jest.fn(),
generateFromSchedules: jest.fn(), generateFromSchedules: jest.fn(),
findAttendanceRecord: jest.fn(), findAttendanceRecord: jest.fn(),

View File

@@ -13,6 +13,7 @@ import {
Res, Res,
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { Observable, filter } from 'rxjs'; import { Observable, filter } from 'rxjs';
import type { Request as ExpressRequest, Response } from 'express'; import type { Request as ExpressRequest, Response } from 'express';
@@ -27,6 +28,7 @@ import {
QueryDingRawDto, QueryDingRawDto,
MatchDingRecordDto, MatchDingRecordDto,
AttendanceReportQueryDto, AttendanceReportQueryDto,
AttendanceAlertsQueryDto,
UpdateAttendanceRecordDto, UpdateAttendanceRecordDto,
GenerateFromSchedulesDto, GenerateFromSchedulesDto,
LessonAttendanceQueryDto, LessonAttendanceQueryDto,
@@ -93,11 +95,11 @@ export class AttendanceController {
@Get('attendance-lessons/schedules/:scheduleId') @Get('attendance-lessons/schedules/:scheduleId')
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
async getLessonAttendance( async getLessonAttendance(
@Param('scheduleId') scheduleId: string, @Param('scheduleId', ParseIntPipe) scheduleId: number,
@Query() query: LessonAttendanceQueryDto, @Query() query: LessonAttendanceQueryDto,
@Request() req: { user: RequestUser }, @Request() req: { user: RequestUser },
) { ) {
const result = await this.service.getLessonAttendance(+scheduleId, query.date); const result = await this.service.getLessonAttendance(scheduleId, query.date);
await this.assertClassAccess(req, result.schedule.classId!); await this.assertClassAccess(req, result.schedule.classId!);
return result; return result;
} }
@@ -105,26 +107,29 @@ export class AttendanceController {
@Post('attendance-lessons/schedules/:scheduleId/pull') @Post('attendance-lessons/schedules/:scheduleId/pull')
@RequirePermission('attendance:create') @RequirePermission('attendance:create')
async pullLessonAttendance( async pullLessonAttendance(
@Param('scheduleId') scheduleId: string, @Param('scheduleId', ParseIntPipe) scheduleId: number,
@Body() dto: StartLessonAttendanceDto, @Body() dto: StartLessonAttendanceDto,
@Request() req: { user: RequestUser }, @Request() req: { user: RequestUser },
) { ) {
const schedule = await this.service.getLessonAttendance(+scheduleId, dto.date); const schedule = await this.service.getLessonAttendance(scheduleId, dto.date);
await this.assertClassAccess(req, schedule.schedule.classId!); await this.assertClassAccess(req, schedule.schedule.classId!);
const importClassIds = await this.service.getTeacherClassDingUserIds( const importClassIds = await this.service.getTeacherClassDingUserIds(
req.user.id, req.user.id,
schedule.schedule.classId!, schedule.schedule.classId!,
this.canManageAllAttendance(req), this.canManageAllAttendance(req),
); );
const importRange = this.service.getLessonAttendanceImportDateRange(
schedule.schedule,
dto.date,
);
const importResult = await this.importService.importFromDingTalk({ const importResult = await this.importService.importFromDingTalk({
startDate: dto.date, ...importRange,
endDate: dto.date,
userIds: importClassIds, userIds: importClassIds,
autoMatch: true, autoMatch: true,
userId: req.user.id, userId: req.user.id,
}); });
const result = await this.service.createLessonAttendanceFromDingTalk( const result = await this.service.createLessonAttendanceFromDingTalk(
+scheduleId, scheduleId,
dto.date, dto.date,
req.user.id, req.user.id,
); );
@@ -143,18 +148,18 @@ export class AttendanceController {
@Post('attendance-lessons/:sessionId/complete') @Post('attendance-lessons/:sessionId/complete')
@RequirePermission('attendance:create') @RequirePermission('attendance:create')
async completeLessonAttendance( async completeLessonAttendance(
@Param('sessionId') sessionId: string, @Param('sessionId', ParseIntPipe) sessionId: number,
@Request() req: { user: RequestUser }, @Request() req: { user: RequestUser },
) { ) {
const session = await this.service.findAttendanceSession(+sessionId); const session = await this.service.findAttendanceSession(sessionId);
await this.assertClassAccess(req, session.classId); await this.assertClassAccess(req, session.classId);
const result = await this.service.completeLessonAttendance(+sessionId, req.user.id); const result = await this.service.completeLessonAttendance(sessionId, req.user.id);
await this.logService.log({ await this.logService.log({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
module: '考勤管理', module: '考勤管理',
action: '完成课程点名', action: '完成课程点名',
targetId: +sessionId, targetId: sessionId,
targetType: 'attendanceSession', targetType: 'attendanceSession',
detail: `班级${session.classId} 日期${session.lessonDate}`, detail: `班级${session.classId} 日期${session.lessonDate}`,
}); });
@@ -278,23 +283,23 @@ export class AttendanceController {
@Put('attendance-records/:id') @Put('attendance-records/:id')
@RequirePermission('attendance:edit', 'attendance:self-edit') @RequirePermission('attendance:edit', 'attendance:self-edit')
async update( async update(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateAttendanceRecordDto, @Body() dto: UpdateAttendanceRecordDto,
@Request() req: any, @Request() req: any,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const existing = await this.service.findAttendanceRecord(+id); const existing = await this.service.findAttendanceRecord(id);
if (existing.classId == null && !this.canManageAllAttendance(req)) { if (existing.classId == null && !this.canManageAllAttendance(req)) {
throw new ForbiddenException('无权修改未关联班级的考勤记录'); throw new ForbiddenException('无权修改未关联班级的考勤记录');
} }
if (existing.classId != null) await this.assertClassAccess(req, existing.classId); if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
const result = await this.service.update(+id, dto); const result = await this.service.update(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '考勤管理', module: '考勤管理',
action: '编辑考勤记录', action: '编辑考勤记录',
targetId: +id, targetId: id,
targetType: 'attendanceRecord', targetType: 'attendanceRecord',
detail: `状态=${result.status}, 备注=${result.remark || ''}`, detail: `状态=${result.status}, 备注=${result.remark || ''}`,
ipAddress, ipAddress,
@@ -306,20 +311,20 @@ export class AttendanceController {
// ── Delete a single attendance record ── // ── Delete a single attendance record ──
@Delete('attendance-records/:id') @Delete('attendance-records/:id')
@RequirePermission('attendance:edit', 'attendance:self-edit') @RequirePermission('attendance:edit', 'attendance:self-edit')
async remove(@Param('id') id: string, @Request() req: any) { async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const existing = await this.service.findAttendanceRecord(+id); const existing = await this.service.findAttendanceRecord(id);
if (existing.classId == null && !this.canManageAllAttendance(req)) { if (existing.classId == null && !this.canManageAllAttendance(req)) {
throw new ForbiddenException('无权删除未关联班级的考勤记录'); throw new ForbiddenException('无权删除未关联班级的考勤记录');
} }
if (existing.classId != null) await this.assertClassAccess(req, existing.classId); if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
const result = await this.service.remove(+id); const result = await this.service.remove(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '考勤管理', module: '考勤管理',
action: '删除考勤记录', action: '删除考勤记录',
targetId: +id, targetId: id,
targetType: 'attendanceRecord', targetType: 'attendanceRecord',
detail: `删除考勤记录 ${id}`, detail: `删除考勤记录 ${id}`,
ipAddress, ipAddress,
@@ -369,18 +374,18 @@ export class AttendanceController {
@Post('ding-attendance-raw/:id/match') @Post('ding-attendance-raw/:id/match')
@RequirePermission('attendance:edit') @RequirePermission('attendance:edit')
async matchDingRecord( async matchDingRecord(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() dto: MatchDingRecordDto, @Body() dto: MatchDingRecordDto,
@Request() req: any, @Request() req: any,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.matchDingRecord(+id, dto); const result = await this.service.matchDingRecord(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '考勤管理', module: '考勤管理',
action: '匹配考勤记录', action: '匹配考勤记录',
targetId: +id, targetId: id,
targetType: 'dingAttendanceRaw', targetType: 'dingAttendanceRaw',
detail: `匹配到学生 ${dto.studentId}`, detail: `匹配到学生 ${dto.studentId}`,
ipAddress, ipAddress,
@@ -458,12 +463,11 @@ export class AttendanceController {
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
async getAlerts( async getAlerts(
@Request() req: { user: RequestUser }, @Request() req: { user: RequestUser },
@Query('days') days?: string, @Query() query: AttendanceAlertsQueryDto,
@Query('threshold') threshold?: string,
) { ) {
return this.service.getAlerts( return this.service.getAlerts(
days ? +days : 14, query.days ?? 14,
threshold ? +threshold : 3, query.threshold ?? 3,
await this.getAccessibleClassIds(req), await this.getAccessibleClassIds(req),
); );
} }

View File

@@ -21,6 +21,7 @@ const createService = () => {
create: jest.fn((value: Record<string, unknown>) => ({ id: 90, ...value })), create: jest.fn((value: Record<string, unknown>) => ({ id: 90, ...value })),
save: jest.fn(async (value: unknown) => value), save: jest.fn(async (value: unknown) => value),
}; };
const attendanceDeviceRepo = { find: jest.fn().mockResolvedValue([]) };
const dataSource = { const dataSource = {
transaction: jest.fn( transaction: jest.fn(
async (cb: (manager: { getRepository: jest.Mock }) => Promise<unknown>) => { async (cb: (manager: { getRepository: jest.Mock }) => Promise<unknown>) => {
@@ -43,9 +44,10 @@ const createService = () => {
{} as never, {} as never,
{} as never, {} as never,
sessionRepo as never, sessionRepo as never,
attendanceDeviceRepo as never,
dataSource as unknown as DataSource, dataSource as unknown as DataSource,
); );
return { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo, dataSource }; return { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo, attendanceDeviceRepo, dataSource };
}; };
const endedSchedule = { const endedSchedule = {
@@ -59,6 +61,7 @@ const endedSchedule = {
subject: '\u6570\u5B66', subject: '\u6570\u5B66',
status: 'active', status: 'active',
scheduleType: 'INTERNAL', scheduleType: 'INTERNAL',
attendanceAdvanceMinutes: 30,
}; };
describe('AttendanceService \u2014 DingTalk course attendance', () => { describe('AttendanceService \u2014 DingTalk course attendance', () => {
@@ -79,6 +82,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
attendanceType: 'OnDuty', attendanceType: 'OnDuty',
timeResult: 'Normal', timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'), checkInTime: new Date('2026-07-11T08:55:00+08:00'),
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
}, },
{ {
matchedStudentId: 2, matchedStudentId: 2,
@@ -100,7 +106,15 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
}), }),
); );
expect(attendanceRepo.save).toHaveBeenCalledWith([ expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present', source: 'dingtalk' }), expect.objectContaining({
studentId: 1,
status: 'present',
source: 'dingtalk',
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
punchTime: new Date('2026-07-11T08:55:00+08:00'),
}),
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }), expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }), expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }), expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
@@ -175,6 +189,40 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
]); ]);
}); });
it('counts both OnDuty and OffDuty punches only inside the configured window', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue({ ...endedSchedule, attendanceAdvanceMinutes: 20 });
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
{ studentId: 2, student: { id: 2, name: '李四' } },
{ studentId: 3, student: { id: 3, name: '王五' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T08:40:00+08:00') },
{ matchedStudentId: 2, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:00+08:00') },
{ matchedStudentId: 3, attendanceType: 'OnDuty', checkInTime: new Date('2026-07-11T08:39:59+08:00') },
{ matchedStudentId: 3, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:01+08:00') },
]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present' }),
expect.objectContaining({ studentId: 2, status: 'present' }),
expect.objectContaining({ studentId: 3, status: 'absent' }),
]);
});
it('expands import dates when the pre-class window crosses midnight', () => {
const { service } = createService();
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 },
'2026-07-11',
)).toEqual({ startDate: '2026-07-10', endDate: '2026-07-11' });
});
it('creates local attendance after the lesson starts', async () => { it('creates local attendance after the lesson starts', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } = const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService(); createService();
@@ -547,3 +595,38 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
expect(result.source).toBe('manual'); expect(result.source).toBe('manual');
}); });
}); });
describe('AttendanceService — attendance window boundaries', () => {
it('crosses calendar boundaries only when the window requires it', () => {
const { service } = createService();
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 30 }, '2026-07-13',
)).toEqual({ startDate: '2026-07-13', endDate: '2026-07-13' });
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 31 }, '2026-07-13',
)).toEqual({ startDate: '2026-07-12', endDate: '2026-07-13' });
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '22:00', endTime: '01:00', attendanceAdvanceMinutes: 30 }, '2026-07-13',
)).toEqual({ startDate: '2026-07-13', endDate: '2026-07-14' });
});
it('uses Asia/Shanghai time when deciding whether todays lesson has started', async () => {
const originalTz = process.env.TZ;
process.env.TZ = 'UTC';
jest.useFakeTimers().setSystemTime(new Date('2026-07-13T01:00:00.000Z'));
try {
const { service, scheduleRepo, sessionRepo, attendanceRepo } = createService();
scheduleRepo.findOne.mockResolvedValue({
...endedSchedule, weekDay: 1, startTime: '08:30', endTime: '10:00',
startDate: '2026-07-13', endDate: '2026-07-13',
});
sessionRepo.findOne.mockResolvedValue({ id: 90, status: 'completed' });
attendanceRepo.find.mockResolvedValue([]);
await expect(service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21))
.resolves.toMatchObject({ records: [] });
} finally {
jest.useRealTimers();
process.env.TZ = originalTz;
}
});
});

View File

@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities'; import { AttendanceRecord, AttendanceSession, AttendanceDevice, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
import { AttendanceService } from './attendance.service'; import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service'; import { AttendanceImportService } from './attendance-import.service';
import { AttendanceSettlementService } from './attendance-settlement.service'; import { AttendanceSettlementService } from './attendance-settlement.service';
@@ -10,7 +10,7 @@ import { IntegrationModule } from '../integration/integration.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]), TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
OperationLogsModule, OperationLogsModule,
IntegrationModule, IntegrationModule,
], ],

View File

@@ -6,6 +6,7 @@ import { Repository } from 'typeorm';
import { AttendanceService } from './attendance.service'; import { AttendanceService } from './attendance.service';
import { AttendanceRecord } from '../entities/attendance-record.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity'; import { AttendanceSession } from '../entities/attendance-session.entity';
import { AttendanceDevice } from '../entities/attendance-device.entity';
import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity'; import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity';
import { Class } from '../entities/class.entity'; import { Class } from '../entities/class.entity';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
@@ -43,6 +44,7 @@ describe('AttendanceService — batchCreate', () => {
const mockScheduleRepo = { find: jest.fn().mockResolvedValue([]) }; const mockScheduleRepo = { find: jest.fn().mockResolvedValue([]) };
const mockClassStudentRepo = { find: jest.fn().mockResolvedValue([]) }; const mockClassStudentRepo = { find: jest.fn().mockResolvedValue([]) };
const mockStudentDingMappingRepo = { find: jest.fn().mockResolvedValue([]) }; const mockStudentDingMappingRepo = { find: jest.fn().mockResolvedValue([]) };
const mockAttendanceDeviceRepo = { find: jest.fn().mockResolvedValue([]) };
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
@@ -56,6 +58,7 @@ describe('AttendanceService — batchCreate', () => {
{ provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo }, { provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo },
{ provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } }, { provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } },
{ provide: getRepositoryToken(AttendanceSession), useValue: {} }, { provide: getRepositoryToken(AttendanceSession), useValue: {} },
{ provide: getRepositoryToken(AttendanceDevice), useValue: mockAttendanceDeviceRepo },
{ provide: getDataSourceToken(), useValue: { transaction: jest.fn() } }, { provide: getDataSourceToken(), useValue: { transaction: jest.fn() } },
], ],
}).compile(); }).compile();
@@ -136,6 +139,7 @@ describe('AttendanceService — teacher DingTalk class scope', () => {
classTeacherRepo as never, classTeacherRepo as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
beforeEach(() => { beforeEach(() => {
@@ -211,6 +215,7 @@ describe('AttendanceService — DingTalk raw query', () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
await expect( await expect(
@@ -283,6 +288,7 @@ describe('AttendanceService — session serialization', () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{ find: jest.fn().mockResolvedValue([]) } as never,
dataSourceMock as never, dataSourceMock as never,
); );
} }

View File

@@ -4,6 +4,7 @@ import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual, DataSource }
import { import {
AttendanceRecord, AttendanceRecord,
AttendanceSession, AttendanceSession,
AttendanceDevice,
DingAttendanceRaw, DingAttendanceRaw,
Class, Class,
Student, Student,
@@ -66,11 +67,71 @@ export class AttendanceService {
private classTeacherRepo: Repository<ClassTeacher>, private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(AttendanceSession) @InjectRepository(AttendanceSession)
private attendanceSessionRepo: Repository<AttendanceSession>, private attendanceSessionRepo: Repository<AttendanceSession>,
@InjectRepository(AttendanceDevice)
private attendanceDeviceRepo: Repository<AttendanceDevice>,
private dataSource: DataSource, private dataSource: DataSource,
) {} ) {}
private sessionMutex = new SessionMutex(); private sessionMutex = new SessionMutex();
private formatDeviceDetail(device: AttendanceDevice): string {
const classroomName = device.classroom?.name;
return classroomName ? `${device.deviceName} · ${classroomName}` : device.deviceName;
}
private async attachAttendanceDeviceMappings<T extends AttendanceRecord>(
records: T[],
classroomId?: number | null,
): Promise<T[]> {
if (records.length === 0) return records;
const sns = [...new Set(records.map((record) => record.punchDeviceId?.trim()).filter(Boolean) as string[])];
const devicesBySn = new Map<string, AttendanceDevice>();
if (sns.length > 0) {
const devices = await this.attendanceDeviceRepo.find({
where: { deviceSn: In(sns), status: 'active' },
relations: ['classroom'],
});
for (const device of devices) devicesBySn.set(device.deviceSn, device);
}
const classroomIds = [...new Set([
...records.map((record) => record.classId).filter((id): id is number => id != null),
...(classroomId != null ? [classroomId] : []),
])];
const devicesByClassroom = new Map<number, AttendanceDevice>();
if (classroomIds.length > 0) {
const devices = await this.attendanceDeviceRepo.find({
where: { classroomId: In(classroomIds), status: 'active' },
relations: ['classroom'],
order: { id: 'ASC' },
});
for (const device of devices) {
if (!devicesByClassroom.has(device.classroomId)) devicesByClassroom.set(device.classroomId, device);
}
}
for (const record of records) {
const sn = record.punchDeviceId?.trim();
const mappedBySn = sn ? devicesBySn.get(sn) : undefined;
if (mappedBySn) {
record.punchDeviceName = this.formatDeviceDetail(mappedBySn);
record.punchDeviceId = mappedBySn.deviceSn;
continue;
}
const source = (record.punchSource || '').trim().toUpperCase();
const isMachine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some(
(value) => source === value || source.includes(value),
);
const fallbackClassroomId = record.classId ?? classroomId ?? undefined;
const mappedByClassroom = fallbackClassroomId ? devicesByClassroom.get(fallbackClassroomId) : undefined;
if (isMachine && mappedByClassroom && !record.punchDeviceName) {
record.punchDeviceName = this.formatDeviceDetail(mappedByClassroom);
record.punchDeviceId = record.punchDeviceId || mappedByClassroom.deviceSn;
}
}
return records;
}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> { async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined; if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } }); const assignments = await this.classTeacherRepo.find({ where: { userId } });
@@ -172,27 +233,48 @@ export class AttendanceService {
order: { studentId: 'ASC' }, order: { studentId: 'ASC' },
}) })
: []; : [];
return { schedule, session, records }; return { schedule, session, records: await this.attachAttendanceDeviceMappings(records, schedule.classId) };
}
private getLessonAttendanceWindow(
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): { start: number; end: number; dateFrom: string; dateTo: string } {
const startMinuteOfDay = this.toMinutes(schedule.startTime);
const endMinuteOfDay = this.toMinutes(schedule.endTime);
const advanceMinutes = Math.max(0, schedule.attendanceAdvanceMinutes ?? 30);
const lessonStart = new Date(`${lessonDate}T${schedule.startTime}:00+08:00`).getTime();
let lessonEnd = new Date(`${lessonDate}T${schedule.endTime}:00+08:00`).getTime();
const overnight = endMinuteOfDay <= startMinuteOfDay;
if (overnight) lessonEnd += 24 * 60 * 60 * 1000;
return {
start: lessonStart - advanceMinutes * 60 * 1000,
end: lessonEnd,
dateFrom: advanceMinutes > startMinuteOfDay ? this.shiftDate(lessonDate, -1) : lessonDate,
dateTo: overnight ? this.shiftDate(lessonDate, 1) : lessonDate,
};
}
getLessonAttendanceImportDateRange(
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): { startDate: string; endDate: string } {
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
return { startDate: window.dateFrom, endDate: window.dateTo };
} }
private selectDingTalkRecordsForLesson( private selectDingTalkRecordsForLesson(
records: DingAttendanceRaw[], records: DingAttendanceRaw[],
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string, lessonDate: string,
startTime: string,
endTime: string,
): DingAttendanceRaw[] { ): DingAttendanceRaw[] {
const [startHour, startMinute] = startTime.split(':').map(Number); const window = this.getLessonAttendanceWindow(schedule, lessonDate);
const [endHour, endMinute] = endTime.split(':').map(Number); return records.filter((record) => {
const start = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime(); // 上班、下班打卡都有效,按原始记录中实际存在的时间判断。
let end = new Date(`${lessonDate}T${endTime}:00+08:00`).getTime();
if (endHour * 60 + endMinute <= startHour * 60 + startMinute) end += 24 * 60 * 60 * 1000;
const windowStart = start - 3 * 60 * 60 * 1000;
const windowEnd = end + 3 * 60 * 60 * 1000;
const timed = records.filter((record) => {
const time = record.checkInTime ?? record.checkOutTime; const time = record.checkInTime ?? record.checkOutTime;
return time && time.getTime() >= windowStart && time.getTime() <= windowEnd; return time && time.getTime() >= window.start && time.getTime() <= window.end;
}); });
return timed.length > 0 ? timed : records.filter((record) => !record.checkInTime && !record.checkOutTime);
} }
private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string { private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
@@ -200,6 +282,53 @@ export class AttendanceService {
if (hasPunch) return 'present'; if (hasPunch) return 'present';
return finalize ? 'absent' : 'pending'; return finalize ? 'absent' : 'pending';
} }
private getLessonPunchMetadata(
records: DingAttendanceRaw[],
lessonDate: string,
startTime: string,
): Pick<AttendanceRecord, 'punchTime' | 'punchSource' | 'punchDeviceName' | 'punchDeviceId'> {
const punches = records
.map((record) => ({ record, time: record.checkInTime ?? record.checkOutTime }))
.filter((item): item is { record: DingAttendanceRaw; time: Date } => !!item.time);
if (punches.length === 0) {
return {
punchTime: null,
punchSource: null,
punchDeviceName: null,
punchDeviceId: null,
};
}
const lessonStart = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
punches.sort(
(left, right) =>
Math.abs(left.time.getTime() - lessonStart) - Math.abs(right.time.getTime() - lessonStart),
);
const primary = punches[0];
const metadataRecord = [...punches]
.filter(({ record }) =>
!!(record.punchSource || record.punchDeviceName || record.punchDeviceId) ||
!['OnDuty', 'OffDuty'].includes(record.attendanceType),
)
.sort(
(left, right) =>
Math.abs(left.time.getTime() - primary.time.getTime()) -
Math.abs(right.time.getTime() - primary.time.getTime()),
)[0]?.record;
const source =
metadataRecord?.punchSource ||
(metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType)
? metadataRecord.attendanceType
: primary.record.punchSource);
return {
punchTime: primary.time,
punchSource: source || null,
punchDeviceName: metadataRecord?.punchDeviceName || primary.record.punchDeviceName || null,
punchDeviceId: metadataRecord?.punchDeviceId || primary.record.punchDeviceId || null,
};
}
async createLessonAttendanceFromDingTalk( async createLessonAttendanceFromDingTalk(
scheduleId: number, scheduleId: number,
lessonDate: string, lessonDate: string,
@@ -208,16 +337,13 @@ export class AttendanceService {
) { ) {
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate); const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
const now = new Date(); const now = new Date();
const today = [ const courseClock = this.getCourseClock(now);
now.getFullYear(), const today = courseClock.date;
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('-');
if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤'); if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤');
if (lessonDate === today) { if (lessonDate === today) {
const [hour, minute] = schedule.startTime.split(':').map(Number); const [hour, minute] = schedule.startTime.split(':').map(Number);
const startMinute = hour * 60 + minute; const startMinute = hour * 60 + minute;
const currentMinute = now.getHours() * 60 + now.getMinutes(); const currentMinute = courseClock.minutes;
if (currentMinute < startMinute) { if (currentMinute < startMinute) {
throw new BadRequestException('课程尚未开始,不能拉取考勤'); throw new BadRequestException('课程尚未开始,不能拉取考勤');
} }
@@ -234,7 +360,7 @@ export class AttendanceService {
relations: ['student'], relations: ['student'],
order: { studentId: 'ASC' }, order: { studentId: 'ASC' },
}); });
return { schedule, session: existing, records }; return { schedule, session: existing, records: await this.attachAttendanceDeviceMappings(records, schedule.classId) };
} }
if (existing.status !== 'in_progress' && !(finalize && existing.status === 'settling')) { if (existing.status !== 'in_progress' && !(finalize && existing.status === 'settling')) {
throw new BadRequestException('课程考勤正在结算'); throw new BadRequestException('课程考勤正在结算');
@@ -244,7 +370,7 @@ export class AttendanceService {
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession); const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord); const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate); const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
const existingRecords = await recordRepo.find({ const existingRecords = await recordRepo.find({
where: { attendanceSessionId: existing.id }, where: { attendanceSessionId: existing.id },
order: { studentId: 'ASC' }, order: { studentId: 'ASC' },
@@ -265,11 +391,15 @@ export class AttendanceService {
const raw = this.selectDingTalkRecordsForLesson( const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(record.studentId) ?? [], rawByStudent.get(record.studentId) ?? [],
schedule,
lessonDate, lessonDate,
schedule.startTime,
schedule.endTime,
); );
record.status = this.mapDingTalkStatus(raw, finalize); record.status = this.mapDingTalkStatus(raw, finalize);
Object.assign(record, this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
));
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime) record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
? null ? null
: finalize : finalize
@@ -281,9 +411,8 @@ export class AttendanceService {
if (existingStudentIds.has(classStudent.studentId)) continue; if (existingStudentIds.has(classStudent.studentId)) continue;
const raw = this.selectDingTalkRecordsForLesson( const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [], rawByStudent.get(classStudent.studentId) ?? [],
schedule,
lessonDate, lessonDate,
schedule.startTime,
schedule.endTime,
); );
updatedRecords.push( updatedRecords.push(
recordRepo.create({ recordRepo.create({
@@ -296,6 +425,11 @@ export class AttendanceService {
session: this.mapScheduleTimeToSession(schedule.startTime), session: this.mapScheduleTimeToSession(schedule.startTime),
status: this.mapDingTalkStatus(raw, finalize), status: this.mapDingTalkStatus(raw, finalize),
source: 'dingtalk', source: 'dingtalk',
...this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime) remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined ? undefined
: finalize : finalize
@@ -312,7 +446,7 @@ export class AttendanceService {
existing.completedAt = new Date(); existing.completedAt = new Date();
await sessionRepo.save(existing); await sessionRepo.save(existing);
} }
return { schedule, session: existing, records: saved }; return { schedule, session: existing, records: await this.attachAttendanceDeviceMappings(saved, schedule.classId) };
}); });
} }
@@ -320,7 +454,7 @@ export class AttendanceService {
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession); const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord); const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate); const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
const classStudents = await this.classStudentRepo.find({ const classStudents = await this.classStudentRepo.find({
where: { classId: schedule.classId!, status: 'active' }, where: { classId: schedule.classId!, status: 'active' },
@@ -355,7 +489,7 @@ export class AttendanceService {
relations: ['student'], relations: ['student'],
order: { studentId: 'ASC' }, order: { studentId: 'ASC' },
}); });
return { schedule, session, records: existingRecords }; return { schedule, session, records: await this.attachAttendanceDeviceMappings(existingRecords, schedule.classId) };
} }
} }
throw err; throw err;
@@ -364,9 +498,8 @@ export class AttendanceService {
const records = classStudents.map((classStudent) => { const records = classStudents.map((classStudent) => {
const raw = this.selectDingTalkRecordsForLesson( const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [], rawByStudent.get(classStudent.studentId) ?? [],
schedule,
lessonDate, lessonDate,
schedule.startTime,
schedule.endTime,
); );
return recordRepo.create({ return recordRepo.create({
studentId: classStudent.studentId, studentId: classStudent.studentId,
@@ -378,6 +511,11 @@ export class AttendanceService {
session: this.mapScheduleTimeToSession(schedule.startTime), session: this.mapScheduleTimeToSession(schedule.startTime),
status: this.mapDingTalkStatus(raw, finalize), status: this.mapDingTalkStatus(raw, finalize),
source: 'dingtalk', source: 'dingtalk',
...this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime) remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined ? undefined
: finalize : finalize
@@ -392,12 +530,13 @@ export class AttendanceService {
session.completedAt = new Date(); session.completedAt = new Date();
session = await sessionRepo.save(session); session = await sessionRepo.save(session);
} }
return { schedule, session, records: saved }; return { schedule, session, records: await this.attachAttendanceDeviceMappings(saved, schedule.classId) };
}); });
} }
private async fetchDingTalkRawByStudent( private async fetchDingTalkRawByStudent(
classId: number, classId: number,
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string, lessonDate: string,
): Promise<Map<number, DingAttendanceRaw[]>> { ): Promise<Map<number, DingAttendanceRaw[]>> {
const classStudents = await this.classStudentRepo.find({ const classStudents = await this.classStudentRepo.find({
@@ -405,9 +544,10 @@ export class AttendanceService {
}); });
if (classStudents.length === 0) return new Map(); if (classStudents.length === 0) return new Map();
const studentIds = classStudents.map((cs) => cs.studentId); const studentIds = classStudents.map((cs) => cs.studentId);
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
const rawRecords = await this.dingRawRepo.find({ const rawRecords = await this.dingRawRepo.find({
where: { where: {
attendanceDate: lessonDate, attendanceDate: Between(window.dateFrom, window.dateTo),
matchedStudentId: In(studentIds), matchedStudentId: In(studentIds),
}, },
}); });
@@ -437,7 +577,7 @@ export class AttendanceService {
relations: ['student'], relations: ['student'],
order: { studentId: 'ASC' }, order: { studentId: 'ASC' },
}); });
return { session, records }; return { session, records: await this.attachAttendanceDeviceMappings(records, session.classId) };
} }
const pendingRecords = await recordRepo.count({ const pendingRecords = await recordRepo.count({
@@ -456,7 +596,7 @@ export class AttendanceService {
relations: ['student'], relations: ['student'],
order: { studentId: 'ASC' }, order: { studentId: 'ASC' },
}); });
return { session: savedSession, records }; return { session: savedSession, records: await this.attachAttendanceDeviceMappings(records, session.classId) };
}), }),
); );
} }
@@ -589,6 +729,31 @@ export class AttendanceService {
}); });
} }
private toMinutes(time: string): number {
const [hour, minute] = time.split(':').map(Number);
return hour * 60 + minute;
}
private getCourseClock(date: Date): { date: string; minutes: number } {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
);
return {
date: `${parts.year}-${parts.month}-${parts.day}`,
minutes: Number(parts.hour) * 60 + Number(parts.minute),
};
}
private shiftDate(date: string, days: number): string {
const shifted = new Date(`${date}T00:00:00.000Z`);
shifted.setUTCDate(shifted.getUTCDate() + days);
return shifted.toISOString().slice(0, 10);
}
private mapScheduleTimeToSession(startTime: string): string { private mapScheduleTimeToSession(startTime: string): string {
const hour = parseInt(startTime.slice(0, 2), 10); const hour = parseInt(startTime.slice(0, 2), 10);
if (hour < 8) return 'morning_reading'; if (hour < 8) return 'morning_reading';
@@ -908,6 +1073,10 @@ export class AttendanceService {
if (dto.status !== undefined) { if (dto.status !== undefined) {
record.status = dto.status; record.status = dto.status;
record.source = 'manual'; record.source = 'manual';
record.punchTime = null;
record.punchSource = null;
record.punchDeviceName = null;
record.punchDeviceId = null;
} }
if (dto.remark !== undefined) { if (dto.remark !== undefined) {
record.remark = dto.remark; record.remark = dto.remark;
@@ -933,6 +1102,10 @@ export class AttendanceService {
if (dto.status !== undefined) { if (dto.status !== undefined) {
freshRecord.status = dto.status; freshRecord.status = dto.status;
freshRecord.source = 'manual'; freshRecord.source = 'manual';
freshRecord.punchTime = null;
freshRecord.punchSource = null;
freshRecord.punchDeviceName = null;
freshRecord.punchDeviceId = null;
} }
if (dto.remark !== undefined) { if (dto.remark !== undefined) {
freshRecord.remark = dto.remark; freshRecord.remark = dto.remark;

View File

@@ -68,8 +68,10 @@ describe('DingTalkService — attendance records', () => {
userId: 'ding-1', userId: 'ding-1',
workDate: Date.parse('2026-07-12T00:00:00+08:00'), workDate: Date.parse('2026-07-12T00:00:00+08:00'),
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'), userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
sourceType: 'USER', sourceType: 'ATM',
checkType: 'OnDuty', checkType: 'OnDuty',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
timeResult: 'Normal', timeResult: 'Normal',
}, },
], ],
@@ -83,6 +85,13 @@ describe('DingTalkService — attendance records', () => {
}); });
expect(record.workDate).toBe('2026-07-12'); expect(record.workDate).toBe('2026-07-12');
expect(record).toEqual(
expect.objectContaining({
checkType: 'OnDuty',
sourceType: 'ATM',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
}),
);
}); });
}); });

View File

@@ -7,6 +7,9 @@ import {
IsIn, IsIn,
ValidateNested, ValidateNested,
IsNotEmpty, IsNotEmpty,
ArrayNotEmpty,
Max,
Min,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
@@ -44,6 +47,7 @@ export class AttendanceRecordItem {
export class BatchCreateAttendanceDto { export class BatchCreateAttendanceDto {
@IsArray() @IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => AttendanceRecordItem) @Type(() => AttendanceRecordItem)
records: AttendanceRecordItem[]; records: AttendanceRecordItem[];
@@ -96,11 +100,14 @@ export class QueryDingRawDto {
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1)
@Type(() => Number) @Type(() => Number)
page?: number; page?: number;
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1)
@Max(200)
@Type(() => Number) @Type(() => Number)
pageSize?: number; pageSize?: number;
} }
@@ -139,11 +146,14 @@ export class QueryAttendanceRecordsDto {
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1)
@Type(() => Number) @Type(() => Number)
page?: number; page?: number;
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1)
@Max(200)
@Type(() => Number) @Type(() => Number)
pageSize?: number; pageSize?: number;
} }
@@ -165,6 +175,22 @@ export class UpdateAttendanceRecordDto {
remark?: string; remark?: string;
} }
export class AttendanceAlertsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
days?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
threshold?: number;
}
export class AttendanceReportQueryDto { export class AttendanceReportQueryDto {
@IsOptional() @IsOptional()
@IsInt() @IsInt()

View File

@@ -1,7 +1,7 @@
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
describe('AuthService — super admin identity', () => { describe('AuthService — authentication boundaries', () => {
it('marks the preset 超管 role as super admin in the JWT payload', async () => { it('marks the preset 超管 role as super admin in the JWT payload', async () => {
const userRepo = { const userRepo = {
findOne: jest.fn().mockResolvedValue({ findOne: jest.fn().mockResolvedValue({
@@ -22,8 +22,29 @@ describe('AuthService — super admin identity', () => {
await service.login({ username: 'admin', password: 'secret' }, '127.0.0.1'); await service.login({ username: 'admin', password: 'secret' }, '127.0.0.1');
expect(jwtService.sign).toHaveBeenCalledWith( expect(jwtService.sign).toHaveBeenCalledWith(expect.objectContaining({ isSuperAdmin: true }));
expect.objectContaining({ isSuperAdmin: true }), });
it('rejects an archived user even when the password is valid', async () => {
const userRepo = {
findOne: jest.fn().mockResolvedValue({
id: 2,
username: 'archived',
passwordHash: await bcrypt.hash('secret', 4),
isActive: true,
isArchived: true,
roles: [],
}),
save: jest.fn(),
};
const service = new AuthService(
userRepo as never,
{ sign: jest.fn() } as never,
{ getUserPermissions: jest.fn() } as never,
); );
await expect(
service.login({ username: 'archived', password: 'secret' }, '192.0.2.10'),
).rejects.toThrow('账号已失效');
expect(userRepo.save).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -38,7 +38,9 @@ export class AuthService {
this.recordFailedAttempt(attemptKey); this.recordFailedAttempt(attemptKey);
throw new UnauthorizedException('用户名或密码错误'); throw new UnauthorizedException('用户名或密码错误');
} }
if (!user.isActive) throw new UnauthorizedException('账号已被禁用,请联系管理员'); if (!user.isActive || user.isArchived) {
throw new UnauthorizedException('账号已失效,请联系管理员');
}
const valid = await bcrypt.compare(dto.password, user.passwordHash); const valid = await bcrypt.compare(dto.password, user.passwordHash);
if (!valid) { if (!valid) {
this.recordFailedAttempt(attemptKey); this.recordFailedAttempt(attemptKey);

View File

@@ -33,6 +33,23 @@ describe('JwtStrategy', () => {
}); });
}); });
it('recognizes the canonical super_admin role code even when the display name changes', async () => {
const userRepo = {
findOne: jest.fn().mockResolvedValue({
id: 1,
username: 'admin',
isActive: true,
isArchived: false,
roles: [{ name: '系统管理员', code: 'super_admin', status: 1, permissions: [] }],
}),
};
const strategy = new JwtStrategy(config as never, userRepo as never);
await expect(strategy.validate({ sub: 1 })).resolves.toEqual(
expect.objectContaining({ isSuperAdmin: true }),
);
});
it.each([ it.each([
[{ id: 7, isActive: false, isArchived: false, roles: [] }], [{ id: 7, isActive: false, isArchived: false, roles: [] }],
[{ id: 7, isActive: true, isArchived: true, roles: [] }], [{ id: 7, isActive: true, isArchived: true, roles: [] }],

View File

@@ -47,7 +47,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
for (const role of user.roles ?? []) { for (const role of user.roles ?? []) {
if (role.status !== 1) continue; if (role.status !== 1) continue;
roles.push(role.name); roles.push(role.name);
if (role.name === '超管' || role.name === 'super_admin') isSuperAdmin = true; if (role.name === '超管' || role.name === 'super_admin' || role.code === 'super_admin') {
isSuperAdmin = true;
}
for (const permission of role.permissions ?? []) permissions.add(permission.code); for (const permission of role.permissions ?? []) permissions.add(permission.code);
} }

View File

@@ -34,20 +34,6 @@ export class BillsExportService {
if (query.status) qb.andWhere('b.status = :status', { status: query.status }); if (query.status) qb.andWhere('b.status = :status', { status: query.status });
const bills = await qb.getMany(); const bills = await qb.getMany();
// 查询涉及学生的"已缴未退"押金,用于导出押金抵扣字段
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
const depMap = new Map<number, number>();
if (studentIds.length > 0) {
const deposits = await this.depositRepo
.createQueryBuilder('d')
.where('d.studentId IN (:...ids)', { ids: studentIds })
.andWhere('d.status = :status', { status: 'paid' })
.getMany();
for (const d of deposits) {
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0));
}
}
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
workbook.creator = '恭学教育基地管理系统'; workbook.creator = '恭学教育基地管理系统';
@@ -60,9 +46,8 @@ export class BillsExportService {
{ header: '分摊费用', key: 'shared', width: 12 }, { header: '分摊费用', key: 'shared', width: 12 },
{ header: '个人费用', key: 'personal', width: 12 }, { header: '个人费用', key: 'personal', width: 12 },
{ header: '总金额', key: 'total', width: 12 }, { header: '总金额', key: 'total', width: 12 },
{ header: '可用押金', key: 'deposit', width: 12 }, { header: '已扣余额', key: 'paidAmount', width: 12 },
{ header: '押金抵扣', key: 'depositApplied', width: 12 }, { header: '待补缴', key: 'outstandingAmount', width: 12 },
{ header: '抵扣后应付', key: 'afterDeposit', width: 14 },
{ header: '状态', key: 'status', width: 10 }, { header: '状态', key: 'status', width: 10 },
{ header: '生成时间', key: 'generatedAt', width: 20 }, { header: '生成时间', key: 'generatedAt', width: 20 },
]; ];
@@ -71,15 +56,13 @@ export class BillsExportService {
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
draft: '草稿', unpaid: '待支付',
confirmed: '已确认', partially_paid: '部分支付',
paid: '已结清', paid: '已结清',
cancelled: '已取消',
}; };
for (const bill of bills) { for (const bill of bills) {
const total = Number(bill.totalAmount || 0); const total = Number(bill.totalAmount || 0);
const dep = Number((depMap.get(bill.studentId) || 0).toFixed(2));
const applied = Number(Math.min(dep, total).toFixed(2));
const after = Number(Math.max(0, total - applied).toFixed(2));
ws.addRow({ ws.addRow({
id: bill.id, id: bill.id,
studentName: (bill as any).student?.name || '-', studentName: (bill as any).student?.name || '-',
@@ -87,9 +70,8 @@ export class BillsExportService {
shared: Number(bill.sharedAmount), shared: Number(bill.sharedAmount),
personal: Number(bill.personalAmount), personal: Number(bill.personalAmount),
total, total,
deposit: dep, paidAmount: Number(bill.paidAmount || 0),
depositApplied: applied, outstandingAmount: Number(bill.outstandingAmount || 0),
afterDeposit: after,
status: statusMap[bill.status] || bill.status, status: statusMap[bill.status] || bill.status,
generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '', generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '',
}); });
@@ -147,16 +129,9 @@ export class BillsExportService {
return; return;
} }
// 查询该学生的可用押金(已缴未退)
const deposits = await this.depositRepo
.createQueryBuilder('d')
.where('d.studentId = :sid', { sid: bill.studentId })
.andWhere('d.status = :status', { status: 'paid' })
.getMany();
const availableDeposit = deposits.reduce((s, d) => s + Number(d.amount || 0), 0);
const totalAmount = Number(bill.totalAmount || 0); const totalAmount = Number(bill.totalAmount || 0);
const depositApplied = Math.min(availableDeposit, totalAmount); const paidAmount = Number(bill.paidAmount || 0);
const amountAfterDeposit = Math.max(0, totalAmount - depositApplied); const outstandingAmount = Number(bill.outstandingAmount || 0);
const doc = new PDFDocument({ size: 'A4', margin: 50 }); const doc = new PDFDocument({ size: 'A4', margin: 50 });
res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Type', 'application/pdf');
@@ -191,9 +166,10 @@ export class BillsExportService {
} }
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
draft: '草稿', unpaid: '待支付',
confirmed: '已确认', partially_paid: '部分支付',
paid: '已结清', paid: '已结清',
cancelled: '已取消',
}; };
// 标题 // 标题
@@ -223,20 +199,8 @@ export class BillsExportService {
.fillColor('#007AFF') .fillColor('#007AFF')
.text(`应付总额: ¥${totalAmount.toFixed(2)}`); .text(`应付总额: ¥${totalAmount.toFixed(2)}`);
doc.moveDown(0.3); doc.moveDown(0.3);
if (availableDeposit > 0) { doc.fontSize(11).fillColor('#389E0D').text(`已扣余额: ¥${paidAmount.toFixed(2)}`);
doc doc.fontSize(14).fillColor(outstandingAmount > 0 ? '#FF3B30' : '#389E0D').text(`待补缴: ¥${outstandingAmount.toFixed(2)}`);
.fontSize(11)
.fillColor('#52C41A')
.text(`可用押金: ¥${availableDeposit.toFixed(2)}`);
doc
.fontSize(11)
.fillColor('#FA8C16')
.text(`押金抵扣: -¥${depositApplied.toFixed(2)}`);
doc
.fontSize(14)
.fillColor('#FF3B30')
.text(`抵扣后应付: ¥${amountAfterDeposit.toFixed(2)}`);
}
doc.moveDown(1); doc.moveDown(1);
// 明细表格 // 明细表格

View File

@@ -0,0 +1,77 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BillsService } from './bills.service';
import { Bill } from '../entities/bill.entity';
function queryBuilder() {
return {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 1 }),
};
}
function createService(bills: Partial<Bill>[] = []) {
const billRepo = {
find: jest.fn().mockResolvedValue(bills),
findOne: jest.fn().mockResolvedValue(bills[0] ?? null),
save: jest.fn(async (value) => value),
createQueryBuilder: jest.fn(() => queryBuilder()),
};
const manager = {
delete: jest.fn(),
update: jest.fn(),
};
const dataSource = { transaction: jest.fn(async (callback) => callback(manager)) };
const service = new BillsService(
billRepo as any,
{ delete: jest.fn() } as any,
{} as any,
{ update: jest.fn() } as any,
{} as any,
{} as any,
dataSource as any,
{} as any,
);
return { service, billRepo, dataSource, manager };
}
describe('BillsService state and batch boundaries', () => {
it('rejects an empty batch status update', async () => {
const { service, billRepo } = createService();
await expect(service.batchUpdateStatus([], 'paid')).rejects.toBeInstanceOf(BadRequestException);
expect(billRepo.find).not.toHaveBeenCalled();
});
it('rejects a batch status update when some ids do not exist', async () => {
const { service, billRepo } = createService([{ id: 1, paidAmount: 0, outstandingAmount: 10 }]);
await expect(service.batchUpdateStatus([1, 2], 'unpaid')).rejects.toBeInstanceOf(NotFoundException);
expect(billRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('rejects marking a partially paid bill unpaid', async () => {
const { service, billRepo } = createService([{ id: 1, paidAmount: 10, outstandingAmount: 90, status: 'partially_paid' }]);
await expect(service.updateStatus(1, { status: 'unpaid' })).rejects.toBeInstanceOf(BadRequestException);
expect(billRepo.save).not.toHaveBeenCalled();
});
it('rejects an empty batch delete', async () => {
const { service, dataSource } = createService();
await expect(service.batchRemove([])).rejects.toBeInstanceOf(BadRequestException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects a batch delete when some ids do not exist', async () => {
const { service, dataSource } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
await expect(service.batchRemove([1, 2])).rejects.toBeInstanceOf(NotFoundException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('deletes a bill and its links in one transaction', async () => {
const { service, dataSource, manager } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
await expect(service.remove(1)).resolves.toEqual({ message: '账单已删除' });
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(manager.delete).toHaveBeenCalledTimes(2);
expect(manager.update).toHaveBeenCalledTimes(1);
});
});

View File

@@ -11,6 +11,7 @@ import {
Request, Request,
Res, Res,
Req, Req,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm'; import { Repository, In } from 'typeorm';
@@ -20,7 +21,7 @@ import { NotificationType } from '../entities/notification.entity';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { Bill } from '../entities/bill.entity'; import { Bill } from '../entities/bill.entity';
import { BillsExportService } from './bills-export.service'; import { BillsExportService } from './bills-export.service';
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils'; import { extractRequestInfo } from '../common/request-utils';
@@ -49,7 +50,7 @@ export class BillsController {
username: req.user?.username, username: req.user?.username,
module: '账单管理', module: '账单管理',
action: '生成账单', action: '生成账单',
detail: `周期 ${dto.periodStart}~${dto.periodEnd}, 生成 ${result.count}`, detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count}`,
ipAddress, ipAddress,
userAgent, userAgent,
}); });
@@ -62,7 +63,7 @@ export class BillsController {
recipientIds: [student.userId], recipientIds: [student.userId],
type: NotificationType.BILL_GENERATED, type: NotificationType.BILL_GENERATED,
title: '新账单', title: '新账单',
content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${dto.periodStart}~${dto.periodEnd}`, content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${result.periodStart}~${result.periodEnd}`,
}); });
} }
} }
@@ -75,38 +76,38 @@ export class BillsController {
findAll( findAll(
@Query('periodStart') periodStart?: string, @Query('periodStart') periodStart?: string,
@Query('periodEnd') periodEnd?: string, @Query('periodEnd') periodEnd?: string,
@Query('studentId') studentId?: string, @Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
@Query('status') status?: string, @Query('status') status?: string,
@Query('expenseType') expenseType?: string, @Query('expenseType') expenseType?: string,
) { ) {
return this.service.findAll({ return this.service.findAll({
periodStart, periodEnd, periodStart, periodEnd,
studentId: studentId ? +studentId : undefined, studentId,
status, expenseType, status, expenseType,
}); });
} }
@Get(':id') @Get(':id')
@RequirePermission('bill:view') @RequirePermission('bill:view')
findOne(@Param('id') id: string) { findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(+id); return this.service.findOne(id);
} }
@Put(':id/status') @Put(':id/status')
@RequirePermission('bill:confirm') @RequirePermission('bill:confirm')
async updateStatus( async updateStatus(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateBillStatusDto, @Body() dto: UpdateBillStatusDto,
@Request() req: any, @Request() req: any,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateStatus(+id, dto); const result = await this.service.updateStatus(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '账单管理', module: '账单管理',
action: '确认账单', action: '确认账单',
targetId: +id, targetId: id,
targetType: 'bill', targetType: 'bill',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -158,17 +159,36 @@ export class BillsController {
return result; return result;
} }
@Post(':id/cancel')
@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,
});
return result;
}
@Delete(':id') @Delete(':id')
@RequirePermission('bill:delete') @RequirePermission('bill:delete')
async remove(@Param('id') id: string, @Request() req: any) { async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id); const result = await this.service.remove(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '账单管理', module: '账单管理',
action: '删除账单', action: '删除账单',
targetId: +id, targetId: id,
targetType: 'bill', targetType: 'bill',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -198,7 +218,7 @@ export class BillsController {
async exportExcel( async exportExcel(
@Query('periodStart') periodStart?: string, @Query('periodStart') periodStart?: string,
@Query('periodEnd') periodEnd?: string, @Query('periodEnd') periodEnd?: string,
@Query('studentId') studentId?: string, @Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
@Query('status') status?: string, @Query('status') status?: string,
@Res() res?: Response, @Res() res?: Response,
@Req() req?: any, @Req() req?: any,
@@ -217,7 +237,7 @@ export class BillsController {
{ {
periodStart, periodStart,
periodEnd, periodEnd,
studentId: studentId ? +studentId : undefined, studentId,
status, status,
}, },
res!, res!,
@@ -226,18 +246,18 @@ export class BillsController {
@Get('export/pdf/:id') @Get('export/pdf/:id')
@RequirePermission('bill:export-pdf') @RequirePermission('bill:export-pdf')
async exportPdf(@Param('id') id: string, @Res() res: Response, @Req() req: any) { async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({ await this.logService.log({
userId: req?.user?.id, userId: req?.user?.id,
username: req?.user?.username, username: req?.user?.username,
module: '账单管理', module: '账单管理',
action: '导出账单', action: '导出账单',
targetId: +id, targetId: id,
targetType: 'bill', targetType: 'bill',
ipAddress, ipAddress,
userAgent, userAgent,
}); });
return this.exportService.exportStudentPdf(+id, res); return this.exportService.exportStudentPdf(id, res);
} }
} }

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { WalletsModule } from '../wallets/wallets.module';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Bill } from '../entities/bill.entity'; import { Bill } from '../entities/bill.entity';
import { BillItem } from '../entities/bill-item.entity'; import { BillItem } from '../entities/bill-item.entity';
@@ -7,8 +8,8 @@ import { RoomExpense } from '../entities/room-expense.entity';
import { PersonalExpense } from '../entities/personal-expense.entity'; import { PersonalExpense } from '../entities/personal-expense.entity';
import { Occupancy } from '../entities/occupancy.entity'; import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity'; import { Room } from '../entities/room.entity';
import { Deposit } from '../entities/deposit.entity';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { BillsService } from './bills.service'; import { BillsService } from './bills.service';
import { BillsExportService } from './bills-export.service'; import { BillsExportService } from './bills-export.service';
import { BillsController } from './bills.controller'; import { BillsController } from './bills.controller';
@@ -22,10 +23,11 @@ import { BillsController } from './bills.controller';
PersonalExpense, PersonalExpense,
Occupancy, Occupancy,
Room, Room,
Deposit,
Student, Student,
Deposit,
]), ]),
NotificationsModule, NotificationsModule,
WalletsModule,
], ],
controllers: [BillsController], controllers: [BillsController],
providers: [BillsService, BillsExportService], providers: [BillsService, BillsExportService],

View File

@@ -9,6 +9,7 @@ import { PersonalExpense } from '../entities/personal-expense.entity';
import { Occupancy } from '../entities/occupancy.entity'; import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity'; import { Room } from '../entities/room.entity';
import { Deposit } from '../entities/deposit.entity'; import { Deposit } from '../entities/deposit.entity';
import { WalletsService } from '../wallets/wallets.service';
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>; type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
@@ -56,7 +57,23 @@ describe('BillsService — generateBills', () => {
occRepo = mockRepo<Occupancy>(); occRepo = mockRepo<Occupancy>();
roomRepo = mockRepo<Room>(); roomRepo = mockRepo<Room>();
depositRepo = mockRepo<Deposit>(); depositRepo = mockRepo<Deposit>();
dataSource = { transaction: jest.fn(), query: jest.fn().mockResolvedValue([]) }; let nextBillId = 0;
dataSource = {
transaction: jest.fn(async (callback) => callback({
create: (_entity: unknown, value: unknown) => value,
save: jest.fn(async (value: any) => {
if ('totalAmount' in value && 'studentId' in value) {
const saved = { id: ++nextBillId, ...value };
await (billRepo.save as jest.Mock)(saved);
return saved;
}
await (itemRepo.save as jest.Mock)(value);
return { id: value.id || 1, ...value };
}),
createQueryBuilder: jest.fn(() => ({ update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })),
})),
query: jest.fn().mockResolvedValue([]),
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
@@ -69,6 +86,7 @@ describe('BillsService — generateBills', () => {
{ provide: getRepositoryToken(Room), useValue: roomRepo }, { provide: getRepositoryToken(Room), useValue: roomRepo },
{ provide: getRepositoryToken(Deposit), useValue: depositRepo }, { provide: getRepositoryToken(Deposit), useValue: depositRepo },
{ provide: DataSource, useValue: dataSource }, { provide: DataSource, useValue: dataSource },
{ provide: WalletsService, useValue: { debitBill: jest.fn(async (_manager, bill) => bill), refundBill: jest.fn() } },
], ],
}).compile(); }).compile();
@@ -166,6 +184,43 @@ describe('BillsService — generateBills', () => {
).toBeCloseTo(300, 0); ).toBeCloseTo(300, 0);
}); });
it('includes room expenses whose periods are inside the generated bill period', async () => {
const qb = mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'water',
amount: '300' as unknown as number, periodStart: '2026-07-01', periodEnd: '2026-07-31',
} as RoomExpense,
]);
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-07-01', billingEndDate: null as unknown as string,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills({
periodStart: '2026-06-29',
periodEnd: '2026-07-31',
});
expect(result.count).toBe(1);
expect(qb.where).toHaveBeenCalledWith(
'e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd',
{ periodStart: '2026-06-29', periodEnd: '2026-07-31' },
);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
expect(Number(savedCalls[0][0].sharedAmount)).toBeCloseTo(300, 0);
});
it('mixed → long-term get individual bills, short-term share expenses', async () => { it('mixed → long-term get individual bills, short-term share expenses', async () => {
// Room 1: two expenses // Room 1: two expenses
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
@@ -489,3 +544,52 @@ describe('BillsService — generateBills', () => {
expect(result.count).toBe(0); expect(result.count).toBe(0);
}); });
}); });
describe('BillsService — allocation rounding boundary', () => {
it('keeps allocated cents equal to the original expense total', async () => {
const billRepo = mockRepo<Bill>();
const itemRepo = mockRepo<BillItem>();
const roomExpRepo = mockRepo<RoomExpense>();
const personalExpRepo = mockRepo<PersonalExpense>();
const occRepo = mockRepo<Occupancy>();
const roomRepo = mockRepo<Room>();
let nextBillId = 0;
const dataSource = {
query: jest.fn().mockResolvedValue([]),
transaction: jest.fn(async (callback) => callback({
create: (_entity: unknown, value: any) => value,
save: jest.fn(async (value: any) => ({ id: value.id || ++nextBillId, ...value })),
createQueryBuilder: jest.fn(() => ({
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 1 }),
})),
})),
};
const service = new BillsService(
billRepo as any,
itemRepo as any,
roomExpRepo as any,
personalExpRepo as any,
occRepo as any,
roomRepo as any,
dataSource as any,
{ debitBill: jest.fn(async (_manager, bill) => bill) } as any,
);
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<RoomExpense>([
{ id: 1, roomId: 1, expenseType: 'water', amount: 100, periodStart: '2026-06-01', periodEnd: '2026-06-30' } as RoomExpense,
]));
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<Occupancy>([
{ id: 1, roomId: 1, studentId: 1, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
{ id: 2, roomId: 1, studentId: 2, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
{ id: 3, roomId: 1, studentId: 3, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
]));
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<PersonalExpense>([]));
const result = await service.generateBills({ periodStart: '2026-06-01', periodEnd: '2026-06-30' } as any);
expect(result.bills.map((bill) => Number(bill.totalAmount))).toEqual([33.33, 33.33, 33.34]);
expect(result.bills.reduce((sum, bill) => sum + Number(bill.totalAmount), 0)).toBe(100);
});
});

View File

@@ -1,14 +1,15 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, DataSource } from 'typeorm'; import { Repository, In, DataSource, EntityManager } from 'typeorm';
import { Bill } from '../entities/bill.entity'; import { Bill } from '../entities/bill.entity';
import { BillItem } from '../entities/bill-item.entity'; import { BillItem } from '../entities/bill-item.entity';
import { RoomExpense } from '../entities/room-expense.entity'; import { RoomExpense } from '../entities/room-expense.entity';
import { PersonalExpense } from '../entities/personal-expense.entity'; import { PersonalExpense } from '../entities/personal-expense.entity';
import { Occupancy } from '../entities/occupancy.entity'; import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity'; import { Room } from '../entities/room.entity';
import { Deposit } from '../entities/deposit.entity'; import { StudentWallet } from '../entities/student-wallet.entity';
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
import { WalletsService } from '../wallets/wallets.service';
@Injectable() @Injectable()
@@ -20,24 +21,37 @@ export class BillsService {
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>, @InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>, @InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
@InjectRepository(Room) private roomRepo: Repository<Room>, @InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
private dataSource: DataSource, private dataSource: DataSource,
private walletsService: WalletsService,
) {} ) {}
/** /**
* 核心计费引擎:按"人天数"加权分摊 * 核心计费引擎:按"人天数"加权分摊
*/ */
async generateBills(dto: GenerateBillsDto) { async generateBills(dto: GenerateBillsDto) {
const { periodStart, periodEnd } = dto; const { periodStart, periodEnd } = dto.billingMonth
const pStart = new Date(periodStart); ? this.resolveBillingPeriod(dto.billingMonth)
const pEnd = new Date(periodEnd); : { 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 } });
const existingDrafts = await this.billRepo.find({ if (existingBills.length > 0) {
where: { periodStart, periodEnd, status: 'draft' }, throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`);
}); }
const existingDrafts: Bill[] = [];
if (existingDrafts.length > 0) { if (existingDrafts.length > 0) {
const draftIds = existingDrafts.map((b) => b.id); const draftIds = existingDrafts.map((b) => b.id);
await this.personalExpRepo
.createQueryBuilder()
.update()
.set({ billId: null })
.where('billId IN (:...ids)', { ids: draftIds })
.execute();
await this.itemRepo await this.itemRepo
.createQueryBuilder() .createQueryBuilder()
.delete() .delete()
@@ -53,7 +67,7 @@ export class BillsService {
// 获取所有有费用的宿舍 // 获取所有有费用的宿舍
const roomExpenses = await this.roomExpRepo const roomExpenses = await this.roomExpRepo
.createQueryBuilder('e') .createQueryBuilder('e')
.where('e.periodStart = :periodStart AND e.periodEnd = :periodEnd', { .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', {
periodStart, periodStart,
periodEnd, periodEnd,
}) })
@@ -128,11 +142,16 @@ export class BillsService {
if (totalDays === 0) continue; if (totalDays === 0) continue;
// 对每项费用进行分摊 // 对每项费用进行分摊;最后一人承接舍入尾差,保证分摊合计与原费用一致。
for (const expense of expenses) { for (const expense of expenses) {
for (const sd of studentDays) { const eligibleDays = studentDays.filter((sd) => sd.days > 0);
if (sd.days === 0) continue; const expenseTotal = Number(Number(expense.amount).toFixed(2));
const amount = Number(((sd.days / totalDays) * Number(expense.amount)).toFixed(2)); let allocated = 0;
for (const [index, sd] of eligibleDays.entries()) {
const amount = index === eligibleDays.length - 1
? Number((expenseTotal - allocated).toFixed(2))
: Number(((sd.days / totalDays) * expenseTotal).toFixed(2));
allocated = Number((allocated + amount).toFixed(2));
if (!studentBillData.has(sd.studentId)) { if (!studentBillData.has(sd.studentId)) {
studentBillData.set(sd.studentId, { shared: 0, items: [] }); studentBillData.set(sd.studentId, { shared: 0, items: [] });
} }
@@ -158,6 +177,7 @@ export class BillsService {
periodStart, periodStart,
periodEnd, periodEnd,
}) })
.andWhere('pe.billId IS NULL')
.getMany(); .getMany();
const personalMap = new Map<number, number>(); const personalMap = new Map<number, number>();
@@ -177,38 +197,113 @@ export class BillsService {
} }
// 合并所有涉及的学生 // 合并所有涉及的学生,并在同一个事务中生成整批账单,避免中途失败留下半批数据。
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]); const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
// 生成账单 const bills = await this.dataSource.transaction(async (manager) => {
const bills: Bill[] = []; const generated: Bill[] = [];
for (const studentId of allStudentIds) { for (const studentId of allStudentIds) {
const shared = studentBillData.get(studentId)?.shared || 0; const shared = studentBillData.get(studentId)?.shared || 0;
const personal = personalMap.get(studentId) || 0; const personal = personalMap.get(studentId) || 0;
const total = Number((shared + personal).toFixed(2)); const total = Number((shared + personal).toFixed(2));
let bill = await manager.save(
const bill = this.billRepo.create({ manager.create(Bill, {
studentId, studentId,
periodStart, periodStart,
periodEnd, periodEnd,
sharedAmount: Number(shared.toFixed(2)), sharedAmount: Number(shared.toFixed(2)),
personalAmount: personal, personalAmount: personal,
totalAmount: total, totalAmount: total,
status: 'draft', source: 'batch',
}); paidAmount: 0,
const savedBill = await this.billRepo.save(bill); outstandingAmount: total,
status: 'unpaid',
// 保存明细 }),
);
const items = [ const items = [
...(studentBillData.get(studentId)?.items || []), ...(studentBillData.get(studentId)?.items || []),
...(personalItems.get(studentId) || []), ...(personalItems.get(studentId) || []),
]; ];
for (const item of items) { for (const item of items) {
await this.itemRepo.save(this.itemRepo.create({ ...item, billId: savedBill.id })); await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));
} }
bills.push(savedBill); 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 };
} }
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills }; 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,
periodEnd: string,
recordedBy?: number,
) {
return this.dataSource.transaction(async (manager) => {
let bill = await manager.save(
manager.create(Bill, {
studentId: expense.studentId,
periodStart,
periodEnd,
sharedAmount: 0,
personalAmount: Number(expense.amount),
totalAmount: Number(expense.amount),
source: 'student_utility',
paidAmount: 0,
outstandingAmount: Number(expense.amount),
status: 'unpaid',
}),
);
await manager.save(
manager.create(BillItem, {
billId: bill.id,
roomId: expense.roomId,
expenseType: expense.expenseType,
description: expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'),
days: 0,
totalRoomDays: 0,
roomTotalAmount: expense.amount,
studentAmount: expense.amount,
}),
);
expense.billId = bill.id;
await manager.save(expense);
bill = await this.walletsService.debitBill(manager, bill, recordedBy);
return bill;
});
} }
async findAll(query?: { async findAll(query?: {
@@ -240,70 +335,98 @@ export class BillsService {
return withDeposit; return withDeposit;
} }
/** /** 查询时附加钱包余额和实际支付数据。 */
* 给账单挂上"押金联动"信息:
* - availableDeposit: 当前学生处于已缴未退状态(paid)的押金总额
* - depositApplied: 本张账单可从押金抵扣的金额min(押金, 应付总额)
* - amountAfterDeposit: 抵扣押金后学生需另外支付的金额
*/
private async attachDepositInfo(bills: Bill[]): Promise<any[]> { private async attachDepositInfo(bills: Bill[]): Promise<any[]> {
if (!bills || bills.length === 0) return bills; if (!bills?.length) return bills;
const studentIds = Array.from(new Set(bills.map((b) => b.studentId))); const studentIds = Array.from(new Set(bills.map((bill) => bill.studentId)));
if (studentIds.length === 0) return bills; const wallets = await this.dataSource
const deposits = await this.depositRepo .getRepository(StudentWallet)
.createQueryBuilder('d') .createQueryBuilder('wallet')
.where('d.studentId IN (:...ids)', { ids: studentIds }) .where('wallet.studentId IN (:...ids)', { ids: studentIds })
.andWhere('d.status = :status', { status: 'paid' })
.getMany(); .getMany();
const depMap = new Map<number, number>(); const balanceMap = new Map(wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]));
for (const d of deposits) { return bills.map((bill) => ({
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0)); ...bill,
} walletBalance: Number((balanceMap.get(bill.studentId) || 0).toFixed(2)),
return bills.map((b) => { paidAmount: Number(bill.paidAmount || 0),
const total = Number(b.totalAmount || 0); outstandingAmount: Number(bill.outstandingAmount || 0),
const available = Number((depMap.get(b.studentId) || 0).toFixed(2)); }));
const applied = Number(Math.min(available, total).toFixed(2));
const afterDeposit = Number(Math.max(0, total - applied).toFixed(2));
return Object.assign({}, b, {
availableDeposit: available,
depositApplied: applied,
amountAfterDeposit: afterDeposit,
});
});
} }
async updateStatus(id: number, dto: UpdateBillStatusDto) { async updateStatus(id: number, dto: UpdateBillStatusDto) {
const bill = await this.billRepo.findOne({ where: { id } }); const bill = await this.billRepo.findOne({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在'); if (!bill) throw new NotFoundException('账单不存在');
this.assertStatusMatchesAmounts(bill, dto.status);
bill.status = dto.status; bill.status = dto.status;
return this.billRepo.save(bill); return this.billRepo.save(bill);
} }
async batchUpdateStatus(ids: number[], status: string) { 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('账单状态无效');
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);
await this.billRepo await this.billRepo
.createQueryBuilder() .createQueryBuilder()
.update() .update()
.set({ status }) .set({ status })
.where('id IN (:...ids)', { ids }) .where('id IN (:...ids)', { ids: uniqueIds })
.execute(); .execute();
return { message: `成功更新 ${ids.length} 条账单状态` }; return { message: `成功更新 ${uniqueIds.length} 条账单状态` };
}
async cancel(id: number, dto: CancelBillDto, recordedBy?: number) {
const reason = dto.reason?.trim();
if (!reason) throw new BadRequestException('取消原因不能为空');
return this.dataSource.transaction(async (manager) => {
const bill = await manager.findOne(Bill, { where: { id } });
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);
});
} }
async remove(id: number) { async remove(id: number) {
const exists = await this.billRepo.findOne({ where: { id } }); const exists = await this.billRepo.findOne({ where: { id } });
if (!exists) throw new NotFoundException('账单不存在'); if (!exists) throw new NotFoundException('账单不存在');
await this.itemRepo.delete({ billId: id }); if (Number(exists.paidAmount) > 0 || exists.status === 'cancelled') {
await this.billRepo.delete(id); throw new BadRequestException('已发生资金流水的账单不能删除,请使用取消账单');
}
await this.dataSource.transaction(async (manager) => {
await manager.delete(BillItem, { billId: id });
await manager.update(PersonalExpense, { billId: id }, { billId: null });
await manager.delete(Bill, id);
});
return { message: '账单已删除' }; return { message: '账单已删除' };
} }
async batchRemove(ids: number[]) { async batchRemove(ids: number[]) {
await this.itemRepo const uniqueIds = [...new Set(ids || [])];
.createQueryBuilder() if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的账单');
.delete() const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
.where('billId IN (:...ids)', { ids }) if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
.execute(); if (bills.some((bill) => Number(bill.paidAmount) > 0 || bill.status === 'cancelled')) {
await this.billRepo.createQueryBuilder().delete().where('id IN (:...ids)', { ids }).execute(); throw new BadRequestException('选中账单包含资金流水,不能批量删除');
return { message: `成功删除 ${ids.length} 条账单` }; }
await this.dataSource.transaction(async (manager) => {
await manager.delete(BillItem, { billId: In(uniqueIds) });
await manager.update(PersonalExpense, { billId: In(uniqueIds) }, { billId: null });
await manager.delete(Bill, uniqueIds);
});
return { message: `成功删除 ${uniqueIds.length} 条账单` };
}
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;
if (!matches) throw new BadRequestException('账单状态必须与实付及未付金额一致');
} }
} }

View File

@@ -1,14 +1,35 @@
import { IsString, IsOptional } from 'class-validator'; import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class GenerateBillsDto { export class GenerateBillsDto {
@IsString() @IsString()
periodStart: string; // YYYY-MM-DD @Matches(/^\d{4}-\d{2}$/)
billingMonth: string;
@IsOptional()
@IsString() @IsString()
periodEnd: string; // YYYY-MM-DD periodStart?: string;
@IsOptional()
@IsString()
periodEnd?: string;
} }
export class UpdateBillStatusDto { export class UpdateBillStatusDto {
@IsString() @IsIn(['unpaid', 'partially_paid', 'paid'])
status: 'draft' | 'confirmed' | 'paid'; status: 'unpaid' | 'partially_paid' | 'paid';
}
export class CancelBillDto {
@IsString()
@IsNotEmpty()
@Matches(/\S/)
@MaxLength(300)
reason: string;
}
export class BatchUpdateBillStatusDto extends UpdateBillStatusDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
ids: number[];
} }

View File

@@ -0,0 +1,69 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ClassesService } from './classes.service';
function createService(classRepo: Record<string, jest.Mock>, classTeacherRepo = {}) {
return new ClassesService(
classRepo as never,
{} as never,
classTeacherRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
}
describe('ClassesService — archive and teacher boundaries', () => {
it('rejects repeated archive and restore operations', async () => {
await expect(
createService({ findOne: jest.fn().mockResolvedValue({ isArchived: true }) }).archive(1),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
createService({ findOne: jest.fn().mockResolvedValue({ isArchived: false }) }).restore(1),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects assigning a teacher to a missing class', async () => {
const classTeacherRepo = { findOne: jest.fn(), create: jest.fn(), save: jest.fn() };
await expect(
createService({ findOne: jest.fn().mockResolvedValue(null) }, classTeacherRepo).addTeacher(
9,
{
userId: 2,
roleType: 'head_teacher',
},
),
).rejects.toBeInstanceOf(NotFoundException);
expect(classTeacherRepo.save).not.toHaveBeenCalled();
});
it('rejects duplicate teacher roles', async () => {
const classTeacherRepo = {
findOne: jest.fn().mockResolvedValue({ id: 3 }),
create: jest.fn(),
save: jest.fn(),
};
await expect(
createService(
{ findOne: jest.fn().mockResolvedValue({ id: 1 }) },
classTeacherRepo,
).addTeacher(1, {
userId: 2,
roleType: 'head_teacher',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects removing a teacher or assignment that is not in the class', async () => {
const classTeacherRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
delete: jest.fn(),
};
const service = createService({}, classTeacherRepo);
await expect(service.removeTeacher(1, 2)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.removeTeacherAssignment(1, 3)).rejects.toBeInstanceOf(NotFoundException);
expect(classTeacherRepo.delete).not.toHaveBeenCalled();
});
});

View File

@@ -41,7 +41,10 @@ describe('ClassesService — teacher data scope', () => {
it('clears denormalized teacher ids when the last teacher for that role is removed', async () => { it('clears denormalized teacher ids when the last teacher for that role is removed', async () => {
const classRepo = { update: jest.fn() }; const classRepo = { update: jest.fn() };
const classTeacherRepo = { const classTeacherRepo = {
find: jest.fn().mockResolvedValue([]), find: jest
.fn()
.mockResolvedValueOnce([{ id: 1, classId: 8, userId: 21 }])
.mockResolvedValueOnce([]),
delete: jest.fn().mockResolvedValue({ affected: 1 }), delete: jest.fn().mockResolvedValue({ affected: 1 }),
}; };
const service = new ClassesService( const service = new ClassesService(

View File

@@ -286,6 +286,7 @@ export class ClassesService {
async archive(id: number) { async archive(id: number) {
const cls = await this.classRepo.findOne({ where: { id } }); const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在'); if (!cls) throw new NotFoundException('班级不存在');
if (cls.isArchived) throw new BadRequestException('班级已归档');
await this.classRepo.update(id, { isArchived: true }); await this.classRepo.update(id, { isArchived: true });
return { success: true }; return { success: true };
} }
@@ -294,6 +295,7 @@ export class ClassesService {
async restore(id: number) { async restore(id: number) {
const cls = await this.classRepo.findOne({ where: { id } }); const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在'); if (!cls) throw new NotFoundException('班级不存在');
if (!cls.isArchived) throw new BadRequestException('班级未归档');
await this.classRepo.update(id, { isArchived: false }); await this.classRepo.update(id, { isArchived: false });
return { success: true }; return { success: true };
} }
@@ -392,6 +394,9 @@ export class ClassesService {
} }
async addTeacher(classId: number, dto: AddTeacherDto) { async addTeacher(classId: number, dto: AddTeacherDto) {
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) throw new NotFoundException('班级不存在');
const existing = await this.classTeacherRepo.findOne({ const existing = await this.classTeacherRepo.findOne({
where: { classId, userId: dto.userId, roleType: dto.roleType }, where: { classId, userId: dto.userId, roleType: dto.roleType },
}); });
@@ -410,12 +415,18 @@ export class ClassesService {
} }
async removeTeacher(classId: number, userId: number) { async removeTeacher(classId: number, userId: number) {
const assignments = await this.classTeacherRepo.find({ where: { classId, userId } });
if (assignments.length === 0) throw new NotFoundException('教师未分配到该班级');
await this.classTeacherRepo.delete({ classId, userId }); await this.classTeacherRepo.delete({ classId, userId });
await this.syncClassTeacherIds(classId); await this.syncClassTeacherIds(classId);
return { success: true }; return { success: true };
} }
async removeTeacherAssignment(classId: number, assignmentId: number) { async removeTeacherAssignment(classId: number, assignmentId: number) {
const assignment = await this.classTeacherRepo.findOne({
where: { id: assignmentId, classId },
});
if (!assignment) throw new NotFoundException('教师角色分配不存在');
await this.classTeacherRepo.delete({ id: assignmentId, classId }); await this.classTeacherRepo.delete({ id: assignmentId, classId });
await this.syncClassTeacherIds(classId); await this.syncClassTeacherIds(classId);
return { success: true }; return { success: true };

View File

@@ -1,43 +1,81 @@
import { IsOptional, IsString, IsNotEmpty, IsInt, IsArray, IsDateString, IsEnum, ArrayNotEmpty, ValidateNested } from 'class-validator'; import {
IsOptional,
IsString,
IsNotEmpty,
IsInt,
IsArray,
IsDateString,
IsEnum,
ArrayNotEmpty,
ValidateNested,
Min,
} from 'class-validator';
import { Type, Transform } from 'class-transformer'; import { Type, Transform } from 'class-transformer';
import { ClassType, ClassStatus, TeacherRoleType } from '../../entities'; import { ClassType, ClassStatus, TeacherRoleType } from '../../entities';
export class ClassTeacherItemDto {
@IsInt()
userId: number;
@IsEnum(TeacherRoleType)
roleType: string;
@IsOptional()
@IsString()
subject?: string;
}
export class CreateClassDto { export class CreateClassDto {
@IsString() @IsNotEmpty() @IsString()
@IsNotEmpty()
name: string; name: string;
@IsString() @IsNotEmpty() @IsString()
@IsNotEmpty()
code: string; code: string;
@IsEnum(ClassType)
@IsEnum(ClassType) @IsString() @IsNotEmpty() @IsString()
@IsNotEmpty()
classType: string; classType: string;
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
startDate?: string; startDate?: string;
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
endDate?: string; endDate?: string;
@IsEnum(ClassStatus) @IsOptional() @IsString() @IsEnum(ClassStatus)
@IsOptional()
@IsString()
status?: string; status?: string;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
headTeacherId?: number; headTeacherId?: number;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
lifeTeacherId?: number; lifeTeacherId?: number;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
academicTeacherId?: number; academicTeacherId?: number;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
@Min(0)
maxStudents?: number; maxStudents?: number;
@IsOptional() @IsString() @IsOptional()
@IsString()
notes?: string; notes?: string;
@IsOptional() @IsArray() @IsOptional()
@IsArray()
@IsInt({ each: true })
studentIds?: number[]; studentIds?: number[];
@IsOptional() @IsOptional()
@@ -46,55 +84,73 @@ export class CreateClassDto {
@Type(() => ImportUserItem) @Type(() => ImportUserItem)
users?: ImportUserItem[]; users?: ImportUserItem[];
@IsOptional() @IsArray() @IsOptional()
teachers?: Array<{ userId: number; roleType: string; subject?: string }>; @IsArray()
@ValidateNested({ each: true })
@Type(() => ClassTeacherItemDto)
teachers?: ClassTeacherItemDto[];
} }
export class UpdateClassDto { export class UpdateClassDto {
@IsOptional() @IsString() @IsOptional()
@IsString()
name?: string; name?: string;
@IsOptional() @IsString() @IsOptional()
@IsString()
code?: string; code?: string;
@IsEnum(ClassType)
@IsEnum(ClassType) @IsOptional() @IsString() @IsOptional()
@IsString()
classType?: string; classType?: string;
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
startDate?: string; startDate?: string;
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
endDate?: string; endDate?: string;
@IsEnum(ClassStatus) @IsOptional() @IsString() @IsEnum(ClassStatus)
@IsOptional()
@IsString()
status?: string; status?: string;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
headTeacherId?: number; headTeacherId?: number;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
lifeTeacherId?: number; lifeTeacherId?: number;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
academicTeacherId?: number; academicTeacherId?: number;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
@Min(0)
maxStudents?: number; maxStudents?: number;
@IsOptional() @IsString() @IsOptional()
@IsString()
notes?: string; notes?: string;
} }
export class QueryClassDto { export class QueryClassDto {
@IsOptional()
@IsOptional() @IsString() @IsString()
status?: string; status?: string;
@IsOptional() @IsString() @IsOptional()
@IsString()
classType?: string; classType?: string;
@IsOptional() @IsString() @IsOptional()
@IsString()
keyword?: string; keyword?: string;
@IsOptional() @IsOptional()
@@ -108,7 +164,8 @@ export class QueryClassDto {
} }
export class AddStudentsDto { export class AddStudentsDto {
@IsArray() @IsInt({ each: true }) @IsArray()
@IsInt({ each: true })
studentIds: number[]; studentIds: number[];
} }
@@ -119,23 +176,28 @@ export class AddTeacherDto {
@IsEnum(TeacherRoleType) @IsEnum(TeacherRoleType)
roleType: string; roleType: string;
@IsOptional() @IsString() @IsOptional()
@IsString()
subject?: string; subject?: string;
} }
export class QueryClassScheduleDto { export class QueryClassScheduleDto {
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
startDate?: string; startDate?: string;
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
endDate?: string; endDate?: string;
} }
export class QueryClassAttendanceSummaryDto { export class QueryClassAttendanceSummaryDto {
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
startDate?: string; startDate?: string;
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
endDate?: string; endDate?: string;
} }
export class BatchImportStudentsDto { export class BatchImportStudentsDto {
@@ -147,12 +209,15 @@ export class BatchImportStudentsDto {
} }
export class ImportUserItem { export class ImportUserItem {
@IsString() @IsNotEmpty() @IsString()
@IsNotEmpty()
dingUserId: string; dingUserId: string;
@IsString() @IsNotEmpty() @IsString()
@IsNotEmpty()
name: string; name: string;
@IsOptional() @IsString() @IsOptional()
@IsString()
mobile?: string; mobile?: string;
} }

View File

@@ -0,0 +1,42 @@
import { validate } from 'class-validator';
import { CreateRentalDto } from './rental.dto';
const createRental = (overrides: Partial<CreateRentalDto> = {}) =>
Object.assign(new CreateRentalDto(), {
classroomId: 1,
lesseeOrganizationId: 2,
startDate: '2026-08-01',
endDate: '2026-08-31',
...overrides,
});
describe('classroom rental DTO boundaries', () => {
it.each(['2026-02-31', '2026-08-01T00:00:00Z', '2026-8-1'])(
'rejects invalid or non-date-only value %s',
async (startDate) => {
const errors = await validate(createRental({ startDate }));
expect(errors.some((error) => error.property === 'startDate')).toBe(true);
},
);
it.each([
['dailyRate', 0],
['totalAmount', -1],
] as const)('rejects non-positive %s', async (field, value) => {
const errors = await validate(createRental({ [field]: value }));
expect(errors.some((error) => error.property === field)).toBe(true);
});
it('accepts positive amounts and a leap-day date', async () => {
await expect(
validate(
createRental({
startDate: '2028-02-29',
endDate: '2028-02-29',
dailyRate: 0.01,
totalAmount: 0.01,
}),
),
).resolves.toHaveLength(0);
});
});

View File

@@ -1,4 +1,4 @@
import { IsOptional, IsString, IsInt, IsNumber, IsDateString } from 'class-validator'; import { IsOptional, IsString, IsInt, IsNumber, IsISO8601, Matches, Min } from 'class-validator';
export class CreateRentalDto { export class CreateRentalDto {
@IsInt() @IsInt()
@@ -11,18 +11,22 @@ export class CreateRentalDto {
@IsInt() @IsInt()
lesseeOrganizationId: number; lesseeOrganizationId: number;
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
startDate: string; startDate: string;
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
endDate: string; endDate: string;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0.01)
dailyRate?: number; dailyRate?: number;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0.01)
totalAmount?: number; totalAmount?: number;
@IsOptional() @IsOptional()
@@ -44,19 +48,23 @@ export class UpdateRentalDto {
lesseeOrganizationId?: number; lesseeOrganizationId?: number;
@IsOptional() @IsOptional()
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
startDate?: string; startDate?: string;
@IsOptional() @IsOptional()
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
endDate?: string; endDate?: string;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0.01)
dailyRate?: number; dailyRate?: number;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0.01)
totalAmount?: number; totalAmount?: number;
@IsOptional() @IsOptional()

View File

@@ -7,6 +7,7 @@ import {
SubjectName, SubjectName,
} from '../authorization'; } from '../authorization';
import { RequirePermission } from '../auth/decorators/permission.decorator'; import { RequirePermission } from '../auth/decorators/permission.decorator';
import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dto/dashboard-query.dto';
interface RequestUser { interface RequestUser {
id: number; id: number;
@@ -43,28 +44,18 @@ export class DashboardController {
} }
@Get('gantt') @Get('gantt')
getGanttData( getGanttData(@Query() query: DashboardGanttQueryDto) {
@Query('periodStart') periodStart?: string, return this.service.getGanttData(query);
@Query('periodEnd') periodEnd?: string,
@Query('building') building?: string,
) {
return this.service.getGanttData({ periodStart, periodEnd, building });
} }
@Get('expense-stats') @Get('expense-stats')
getExpenseStats( getExpenseStats(@Query() query: DashboardPeriodQueryDto) {
@Query('periodStart') periodStart?: string, return this.service.getExpenseStats(query.periodStart, query.periodEnd);
@Query('periodEnd') periodEnd?: string,
) {
return this.service.getExpenseStats(periodStart, periodEnd);
} }
@Get('room-ranking') @Get('room-ranking')
getRoomExpenseRanking( getRoomExpenseRanking(@Query() query: DashboardPeriodQueryDto) {
@Query('periodStart') periodStart?: string, return this.service.getRoomExpenseRanking(query.periodStart, query.periodEnd);
@Query('periodEnd') periodEnd?: string,
) {
return this.service.getRoomExpenseRanking(periodStart, periodEnd);
} }
@Get('class-attendance-ranking') @Get('class-attendance-ranking')

View File

@@ -42,3 +42,45 @@ describe('DashboardService — teacher class scope', () => {
}); });
}); });
}); });
describe('DashboardService — boundary conditions', () => {
it('uses a deny-all predicate instead of an empty SQL IN list', async () => {
const qb = createQb();
const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
const service = new DashboardService(
{} 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,
);
await (service as unknown as {
getAttendanceTrend: (today: string, classIds: number[]) => Promise<unknown>;
}).getAttendanceTrend('2026-07-14', []);
expect(qb.andWhere).toHaveBeenCalledWith('1 = 0');
});
it.each([
['getGanttData', [{ periodStart: '2026-08-01', periodEnd: '2026-07-31' }]],
['getExpenseStats', ['2026-08-01', '2026-07-31']],
['getRoomExpenseRanking', ['2026-08-01', '2026-07-31']],
] as const)('rejects a reversed period in %s', async (method, args) => {
const service = new DashboardService(
{} 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,
);
await expect((service[method] as (...values: never[]) => Promise<unknown>)(...(args as never[])))
.rejects.toThrow('结束日期不能早于开始日期');
});
it('uses the China calendar date when the server timezone is behind China', () => {
const service = new DashboardService(
{} 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,
);
expect((service as unknown as { getChinaDate: (date: Date) => string })
.getChinaDate(new Date('2026-07-13T16:30:00.000Z'))).toBe('2026-07-14');
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, Not, MoreThanOrEqual, In } from 'typeorm'; import { Repository, IsNull, Not, MoreThanOrEqual, In } from 'typeorm';
import { Room } from '../entities/room.entity'; import { Room } from '../entities/room.entity';
@@ -40,8 +40,7 @@ export class DashboardService {
} }
async getStats(accessibleClassIds?: number[]) { async getStats(accessibleClassIds?: number[]) {
const today = new Date(); const todayStr = this.getChinaDate(new Date());
const todayStr = today.toISOString().slice(0, 10);
const currentMonth = todayStr.slice(0, 7); // YYYY-MM const currentMonth = todayStr.slice(0, 7); // YYYY-MM
const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } }); const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } });
@@ -180,6 +179,10 @@ export class DashboardService {
accessibleClassIds?: number[], accessibleClassIds?: number[],
) { ) {
if (accessibleClassIds) { if (accessibleClassIds) {
if (accessibleClassIds.length === 0) {
qb.andWhere('1 = 0');
return;
}
qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds }); qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds });
} }
} }
@@ -260,6 +263,7 @@ export class DashboardService {
// 甘特图数据:每个宿舍的入住时间线 // 甘特图数据:每个宿舍的入住时间线
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) { async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) {
this.assertPeriodRange(query?.periodStart, query?.periodEnd);
const qb = this.occRepo const qb = this.occRepo
.createQueryBuilder('o') .createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student') .leftJoinAndSelect('o.student', 'student')
@@ -302,6 +306,7 @@ export class DashboardService {
} }
// 费用统计 // 费用统计
async getExpenseStats(periodStart?: string, periodEnd?: string) { async getExpenseStats(periodStart?: string, periodEnd?: string) {
this.assertPeriodRange(periodStart, periodEnd);
const qb = this.expRepo const qb = this.expRepo
.createQueryBuilder('e') .createQueryBuilder('e')
.select('e.expenseType', 'type') .select('e.expenseType', 'type')
@@ -314,6 +319,7 @@ export class DashboardService {
// 各宿舍费用排行 // 各宿舍费用排行
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) { async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
this.assertPeriodRange(periodStart, periodEnd);
const qb = this.expRepo const qb = this.expRepo
.createQueryBuilder('e') .createQueryBuilder('e')
.leftJoin('e.room', 'room') .leftJoin('e.room', 'room')
@@ -368,7 +374,7 @@ export class DashboardService {
where: { status: 'available' as const }, where: { status: 'available' as const },
order: { building: 'ASC', name: 'ASC' }, order: { building: 'ASC', name: 'ASC' },
}); });
const today = new Date().toISOString().slice(0, 10); const today = this.getChinaDate(new Date());
const schedQb = this.scheduleRepo const schedQb = this.scheduleRepo
.createQueryBuilder('s') .createQueryBuilder('s')
.select('s.classroomId', 'classroomId') .select('s.classroomId', 'classroomId')
@@ -400,12 +406,31 @@ export class DashboardService {
})); }));
} }
private assertPeriodRange(periodStart?: string, periodEnd?: string) {
if (periodStart && periodEnd && periodStart > periodEnd) {
throw new BadRequestException('结束日期不能早于开始日期');
}
}
private getChinaDate(date: Date): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const values = Object.fromEntries(
parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
);
return `${values.year}-${values.month}-${values.day}`;
}
async getClassroomUtilizationStats() { async getClassroomUtilizationStats() {
const totalClassrooms = await this.classroomRepo.count({ const totalClassrooms = await this.classroomRepo.count({
where: { status: 'available' as const }, where: { status: 'available' as const },
}); });
const today = new Date().toISOString().slice(0, 10); const today = this.getChinaDate(new Date());
// Count classrooms with active schedules today // Count classrooms with active schedules today
const schedQb = this.scheduleRepo const schedQb = this.scheduleRepo

View File

@@ -0,0 +1,28 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dashboard-query.dto';
describe('dashboard query boundaries', () => {
it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])(
'rejects invalid or non-date-only value %s',
async (periodStart) => {
const dto = plainToInstance(DashboardPeriodQueryDto, { periodStart });
expect((await validate(dto)).some((error) => error.property === 'periodStart')).toBe(true);
},
);
it('accepts a valid date range and bounded building name', async () => {
const dto = plainToInstance(DashboardGanttQueryDto, {
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
building: 'A座',
});
expect(await validate(dto)).toEqual([]);
});
it('rejects an excessively long building name', async () => {
const dto = plainToInstance(DashboardGanttQueryDto, { building: 'A'.repeat(51) });
expect((await validate(dto)).some((error) => error.property === 'building')).toBe(true);
});
});

View File

@@ -0,0 +1,20 @@
import { IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class DashboardPeriodQueryDto {
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
periodStart?: string;
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
periodEnd?: string;
}
export class DashboardGanttQueryDto extends DashboardPeriodQueryDto {
@IsOptional()
@IsString()
@MaxLength(50)
building?: string;
}

View File

@@ -11,6 +11,8 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
async onApplicationBootstrap(): Promise<void> { async onApplicationBootstrap(): Promise<void> {
await this.ensureAiConfigTable(); await this.ensureAiConfigTable();
await this.ensureCourseAttendanceSchema(); await this.ensureCourseAttendanceSchema();
await this.ensureAttendanceDevicesSchema();
await this.ensureStudentWalletSchema();
await this.backfillOrganizations(); await this.backfillOrganizations();
await this.normalizeClassDates(); await this.normalizeClassDates();
await this.protectAttendanceHistory(); await this.protectAttendanceHistory();
@@ -21,6 +23,107 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
await this.normalizeClassroomStatuses(); await this.normalizeClassroomStatuses();
} }
private async ensureAttendanceDevicesSchema(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const isMySQL = this.dataSource.options.type === 'mysql';
const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
await runner.query(`CREATE TABLE IF NOT EXISTS attendance_devices (
id ${pk},
device_sn VARCHAR(100) NOT NULL,
device_name VARCHAR(100) NOT NULL,
classroom_id INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
location VARCHAR(200),
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
const table = await runner.getTable('attendance_devices');
const columnNames = new Set(table?.columns.map((column) => column.name) ?? []);
const additions: Array<[string, string]> = [
['device_sn', 'VARCHAR(100) NOT NULL DEFAULT \'\''],
['device_name', 'VARCHAR(100) NOT NULL DEFAULT \'\''],
['classroom_id', 'INTEGER NOT NULL DEFAULT 0'],
['status', "VARCHAR(20) NOT NULL DEFAULT 'active'"],
['location', 'VARCHAR(200)'],
['notes', 'TEXT'],
['created_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'],
['updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'],
];
for (const [name, definition] of additions) {
if (!columnNames.has(name)) await runner.query(`ALTER TABLE attendance_devices ADD COLUMN ${name} ${definition}`);
}
const refreshed = await runner.getTable('attendance_devices');
const createIndex = async (sql: string) => {
try {
await runner.query(sql);
} catch {
// Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.
}
};
const uniqueSn = refreshed?.indices.some((index) => index.columnNames.length === 1 && index.columnNames[0] === 'device_sn' && index.isUnique);
if (!uniqueSn) {
await createIndex(
isMySQL
? 'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)'
: 'CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_devices_device_sn ON attendance_devices (device_sn)',
);
}
await createIndex(
isMySQL
? 'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)'
: 'CREATE INDEX IF NOT EXISTS idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)',
);
} finally {
await runner.release();
}
}
private async ensureStudentWalletSchema(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const isMySQL = this.dataSource.options.type === 'mysql';
const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
await runner.query(`CREATE TABLE IF NOT EXISTS student_wallets (
id ${pk}, student_id INTEGER NOT NULL UNIQUE, balance DECIMAL(12,2) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
await runner.query(`CREATE TABLE IF NOT EXISTS wallet_transactions (
id ${pk}, student_id INTEGER NOT NULL, bill_id INTEGER, type VARCHAR(30) NOT NULL,
amount DECIMAL(12,2) NOT NULL, balance_after DECIMAL(12,2) NOT NULL,
description VARCHAR(300), recorded_by INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
const bills = await runner.getTable('bills');
if (bills) {
const columns = new Set(bills.columns.map((column) => column.name));
const additions = [
['source', "VARCHAR(30) NOT NULL DEFAULT 'batch'"],
['paid_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'],
['outstanding_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'],
['cancelled_at', 'DATETIME'],
['cancel_reason', 'VARCHAR(300)'],
];
for (const [name, definition] of additions) {
if (!columns.has(name)) await runner.query(`ALTER TABLE bills ADD COLUMN ${name} ${definition}`);
}
await runner.query("UPDATE bills SET outstanding_amount = total_amount WHERE outstanding_amount = 0 AND status <> 'paid'");
await runner.query("UPDATE bills SET paid_amount = total_amount, outstanding_amount = 0 WHERE status = 'paid'");
await runner.query("UPDATE bills SET status = 'unpaid' WHERE status IN ('draft', 'confirmed')");
}
const personalExpenses = await runner.getTable('personal_expenses');
if (personalExpenses && !personalExpenses.columns.some((column) => column.name === 'bill_id')) {
await runner.query('ALTER TABLE personal_expenses ADD COLUMN bill_id INTEGER');
}
} finally {
await runner.release();
}
}
private async removeUnusedClassroomColumns(): Promise<void> { private async removeUnusedClassroomColumns(): Promise<void> {
const runner = this.dataSource.createQueryRunner(); const runner = this.dataSource.createQueryRunner();
await runner.connect(); await runner.connect();
@@ -216,7 +319,11 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
const runner = this.dataSource.createQueryRunner(); const runner = this.dataSource.createQueryRunner();
await runner.connect(); await runner.connect();
try { try {
const tables = await runner.getTables(['attendance_records', 'attendance_sessions']); const tables = await runner.getTables([
'class_schedule',
'attendance_records',
'attendance_sessions',
]);
const tableNames = new Set(tables.map((table) => table.name)); const tableNames = new Set(tables.map((table) => table.name));
const isMySQL = this.dataSource.options.type === 'mysql'; const isMySQL = this.dataSource.options.type === 'mysql';
@@ -243,6 +350,17 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
`); `);
} }
if (tableNames.has('class_schedule')) {
const scheduleTable = await runner.getTable('class_schedule');
const scheduleColumns = new Set(scheduleTable?.columns.map((column) => column.name) ?? []);
if (!scheduleColumns.has('attendance_advance_minutes')) {
await runner.query(
'ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes INTEGER NOT NULL DEFAULT 30',
);
this.logger.log('已为排课添加课前签到分钟配置');
}
}
const attendanceTable = await runner.getTable('attendance_records'); const attendanceTable = await runner.getTable('attendance_records');
const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []); const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []);
if (!columnNames.has('schedule_id')) { if (!columnNames.has('schedule_id')) {
@@ -397,25 +515,57 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
private async normalizeClassDates(): Promise<void> { private async normalizeClassDates(): Promise<void> {
const driver = this.dataSource.options.type; const driver = this.dataSource.options.type;
const dateExpression = (column: string) => let columns: Array<'start_date' | 'end_date'> = ['start_date', 'end_date'];
driver === 'mysql' ? `DATE(${column})` : `substr(${column}, 1, 10)`;
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const table = await runner.getTable('classes');
if (!table) return;
// Fresh MySQL schemas created by TypeORM already use native DATE columns.
// This cleanup is only for legacy schemas that stored dates as strings;
// comparing a native DATE column with '' raises ER_TRUNCATED_WRONG_VALUE
// in strict SQL mode.
if (driver === 'mysql') {
columns = columns.filter((columnName) => {
const column = table.columns.find((item) => item.name === columnName);
const type = String(column?.type ?? '').toLowerCase();
return !['date', 'datetime', 'timestamp'].includes(type);
});
if (columns.length === 0) return;
}
} finally {
await runner.release();
}
const columnText = (column: string) =>
driver === 'mysql' ? `CAST(${column} AS CHAR)` : column;
const firstTenChars = (column: string) =>
driver === 'mysql'
? `NULLIF(LEFT(${columnText(column)}, 10), '')`
: `NULLIF(substr(${column}, 1, 10), '')`;
const normalizedDate = (column: string) => `CASE
WHEN ${column} IS NULL THEN NULL
ELSE ${firstTenChars(column)}
END`;
const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length'; const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length';
const needsNormalization = (column: string) => `(
${column} IS NOT NULL
AND (${columnText(column)} = '' OR ${lengthFunction}(${columnText(column)}) > 10)
)`;
const assignments = columns
.map((column) => `${column} = ${normalizedDate(column)}`)
.join(',\n ');
const predicates = columns.map((column) => needsNormalization(column)).join('\n OR ');
const result = await this.dataSource.transaction((manager) => const result = await this.dataSource.transaction((manager) =>
manager.query(` manager.query(`
UPDATE classes UPDATE classes
SET SET
start_date = CASE ${assignments}
WHEN start_date IS NULL OR start_date = '' THEN start_date
ELSE ${dateExpression('start_date')}
END,
end_date = CASE
WHEN end_date IS NULL OR end_date = '' THEN end_date
ELSE ${dateExpression('end_date')}
END
WHERE WHERE
(start_date IS NOT NULL AND ${lengthFunction}(start_date) > 10) ${predicates}
OR (end_date IS NOT NULL AND ${lengthFunction}(end_date) > 10)
`), `),
); );

View File

@@ -209,6 +209,28 @@ describe('DatabaseMigrationsService — course attendance schema', () => {
expect(runner.release).toHaveBeenCalled(); expect(runner.release).toHaveBeenCalled();
}); });
it('adds the configurable attendance window to existing schedules', async () => {
const runner = mockRunner({
getTables: [
{ name: 'class_schedule', columns: [{ name: 'id' }] },
{ name: 'attendance_records', columns: [{ name: 'id' }] },
{ name: 'attendance_sessions', columns: [{ name: 'id' }] },
],
});
runner.getTable.mockImplementation(async (name: string) =>
name === 'class_schedule'
? { name, columns: [{ name: 'id' }] }
: { name, columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }] },
);
await bootstrapCourseAttendance(runner);
await service.ensureCourseAttendanceSchema();
expect(runner.query).toHaveBeenCalledWith(
expect.stringContaining('ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes'),
);
});
it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => { it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => {
const runner = mockRunner({ const runner = mockRunner({
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }], getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],

View File

@@ -0,0 +1,53 @@
import { BadRequestException } from '@nestjs/common';
import { DepositsService } from './deposits.service';
import { Deposit } from '../entities/deposit.entity';
function serviceWith(deposit?: Partial<Deposit>) {
const record = deposit ? ({ id: 1, studentId: 2, ...deposit } as Deposit) : null;
const repo = {
findOne: jest.fn(async (options: any) => options?.where?.studentId ? record : record),
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
};
const installmentRepo = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
};
const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 2 }) };
return { service: new DepositsService(repo as any, installmentRepo as any, studentRepo as any), repo, installmentRepo };
}
describe('DepositsService boundaries', () => {
it('rejects an installment amount that rounds to zero', async () => {
const { service, installmentRepo } = serviceWith({ amount: 500, status: 'paid' });
await expect(service.addInstallment(1, 0.004, '2026-08-01')).rejects.toBeInstanceOf(BadRequestException);
expect(installmentRepo.save).not.toHaveBeenCalled();
});
it('rejects a repeated full refund', async () => {
const { service, repo } = serviceWith({ amount: 0, status: 'refunded' });
await expect(service.refund(1, { refundDate: '2026-07-14' })).rejects.toBeInstanceOf(BadRequestException);
expect(repo.save).not.toHaveBeenCalled();
});
it('rounds cumulative collections and clears stale refund audit fields', async () => {
const { service } = serviceWith({
amount: 10.01,
status: 'refunded',
refundDate: '2026-07-01',
refundAmount: 5,
refundedBy: 9,
refundedAt: new Date(),
});
const result = await service.create({ studentId: 2, amount: 0.02, paidDate: '2026-07-14' }, 7);
expect(result).toMatchObject({ amount: 10.03, status: 'paid', recordedBy: 7 });
expect(result.refundDate).toBeNull();
expect(result.refundAmount).toBeNull();
expect(result.refundedBy).toBeNull();
expect(result.refundedAt).toBeNull();
});
});

View File

@@ -9,6 +9,7 @@ import {
Query, Query,
UseGuards, UseGuards,
Request, Request,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -16,7 +17,12 @@ import { Student } from '../entities/student.entity';
import { DepositsService } from './deposits.service'; import { DepositsService } from './deposits.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity'; import { NotificationType } from '../entities/notification.entity';
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; import {
CreateDepositDto,
CreateDepositInstallmentDto,
RefundDepositDto,
UpdateDepositInstallmentDto,
} from './dto/deposit.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils'; import { extractRequestInfo } from '../common/request-utils';
@@ -40,9 +46,12 @@ export class DepositsController {
@Get() @Get()
@RequirePermission('deposit:view') @RequirePermission('deposit:view')
findAll(@Query('studentId') studentId?: string, @Query('status') status?: string) { findAll(
@Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
@Query('status') status?: string,
) {
return this.service.findAll({ return this.service.findAll({
studentId: studentId ? +studentId : undefined, studentId,
status: status || undefined, status: status || undefined,
}); });
} }
@@ -55,8 +64,8 @@ export class DepositsController {
@Get(':id') @Get(':id')
@RequirePermission('deposit:view') @RequirePermission('deposit:view')
findOne(@Param('id') id: string) { findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(+id); return this.service.findOne(id);
} }
@Post() @Post()
@@ -93,12 +102,12 @@ export class DepositsController {
@Post(':id/installments') @Post(':id/installments')
@RequirePermission('deposit:edit') @RequirePermission('deposit:edit')
async addInstallment( async addInstallment(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() body: { amount: number; dueDate: string }, @Body() body: CreateDepositInstallmentDto,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.addInstallment(+id, body.amount, body.dueDate); const result = await this.service.addInstallment(id, body.amount, body.dueDate);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
@@ -116,18 +125,18 @@ export class DepositsController {
@Put('installments/:installmentId') @Put('installments/:installmentId')
@RequirePermission('deposit:edit') @RequirePermission('deposit:edit')
async updateInstallment( async updateInstallment(
@Param('installmentId') installmentId: string, @Param('installmentId', ParseIntPipe) installmentId: number,
@Body() body: { paidDate?: string; status?: string }, @Body() body: UpdateDepositInstallmentDto,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateInstallment(+installmentId, body); const result = await this.service.updateInstallment(installmentId, body);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '押金管理', module: '押金管理',
action: '更新分期', action: '更新分期',
targetId: +installmentId, targetId: installmentId,
targetType: 'deposit-installment', targetType: 'deposit-installment',
detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`, detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
ipAddress, ipAddress,
@@ -139,17 +148,17 @@ export class DepositsController {
@Delete('installments/:installmentId') @Delete('installments/:installmentId')
@RequirePermission('deposit:delete') @RequirePermission('deposit:delete')
async deleteInstallment( async deleteInstallment(
@Param('installmentId') installmentId: string, @Param('installmentId', ParseIntPipe) installmentId: number,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.deleteInstallment(+installmentId); const result = await this.service.deleteInstallment(installmentId);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '押金管理', module: '押金管理',
action: '删除分期', action: '删除分期',
targetId: +installmentId, targetId: installmentId,
targetType: 'deposit-installment', targetType: 'deposit-installment',
detail: `删除分期${installmentId}`, detail: `删除分期${installmentId}`,
ipAddress, ipAddress,
@@ -160,17 +169,17 @@ export class DepositsController {
@Put(':id/refund') @Put(':id/refund')
@RequirePermission('deposit:refund') @RequirePermission('deposit:refund')
async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) { async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.refund(+id, dto, req.user?.id); const result = await this.service.refund(id, dto, req.user?.id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '押金管理', module: '押金管理',
action: '退还押金', action: '退还押金',
targetId: +id, targetId: id,
targetType: 'deposit', targetType: 'deposit',
detail: `退还¥${result.refundAmount}, 扣除¥${result.deductionAmount}`, detail: `退还全部可用押金 ¥${result.refundAmount}`,
ipAddress, ipAddress,
userAgent, userAgent,
}); });
@@ -182,7 +191,7 @@ export class DepositsController {
recipientIds: [student.userId], recipientIds: [student.userId],
type: 'deposit_refunded', type: 'deposit_refunded',
title: '押金已退还', title: '押金已退还',
content: `您的押金已退还,退还¥${result.refundAmount},扣除¥${result.deductionAmount}`, content: `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`,
}); });
} }
} catch (_) { /* don't block response */ } } catch (_) { /* don't block response */ }
@@ -191,15 +200,15 @@ export class DepositsController {
@Delete(':id') @Delete(':id')
@RequirePermission('deposit:delete') @RequirePermission('deposit:delete')
async remove(@Param('id') id: string, @Request() req: any) { async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id); const result = await this.service.remove(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '押金管理', module: '押金管理',
action: '删除押金记录', action: '删除押金记录',
targetId: +id, targetId: id,
targetType: 'deposit', targetType: 'deposit',
ipAddress, ipAddress,
userAgent, userAgent,

View File

@@ -2,7 +2,7 @@ import { DepositsService } from './deposits.service';
import { Deposit } from '../entities/deposit.entity'; import { Deposit } from '../entities/deposit.entity';
describe('DepositsService — direct refund', () => { describe('DepositsService — direct refund', () => {
it('stores the refund result on the main status and renamed audit fields', async () => { it('refunds the full available balance and stores audit fields', async () => {
const deposit = { const deposit = {
id: 1, id: 1,
amount: 500, amount: 500,
@@ -16,20 +16,16 @@ describe('DepositsService — direct refund', () => {
const result = await service.refund( const result = await service.refund(
1, 1,
{ { refundDate: '2026-07-13', notes: '退还剩余押金' },
refundDate: '2026-07-13',
deductionAmount: 100,
deductionReason: '物品损坏',
},
42, 42,
); );
expect(result).toMatchObject({ expect(result).toMatchObject({
refundDate: '2026-07-13', refundDate: '2026-07-13',
refundAmount: 400, amount: 0,
deductionAmount: 100, refundAmount: 500,
deductionReason: '物品损坏', notes: '退还剩余押金',
status: 'partial_refund', status: 'refunded',
refundedBy: 42, refundedBy: 42,
}); });
expect(result.refundedAt).toBeInstanceOf(Date); expect(result.refundedAt).toBeInstanceOf(Date);

View File

@@ -7,6 +7,8 @@ import { DepositInstallment } from '../entities/deposit-installment.entity';
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
@Injectable() @Injectable()
export class DepositsService { export class DepositsService {
@@ -46,25 +48,50 @@ export class DepositsService {
async create(dto: CreateDepositDto, userId?: number) { async create(dto: CreateDepositDto, userId?: number) {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
const deposit = this.repo.create({ const amount = money(dto.amount);
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');
const existing = await this.repo.findOne({ where: { studentId: dto.studentId } });
if (existing) {
existing.amount = money(Number(existing.amount || 0) + amount);
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.refundedBy = null;
existing.refundedAt = null;
if (dto.notes) existing.notes = dto.notes;
return this.repo.save(existing);
}
return this.repo.save(
this.repo.create({
studentId: dto.studentId, studentId: dto.studentId,
amount: dto.amount, amount,
paidDate: dto.paidDate, paidDate: dto.paidDate,
notes: dto.notes, notes: dto.notes,
status: 'paid', status: 'paid',
recordedBy: userId, recordedBy: userId,
}); }),
);
return this.repo.save(deposit);
} }
async addInstallment(depositId: number, amount: number, dueDate: string) { async addInstallment(depositId: number, amount: number, dueDate: string) {
const normalizedAmount = money(amount);
if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) {
throw new BadRequestException('分期金额最多保留两位小数');
}
if (normalizedAmount <= 0) throw new BadRequestException('分期金额必须大于0');
const deposit = await this.repo.findOne({ where: { id: depositId } }); const deposit = await this.repo.findOne({ where: { id: depositId } });
if (!deposit) throw new NotFoundException('押金记录不存在'); if (!deposit) throw new NotFoundException('押金记录不存在');
const installment = this.installmentRepo.create({ const installment = this.installmentRepo.create({
depositId, depositId,
amount, amount: normalizedAmount,
dueDate, dueDate,
status: 'pending', status: 'pending',
}); });
@@ -90,18 +117,16 @@ export class DepositsService {
async refund(id: number, dto: RefundDepositDto, userId?: number) { async refund(id: number, dto: RefundDepositDto, userId?: number) {
const deposit = await this.repo.findOne({ where: { id } }); const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在'); if (!deposit) throw new NotFoundException('押金记录不存在');
if (deposit.status !== 'paid') throw new BadRequestException('该押金已处理'); if (deposit.status !== 'paid' || Number(deposit.amount) <= 0) {
throw new BadRequestException('该学生当前没有可退押金');
}
const deduction = dto.deductionAmount || 0; const refundAmount = money(deposit.amount);
const refundAmount = Number(deposit.amount) - deduction;
if (refundAmount < 0) throw new BadRequestException('扣除金额不能大于押金金额');
deposit.refundDate = dto.refundDate; deposit.refundDate = dto.refundDate;
deposit.deductionAmount = deduction;
deposit.deductionReason = dto.deductionReason || '';
deposit.refundAmount = refundAmount; deposit.refundAmount = refundAmount;
deposit.status = deposit.amount = 0;
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded'; deposit.status = 'refunded';
if (dto.notes) deposit.notes = dto.notes; if (dto.notes) deposit.notes = dto.notes;
deposit.refundedBy = userId ?? null; deposit.refundedBy = userId ?? null;
deposit.refundedAt = new Date(); deposit.refundedAt = new Date();

View File

@@ -1,13 +1,14 @@
import { IsInt, IsNumber, IsString, IsOptional } from 'class-validator'; import { IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator';
export class CreateDepositDto { export class CreateDepositDto {
@IsInt() @IsInt()
studentId: number; studentId: number;
@IsNumber() @IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
amount: number; amount: number;
@IsString() @IsDateString()
paidDate: string; paidDate: string;
@IsOptional() @IsOptional()
@@ -16,18 +17,29 @@ export class CreateDepositDto {
} }
export class RefundDepositDto { export class RefundDepositDto {
@IsString() @IsDateString()
refundDate: string; refundDate: string;
@IsOptional()
@IsNumber()
deductionAmount?: number;
@IsOptional()
@IsString()
deductionReason?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
notes?: string; notes?: string;
} }
export class CreateDepositInstallmentDto {
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
amount: number;
@IsDateString()
dueDate: string;
}
export class UpdateDepositInstallmentDto {
@IsOptional()
@IsDateString()
paidDate?: string;
@IsOptional()
@IsIn(['pending', 'paid', 'overdue'])
status?: string;
}

View File

@@ -0,0 +1,52 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Classroom } from './classroom.entity';
export enum AttendanceDeviceStatus {
ACTIVE = 'active',
DISABLED = 'disabled',
}
@Entity('attendance_devices')
@Index(['deviceSn'], { unique: true })
@Index(['classroomId'])
export class AttendanceDevice {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'device_sn', type: 'varchar', length: 100 })
deviceSn: string;
@Column({ name: 'device_name', type: 'varchar', length: 100 })
deviceName: string;
@Column({ name: 'classroom_id', type: 'integer' })
classroomId: number;
@ManyToOne(() => Classroom, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'classroom_id' })
classroom: Classroom;
@Column({ type: 'varchar', length: 20, default: AttendanceDeviceStatus.ACTIVE })
status: AttendanceDeviceStatus | 'active' | 'disabled';
@Column({ type: 'varchar', length: 200, nullable: true })
location: string | null;
@Column({ type: 'text', nullable: true })
notes: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -67,6 +67,18 @@ export class AttendanceRecord {
@Column({ name: 'source', length: 20, default: 'manual' }) @Column({ name: 'source', length: 20, default: 'manual' })
source: string; source: string;
@Column({ name: 'punch_time', type: 'datetime', nullable: true })
punchTime: Date | null;
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
punchSource: string | null;
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
punchDeviceName: string | null;
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
punchDeviceId: string | null;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;

View File

@@ -33,9 +33,24 @@ export class Bill {
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) @Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
totalAmount: number; totalAmount: number;
@Column({ type: 'varchar', length: 20, default: 'draft' }) @Column({ type: 'varchar', length: 30, default: 'batch' })
source: 'batch' | 'student_utility';
@Column({ name: 'paid_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
paidAmount: number;
@Column({ name: 'outstanding_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
outstandingAmount: number;
@Column({ type: 'varchar', length: 20, default: 'unpaid' })
status: string; status: string;
@Column({ name: 'cancelled_at', type: 'datetime', nullable: true })
cancelledAt: Date | null;
@Column({ name: 'cancel_reason', type: 'varchar', length: 300, nullable: true })
cancelReason: string | null;
@CreateDateColumn({ name: 'generated_at' }) @CreateDateColumn({ name: 'generated_at' })
generatedAt: Date; generatedAt: Date;

View File

@@ -45,6 +45,10 @@ export class ClassSchedule {
@Column({ name: 'end_time', length: 5 }) @Column({ name: 'end_time', length: 5 })
endTime: string; endTime: string;
/** 课程开始前允许计入签到的分钟数。 */
@Column({ name: 'attendance_advance_minutes', type: 'integer', default: 30 })
attendanceAdvanceMinutes: number;
@Column({ name: 'start_date', type: 'date' }) @Column({ name: 'start_date', type: 'date' })
startDate: string; startDate: string;

View File

@@ -21,7 +21,7 @@ export class Deposit {
@Column({ type: 'decimal', precision: 10, scale: 2, default: 500 }) @Column({ type: 'decimal', precision: 10, scale: 2, default: 500 })
amount: number; amount: number;
// paid: 已缴 | refunded: 已退 | deducted: 已扣除(部分或全部) // paid: 有可用余额 | refunded: 余额已全部退还 | depleted: 余额已被账单扣完
@Column({ type: 'varchar', length: 20, default: 'paid' }) @Column({ type: 'varchar', length: 20, default: 'paid' })
status: string; status: string;
@@ -43,8 +43,8 @@ export class Deposit {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
notes: string; notes: string;
@Column({ name: 'recorded_by', nullable: true }) @Column({ name: 'recorded_by', type: 'integer', nullable: true })
recordedBy: number; recordedBy: number | null;
@Column({ name: 'refunded_by', type: 'integer', nullable: true }) @Column({ name: 'refunded_by', type: 'integer', nullable: true })
refundedBy: number | null; refundedBy: number | null;

View File

@@ -44,6 +44,15 @@ export class DingAttendanceRaw {
@Column({ name: 'location_result', length: 20, nullable: true }) @Column({ name: 'location_result', length: 20, nullable: true })
locationResult: string; locationResult: string;
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
punchSource: string | null;
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
punchDeviceName: string | null;
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
punchDeviceId: string | null;
@Column({ name: 'match_status', length: 20, default: 'unmatched' }) @Column({ name: 'match_status', length: 20, default: 'unmatched' })
matchStatus: string; matchStatus: string;

View File

@@ -14,7 +14,7 @@ export class ExpenseType {
@Column({ length: 20, default: 'room' }) @Column({ length: 20, default: 'room' })
category: string; category: string;
@Column({ type: 'integer', default: 0 }) @Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder: number; sortOrder: number;
@Column({ default: true }) @Column({ default: true })

View File

@@ -22,6 +22,7 @@ export { ClassTeacher, TeacherRoleType } from './class-teacher.entity';
export { ClassSchedule, ScheduleType } from './class-schedule.entity'; export { ClassSchedule, ScheduleType } from './class-schedule.entity';
export { AttendanceRecord } from './attendance-record.entity'; export { AttendanceRecord } from './attendance-record.entity';
export { AttendanceSession } from './attendance-session.entity'; export { AttendanceSession } from './attendance-session.entity';
export { AttendanceDevice, AttendanceDeviceStatus } from './attendance-device.entity';
export { DingAttendanceRaw } from './ding-attendance-raw.entity'; export { DingAttendanceRaw } from './ding-attendance-raw.entity';
export { SyncLog } from './sync-log.entity'; export { SyncLog } from './sync-log.entity';
export { SyncState } from './sync-state.entity'; export { SyncState } from './sync-state.entity';
@@ -35,3 +36,6 @@ export { ResultArchive } from './result-archive.entity';
export { ArchiveAttachment } from './archive-attachment.entity'; export { ArchiveAttachment } from './archive-attachment.entity';
export { StudentDingMapping } from './student-ding-mapping.entity'; export { StudentDingMapping } from './student-ding-mapping.entity';
export { AiConfig } from '../ai-config/ai-config.entity'; export { AiConfig } from '../ai-config/ai-config.entity';
export * from './student-wallet.entity';
export * from './wallet-transaction.entity';

View File

@@ -34,6 +34,9 @@ export class PersonalExpense {
@Column({ name: 'recorded_by', nullable: true }) @Column({ name: 'recorded_by', nullable: true })
recordedBy: number; recordedBy: number;
@Column({ name: 'bill_id', type: 'integer', nullable: true })
billId: number | null;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;

View File

@@ -0,0 +1,32 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Student } from './student.entity';
@Entity('student_wallets')
export class StudentWallet {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'student_id', type: 'integer', unique: true })
studentId: number;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
balance: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@OneToOne(() => Student, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'student_id' })
student: Student;
}

View File

@@ -0,0 +1,32 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('wallet_transactions')
@Index(['studentId', 'createdAt'])
export class WalletTransaction {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'student_id', type: 'integer' })
studentId: number;
@Column({ name: 'bill_id', type: 'integer', nullable: true })
billId: number | null;
@Column({ type: 'varchar', length: 30 })
type: 'recharge' | 'adjustment' | 'bill_payment' | 'bill_refund';
@Column({ type: 'decimal', precision: 12, scale: 2 })
amount: number;
@Column({ name: 'balance_after', type: 'decimal', precision: 12, scale: 2 })
balanceAfter: number;
@Column({ type: 'varchar', length: 300, nullable: true })
description: string | null;
@Column({ name: 'recorded_by', type: 'integer', nullable: true })
recordedBy: number | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@@ -1,10 +1,16 @@
import { IsString, IsOptional, IsInt, IsBoolean, IsIn } from 'class-validator'; import { IsString, IsOptional, IsInt, IsBoolean, IsIn, IsNotEmpty, Matches, MaxLength, Min } from 'class-validator';
export class CreateExpenseTypeDto { export class CreateExpenseTypeDto {
@IsString() @IsString()
@IsNotEmpty()
@MaxLength(30)
@Matches(/^[a-z][a-z0-9_]*$/)
code: string; code: string;
@IsString() @IsString()
@IsNotEmpty()
@MaxLength(30)
@Matches(/\S/)
name: string; name: string;
@IsOptional() @IsOptional()
@@ -13,12 +19,16 @@ export class CreateExpenseTypeDto {
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(0)
sortOrder?: number; sortOrder?: number;
} }
export class UpdateExpenseTypeDto { export class UpdateExpenseTypeDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsNotEmpty()
@MaxLength(30)
@Matches(/\S/)
name?: string; name?: string;
@IsOptional() @IsOptional()
@@ -27,6 +37,7 @@ export class UpdateExpenseTypeDto {
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(0)
sortOrder?: number; sortOrder?: number;
@IsOptional() @IsOptional()

View File

@@ -0,0 +1,68 @@
import 'reflect-metadata';
import { validate } from 'class-validator';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto';
import { ExpenseTypesService } from './expense-types.service';
describe('ExpenseTypesService boundaries', () => {
const createService = () => {
const repo = {
findOne: jest.fn(),
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ id: 1, ...value })),
remove: jest.fn(),
};
return { service: new ExpenseTypesService(repo as never), repo };
};
it('normalizes code and name before duplicate detection and save', async () => {
const { service, repo } = createService();
repo.findOne.mockResolvedValue(null);
await service.create({ code: ' water ', name: ' 水费 ' });
expect(repo.findOne).toHaveBeenCalledWith({ where: { code: 'water' } });
expect(repo.create).toHaveBeenCalledWith({ code: 'water', name: '水费' });
});
it('rejects a normalized duplicate code', async () => {
const { service, repo } = createService();
repo.findOne.mockResolvedValue({ id: 1, code: 'water' });
await expect(service.create({ code: ' water ', name: '水费' }))
.rejects.toBeInstanceOf(ConflictException);
});
it('trims an updated name and preserves omitted fields', async () => {
const { service, repo } = createService();
repo.findOne.mockResolvedValue({ id: 1, code: 'water', name: '旧名称', enabled: true });
await service.update(1, { name: ' 新名称 ' });
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({
code: 'water', name: '新名称', enabled: true,
}));
});
it('rejects removal of a missing type', async () => {
const { service, repo } = createService();
repo.findOne.mockResolvedValue(null);
await expect(service.remove(999)).rejects.toBeInstanceOf(NotFoundException);
expect(repo.remove).not.toHaveBeenCalled();
});
});
describe('expense type DTO boundaries', () => {
it.each(['Water', '1water', 'water-fee', '', 'a'.repeat(31)])('rejects code %j', async (code) => {
const dto = Object.assign(new CreateExpenseTypeDto(), { code, name: '水费' });
expect((await validate(dto)).some((error) => error.property === 'code')).toBe(true);
});
it.each(['', ' '])('rejects blank name %j and negative sort order', async (name) => {
const dto = Object.assign(new CreateExpenseTypeDto(), {
code: 'water', name, sortOrder: -1,
});
const properties = (await validate(dto)).map((error) => error.property);
expect(properties).toEqual(expect.arrayContaining(['name', 'sortOrder']));
});
it('accepts zero sort order and boolean enabled update', async () => {
const dto = Object.assign(new UpdateExpenseTypeDto(), { sortOrder: 0, enabled: false });
expect(await validate(dto)).toEqual([]);
});
});

View File

@@ -52,14 +52,15 @@ export class ExpenseTypesService {
} }
async create(dto: CreateExpenseTypeDto): Promise<ExpenseType> { async create(dto: CreateExpenseTypeDto): Promise<ExpenseType> {
const exists = await this.repo.findOne({ where: { code: dto.code } }); const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() };
const exists = await this.repo.findOne({ where: { code: normalized.code } });
if (exists) throw new ConflictException('费用类型代码已存在'); if (exists) throw new ConflictException('费用类型代码已存在');
return this.repo.save(this.repo.create(dto)); return this.repo.save(this.repo.create(normalized));
} }
async update(id: number, dto: UpdateExpenseTypeDto): Promise<ExpenseType> { async update(id: number, dto: UpdateExpenseTypeDto): Promise<ExpenseType> {
const t = await this.findOne(id); const t = await this.findOne(id);
Object.assign(t, dto); Object.assign(t, dto, dto.name === undefined ? {} : { name: dto.name.trim() });
return this.repo.save(t); return this.repo.save(t);
} }

Some files were not shown because too many files have changed in this diff Show More