38 Commits

Author SHA1 Message Date
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
xyx
7bfd30b513 fix: 添加表单验证错误处理逻辑以改善错误提示 2026-07-13 16:50:47 +08:00
xyx
ceb0e2bf14 fix: 添加默认床位生成逻辑并优化床位添加验证 2026-07-13 16:50:47 +08:00
xyx
acb11b87a3 fix: enhance pagination settings across multiple pages with default size and options 2026-07-13 16:49:21 +08:00
b9e4caae99 Merge PR #4: align forms, contracts, and lifecycle models
Reviewed and validated across admin/server builds, typechecks, migrations, and full test suites.
2026-07-13 08:31:24 +00:00
f98c0ccaa8 refactor(rooms): remove dorm gender restrictions 2026-07-13 16:12:57 +08:00
aa1ed7db56 refactor: resolve remaining field audit issues 2026-07-13 16:12:57 +08:00
0533c30ece refactor(classes): preserve student membership history 2026-07-13 16:12:57 +08:00
1f32d1285b refactor(deposits): remove refund approval remnants 2026-07-13 16:12:56 +08:00
7b08560aef fix(deposits): show student numbers in lookup 2026-07-13 16:12:56 +08:00
04ef5c42d9 refactor(deposits): remove inline installment creation 2026-07-13 16:12:22 +08:00
0515e6ed27 fix(integration): simplify manual DingTalk sync setup 2026-07-13 16:12:22 +08:00
effa34434b refactor(students): remove obsolete class id input 2026-07-13 16:12:22 +08:00
0377acd33b refactor: remove residual campus and department fields 2026-07-13 16:12:22 +08:00
6396d3e934 fix(schedules): support editable schedule notes 2026-07-13 16:12:21 +08:00
dfd3cf6772 fix(archive): align aggregate result field 2026-07-13 16:12:21 +08:00
2874ab7bee fix(occupancies): assign room resources during transfers 2026-07-13 16:12:21 +08:00
8cadf8970e fix(admin): restore teacher profile form values 2026-07-13 16:12:20 +08:00
337d25e370 Merge PR #3: validated codex/xyx fixes
Includes PR #2 changes and DingTalk token cache test fixture updates.
2026-07-13 08:08:19 +00:00
7860703672 test: update DingTalk token cache fixtures 2026-07-13 16:04:05 +08:00
xyx
ebce2463f0 fix: remove sensitive data masking from student options label 2026-07-13 15:27:17 +08:00
xyx
910a4263f6 fix: refactor notification display logic and add format function 2026-07-13 14:52:03 +08:00
xyx
6d42241918 fix: enhance emergency contact phone display with masking and view option 2026-07-13 14:29:01 +08:00
xyx
70283d9dfe fix: enhance 401 error handling to exclude login requests 2026-07-13 14:22:22 +08:00
xyx
187e4f2af0 fix: improve error handling in expense form submissions 2026-07-13 12:16:39 +08:00
xyx
619b3b22bd fix: use saved dingtalk config for integration calls 2026-07-13 11:43:11 +08:00
b5ef91bf2d fix: support Windows dev startup 2026-07-13 10:35:43 +08:00
109 changed files with 3605 additions and 780 deletions

View File

