Compare commits
11 Commits
b9e4caae99
...
aaf49d5580
| Author | SHA1 | Date | |
|---|---|---|---|
| aaf49d5580 | |||
| 5cf6aede1e | |||
| 811e7ce826 | |||
| d572e984d2 | |||
| a93ba657a8 | |||
| 77714642a5 | |||
| e7aa202603 | |||
| 013b3f4afe | |||
| 7bfd30b513 | |||
| ceb0e2bf14 | |||
| acb11b87a3 |
@@ -321,7 +321,11 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 15 }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="添加报读记录"
|
title="添加报读记录"
|
||||||
@@ -428,7 +432,11 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 15 }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="添加考试成绩"
|
title="添加考试成绩"
|
||||||
@@ -529,7 +537,11 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({ data, st
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 15 }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="添加学情记录"
|
title="添加学情记录"
|
||||||
@@ -712,7 +724,11 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ dat
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 15 }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50],
|
||||||
|
}}
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
canPullAttendance,
|
canPullAttendance,
|
||||||
getAttendanceExperience,
|
getAttendanceExperience,
|
||||||
|
getPunchDisplayInfo,
|
||||||
getSchedulePhase,
|
getSchedulePhase,
|
||||||
summarizeAttendance,
|
summarizeAttendance,
|
||||||
summarizeLessonCheckins,
|
summarizeLessonCheckins,
|
||||||
@@ -65,3 +66,34 @@ describe('lesson check-in summary', () => {
|
|||||||
).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 });
|
).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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -82,3 +82,56 @@ export function summarizeLessonCheckins(
|
|||||||
notCheckedIn: records.length - checkedIn,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -502,3 +502,25 @@
|
|||||||
.attendance-marking-actions .ant-btn {
|
.attendance-marking-actions .ant-btn {
|
||||||
min-width: 54px;
|
min-width: 54px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.punch-device-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punch-device-cell .ant-tag {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punch-device-cell strong {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punch-device-cell span {
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { message } from '../../ui/app-message';
|
|||||||
import {
|
import {
|
||||||
canPullAttendance,
|
canPullAttendance,
|
||||||
getAttendanceExperience,
|
getAttendanceExperience,
|
||||||
|
getPunchDisplayInfo,
|
||||||
getSchedulePhase,
|
getSchedulePhase,
|
||||||
summarizeLessonCheckins,
|
summarizeLessonCheckins,
|
||||||
type AttendanceSummary,
|
type AttendanceSummary,
|
||||||
@@ -96,6 +97,10 @@ interface AttendanceRecordItem {
|
|||||||
class: { id: number; name: string } | null;
|
class: { id: number; name: string } | null;
|
||||||
scheduleId?: number | null;
|
scheduleId?: number | null;
|
||||||
attendanceSessionId?: number | null;
|
attendanceSessionId?: number | null;
|
||||||
|
punchTime?: string | null;
|
||||||
|
punchSource?: string | null;
|
||||||
|
punchDeviceName?: string | null;
|
||||||
|
punchDeviceId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssignedClass {
|
interface AssignedClass {
|
||||||
@@ -288,6 +293,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
`/attendance-lessons/schedules/${schedule.id}/pull`,
|
`/attendance-lessons/schedules/${schedule.id}/pull`,
|
||||||
{ date: today },
|
{ date: today },
|
||||||
);
|
);
|
||||||
|
setSelectedSchedule(data.schedule);
|
||||||
setLessonSession(data.session);
|
setLessonSession(data.session);
|
||||||
setLessonRecords(data.records);
|
setLessonRecords(data.records);
|
||||||
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
||||||
@@ -386,7 +392,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
|
|
||||||
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={820} title={null} className="attendance-drawer">
|
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
|
||||||
<div className="lesson-record-header">
|
<div className="lesson-record-header">
|
||||||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||||||
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
||||||
@@ -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: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
|
||||||
|
{
|
||||||
|
title: '打卡设备',
|
||||||
|
width: 220,
|
||||||
|
render: (_: unknown, record: AttendanceRecordItem) => {
|
||||||
|
const info = getPunchDisplayInfo(record);
|
||||||
|
if (!info) return <span className="muted-text">—</span>;
|
||||||
|
return (
|
||||||
|
<div className="punch-device-cell">
|
||||||
|
<Tag color={info.machine ? 'green' : 'blue'}>{info.label}</Tag>
|
||||||
|
{info.detail && <strong>{info.detail}</strong>}
|
||||||
|
{info.time && <span>{dayjs(info.time).format('HH:mm:ss')}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">—</span> },
|
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">—</span> },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -89,9 +89,11 @@ const BillsPage: React.FC = () => {
|
|||||||
}, [bills, searchText, filterStatus]);
|
}, [bills, searchText, filterStatus]);
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
setSaving(true);
|
if (saving) return;
|
||||||
const values = await generateForm.validateFields();
|
|
||||||
try {
|
try {
|
||||||
|
const values = await generateForm.validateFields();
|
||||||
|
setSaving(true);
|
||||||
const res: any = await api.post('/bills/generate', {
|
const res: any = await api.post('/bills/generate', {
|
||||||
periodStart: values.period[0].format('YYYY-MM-DD'),
|
periodStart: values.period[0].format('YYYY-MM-DD'),
|
||||||
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
||||||
@@ -99,9 +101,12 @@ const BillsPage: React.FC = () => {
|
|||||||
message.success(res.message || '生成成功');
|
message.success(res.message || '生成成功');
|
||||||
setGenerateModal(false);
|
setGenerateModal(false);
|
||||||
generateForm.resetFields();
|
generateForm.resetFields();
|
||||||
fetchData();
|
void fetchData();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '生成失败');
|
// Ant Design 的表单校验失败会 reject;字段本身已展示错误,无需再弹“生成失败”。
|
||||||
|
if (!e?.errorFields) {
|
||||||
|
message.error(e?.message || '生成失败');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -399,7 +404,12 @@ const BillsPage: React.FC = () => {
|
|||||||
dataSource={filteredBills}
|
dataSource={filteredBills}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
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="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedRows,
|
selectedRowKeys: selectedRows,
|
||||||
|
|||||||
@@ -530,7 +530,11 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
columns={studentColumns}
|
columns={studentColumns}
|
||||||
dataSource={students}
|
dataSource={students}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="添加学员"
|
title="添加学员"
|
||||||
@@ -574,7 +578,11 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
columns={teacherColumns}
|
columns={teacherColumns}
|
||||||
dataSource={teachers}
|
dataSource={teachers}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="添加教师"
|
title="添加教师"
|
||||||
@@ -633,7 +641,11 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
columns={scheduleColumns}
|
columns={scheduleColumns}
|
||||||
dataSource={schedules}
|
dataSource={schedules}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -264,7 +264,11 @@ const ClassesPage: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
}}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1100 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -483,7 +483,12 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
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 }}
|
scroll={{ x: 1200 }}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -287,7 +287,12 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? '编辑教室' : '添加教室'}
|
title={editing ? '编辑教室' : '添加教室'}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildDepositStudentOption } from './deposit-student-option';
|
import { buildDepositStudentOption, buildDepositStudentOptions } from './deposit-student-option';
|
||||||
|
|
||||||
describe('deposit student option', () => {
|
describe('deposit student option', () => {
|
||||||
it('uses the student number as the non-sensitive identifier', () => {
|
it('uses the student number as the non-sensitive identifier', () => {
|
||||||
@@ -17,4 +17,13 @@ describe('deposit student option', () => {
|
|||||||
label: '张三 (#23)',
|
label: '张三 (#23)',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses lookup rows without requiring a status field', () => {
|
||||||
|
expect(buildDepositStudentOptions([{ id: 23, name: '张三', studentNo: 'S2026001' }])).toEqual([
|
||||||
|
{
|
||||||
|
value: 23,
|
||||||
|
label: '张三 (S2026001)',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ export const buildDepositStudentOption = (student: DepositStudentLookup) => ({
|
|||||||
value: student.id,
|
value: student.id,
|
||||||
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
|
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const buildDepositStudentOptions = (students: DepositStudentLookup[]) =>
|
||||||
|
students.map(buildDepositStudentOption);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import dayjs from 'dayjs';
|
|||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { buildDepositStudentOption } from './deposit-student-option';
|
import { buildDepositStudentOptions } from './deposit-student-option';
|
||||||
|
|
||||||
const statusMap: Record<string, { text: string; color: string }> = {
|
const statusMap: Record<string, { text: string; color: string }> = {
|
||||||
paid: { text: '已缴', color: 'green' },
|
paid: { text: '已缴', color: 'green' },
|
||||||
@@ -33,6 +33,10 @@ const installmentStatusMap: Record<string, { text: string; color: string }> = {
|
|||||||
paid: { text: '已缴', color: 'green' },
|
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 DepositsPage: React.FC = () => {
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
@@ -82,17 +86,14 @@ const DepositsPage: React.FC = () => {
|
|||||||
}, [data, searchText, filterStatus]);
|
}, [data, searchText, filterStatus]);
|
||||||
|
|
||||||
const studentOptions = useMemo(
|
const studentOptions = useMemo(
|
||||||
() =>
|
() => buildDepositStudentOptions(students),
|
||||||
students
|
|
||||||
.filter((s: any) => s.status === 'active')
|
|
||||||
.map(buildDepositStudentOption),
|
|
||||||
[students],
|
[students],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const values = await createForm.validateFields();
|
|
||||||
try {
|
try {
|
||||||
|
const values = await createForm.validateFields();
|
||||||
await api.post('/deposits', {
|
await api.post('/deposits', {
|
||||||
studentId: values.studentId,
|
studentId: values.studentId,
|
||||||
amount: values.amount,
|
amount: values.amount,
|
||||||
@@ -104,7 +105,9 @@ const DepositsPage: React.FC = () => {
|
|||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '操作失败');
|
if (!isFormValidationError(e)) {
|
||||||
|
message.error(e?.message || '操作失败');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -112,8 +115,8 @@ const DepositsPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleRefund = async () => {
|
const handleRefund = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const values = await refundForm.validateFields();
|
|
||||||
try {
|
try {
|
||||||
|
const values = await refundForm.validateFields();
|
||||||
await api.put(`/deposits/${refundModal.id}/refund`, {
|
await api.put(`/deposits/${refundModal.id}/refund`, {
|
||||||
refundDate: values.refundDate.format('YYYY-MM-DD'),
|
refundDate: values.refundDate.format('YYYY-MM-DD'),
|
||||||
deductionAmount: values.deductionAmount || 0,
|
deductionAmount: values.deductionAmount || 0,
|
||||||
@@ -125,7 +128,9 @@ const DepositsPage: React.FC = () => {
|
|||||||
refundForm.resetFields();
|
refundForm.resetFields();
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '操作失败');
|
if (!isFormValidationError(e)) {
|
||||||
|
message.error(e?.message || '操作失败');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -133,8 +138,8 @@ const DepositsPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleAddInstallment = async () => {
|
const handleAddInstallment = async () => {
|
||||||
if (installmentModal == null) return;
|
if (installmentModal == null) return;
|
||||||
const values = await installmentForm.validateFields();
|
|
||||||
try {
|
try {
|
||||||
|
const values = await installmentForm.validateFields();
|
||||||
await api.post(`/deposits/${installmentModal}/installments`, {
|
await api.post(`/deposits/${installmentModal}/installments`, {
|
||||||
amount: values.amount,
|
amount: values.amount,
|
||||||
dueDate: values.dueDate.format('YYYY-MM-DD'),
|
dueDate: values.dueDate.format('YYYY-MM-DD'),
|
||||||
@@ -144,7 +149,9 @@ const DepositsPage: React.FC = () => {
|
|||||||
installmentForm.resetFields();
|
installmentForm.resetFields();
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '操作失败');
|
if (!isFormValidationError(e)) {
|
||||||
|
message.error(e?.message || '操作失败');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -304,7 +311,12 @@ const DepositsPage: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 1200 }}
|
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="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -452,7 +452,12 @@ const ExpensesPage: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 1200 }}
|
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="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedRoomKeys,
|
selectedRowKeys: selectedRoomKeys,
|
||||||
@@ -575,7 +580,12 @@ const ExpensesPage: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 1200 }}
|
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="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedPersonalKeys,
|
selectedRowKeys: selectedPersonalKeys,
|
||||||
|
|||||||
@@ -522,7 +522,12 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
scroll={{ x: 1300 }}
|
scroll={{ x: 1300 }}
|
||||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -593,7 +598,7 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
placeholder="默认为短租"
|
placeholder="默认为短租"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="responsibleOrganizationId" label="负责机构">
|
<Form.Item name="responsibleOrganizationId" label="所属机构">
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
allowClear
|
allowClear
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ const OperationLogsPage: React.FC = () => {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
const [filterModule, setFilterModule] = useState<string | undefined>();
|
const [filterModule, setFilterModule] = useState<string | undefined>();
|
||||||
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
|
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: any = { page, pageSize: 20 };
|
const params: any = { page, pageSize };
|
||||||
if (filterModule) params.module = filterModule;
|
if (filterModule) params.module = filterModule;
|
||||||
if (dateRange) {
|
if (dateRange) {
|
||||||
params.startDate = dateRange[0];
|
params.startDate = dateRange[0];
|
||||||
@@ -46,7 +47,7 @@ const OperationLogsPage: React.FC = () => {
|
|||||||
message.error(err?.message || '加载失败,请稍后重试');
|
message.error(err?.message || '加载失败,请稍后重试');
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [page, filterModule, dateRange]);
|
}, [page, pageSize, filterModule, dateRange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -159,8 +160,13 @@ const OperationLogsPage: React.FC = () => {
|
|||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
total,
|
total,
|
||||||
pageSize: 20,
|
pageSize,
|
||||||
onChange: setPage,
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage);
|
||||||
|
setPageSize(nextPageSize);
|
||||||
|
},
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -235,7 +235,12 @@ const OrganizationsPage: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无机构" /> }}
|
locale={{ emptyText: <Empty description="暂无机构" /> }}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1100 }}
|
||||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 个机构` }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 个机构`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
|
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ const RoomsPage: React.FC = () => {
|
|||||||
if (filterStatus) result = result.filter((r: Record<string, unknown>) => r.status === filterStatus);
|
if (filterStatus) result = result.filter((r: Record<string, unknown>) => r.status === filterStatus);
|
||||||
return result;
|
return result;
|
||||||
}, [data, searchText, filterBuilding, filterStatus]);
|
}, [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 handleSave = async () => {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
@@ -167,7 +172,7 @@ const RoomsPage: React.FC = () => {
|
|||||||
message.success('更新成功');
|
message.success('更新成功');
|
||||||
} else {
|
} else {
|
||||||
await api.post('/rooms', payload);
|
await api.post('/rooms', payload);
|
||||||
message.success('创建成功');
|
message.success(`创建成功,已自动生成 ${values.capacity} 张床位`);
|
||||||
}
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
@@ -514,7 +519,12 @@ const RoomsPage: React.FC = () => {
|
|||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1200 }}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
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' : '')}
|
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
@@ -629,24 +639,26 @@ const RoomsPage: React.FC = () => {
|
|||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
disabled={drawerRoom?.status === 'archived'}
|
disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}
|
||||||
onClick={() => { setBedEditing(null); bedForm.resetFields(); setBedModalOpen(true); }}
|
onClick={() => { setBedEditing(null); bedForm.resetFields(); setBedModalOpen(true); }}
|
||||||
>
|
>
|
||||||
添加床位
|
添加床位
|
||||||
</Button>
|
</Button>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="批量生成床位"
|
title={remainingBedSlots > 0 ? '批量生成床位' : '床位已达到额定人数'}
|
||||||
description={
|
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={() => {
|
onConfirm={() => {
|
||||||
const input = document.getElementById('batch-bed-count') as HTMLInputElement;
|
const input = document.getElementById('batch-bed-count') as HTMLInputElement;
|
||||||
handleBatchBeds(input ? parseInt(input.value) || 4 : 4);
|
handleBatchBeds(input ? parseInt(input.value) || defaultBatchBedCount : defaultBatchBedCount);
|
||||||
}}
|
}}
|
||||||
okText="生成"
|
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>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -577,7 +577,12 @@ const StudentsPage: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
scroll={{ x: 1410 }}
|
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' : '')}
|
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
|
|||||||
@@ -146,7 +146,12 @@ const TeacherWorkspacePage: React.FC = () => {
|
|||||||
columns={classColumns}
|
columns={classColumns}
|
||||||
dataSource={data.assignedClasses}
|
dataSource={data.assignedClasses}
|
||||||
rowKey="classId"
|
rowKey="classId"
|
||||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 个班级` }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 个班级`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Empty description="暂无分配的班级" />
|
<Empty description="暂无分配的班级" />
|
||||||
@@ -160,7 +165,12 @@ const TeacherWorkspacePage: React.FC = () => {
|
|||||||
columns={scheduleColumns}
|
columns={scheduleColumns}
|
||||||
dataSource={data.todaySchedules}
|
dataSource={data.todaySchedules}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 节` }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 节`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Empty description="今日无排课" />
|
<Empty description="今日无排课" />
|
||||||
@@ -174,7 +184,12 @@ const TeacherWorkspacePage: React.FC = () => {
|
|||||||
columns={studentColumns}
|
columns={studentColumns}
|
||||||
dataSource={data.myStudents}
|
dataSource={data.myStudents}
|
||||||
rowKey="studentId"
|
rowKey="studentId"
|
||||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 人` }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 人`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Empty description="暂无学生" />
|
<Empty description="暂无学生" />
|
||||||
|
|||||||
@@ -46,13 +46,14 @@ const ROLE_TYPE_LABELS: Record<string, string> = {
|
|||||||
academic_teacher: '教务老师',
|
academic_teacher: '教务老师',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
const TeachersPage: React.FC = () => {
|
const TeachersPage: React.FC = () => {
|
||||||
const [data, setData] = useState<TeacherRow[]>([]);
|
const [data, setData] = useState<TeacherRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [profileModal, setProfileModal] = useState<TeacherRow | null>(null);
|
const [profileModal, setProfileModal] = useState<TeacherRow | null>(null);
|
||||||
const [form] = Form.useForm<ProfileFormValues>();
|
const [form] = Form.useForm<ProfileFormValues>();
|
||||||
@@ -62,7 +63,7 @@ const TeachersPage: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get<TeacherListResponse>('/rbac/teachers', {
|
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);
|
setData(res.list);
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
@@ -70,7 +71,7 @@ const TeachersPage: React.FC = () => {
|
|||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [page, search]);
|
}, [page, pageSize, search]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -199,9 +200,14 @@ const TeachersPage: React.FC = () => {
|
|||||||
scroll={{ x: 1300 }}
|
scroll={{ x: 1300 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize,
|
||||||
total,
|
total,
|
||||||
onChange: setPage,
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage);
|
||||||
|
setPageSize(nextPageSize);
|
||||||
|
},
|
||||||
showTotal: (t) => `共 ${t} 人`,
|
showTotal: (t) => `共 ${t} 人`,
|
||||||
}}
|
}}
|
||||||
expandable={{
|
expandable={{
|
||||||
|
|||||||
@@ -93,6 +93,31 @@ describe('AttendanceImportService', () => {
|
|||||||
expect(entity.userName).toBe('张三');
|
expect(entity.userName).toBe('张三');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores DingTalk punch source and attendance machine metadata', async () => {
|
||||||
|
const entity = await (service as any).mapToEntity({
|
||||||
|
userId: 'ding-1',
|
||||||
|
userName: '张三',
|
||||||
|
workDate: '2026-07-01',
|
||||||
|
timeResult: 'Normal',
|
||||||
|
locationResult: '',
|
||||||
|
planCheckTime: '',
|
||||||
|
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||||
|
checkId: 'check-1',
|
||||||
|
checkType: 'OnDuty',
|
||||||
|
sourceType: 'ATM',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entity).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
|
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
|
||||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -138,9 +163,19 @@ describe('AttendanceImportService', () => {
|
|||||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||||
checkId: 'check-1',
|
checkId: 'check-1',
|
||||||
checkType: 'OnDuty',
|
checkType: 'OnDuty',
|
||||||
|
sourceType: 'ATM',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
dingRawRepo.find.mockResolvedValue([{ dingId: 'check-1' }]);
|
dingRawRepo.find.mockResolvedValue([{
|
||||||
|
dingId: 'check-1',
|
||||||
|
punchSource: null,
|
||||||
|
punchDeviceName: null,
|
||||||
|
punchDeviceId: null,
|
||||||
|
rawData: '',
|
||||||
|
}]);
|
||||||
|
dingRawRepo.save.mockImplementation(async (entities) => entities);
|
||||||
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
|
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
|
||||||
|
|
||||||
const result = await service.importFromDingTalk({
|
const result = await service.importFromDingTalk({
|
||||||
@@ -150,10 +185,57 @@ describe('AttendanceImportService', () => {
|
|||||||
autoMatch: true,
|
autoMatch: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(dingRawRepo.save).toHaveBeenCalledWith(
|
||||||
|
[expect.objectContaining({
|
||||||
|
dingId: 'check-1',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
})],
|
||||||
|
{ chunk: 50 },
|
||||||
|
);
|
||||||
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
|
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
|
||||||
expect(result.matched).toBe(1);
|
expect(result.matched).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves existing device metadata when a duplicate response omits it', async () => {
|
||||||
|
const existing = {
|
||||||
|
dingId: 'check-keep-device',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
rawData: '{}',
|
||||||
|
};
|
||||||
|
dingTalkService.fetchAttendanceResults.mockResolvedValue([{
|
||||||
|
userId: 'ding-1',
|
||||||
|
userName: '张三',
|
||||||
|
workDate: '2026-07-01',
|
||||||
|
timeResult: 'Normal',
|
||||||
|
locationResult: '',
|
||||||
|
planCheckTime: '',
|
||||||
|
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||||
|
checkId: 'check-keep-device',
|
||||||
|
checkType: 'OnDuty',
|
||||||
|
sourceType: '',
|
||||||
|
}]);
|
||||||
|
dingRawRepo.find.mockResolvedValue([existing]);
|
||||||
|
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 0, total: 1 });
|
||||||
|
|
||||||
|
await service.importFromDingTalk({
|
||||||
|
startDate: '2026-07-01',
|
||||||
|
endDate: '2026-07-01',
|
||||||
|
userIds: ['ding-1'],
|
||||||
|
autoMatch: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(existing).toEqual(expect.objectContaining({
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
}));
|
||||||
|
expect(dingRawRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('scopes SSE progress events to the importing user', async () => {
|
it('scopes SSE progress events to the importing user', async () => {
|
||||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -104,8 +104,10 @@ export class AttendanceImportService {
|
|||||||
|
|
||||||
// Stage 2: Parse & deduplicate
|
// Stage 2: Parse & deduplicate
|
||||||
this.emit('parsing', 0, total, `Parsing ${total} records...`);
|
this.emit('parsing', 0, total, `Parsing ${total} records...`);
|
||||||
const existingDingIds = await this.getExistingDingIds(rawResults);
|
const existingByDingId = await this.getExistingRecordsByDingId(rawResults);
|
||||||
const newRecords = rawResults.filter((r) => !existingDingIds.has(r.checkId));
|
const newRecords = rawResults.filter((r) => !existingByDingId.has(r.checkId));
|
||||||
|
const duplicateRecords = rawResults.filter((r) => existingByDingId.has(r.checkId));
|
||||||
|
await this.refreshDuplicatePunchMetadata(duplicateRecords, existingByDingId);
|
||||||
skipped = rawResults.length - newRecords.length;
|
skipped = rawResults.length - newRecords.length;
|
||||||
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
|
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
|
||||||
|
|
||||||
@@ -246,17 +248,45 @@ export class AttendanceImportService {
|
|||||||
/**
|
/**
|
||||||
* Query which dingIds already exist to skip duplicates.
|
* Query which dingIds already exist to skip duplicates.
|
||||||
*/
|
*/
|
||||||
private async getExistingDingIds(
|
private async getExistingRecordsByDingId(
|
||||||
results: DingTalkAttendanceResult[],
|
results: DingTalkAttendanceResult[],
|
||||||
): Promise<Set<string>> {
|
): Promise<Map<string, DingAttendanceRaw>> {
|
||||||
const dingIds = results.map((r) => r.checkId).filter(Boolean);
|
const dingIds = results.map((r) => r.checkId).filter(Boolean);
|
||||||
if (dingIds.length === 0) return new Set();
|
if (dingIds.length === 0) return new Map();
|
||||||
|
|
||||||
const existing = await this.dingRawRepo.find({
|
const existing = await this.dingRawRepo.find({
|
||||||
where: { dingId: In(dingIds) },
|
where: { dingId: In(dingIds) },
|
||||||
select: ['dingId'],
|
|
||||||
});
|
});
|
||||||
return new Set(existing.map((e) => e.dingId));
|
return new Map(existing.map((entity) => [entity.dingId, entity]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshDuplicatePunchMetadata(
|
||||||
|
results: DingTalkAttendanceResult[],
|
||||||
|
existingByDingId: Map<string, DingAttendanceRaw>,
|
||||||
|
): Promise<void> {
|
||||||
|
const changed: DingAttendanceRaw[] = [];
|
||||||
|
for (const result of results) {
|
||||||
|
const entity = existingByDingId.get(result.checkId);
|
||||||
|
if (!entity) continue;
|
||||||
|
const punchSource = result.sourceType || entity.punchSource || null;
|
||||||
|
const punchDeviceName = result.deviceName || entity.punchDeviceName || null;
|
||||||
|
const punchDeviceId = result.deviceId || entity.punchDeviceId || null;
|
||||||
|
if (
|
||||||
|
entity.punchSource === punchSource &&
|
||||||
|
entity.punchDeviceName === punchDeviceName &&
|
||||||
|
entity.punchDeviceId === punchDeviceId
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entity.punchSource = punchSource;
|
||||||
|
entity.punchDeviceName = punchDeviceName;
|
||||||
|
entity.punchDeviceId = punchDeviceId;
|
||||||
|
entity.rawData = JSON.stringify(result);
|
||||||
|
changed.push(entity);
|
||||||
|
}
|
||||||
|
if (changed.length > 0) {
|
||||||
|
await this.dingRawRepo.save(changed, { chunk: 50 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -271,6 +301,9 @@ export class AttendanceImportService {
|
|||||||
entity.attendanceType = r.checkType || 'OnDuty';
|
entity.attendanceType = r.checkType || 'OnDuty';
|
||||||
entity.timeResult = r.timeResult;
|
entity.timeResult = r.timeResult;
|
||||||
entity.locationResult = r.locationResult || '';
|
entity.locationResult = r.locationResult || '';
|
||||||
|
entity.punchSource = r.sourceType || null;
|
||||||
|
entity.punchDeviceName = r.deviceName || null;
|
||||||
|
entity.punchDeviceId = r.deviceId || null;
|
||||||
|
|
||||||
// Parse check-in/out times
|
// Parse check-in/out times
|
||||||
if (r.actualCheckTime) {
|
if (r.actualCheckTime) {
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
attendanceType: 'OnDuty',
|
attendanceType: 'OnDuty',
|
||||||
timeResult: 'Normal',
|
timeResult: 'Normal',
|
||||||
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matchedStudentId: 2,
|
matchedStudentId: 2,
|
||||||
@@ -100,7 +103,15 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||||
expect.objectContaining({ studentId: 1, status: 'present', source: 'dingtalk' }),
|
expect.objectContaining({
|
||||||
|
studentId: 1,
|
||||||
|
status: 'present',
|
||||||
|
source: 'dingtalk',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
punchTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||||
|
}),
|
||||||
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
|
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
|
||||||
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
|
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
|
||||||
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
|
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
|
||||||
|
|||||||
@@ -200,6 +200,53 @@ export class AttendanceService {
|
|||||||
if (hasPunch) return 'present';
|
if (hasPunch) return 'present';
|
||||||
return finalize ? 'absent' : 'pending';
|
return finalize ? 'absent' : 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getLessonPunchMetadata(
|
||||||
|
records: DingAttendanceRaw[],
|
||||||
|
lessonDate: string,
|
||||||
|
startTime: string,
|
||||||
|
): Pick<AttendanceRecord, 'punchTime' | 'punchSource' | 'punchDeviceName' | 'punchDeviceId'> {
|
||||||
|
const punches = records
|
||||||
|
.map((record) => ({ record, time: record.checkInTime ?? record.checkOutTime }))
|
||||||
|
.filter((item): item is { record: DingAttendanceRaw; time: Date } => !!item.time);
|
||||||
|
if (punches.length === 0) {
|
||||||
|
return {
|
||||||
|
punchTime: null,
|
||||||
|
punchSource: null,
|
||||||
|
punchDeviceName: null,
|
||||||
|
punchDeviceId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lessonStart = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
|
||||||
|
punches.sort(
|
||||||
|
(left, right) =>
|
||||||
|
Math.abs(left.time.getTime() - lessonStart) - Math.abs(right.time.getTime() - lessonStart),
|
||||||
|
);
|
||||||
|
const primary = punches[0];
|
||||||
|
const metadataRecord = [...punches]
|
||||||
|
.filter(({ record }) =>
|
||||||
|
!!(record.punchSource || record.punchDeviceName || record.punchDeviceId) ||
|
||||||
|
!['OnDuty', 'OffDuty'].includes(record.attendanceType),
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
Math.abs(left.time.getTime() - primary.time.getTime()) -
|
||||||
|
Math.abs(right.time.getTime() - primary.time.getTime()),
|
||||||
|
)[0]?.record;
|
||||||
|
const source =
|
||||||
|
metadataRecord?.punchSource ||
|
||||||
|
(metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType)
|
||||||
|
? metadataRecord.attendanceType
|
||||||
|
: primary.record.punchSource);
|
||||||
|
|
||||||
|
return {
|
||||||
|
punchTime: primary.time,
|
||||||
|
punchSource: source || null,
|
||||||
|
punchDeviceName: metadataRecord?.punchDeviceName || primary.record.punchDeviceName || null,
|
||||||
|
punchDeviceId: metadataRecord?.punchDeviceId || primary.record.punchDeviceId || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
async createLessonAttendanceFromDingTalk(
|
async createLessonAttendanceFromDingTalk(
|
||||||
scheduleId: number,
|
scheduleId: number,
|
||||||
lessonDate: string,
|
lessonDate: string,
|
||||||
@@ -270,6 +317,11 @@ export class AttendanceService {
|
|||||||
schedule.endTime,
|
schedule.endTime,
|
||||||
);
|
);
|
||||||
record.status = this.mapDingTalkStatus(raw, finalize);
|
record.status = this.mapDingTalkStatus(raw, finalize);
|
||||||
|
Object.assign(record, this.getLessonPunchMetadata(
|
||||||
|
raw,
|
||||||
|
lessonDate,
|
||||||
|
schedule.startTime,
|
||||||
|
));
|
||||||
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||||
? null
|
? null
|
||||||
: finalize
|
: finalize
|
||||||
@@ -296,6 +348,11 @@ export class AttendanceService {
|
|||||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||||
status: this.mapDingTalkStatus(raw, finalize),
|
status: this.mapDingTalkStatus(raw, finalize),
|
||||||
source: 'dingtalk',
|
source: 'dingtalk',
|
||||||
|
...this.getLessonPunchMetadata(
|
||||||
|
raw,
|
||||||
|
lessonDate,
|
||||||
|
schedule.startTime,
|
||||||
|
),
|
||||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||||
? undefined
|
? undefined
|
||||||
: finalize
|
: finalize
|
||||||
@@ -378,6 +435,11 @@ export class AttendanceService {
|
|||||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||||
status: this.mapDingTalkStatus(raw, finalize),
|
status: this.mapDingTalkStatus(raw, finalize),
|
||||||
source: 'dingtalk',
|
source: 'dingtalk',
|
||||||
|
...this.getLessonPunchMetadata(
|
||||||
|
raw,
|
||||||
|
lessonDate,
|
||||||
|
schedule.startTime,
|
||||||
|
),
|
||||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||||
? undefined
|
? undefined
|
||||||
: finalize
|
: finalize
|
||||||
@@ -908,6 +970,10 @@ export class AttendanceService {
|
|||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
record.status = dto.status;
|
record.status = dto.status;
|
||||||
record.source = 'manual';
|
record.source = 'manual';
|
||||||
|
record.punchTime = null;
|
||||||
|
record.punchSource = null;
|
||||||
|
record.punchDeviceName = null;
|
||||||
|
record.punchDeviceId = null;
|
||||||
}
|
}
|
||||||
if (dto.remark !== undefined) {
|
if (dto.remark !== undefined) {
|
||||||
record.remark = dto.remark;
|
record.remark = dto.remark;
|
||||||
@@ -933,6 +999,10 @@ export class AttendanceService {
|
|||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
freshRecord.status = dto.status;
|
freshRecord.status = dto.status;
|
||||||
freshRecord.source = 'manual';
|
freshRecord.source = 'manual';
|
||||||
|
freshRecord.punchTime = null;
|
||||||
|
freshRecord.punchSource = null;
|
||||||
|
freshRecord.punchDeviceName = null;
|
||||||
|
freshRecord.punchDeviceId = null;
|
||||||
}
|
}
|
||||||
if (dto.remark !== undefined) {
|
if (dto.remark !== undefined) {
|
||||||
freshRecord.remark = dto.remark;
|
freshRecord.remark = dto.remark;
|
||||||
|
|||||||
@@ -68,8 +68,10 @@ describe('DingTalkService — attendance records', () => {
|
|||||||
userId: 'ding-1',
|
userId: 'ding-1',
|
||||||
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
|
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
|
||||||
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
|
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
|
||||||
sourceType: 'USER',
|
sourceType: 'ATM',
|
||||||
checkType: 'OnDuty',
|
checkType: 'OnDuty',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
timeResult: 'Normal',
|
timeResult: 'Normal',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -83,6 +85,13 @@ describe('DingTalkService — attendance records', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(record.workDate).toBe('2026-07-12');
|
expect(record.workDate).toBe('2026-07-12');
|
||||||
|
expect(record).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
checkType: 'OnDuty',
|
||||||
|
sourceType: 'ATM',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Bill } from '../entities/bill.entity';
|
|||||||
import { BillItem } from '../entities/bill-item.entity';
|
import { BillItem } from '../entities/bill-item.entity';
|
||||||
import { Deposit } from '../entities/deposit.entity';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
import * as PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -67,6 +67,18 @@ export class AttendanceRecord {
|
|||||||
@Column({ name: 'source', length: 20, default: 'manual' })
|
@Column({ name: 'source', length: 20, default: 'manual' })
|
||||||
source: string;
|
source: string;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_time', type: 'datetime', nullable: true })
|
||||||
|
punchTime: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
|
||||||
|
punchSource: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
punchDeviceName: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
|
||||||
|
punchDeviceId: string | null;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ export class DingAttendanceRaw {
|
|||||||
@Column({ name: 'location_result', length: 20, nullable: true })
|
@Column({ name: 'location_result', length: 20, nullable: true })
|
||||||
locationResult: string;
|
locationResult: string;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
|
||||||
|
punchSource: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
punchDeviceName: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
|
||||||
|
punchDeviceId: string | null;
|
||||||
|
|
||||||
@Column({ name: 'match_status', length: 20, default: 'unmatched' })
|
@Column({ name: 'match_status', length: 20, default: 'unmatched' })
|
||||||
matchStatus: string;
|
matchStatus: string;
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ export interface DingTalkAttendanceResult {
|
|||||||
actualCheckTime: string;
|
actualCheckTime: string;
|
||||||
checkId: string;
|
checkId: string;
|
||||||
checkType: string;
|
checkType: string;
|
||||||
|
/** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */
|
||||||
|
sourceType: string;
|
||||||
|
/** 部分钉钉租户会额外返回考勤机名称或编号。 */
|
||||||
|
deviceName?: string;
|
||||||
|
deviceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 组织架构 API 类型 ──
|
// ── 组织架构 API 类型 ──
|
||||||
@@ -505,6 +510,8 @@ export class DingTalkService {
|
|||||||
checkType?: string; timeResult?: string;
|
checkType?: string; timeResult?: string;
|
||||||
locationResult?: string; locationMethod?: string;
|
locationResult?: string; locationMethod?: string;
|
||||||
userAddress?: string; userLongitude?: number; userLatitude?: number;
|
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}`);
|
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
|
||||||
@@ -520,7 +527,10 @@ export class DingTalkService {
|
|||||||
planCheckTime: '',
|
planCheckTime: '',
|
||||||
actualCheckTime: new Date(r.userCheckTime).toISOString(),
|
actualCheckTime: new Date(r.userCheckTime).toISOString(),
|
||||||
checkId: String(r.id),
|
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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
|||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
|
import {
|
||||||
|
createOccupancyImportTemplateWorkbook,
|
||||||
|
parseOccupancyImportWorksheet,
|
||||||
|
} from './occupancy-import-template';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('occupancies')
|
@Controller('occupancies')
|
||||||
@@ -240,68 +244,7 @@ export class OccupanciesController {
|
|||||||
@Get('template')
|
@Get('template')
|
||||||
@RequirePermission('occupancy:view')
|
@RequirePermission('occupancy:view')
|
||||||
async downloadTemplate(@Res() res: Response) {
|
async downloadTemplate(@Res() res: Response) {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = createOccupancyImportTemplateWorkbook();
|
||||||
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.getRow(1).font = { bold: true, size: 14 };
|
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Type',
|
'Content-Type',
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
@@ -324,47 +267,7 @@ export class OccupanciesController {
|
|||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(file.buffer as any);
|
await workbook.xlsx.load(file.buffer as any);
|
||||||
const ws = workbook.worksheets[0];
|
const ws = workbook.worksheets[0];
|
||||||
const rows: any[] = [];
|
const rows = parseOccupancyImportWorksheet(ws);
|
||||||
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 result = await this.service.batchImportCheckIn(rows, {
|
const result = await this.service.batchImportCheckIn(rows, {
|
||||||
autoDeposit: autoDeposit === 'true',
|
autoDeposit: autoDeposit === 'true',
|
||||||
depositAmount: depositAmount ? +depositAmount : undefined,
|
depositAmount: depositAmount ? +depositAmount : undefined,
|
||||||
|
|||||||
@@ -49,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -341,7 +341,12 @@ export class OccupanciesService {
|
|||||||
roomNumber: string;
|
roomNumber: string;
|
||||||
building?: string;
|
building?: string;
|
||||||
checkInDate: string;
|
checkInDate: string;
|
||||||
|
billingStartDate?: string;
|
||||||
checkOutDate?: string;
|
checkOutDate?: string;
|
||||||
|
bedNumber?: string;
|
||||||
|
lockerNumber?: string;
|
||||||
|
stayType?: string;
|
||||||
|
notes?: string;
|
||||||
}[],
|
}[],
|
||||||
options?: { autoDeposit?: boolean; depositAmount?: number },
|
options?: { autoDeposit?: boolean; depositAmount?: number },
|
||||||
) {
|
) {
|
||||||
@@ -450,14 +455,54 @@ export class OccupanciesService {
|
|||||||
continue;
|
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. 创建入住记录
|
// 6. 创建入住记录
|
||||||
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
|
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
|
||||||
const occData: any = {
|
const occData: any = {
|
||||||
studentId: student.id,
|
studentId: student.id,
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
checkInDate,
|
checkInDate,
|
||||||
billingStartDate: checkInDate,
|
billingStartDate: row.billingStartDate?.trim() || checkInDate,
|
||||||
|
stayType: row.stayType || undefined,
|
||||||
responsibleOrganizationId: student.organizationId || organization.id,
|
responsibleOrganizationId: student.organizationId || organization.id,
|
||||||
|
notes: row.notes || undefined,
|
||||||
|
bedId: bed?.id,
|
||||||
|
lockerId: locker?.id,
|
||||||
};
|
};
|
||||||
// 如果有退宿日期,直接记录
|
// 如果有退宿日期,直接记录
|
||||||
if (row.checkOutDate?.trim()) {
|
if (row.checkOutDate?.trim()) {
|
||||||
@@ -466,9 +511,13 @@ export class OccupanciesService {
|
|||||||
}
|
}
|
||||||
await this.repo.save(this.repo.create(occData));
|
await this.repo.save(this.repo.create(occData));
|
||||||
|
|
||||||
// 8. 更新宿舍状态
|
// 7. 更新床位、柜子和宿舍状态
|
||||||
if (!row.checkOutDate?.trim() && count + 1 >= room.capacity) {
|
if (!isHistoricalRecord) {
|
||||||
await this.roomRepo.update(room.id, { status: 'full' });
|
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. 自动收取押金(仅对新入住且非历史记录的学生)
|
// 9. 自动收取押金(仅对新入住且非历史记录的学生)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
224
apps/server/src/occupancies/occupancy-import-template.ts
Normal file
224
apps/server/src/occupancies/occupancy-import-template.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -42,7 +42,9 @@ describe('preset role permissions', () => {
|
|||||||
expect(accommodation.groups).toEqual(
|
expect(accommodation.groups).toEqual(
|
||||||
expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'deposit']),
|
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', () => {
|
it('keeps classroom rental operations separate from accommodation operations', () => {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export const PRESET_ROLES: Array<{
|
|||||||
'notification',
|
'notification',
|
||||||
'profile',
|
'profile',
|
||||||
],
|
],
|
||||||
extraPermissions: ['student:basic-view'],
|
extraPermissions: ['student:basic-view', 'organization:view'],
|
||||||
legacyNames: ['宿管老师', '宿管', '财务'],
|
legacyNames: ['宿管老师', '宿管', '财务'],
|
||||||
legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'],
|
legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'],
|
||||||
},
|
},
|
||||||
|
|||||||
136
apps/server/src/rooms/rooms.service.spec.ts
Normal file
136
apps/server/src/rooms/rooms.service.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||||
|
import { Bed } from '../entities/bed.entity';
|
||||||
|
import { Locker } from '../entities/locker.entity';
|
||||||
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
|
import { Room } from '../entities/room.entity';
|
||||||
|
import { RoomExpense } from '../entities/room-expense.entity';
|
||||||
|
import { RoomsService } from './rooms.service';
|
||||||
|
|
||||||
|
describe('RoomsService — capacity consistency', () => {
|
||||||
|
const createService = (options?: {
|
||||||
|
room?: Partial<Room>;
|
||||||
|
beds?: Partial<Bed>[];
|
||||||
|
activeOccupantCount?: number;
|
||||||
|
}) => {
|
||||||
|
const room = { id: 1, capacity: 2, status: 'full', ...options?.room } as Room;
|
||||||
|
const beds = (options?.beds ?? [
|
||||||
|
{ id: 1, roomId: 1, bedNumber: '1号床' },
|
||||||
|
{ id: 2, roomId: 1, bedNumber: '2号床' },
|
||||||
|
]) as Bed[];
|
||||||
|
|
||||||
|
const roomRepo = {
|
||||||
|
findOne: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(room)
|
||||||
|
.mockResolvedValue({ ...room, capacity: 4 }),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
} as unknown as Repository<Room>;
|
||||||
|
const bedRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue(beds),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(async (value) => value),
|
||||||
|
} as unknown as Repository<Bed>;
|
||||||
|
const occupancyRepo = {
|
||||||
|
count: jest.fn().mockResolvedValue(options?.activeOccupantCount ?? 2),
|
||||||
|
} as unknown as Repository<Occupancy>;
|
||||||
|
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn((entity) => {
|
||||||
|
if (entity === Room) return roomRepo;
|
||||||
|
if (entity === Bed) return bedRepo;
|
||||||
|
if (entity === Occupancy) return occupancyRepo;
|
||||||
|
throw new Error(`Unexpected repository: ${String(entity)}`);
|
||||||
|
}),
|
||||||
|
} as unknown as EntityManager;
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn(async (callback) => callback(manager)),
|
||||||
|
} as unknown as DataSource;
|
||||||
|
|
||||||
|
const service = new RoomsService(
|
||||||
|
roomRepo,
|
||||||
|
occupancyRepo,
|
||||||
|
{} as Repository<RoomExpense>,
|
||||||
|
bedRepo,
|
||||||
|
{} as Repository<Locker>,
|
||||||
|
dataSource,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, roomRepo, bedRepo, occupancyRepo };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('automatically creates missing beds when capacity increases', async () => {
|
||||||
|
const { service, roomRepo, bedRepo } = createService();
|
||||||
|
|
||||||
|
await service.update(1, { capacity: 4 });
|
||||||
|
|
||||||
|
expect(bedRepo.create).toHaveBeenNthCalledWith(1, { roomId: 1, bedNumber: '3号床' });
|
||||||
|
expect(bedRepo.create).toHaveBeenNthCalledWith(2, { roomId: 1, bedNumber: '4号床' });
|
||||||
|
expect(bedRepo.save).toHaveBeenCalledWith([
|
||||||
|
{ roomId: 1, bedNumber: '3号床' },
|
||||||
|
{ roomId: 1, bedNumber: '4号床' },
|
||||||
|
]);
|
||||||
|
expect(roomRepo.update).toHaveBeenCalledWith(1, { capacity: 4, status: 'available' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects capacity lower than the active occupant count', async () => {
|
||||||
|
const { service, roomRepo, bedRepo } = createService({
|
||||||
|
room: { capacity: 4, status: 'available' },
|
||||||
|
beds: [{ id: 1, roomId: 1, bedNumber: '1号床' }],
|
||||||
|
activeOccupantCount: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.update(1, { capacity: 2 })).rejects.toThrow(
|
||||||
|
new BadRequestException('额定人数不能少于当前入住人数,当前有 3 人入住'),
|
||||||
|
);
|
||||||
|
expect(roomRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(bedRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects capacity lower than the existing bed count', async () => {
|
||||||
|
const { service, roomRepo, bedRepo } = createService({
|
||||||
|
room: { capacity: 4, status: 'available' },
|
||||||
|
activeOccupantCount: 1,
|
||||||
|
beds: [
|
||||||
|
{ id: 1, roomId: 1, bedNumber: '1号床' },
|
||||||
|
{ id: 2, roomId: 1, bedNumber: '2号床' },
|
||||||
|
{ id: 3, roomId: 1, bedNumber: '3号床' },
|
||||||
|
{ id: 4, roomId: 1, bedNumber: '4号床' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.update(1, { capacity: 3 })).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'额定人数不能少于现有床位数,当前有 4 张床位,请先删除多余的空闲床位',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(roomRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(bedRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the room full when a valid capacity reduction reaches the occupant count', async () => {
|
||||||
|
const { service, roomRepo, bedRepo } = createService({
|
||||||
|
room: { capacity: 4, status: 'available' },
|
||||||
|
activeOccupantCount: 2,
|
||||||
|
beds: [
|
||||||
|
{ id: 1, roomId: 1, bedNumber: '1号床' },
|
||||||
|
{ id: 2, roomId: 1, bedNumber: '2号床' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.update(1, { capacity: 2 });
|
||||||
|
|
||||||
|
expect(roomRepo.update).toHaveBeenCalledWith(1, { capacity: 2, status: 'full' });
|
||||||
|
expect(bedRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates other room fields without changing beds', async () => {
|
||||||
|
const { service, roomRepo, bedRepo, occupancyRepo } = createService();
|
||||||
|
|
||||||
|
await service.update(1, { building: '2号楼' });
|
||||||
|
|
||||||
|
expect(roomRepo.update).toHaveBeenCalledWith(1, { building: '2号楼' });
|
||||||
|
expect(bedRepo.find).not.toHaveBeenCalled();
|
||||||
|
expect(occupancyRepo.count).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, Like, IsNull, Not, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
import {
|
||||||
|
DataSource,
|
||||||
|
Repository,
|
||||||
|
Like,
|
||||||
|
IsNull,
|
||||||
|
Not,
|
||||||
|
In,
|
||||||
|
LessThanOrEqual,
|
||||||
|
MoreThanOrEqual,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
import { Room } from '../entities/room.entity';
|
import { Room } from '../entities/room.entity';
|
||||||
import { Occupancy } from '../entities/occupancy.entity';
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
@@ -19,6 +28,7 @@ export class RoomsService {
|
|||||||
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
||||||
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
|
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
|
||||||
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
||||||
|
private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,13 +120,60 @@ export class RoomsService {
|
|||||||
roomType: dto.roomType ?? parsed.roomType,
|
roomType: dto.roomType ?? parsed.roomType,
|
||||||
capacity: dto.capacity ?? parsed.capacity,
|
capacity: dto.capacity ?? parsed.capacity,
|
||||||
});
|
});
|
||||||
return this.repo.save(entity);
|
const room = await this.repo.save(entity);
|
||||||
|
await this.createDefaultBeds(room.id, room.capacity);
|
||||||
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, dto: UpdateRoomDto) {
|
async update(id: number, dto: UpdateRoomDto) {
|
||||||
await this.findOne(id);
|
return this.dataSource.transaction(async (manager) => {
|
||||||
await this.repo.update(id, dto);
|
const roomRepo = manager.getRepository(Room);
|
||||||
return this.repo.findOne({ where: { id } });
|
const bedRepo = manager.getRepository(Bed);
|
||||||
|
const occupancyRepo = manager.getRepository(Occupancy);
|
||||||
|
const room = await roomRepo.findOne({ where: { id } });
|
||||||
|
if (!room) throw new NotFoundException('宿舍不存在');
|
||||||
|
|
||||||
|
if (dto.capacity !== undefined) {
|
||||||
|
const [beds, activeOccupantCount] = await Promise.all([
|
||||||
|
bedRepo.find({ where: { roomId: id }, order: { bedNumber: 'ASC' } }),
|
||||||
|
occupancyRepo.count({ where: { roomId: id, checkOutDate: IsNull() } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (dto.capacity < activeOccupantCount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`额定人数不能少于当前入住人数,当前有 ${activeOccupantCount} 人入住`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dto.capacity < beds.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`额定人数不能少于现有床位数,当前有 ${beds.length} 张床位,请先删除多余的空闲床位`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.capacity > beds.length) {
|
||||||
|
const countToCreate = dto.capacity - beds.length;
|
||||||
|
const start = this.getNextBedNumber(beds);
|
||||||
|
const newBeds = Array.from({ length: countToCreate }, (_, index) =>
|
||||||
|
bedRepo.create({ roomId: id, bedNumber: `${start + index}号床` }),
|
||||||
|
);
|
||||||
|
await bedRepo.save(newBeds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
dto.status === undefined &&
|
||||||
|
room.status !== 'maintenance' &&
|
||||||
|
room.status !== 'archived'
|
||||||
|
) {
|
||||||
|
dto = {
|
||||||
|
...dto,
|
||||||
|
status: activeOccupantCount >= dto.capacity ? 'full' : 'available',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await roomRepo.update(id, dto);
|
||||||
|
return roomRepo.findOne({ where: { id } });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number) {
|
async remove(id: number) {
|
||||||
@@ -312,7 +369,7 @@ export class RoomsService {
|
|||||||
}
|
}
|
||||||
// 智能解析房间号
|
// 智能解析房间号
|
||||||
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
|
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
|
||||||
await this.repo.save(
|
const room = await this.repo.save(
|
||||||
this.repo.create({
|
this.repo.create({
|
||||||
roomNumber: row.roomNumber.trim(),
|
roomNumber: row.roomNumber.trim(),
|
||||||
building: row.building?.trim() || parsed.building || undefined,
|
building: row.building?.trim() || parsed.building || undefined,
|
||||||
@@ -323,6 +380,7 @@ export class RoomsService {
|
|||||||
monthlyRate: row.monthlyRate ?? undefined,
|
monthlyRate: row.monthlyRate ?? undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
await this.createDefaultBeds(room.id, room.capacity);
|
||||||
imported++;
|
imported++;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -353,6 +411,7 @@ export class RoomsService {
|
|||||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||||
if (!room) throw new NotFoundException('宿舍不存在');
|
if (!room) throw new NotFoundException('宿舍不存在');
|
||||||
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
|
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
|
||||||
|
await this.assertCanAddBeds(room, 1);
|
||||||
const existing = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } });
|
const existing = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } });
|
||||||
if (existing) throw new BadRequestException('该床位编号已存在');
|
if (existing) throw new BadRequestException('该床位编号已存在');
|
||||||
const bed = this.bedRepo.create({ ...dto, roomId });
|
const bed = this.bedRepo.create({ ...dto, roomId });
|
||||||
@@ -387,6 +446,7 @@ export class RoomsService {
|
|||||||
if (!room) throw new NotFoundException('宿舍不存在');
|
if (!room) throw new NotFoundException('宿舍不存在');
|
||||||
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
|
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
|
||||||
const existing = await this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } });
|
const existing = await this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } });
|
||||||
|
this.assertCanAddBedsFromCount(room, existing.length, dto.count);
|
||||||
const numbers = existing.map((b) => {
|
const numbers = existing.map((b) => {
|
||||||
const match = b.bedNumber.match(/^\d+/);
|
const match = b.bedNumber.match(/^\d+/);
|
||||||
return match ? parseInt(match[0]) : 0;
|
return match ? parseInt(match[0]) : 0;
|
||||||
@@ -399,6 +459,37 @@ export class RoomsService {
|
|||||||
return this.bedRepo.save(beds);
|
return this.bedRepo.save(beds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async createDefaultBeds(roomId: number, capacity: number): Promise<void> {
|
||||||
|
const count = Math.max(capacity ?? 0, 0);
|
||||||
|
if (count === 0) return;
|
||||||
|
const beds = Array.from({ length: count }, (_, index) =>
|
||||||
|
this.bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }),
|
||||||
|
);
|
||||||
|
await this.bedRepo.save(beds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNextBedNumber(beds: Pick<Bed, 'bedNumber'>[]): number {
|
||||||
|
const numbers = beds.map((bed) => {
|
||||||
|
const match = bed.bedNumber.match(/^\d+/);
|
||||||
|
return match ? parseInt(match[0], 10) : 0;
|
||||||
|
});
|
||||||
|
return numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanAddBeds(room: Room, count: number): Promise<void> {
|
||||||
|
const existingCount = await this.bedRepo.count({ where: { roomId: room.id } });
|
||||||
|
this.assertCanAddBedsFromCount(room, existingCount, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void {
|
||||||
|
const remaining = Math.max((room.capacity ?? 0) - existingCount, 0);
|
||||||
|
if (count > remaining) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining} 张`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 柜子管理 ──
|
// ── 柜子管理 ──
|
||||||
|
|
||||||
async getRoomLockers(roomId: number): Promise<Locker[]> {
|
async getRoomLockers(roomId: number): Promise<Locker[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user