@@ -16,7 +16,10 @@ instance.interceptors.request.use((config) => {
instance.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 401) {
const isLoginRequest =
err.config?.url === '/auth/login' || err.config?.url === 'auth/login';
if (err.response?.status === 401 && !isLoginRequest) {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');

View File

@@ -3,6 +3,7 @@ import { Badge, Popover, Button, List, Typography, Empty } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import api from '../api';
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
interface NotificationItem {
id: number;
@@ -14,18 +15,6 @@ interface NotificationItem {
createdAt: string;
}
const typeLabels: Record<string, string> = {
bill_generated: '账单',
bill_paid: '账单',
check_in: '入住',
check_out: '退宿',
deposit_due: '押金',
deposit_refunded: '押金',
class_change: '班级',
schedule_conflict: '排课',
announcement: '公告',
};
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
@@ -163,7 +152,7 @@ const NotificationBell: React.FC = () => {
strong={!item.isRead}
style={{ fontSize: 14 }}
>
[{typeLabels[item.type] || item.type}] {item.title}
[{notificationTypeLabels[item.type] || item.type}] {formatNotificationText(item.title)}
</Typography.Text>
}
description={

View File

@@ -321,7 +321,11 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
columns={columns}
dataSource={data}
rowKey="id"
pagination={{ pageSize: 15 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加报读记录"
@@ -428,7 +432,11 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
columns={columns}
dataSource={data}
rowKey="id"
pagination={{ pageSize: 15 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加考试成绩"
@@ -529,7 +537,11 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({ data, st
columns={columns}
dataSource={data}
rowKey="id"
pagination={{ pageSize: 15 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加学情记录"
@@ -712,7 +724,11 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ dat
columns={columns}
dataSource={data}
rowKey="id"
pagination={{ pageSize: 15 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
style={{ marginTop: 16 }}
/>
</div>

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
canPullAttendance,
getAttendanceExperience,
getPunchDisplayInfo,
getSchedulePhase,
summarizeAttendance,
summarizeLessonCheckins,
@@ -65,3 +66,34 @@ describe('lesson check-in summary', () => {
).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 });
});
});
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,56 @@ export function summarizeLessonCheckins(
notCheckedIn: records.length - 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

@@ -502,3 +502,25 @@
.attendance-marking-actions .ant-btn {
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

@@ -41,6 +41,7 @@ import { message } from '../../ui/app-message';
import {
canPullAttendance,
getAttendanceExperience,
getPunchDisplayInfo,
getSchedulePhase,
summarizeLessonCheckins,
type AttendanceSummary,
@@ -96,6 +97,10 @@ interface AttendanceRecordItem {
class: { id: number; name: string } | null;
scheduleId?: number | null;
attendanceSessionId?: number | null;
punchTime?: string | null;
punchSource?: string | null;
punchDeviceName?: string | null;
punchDeviceId?: string | null;
}
interface AssignedClass {
@@ -288,6 +293,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
`/attendance-lessons/schedules/${schedule.id}/pull`,
{ date: today },
);
setSelectedSchedule(data.schedule);
setLessonSession(data.session);
setLessonRecords(data.records);
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
@@ -386,7 +392,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
)}
</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">
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
@@ -438,6 +444,21 @@ const TeacherAttendanceWorkspace: React.FC = () => {
},
},
{ 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> },
]}
/>

View File

@@ -38,11 +38,166 @@ const typeMap: Record<string, string> = {
water: '水费',
electricity: '电费',
cleaning: '保洁费',
rent: '租金',
damage: '损坏赔偿',
penalty: '罚款',
other: '其他',
};
const escapeHtml = (value: unknown) =>
String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`;
const buildBillPrintHtml = (bill: any) => {
const studentName = bill.student?.name || '-';
const status = statusMap[bill.status]?.text || bill.status || '-';
const generatedAt = bill.generatedAt
? dayjs(bill.generatedAt).format('YYYY-MM-DD HH:mm')
: dayjs().format('YYYY-MM-DD HH:mm');
const hasDeposit = Number(bill.availableDeposit || 0) > 0;
const items = bill.items || [];
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>账单_${escapeHtml(studentName)}_${escapeHtml(bill.id)}</title>
<style>
@page { size: A4; margin: 0; }
* { box-sizing: border-box; }
body {
margin: 0;
color: #000;
background: #f5f5f5;
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", Arial, sans-serif;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.page {
width: 210mm;
min-height: 297mm;
margin: 0 auto;
padding: 50px;
background: #fff;
}
h1 { margin: 0; text-align: center; font-size: 20px; line-height: 1.35; font-weight: 700; }
.generated-time { margin-top: 8px; text-align: center; color: #666; font-size: 10px; }
.basic-info { margin-top: 24px; font-size: 12px; line-height: 1.8; }
.section-title { margin: 16px 0 6px; font-size: 14px; font-weight: 700; text-decoration: underline; }
.amount-summary { font-size: 12px; line-height: 1.75; }
.total { color: #007aff; font-size: 14px; font-weight: 700; }
.deposit { color: #52c41a; font-size: 11px; }
.deposit-applied { color: #fa8c16; font-size: 11px; }
.after-deposit { color: #ff3b30; font-size: 14px; font-weight: 700; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; margin-top: 8px; }
th, td { padding: 5px 6px; border-bottom: 1px solid #ccc; font-size: 9px; line-height: 1.45; text-align: left; vertical-align: top; word-break: break-word; }
th { color: #333; font-weight: 700; }
td.amount, th.amount { text-align: right; white-space: nowrap; }
.footer { margin-top: 34px; text-align: center; color: #999; font-size: 8px; }
.print-actions {
position: fixed;
right: 18px;
top: 18px;
display: flex;
gap: 8px;
}
.print-actions button {
height: 32px;
padding: 0 12px;
border: 1px solid #1f6feb;
border-radius: 4px;
background: #1f6feb;
color: #fff;
cursor: pointer;
}
@media print {
body { background: #fff; }
.page { margin: 0; }
.print-actions { display: none; }
}
</style>
</head>
<body>
<div class="print-actions">
<button onclick="window.print()">打印 / 另存为 PDF</button>
</div>
<main class="page">
<h1>恭学教育基地水电费账单</h1>
<div class="generated-time">生成时间: ${escapeHtml(generatedAt)}</div>
<div class="basic-info">
<div>学生姓名: ${escapeHtml(studentName)}</div>
<div>计费周期: ${escapeHtml(bill.periodStart)} ~ ${escapeHtml(bill.periodEnd)}</div>
<div>账单状态: ${escapeHtml(status)}</div>
</div>
<section>
<div class="section-title">费用汇总</div>
<div class="amount-summary">
<div>分摊费用: ${escapeHtml(money(bill.sharedAmount))}</div>
<div>个人费用: ${escapeHtml(money(bill.personalAmount))}</div>
<div class="total">应付总额: ${escapeHtml(money(bill.totalAmount))}</div>
${
hasDeposit
? `<div class="deposit">可用押金: ${escapeHtml(money(bill.availableDeposit))}</div>
<div class="deposit-applied">押金抵扣: -${escapeHtml(money(bill.depositApplied))}</div>
<div class="after-deposit">抵扣后应付: ${escapeHtml(money(bill.amountAfterDeposit ?? bill.totalAmount))}</div>`
: ''
}
</div>
</section>
<section>
<div class="section-title">费用明细</div>
<table>
<thead>
<tr>
<th style="width: 22%;">费用类型</th>
<th>说明</th>
<th style="width: 11%;">天数</th>
<th style="width: 12%;">总人天</th>
<th class="amount" style="width: 16%;">金额(元)</th>
</tr>
</thead>
<tbody>
${
items.length
? items
.map(
(item: any) => `<tr>
<td>${escapeHtml(typeMap[item.expenseType] || item.expenseType || '-')}</td>
<td>${escapeHtml(item.description || '-')}</td>
<td>${escapeHtml(item.days || 0)}</td>
<td>${escapeHtml(item.totalRoomDays || 0)}</td>
<td class="amount">${escapeHtml(Number(item.studentAmount || 0).toFixed(2))}</td>
</tr>`,
)
.join('')
: '<tr><td colspan="5" style="text-align:center; color:#999;">暂无费用明细</td></tr>'
}
</tbody>
</table>
</section>
<div class="footer">
本账单由恭学教育基地管理系统自动生成
</div>
</main>
<script>
window.addEventListener('load', () => {
setTimeout(() => window.print(), 250);
});
</script>
</body>
</html>`;
};
const BillsPage: React.FC = () => {
const [bills, setBills] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
@@ -89,9 +244,11 @@ const BillsPage: React.FC = () => {
}, [bills, searchText, filterStatus]);
const handleGenerate = async () => {
setSaving(true);
const values = await generateForm.validateFields();
if (saving) return;
try {
const values = await generateForm.validateFields();
setSaving(true);
const res: any = await api.post('/bills/generate', {
periodStart: values.period[0].format('YYYY-MM-DD'),
periodEnd: values.period[1].format('YYYY-MM-DD'),
@@ -99,9 +256,12 @@ const BillsPage: React.FC = () => {
message.success(res.message || '生成成功');
setGenerateModal(false);
generateForm.resetFields();
fetchData();
void fetchData();
} catch (e: any) {
message.error(e?.message || '生成失败');
// Ant Design 的表单校验失败会 reject字段本身已展示错误无需再弹“生成失败”。
if (!e?.errorFields) {
message.error(e?.message || '生成失败');
}
} finally {
setSaving(false);
}
@@ -181,11 +341,24 @@ const BillsPage: React.FC = () => {
);
};
const handleExportPdf = (billId: number) => {
downloadBlob(`/bills/export/pdf/${billId}`, `账单_${billId}.pdf`).catch(() =>
message.error('导出失败'),
);
};
const handleExportPdf = useCallback(async (billId: number) => {
const printWindow = window.open('', '_blank');
if (!printWindow) {
message.error('无法打开打印窗口,请允许浏览器弹窗后重试');
return;
}
printWindow.document.write('<!doctype html><title>账单加载中</title><body>账单加载中...</body>');
try {
const bill = await api.get(`/bills/${billId}`);
printWindow.document.open();
printWindow.document.write(buildBillPrintHtml(bill));
printWindow.document.close();
} catch (e: any) {
printWindow.close();
message.error(e?.message || '账单数据加载失败');
}
}, []);
const columns = useMemo(() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
@@ -399,7 +572,12 @@ const BillsPage: React.FC = () => {
dataSource={filteredBills}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{
selectedRowKeys: selectedRows,

View File

@@ -19,6 +19,7 @@ interface ClassStudent {
studentName: string;
studentNo: string;
joinDate: string;
leaveDate: string | null;
status: string;
}
@@ -305,6 +306,7 @@ const ClassDetailPage: React.FC = () => {
{ title: '姓名', dataIndex: 'studentName' },
{ title: '学号', dataIndex: 'studentNo' },
{ title: '加入日期', dataIndex: 'joinDate' },
{ title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' },
{
title: '状态',
dataIndex: 'status',
@@ -316,11 +318,12 @@ const ClassDetailPage: React.FC = () => {
},
{
title: '操作',
render: (_: unknown, r: ClassStudent) => (
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
<PermissionButton permission="class:edit" size="small" danger></PermissionButton>
</Popconfirm>
),
render: (_: unknown, r: ClassStudent) =>
r.status === 'active' ? (
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
<PermissionButton permission="class:edit" size="small" danger></PermissionButton>
</Popconfirm>
) : null,
},
];
@@ -527,7 +530,11 @@ const ClassDetailPage: React.FC = () => {
columns={studentColumns}
dataSource={students}
rowKey="id"
pagination={{ pageSize: 20 }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
/>
<Modal
title="添加学员"
@@ -571,7 +578,11 @@ const ClassDetailPage: React.FC = () => {
columns={teacherColumns}
dataSource={teachers}
rowKey="id"
pagination={{ pageSize: 20 }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
/>
<Modal
title="添加教师"
@@ -630,7 +641,11 @@ const ClassDetailPage: React.FC = () => {
columns={scheduleColumns}
dataSource={schedules}
rowKey="id"
pagination={{ pageSize: 20 }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
/>
</div>
),

View File

@@ -264,7 +264,11 @@ const ClassesPage: React.FC = () => {
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 20 }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
scroll={{ x: 1100 }}
/>

View File

@@ -15,7 +15,7 @@ import {
Tooltip,
Empty,
} from 'antd';
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined, StopOutlined, CheckOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
@@ -39,6 +39,7 @@ const ClassroomRentalsPage: React.FC = () => {
const [editing, setEditing] = useState<any>(null);
const [form] = Form.useForm();
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
const [filterStatus, setFilterStatus] = useState<string | undefined>();
const [searchText, setSearchText] = useState('');
const [saving, setSaving] = useState(false);
const [unavailableDates, setUnavailableDates] = useState<Set<string>>(new Set());
@@ -48,20 +49,22 @@ const ClassroomRentalsPage: React.FC = () => {
const selectedClassroomId = Form.useWatch('classroomId', form);
const filteredData = useMemo(() => {
if (!searchText) return data;
const s = searchText.toLowerCase();
return data.filter((r: any) => {
if (filterStatus && r.effectiveStatus !== filterStatus) return false;
if (!searchText) return true;
const s = searchText.toLowerCase();
const matchClassroom = r.classroom?.name?.toLowerCase().includes(s);
const matchOrganization = r.lesseeOrganization?.name?.toLowerCase().includes(s);
return matchClassroom || matchOrganization;
});
}, [data, searchText]);
}, [data, searchText, filterStatus]);
const fetchData = async () => {
setLoading(true);
try {
const params: any = {};
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
params.includeEnded = true;
const res: any = await api.get('/classroom-rentals', { params });
setData(res);
} catch (e: any) {
@@ -215,6 +218,16 @@ const ClassroomRentalsPage: React.FC = () => {
}
};
const handleRentalAction = async (id: number, action: 'cancel' | 'end') => {
try {
await api.put(`/classroom-rentals/${id}/${action}`);
message.success(action === 'cancel' ? '租赁已取消' : '租赁已结束');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleDownloadContract = async (id: number, filename?: string) => {
try {
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
@@ -308,6 +321,19 @@ const ClassroomRentalsPage: React.FC = () => {
width: 100,
render: (v: any) => (v ? `¥${v}` : '-'),
},
{
title: '状态',
dataIndex: 'effectiveStatus',
width: 90,
render: (status: string) => {
const config: Record<string, { text: string; color: string }> = {
active: { text: '进行中', color: 'green' },
ended: { text: '已结束', color: 'default' },
cancelled: { text: '已取消', color: 'red' },
};
return <Tag color={config[status]?.color}>{config[status]?.text || status}</Tag>;
},
},
{
title: '合同',
width: 120,
@@ -364,21 +390,30 @@ const ClassroomRentalsPage: React.FC = () => {
width: 150,
render: (_: any, record: any) => (
<Space>
<PermissionButton
permission="rental:edit"
size="small"
onClick={() => openEdit(record)}
>
</PermissionButton>
<Popconfirm
title="确定删除该租赁订单?合同文件将一并删除。"
onConfirm={() => handleDelete(record.id)}
>
<PermissionButton permission="rental:delete" size="small" danger>
</PermissionButton>
</Popconfirm>
{record.effectiveStatus === 'active' && (
<>
<PermissionButton permission="rental:edit" size="small" onClick={() => openEdit(record)}>
</PermissionButton>
<Popconfirm title="确定取消该租赁?" onConfirm={() => handleRentalAction(record.id, 'cancel')}>
<PermissionButton permission="rental:edit" size="small" danger icon={<StopOutlined />}>
</PermissionButton>
</Popconfirm>
{!dayjs(record.startDate).isAfter(dayjs(), 'day') && (
<Popconfirm title="确定今天结束该租赁?" onConfirm={() => handleRentalAction(record.id, 'end')}>
<PermissionButton permission="rental:edit" size="small" icon={<CheckOutlined />}>
</PermissionButton>
</Popconfirm>
)}
</>
)}
{record.effectiveStatus !== 'active' && (
<Popconfirm title="确定删除该租赁订单?合同文件将一并删除。" onConfirm={() => handleDelete(record.id)}>
<PermissionButton permission="rental:delete" size="small" danger></PermissionButton>
</Popconfirm>
)}
</Space>
),
},
@@ -415,6 +450,18 @@ const ClassroomRentalsPage: React.FC = () => {
allowClear
format="YYYY-MM"
/>
<Select
placeholder="状态"
allowClear
style={{ width: 110 }}
value={filterStatus}
onChange={setFilterStatus}
options={[
{ value: 'active', label: '进行中' },
{ value: 'ended', label: '已结束' },
{ value: 'cancelled', label: '已取消' },
]}
/>
</Space>
<PermissionButton
permission="rental:create"
@@ -436,7 +483,12 @@ const ClassroomRentalsPage: React.FC = () => {
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
scroll={{ x: 1200 }}
/>
<Modal
@@ -459,7 +511,7 @@ const ClassroomRentalsPage: React.FC = () => {
optionFilterProp="label"
placeholder="选择教室"
onChange={handleClassroomChange}
options={classrooms.map((c) => ({
options={classrooms.filter((c) => c.status === 'available').map((c) => ({
value: c.id,
label: `${c.building ? c.building + ' · ' : ''}${c.name}${c.roomType}`,
}))}

View File

@@ -61,7 +61,7 @@ const ClassroomsPage: React.FC = () => {
const filteredData = useMemo(() => {
let result = data;
if (searchText) { const s = searchText.toLowerCase(); result = result.filter((d: Record<string, unknown>) => (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || (typeof d.building === 'string' && d.building.toLowerCase().includes(s))); }
if (filterStatus) result = result.filter((d: Record<string, unknown>) => d.status === filterStatus);
if (filterStatus) result = result.filter((d: Record<string, unknown>) => d.effectiveStatus === filterStatus);
return result;
}, [data, searchText, filterStatus]);
@@ -157,11 +157,11 @@ const ClassroomsPage: React.FC = () => {
{
title: '状态', width: 100,
dataIndex: 'status',
render: (s: string, record: { currentUsage?: CurrentUsage | null }) => {
const effectiveStatus = record.currentUsage ? 'in_use' : s;
render: (_s: string, record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null }) => {
const effectiveStatus = record.effectiveStatus || record.status;
return (
<Tooltip title={record.currentUsage ? `${record.currentUsage.title} (${record.currentUsage.startTime}-${record.currentUsage.endTime})` : undefined}>
<Tag color={statusMap[effectiveStatus]?.color}>{statusMap[effectiveStatus]?.text || s}</Tag>
<Tag color={statusMap[effectiveStatus]?.color}>{statusMap[effectiveStatus]?.text || effectiveStatus}</Tag>
</Tooltip>
);
},
@@ -228,7 +228,7 @@ const ClassroomsPage: React.FC = () => {
if (!e.target.value) setSearchText('');
}}
/>
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus} options={[{value:'available',label:'可用'},{value:'in_use',label:'使用中'},{value:'reserved',label:'已预留'},{value:'maintenance',label:'维护中'}]} />
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus} options={[{value:'available',label:'可用'},{value:'in_use',label:'使用中'},{value:'reserved',label:'已预留'},{value:'maintenance',label:'维护中'},{value:'archived',label:'已归档'}]} />
<Button
type={showArchived ? 'primary' : 'default'}
onClick={() => setShowArchived(!showArchived)}
@@ -287,7 +287,12 @@ const ClassroomsPage: React.FC = () => {
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total}`,
}}
/>
<Modal
title={editing ? '编辑教室' : '添加教室'}
@@ -323,6 +328,16 @@ const ClassroomsPage: React.FC = () => {
<Form.Item name="capacity" label="容量">
<InputNumber min={1} max={500} style={{ width: '100%' }} />
</Form.Item>
{editing && (
<Form.Item name="status" label="基础状态">
<Select
options={[
{ value: 'available', label: '可用' },
{ value: 'maintenance', label: '维护中' },
]}
/>
</Form.Item>
)}
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { buildDepositStudentOption, buildDepositStudentOptions } from './deposit-student-option';
describe('deposit student option', () => {
it('uses the student number as the non-sensitive identifier', () => {
expect(
buildDepositStudentOption({ id: 23, name: '张三', studentNo: 'S2026001' }),
).toEqual({
value: 23,
label: '张三 (S2026001)',
});
});
it('falls back to the internal id when the student number is missing', () => {
expect(buildDepositStudentOption({ id: 23, name: '张三', studentNo: null })).toEqual({
value: 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

@@ -0,0 +1,13 @@
export interface DepositStudentLookup {
id: number;
name: string;
studentNo?: string | null;
}
export const buildDepositStudentOption = (student: DepositStudentLookup) => ({
value: student.id,
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
});
export const buildDepositStudentOptions = (students: DepositStudentLookup[]) =>
students.map(buildDepositStudentOption);

View File

@@ -17,9 +17,9 @@ import {
import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import { buildDepositStudentOptions } from './deposit-student-option';
const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' },
@@ -28,18 +28,15 @@ const statusMap: Record<string, { text: string; color: string }> = {
deducted: { text: '已全扣', color: 'red' },
};
const refundStatusMap: Record<string, { text: string; color: string }> = {
pending: { text: '历史退款处理中', color: 'orange' },
head_teacher_approved: { text: '历史退款处理中', color: 'blue' },
finance_approved: { text: '已退款', color: 'green' },
refunded: { text: '已退款', color: 'green' },
};
const installmentStatusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待缴', color: 'orange' },
paid: { text: '已缴', color: 'green' },
};
const isFormValidationError = (error: unknown) =>
typeof error === 'object'
&& error !== null
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
const DepositsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
@@ -89,20 +86,14 @@ const DepositsPage: React.FC = () => {
}, [data, searchText, filterStatus]);
const studentOptions = useMemo(
() =>
students
.filter((s: any) => s.status === 'active')
.map((s: any) => ({
value: s.id,
label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`,
})),
() => buildDepositStudentOptions(students),
[students],
);
const handleCreate = async () => {
setSaving(true);
const values = await createForm.validateFields();
try {
const values = await createForm.validateFields();
await api.post('/deposits', {
studentId: values.studentId,
amount: values.amount,
@@ -114,7 +105,9 @@ const DepositsPage: React.FC = () => {
createForm.resetFields();
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
}
@@ -122,8 +115,8 @@ const DepositsPage: React.FC = () => {
const handleRefund = async () => {
setSaving(true);
const values = await refundForm.validateFields();
try {
const values = await refundForm.validateFields();
await api.put(`/deposits/${refundModal.id}/refund`, {
refundDate: values.refundDate.format('YYYY-MM-DD'),
deductionAmount: values.deductionAmount || 0,
@@ -135,7 +128,9 @@ const DepositsPage: React.FC = () => {
refundForm.resetFields();
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
}
@@ -143,8 +138,8 @@ const DepositsPage: React.FC = () => {
const handleAddInstallment = async () => {
if (installmentModal == null) return;
const values = await installmentForm.validateFields();
try {
const values = await installmentForm.validateFields();
await api.post(`/deposits/${installmentModal}/installments`, {
amount: values.amount,
dueDate: values.dueDate.format('YYYY-MM-DD'),
@@ -154,7 +149,9 @@ const DepositsPage: React.FC = () => {
installmentForm.resetFields();
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
}
};
@@ -190,12 +187,6 @@ const DepositsPage: React.FC = () => {
dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
},
{
title: '退款状态',
dataIndex: 'refundStatus',
render: (s: string) =>
s ? <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag> : '-',
},
{
title: '退还金额',
dataIndex: 'refundAmount',
@@ -223,7 +214,7 @@ const DepositsPage: React.FC = () => {
>
</PermissionButton>
{record.status === 'paid' && !record.refundStatus && (
{record.status === 'paid' && (
<>
<PermissionButton
permission="deposit:refund"
@@ -320,7 +311,12 @@ const DepositsPage: React.FC = () => {
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
/>
@@ -410,14 +406,6 @@ const DepositsPage: React.FC = () => {
{statusMap[detailModal.status]?.text || detailModal.status}
</Tag>
</p>
{detailModal.refundStatus && (
<p>
<strong>退:</strong>{' '}
<Tag color={refundStatusMap[detailModal.refundStatus]?.color}>
{refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus}
</Tag>
</p>
)}
{detailModal.notes && <p><strong>:</strong> {detailModal.notes}</p>}
</Card>

View File

@@ -31,6 +31,11 @@ import { message } from '../../ui/app-message';
const { RangePicker } = DatePicker;
const isFormValidationError = (error: unknown) =>
typeof error === 'object'
&& error !== null
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
const ExpensesPage: React.FC = () => {
@@ -157,16 +162,16 @@ const ExpensesPage: React.FC = () => {
const handleRoomExpense = async () => {
setSaving(true);
const values = await roomForm.validateFields();
const payload = {
roomId: values.roomId,
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,
};
try {
const values = await roomForm.validateFields();
const payload = {
roomId: values.roomId,
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,
};
if (editingRoom) {
await api.put(`/expenses/room/${editingRoom.id}`, payload);
message.success('更新成功');
@@ -179,7 +184,9 @@ const ExpensesPage: React.FC = () => {
roomForm.resetFields();
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
}
@@ -187,16 +194,16 @@ const ExpensesPage: React.FC = () => {
const handlePersonalExpense = async () => {
setSaving(true);
const values = await personalForm.validateFields();
const payload = {
studentId: values.studentId,
roomId: values.roomId,
expenseType: values.expenseType,
amount: values.amount,
expenseDate: values.expenseDate.format('YYYY-MM-DD'),
description: values.description,
};
try {
const values = await personalForm.validateFields();
const payload = {
studentId: values.studentId,
roomId: values.roomId,
expenseType: values.expenseType,
amount: values.amount,
expenseDate: values.expenseDate.format('YYYY-MM-DD'),
description: values.description,
};
if (editingPersonal) {
await api.put(`/expenses/personal/${editingPersonal.id}`, payload);
message.success('更新成功');
@@ -209,7 +216,9 @@ const ExpensesPage: React.FC = () => {
personalForm.resetFields();
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
}
@@ -443,7 +452,12 @@ const ExpensesPage: React.FC = () => {
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{
selectedRowKeys: selectedRoomKeys,
@@ -566,7 +580,12 @@ const ExpensesPage: React.FC = () => {
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{
selectedRowKeys: selectedPersonalKeys,

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Card, Form, Input, Button, Space, Spin, Switch, Alert, Descriptions, Tag,
Tabs, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider,
Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
Row, Col, List,
} from 'antd';
import {
@@ -15,12 +15,15 @@ import api from '../../api';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
import PermissionButton from '../../components/PermissionButton';
import {
buildDingTalkConfigPayload,
isAppSecretRequired,
type DingTalkConfigFormValues,
} from './integration-config-form';
interface DingTalkConfig {
agentId: string;
appSecret: string;
corpId: string;
startEnable: boolean;
}
interface DingOrgTreeNodeExt {
@@ -94,9 +97,9 @@ const IntegrationConfigPage: React.FC = () => {
const [testing, setTesting] = useState(false);
const [config, setConfig] = useState<DingTalkConfig | null>(null);
const [verified, setVerified] = useState<boolean | null>(null);
const [form] = Form.useForm<DingTalkConfig>();
const [form] = Form.useForm<DingTalkConfigFormValues>();
// ── Sync Users Tab ──
// ── Manual organization sync ──
const [syncRootDeptId, setSyncRootDeptId] = useState<number | undefined>(undefined);
const [orgTree, setOrgTree] = useState<DingOrgTreeNodeExt[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -137,9 +140,10 @@ const IntegrationConfigPage: React.FC = () => {
const handleSave = async () => {
const values = await form.validateFields();
const payload = buildDingTalkConfigPayload(values);
setSaving(true);
try {
await api.post('/integration/config', { type: 'DINGTALK', config: values });
await api.post('/integration/config', { type: 'DINGTALK', config: payload });
message.success('配置已保存');
await fetchConfig();
} catch (e: unknown) {
@@ -152,11 +156,12 @@ const IntegrationConfigPage: React.FC = () => {
const handleTest = async () => {
const values = await form.validateFields();
const payload = buildDingTalkConfigPayload(values);
setTesting(true);
try {
const res = await api.post<{ success: boolean; message: string }>('/integration/config/test', {
type: 'DINGTALK',
config: values,
config: payload,
});
setVerified(res.success);
message.success(res.message);
@@ -345,12 +350,8 @@ const IntegrationConfigPage: React.FC = () => {
}
};
const syncTabItems = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
? [
{
key: 'sync-users',
label: '同步用户',
children: (
const syncPanel = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
? (
<div>
<Alert
type="info"
@@ -519,77 +520,100 @@ const IntegrationConfigPage: React.FC = () => {
/>
</Modal>
</div>
),
},
]
: [];
const tabItems = [
{
key: 'config',
label: '配置',
children: (
<Spin spinning={loading}>
{config && (
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
<Descriptions.Item label="启用同步">
<Tag color={config.startEnable ? 'green' : 'default'}>
{config.startEnable ? '已启用' : '未启用'}
</Tag>
</Descriptions.Item>
</Descriptions>
)}
<Alert
type="info"
message="配置钉钉应用凭证后,可使用组织架构同步、考勤导入和排班同步功能。"
style={{ marginBottom: 24 }}
showIcon
/>
<Form form={form} layout="vertical" style={{ maxWidth: 480 }}>
<Form.Item name="corpId" label="CorpId企业ID" rules={[{ required: true, message: '请输入 CorpId' }]}>
<Input placeholder="dingxxxxxxxx" />
</Form.Item>
<Form.Item name="agentId" label="AppKey应用凭证" rules={[{ required: true, message: '请输入 AppKey' }]}>
<Input placeholder="从钉钉开放平台获取" />
</Form.Item>
<Form.Item
name="appSecret"
label="AppSecret应用密钥"
rules={[{ required: true, message: '请输入 AppSecret' }]}
extra="保存后仅返回脱敏信息,重新编辑时需再次输入完整密钥"
>
<Input.Password placeholder="从钉钉开放平台获取" />
</Form.Item>
<Form.Item name="startEnable" label="启用同步" valuePropName="checked">
<Switch />
</Form.Item>
<Space>
<PermissionButton permission="integration:trigger" type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
</PermissionButton>
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
</Button>
</Space>
</Form>
</Spin>
),
},
...syncTabItems,
];
)
: null;
return (
<Card title="钉钉集成配置" extra={
<Space>
{verified === true && <Tag icon={<CheckCircleOutlined />} color="success"></Tag>}
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error"></Tag>}
</Space>
}>
<Tabs items={tabItems} />
<Card
title="钉钉集成配置"
extra={
<Space>
{verified === true && (
<Tag icon={<CheckCircleOutlined />} color="success">
</Tag>
)}
{verified === false && (
<Tag icon={<CloseCircleOutlined />} color="error">
</Tag>
)}
</Space>
}
>
<Spin spinning={loading}>
{config && (
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
<Descriptions.Item label="同步方式"></Descriptions.Item>
</Descriptions>
)}
<Alert
type="info"
message="配置钉钉应用凭证后,可在本页手动获取组织架构并导入用户。排班同步仍在排课管理中手动触发。"
style={{ marginBottom: 24 }}
showIcon
/>
<Form form={form} layout="vertical" style={{ maxWidth: 520 }}>
<Form.Item
name="corpId"
label="CorpId企业ID"
rules={[{ required: true, message: '请输入 CorpId' }]}
>
<Input placeholder="dingxxxxxxxx" />
</Form.Item>
<Form.Item
name="agentId"
label="AppKey应用凭证"
rules={[{ required: true, message: '请输入 AppKey' }]}
>
<Input placeholder="从钉钉开放平台获取" />
</Form.Item>
<Form.Item
name="appSecret"
label="AppSecret应用密钥"
rules={[
{
required: isAppSecretRequired(!!config),
message: '首次配置请输入 AppSecret',
},
]}
extra={
config
? '已保存密钥;留空保持原值,输入新值将替换原密钥'
: '首次配置需要填写完整 AppSecret'
}
>
<Input.Password
placeholder={config ? '留空保持已保存的密钥' : '从钉钉开放平台获取'}
/>
</Form.Item>
<Space>
<PermissionButton
permission="integration:trigger"
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={handleSave}
>
</PermissionButton>
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
</Button>
</Space>
</Form>
{syncPanel && (
<>
<Divider titlePlacement="start"></Divider>
{syncPanel}
</>
)}
</Spin>
</Card>
);
};

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import {
buildDingTalkConfigPayload,
isAppSecretRequired,
} from './integration-config-form';
describe('DingTalk integration config form', () => {
it('requires AppSecret only for the first configuration', () => {
expect(isAppSecretRequired(false)).toBe(true);
expect(isAppSecretRequired(true)).toBe(false);
});
it('builds a manual-sync config without the retired startEnable flag', () => {
expect(
buildDingTalkConfigPayload({
corpId: 'ding-corp',
agentId: 'app-key',
appSecret: '',
}),
).toEqual({
corpId: 'ding-corp',
agentId: 'app-key',
appSecret: undefined,
});
});
});

View File

@@ -0,0 +1,13 @@
export interface DingTalkConfigFormValues {
agentId: string;
appSecret?: string;
corpId: string;
}
export const isAppSecretRequired = (hasSavedConfig: boolean) => !hasSavedConfig;
export const buildDingTalkConfigPayload = (values: DingTalkConfigFormValues) => ({
corpId: values.corpId.trim(),
agentId: values.agentId.trim(),
appSecret: values.appSecret?.trim() || undefined,
});

View File

@@ -10,6 +10,7 @@ import {
import { useNavigate } from 'react-router-dom';
import api from '../../api';
import { message } from '../../ui/app-message';
import { formatNotificationText } from '../../utils/notification-display';
const { Sider, Content } = Layout;
@@ -169,7 +170,7 @@ const NotificationsPage: React.FC = () => {
strong={!item.isRead}
style={{ fontSize: 15 }}
>
{item.title}
{formatNotificationText(item.title)}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{timeAgo(item.createdAt)}
@@ -183,7 +184,7 @@ const NotificationsPage: React.FC = () => {
ellipsis={{ rows: 1 }}
style={{ marginBottom: 0 }}
>
{item.content}
{formatNotificationText(item.content)}
</Typography.Paragraph>
)
}

View File

@@ -32,6 +32,7 @@ import { downloadBlob } from '../../utils/download';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import { buildTransferPayload } from './occupancy-form';
const { RangePicker } = DatePicker;
@@ -59,6 +60,8 @@ const OccupanciesPage: React.FC = () => {
const [batchCheckOutForm] = Form.useForm();
const [availableBeds, setAvailableBeds] = useState<any[]>([]);
const [availableLockers, setAvailableLockers] = useState<any[]>([]);
const [transferAvailableBeds, setTransferAvailableBeds] = useState<any[]>([]);
const [transferAvailableLockers, setTransferAvailableLockers] = useState<any[]>([]);
const fetchData = useCallback(async () => {
setLoading(true);
@@ -110,6 +113,30 @@ const OccupanciesPage: React.FC = () => {
} catch (e) { console.error(e); }
};
const handleTransferRoomChange = async (roomId: number) => {
transferForm.setFieldValue('newBedId', undefined);
transferForm.setFieldValue('newLockerId', undefined);
if (!roomId) {
setTransferAvailableBeds([]);
setTransferAvailableLockers([]);
return;
}
try {
const [beds, lockers] = await Promise.all([
api.get<any[]>(`/rooms/${roomId}/beds/available`),
api.get<any[]>(`/rooms/${roomId}/lockers/available`),
]);
setTransferAvailableBeds(beds);
setTransferAvailableLockers(lockers);
if (beds.length === 1) transferForm.setFieldValue('newBedId', beds[0].id);
} catch (e) {
console.error(e);
setTransferAvailableBeds([]);
setTransferAvailableLockers([]);
message.error('目标宿舍床位和柜子加载失败');
}
};
const filteredData = useMemo(() => {
if (!searchText) return data;
const keyword = searchText.toLowerCase();
@@ -170,13 +197,10 @@ const OccupanciesPage: React.FC = () => {
const values = await transferForm.validateFields();
setSaving(true);
try {
await api.put(`/occupancies/${transferModal.id}/transfer`, {
newRoomId: values.newRoomId,
transferDate: values.transferDate.format('YYYY-MM-DD'),
oldBillingEndDate: values.oldBillingEndDate?.format('YYYY-MM-DD'),
newBillingStartDate: values.newBillingStartDate?.format('YYYY-MM-DD'),
reason: values.reason,
});
await api.put(
`/occupancies/${transferModal.id}/transfer`,
buildTransferPayload(values),
);
message.success('换房成功');
setTransferModal(null);
transferForm.resetFields();
@@ -261,6 +285,9 @@ const OccupanciesPage: React.FC = () => {
size="small"
icon={<SwapOutlined />}
onClick={() => {
setTransferAvailableBeds([]);
setTransferAvailableLockers([]);
transferForm.resetFields();
setTransferModal(record);
transferForm.setFieldsValue({ transferDate: dayjs() });
}}
@@ -495,7 +522,12 @@ const OccupanciesPage: React.FC = () => {
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 1300 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
rowSelection={rowSelection}
/>
<Modal
@@ -566,7 +598,7 @@ const OccupanciesPage: React.FC = () => {
placeholder="默认为短租"
/>
</Form.Item>
<Form.Item name="responsibleOrganizationId" label="负责机构">
<Form.Item name="responsibleOrganizationId" label="所属机构">
<Select
showSearch
allowClear
@@ -708,7 +740,12 @@ const OccupanciesPage: React.FC = () => {
title={`换房 - ${transferModal?.student?.name}`}
open={!!transferModal}
onOk={handleTransfer}
onCancel={() => setTransferModal(null)}
onCancel={() => {
setTransferModal(null);
transferForm.resetFields();
setTransferAvailableBeds([]);
setTransferAvailableLockers([]);
}}
okText="确认换房"
confirmLoading={saving}
width={500}
@@ -719,6 +756,7 @@ const OccupanciesPage: React.FC = () => {
showSearch
optionFilterProp="label"
placeholder="选择目标宿舍"
onChange={handleTransferRoomChange}
options={rooms
.filter((r: any) => r.id !== transferModal?.roomId)
.map((r: any) => ({
@@ -728,6 +766,38 @@ const OccupanciesPage: React.FC = () => {
}))}
/>
</Form.Item>
<Form.Item
name="newBedId"
label="目标床位"
rules={[{ required: true, message: '请选择目标床位' }]}
>
<Select
placeholder="请先选择目标宿舍"
disabled={transferAvailableBeds.length === 0}
options={transferAvailableBeds.map((bed) => ({
value: bed.id,
label: bed.bedNumber,
}))}
notFoundContent="目标宿舍暂无可用床位"
/>
</Form.Item>
{transferAvailableBeds.length > 0 && (
<div style={{ marginTop: -16, marginBottom: 16, color: '#888', fontSize: 12 }}>
{transferAvailableBeds.length}
</div>
)}
<Form.Item name="newLockerId" label="目标柜子(可选)">
<Select
allowClear
placeholder="可选分配目标宿舍柜子"
disabled={transferAvailableLockers.length === 0}
options={transferAvailableLockers.map((locker) => ({
value: locker.id,
label: locker.lockerNumber,
}))}
notFoundContent="目标宿舍暂无可用柜子"
/>
</Form.Item>
<Form.Item name="transferDate" label="换房日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" />
</Form.Item>

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import dayjs from 'dayjs';
import { buildTransferPayload } from './occupancy-form';
describe('occupancy transfer form', () => {
it('submits the target room resources with the transfer dates', () => {
expect(
buildTransferPayload({
newRoomId: 5,
newBedId: 12,
newLockerId: 18,
transferDate: dayjs('2026-07-13'),
oldBillingEndDate: dayjs('2026-07-13'),
newBillingStartDate: dayjs('2026-07-14'),
reason: '调整宿舍',
}),
).toEqual({
newRoomId: 5,
newBedId: 12,
newLockerId: 18,
transferDate: '2026-07-13',
oldBillingEndDate: '2026-07-13',
newBillingStartDate: '2026-07-14',
reason: '调整宿舍',
});
});
});

View File

@@ -0,0 +1,21 @@
import type { Dayjs } from 'dayjs';
export interface TransferFormValues {
newRoomId: number;
newBedId: number;
newLockerId?: number;
transferDate: Dayjs;
oldBillingEndDate?: Dayjs;
newBillingStartDate?: Dayjs;
reason?: string;
}
export const buildTransferPayload = (values: TransferFormValues) => ({
newRoomId: values.newRoomId,
newBedId: values.newBedId,
newLockerId: values.newLockerId || undefined,
transferDate: values.transferDate.format('YYYY-MM-DD'),
oldBillingEndDate: values.oldBillingEndDate?.format('YYYY-MM-DD'),
newBillingStartDate: values.newBillingStartDate?.format('YYYY-MM-DD'),
reason: values.reason,
});

View File

@@ -26,13 +26,14 @@ const OperationLogsPage: React.FC = () => {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [filterModule, setFilterModule] = useState<string | undefined>();
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: any = { page, pageSize: 20 };
const params: any = { page, pageSize };
if (filterModule) params.module = filterModule;
if (dateRange) {
params.startDate = dateRange[0];
@@ -46,7 +47,7 @@ const OperationLogsPage: React.FC = () => {
message.error(err?.message || '加载失败,请稍后重试');
}
setLoading(false);
}, [page, filterModule, dateRange]);
}, [page, pageSize, filterModule, dateRange]);
useEffect(() => {
fetchData();
@@ -159,8 +160,13 @@ const OperationLogsPage: React.FC = () => {
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
pageSize,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
onChange: (nextPage, nextPageSize) => {
setPage(nextPage);
setPageSize(nextPageSize);
},
showTotal: (t) => `${t}`,
}}
/>

View File

@@ -235,7 +235,12 @@ const OrganizationsPage: React.FC = () => {
loading={loading}
locale={{ emptyText: <Empty description="暂无机构" /> }}
scroll={{ x: 1100 }}
pagination={{ pageSize: 20, showTotal: (total) => `${total} 个机构` }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total} 个机构`,
}}
/>
<Modal
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}

View File

@@ -156,17 +156,23 @@ const RoomsPage: React.FC = () => {
if (filterStatus) result = result.filter((r: Record<string, unknown>) => r.status === filterStatus);
return result;
}, [data, searchText, filterBuilding, filterStatus]);
const remainingBedSlots = useMemo(() => {
const capacity = Number(drawerRoom?.capacity) || 0;
return Math.max(capacity - beds.length, 0);
}, [drawerRoom?.capacity, beds.length]);
const defaultBatchBedCount = Math.min(4, Math.max(remainingBedSlots, 1));
const handleSave = async () => {
const values = await form.validateFields();
const payload = values;
setSaving(true);
try {
if (editing) {
await api.put(`/rooms/${editing.id}`, values);
await api.put(`/rooms/${editing.id}`, payload);
message.success('更新成功');
} else {
await api.post('/rooms', values);
message.success('创建成功');
await api.post('/rooms', payload);
message.success(`创建成功,已自动生成 ${values.capacity} 张床位`);
}
setModalOpen(false);
form.resetFields();
@@ -337,12 +343,6 @@ const RoomsPage: React.FC = () => {
/>
),
},
{
title: '性别',
dataIndex: 'gender',
width: 80,
render: (v: any) => (v ? <Tag color={v === '男' ? 'blue' : 'pink'}>{v}</Tag> : '-'),
},
{
title: '状态',
dataIndex: 'status',
@@ -519,7 +519,12 @@ const RoomsPage: React.FC = () => {
scroll={{ x: 1200 }}
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total}`,
}}
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
rowSelection={{
selectedRowKeys,
@@ -634,24 +639,26 @@ const RoomsPage: React.FC = () => {
type="primary"
size="small"
icon={<PlusOutlined />}
disabled={drawerRoom?.status === 'archived'}
disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}
onClick={() => { setBedEditing(null); bedForm.resetFields(); setBedModalOpen(true); }}
>
</Button>
<Popconfirm
title="批量生成床位"
title={remainingBedSlots > 0 ? '批量生成床位' : '床位已达到额定人数'}
description={
<InputNumber min={1} max={20} defaultValue={4} id="batch-bed-count" style={{ width: 80 }} />
remainingBedSlots > 0
? <InputNumber min={1} max={remainingBedSlots} defaultValue={defaultBatchBedCount} id="batch-bed-count" style={{ width: 80 }} />
: '如需增加床位,请先调整宿舍额定人数'
}
onConfirm={() => {
const input = document.getElementById('batch-bed-count') as HTMLInputElement;
handleBatchBeds(input ? parseInt(input.value) || 4 : 4);
handleBatchBeds(input ? parseInt(input.value) || defaultBatchBedCount : defaultBatchBedCount);
}}
okText="生成"
disabled={drawerRoom?.status === 'archived'}
disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}
>
<Button size="small" disabled={drawerRoom?.status === 'archived'}></Button>
<Button size="small" disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}></Button>
</Popconfirm>
</div>
<Table

View File

@@ -947,6 +947,19 @@ const SchedulesPage: React.FC = () => {
/>
</Form.Item>
<Form.Item
name="notes"
label="备注"
rules={[{ max: 500, message: '备注不能超过500字' }]}
>
<Input.TextArea
rows={3}
maxLength={500}
showCount
placeholder="可填写排课说明、设备需求或临时调整原因"
/>
</Form.Item>
<Form.Item
name="timeRange"
label="上课时段"

View File

@@ -15,10 +15,12 @@ describe('schedule edit form mapping', () => {
endTime: '18:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
notes: '需要投影设备',
});
expect(values.classroomId).toBe(1);
expect(values.weekDay).toBe(5);
expect(values.notes).toBe('需要投影设备');
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([
'2026-07-01',
@@ -36,6 +38,7 @@ describe('schedule edit form mapping', () => {
teacherId: 4,
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
notes: ' 临时调整教室 ',
}),
).toEqual({
classId: 1,
@@ -47,6 +50,24 @@ describe('schedule edit form mapping', () => {
endTime: '17:20',
startDate: '2026-08-01',
endDate: '2026-08-31',
notes: '临时调整教室',
});
});
});
describe('schedule notes normalization', () => {
it('omits whitespace-only notes from the payload', () => {
expect(
buildSchedulePayload({
classId: 1,
classroomId: 2,
weekDay: 6,
subject: '作文',
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
notes: ' ',
}).notes,
).toBeUndefined();
});
});

View File

@@ -6,6 +6,7 @@ export interface ScheduleFormValues {
weekDay: number;
subject: string;
teacherId?: number;
notes?: string;
timeRange: [Dayjs, Dayjs];
dateRange: [Dayjs, Dayjs];
}
@@ -17,6 +18,7 @@ export interface EditableSchedule {
weekDay: number;
subject: string;
teacherId: number | null;
notes?: string | null;
startTime: string;
endTime: string;
startDate: string;
@@ -29,6 +31,7 @@ export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormVa
weekDay: schedule.weekDay,
subject: schedule.subject,
teacherId: schedule.teacherId ?? undefined,
notes: schedule.notes ?? undefined,
timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
});
@@ -39,6 +42,7 @@ export const buildSchedulePayload = (values: ScheduleFormValues) => ({
weekDay: values.weekDay,
subject: values.subject,
teacherId: values.teacherId,
notes: values.notes?.trim() || undefined,
startTime: values.timeRange[0].format('HH:mm'),
endTime: values.timeRange[1].format('HH:mm'),
startDate: values.dateRange[0].format('YYYY-MM-DD'),

View File

@@ -322,7 +322,28 @@ const StudentsPage: React.FC = () => {
},
{ title: '民族', dataIndex: 'ethnicity', width: 90 },
{ title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 },
{
title: '紧急联系人电话',
dataIndex: 'emergencyPhone',
width: 150,
render: (v: string, record: any) => {
if (!v) return '-';
return (
<span>
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
<Button
type="link"
size="small"
style={{ padding: '8px 4px' }}
onClick={() => handleViewSensitive(record.id, '紧急联系人电话', v)}
title="点击查看完整号码"
>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</Button>
</span>
);
},
},
{
title: '所属机构',
dataIndex: 'organization',
@@ -556,7 +577,12 @@ const StudentsPage: React.FC = () => {
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 1410 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
rowSelection={{
selectedRowKeys,

View File

@@ -146,7 +146,12 @@ const TeacherWorkspacePage: React.FC = () => {
columns={classColumns}
dataSource={data.assignedClasses}
rowKey="classId"
pagination={{ pageSize: 20, showTotal: (total) => `${total} 个班级` }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total} 个班级`,
}}
/>
) : (
<Empty description="暂无分配的班级" />
@@ -160,7 +165,12 @@ const TeacherWorkspacePage: React.FC = () => {
columns={scheduleColumns}
dataSource={data.todaySchedules}
rowKey="id"
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total}`,
}}
/>
) : (
<Empty description="今日无排课" />
@@ -174,7 +184,12 @@ const TeacherWorkspacePage: React.FC = () => {
columns={studentColumns}
dataSource={data.myStudents}
rowKey="studentId"
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total}`,
}}
/>
) : (
<Empty description="暂无学生" />

View File

@@ -46,13 +46,14 @@ const ROLE_TYPE_LABELS: Record<string, string> = {
academic_teacher: '教务老师',
};
const PAGE_SIZE = 20;
const DEFAULT_PAGE_SIZE = 20;
const TeachersPage: React.FC = () => {
const [data, setData] = useState<TeacherRow[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [search, setSearch] = useState('');
const [profileModal, setProfileModal] = useState<TeacherRow | null>(null);
const [form] = Form.useForm<ProfileFormValues>();
@@ -62,7 +63,7 @@ const TeachersPage: React.FC = () => {
setLoading(true);
try {
const res = await api.get<TeacherListResponse>('/rbac/teachers', {
params: { search: search || undefined, page, pageSize: PAGE_SIZE },
params: { search: search || undefined, page, pageSize },
});
setData(res.list);
setTotal(res.total);
@@ -70,7 +71,7 @@ const TeachersPage: React.FC = () => {
// silent
}
setLoading(false);
}, [page, search]);
}, [page, pageSize, search]);
useEffect(() => {
fetchData();
@@ -199,9 +200,14 @@ const TeachersPage: React.FC = () => {
scroll={{ x: 1300 }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
pageSize,
total,
onChange: setPage,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
onChange: (nextPage, nextPageSize) => {
setPage(nextPage);
setPageSize(nextPageSize);
},
showTotal: (t) => `${t}`,
}}
expandable={{

View File

@@ -15,6 +15,10 @@ import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import {
userProfileResponseToFormValues,
type UserProfileResponse,
} from './user-profile-form';
const UsersPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
@@ -36,8 +40,8 @@ const UsersPage: React.FC = () => {
const handleOpenProfile = async (record: any) => {
setProfileUser(record);
try {
const res: any = await api.get(`/rbac/users/${record.id}/profile`);
profileForm.setFieldsValue(res);
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
profileForm.setFieldsValue(userProfileResponseToFormValues(res));
} catch {
profileForm.setFieldsValue({});
}

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { userProfileResponseToFormValues } from './user-profile-form';
describe('user profile form mapping', () => {
it('unwraps the nested profile returned by the user profile endpoint', () => {
expect(
userProfileResponseToFormValues({
id: 9,
username: 'teacher01',
name: '测试教师',
profile: {
joinedAt: '2026-07-01',
qualifications: '教师资格证',
subjects: ['语文', '历史'],
},
}),
).toEqual({
joinedAt: '2026-07-01',
qualifications: '教师资格证',
subjects: ['语文', '历史'],
});
});
it('returns empty form values when the user has no profile', () => {
expect(userProfileResponseToFormValues({ profile: null })).toEqual({});
});
});

View File

@@ -0,0 +1,16 @@
export interface UserProfileFormValues {
joinedAt?: string;
qualifications?: string;
subjects?: string[];
}
export interface UserProfileResponse {
id?: number;
username?: string;
name?: string;
profile?: UserProfileFormValues | null;
}
export const userProfileResponseToFormValues = (
response: UserProfileResponse,
): UserProfileFormValues => response.profile || {};

View File

@@ -0,0 +1,25 @@
export const notificationTypeLabels: Record<string, string> = {
bill_generated: '账单',
bill_paid: '账单',
check_in: '入住',
check_out: '退宿',
deposit_due: '押金',
deposit_refunded: '押金',
class_change: '班级',
schedule_conflict: '排课',
announcement: '公告',
};
const teacherRoleLabels: Record<string, string> = {
subject_teacher: '任课老师',
head_teacher: '班主任',
life_teacher: '生活老师',
academic_teacher: '学服老师',
};
export function formatNotificationText(text: string): string {
return Object.entries(teacherRoleLabels).reduce(
(result, [roleType, label]) => result.replaceAll(roleType, label),
text,
);
}

View File

@@ -6,7 +6,7 @@
"license": "UNLICENSED",
"author": "",
"scripts": {
"dev": "SEED_DEV=true nest start --watch -p tsconfig.build.json",
"dev": "cross-env SEED_DEV=true nest start --watch -p tsconfig.build.json",
"build": "nest build -p tsconfig.build.json",
"start:dev": "nest start --watch",
"format": "oxfmt",
@@ -28,6 +28,7 @@
"@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.1.0",
"@nestjs/jwt": "^11.0.2",
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
@@ -67,6 +68,7 @@
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^7.0.0",
"cross-env": "^10.1.0",
"eslint": "^9.18.0",
"globals": "^17.0.0",
"jest": "^30.0.0",

View File

@@ -0,0 +1,31 @@
import { ArchiveReportService } from './archive-report.service';
describe('ArchiveReportService retired profile fields', () => {
it('does not render the retired campus field in a student report', async () => {
const service = new ArchiveReportService(
{ findOne: jest.fn().mockResolvedValue({ campusLocation: '旧校区', grade: '高三' }) } as never,
{ find: jest.fn().mockResolvedValue([]) } as never,
{ find: jest.fn().mockResolvedValue([]) } as never,
{ find: jest.fn().mockResolvedValue([]) } as never,
{ findOne: jest.fn().mockResolvedValue(null) } as never,
{ find: jest.fn().mockResolvedValue([]) } as never,
{
findOne: jest.fn().mockResolvedValue({
id: 1,
name: '测试学生',
gender: '男',
phone: '',
ethnicity: '',
emergencyContact: '',
emergencyPhone: '',
}),
} as never,
);
const html = await service.generateReportHtml(1);
expect(html).not.toContain('旧校区');
expect(html).not.toContain('<span>校区</span>');
expect(html).toContain('高三');
});
});

View File

@@ -308,7 +308,6 @@ ${this.buildLearningAndResult(learnings, result, now)}
<div class="summary-row"><span>民族</span><span>${this.esc(student.ethnicity || '-')}</span></div>
<div class="summary-row"><span>紧急联系人</span><span>${this.esc(student.emergencyContact || '-')}</span></div>
<div class="summary-row"><span>紧急电话</span><span>${this.esc(student.emergencyPhone || '-')}</span></div>
<div class="summary-row"><span>校区</span><span>${this.esc(profile?.campusLocation || '-')}</span></div>
<div class="summary-row"><span>年级</span><span>${this.esc(profile?.grade || '-')}</span></div>
</div>
</div>`;

View File

@@ -20,8 +20,11 @@ import { ArchiveService } from './archive.service';
import {
UpsertProfileDto,
CreateEnrollmentDto,
UpdateEnrollmentDto,
CreateExamScoreDto,
UpdateExamScoreDto,
CreateLearningRecordDto,
UpdateLearningRecordDto,
UpsertResultDto,
} from './dto/archive.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -110,7 +113,7 @@ export class ArchiveController {
@RequirePermission('student:edit')
async updateEnrollment(
@Param('id') id: string,
@Body() dto: Partial<CreateEnrollmentDto>,
@Body() dto: UpdateEnrollmentDto,
@Request() req: AuthenticatedRequest,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
@@ -174,7 +177,7 @@ export class ArchiveController {
@RequirePermission('student:edit')
async updateExamScore(
@Param('id') id: string,
@Body() dto: Partial<CreateExamScoreDto>,
@Body() dto: UpdateExamScoreDto,
@Request() req: AuthenticatedRequest,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
@@ -238,7 +241,7 @@ export class ArchiveController {
@RequirePermission('student:edit')
async updateLearningRecord(
@Param('id') id: string,
@Body() dto: Partial<CreateLearningRecordDto>,
@Body() dto: UpdateLearningRecordDto,
@Request() req: AuthenticatedRequest,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
@@ -330,12 +333,12 @@ export class ArchiveController {
@Param('id') id: string,
@Res() res: Response,
) {
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(+studentId, +id);
res.setHeader('Content-Type', mimeType);
res.setHeader(
'Content-Disposition',
`inline; filename="${encodeURIComponent(fileName)}"`,
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(
+studentId,
+id,
);
res.setHeader('Content-Type', mimeType);
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`);
const stream = fs.createReadStream(fullPath);
stream.pipe(res);
}
@@ -358,7 +361,6 @@ export class ArchiveController {
return result;
}
@Get(':studentId/report-html')
@RequirePermission('student:view')
async generateReportHtml(

View File

@@ -0,0 +1,37 @@
import { ArchiveService } from './archive.service';
describe('ArchiveService.getProfile', () => {
it('returns the admission archive under the public result field', async () => {
const student = { id: 7, name: '测试学生' };
const result = {
id: 3,
studentId: 7,
cultureFinalScore: 450,
admittedCollege: '测试学院',
};
const studentRepo = { findOne: jest.fn().mockResolvedValue(student) };
const profileRepo = { findOne: jest.fn().mockResolvedValue(null) };
const enrollmentRepo = { find: jest.fn().mockResolvedValue([]) };
const examScoreRepo = { find: jest.fn().mockResolvedValue([]) };
const learningRecordRepo = { find: jest.fn().mockResolvedValue([]) };
const resultRepo = { findOne: jest.fn().mockResolvedValue(result) };
const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
const service = new ArchiveService(
studentRepo as never,
profileRepo as never,
enrollmentRepo as never,
examScoreRepo as never,
learningRecordRepo as never,
resultRepo as never,
attachmentRepo as never,
{} as never,
);
const response = await service.getProfile(7);
expect(response).toMatchObject({ student, result });
expect(response).not.toHaveProperty('resultArchive');
});
});

View File

@@ -15,8 +15,11 @@ import { ArchiveAttachment } from '../entities/archive-attachment.entity';
import {
UpsertProfileDto,
CreateEnrollmentDto,
UpdateEnrollmentDto,
CreateExamScoreDto,
UpdateExamScoreDto,
CreateLearningRecordDto,
UpdateLearningRecordDto,
UpsertResultDto,
} from './dto/archive.dto';
@@ -44,7 +47,9 @@ export class ArchiveService {
? path.resolve(process.cwd(), normalizedPath)
: path.resolve(this.uploadDir, normalizedPath);
const allowedRoots = [this.uploadDir, path.resolve(process.cwd(), 'uploads', 'archive')];
if (!allowedRoots.some((root) => fullPath === root || fullPath.startsWith(`${root}${path.sep}`))) {
if (
!allowedRoots.some((root) => fullPath === root || fullPath.startsWith(`${root}${path.sep}`))
) {
throw new BadRequestException('路径非法');
}
return fullPath;
@@ -54,21 +59,15 @@ export class ArchiveService {
const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在');
const [
profileRaw,
enrollments,
examScores,
learningRecords,
resultArchive,
attachments,
] = await Promise.all([
this.profileRepo.findOne({ where: { studentId } }),
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
this.resultRepo.findOne({ where: { studentId } }),
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
]);
const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments] =
await Promise.all([
this.profileRepo.findOne({ where: { studentId } }),
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
this.resultRepo.findOne({ where: { studentId } }),
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
]);
return {
student,
@@ -76,7 +75,7 @@ export class ArchiveService {
enrollments,
examScores,
learningRecords,
resultArchive,
result: resultArchive,
attachments,
};
}
@@ -102,7 +101,7 @@ export class ArchiveService {
return this.enrollmentRepo.save(entity);
}
async updateEnrollment(id: number, dto: Partial<CreateEnrollmentDto>) {
async updateEnrollment(id: number, dto: UpdateEnrollmentDto) {
const entity = await this.enrollmentRepo.findOne({ where: { id } });
if (!entity) throw new NotFoundException('报名记录不存在');
Object.assign(entity, dto);
@@ -124,7 +123,7 @@ export class ArchiveService {
return this.examScoreRepo.save(entity);
}
async updateExamScore(id: number, dto: Partial<CreateExamScoreDto>) {
async updateExamScore(id: number, dto: UpdateExamScoreDto) {
const entity = await this.examScoreRepo.findOne({ where: { id } });
if (!entity) throw new NotFoundException('考试成绩不存在');
Object.assign(entity, dto);
@@ -146,7 +145,7 @@ export class ArchiveService {
return this.learningRecordRepo.save(entity);
}
async updateLearningRecord(id: number, dto: Partial<CreateLearningRecordDto>) {
async updateLearningRecord(id: number, dto: UpdateLearningRecordDto) {
const entity = await this.learningRecordRepo.findOne({ where: { id } });
if (!entity) throw new NotFoundException('学习记录不存在');
Object.assign(entity, dto);
@@ -225,4 +224,3 @@ export class ArchiveService {
return { message: '已删除' };
}
}

View File

@@ -0,0 +1,57 @@
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
UpdateEnrollmentDto,
UpdateExamScoreDto,
UpdateLearningRecordDto,
UpsertProfileDto,
} from './archive.dto';
const pipe = new ValidationPipe({ transform: true, whitelist: true });
async function transform<T extends object>(metatype: new () => T, value: unknown) {
return pipe.transform(value, { type: 'body', metatype });
}
describe('UpsertProfileDto retired fields', () => {
it('removes the retired campusLocation field under whitelist validation', async () => {
const dto = plainToInstance(UpsertProfileDto, {
grade: '高三',
campusLocation: '旧校区',
});
await validate(dto, { whitelist: true });
expect(dto).toMatchObject({ grade: '高三' });
expect(dto).not.toHaveProperty('campusLocation');
});
});
describe('archive update DTOs', () => {
it('allows partial enrollment updates and strips unknown fields', async () => {
await expect(
transform(UpdateEnrollmentDto, { className: '新班级', ignored: 'value' }),
).resolves.toEqual(expect.objectContaining({ className: '新班级' }));
const result = await transform(UpdateEnrollmentDto, {
className: '新班级',
ignored: 'value',
});
expect(result).not.toHaveProperty('ignored');
});
it('retains create DTO validation rules for exam scores', async () => {
await expect(transform(UpdateExamScoreDto, { score: '90' })).rejects.toThrow();
await expect(transform(UpdateExamScoreDto, { score: 90 })).resolves.toMatchObject({
score: 90,
});
});
it('retains create DTO date validation for learning records', async () => {
await expect(
transform(UpdateLearningRecordDto, { recordDate: 'not-a-date' }),
).rejects.toThrow();
await expect(transform(UpdateLearningRecordDto, {})).resolves.toEqual({});
});
});

View File

@@ -1,3 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator';
export class UpsertProfileDto {
@@ -5,7 +6,6 @@ export class UpsertProfileDto {
@IsOptional() @IsString() targetMajor?: string;
@IsOptional() @IsString() subjectDirection?: string;
@IsOptional() @IsString() grade?: string;
@IsOptional() @IsString() campusLocation?: string;
@IsOptional() @IsDateString() profileDate?: string;
@IsOptional() @IsString() notes?: string;
}
@@ -21,6 +21,8 @@ export class CreateEnrollmentDto {
@IsOptional() @IsString() status?: string;
}
export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {}
export class CreateExamScoreDto {
@IsString() examType: string;
@IsOptional() @IsString() examName?: string;
@@ -32,6 +34,8 @@ export class CreateExamScoreDto {
@IsOptional() @IsNumber() enrollmentId?: number;
}
export class UpdateExamScoreDto extends PartialType(CreateExamScoreDto) {}
export class CreateLearningRecordDto {
@IsDateString() recordDate: string;
@IsString() recordType: string;
@@ -40,6 +44,8 @@ export class CreateLearningRecordDto {
@IsOptional() @IsString() nextStep?: string;
}
export class UpdateLearningRecordDto extends PartialType(CreateLearningRecordDto) {}
export class UpsertResultDto {
@IsOptional() @IsNumber() cultureFinalScore?: number;
@IsOptional() @IsNumber() professionalFinalScore?: number;

View File

@@ -93,6 +93,31 @@ describe('AttendanceImportService', () => {
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 () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
@@ -138,9 +163,19 @@ describe('AttendanceImportService', () => {
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-1',
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 });
const result = await service.importFromDingTalk({
@@ -150,10 +185,57 @@ describe('AttendanceImportService', () => {
autoMatch: true,
});
expect(dingRawRepo.save).toHaveBeenCalledWith(
[expect.objectContaining({
dingId: 'check-1',
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
})],
{ chunk: 50 },
);
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
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 () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{

View File

@@ -104,8 +104,10 @@ export class AttendanceImportService {
// Stage 2: Parse & deduplicate
this.emit('parsing', 0, total, `Parsing ${total} records...`);
const existingDingIds = await this.getExistingDingIds(rawResults);
const newRecords = rawResults.filter((r) => !existingDingIds.has(r.checkId));
const existingByDingId = await this.getExistingRecordsByDingId(rawResults);
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;
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.
*/
private async getExistingDingIds(
private async getExistingRecordsByDingId(
results: DingTalkAttendanceResult[],
): Promise<Set<string>> {
): Promise<Map<string, DingAttendanceRaw>> {
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({
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.timeResult = r.timeResult;
entity.locationResult = r.locationResult || '';
entity.punchSource = r.sourceType || null;
entity.punchDeviceName = r.deviceName || null;
entity.punchDeviceId = r.deviceId || null;
// Parse check-in/out times
if (r.actualCheckTime) {

View File

@@ -79,6 +79,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
},
{
matchedStudentId: 2,
@@ -100,7 +103,15 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
}),
);
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: 3, status: 'pending', source: 'dingtalk' }),
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),

View File

@@ -200,6 +200,53 @@ export class AttendanceService {
if (hasPunch) return 'present';
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(
scheduleId: number,
lessonDate: string,
@@ -270,6 +317,11 @@ export class AttendanceService {
schedule.endTime,
);
record.status = this.mapDingTalkStatus(raw, finalize);
Object.assign(record, this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
));
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
? null
: finalize
@@ -296,6 +348,11 @@ export class AttendanceService {
session: this.mapScheduleTimeToSession(schedule.startTime),
status: this.mapDingTalkStatus(raw, finalize),
source: 'dingtalk',
...this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: finalize
@@ -378,6 +435,11 @@ export class AttendanceService {
session: this.mapScheduleTimeToSession(schedule.startTime),
status: this.mapDingTalkStatus(raw, finalize),
source: 'dingtalk',
...this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: finalize
@@ -908,6 +970,10 @@ export class AttendanceService {
if (dto.status !== undefined) {
record.status = dto.status;
record.source = 'manual';
record.punchTime = null;
record.punchSource = null;
record.punchDeviceName = null;
record.punchDeviceId = null;
}
if (dto.remark !== undefined) {
record.remark = dto.remark;
@@ -933,6 +999,10 @@ export class AttendanceService {
if (dto.status !== undefined) {
freshRecord.status = dto.status;
freshRecord.source = 'manual';
freshRecord.punchTime = null;
freshRecord.punchSource = null;
freshRecord.punchDeviceName = null;
freshRecord.punchDeviceId = null;
}
if (dto.remark !== undefined) {
freshRecord.remark = dto.remark;

View File

@@ -12,6 +12,7 @@ describe('DingTalkService — attendance records', () => {
service = new DingTalkService({} as never, {} as never);
Object.assign(service, {
accessToken: 'test-token',
accessTokenCredentialKey: 'test-app-key:test-app-secret',
tokenExpiresAt: Date.now() + 3_600_000,
});
});
@@ -67,8 +68,10 @@ describe('DingTalkService — attendance records', () => {
userId: 'ding-1',
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
sourceType: 'USER',
sourceType: 'ATM',
checkType: 'OnDuty',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
timeResult: 'Normal',
},
],
@@ -82,6 +85,13 @@ describe('DingTalkService — attendance records', () => {
});
expect(record.workDate).toBe('2026-07-12');
expect(record).toEqual(
expect.objectContaining({
checkType: 'OnDuty',
sourceType: 'ATM',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
}),
);
});
});

View File

@@ -5,7 +5,7 @@ import { Bill } from '../entities/bill.entity';
import { BillItem } from '../entities/bill-item.entity';
import { Deposit } from '../entities/deposit.entity';
import * as ExcelJS from 'exceljs';
import * as PDFDocument from 'pdfkit';
import PDFDocument from 'pdfkit';
import { Response } from 'express';
@Injectable()

View File

@@ -0,0 +1,47 @@
import { ClassesService } from './classes.service';
import { ClassStudent } from '../entities';
describe('ClassesService — DingTalk class import membership lifecycle', () => {
it('reactivates left memberships and skips active memberships', async () => {
const left = {
classId: 3,
studentId: 8,
status: 'left',
joinDate: '2026-01-01',
leaveDate: '2026-02-01',
} as ClassStudent;
const active = { classId: 3, studentId: 9, status: 'active' } as ClassStudent;
const classStudentRepo = {
find: jest.fn().mockResolvedValue([left, active]),
create: jest.fn().mockImplementation((value: Partial<ClassStudent>) => value),
save: jest.fn().mockImplementation(async (value: ClassStudent[]) => value),
};
const service = new ClassesService(
{ findOne: jest.fn().mockResolvedValue({ id: 3 }) } as never,
classStudentRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{ create: jest.fn(), save: jest.fn() } as never,
{
find: jest.fn().mockResolvedValue([
{ dingUserId: 'd8', studentId: 8 },
{ dingUserId: 'd9', studentId: 9 },
]),
create: jest.fn(),
save: jest.fn(),
} as never,
);
const result = await service.batchImportStudents(3, [
{ dingUserId: 'd8', name: '学生8' },
{ dingUserId: 'd9', name: '学生9' },
]);
expect(result).toEqual({ imported: 1, skipped: 1 });
expect(left).toMatchObject({ status: 'active', leaveDate: null });
expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(classStudentRepo.save).toHaveBeenCalledWith([left]);
});
});

View File

@@ -29,6 +29,7 @@ import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity';
import { TeacherRoleType } from '../entities';
import * as ExcelJS from 'exceljs';
import { AuthorizationService, CaslAction, SubjectName, AuthenticatedUser } from '../authorization';
@@ -36,6 +37,13 @@ interface AuthenticatedRequest {
user: AuthenticatedUser;
}
const teacherRoleLabels: Record<string, string> = {
[TeacherRoleType.SUBJECT_TEACHER]: '任课老师',
[TeacherRoleType.HEAD_TEACHER]: '班主任',
[TeacherRoleType.LIFE_TEACHER]: '生活老师',
[TeacherRoleType.ACADEMIC_TEACHER]: '学服老师',
};
@UseGuards(JwtAuthGuard)
@Controller('classes')
export class ClassesController {
@@ -302,7 +310,7 @@ export class ClassesController {
recipientIds: [dto.userId],
type: NotificationType.CLASS_CHANGE,
title: '班级分配',
content: `您已被分配到班级担任${dto.roleType}角色`,
content: `您已被分配到班级担任${teacherRoleLabels[dto.roleType] ?? dto.roleType}角色`,
});
} catch {}
return result;

View File

@@ -0,0 +1,95 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ClassesService } from './classes.service';
import { ClassStudent } from '../entities';
function createService(
classStudentRepo: Record<string, jest.Mock>,
classRepo: Record<string, jest.Mock> = { findOne: jest.fn().mockResolvedValue({ id: 3 }) },
studentRepo: Record<string, jest.Mock> = {
find: jest.fn().mockResolvedValue([{ id: 8 }, { id: 9 }, { id: 10 }]),
},
) {
return new ClassesService(
classRepo as never,
classStudentRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
studentRepo as never,
{} as never,
);
}
describe('ClassesService — student membership lifecycle', () => {
it('marks an active membership as left instead of deleting it', async () => {
const membership = {
classId: 3,
studentId: 8,
status: 'active',
leaveDate: null,
} as ClassStudent;
const repo = {
findOne: jest.fn().mockResolvedValue(membership),
save: jest.fn().mockImplementation(async (value: ClassStudent) => value),
};
const service = createService(repo);
await service.removeStudent(3, 8);
expect(membership.status).toBe('left');
expect(membership.leaveDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(repo.save).toHaveBeenCalledWith(membership);
});
it('rejects removing an already-left membership', async () => {
const repo = {
findOne: jest.fn().mockResolvedValue({ status: 'left' }),
save: jest.fn(),
};
const service = createService(repo);
await expect(service.removeStudent(3, 8)).rejects.toBeInstanceOf(BadRequestException);
expect(repo.save).not.toHaveBeenCalled();
});
it('rejects removing a student without a membership', async () => {
const repo = {
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn(),
};
const service = createService(repo);
await expect(service.removeStudent(3, 8)).rejects.toBeInstanceOf(NotFoundException);
});
it('reactivates left memberships, creates new ones, and skips active ones', async () => {
const left = {
id: 1,
classId: 3,
studentId: 8,
status: 'left',
joinDate: '2026-01-01',
leaveDate: '2026-02-01',
} as ClassStudent;
const active = { id: 2, classId: 3, studentId: 9, status: 'active' } as ClassStudent;
const repo = {
find: jest.fn().mockResolvedValue([left, active]),
create: jest.fn().mockImplementation((value: Partial<ClassStudent>) => value),
save: jest.fn().mockImplementation(async (value: ClassStudent[]) => value),
};
const service = createService(repo);
const result = await service.addStudents(3, [8, 9, 10, 10]);
expect(result).toEqual({ added: 2, skipped: 1 });
expect(left).toMatchObject({ status: 'active', leaveDate: null });
expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(repo.create).toHaveBeenCalledWith(
expect.objectContaining({ classId: 3, studentId: 10, status: 'active' }),
);
expect(repo.save).toHaveBeenCalledWith(
expect.arrayContaining([left, expect.objectContaining({ studentId: 10 })]),
);
});
});

View File

@@ -227,34 +227,45 @@ export class ClassesService {
}
// 3. Fetch existing class-student links in one query
const allStudentIds = Array.from(dingToStudentId.values());
const alreadyInClass = new Set<number>();
if (allStudentIds.length > 0) {
const existingClassStudents = await this.classStudentRepo.find({
where: { classId, studentId: In(allStudentIds) },
});
for (const cs of existingClassStudents) {
alreadyInClass.add(cs.studentId);
const allStudentIds = Array.from(new Set(dingToStudentId.values()));
const existingClassStudents =
allStudentIds.length > 0
? await this.classStudentRepo.find({
where: { classId, studentId: In(allStudentIds) },
})
: [];
const existingByStudentId = new Map(
existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]),
);
const today = new Date().toISOString().slice(0, 10);
let skipped = 0;
const memberships = allStudentIds.flatMap((studentId) => {
const existing = existingByStudentId.get(studentId);
if (existing?.status === 'active') {
skipped++;
return [];
}
}
// 4. Batch insert new class-student records
const newClassStudents = allStudentIds
.filter((sid) => !alreadyInClass.has(sid))
.map((studentId) =>
if (existing) {
existing.status = 'active';
existing.joinDate = today;
existing.leaveDate = null;
return [existing];
}
return [
this.classStudentRepo.create({
classId,
studentId,
status: 'active',
joinDate: new Date().toISOString().slice(0, 10),
joinDate: today,
}),
);
];
});
if (newClassStudents.length > 0) {
await this.classStudentRepo.save(newClassStudents);
if (memberships.length > 0) {
await this.classStudentRepo.save(memberships);
}
return { imported: newClassStudents.length, skipped: alreadyInClass.size };
return { imported: memberships.length, skipped };
}
async update(id: number, dto: UpdateClassDto) {
const cls = await this.classRepo.findOne({ where: { id } });
@@ -315,26 +326,61 @@ export class ClassesService {
}
async addStudents(classId: number, studentIds: number[]) {
const uniqueStudentIds = [...new Set(studentIds)];
if (uniqueStudentIds.length === 0) return { added: 0, skipped: 0 };
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) throw new NotFoundException('班级不存在');
const students = await this.studentRepo.find({ where: { id: In(uniqueStudentIds) } });
if (students.length !== uniqueStudentIds.length) {
throw new NotFoundException('部分学生不存在');
}
const existing = await this.classStudentRepo.find({
where: { classId, studentId: In(studentIds) },
where: { classId, studentId: In(uniqueStudentIds) },
});
const existingIds = new Set(existing.map((e) => e.studentId));
const newIds = studentIds.filter((id) => !existingIds.has(id));
const entries = newIds.map((sid) =>
this.classStudentRepo.create({
classId,
studentId: sid,
joinDate: new Date().toISOString().split('T')[0],
}),
const existingByStudentId = new Map(
existing.map((classStudent) => [classStudent.studentId, classStudent]),
);
if (entries.length) await this.classStudentRepo.save(entries);
const today = new Date().toISOString().split('T')[0];
let skipped = 0;
const memberships = uniqueStudentIds.flatMap((studentId) => {
const current = existingByStudentId.get(studentId);
if (current?.status === 'active') {
skipped++;
return [];
}
if (current) {
current.status = 'active';
current.joinDate = today;
current.leaveDate = null;
return [current];
}
return [
this.classStudentRepo.create({
classId,
studentId,
status: 'active',
joinDate: today,
}),
];
});
if (memberships.length) await this.classStudentRepo.save(memberships);
return { added: entries.length, skipped: studentIds.length - entries.length };
return { added: memberships.length, skipped };
}
async removeStudent(classId: number, studentId: number) {
await this.classStudentRepo.delete({ classId, studentId });
const membership = await this.classStudentRepo.findOne({
where: { classId, studentId },
});
if (!membership) throw new NotFoundException('学生不在该班级');
if (membership.status !== 'active') throw new BadRequestException('学生已离班');
membership.status = 'left';
membership.leaveDate = new Date().toISOString().split('T')[0];
await this.classStudentRepo.save(membership);
return { success: true };
}

View File

@@ -137,6 +137,42 @@ export class ClassroomRentalsController {
return result;
}
@Put(':id/cancel')
@RequirePermission('rental:edit')
async cancel(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.cancel(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '取消租赁',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,
userAgent,
});
return result;
}
@Put(':id/end')
@RequirePermission('rental:edit')
async end(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.end(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '结束租赁',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,
userAgent,
});
return result;
}
@Delete(':id')
@RequirePermission('rental:delete')
async remove(@Param('id') id: string, @Request() req: any) {

View File

@@ -243,7 +243,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
startDate: '2026-03-01',
endDate: '2026-03-31',
};
const classroom = { id: 1, departmentId: 10 } as Classroom;
const classroom = { id: 1, status: 'available' } as Classroom;
const hostOrganization = {
id: 1,
name: 'Host',
@@ -315,8 +315,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
id: 1,
classroomId: 1,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
startDate: '2026-07-01',
endDate: '2099-03-31',
status: 'active',
notes: '',
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
@@ -324,8 +324,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
} as ClassroomRental;
const updatedRental = {
...existingRental,
startDate: '2026-04-01',
endDate: '2026-04-30',
startDate: '2026-08-01',
endDate: '2099-04-30',
};
const existingSchedule = {
id: 50,
@@ -339,12 +339,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
scheduleRepo.findOne.mockResolvedValue(existingSchedule);
const dto: UpdateRentalDto = { startDate: '2026-04-01', endDate: '2026-04-30' };
const dto: UpdateRentalDto = { startDate: '2026-08-01', endDate: '2099-04-30' };
await service.update(1, dto);
expect(rentalRepo.update).toHaveBeenCalledWith(
1,
expect.objectContaining({ startDate: '2026-04-01', endDate: '2026-04-30' }),
expect.objectContaining({ startDate: '2026-08-01', endDate: '2099-04-30' }),
);
expect(scheduleRepo.update).toHaveBeenCalledWith(
50,
@@ -352,8 +352,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
scheduleType: 'RENTAL',
rentalId: 1,
classroomId: 1,
startDate: '2026-04-01',
endDate: '2026-04-30',
startDate: '2026-08-01',
endDate: '2099-04-30',
status: 'active',
subject: 'Organization A 租赁',
}),
@@ -362,27 +362,59 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
expect(scheduleRepo.delete).not.toHaveBeenCalled();
});
it('deletes the RENTAL schedule row when status changes to cancelled', async () => {
it('deletes the RENTAL schedule row when the rental is cancelled', async () => {
const rental = {
id: 1,
classroomId: 1,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
startDate: '2026-07-01',
endDate: '2099-03-31',
status: 'active',
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
} as ClassroomRental;
const cancelledRental = { ...rental, status: 'cancelled' };
const cancelledRental = { ...rental, status: 'cancelled' } as ClassroomRental;
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(cancelledRental);
await service.update(1, { status: 'cancelled' });
await service.cancel(1);
expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' });
expect(scheduleRepo.delete).toHaveBeenCalledWith({ rentalId: 1, scheduleType: 'RENTAL' });
expect(scheduleRepo.findOne).not.toHaveBeenCalled();
expect(scheduleRepo.update).not.toHaveBeenCalled();
expect(scheduleRepo.create).not.toHaveBeenCalled();
});
});
describe('lifecycle actions', () => {
it('ends an active rental and shortens a future end date', async () => {
const rental = {
id: 1,
classroomId: 1,
startDate: '2026-07-01',
endDate: '2099-12-31',
status: 'active',
lesseeOrganization: { name: 'Organization A' },
} as ClassroomRental;
const ended = { ...rental, status: 'ended', endDate: '2026-07-13' } as ClassroomRental;
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(ended);
scheduleRepo.findOne.mockResolvedValue({ id: 50 } as ClassSchedule);
await service.end(1);
expect(rentalRepo.update).toHaveBeenCalledWith(
1,
expect.objectContaining({ status: 'ended' }),
);
expect(scheduleRepo.update).toHaveBeenCalled();
});
it('rejects ending a future rental', async () => {
rentalRepo.findOne.mockResolvedValue({
id: 1,
startDate: '2099-01-01',
endDate: '2099-12-31',
status: 'active',
} as ClassroomRental);
await expect(service.end(1)).rejects.toThrow('租赁尚未开始');
});
});
@@ -394,7 +426,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
status: 'active',
status: 'cancelled',
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
} as ClassroomRental;
@@ -419,7 +451,7 @@ describe('ClassroomRentalsService — organization roles', () => {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassroomRental>([])),
} as any;
const classroomRepo = {
findOne: jest.fn().mockResolvedValue({ id: 1, departmentId: 10 }),
findOne: jest.fn().mockResolvedValue({ id: 1, status: 'available' }),
} as any;
const organizationRepo = {
findOne: jest

View File

@@ -6,8 +6,8 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity';
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
import { Organization } from '../entities/organization.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
@@ -70,8 +70,11 @@ export class ClassroomRentalsService {
const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last });
}
if (!query?.includeEnded) qb.andWhere('r.status != :cancelled', { cancelled: 'cancelled' });
return qb.getMany();
if (!query?.includeEnded) {
qb.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE });
}
const rentals = await qb.getMany();
return rentals.map((rental) => this.withEffectiveStatus(rental));
}
async findOne(id: number) {
@@ -80,7 +83,7 @@ export class ClassroomRentalsService {
relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'],
});
if (!rental) throw new NotFoundException('租赁订单不存在');
return rental;
return this.withEffectiveStatus(rental);
}
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
@@ -93,7 +96,7 @@ export class ClassroomRentalsService {
where: {
...(excludeId ? { id: Not(excludeId) } : {}),
classroomId,
status: Not('cancelled'),
status: ClassroomRentalStatus.ACTIVE,
startDate: LessThanOrEqual(monthEnd),
endDate: MoreThanOrEqual(monthStart),
},
@@ -101,7 +104,7 @@ export class ClassroomRentalsService {
this.scheduleRepo.find({
where: {
classroomId,
status: 'active',
status: ClassroomRentalStatus.ACTIVE,
scheduleType: 'INTERNAL',
startDate: LessThanOrEqual(monthEnd),
endDate: MoreThanOrEqual(monthStart),
@@ -133,7 +136,7 @@ export class ClassroomRentalsService {
.createQueryBuilder('r')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.where('r.classroomId = :cid', { cid: classroomId })
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
.andWhere('r.startDate <= :end', { end: endDate })
.andWhere('r.endDate >= :start', { start: startDate });
if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId });
@@ -222,6 +225,9 @@ export class ClassroomRentalsService {
if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期');
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
if (!classroom) throw new NotFoundException('教室不存在');
if (classroom.status !== ClassroomStatus.AVAILABLE) {
throw new BadRequestException('仅可用教室可以创建租赁');
}
const lessorOrganization = dto.lessorOrganizationId
? await this.organizationRepo.findOne({
where: { id: dto.lessorOrganizationId, status: 'active' },
@@ -253,7 +259,7 @@ export class ClassroomRentalsService {
lessorOrganizationId: lessorOrganization.id,
lesseeOrganizationId: lesseeOrganization.id,
createdBy: userId,
status: 'active',
status: ClassroomRentalStatus.ACTIVE,
});
const saved = await this.repo.save(rental);
await this.syncScheduleFromRental(saved, lesseeOrganization.name);
@@ -262,11 +268,21 @@ export class ClassroomRentalsService {
async update(id: number, dto: UpdateRentalDto) {
const rental = await this.findOne(id);
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
throw new BadRequestException('已结束或已取消的租赁不能编辑');
}
// 若修改了教室/日期,重新冲突检查
const newClassroomId = dto.classroomId ?? rental.classroomId;
const newStart = dto.startDate ?? rental.startDate;
const newEnd = dto.endDate ?? rental.endDate;
if (newStart > newEnd) throw new BadRequestException('起始日期不能晚于结束日期');
if (dto.classroomId && dto.classroomId !== rental.classroomId) {
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
if (!classroom) throw new NotFoundException('教室不存在');
if (classroom.status !== ClassroomStatus.AVAILABLE) {
throw new BadRequestException('仅可用教室可以承接租赁');
}
}
if (dto.classroomId || dto.startDate || dto.endDate) {
const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id);
if (conflicts.length > 0) {
@@ -300,16 +316,46 @@ export class ClassroomRentalsService {
}
await this.repo.update(id, dto);
const updated = await this.findOne(id);
if (dto.status === 'cancelled') {
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
} else {
await this.syncScheduleFromRental(updated);
}
await this.syncScheduleFromRental(updated);
return updated;
}
async cancel(id: number) {
const rental = await this.findOne(id);
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
throw new BadRequestException('仅有效租赁可以取消');
}
await this.repo.update(id, { status: ClassroomRentalStatus.CANCELLED });
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
return this.findOne(id);
}
async end(id: number) {
const rental = await this.findOne(id);
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
throw new BadRequestException('仅有效租赁可以结束');
}
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
if (rental.startDate > today) throw new BadRequestException('租赁尚未开始,不能结束');
await this.repo.update(id, {
status: ClassroomRentalStatus.ENDED,
endDate: rental.endDate > today ? today : rental.endDate,
});
const ended = await this.findOne(id);
await this.syncScheduleFromRental(ended);
return ended;
}
async remove(id: number) {
const rental = await this.findOne(id);
if (rental.effectiveStatus === ClassroomRentalStatus.ACTIVE) {
throw new BadRequestException('进行中的租赁请先取消或结束');
}
// 同步删除对应排课记录
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
// 同时删除合同文件
@@ -327,6 +373,20 @@ export class ClassroomRentalsService {
return { message: '删除成功' };
}
private withEffectiveStatus(rental: ClassroomRental) {
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const effectiveStatus =
rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today
? ClassroomRentalStatus.ENDED
: rental.status;
return Object.assign(rental, { effectiveStatus });
}
/**
* 同步租赁订单到 class_schedulesschedule_type = 'RENTAL'
*/
@@ -348,7 +408,7 @@ export class ClassroomRentalsService {
teacherId: null,
scheduleType: 'RENTAL',
rentalId: rental.id,
status: 'active',
status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active',
notes: rental.notes,
};
if (schedule) {
@@ -437,14 +497,16 @@ export class ClassroomRentalsService {
const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const classrooms = await this.classroomRepo.find({
where: { status: Not('archived') },
where: { status: Not(ClassroomStatus.ARCHIVED) },
order: { building: 'ASC', name: 'ASC' },
});
const rentals = await this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.leftJoinAndSelect('r.classroom', 'classroom')
.where('r.status != :cancelled', { cancelled: 'cancelled' })
.where('r.status IN (:...statuses)', {
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
})
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
.getMany();

View File

@@ -1,4 +1,4 @@
import { IsOptional, IsString, IsInt, IsNumber, IsEnum, IsDateString } from 'class-validator';
import { IsOptional, IsString, IsInt, IsNumber, IsDateString } from 'class-validator';
export class CreateRentalDto {
@IsInt()
@@ -62,8 +62,4 @@ export class UpdateRentalDto {
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsEnum(['active', 'ended', 'cancelled'])
status?: string;
}

View File

@@ -1,14 +1,13 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm';
import { Classroom } from '../entities/classroom.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Repository, Not, MoreThanOrEqual } from 'typeorm';
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
@Injectable()
export class ClassroomsService {
constructor(
@InjectRepository(Classroom) private repo: Repository<Classroom>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@@ -21,49 +20,148 @@ export class ClassroomsService {
if (query?.roomType) where.roomType = query.roomType;
if (!query?.includeArchived) where.status = Not('archived');
const list = await this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } });
const usageMap = await this.getCurrentUsageForClassrooms(list.map((c) => c.id));
return list.map((c) => ({ ...c, currentUsage: usageMap.get(c.id) ?? null }));
const usageMap = await this.getUsageForClassrooms(list.map((c) => c.id));
return list.map((classroom) => this.withEffectiveStatus(classroom, usageMap.get(classroom.id)));
}
async findOne(id: number) {
const cls = await this.repo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('教室不存在');
const usageMap = await this.getCurrentUsageForClassrooms([id]);
return { ...cls, currentUsage: usageMap.get(id) ?? null };
const usageMap = await this.getUsageForClassrooms([id]);
return this.withEffectiveStatus(cls, usageMap.get(id));
}
async create(dto: CreateClassroomDto) {
const exists = await this.repo.findOne({ where: { name: dto.name } });
if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
return this.repo.save(this.repo.create(dto));
return this.repo.save(this.repo.create({ ...dto, status: ClassroomStatus.AVAILABLE }));
}
async update(id: number, dto: UpdateClassroomDto) {
await this.findOne(id);
const classroom = await this.repo.findOne({ where: { id } });
if (!classroom) throw new NotFoundException('教室不存在');
if (dto.status === ClassroomStatus.MAINTENANCE && classroom.status !== dto.status) {
await this.assertNoActiveAllocations(id);
}
await this.repo.update(id, dto);
return this.repo.findOne({ where: { id } });
}
async remove(id: number) {
await this.findOne(id);
await this.repo.update(id, { status: 'archived' });
const classroom = await this.repo.findOne({ where: { id } });
if (!classroom) throw new NotFoundException('教室不存在');
await this.assertNoActiveAllocations(id);
await this.repo.update(id, { status: ClassroomStatus.ARCHIVED });
return { message: '已归档' };
}
async restore(id: number) {
await this.findOne(id);
await this.repo.update(id, { status: 'reserved' });
const classroom = await this.repo.findOne({ where: { id } });
if (!classroom) throw new NotFoundException('教室不存在');
await this.repo.update(id, { status: ClassroomStatus.AVAILABLE });
return this.repo.findOne({ where: { id } });
}
private async getCurrentUsageForClassrooms(classroomIds: number[]): Promise<Map<number, { type: 'schedule' | 'rental'; title: string; startTime: string; endTime: string }>> {
const result = new Map<number, { type: 'schedule' | 'rental'; title: string; startTime: string; endTime: string }>();
private withEffectiveStatus(
classroom: Classroom,
usage?: {
state: 'in_use' | 'reserved';
currentUsage: {
type: 'schedule' | 'rental';
title: string;
startTime: string;
endTime: string;
} | null;
},
) {
const effectiveStatus =
classroom.status === ClassroomStatus.ARCHIVED ||
classroom.status === ClassroomStatus.MAINTENANCE
? classroom.status
: (usage?.state ?? ClassroomStatus.AVAILABLE);
return { ...classroom, currentUsage: usage?.currentUsage ?? null, effectiveStatus };
}
private async assertNoActiveAllocations(classroomId: number) {
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const scheduleCount = await this.scheduleRepo
.createQueryBuilder('schedule')
.where('schedule.classroomId = :classroomId', { classroomId })
.andWhere('schedule.status = :active', { active: 'active' })
.andWhere('schedule.endDate >= :today', { today })
.getCount();
const rentalCount = await this.rentalRepo.count({
where: {
classroomId,
status: ClassroomRentalStatus.ACTIVE,
endDate: MoreThanOrEqual(today),
},
});
if (rentalCount > 0 || scheduleCount > 0) {
throw new BadRequestException('该教室存在有效排课或租赁,无法维护或归档');
}
}
private async getUsageForClassrooms(classroomIds: number[]): Promise<
Map<
number,
{
state: 'in_use' | 'reserved';
currentUsage: {
type: 'schedule' | 'rental';
title: string;
startTime: string;
endTime: string;
} | null;
}
>
> {
const result = new Map<
number,
{
state: 'in_use' | 'reserved';
currentUsage: {
type: 'schedule' | 'rental';
title: string;
startTime: string;
endTime: string;
} | null;
}
>();
if (classroomIds.length === 0) return result;
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const currentTime = now.toTimeString().slice(0, 5);
const weekDay = now.getDay() || 7;
const todayStr = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
const currentTime = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Shanghai',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(now);
const shanghaiParts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
weekday: 'short',
}).format(now);
const weekDayMap: Record<string, number> = {
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
Sun: 7,
};
const weekDay = weekDayMap[shanghaiParts];
const schedules = await this.scheduleRepo
.createQueryBuilder('s')
@@ -71,26 +169,37 @@ export class ClassroomsService {
.select('s.classroomId', 'classroomId')
.addSelect('s.startTime', 'startTime')
.addSelect('s.endTime', 'endTime')
.addSelect('s.startDate', 'startDate')
.addSelect('s.endDate', 'endDate')
.addSelect('s.weekDay', 'weekDay')
.addSelect('s.subject', 'subject')
.addSelect('c.name', 'className')
.where('s.classroomId IN (:...ids)', { ids: classroomIds })
.andWhere('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
.andWhere('s.startDate <= :today', { today: todayStr })
.andWhere('s.endDate >= :today', { today: todayStr })
.andWhere('s.weekDay = :weekDay', { weekDay })
.andWhere('s.startTime <= :currentTime', { currentTime })
.andWhere('s.endTime >= :currentTime', { currentTime })
.getRawMany();
for (const s of schedules) {
const classroomId = Number(s.classroomId);
if (!result.has(classroomId)) {
for (const schedule of schedules) {
const classroomId = Number(schedule.classroomId);
const isCurrent =
String(schedule.startDate) <= todayStr &&
String(schedule.endDate) >= todayStr &&
Number(schedule.weekDay) === weekDay &&
String(schedule.startTime) <= currentTime &&
String(schedule.endTime) >= currentTime;
const existing = result.get(classroomId);
if (!existing || isCurrent) {
result.set(classroomId, {
type: 'schedule',
title: `${s.className || ''} ${s.subject || ''}`.trim() || '内部课程',
startTime: String(s.startTime),
endTime: String(s.endTime),
state: isCurrent ? 'in_use' : 'reserved',
currentUsage: isCurrent
? {
type: 'schedule',
title: `${schedule.className || ''} ${schedule.subject || ''}`.trim() || '内部课程',
startTime: String(schedule.startTime),
endTime: String(schedule.endTime),
}
: null,
});
}
}
@@ -103,19 +212,25 @@ export class ClassroomsService {
.addSelect('r.endDate', 'endDate')
.addSelect('t.name', 'tenantName')
.where('r.classroomId IN (:...ids)', { ids: classroomIds })
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
.andWhere('r.startDate <= :today', { today: todayStr })
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
.andWhere('r.endDate >= :today', { today: todayStr })
.getRawMany();
for (const r of rentals) {
const classroomId = Number(r.classroomId);
if (!result.has(classroomId)) {
for (const rental of rentals) {
const classroomId = Number(rental.classroomId);
const isCurrent = String(rental.startDate) <= todayStr && String(rental.endDate) >= todayStr;
const existing = result.get(classroomId);
if (!existing || isCurrent) {
result.set(classroomId, {
type: 'rental',
title: r.tenantName ? `${r.tenantName} 租赁` : '外部租赁',
startTime: '00:00',
endTime: '23:59',
state: isCurrent ? 'in_use' : 'reserved',
currentUsage: isCurrent
? {
type: 'rental',
title: rental.tenantName ? `${rental.tenantName} 租赁` : '外部租赁',
startTime: '00:00',
endTime: '23:59',
}
: null,
});
}
}
@@ -136,24 +251,38 @@ export class ClassroomsService {
let skipped = 0;
const errors: string[] = [];
for (const row of rows) {
if (!row.name?.trim()) { skipped++; continue; }
if (!row.name?.trim()) {
skipped++;
continue;
}
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
if (exists) { errors.push(`教室 ${row.name} 已存在`); skipped++; continue; }
if (exists) {
errors.push(`教室 ${row.name} 已存在`);
skipped++;
continue;
}
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
imported++;
}
return { message: `成功导入 ${imported} 间教室,跳过 ${skipped}`, imported, skipped, errors: errors.length > 0 ? errors : undefined };
return {
message: `成功导入 ${imported} 间教室,跳过 ${skipped}`,
imported,
skipped,
errors: errors.length > 0 ? errors : undefined,
};
}
async getUsageReport(dateFrom: string, dateTo: string) {
const classrooms = await this.repo.find({
where: { status: Not('archived') },
where: { status: Not(ClassroomStatus.ARCHIVED) },
order: { building: 'ASC', name: 'ASC' },
});
const rentals = await this.rentalRepo
.createQueryBuilder('r')
.where('r.status != :cancelled', { cancelled: 'cancelled' })
.where('r.status IN (:...statuses)', {
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
})
.andWhere('r.startDate <= :dateTo AND r.endDate >= :dateFrom', { dateFrom, dateTo })
.getMany();

View File

@@ -0,0 +1,39 @@
import { BadRequestException } from '@nestjs/common';
import { ClassroomStatus } from '../entities/classroom.entity';
import { ClassroomsService } from './classrooms.service';
function createService(options?: { rentals?: number; schedules?: number }) {
const repo = {
findOne: jest.fn().mockResolvedValue({ id: 1, name: 'A101', status: ClassroomStatus.ARCHIVED }),
update: jest.fn(),
};
const rentalRepo = { count: jest.fn().mockResolvedValue(options?.rentals ?? 0) };
const scheduleRepo = {
createQueryBuilder: jest.fn().mockReturnValue({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getCount: jest.fn().mockResolvedValue(options?.schedules ?? 0),
}),
};
return {
service: new ClassroomsService(repo as never, rentalRepo as never, scheduleRepo as never),
repo,
};
}
describe('ClassroomsService — persisted classroom status', () => {
it('restores an archived classroom to available', async () => {
const { service, repo } = createService();
await service.restore(1);
expect(repo.update).toHaveBeenCalledWith(1, { status: ClassroomStatus.AVAILABLE });
});
it('rejects archiving a classroom with active allocations', async () => {
const { service, repo } = createService({ rentals: 1 });
await expect(service.remove(1)).rejects.toBeInstanceOf(BadRequestException);
expect(repo.update).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,5 @@
import { IsOptional, IsString, IsNotEmpty, IsInt, IsEnum } from 'class-validator';
import { ClassroomStatus } from '../../entities/classroom.entity';
export class CreateClassroomDto {
@IsString()
@@ -21,11 +22,9 @@ export class CreateClassroomDto {
@IsString()
roomType?: string; // 大 / 次大 / 小
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateClassroomDto {
@@ -49,12 +48,11 @@ export class UpdateClassroomDto {
@IsString()
roomType?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsEnum(['available', 'archived'])
status?: string;
@IsEnum([ClassroomStatus.AVAILABLE, ClassroomStatus.MAINTENANCE])
status?: ClassroomStatus;
}

View File

@@ -119,13 +119,12 @@ export class DashboardService {
const pendingQb = this.depositRepo
.createQueryBuilder('d')
.select('SUM(d.amount)', 'total')
.where('d.status = :paid', { paid: 'paid' })
.andWhere('d.refundStatus IS NULL');
.where('d.status = :paid', { paid: 'paid' });
const pendingResult = await pendingQb.getRawOne();
const pendingDeposits = parseFloat(pendingResult?.total || '0');
const activeRentals = await this.rentalRepo.count({
where: { endDate: MoreThanOrEqual(todayStr) },
where: { status: 'active' as const, endDate: MoreThanOrEqual(todayStr) },
});
const occByBldQb = this.occRepo
@@ -366,7 +365,7 @@ export class DashboardService {
async getClassroomOccupancy() {
const classrooms = await this.classroomRepo.find({
where: { status: Not('archived') },
where: { status: 'available' as const },
order: { building: 'ASC', name: 'ASC' },
});
const today = new Date().toISOString().slice(0, 10);
@@ -383,7 +382,7 @@ export class DashboardService {
.createQueryBuilder('r')
.select('r.classroomId', 'classroomId')
.addSelect('COUNT(*)', 'rentalCount')
.where('r.status != :cancelled', { cancelled: 'cancelled' })
.where('r.status = :active', { active: 'active' })
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
.groupBy('r.classroomId');
const rentals = await rentalQb.getRawMany();
@@ -403,7 +402,7 @@ export class DashboardService {
async getClassroomUtilizationStats() {
const totalClassrooms = await this.classroomRepo.count({
where: { status: Not('archived') },
where: { status: 'available' as const },
});
const today = new Date().toISOString().slice(0, 10);
@@ -421,7 +420,7 @@ export class DashboardService {
const rentalQb = this.rentalRepo
.createQueryBuilder('r')
.select('COUNT(DISTINCT r.classroomId)', 'cnt')
.where('r.status != :cancelled', { cancelled: 'cancelled' })
.where('r.status = :active', { active: 'active' })
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
const rentalResult = await rentalQb.getRawOne();
@@ -438,7 +437,7 @@ export class DashboardService {
const combinedRentalQb = this.rentalRepo
.createQueryBuilder('r')
.select('r.classroomId')
.where('r.status != :cancelled', { cancelled: 'cancelled' })
.where('r.status = :active', { active: 'active' })
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
.groupBy('r.classroomId');
const rentalIds = await combinedRentalQb.getRawMany();

View File

@@ -0,0 +1,56 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getDataSourceToken } from '@nestjs/typeorm';
import { DatabaseMigrationsService } from './database-migrations.service';
function createRunner(tableExists: boolean, columns: string[] = []) {
return {
connect: jest.fn(),
release: jest.fn(),
getTables: jest.fn().mockResolvedValue(tableExists ? [{ name: 'class_student' }] : []),
getTable: jest.fn().mockResolvedValue({
name: 'class_student',
columns: columns.map((name) => ({ name })),
}),
dropColumn: jest.fn().mockResolvedValue(undefined),
};
}
async function createService(runner: ReturnType<typeof createRunner>) {
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMigrationsService,
{
provide: getDataSourceToken(),
useValue: {
options: { type: 'better-sqlite3' },
createQueryRunner: jest.fn().mockReturnValue(runner),
},
},
],
}).compile();
return module.get(DatabaseMigrationsService) as DatabaseMigrationsService & {
removeUnusedClassStudentColumns(): Promise<void>;
};
}
describe('DatabaseMigrationsService — class student cleanup', () => {
it('drops the unused enrollment_id column', async () => {
const runner = createRunner(true, ['id', 'enrollment_id']);
const service = await createService(runner);
await service.removeUnusedClassStudentColumns();
expect(runner.dropColumn).toHaveBeenCalledWith('class_student', 'enrollment_id');
expect(runner.release).toHaveBeenCalled();
});
it('does nothing when the table is absent', async () => {
const runner = createRunner(false);
const service = await createService(runner);
await service.removeUnusedClassStudentColumns();
expect(runner.dropColumn).not.toHaveBeenCalled();
expect(runner.release).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,36 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getDataSourceToken } from '@nestjs/typeorm';
import { DatabaseMigrationsService } from './database-migrations.service';
describe('DatabaseMigrationsService — classroom status normalization', () => {
it('normalizes legacy persisted statuses to available', async () => {
const runner = {
connect: jest.fn(),
release: jest.fn(),
getTables: jest.fn().mockResolvedValue([{ name: 'classrooms' }]),
query: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMigrationsService,
{
provide: getDataSourceToken(),
useValue: {
options: { type: 'better-sqlite3' },
createQueryRunner: jest.fn().mockReturnValue(runner),
},
},
],
}).compile();
const service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & {
normalizeClassroomStatuses(): Promise<void>;
};
await service.normalizeClassroomStatuses();
expect(runner.query).toHaveBeenCalledWith(
expect.stringContaining("status NOT IN ('available', 'maintenance', 'archived')"),
);
expect(runner.release).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,101 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getDataSourceToken } from '@nestjs/typeorm';
import { DatabaseMigrationsService } from './database-migrations.service';
function createRunner(columns: string[]) {
return {
connect: jest.fn(),
release: jest.fn(),
query: jest.fn().mockResolvedValue([]),
getTables: jest.fn().mockResolvedValue(columns.length ? [{ name: 'deposits' }] : []),
getTable: jest.fn().mockResolvedValue({
name: 'deposits',
columns: columns.map((name) => ({ name })),
}),
renameColumn: jest.fn().mockResolvedValue(undefined),
dropColumn: jest.fn().mockResolvedValue(undefined),
};
}
async function createService(runner: ReturnType<typeof createRunner>) {
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMigrationsService,
{
provide: getDataSourceToken(),
useValue: {
options: { type: 'better-sqlite3' },
createQueryRunner: jest.fn().mockReturnValue(runner),
},
},
],
}).compile();
return module.get(DatabaseMigrationsService) as DatabaseMigrationsService & {
cleanupDepositRefundColumns(): Promise<void>;
};
}
describe('DatabaseMigrationsService — deposit refund cleanup', () => {
it('renames refund audit fields and drops approval-flow remnants', async () => {
const runner = createRunner([
'id',
'refund_status',
'refund_requested_at',
'refund_approved_by',
'refund_approved_at',
'refund_rejected_reason',
]);
const service = await createService(runner);
await service.cleanupDepositRefundColumns();
expect(runner.renameColumn).toHaveBeenCalledWith(
'deposits',
'refund_approved_by',
'refunded_by',
);
expect(runner.renameColumn).toHaveBeenCalledWith(
'deposits',
'refund_approved_at',
'refunded_at',
);
expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_status');
expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_requested_at');
expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_rejected_reason');
expect(runner.release).toHaveBeenCalled();
});
it('merges legacy audit values before dropping duplicate legacy columns', async () => {
const runner = createRunner([
'id',
'refund_approved_by',
'refund_approved_at',
'refunded_by',
'refunded_at',
]);
const service = await createService(runner);
await service.cleanupDepositRefundColumns();
expect(runner.query).toHaveBeenCalledWith(
'UPDATE deposits SET refunded_by = COALESCE(refunded_by, refund_approved_by)',
);
expect(runner.query).toHaveBeenCalledWith(
'UPDATE deposits SET refunded_at = COALESCE(refunded_at, refund_approved_at)',
);
expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_approved_by');
expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_approved_at');
expect(runner.renameColumn).not.toHaveBeenCalled();
});
it('does nothing when the deposits table is absent', async () => {
const runner = createRunner([]);
const service = await createService(runner);
await service.cleanupDepositRefundColumns();
expect(runner.renameColumn).not.toHaveBeenCalled();
expect(runner.dropColumn).not.toHaveBeenCalled();
expect(runner.release).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,38 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getDataSourceToken } from '@nestjs/typeorm';
import { DatabaseMigrationsService } from './database-migrations.service';
describe('DatabaseMigrationsService — room gender cleanup', () => {
it('drops the retired rooms.gender column', async () => {
const runner = {
connect: jest.fn(),
release: jest.fn(),
getTables: jest.fn().mockResolvedValue([{ name: 'rooms' }]),
getTable: jest.fn().mockResolvedValue({
name: 'rooms',
columns: [{ name: 'id' }, { name: 'gender' }],
}),
dropColumn: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMigrationsService,
{
provide: getDataSourceToken(),
useValue: {
options: { type: 'better-sqlite3' },
createQueryRunner: jest.fn().mockReturnValue(runner),
},
},
],
}).compile();
const service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & {
removeUnusedRoomColumns(): Promise<void>;
};
await service.removeUnusedRoomColumns();
expect(runner.dropColumn).toHaveBeenCalledWith('rooms', 'gender');
expect(runner.release).toHaveBeenCalled();
});
});

View File

@@ -15,6 +15,10 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
await this.normalizeClassDates();
await this.protectAttendanceHistory();
await this.removeUnusedClassroomColumns();
await this.removeUnusedRoomColumns();
await this.cleanupDepositRefundColumns();
await this.removeUnusedClassStudentColumns();
await this.normalizeClassroomStatuses();
}
private async removeUnusedClassroomColumns(): Promise<void> {
@@ -36,6 +40,92 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
}
}
private async removeUnusedRoomColumns(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const tables = await runner.getTables(['rooms']);
if (tables.length === 0) return;
const table = await runner.getTable('rooms');
if (table?.columns.some((column) => column.name === 'gender')) {
await runner.dropColumn('rooms', 'gender');
}
} finally {
await runner.release();
}
}
private async cleanupDepositRefundColumns(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const tables = await runner.getTables(['deposits']);
if (tables.length === 0) return;
const table = await runner.getTable('deposits');
const columnNames = new Set(table?.columns.map((column) => column.name) ?? []);
for (const [legacyName, currentName] of [
['refund_approved_by', 'refunded_by'],
['refund_approved_at', 'refunded_at'],
] as const) {
if (!columnNames.has(legacyName)) continue;
if (columnNames.has(currentName)) {
await runner.query(
`UPDATE deposits SET ${currentName} = COALESCE(${currentName}, ${legacyName})`,
);
await runner.dropColumn('deposits', legacyName);
} else {
await runner.renameColumn('deposits', legacyName, currentName);
columnNames.add(currentName);
}
columnNames.delete(legacyName);
}
for (const columnName of ['refund_status', 'refund_requested_at', 'refund_rejected_reason']) {
if (columnNames.has(columnName)) {
await runner.dropColumn('deposits', columnName);
columnNames.delete(columnName);
}
}
} finally {
await runner.release();
}
}
private async removeUnusedClassStudentColumns(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const tables = await runner.getTables(['class_student']);
if (tables.length === 0) return;
const table = await runner.getTable('class_student');
if (table?.columns.some((column) => column.name === 'enrollment_id')) {
await runner.dropColumn('class_student', 'enrollment_id');
}
} finally {
await runner.release();
}
}
private async normalizeClassroomStatuses(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const tables = await runner.getTables(['classrooms']);
if (tables.length === 0) return;
await runner.query(`
UPDATE classrooms
SET status = 'available'
WHERE status IS NULL OR status NOT IN ('available', 'maintenance', 'archived')
`);
} finally {
await runner.release();
}
}
private async ensureAiConfigTable(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
@@ -354,14 +444,17 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
// Drop any existing FK constraint on schedule_id or class_id
const fkColumns = ['schedule_id', 'class_id'];
for (const col of fkColumns) {
const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(`
const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(
`
SELECT CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'attendance_sessions'
AND COLUMN_NAME = ?
AND REFERENCED_TABLE_NAME IS NOT NULL
`, [col]);
`,
[col],
);
for (const row of fkRows) {
try {
@@ -381,13 +474,16 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
];
for (const c of constraints) {
// Only skip if RESTRICT constraint is already confirmed via information_schema
const existing: Array<{ DELETE_RULE: string }> = await runner.query(`
const existing: Array<{ DELETE_RULE: string }> = await runner.query(
`
SELECT DELETE_RULE
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = 'attendance_sessions'
AND CONSTRAINT_NAME = ?
`, [c.name]);
`,
[c.name],
);
if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') {
this.logger.log(`考勤场次删除保护约束已存在: ${c.name}`);
@@ -449,17 +545,13 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
FROM attendance_sessions
`);
await runner.query('DROP TABLE attendance_sessions');
await runner.query(
'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions',
);
await runner.query('ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions');
await runner.query(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
);
// Rebuild attendance_records to add/protect FK on attendance_session_id
const recordsFk = await runner.query(
"PRAGMA foreign_key_list('attendance_records')",
);
const recordsFk = await runner.query("PRAGMA foreign_key_list('attendance_records')");
const hasSessionFk = recordsFk.some(
(r: { from: string }) => r.from === 'attendance_session_id',
);
@@ -492,9 +584,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
FROM attendance_records
`);
await runner.query('DROP TABLE attendance_records');
await runner.query(
'ALTER TABLE attendance_records_new RENAME TO attendance_records',
);
await runner.query('ALTER TABLE attendance_records_new RENAME TO attendance_records');
await runner.query(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
);
@@ -504,9 +594,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
// If violations exist, the transaction rolls back and old tables are preserved.
const checkRows = await runner.query('PRAGMA foreign_key_check');
if (checkRows.length > 0) {
throw new Error(
`外键一致性检查失败: ${checkRows.length} 行违反外键约束`,
);
throw new Error(`外键一致性检查失败: ${checkRows.length} 行违反外键约束`);
}
await runner.query('COMMIT');

View File

@@ -16,7 +16,7 @@ import { Student } from '../entities/student.entity';
import { DepositsService } from './deposits.service';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity';
import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto';
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
@@ -61,7 +61,7 @@ export class DepositsController {
@Post()
@RequirePermission('deposit:create')
async create(@Body() dto: CreateDepositDto | CreateDepositWithInstallmentsDto, @Request() req: any) {
async create(@Body() dto: CreateDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto, req.user?.id);
await this.logService.log({

View File

@@ -0,0 +1,38 @@
import { DepositsService } from './deposits.service';
import { Deposit } from '../entities/deposit.entity';
describe('DepositsService — direct refund', () => {
it('stores the refund result on the main status and renamed audit fields', async () => {
const deposit = {
id: 1,
amount: 500,
status: 'paid',
} as Deposit;
const repo = {
findOne: jest.fn().mockResolvedValue(deposit),
save: jest.fn().mockImplementation(async (value: Deposit) => value),
};
const service = new DepositsService(repo as never, {} as never, {} as never);
const result = await service.refund(
1,
{
refundDate: '2026-07-13',
deductionAmount: 100,
deductionReason: '物品损坏',
},
42,
);
expect(result).toMatchObject({
refundDate: '2026-07-13',
refundAmount: 400,
deductionAmount: 100,
deductionReason: '物品损坏',
status: 'partial_refund',
refundedBy: 42,
});
expect(result.refundedAt).toBeInstanceOf(Date);
expect(repo.save).toHaveBeenCalledWith(deposit);
});
});

View File

@@ -5,7 +5,7 @@ import { Deposit } from '../entities/deposit.entity';
import { Student } from '../entities/student.entity';
import { DepositInstallment } from '../entities/deposit-installment.entity';
import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto';
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
@Injectable()
export class DepositsService {
@@ -55,17 +55,6 @@ export class DepositsService {
recordedBy: userId,
});
if (dto instanceof CreateDepositWithInstallmentsDto && dto.installments?.length) {
deposit.installments = dto.installments.map((i) => {
const inst = this.installmentRepo.create({
amount: i.amount,
dueDate: i.dueDate,
status: 'pending',
});
return inst;
});
}
return this.repo.save(deposit);
}
@@ -114,9 +103,8 @@ export class DepositsService {
deposit.status =
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
if (dto.notes) deposit.notes = dto.notes;
deposit.refundStatus = 'refunded';
deposit.refundApprovedBy = userId ?? null as unknown as number;
deposit.refundApprovedAt = new Date();
deposit.refundedBy = userId ?? null;
deposit.refundedAt = new Date();
return this.repo.save(deposit);
}

View File

@@ -0,0 +1,19 @@
import 'reflect-metadata';
import { validate } from 'class-validator';
import { CreateDepositDto } from './deposit.dto';
describe('CreateDepositDto boundaries', () => {
it('removes inline installments because they are managed after deposit creation', async () => {
const dto = Object.assign(new CreateDepositDto(), {
studentId: 1,
amount: 500,
paidDate: '2026-07-13',
installments: [{ amount: 250, dueDate: '2026-08-01' }],
});
await validate(dto, { whitelist: true });
expect(dto).toMatchObject({ studentId: 1, amount: 500, paidDate: '2026-07-13' });
expect(dto).not.toHaveProperty('installments');
});
});

View File

@@ -1,5 +1,4 @@
import { IsInt, IsNumber, IsString, IsOptional, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { IsInt, IsNumber, IsString, IsOptional } from 'class-validator';
export class CreateDepositDto {
@IsInt()
@@ -16,16 +15,6 @@ export class CreateDepositDto {
notes?: string;
}
export class CreateInstallmentDto {
@IsNumber()
amount: number;
@IsString()
dueDate: string;
}
export class RefundDepositDto {
@IsString()
refundDate: string;
@@ -42,9 +31,3 @@ export class RefundDepositDto {
@IsString()
notes?: string;
}
export class CreateDepositWithInstallmentsDto extends CreateDepositDto {
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CreateInstallmentDto)
installments?: CreateInstallmentDto[];
}

View File

@@ -67,6 +67,18 @@ export class AttendanceRecord {
@Column({ name: 'source', length: 20, default: 'manual' })
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' })
createdAt: Date;

View File

@@ -30,14 +30,11 @@ export class ClassStudent {
@JoinColumn({ name: 'student_id' })
student: Student;
@Column({ name: 'enrollment_id', type: 'integer', nullable: true })
enrollmentId: number;
@Column({ name: 'join_date', type: 'date', nullable: true })
joinDate: string;
joinDate: string | null;
@Column({ name: 'leave_date', type: 'date', nullable: true })
leaveDate: string;
leaveDate: string | null;
@Column({ name: 'status', length: 10, default: 'active' })
status: string;

View File

@@ -11,6 +11,12 @@ import {
import { Classroom } from './classroom.entity';
import { Organization } from './organization.entity';
export enum ClassroomRentalStatus {
ACTIVE = 'active',
ENDED = 'ended',
CANCELLED = 'cancelled',
}
@Entity('classroom_rentals')
@Index(['classroomId', 'startDate', 'endDate'])
export class ClassroomRental {
@@ -57,8 +63,8 @@ export class ClassroomRental {
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, nullable: true })
totalAmount: number;
@Column({ type: 'varchar', length: 20, default: 'active' })
status: string; // active / ended / cancelled
@Column({ type: 'varchar', length: 20, default: ClassroomRentalStatus.ACTIVE })
status: ClassroomRentalStatus | 'active' | 'ended' | 'cancelled';
@Column({ type: 'text', nullable: true })
notes: string;

View File

@@ -1,4 +1,10 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
export enum ClassroomStatus {
AVAILABLE = 'available',
MAINTENANCE = 'maintenance',
ARCHIVED = 'archived',
}
@Entity('classrooms')
export class Classroom {
@@ -20,14 +26,12 @@ export class Classroom {
@Column({ name: 'room_type', type: 'varchar', length: 20, default: '大' })
roomType: string; // 大 / 次大 / 小
@Column({ type: 'varchar', length: 20, default: 'reserved' })
status: string;
@Column({ type: 'varchar', length: 20, default: ClassroomStatus.AVAILABLE })
status: ClassroomStatus | 'available' | 'maintenance' | 'archived';
@Column({ type: 'text', nullable: true })
notes: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@@ -46,20 +46,11 @@ export class Deposit {
@Column({ name: 'recorded_by', nullable: true })
recordedBy: number;
@Column({ name: 'refund_status', length: 30, nullable: true })
refundStatus: string; // pending | head_teacher_approved | finance_approved | refunded
@Column({ name: 'refunded_by', type: 'integer', nullable: true })
refundedBy: number | null;
@Column({ name: 'refund_requested_at', type: 'datetime', nullable: true })
refundRequestedAt: Date;
@Column({ name: 'refund_approved_by', type: 'integer', nullable: true })
refundApprovedBy: number;
@Column({ name: 'refund_approved_at', type: 'datetime', nullable: true })
refundApprovedAt: Date;
@Column({ name: 'refund_rejected_reason', length: 500, nullable: true })
refundRejectedReason: string;
@Column({ name: 'refunded_at', type: 'datetime', nullable: true })
refundedAt: Date | null;
@OneToMany(() => DepositInstallment, (i) => i.deposit, { cascade: true, eager: true })
installments: DepositInstallment[];

View File

@@ -44,6 +44,15 @@ export class DingAttendanceRaw {
@Column({ name: 'location_result', length: 20, nullable: true })
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' })
matchStatus: string;

View File

@@ -11,9 +11,9 @@ export { User } from './user.entity';
export { OperationLog } from './operation-log.entity';
export { Deposit } from './deposit.entity';
export { DepositInstallment } from './deposit-installment.entity';
export { Classroom } from './classroom.entity';
export { Classroom, ClassroomStatus } from './classroom.entity';
export { Organization } from './organization.entity';
export { ClassroomRental } from './classroom-rental.entity';
export { ClassroomRental, ClassroomRentalStatus } from './classroom-rental.entity';
export { Permission } from './permission.entity';
export { Role } from './role.entity';
export { Class, ClassType, ClassStatus } from './class.entity';

View File

@@ -1,4 +1,12 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
OneToMany,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Occupancy } from './occupancy.entity';
import { RoomExpense } from './room-expense.entity';
@@ -25,9 +33,6 @@ export class Room {
@Column({ name: 'room_type', length: 20, nullable: true })
roomType: string;
@Column({ length: 10, nullable: true })
gender: string;
@Column({ name: 'rental_category', length: 10, default: 'short' })
rentalCategory: string;
@@ -42,5 +47,4 @@ export class Room {
@OneToMany(() => RoomExpense, (e) => e.room)
roomExpenses: RoomExpense[];
}

View File

@@ -33,9 +33,6 @@ export class StudentProfile {
@Column({ length: 20, nullable: true })
grade: string;
@Column({ name: 'campus_location', length: 100, nullable: true })
campusLocation: string;
@Column({ name: 'profile_date', type: 'date', nullable: true })
profileDate: string;

View File

@@ -0,0 +1,48 @@
import { ValidationPipe } from '@nestjs/common';
import {
DingTalkThirdConfigDto,
IntegrationType,
SaveIntegrationConfigDto,
WeComThirdConfigDto,
} from './config.dto';
const pipe = new ValidationPipe({ transform: true, whitelist: true });
const transform = (value: unknown) =>
pipe.transform(value, { type: 'body', metatype: SaveIntegrationConfigDto });
describe('integration config request DTO', () => {
it('validates and transforms DingTalk configuration', async () => {
const result = await transform({
type: 'DINGTALK',
config: {
agentId: 'app-key',
corpId: 'corp-id',
appSecret: '',
appId: 'app-id',
ignored: 'value',
},
});
expect(result.type).toBe(IntegrationType.DINGTALK);
expect(result.config).toBeInstanceOf(DingTalkThirdConfigDto);
expect(result.config).toMatchObject({ agentId: 'app-key', corpId: 'corp-id', appSecret: '' });
expect(result.config).not.toHaveProperty('ignored');
});
it('uses the WeCom nested DTO and removes DingTalk-only fields', async () => {
const result = await transform({
type: 'WECOM',
config: { agentId: 'agent', corpId: 'corp', appId: 'not-supported' },
});
expect(result.config).toBeInstanceOf(WeComThirdConfigDto);
expect(result.config).not.toHaveProperty('appId');
});
it('rejects invalid platform types and incomplete nested config', async () => {
await expect(transform({ type: 'UNKNOWN', config: {} })).rejects.toThrow();
await expect(transform({ type: 'DINGTALK', config: { corpId: 'corp' } })).rejects.toThrow();
await expect(transform({ type: 'DINGTALK' })).rejects.toThrow();
});
});

View File

@@ -1,29 +1,71 @@
/** 钉钉配置 */
export interface DingTalkThirdConfig {
agentId: string; // AppKey
appSecret: string; // AppSecret
corpId: string; // CorpId
startEnable: boolean; // 是否启用同步
appId?: string; // 内部应用ID用于消息推送可选
import { Type } from 'class-transformer';
import {
IsDefined,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
export enum IntegrationType {
WECOM = 'WECOM',
DINGTALK = 'DINGTALK',
}
/** 企微配置 */
export interface WeComThirdConfig {
export class DingTalkThirdConfigDto {
@IsString()
@IsNotEmpty()
agentId: string;
appSecret: string;
@IsOptional()
@IsString()
appSecret?: string;
@IsString()
@IsNotEmpty()
corpId: string;
startEnable: boolean;
@IsOptional()
@IsString()
appId?: string;
}
export class WeComThirdConfigDto {
@IsString()
@IsNotEmpty()
agentId: string;
@IsOptional()
@IsString()
appSecret?: string;
@IsString()
@IsNotEmpty()
corpId: string;
}
export class IntegrationConfigRequestDto {
@IsEnum(IntegrationType)
type: IntegrationType;
@IsDefined()
@ValidateNested()
@Type((options) =>
options?.object?.type === IntegrationType.DINGTALK
? DingTalkThirdConfigDto
: WeComThirdConfigDto,
)
config: DingTalkThirdConfigDto | WeComThirdConfigDto;
}
export class SaveIntegrationConfigDto extends IntegrationConfigRequestDto {}
export class TestIntegrationConfigDto extends IntegrationConfigRequestDto {}
/** 对外返回的配置(脱敏后,不含 appSecret */
export interface ThirdConfigBaseDTO<T = unknown> {
type: string;
verify?: boolean;
config: T;
}
/** 保存配置的请求体 */
export interface SaveConfigRequest {
type: 'WECOM' | 'DINGTALK';
config: DingTalkThirdConfig | WeComThirdConfig;
}

View File

@@ -2,7 +2,7 @@ import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../../auth/decorators/permission.decorator';
import { IntegrationConfigService } from './integration-config.service';
import type { SaveConfigRequest } from './dto/config.dto';
import { SaveIntegrationConfigDto, TestIntegrationConfigDto } from './dto/config.dto';
@Controller('integration/config')
@UseGuards(JwtAuthGuard)
@@ -31,7 +31,7 @@ export class IntegrationConfigController {
/** 保存配置 */
@Post()
@RequirePermission('integration:trigger')
async saveConfig(@Body() body: SaveConfigRequest) {
async saveConfig(@Body() body: SaveIntegrationConfigDto) {
await this.service.saveConfig(body);
return { success: true, message: '配置已保存' };
}
@@ -39,7 +39,7 @@ export class IntegrationConfigController {
/** 测试连接 */
@Post('test')
@RequirePermission('integration:read')
async testConnection(@Body() body: SaveConfigRequest) {
async testConnection(@Body() body: TestIntegrationConfigDto) {
const success = await this.service.testConnection(body.type, body.config);
return { success, message: success ? '连接成功' : '连接失败,请检查配置信息' };
}

View File

@@ -0,0 +1,49 @@
import { IntegrationConfigService } from './integration-config.service';
describe('IntegrationConfigService.testConnection', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
jest.restoreAllMocks();
});
it('uses the saved AppSecret when testing an existing configuration with a blank secret', async () => {
const configRepo = {
findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }),
};
const detailRepo = {
findOne: jest.fn().mockResolvedValue({
configId: 1,
type: 'DINGTALK_SYNC',
content: JSON.stringify({
config: {
corpId: 'ding-corp',
agentId: 'saved-key',
appSecret: 'saved-secret',
},
}),
}),
};
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({ accessToken: 'token' }),
}) as never;
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
await expect(
service.testConnection('DINGTALK', {
corpId: 'ding-corp',
agentId: 'saved-key',
appSecret: '',
}),
).resolves.toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.dingtalk.com/v1.0/oauth2/accessToken',
expect.objectContaining({
body: JSON.stringify({ appKey: 'saved-key', appSecret: 'saved-secret' }),
}),
);
});
});

View File

@@ -1,15 +1,13 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
IntegrationConfig,
IntegrationConfigDetail,
} from '../entities/integration-config.entity';
import { IntegrationConfig, IntegrationConfigDetail } from '../entities/integration-config.entity';
import {
ThirdConfigBaseDTO,
DingTalkThirdConfig,
WeComThirdConfig,
SaveConfigRequest,
DingTalkThirdConfigDto,
WeComThirdConfigDto,
IntegrationType,
SaveIntegrationConfigDto,
} from './dto/config.dto';
@Injectable()
@@ -67,7 +65,7 @@ export class IntegrationConfigService {
}
/** 保存/更新配置 */
async saveConfig(request: SaveConfigRequest): Promise<void> {
async saveConfig(request: SaveIntegrationConfigDto): Promise<void> {
const config = await this.ensureConfig();
const detailType = this.getDetailType(request.type);
@@ -121,11 +119,16 @@ export class IntegrationConfigService {
/** 测试连接 */
async testConnection(
type: string,
config: DingTalkThirdConfig | WeComThirdConfig,
type: IntegrationType,
config: DingTalkThirdConfigDto | WeComThirdConfigDto,
): Promise<boolean> {
try {
const token = await this.getTokenForTest(type, config as unknown as Record<string, unknown>);
const finalConfig = { ...config } as Record<string, unknown>;
if (!finalConfig.appSecret) {
const savedConfig = await this.getRawConfig(type);
if (savedConfig?.appSecret) finalConfig.appSecret = savedConfig.appSecret;
}
const token = await this.getTokenForTest(type, finalConfig);
return !!token;
} catch (e) {
this.logger.error(`连接测试失败: ${(e as Error).message}`);

View File

@@ -11,6 +11,7 @@ describe('DingTalkService attendance group deletion', () => {
service = new DingTalkService({} as never, {} as never);
Object.assign(service, {
accessToken: 'test-token',
accessTokenCredentialKey: 'test-app-key:test-app-secret',
tokenExpiresAt: Date.now() + 3_600_000,
});
});

View File

@@ -12,6 +12,7 @@ describe('DingTalkService — queryShifts', () => {
service = new DingTalkService({} as never, {} as never);
Object.assign(service, {
accessToken: 'test-token',
accessTokenCredentialKey: 'test-app-key:test-app-secret',
tokenExpiresAt: Date.now() + 3_600_000,
});
jest.spyOn(service, 'rateLimit').mockResolvedValue(undefined);
@@ -105,6 +106,7 @@ describe('DingTalkService — attendance machine only group', () => {
service = new DingTalkService({} as never, {} as never);
Object.assign(service, {
accessToken: 'test-token',
accessTokenCredentialKey: 'test-app-key:test-app-secret',
tokenExpiresAt: Date.now() + 3_600_000,
});
jest.spyOn(service, 'rateLimit').mockResolvedValue(undefined);

View File

@@ -10,6 +10,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Student } from '../entities/student.entity';
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
import { IntegrationConfigService } from './config/integration-config.service';
// ── Types ──
@@ -18,6 +19,11 @@ interface DingTalkTokenResponse {
expireIn: number;
}
interface DingTalkCredentials {
appKey: string;
appSecret: string;
}
interface DingTalkUserListResponse {
errcode: number;
errmsg: string;
@@ -45,6 +51,11 @@ export interface DingTalkAttendanceResult {
actualCheckTime: string;
checkId: string;
checkType: string;
/** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */
sourceType: string;
/** 部分钉钉租户会额外返回考勤机名称或编号。 */
deviceName?: string;
deviceId?: string;
}
// ── 组织架构 API 类型 ──
@@ -168,6 +179,7 @@ export interface DingTalkScheduleResult {
export class DingTalkService {
private readonly logger = new Logger(DingTalkService.name);
private accessToken: string | null = null;
private accessTokenCredentialKey: string | null = null;
private tokenExpiresAt = 0;
private apiRequestCount = 0;
@@ -180,10 +192,28 @@ export class DingTalkService {
private readonly studentRepo: Repository<Student>,
@InjectRepository(StudentDingMapping)
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
private readonly integrationConfigService?: IntegrationConfigService,
) {}
private get configured(): boolean {
return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET);
private async getCredentials(): Promise<DingTalkCredentials | null> {
const rawConfig = await this.integrationConfigService?.getRawConfig('DINGTALK');
const dbAppKey = typeof rawConfig?.agentId === 'string' ? rawConfig.agentId.trim() : '';
const dbAppSecret = typeof rawConfig?.appSecret === 'string' ? rawConfig.appSecret.trim() : '';
if (dbAppKey && dbAppSecret) {
return { appKey: dbAppKey, appSecret: dbAppSecret };
}
const envAppKey = process.env.DINGTALK_APP_KEY?.trim();
const envAppSecret = process.env.DINGTALK_APP_SECRET?.trim();
if (envAppKey && envAppSecret) {
return { appKey: envAppKey, appSecret: envAppSecret };
}
return null;
}
private async isConfigured(): Promise<boolean> {
return !!(await this.getCredentials());
}
// ═══════════════════════════════════════════
@@ -191,16 +221,24 @@ export class DingTalkService {
// ═══════════════════════════════════════════
private async getAccessToken(): Promise<string> {
if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) {
const credentials = await this.getCredentials();
if (!credentials) {
throw new Error('DingTalk not configured');
}
const credentialKey = `${credentials.appKey}:${credentials.appSecret}`;
if (
this.accessToken &&
this.accessTokenCredentialKey === credentialKey &&
Date.now() < this.tokenExpiresAt - 60_000
) {
return this.accessToken;
}
const appKey = process.env.DINGTALK_APP_KEY!;
const appSecret = process.env.DINGTALK_APP_SECRET!;
const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appKey, appSecret }),
body: JSON.stringify(credentials),
});
const body: DingTalkTokenResponse = await res.json();
@@ -209,6 +247,7 @@ export class DingTalkService {
}
this.accessToken = body.accessToken;
this.accessTokenCredentialKey = credentialKey;
this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000;
this.logger.log('钉钉 access_token 获取成功');
return this.accessToken;
@@ -259,7 +298,7 @@ export class DingTalkService {
// ═══════════════════════════════════════════
async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> {
if (!this.configured) {
if (!(await this.isConfigured())) {
this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步');
return { deptCount: 0, userCount: 0 };
}
@@ -333,7 +372,7 @@ export class DingTalkService {
/** 获取钉钉组织部门树(只含部门) */
async fetchOrgTree(rootDeptId = 1): Promise<OrgDeptNode[]> {
if (!this.configured) return [];
if (!(await this.isConfigured())) return [];
const token = await this.getAccessToken();
const rootInfo = await this.getDeptInfo(token, rootDeptId);
if (!rootInfo) return [];
@@ -343,7 +382,7 @@ export class DingTalkService {
/** 获取钉钉组织部门树(含用户) */
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<OrgDeptNodeWithUsers[]> {
if (!this.configured) return [];
if (!(await this.isConfigured())) return [];
const token = await this.getAccessToken();
const rootInfo = await this.getDeptInfo(token, rootDeptId);
if (!rootInfo) return [];
@@ -441,7 +480,7 @@ export class DingTalkService {
endDate: string;
userIds?: string[];
}): Promise<DingTalkAttendanceResult[]> {
if (!this.configured) throw new Error('DingTalk not configured');
if (!(await this.isConfigured())) throw new Error('DingTalk not configured');
if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空');
if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人');
const token = await this.getAccessToken();
@@ -471,6 +510,8 @@ export class DingTalkService {
checkType?: string; timeResult?: string;
locationResult?: string; locationMethod?: string;
userAddress?: string; userLongitude?: number; userLatitude?: number;
deviceName?: string; deviceId?: string | number;
attendanceMachineName?: string; attendanceMachineId?: string | number;
}>;
};
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
@@ -486,7 +527,10 @@ export class DingTalkService {
planCheckTime: '',
actualCheckTime: new Date(r.userCheckTime).toISOString(),
checkId: String(r.id),
checkType: r.checkType ?? r.sourceType ?? '',
checkType: r.checkType ?? '',
sourceType: r.sourceType ?? '',
deviceName: r.deviceName ?? r.attendanceMachineName,
deviceId: String(r.deviceId ?? r.attendanceMachineId ?? '') || undefined,
}));
}
@@ -496,7 +540,7 @@ export class DingTalkService {
/** 创建或修改班次。id 不传=创建,传了=修改 */
async upsertShift(params: DingTalkShiftParams): Promise<number> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const body: Record<string, unknown> = {
@@ -547,7 +591,7 @@ export class DingTalkService {
/** 查询所有班次摘要每页最多200条 */
async queryShifts(opUserId = 'manager'): Promise<DingTalkShiftSummary[]> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const all: DingTalkShiftSummary[] = [];
@@ -598,7 +642,7 @@ export class DingTalkService {
/** 创建排班制考勤组 */
async createAttendanceGroup(params: DingTalkGroupParams): Promise<number> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const topGroup = this.buildAttendanceGroupBody(params);
@@ -627,7 +671,7 @@ export class DingTalkService {
/** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */
async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise<void> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id };
@@ -687,7 +731,7 @@ export class DingTalkService {
/** 查询所有考勤组摘要分页每页10条 */
async queryAttendanceGroups(opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const all: DingTalkGroupSummary[] = [];
@@ -729,7 +773,7 @@ export class DingTalkService {
}
async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise<void> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
await this.rateLimit();
@@ -778,7 +822,7 @@ export class DingTalkService {
async scheduleUsers(
groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager',
): Promise<void> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
if (schedules.length === 0) return;
if (schedules.length > 200) {
throw new Error(`排班单次最多200条当前 ${schedules.length}`);
@@ -818,7 +862,7 @@ export class DingTalkService {
async queryScheduleByUsers(
userIds: string[], fromDate: number, toDate: number, opUserId = 'manager',
): Promise<DingTalkScheduleResult[]> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
await this.rateLimit();

View File

@@ -3,9 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { User, Student, StudentDingMapping, Class } from '../entities';
import { DingTalkService } from './dingtalk.service';
import { WeComService } from './wecom.service';
import { IntegrationConfigModule } from './config/config.module';
@Module({
imports: [TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class])],
imports: [
TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class]),
IntegrationConfigModule,
],
providers: [DingTalkService, WeComService],
exports: [DingTalkService, WeComService],
})

View File

@@ -0,0 +1,28 @@
import { validate } from 'class-validator';
import { CheckInDto, TransferRoomDto } from './occupancy.dto';
describe('manual occupancy DTO bed requirements', () => {
it('requires a bed for manual check-in', async () => {
const dto = Object.assign(new CheckInDto(), {
studentId: 1,
roomId: 2,
checkInDate: '2026-07-13',
});
const errors = await validate(dto);
expect(errors.some((error) => error.property === 'bedId')).toBe(true);
});
it('requires a new bed for a room transfer while keeping the locker optional', async () => {
const dto = Object.assign(new TransferRoomDto(), {
newRoomId: 3,
transferDate: '2026-07-13',
});
const errors = await validate(dto);
expect(errors.some((error) => error.property === 'newBedId')).toBe(true);
expect(errors.some((error) => error.property === 'newLockerId')).toBe(false);
});
});

View File

@@ -25,9 +25,8 @@ export class CheckInDto {
@IsOptional()
@IsInt()
responsibleOrganizationId?: number;
@IsOptional()
@IsInt()
bedId?: number; // 后续改 required
bedId: number;
@IsOptional()
@IsInt()
@@ -58,9 +57,8 @@ export class TransferRoomDto {
@IsString()
oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate
@IsOptional()
@IsInt()
newBedId?: number;
newBedId: number;
@IsOptional()
@IsInt()

View File

@@ -27,6 +27,10 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import * as ExcelJS from 'exceljs';
import {
createOccupancyImportTemplateWorkbook,
parseOccupancyImportWorksheet,
} from './occupancy-import-template';
@UseGuards(JwtAuthGuard)
@Controller('occupancies')
@@ -240,69 +244,7 @@ export class OccupanciesController {
@Get('template')
@RequirePermission('occupancy:view')
async downloadTemplate(@Res() res: Response) {
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('入住名单导入模板');
ws.columns = [
{ header: '宿舍号', key: 'roomNumber', width: 12 },
{ header: '床位号', key: 'bedNumber', width: 8 },
{ header: '姓名', key: 'name', width: 12 },
{ header: '性别', key: 'gender', width: 8 },
{ header: '民族', key: 'ethnicity', width: 10 },
{ header: '电话', key: 'phone', width: 15 },
{ header: '学号/身份证', key: 'idNumber', width: 22 },
{ header: '入住时间', key: 'checkInDate', width: 14 },
{ header: '离宿时间', key: 'checkOutDate', width: 14 },
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
{ header: '所属机构', key: 'organization', width: 18 },
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
// 添加说明行
ws.addRow({
roomNumber: '4-102',
bedNumber: 1,
name: '张三',
gender: '男',
ethnicity: '汉族',
phone: '13800138000',
idNumber: '2024001',
checkInDate: '2026-04-21',
checkOutDate: '',
emergencyContact: '张父',
emergencyPhone: '13900000000',
organization: '',
supervisor: '',
});
ws.addRow({
roomNumber: '4-102',
bedNumber: 2,
name: '李四',
gender: '男',
ethnicity: '汉族',
phone: '13800138001',
idNumber: '2024002',
checkInDate: '2026-04-21',
checkOutDate: '',
emergencyContact: '',
emergencyPhone: '',
organization: 'XXX教育科技',
supervisor: '王老师',
});
// 添加使用说明sheet
const helpWs = workbook.addWorksheet('使用说明');
helpWs.getColumn(1).width = 60;
helpWs.addRow(['【入住名单导入说明】']);
helpWs.addRow(['1. 导入入住名单会自动创建不存在的学生和宿舍,无需单独导入学生或宿舍']);
helpWs.addRow(['2. 宿舍号会智能解析楼栋、楼层和房间类型如4-102自动识别为4号楼1层四人间']);
helpWs.addRow(['3. 同一宿舍号的多个学生可合并宿舍号单元格,系统会自动继承上一行的宿舍号']);
helpWs.addRow(['4. 已存在的学生(按姓名匹配)会自动补充缺失信息(性别、民族等)']);
helpWs.addRow(['5. 已有在住记录的学生会自动跳过,不会重复入住']);
helpWs.addRow(['6. 填了离宿时间的记录会直接标记为已退宿(用于导入历史数据)']);
helpWs.addRow(['7. 性别约束:同一宿舍只能住同性别学生,首位入住者确定宿舍性别']);
helpWs.addRow(['8. 床位号仅做标识参考,不影响入住逻辑']);
helpWs.getRow(1).font = { bold: true, size: 14 };
const workbook = createOccupancyImportTemplateWorkbook();
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
@@ -325,47 +267,7 @@ export class OccupanciesController {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
const ws = workbook.worksheets[0];
const rows: any[] = [];
let lastRoomNumber = '';
ws.eachRow((row, idx) => {
if (idx === 1) return; // 跳过表头
// 宿舍号可能是合并单元格,需要继承上一行
const roomNumberVal = row.getCell(1).value;
const roomNumber = roomNumberVal ? String(roomNumberVal).trim() : '';
if (roomNumber) lastRoomNumber = roomNumber;
const name = String(row.getCell(3).value || '').trim();
if (!name) return; // 无姓名则跳过空行
// 解析日期
const parseDate = (cell: any): string => {
const val = cell.value;
if (!val) return '';
if (val instanceof Date) return val.toISOString().split('T')[0];
const s = String(val).trim();
// 处理 "YYYY/MM/DD" 或 "YYYY-MM-DD" 或 "YYYY.MM.DD"
const m = s.match(/(\d{4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})/);
if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
return s;
};
rows.push({
name,
roomNumber: lastRoomNumber,
gender: String(row.getCell(4).value || '').trim() || undefined,
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
phone: String(row.getCell(6).value || '').trim() || undefined,
idNumber: String(row.getCell(7).value || '').trim() || undefined,
checkInDate: parseDate(row.getCell(8)),
checkOutDate: parseDate(row.getCell(9)) || undefined,
emergencyContact: String(row.getCell(10).value || '').trim() || undefined,
emergencyPhone: String(row.getCell(11).value || '').trim() || undefined,
organization: String(row.getCell(12).value || '').trim() || undefined,
supervisor: String(row.getCell(13).value || '').trim() || undefined,
});
});
const rows = parseOccupancyImportWorksheet(ws);
const result = await this.service.batchImportCheckIn(rows, {
autoDeposit: autoDeposit === 'true',
depositAmount: depositAmount ? +depositAmount : undefined,

View File

@@ -16,7 +16,7 @@ describe('OccupanciesService — responsible organization', () => {
save: jest.fn(async (value) => ({ ...value, id: 10 })),
} as any as Repository<Occupancy>;
const roomRepo = {
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4, gender: null }),
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4 }),
update: jest.fn(),
} as any as Repository<Room>;
const studentRepo = {
@@ -28,7 +28,10 @@ describe('OccupanciesService — responsible organization', () => {
roomRepo,
studentRepo,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{
findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }),
update: jest.fn(),
} as any as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
{} as DataSource,
@@ -38,6 +41,7 @@ describe('OccupanciesService — responsible organization', () => {
studentId: 3,
roomId: 2,
checkInDate: '2026-07-10',
bedId: 4,
});
expect(occupancyRepo.create).toHaveBeenCalledWith(
@@ -45,3 +49,68 @@ describe('OccupanciesService — responsible organization', () => {
);
});
});
describe('OccupanciesService — import bed capacity', () => {
it('rejects creating a new bed when the room already has its capacity in beds', async () => {
const occupancyRepo = {
findOne: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(0),
create: jest.fn((value) => value),
save: jest.fn(),
} as any as Repository<Occupancy>;
const roomRepo = {
findOne: jest.fn().mockResolvedValue({ id: 2, roomNumber: '4-102', capacity: 4 }),
create: jest.fn((value) => value),
save: jest.fn(),
update: jest.fn(),
} as any as Repository<Room>;
const studentRepo = {
findOne: jest.fn().mockResolvedValue({ id: 3, name: '张三', organizationId: 7 }),
create: jest.fn((value) => value),
save: jest.fn(),
update: jest.fn(),
} as any as Repository<Student>;
const bedRepo = {
findOne: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(4),
create: jest.fn((value) => value),
save: jest.fn(),
update: jest.fn(),
} as any as Repository<Bed>;
const organizationRepo = {
findOne: jest.fn().mockResolvedValue({ id: 7, name: '本机构', isHost: true }),
create: jest.fn((value) => value),
save: jest.fn(),
} as any as Repository<any>;
const service = new OccupanciesService(
occupancyRepo,
roomRepo,
studentRepo,
{ findOne: jest.fn(), create: jest.fn(), save: jest.fn() } as any as Repository<Deposit>,
bedRepo,
{ findOne: jest.fn() } as any as Repository<Locker>,
organizationRepo,
{} as DataSource,
);
const result = await service.batchImportCheckIn([
{
name: '张三',
roomNumber: '4-102',
bedNumber: '5号床',
checkInDate: '2026-07-14',
},
]);
expect(result).toEqual(
expect.objectContaining({
imported: 0,
skipped: 1,
errors: [expect.stringContaining('不能超过额定人数 4')],
}),
);
expect(bedRepo.save).not.toHaveBeenCalled();
expect(occupancyRepo.save).not.toHaveBeenCalled();
});
});

View File

@@ -61,14 +61,8 @@ export class OccupanciesService {
const count = await this.repo.count({ where: { roomId: dto.roomId, checkOutDate: IsNull() } });
if (count >= room.capacity) throw new BadRequestException('宿舍已满');
// 房间级别性别约束
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
if (student.gender && room.gender && student.gender !== room.gender) {
throw new BadRequestException(
`该宿舍当前为${room.gender}生寝室,${student.gender}生无法入住`,
);
}
// 床位校验
if (dto.bedId) {
@@ -107,11 +101,6 @@ export class OccupanciesService {
await this.lockerRepo.update(dto.lockerId, { status: 'occupied' });
}
// 首位入住者确定房间性别
if (student.gender && !room.gender) {
await this.roomRepo.update(room.id, { gender: student.gender });
}
// 更新宿舍状态
if (count + 1 >= room.capacity) {
await this.roomRepo.update(room.id, { status: 'full' });
@@ -140,14 +129,6 @@ export class OccupanciesService {
// 更新宿舍状态
await this.roomRepo.update(occ.roomId, { status: 'available' });
// 如果房间已无在住人员,重置房间性别
const remaining = await this.repo.count({
where: { roomId: occ.roomId, checkOutDate: IsNull() },
});
if (remaining === 0) {
await this.roomRepo.update(occ.roomId, { gender: null as any });
}
return occ;
}
@@ -173,14 +154,6 @@ export class OccupanciesService {
await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' });
}
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
// 旧房如果已无在住人员,重置性别
const oldRemaining = await runner.manager.count(Occupancy, {
where: { roomId: oldOcc.roomId, checkOutDate: IsNull() },
});
if (oldRemaining === 0) {
await runner.manager.update(Room, oldOcc.roomId, { gender: null as any });
}
// 检查新房容量
const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } });
if (!newRoom) throw new NotFoundException('目标宿舍不存在');
@@ -189,12 +162,6 @@ export class OccupanciesService {
});
if (count >= newRoom.capacity) throw new BadRequestException('目标宿舍已满');
// 换房性别约束检查
const student = await runner.manager.findOne(Student, { where: { id: oldOcc.studentId } });
if (student?.gender && newRoom.gender && student.gender !== newRoom.gender) {
throw new BadRequestException(`目标宿舍为${newRoom.gender}生寝室,无法换入`);
}
// 新床位校验
if (dto.newBedId) {
const newBed = await runner.manager.findOne(Bed, {
@@ -239,11 +206,6 @@ export class OccupanciesService {
await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' });
}
// 首位入住者确定新房性别
if (student?.gender && !newRoom.gender) {
await runner.manager.update(Room, newRoom.id, { gender: student.gender });
}
if (count + 1 >= newRoom.capacity) {
await runner.manager.update(Room, newRoom.id, { status: 'full' });
}
@@ -339,13 +301,6 @@ export class OccupanciesService {
await runner.manager.save(occ);
// 更新房间状态
await runner.manager.update(Room, occ.roomId, { status: 'available' });
// 如果房间已无在住人员,重置性别
const remaining = await runner.manager.count(Occupancy, {
where: { roomId: occ.roomId, checkOutDate: IsNull() },
});
if (remaining === 0) {
await runner.manager.update(Room, occ.roomId, { gender: null as any });
}
// 释放床位/柜子
if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' });
if (occ.lockerId)
@@ -386,7 +341,12 @@ export class OccupanciesService {
roomNumber: string;
building?: string;
checkInDate: string;
billingStartDate?: string;
checkOutDate?: string;
bedNumber?: string;
lockerNumber?: string;
stayType?: string;
notes?: string;
}[],
options?: { autoDeposit?: boolean; depositAmount?: number },
) {
@@ -495,13 +455,40 @@ export class OccupanciesService {
continue;
}
// 5. 房间级别性别约束
if (student.gender && room.gender && student.gender !== room.gender) {
errors.push(
`${rowNum}行: 宿舍 ${row.roomNumber}${room.gender}生寝室,${row.name}(${student.gender})无法入住,跳过`,
);
skipped++;
continue;
// 5. 匹配或创建床位、柜子,并校验是否可用
const isHistoricalRecord = Boolean(row.checkOutDate?.trim());
let bed: Bed | null = null;
if (row.bedNumber?.trim()) {
const bedNumber = row.bedNumber.trim();
bed = await this.bedRepo.findOne({ where: { roomId: room.id, bedNumber } });
if (!bed) {
const existingBedCount = await this.bedRepo.count({ where: { roomId: room.id } });
if (existingBedCount >= room.capacity) {
throw new BadRequestException(
`宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity}`,
);
}
bed = await this.bedRepo.save(
this.bedRepo.create({ roomId: room.id, bedNumber, status: 'available' }),
);
}
if (!isHistoricalRecord && bed.status !== 'available') {
throw new BadRequestException(`床位 ${bedNumber} 已被占用或维修中`);
}
}
let locker: Locker | null = null;
if (row.lockerNumber?.trim()) {
const lockerNumber = row.lockerNumber.trim();
locker = await this.lockerRepo.findOne({ where: { roomId: room.id, lockerNumber } });
if (!locker) {
locker = await this.lockerRepo.save(
this.lockerRepo.create({ roomId: room.id, lockerNumber, status: 'available' }),
);
}
if (!isHistoricalRecord && locker.status !== 'available') {
throw new BadRequestException(`柜子 ${lockerNumber} 已被占用或维修中`);
}
}
// 6. 创建入住记录
@@ -510,8 +497,12 @@ export class OccupanciesService {
studentId: student.id,
roomId: room.id,
checkInDate,
billingStartDate: checkInDate,
billingStartDate: row.billingStartDate?.trim() || checkInDate,
stayType: row.stayType || undefined,
responsibleOrganizationId: student.organizationId || organization.id,
notes: row.notes || undefined,
bedId: bed?.id,
lockerId: locker?.id,
};
// 如果有退宿日期,直接记录
if (row.checkOutDate?.trim()) {
@@ -520,15 +511,13 @@ export class OccupanciesService {
}
await this.repo.save(this.repo.create(occData));
// 7. 首位入住者确定房间性别
if (student.gender && !room.gender) {
await this.roomRepo.update(room.id, { gender: student.gender });
room.gender = student.gender;
}
// 8. 更新宿舍状态
if (!row.checkOutDate?.trim() && count + 1 >= room.capacity) {
await this.roomRepo.update(room.id, { status: 'full' });
// 7. 更新床位、柜子和宿舍状态
if (!isHistoricalRecord) {
if (bed) await this.bedRepo.update(bed.id, { status: 'occupied' });
if (locker) await this.lockerRepo.update(locker.id, { status: 'occupied' });
if (count + 1 >= room.capacity) {
await this.roomRepo.update(room.id, { status: 'full' });
}
}
// 9. 自动收取押金(仅对新入住且非历史记录的学生)

View File

@@ -0,0 +1,62 @@
import {
createOccupancyImportTemplateWorkbook,
OCCUPANCY_IMPORT_COLUMNS,
parseOccupancyImportWorksheet,
} from './occupancy-import-template';
describe('occupancy import template', () => {
it('includes the current occupancy fields including bed and locker numbers', () => {
const workbook = createOccupancyImportTemplateWorkbook();
const ws = workbook.getWorksheet('入住名单导入模板')!;
const headers = ws.getRow(1).values as unknown[];
expect(headers).toEqual(
expect.arrayContaining([
'宿舍号',
'楼栋',
'床位号',
'柜子号',
'计费起始日',
'入住类型',
'备注',
]),
);
expect(ws.columnCount).toBe(OCCUPANCY_IMPORT_COLUMNS.length);
});
it('keeps Excel Date cells on the same local calendar day', () => {
const workbook = createOccupancyImportTemplateWorkbook();
const ws = workbook.getWorksheet('入住名单导入模板')!;
ws.getCell('J2').value = new Date(2026, 3, 21);
ws.getCell('K2').value = new Date(2026, 3, 22);
ws.getCell('L2').value = new Date(2026, 3, 30);
expect(parseOccupancyImportWorksheet(ws)[0]).toMatchObject({
checkInDate: '2026-04-21',
billingStartDate: '2026-04-22',
checkOutDate: '2026-04-30',
});
});
it('parses rows by header so new columns do not shift existing fields', () => {
const workbook = createOccupancyImportTemplateWorkbook();
const ws = workbook.getWorksheet('入住名单导入模板')!;
const rows = parseOccupancyImportWorksheet(ws);
expect(rows[0]).toMatchObject({
roomNumber: '4-102',
building: '4号楼',
bedNumber: '1号床',
lockerNumber: 'A01',
name: '张三',
checkInDate: '2026-04-21',
billingStartDate: '2026-04-21',
stayType: 'short',
});
expect(rows[1]).toMatchObject({
bedNumber: '2号床',
lockerNumber: 'A02',
stayType: 'long',
});
});
});

View File

@@ -0,0 +1,224 @@
import * as ExcelJS from 'exceljs';
export interface OccupancyImportRow {
roomNumber: string;
building?: string;
bedNumber?: string;
lockerNumber?: string;
name: string;
gender?: string;
ethnicity?: string;
phone?: string;
idNumber?: string;
checkInDate: string;
billingStartDate?: string;
checkOutDate?: string;
stayType?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
notes?: string;
}
export const OCCUPANCY_IMPORT_COLUMNS = [
{ header: '宿舍号', key: 'roomNumber', width: 12 },
{ header: '楼栋', key: 'building', width: 10 },
{ header: '床位号', key: 'bedNumber', width: 10 },
{ header: '柜子号', key: 'lockerNumber', width: 10 },
{ header: '姓名', key: 'name', width: 12 },
{ header: '性别', key: 'gender', width: 8 },
{ header: '民族', key: 'ethnicity', width: 10 },
{ header: '电话', key: 'phone', width: 15 },
{ header: '学号/身份证', key: 'idNumber', width: 22 },
{ header: '入住时间', key: 'checkInDate', width: 14 },
{ header: '计费起始日', key: 'billingStartDate', width: 14 },
{ header: '离宿时间', key: 'checkOutDate', width: 14 },
{ header: '入住类型', key: 'stayType', width: 10 },
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
{ header: '所属机构', key: 'organization', width: 18 },
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
{ header: '备注', key: 'notes', width: 20 },
] as const;
const HEADER_ALIASES: Record<keyof OccupancyImportRow, string[]> = {
roomNumber: ['宿舍号', '房间号'],
building: ['楼栋'],
bedNumber: ['床位号'],
lockerNumber: ['柜子号'],
name: ['姓名', '学生姓名'],
gender: ['性别'],
ethnicity: ['民族'],
phone: ['电话', '手机号'],
idNumber: ['学号/身份证', '学号', '身份证号'],
checkInDate: ['入住时间', '入住日期'],
billingStartDate: ['计费起始日', '计费开始日'],
checkOutDate: ['离宿时间', '退宿时间', '退宿日期'],
stayType: ['入住类型', '住宿类型'],
emergencyContact: ['紧急联系人'],
emergencyPhone: ['紧急联系人电话', '紧急联系电话'],
organization: ['所属机构', '机构'],
supervisor: ['负责人/班主任', '负责人', '班主任'],
notes: ['备注'],
};
function cellText(cell: ExcelJS.Cell | undefined): string {
if (!cell?.value) return '';
if (typeof cell.value === 'object' && 'text' in cell.value) {
return String(cell.value.text).trim();
}
return String(cell.value).trim();
}
function parseDate(cell: ExcelJS.Cell | undefined): string {
const value = cell?.value;
if (!value) return '';
if (value instanceof Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
const text = cellText(cell);
const matched = text.match(/(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})/);
if (!matched) return text;
return `${matched[1]}-${matched[2].padStart(2, '0')}-${matched[3].padStart(2, '0')}`;
}
function normalizeStayType(value: string): string | undefined {
if (!value) return undefined;
if (value === '长租' || value.toLowerCase() === 'long') return 'long';
if (value === '短租' || value.toLowerCase() === 'short') return 'short';
return value;
}
export function parseOccupancyImportWorksheet(ws: ExcelJS.Worksheet): OccupancyImportRow[] {
const headerIndexes = new Map<string, number>();
ws.getRow(1).eachCell((cell, columnNumber) => {
const header = cellText(cell).replace(/\s+/g, '');
if (header) headerIndexes.set(header, columnNumber);
});
const columnFor = (key: keyof OccupancyImportRow): number | undefined => {
for (const alias of HEADER_ALIASES[key]) {
const index = headerIndexes.get(alias.replace(/\s+/g, ''));
if (index) return index;
}
return undefined;
};
const getCell = (row: ExcelJS.Row, key: keyof OccupancyImportRow) => {
const index = columnFor(key);
return index ? row.getCell(index) : undefined;
};
const rows: OccupancyImportRow[] = [];
let lastRoomNumber = '';
ws.eachRow((row, rowNumber) => {
if (rowNumber === 1) return;
const roomNumber = cellText(getCell(row, 'roomNumber'));
if (roomNumber) lastRoomNumber = roomNumber;
const name = cellText(getCell(row, 'name'));
if (!name) return;
rows.push({
roomNumber: lastRoomNumber,
building: cellText(getCell(row, 'building')) || undefined,
bedNumber: cellText(getCell(row, 'bedNumber')) || undefined,
lockerNumber: cellText(getCell(row, 'lockerNumber')) || undefined,
name,
gender: cellText(getCell(row, 'gender')) || undefined,
ethnicity: cellText(getCell(row, 'ethnicity')) || undefined,
phone: cellText(getCell(row, 'phone')) || undefined,
idNumber: cellText(getCell(row, 'idNumber')) || undefined,
checkInDate: parseDate(getCell(row, 'checkInDate')),
billingStartDate: parseDate(getCell(row, 'billingStartDate')) || undefined,
checkOutDate: parseDate(getCell(row, 'checkOutDate')) || undefined,
stayType: normalizeStayType(cellText(getCell(row, 'stayType'))),
emergencyContact: cellText(getCell(row, 'emergencyContact')) || undefined,
emergencyPhone: cellText(getCell(row, 'emergencyPhone')) || undefined,
organization: cellText(getCell(row, 'organization')) || undefined,
supervisor: cellText(getCell(row, 'supervisor')) || undefined,
notes: cellText(getCell(row, 'notes')) || undefined,
});
});
return rows;
}
export function createOccupancyImportTemplateWorkbook(): ExcelJS.Workbook {
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('入住名单导入模板');
ws.columns = [...OCCUPANCY_IMPORT_COLUMNS];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
ws.views = [{ state: 'frozen', ySplit: 1 }];
ws.autoFilter = { from: 'A1', to: 'R1' };
ws.addRow({
roomNumber: '4-102',
building: '4号楼',
bedNumber: '1号床',
lockerNumber: 'A01',
name: '张三',
gender: '男',
ethnicity: '汉族',
phone: '13800138000',
idNumber: '2024001',
checkInDate: '2026-04-21',
billingStartDate: '2026-04-21',
checkOutDate: '',
stayType: '短租',
emergencyContact: '张父',
emergencyPhone: '13900000000',
organization: '',
supervisor: '',
notes: '',
});
ws.addRow({
roomNumber: '4-102',
building: '4号楼',
bedNumber: '2号床',
lockerNumber: 'A02',
name: '李四',
gender: '男',
ethnicity: '汉族',
phone: '13800138001',
idNumber: '2024002',
checkInDate: '2026-04-21',
billingStartDate: '2026-04-22',
checkOutDate: '',
stayType: '长租',
emergencyContact: '',
emergencyPhone: '',
organization: 'XXX教育科技',
supervisor: '王老师',
notes: '示例数据,导入前请删除',
});
const stayTypeColumnNumber = ws.getColumn('stayType').number;
for (let row = 2; row <= 1000; row++) {
ws.getCell(row, stayTypeColumnNumber).dataValidation = {
type: 'list',
allowBlank: true,
formulae: ['"短租,长租"'],
};
}
const helpWs = workbook.addWorksheet('使用说明');
helpWs.getColumn(1).width = 90;
const instructions = [
'【入住名单导入说明】',
'1. 宿舍号、姓名、入住时间为必填项;床位号建议填写,柜子号可选。',
'2. 填写床位号或柜子号后,系统会在对应宿舍中匹配;不存在时自动创建,已被占用时该行导入失败。',
'3. 宿舍不存在时会自动创建;宿舍号可智能解析楼栋、楼层和房间类型,楼栋列可用于补充楼栋名称。',
'4. 同一宿舍号的连续多行可以合并或留空,系统会继承上一行宿舍号。',
'5. 入住类型可填“短租”或“长租”;计费起始日不填时默认等于入住时间。',
'6. 已存在的学生按姓名匹配,并自动补充其缺失的基础资料。',
'7. 已有在住记录的学生会自动跳过,不会重复入住。',
'8. 填写离宿时间的记录会作为历史入住导入,床位和柜子不会被标记为占用。',
'9. 模板中的两行示例数据仅用于说明,正式导入前请删除或替换。',
];
instructions.forEach((instruction) => helpWs.addRow([instruction]));
helpWs.getRow(1).font = { bold: true, size: 14 };
return workbook;
}

View File

@@ -42,7 +42,9 @@ describe('preset role permissions', () => {
expect(accommodation.groups).toEqual(
expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'deposit']),
);
expect(accommodation.extras).toContain('student:basic-view');
expect(accommodation.extras).toEqual(
expect.arrayContaining(['student:basic-view', 'organization:view']),
);
});
it('keeps classroom rental operations separate from accommodation operations', () => {

View File

@@ -180,7 +180,7 @@ export const PRESET_ROLES: Array<{
'notification',
'profile',
],
extraPermissions: ['student:basic-view'],
extraPermissions: ['student:basic-view', 'organization:view'],
legacyNames: ['宿管老师', '宿管', '财务'],
legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'],
},

View File

@@ -27,7 +27,6 @@ export class CreateRoomDto {
@IsOptional()
@IsNumber()
monthlyRate?: number;
}
export class UpdateRoomDto {
@@ -52,10 +51,6 @@ export class UpdateRoomDto {
@IsString()
roomType?: string;
@IsOptional()
@IsString()
gender?: string;
@IsOptional()
@IsEnum(['available', 'full', 'maintenance'])
status?: string;

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