forked from wangziqi/gongxue-base
Compare commits
23 Commits
codex/xyx
...
c86550f894
| Author | SHA1 | Date | |
|---|---|---|---|
| c86550f894 | |||
| 17a5046ea0 | |||
| e45da7f998 | |||
| c75a08affe | |||
| b480070e69 | |||
| 598b4e8acd | |||
| eac336a54a | |||
| ce5fd1c6cb | |||
| 05a936bbc2 | |||
| 3adf4933d8 | |||
| d84f37e98f | |||
| 16b56ffcd5 | |||
| 718c58589f | |||
| aaf49d5580 | |||
| 5cf6aede1e | |||
| 811e7ce826 | |||
| 79fa472b78 | |||
| 029af37f3a | |||
| d572e984d2 | |||
| a93ba657a8 | |||
| 77714642a5 | |||
| e7aa202603 | |||
| 013b3f4afe |
@@ -14,6 +14,7 @@ const RoomsPage = lazy(() => import('./pages/Rooms'));
|
||||
const OccupanciesPage = lazy(() => import('./pages/Occupancies'));
|
||||
const ExpensesPage = lazy(() => import('./pages/Expenses'));
|
||||
const BillsPage = lazy(() => import('./pages/Bills'));
|
||||
const WalletsPage = lazy(() => import('./pages/Wallets'));
|
||||
const RoomVisualPage = lazy(() => import('./pages/RoomVisual'));
|
||||
const OperationLogsPage = lazy(() => import('./pages/OperationLogs'));
|
||||
const UsersPage = lazy(() => import('./pages/Users'));
|
||||
@@ -134,6 +135,14 @@ const App: React.FC = () => {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wallets"
|
||||
element={
|
||||
<PermissionRoute permission="wallet:view">
|
||||
<WalletsPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="bills"
|
||||
element={
|
||||
|
||||
@@ -73,6 +73,7 @@ const SECTIONS: MenuSection[] = [
|
||||
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
|
||||
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
|
||||
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
|
||||
{ key: '/wallets', label: '学生余额', icon: 'wallet', permission: 'wallet:view' },
|
||||
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
|
||||
],
|
||||
},
|
||||
@@ -111,8 +112,9 @@ export function getRoleDomains(roles: readonly string[], permissions: readonly s
|
||||
normalized.add('academic');
|
||||
}
|
||||
if (
|
||||
permissions.includes('room:view') &&
|
||||
(permissions.includes('occupancy:view') || permissions.includes('expense:view'))
|
||||
(permissions.includes('room:view') &&
|
||||
(permissions.includes('occupancy:view') || permissions.includes('expense:view'))) ||
|
||||
permissions.includes('wallet:view')
|
||||
) {
|
||||
normalized.add('accommodation');
|
||||
}
|
||||
|
||||
@@ -105,6 +105,20 @@ interface AttachmentRecord {
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
interface AttendanceRecordItem {
|
||||
id: number;
|
||||
attendanceDate: string;
|
||||
session: string;
|
||||
status: string;
|
||||
source?: string;
|
||||
remark?: string | null;
|
||||
punchTime?: string | null;
|
||||
punchDeviceName?: string | null;
|
||||
punchDeviceId?: string | null;
|
||||
schedule?: { subject?: string } | null;
|
||||
class?: { name?: string } | null;
|
||||
}
|
||||
|
||||
interface StudentProfileAggregate {
|
||||
student: StudentInfo;
|
||||
profile: ProfileData | null;
|
||||
@@ -113,6 +127,7 @@ interface StudentProfileAggregate {
|
||||
learningRecords: LearningRecord[];
|
||||
result: ResultData | null;
|
||||
attachments: AttachmentRecord[];
|
||||
attendances: AttendanceRecordItem[];
|
||||
}
|
||||
|
||||
export interface StudentProfileContentProps {
|
||||
@@ -178,6 +193,58 @@ const formatFileSize = (bytes: number): string => {
|
||||
|
||||
// ---- Tab Components ----
|
||||
|
||||
const ATTENDANCE_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
present: { text: '出勤', color: 'green' },
|
||||
late: { text: '迟到', color: 'orange' },
|
||||
absent: { text: '缺勤', color: 'red' },
|
||||
leave: { text: '请假', color: 'blue' },
|
||||
pending: { text: '待确认', color: 'default' },
|
||||
};
|
||||
|
||||
const SESSION_LABELS: Record<string, string> = {
|
||||
morning_reading: '早自习',
|
||||
morning: '上午',
|
||||
afternoon: '下午',
|
||||
evening_study: '晚自习',
|
||||
night_check: '晚寝',
|
||||
};
|
||||
|
||||
const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => {
|
||||
const columns: ColumnsType<AttendanceRecordItem> = [
|
||||
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
|
||||
{ title: '课程', render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤' },
|
||||
{ title: '时段', dataIndex: 'session', width: 100, render: (value: string) => SESSION_LABELS[value] || value || '-' },
|
||||
{
|
||||
title: '结果', dataIndex: 'status', width: 90,
|
||||
render: (value: string) => {
|
||||
const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' };
|
||||
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '打卡时间', dataIndex: 'punchTime', width: 170, render: (value?: string | null) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-' },
|
||||
{
|
||||
title: '打卡设备',
|
||||
render: (_: unknown, record) => {
|
||||
const name = record.punchDeviceName?.trim();
|
||||
const id = record.punchDeviceId?.trim();
|
||||
if (name && id && name !== id) return `${name}(${id})`;
|
||||
return name || id || (record.source === 'manual' ? '老师手动标记' : '-');
|
||||
},
|
||||
},
|
||||
{ title: '备注', dataIndex: 'remark', render: (value?: string | null) => value || '-' },
|
||||
];
|
||||
|
||||
return data.length > 0 ? (
|
||||
<Table<AttendanceRecordItem>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }}
|
||||
/>
|
||||
) : <Empty description="暂无出勤记录" />;
|
||||
};
|
||||
|
||||
interface TabProps {
|
||||
studentId: number;
|
||||
onRefresh: () => void;
|
||||
@@ -779,7 +846,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
|
||||
const tabItems = useMemo(() => {
|
||||
if (!aggregateData) return [];
|
||||
const { profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData;
|
||||
const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } = aggregateData;
|
||||
return [
|
||||
{
|
||||
key: 'profile',
|
||||
@@ -807,8 +874,8 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
},
|
||||
{
|
||||
key: 'attendance',
|
||||
label: '出勤记录',
|
||||
children: <Empty description="暂无出勤记录" />,
|
||||
label: `出勤记录 (${attendances.length})`,
|
||||
children: <AttendanceTab data={attendances} />,
|
||||
},
|
||||
{
|
||||
key: 'learning',
|
||||
|
||||
@@ -51,6 +51,7 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
expense: <DollarOutlined />,
|
||||
bill: <FileTextOutlined />,
|
||||
deposit: <WalletOutlined />,
|
||||
wallet: <WalletOutlined />,
|
||||
classroom: <ReadOutlined />,
|
||||
rental: <FileProtectOutlined />,
|
||||
organization: <TagsOutlined />,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canPullAttendance,
|
||||
getAttendanceExperience,
|
||||
getPunchDisplayInfo,
|
||||
getSchedulePhase,
|
||||
summarizeAttendance,
|
||||
summarizeLessonCheckins,
|
||||
@@ -65,3 +66,34 @@ describe('lesson check-in summary', () => {
|
||||
).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('lesson punch device display', () => {
|
||||
it('labels attendance machine punches with the machine name and id', () => {
|
||||
expect(
|
||||
getPunchDisplayInfo({
|
||||
status: 'present',
|
||||
source: 'dingtalk',
|
||||
punchSource: 'ATM',
|
||||
punchDeviceName: '东门考勤机',
|
||||
punchDeviceId: 'ATM-01',
|
||||
punchTime: '2026-07-11T00:55:00.000Z',
|
||||
}),
|
||||
).toEqual({
|
||||
label: '考勤机打卡',
|
||||
machine: true,
|
||||
detail: '东门考勤机(ATM-01)',
|
||||
time: '2026-07-11T00:55:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('distinguishes mobile punches and manual teacher markings', () => {
|
||||
expect(
|
||||
getPunchDisplayInfo({ status: 'present', source: 'dingtalk', punchSource: 'USER' }),
|
||||
).toEqual({ label: '手机打卡', machine: false, detail: undefined, time: undefined });
|
||||
expect(getPunchDisplayInfo({ status: 'present', source: 'manual' })).toEqual({
|
||||
label: '老师手动标记',
|
||||
machine: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,3 +82,56 @@ export function summarizeLessonCheckins(
|
||||
notCheckedIn: records.length - checkedIn,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export interface PunchDisplayRecord {
|
||||
status: string;
|
||||
source?: string;
|
||||
punchTime?: string | null;
|
||||
punchSource?: string | null;
|
||||
punchDeviceName?: string | null;
|
||||
punchDeviceId?: string | null;
|
||||
}
|
||||
|
||||
export interface PunchDisplayInfo {
|
||||
label: string;
|
||||
machine: boolean;
|
||||
detail?: string;
|
||||
time?: string;
|
||||
}
|
||||
|
||||
export function getPunchDisplayInfo(record: PunchDisplayRecord): PunchDisplayInfo | null {
|
||||
if (record.status !== 'present' && record.status !== 'late') return null;
|
||||
if (record.source === 'manual') return { label: '老师手动标记', machine: false };
|
||||
|
||||
const source = (record.punchSource || '').trim().toUpperCase();
|
||||
const machine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some(
|
||||
(value) => source === value || source.includes(value),
|
||||
);
|
||||
const label = machine
|
||||
? '考勤机打卡'
|
||||
: source === 'USER'
|
||||
? '手机打卡'
|
||||
: source.includes('BEACON') || source.includes('BLE')
|
||||
? '蓝牙打卡'
|
||||
: source.includes('WIFI')
|
||||
? 'Wi-Fi 打卡'
|
||||
: source.includes('APPROVE')
|
||||
? '审批补卡'
|
||||
: source
|
||||
? `其他打卡(${record.punchSource})`
|
||||
: '打卡来源未知';
|
||||
const device = record.punchDeviceName?.trim();
|
||||
const deviceId = record.punchDeviceId?.trim();
|
||||
const detail = device
|
||||
? deviceId && deviceId !== device
|
||||
? `${device}(${deviceId})`
|
||||
: device
|
||||
: deviceId || undefined;
|
||||
return {
|
||||
label,
|
||||
machine,
|
||||
detail,
|
||||
time: record.punchTime || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -502,3 +502,25 @@
|
||||
.attendance-marking-actions .ant-btn {
|
||||
min-width: 54px;
|
||||
}
|
||||
|
||||
|
||||
.punch-device-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.punch-device-cell .ant-tag {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.punch-device-cell strong {
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.punch-device-cell span {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import { message } from '../../ui/app-message';
|
||||
import {
|
||||
canPullAttendance,
|
||||
getAttendanceExperience,
|
||||
getPunchDisplayInfo,
|
||||
getSchedulePhase,
|
||||
summarizeLessonCheckins,
|
||||
type AttendanceSummary,
|
||||
@@ -96,6 +97,10 @@ interface AttendanceRecordItem {
|
||||
class: { id: number; name: string } | null;
|
||||
scheduleId?: number | null;
|
||||
attendanceSessionId?: number | null;
|
||||
punchTime?: string | null;
|
||||
punchSource?: string | null;
|
||||
punchDeviceName?: string | null;
|
||||
punchDeviceId?: string | null;
|
||||
}
|
||||
|
||||
interface AssignedClass {
|
||||
@@ -288,6 +293,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
`/attendance-lessons/schedules/${schedule.id}/pull`,
|
||||
{ date: today },
|
||||
);
|
||||
setSelectedSchedule(data.schedule);
|
||||
setLessonSession(data.session);
|
||||
setLessonRecords(data.records);
|
||||
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
||||
@@ -386,7 +392,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={820} title={null} className="attendance-drawer">
|
||||
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
|
||||
<div className="lesson-record-header">
|
||||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||||
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
||||
@@ -438,6 +444,21 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
},
|
||||
},
|
||||
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
|
||||
{
|
||||
title: '打卡设备',
|
||||
width: 220,
|
||||
render: (_: unknown, record: AttendanceRecordItem) => {
|
||||
const info = getPunchDisplayInfo(record);
|
||||
if (!info) return <span className="muted-text">—</span>;
|
||||
return (
|
||||
<div className="punch-device-cell">
|
||||
<Tag color={info.machine ? 'green' : 'blue'}>{info.label}</Tag>
|
||||
{info.detail && <strong>{info.detail}</strong>}
|
||||
{info.time && <span>{dayjs(info.time).format('HH:mm:ss')}</span>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">—</span> },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Popconfirm,
|
||||
Input,
|
||||
Select,
|
||||
Tooltip,
|
||||
Spin,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
@@ -26,12 +25,12 @@ import PermissionButton from '../../components/PermissionButton';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
draft: { text: '草稿', color: 'default' },
|
||||
confirmed: { text: '已确认', color: 'blue' },
|
||||
unpaid: { text: '待支付', color: 'orange' },
|
||||
partially_paid: { text: '部分支付', color: 'gold' },
|
||||
paid: { text: '已支付', color: 'green' },
|
||||
cancelled: { text: '已取消', color: 'default' },
|
||||
};
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
@@ -93,8 +92,7 @@ const BillsPage: React.FC = () => {
|
||||
const values = await generateForm.validateFields();
|
||||
try {
|
||||
const res: any = await api.post('/bills/generate', {
|
||||
periodStart: values.period[0].format('YYYY-MM-DD'),
|
||||
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
||||
billingMonth: values.billingMonth.format('YYYY-MM'),
|
||||
});
|
||||
message.success(res.message || '生成成功');
|
||||
setGenerateModal(false);
|
||||
@@ -119,43 +117,29 @@ const BillsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (id: number, status: string) => {
|
||||
try {
|
||||
await api.put(`/bills/${id}/status`, { status });
|
||||
message.success('状态更新成功');
|
||||
fetchData();
|
||||
if (detailModal?.id === id) {
|
||||
setDetailModal({ ...detailModal, status });
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const batchUpdateStatus = async (status: string) => {
|
||||
if (selectedRows.length === 0) return message.warning('请先选择账单');
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
await api.put('/bills/batch/status', { ids: selectedRows, status });
|
||||
message.success(`已批量更新 ${selectedRows.length} 条账单`);
|
||||
setSelectedRows([]);
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
|
||||
const handleCancel = async (id: number) => {
|
||||
let reason = '';
|
||||
Modal.confirm({
|
||||
title: '取消账单并退回已扣余额',
|
||||
content: <Input.TextArea placeholder="请输入取消原因" maxLength={300} onChange={(event) => { reason = event.target.value; }} />,
|
||||
okText: '确认取消', cancelText: '返回',
|
||||
onOk: async () => {
|
||||
if (!reason.trim()) { message.error('请输入取消原因'); throw new Error('reason required'); }
|
||||
await api.post(`/bills/${id}/cancel`, { reason: reason.trim() });
|
||||
message.success('账单已取消,已扣余额已冲正退回');
|
||||
fetchData();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/bills/${id}`);
|
||||
message.success('账单已删除');
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
} catch (error: any) { message.error(error?.message || '删除失败'); }
|
||||
};
|
||||
|
||||
const batchDelete = async () => {
|
||||
@@ -212,31 +196,16 @@ const BillsPage: React.FC = () => {
|
||||
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
|
||||
},
|
||||
{
|
||||
title: '可用押金',
|
||||
dataIndex: 'availableDeposit',
|
||||
width: 120,
|
||||
render: (v: number) =>
|
||||
v > 0 ? (
|
||||
<span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>-</span>
|
||||
),
|
||||
title: '已扣余额', dataIndex: 'paidAmount', width: 110,
|
||||
render: (value: number) => <span style={{ color: '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</span>,
|
||||
},
|
||||
{
|
||||
title: '抵扣后应付',
|
||||
dataIndex: 'amountAfterDeposit',
|
||||
width: 130,
|
||||
render: (v: number, r: any) => {
|
||||
const has = Number(r.availableDeposit || 0) > 0;
|
||||
if (!has) return <span style={{ color: '#999' }}>-</span>;
|
||||
const after = Number(v ?? r.totalAmount).toFixed(2);
|
||||
const applied = Number(r.depositApplied || 0).toFixed(2);
|
||||
return (
|
||||
<Tooltip title={`已抵扣押金 ¥${applied}`}>
|
||||
<strong style={{ color: '#fa541c' }}>¥{after}</strong>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
title: '待补缴', dataIndex: 'outstandingAmount', width: 110,
|
||||
render: (value: number) => <strong style={{ color: Number(value) > 0 ? '#cf1322' : '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</strong>,
|
||||
},
|
||||
{
|
||||
title: '钱包余额', dataIndex: 'walletBalance', width: 110,
|
||||
render: (value: number) => `¥${Number(value || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
@@ -263,25 +232,6 @@ const BillsPage: React.FC = () => {
|
||||
>
|
||||
详情
|
||||
</PermissionButton>
|
||||
{record.status === 'draft' && (
|
||||
<PermissionButton
|
||||
permission="bill:confirm"
|
||||
size="small"
|
||||
onClick={() => updateStatus(record.id, 'confirmed')}
|
||||
>
|
||||
确认
|
||||
</PermissionButton>
|
||||
)}
|
||||
{record.status === 'confirmed' && (
|
||||
<PermissionButton
|
||||
permission="bill:confirm"
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => updateStatus(record.id, 'paid')}
|
||||
>
|
||||
标记已付
|
||||
</PermissionButton>
|
||||
)}
|
||||
<PermissionButton
|
||||
permission="bill:export-pdf"
|
||||
size="small"
|
||||
@@ -290,20 +240,20 @@ const BillsPage: React.FC = () => {
|
||||
>
|
||||
PDF
|
||||
</PermissionButton>
|
||||
<Popconfirm
|
||||
title="确定删除此账单?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton permission="bill:delete" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
{record.status !== 'cancelled' && (
|
||||
<PermissionButton permission="bill:delete" size="small" danger onClick={() => handleCancel(record.id)}>
|
||||
取消并冲正
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && (
|
||||
<Popconfirm title="确定删除此未支付账单?" onConfirm={() => handleDelete(record.id)} okText="删除" cancelText="取消">
|
||||
<PermissionButton permission="bill:delete" size="small" danger icon={<DeleteOutlined />}>删除</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [showDetail, updateStatus, handleDelete, handleExportPdf]);
|
||||
], [showDetail, handleDelete, handleCancel, handleExportPdf]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -333,28 +283,14 @@ const BillsPage: React.FC = () => {
|
||||
value={filterStatus}
|
||||
onChange={(v) => setFilterStatus(v)}
|
||||
options={[
|
||||
{ value: 'draft', label: '草稿' },
|
||||
{ value: 'confirmed', label: '已确认' },
|
||||
{ value: 'unpaid', label: '待支付' },
|
||||
{ value: 'partially_paid', label: '部分支付' },
|
||||
{ value: 'paid', label: '已支付' },
|
||||
{ value: 'cancelled', label: '已取消' },
|
||||
]}
|
||||
/>
|
||||
<Select placeholder="费用类型" allowClear style={{ width: 120 }} value={filterExpenseType} onChange={setFilterExpenseType}
|
||||
options={[{value:'water',label:'水费'},{value:'electricity',label:'电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} />
|
||||
<PermissionButton
|
||||
permission="bill:confirm"
|
||||
onClick={() => batchUpdateStatus('confirmed')}
|
||||
disabled={selectedRows.length === 0}
|
||||
>
|
||||
批量确认
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="bill:confirm"
|
||||
type="primary"
|
||||
onClick={() => batchUpdateStatus('paid')}
|
||||
disabled={selectedRows.length === 0}
|
||||
>
|
||||
批量标记已付
|
||||
</PermissionButton>
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedRows.length} 条账单?`}
|
||||
onConfirm={batchDelete}
|
||||
@@ -399,12 +335,7 @@ const BillsPage: React.FC = () => {
|
||||
dataSource={filteredBills}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedRows,
|
||||
@@ -422,15 +353,17 @@ const BillsPage: React.FC = () => {
|
||||
>
|
||||
<Form form={generateForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="period"
|
||||
label="账单周期"
|
||||
rules={[{ required: true, message: '请选择账单周期' }]}
|
||||
extra="选择费用对应的时间段,系统将自动计算每个学生的分摊费用"
|
||||
name="billingMonth"
|
||||
label="账单月份"
|
||||
rules={[{ required: true, message: '请选择账单月份' }]}
|
||||
extra="只能选择已结束月份,每个月只能生成一次账单"
|
||||
>
|
||||
<RangePicker
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
format="YYYY-MM-DD"
|
||||
picker="month"
|
||||
placeholder="选择月份"
|
||||
format="YYYY-MM"
|
||||
disabledDate={(current) => !!current && !current.endOf('month').isBefore(dayjs(), 'day')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -470,42 +403,11 @@ const BillsPage: React.FC = () => {
|
||||
</strong>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{Number(detailModal.availableDeposit || 0) > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>
|
||||
押金联动(不影响实际押金状态,仅作收款参考)
|
||||
</div>
|
||||
<Space size={24} wrap>
|
||||
<span>
|
||||
当前可用押金:
|
||||
<strong style={{ color: '#52c41a' }}>
|
||||
¥{Number(detailModal.availableDeposit).toFixed(2)}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
本账单可抵扣:
|
||||
<strong style={{ color: '#fa8c16' }}>
|
||||
-¥{Number(detailModal.depositApplied || 0).toFixed(2)}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
抵扣后实付:
|
||||
<strong style={{ color: '#fa541c', fontSize: 16 }}>
|
||||
¥
|
||||
{Number(detailModal.amountAfterDeposit ?? detailModal.totalAmount).toFixed(2)}
|
||||
</strong>
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
<Descriptions bordered size="small" column={3} style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="已扣余额">¥{Number(detailModal.paidAmount || 0).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="待补缴">¥{Number(detailModal.outstandingAmount || 0).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="当前钱包余额">¥{Number(detailModal.walletBalance || 0).toFixed(2)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<h4>费用明细</h4>
|
||||
<Table
|
||||
scroll={{ x: 700 }}
|
||||
|
||||
@@ -39,6 +39,7 @@ interface ClassScheduleItem {
|
||||
weekDay: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
attendanceAdvanceMinutes: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
subject: string;
|
||||
@@ -353,6 +354,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
|
||||
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
|
||||
{ title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` },
|
||||
{ title: '签到窗口', render: (_: unknown, r: ClassScheduleItem) => `课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课` },
|
||||
{ title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` },
|
||||
{ title: '科目', dataIndex: 'subject' },
|
||||
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDepositStudentOption } from './deposit-student-option';
|
||||
import { buildDepositStudentOption, buildDepositStudentOptions } from './deposit-student-option';
|
||||
|
||||
describe('deposit student option', () => {
|
||||
it('uses the student number as the non-sensitive identifier', () => {
|
||||
@@ -17,4 +17,13 @@ describe('deposit student option', () => {
|
||||
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,
|
||||
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
|
||||
});
|
||||
|
||||
export const buildDepositStudentOptions = (students: DepositStudentLookup[]) =>
|
||||
students.map(buildDepositStudentOption);
|
||||
|
||||
@@ -19,13 +19,12 @@ import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
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 }> = {
|
||||
paid: { text: '已缴', color: 'green' },
|
||||
paid: { text: '有余额', color: 'green' },
|
||||
refunded: { text: '已全退', color: 'blue' },
|
||||
partial_refund: { text: '部分退还', color: 'orange' },
|
||||
deducted: { text: '已全扣', color: 'red' },
|
||||
depleted: { text: '已扣完', color: 'red' },
|
||||
};
|
||||
|
||||
const installmentStatusMap: Record<string, { text: string; color: string }> = {
|
||||
@@ -38,6 +37,8 @@ const isFormValidationError = (error: unknown) =>
|
||||
&& error !== null
|
||||
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
|
||||
|
||||
const moneyNumber = (value: unknown) => Number(Number(value || 0).toFixed(2));
|
||||
|
||||
const DepositsPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [students, setStudents] = useState<any[]>([]);
|
||||
@@ -86,10 +87,7 @@ const DepositsPage: React.FC = () => {
|
||||
}, [data, searchText, filterStatus]);
|
||||
|
||||
const studentOptions = useMemo(
|
||||
() =>
|
||||
students
|
||||
.filter((s: any) => s.status === 'active')
|
||||
.map(buildDepositStudentOption),
|
||||
() => buildDepositStudentOptions(students),
|
||||
[students],
|
||||
);
|
||||
|
||||
@@ -103,7 +101,7 @@ const DepositsPage: React.FC = () => {
|
||||
paidDate: values.paidDate.format('YYYY-MM-DD'),
|
||||
notes: values.notes,
|
||||
});
|
||||
message.success('押金记录已创建');
|
||||
message.success('押金金额已增加');
|
||||
setCreateModal(false);
|
||||
createForm.resetFields();
|
||||
fetchData();
|
||||
@@ -122,8 +120,6 @@ const DepositsPage: React.FC = () => {
|
||||
const values = await refundForm.validateFields();
|
||||
await api.put(`/deposits/${refundModal.id}/refund`, {
|
||||
refundDate: values.refundDate.format('YYYY-MM-DD'),
|
||||
deductionAmount: values.deductionAmount || 0,
|
||||
deductionReason: values.deductionReason,
|
||||
notes: values.notes,
|
||||
});
|
||||
message.success('退还操作完成');
|
||||
@@ -183,24 +179,13 @@ const DepositsPage: React.FC = () => {
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{ title: '押金金额', dataIndex: 'amount', width: 110, render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||
{ title: '缴纳日期', dataIndex: 'paidDate', width: 110 },
|
||||
{ title: '当前可用押金', dataIndex: 'amount', width: 130, render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||
{ title: '最近收取日期', dataIndex: 'paidDate', width: 120 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '退还金额',
|
||||
dataIndex: 'refundAmount',
|
||||
render: (v: any) => (v != null ? `¥${Number(v).toFixed(2)}` : '-'),
|
||||
},
|
||||
{
|
||||
title: '扣除金额',
|
||||
dataIndex: 'deductionAmount',
|
||||
render: (v: any) => (v > 0 ? `¥${Number(v).toFixed(2)}` : '-'),
|
||||
},
|
||||
{ title: '扣除原因', dataIndex: 'deductionReason', width: 120, render: (v: any) => v || '-' },
|
||||
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: any) => v || '-' },
|
||||
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: any) => v || '-' },
|
||||
{
|
||||
@@ -225,7 +210,7 @@ const DepositsPage: React.FC = () => {
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setRefundModal(record);
|
||||
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 });
|
||||
refundForm.setFieldsValue({ refundDate: dayjs() });
|
||||
}}
|
||||
>
|
||||
退还
|
||||
@@ -288,10 +273,9 @@ const DepositsPage: React.FC = () => {
|
||||
value={filterStatus}
|
||||
onChange={(v) => setFilterStatus(v)}
|
||||
options={[
|
||||
{ value: 'paid', label: '已缴' },
|
||||
{ value: 'paid', label: '有余额' },
|
||||
{ value: 'refunded', label: '已全退' },
|
||||
{ value: 'partial_refund', label: '部分退还' },
|
||||
{ value: 'deducted', label: '已全扣' },
|
||||
{ value: 'depleted', label: '已扣完' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
@@ -345,11 +329,11 @@ const DepositsPage: React.FC = () => {
|
||||
options={studentOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="押金金额(元)" rules={[{ required: true }]}>
|
||||
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="paidDate" label="缴纳日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择缴纳日期" format="YYYY-MM-DD" />
|
||||
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择收取日期" format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
@@ -368,22 +352,42 @@ const DepositsPage: React.FC = () => {
|
||||
>
|
||||
<Form form={refundForm} layout="vertical">
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
押金金额: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
|
||||
<div>
|
||||
当前可用押金:{' '}
|
||||
<strong>¥{moneyNumber(refundModal?.amount).toFixed(2)}</strong>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
未出账个人附加费:{' '}
|
||||
<strong>¥{moneyNumber(refundModal?.personalExpenseAmount).toFixed(2)}</strong>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
自动扣除:{' '}
|
||||
<strong style={{ color: '#fa8c16' }}>
|
||||
¥
|
||||
{Math.min(
|
||||
moneyNumber(refundModal?.amount),
|
||||
moneyNumber(refundModal?.personalExpenseAmount),
|
||||
).toFixed(2)}
|
||||
</strong>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
实际退还:{' '}
|
||||
<strong style={{ color: '#52c41a' }}>
|
||||
¥
|
||||
{Math.max(
|
||||
0,
|
||||
moneyNumber(refundModal?.amount) -
|
||||
Math.min(
|
||||
moneyNumber(refundModal?.amount),
|
||||
moneyNumber(refundModal?.personalExpenseAmount),
|
||||
),
|
||||
).toFixed(2)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="deductionAmount" label="扣除金额(元)" extra="如无扣除填0">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={Number(refundModal?.amount || 500)}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="deductionReason" label="扣除原因">
|
||||
<Input placeholder="如:房间损坏赔偿" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
@@ -401,8 +405,8 @@ const DepositsPage: React.FC = () => {
|
||||
{detailModal && (
|
||||
<div>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<p><strong>押金金额:</strong> ¥{Number(detailModal.amount).toFixed(2)}</p>
|
||||
<p><strong>缴纳日期:</strong> {detailModal.paidDate}</p>
|
||||
<p><strong>当前可用押金:</strong> ¥{Number(detailModal.amount).toFixed(2)}</p>
|
||||
<p><strong>最近收取日期:</strong> {detailModal.paidDate}</p>
|
||||
<p>
|
||||
<strong>状态:</strong>{' '}
|
||||
<Tag color={statusMap[detailModal.status]?.color}>
|
||||
|
||||
@@ -46,10 +46,12 @@ const ExpensesPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [roomModal, setRoomModal] = useState(false);
|
||||
const [personalModal, setPersonalModal] = useState(false);
|
||||
const [utilityModal, setUtilityModal] = useState(false);
|
||||
const [editingRoom, setEditingRoom] = useState<any>(null);
|
||||
const [editingPersonal, setEditingPersonal] = useState<any>(null);
|
||||
const [roomForm] = Form.useForm();
|
||||
const [personalForm] = Form.useForm();
|
||||
const [utilityForm] = Form.useForm();
|
||||
const [roomSearch, setRoomSearch] = useState('');
|
||||
const [roomTypeFilter, setRoomTypeFilter] = useState<string | undefined>(undefined);
|
||||
const [personalSearch, setPersonalSearch] = useState('');
|
||||
@@ -192,6 +194,27 @@ const ExpensesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStudentUtility = async () => {
|
||||
const values = await utilityForm.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const result: any = await api.post('/expenses/student-utility', {
|
||||
studentId: values.studentId,
|
||||
expenseType: values.expenseType,
|
||||
amount: values.amount,
|
||||
periodStart: values.period[0].format('YYYY-MM-DD'),
|
||||
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
||||
description: values.description,
|
||||
});
|
||||
const bill = result.bill;
|
||||
message.success(`账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`);
|
||||
setUtilityModal(false);
|
||||
utilityForm.resetFields();
|
||||
fetchData();
|
||||
} catch (e: any) { message.error(e?.message || '水电费出账失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handlePersonalExpense = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -560,6 +583,13 @@ const ExpensesPage: React.FC = () => {
|
||||
批量删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
<PermissionButton
|
||||
permission="expense:create"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => { utilityForm.resetFields(); setUtilityModal(true); }}
|
||||
>
|
||||
添加学生水电费
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="expense:create"
|
||||
type="primary"
|
||||
@@ -639,6 +669,19 @@ const ExpensesPage: React.FC = () => {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
|
||||
<Modal title="添加学生水电费并立即出账" open={utilityModal} onOk={handleStudentUtility} onCancel={() => setUtilityModal(false)} okText="生成账单并扣余额" confirmLoading={saving}>
|
||||
<Form form={utilityForm} layout="vertical">
|
||||
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={students.map((student: any) => ({ value: student.id, label: `${student.name} (${student.studentNo || `#${student.id}`})` }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}><Select options={[{ value: 'water', label: '水费' }, { value: 'electricity', label: '电费' }]} /></Form.Item>
|
||||
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}><RangePicker style={{ width: '100%' }} format="YYYY-MM-DD" /></Form.Item>
|
||||
<Form.Item name="description" label="说明"><Input.TextArea rows={2} maxLength={300} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={editingPersonal ? '编辑个人费用' : '录入个人附加费'}
|
||||
open={personalModal}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider,
|
||||
Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
|
||||
Drawer, Tree, Select, TreeSelect, Modal, DatePicker,
|
||||
Row, Col, List,
|
||||
} from 'antd';
|
||||
import {
|
||||
@@ -59,7 +59,6 @@ interface ClassItem {
|
||||
classType?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
maxStudents?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
@@ -477,9 +476,6 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
<Form.Item name="endDate" label="结束日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxStudents" label="最大人数">
|
||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="0=不限制" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -40,7 +40,6 @@ const OccupanciesPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [students, setStudents] = useState<any[]>([]);
|
||||
const [rooms, setRooms] = useState<any[]>([]);
|
||||
const [organizations, setOrganizations] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkInModal, setCheckInModal] = useState(false);
|
||||
const [checkOutModal, setCheckOutModal] = useState<any>(null);
|
||||
@@ -66,14 +65,13 @@ const OccupanciesPage: React.FC = () => {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [occRes, stuRes, rmRes, tnRes] = (await Promise.allSettled([
|
||||
const [occRes, stuRes, rmRes] = (await Promise.allSettled([
|
||||
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
|
||||
api.get('/students/basic-lookups'),
|
||||
api.get('/rooms/overview'),
|
||||
api.get('/organizations'),
|
||||
])) as PromiseSettledResult<any>[];
|
||||
const labels = ['入住数据', '学生列表', '房间列表', '机构列表'];
|
||||
[occRes, stuRes, rmRes, tnRes].forEach((res, i) => {
|
||||
const labels = ['入住数据', '学生列表', '房间列表'];
|
||||
[occRes, stuRes, rmRes].forEach((res, i) => {
|
||||
if (res.status === 'rejected') {
|
||||
message.warning(`${labels[i]}加载失败`);
|
||||
}
|
||||
@@ -81,7 +79,6 @@ const OccupanciesPage: React.FC = () => {
|
||||
setData(occRes.status === 'fulfilled' ? occRes.value : []);
|
||||
setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []);
|
||||
setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []);
|
||||
setOrganizations(tnRes.status === 'fulfilled' ? tnRes.value : []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('数据加载异常');
|
||||
@@ -157,7 +154,8 @@ const OccupanciesPage: React.FC = () => {
|
||||
checkInDate: values.checkInDate.format('YYYY-MM-DD'),
|
||||
billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'),
|
||||
stayType: values.stayType,
|
||||
responsibleOrganizationId: values.responsibleOrganizationId,
|
||||
collectDeposit: values.collectDeposit,
|
||||
depositAmount: values.collectDeposit ? values.depositAmount : undefined,
|
||||
notes: values.notes,
|
||||
bedId: values.bedId,
|
||||
lockerId: values.lockerId || undefined,
|
||||
@@ -330,7 +328,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
<div>
|
||||
<Alert
|
||||
title="一站式导入"
|
||||
description="导入入住名单时会自动创建学生和宿舍,无需单独在「学生管理」或「宿舍管理」中手动添加。后续仅需在此页面处理换房/退宿等日常操作即可。"
|
||||
description="导入入住名单时会优先按手机号关联已有学生,所属机构自动取学生档案;未找到学生或宿舍时会自动创建。后续仅需在此页面处理换房/退宿等日常操作即可。"
|
||||
type="info"
|
||||
showIcon
|
||||
closable
|
||||
@@ -367,7 +365,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
checkInForm.resetFields();
|
||||
checkInForm.setFieldsValue({ checkInDate: dayjs() });
|
||||
checkInForm.setFieldsValue({ checkInDate: dayjs(), collectDeposit: true, depositAmount: 500 });
|
||||
setCheckInModal(true);
|
||||
}}
|
||||
>
|
||||
@@ -407,7 +405,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip title="导入时自动创建学生、宿舍和入住记录">
|
||||
<Tooltip title="按手机号关联学生,并自动创建缺失的学生、宿舍和入住记录">
|
||||
<Button type="primary" ghost icon={<UploadOutlined />}>
|
||||
导入入住名单
|
||||
</Button>
|
||||
@@ -598,18 +596,6 @@ const OccupanciesPage: React.FC = () => {
|
||||
placeholder="默认为短租"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="responsibleOrganizationId" label="负责机构">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
optionFilterProp="label"
|
||||
placeholder="默认取学生所属机构"
|
||||
options={organizations.map((t: { id: number; name: string }) => ({
|
||||
value: t.id,
|
||||
label: t.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bedId"
|
||||
label="床位"
|
||||
@@ -630,7 +616,10 @@ const OccupanciesPage: React.FC = () => {
|
||||
空闲 {availableBeds.length} 张床位
|
||||
</div>
|
||||
)}
|
||||
<Form.Item name="lockerId" label="柜子(可选)">
|
||||
<Form.Item
|
||||
name="lockerId"
|
||||
label="柜子(可选)"
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="可选分配柜子"
|
||||
@@ -641,6 +630,32 @@ const OccupanciesPage: React.FC = () => {
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="collectDeposit"
|
||||
label="押金缴纳"
|
||||
valuePropName="checked"
|
||||
extra="开启后,确认入住时同步生成已缴押金记录;已有已缴押金时不会重复创建"
|
||||
>
|
||||
<Switch checkedChildren="已缴" unCheckedChildren="不缴" />
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(prev, current) => prev.collectDeposit !== current.collectDeposit}>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('collectDeposit') ? (
|
||||
<Form.Item
|
||||
name="depositAmount"
|
||||
label="押金金额"
|
||||
rules={[{ required: true, message: '请输入押金金额' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
precision={2}
|
||||
addonAfter="元"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
Popconfirm,
|
||||
@@ -53,6 +54,7 @@ interface ClassScheduleItem {
|
||||
weekDay: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
attendanceAdvanceMinutes: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
subject: string;
|
||||
@@ -351,7 +353,7 @@ const SchedulesPage: React.FC = () => {
|
||||
setEditingSchedule(null);
|
||||
setModalMode('create');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ classroomId, weekDay });
|
||||
form.setFieldsValue({ classroomId, weekDay, attendanceAdvanceMinutes: 30 });
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
@@ -960,9 +962,28 @@ const SchedulesPage: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="attendanceAdvanceMinutes"
|
||||
label="课前签到时间"
|
||||
tooltip="从上课前指定分钟开始,到下课时间结束;期间任意上班或下班打卡都计为出勤"
|
||||
initialValue={30}
|
||||
rules={[{ required: true, message: '请设置课前签到时间' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1440}
|
||||
step={5}
|
||||
addonAfter="分钟"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="例如 30"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="timeRange"
|
||||
label="上课时段"
|
||||
tooltip="同一教室的前后两节排课必须至少间隔10分钟"
|
||||
extra="系统按10分钟选择时间,并为相邻排课强制预留至少10分钟。"
|
||||
rules={[{ required: true, message: '请选择时段' }]}
|
||||
>
|
||||
<TimePicker.RangePicker
|
||||
@@ -1008,6 +1029,7 @@ const SchedulesPage: React.FC = () => {
|
||||
weekDay:
|
||||
selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined),
|
||||
dateRange: selectedDate ? [selectedDate, selectedDate] : undefined,
|
||||
attendanceAdvanceMinutes: 30,
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -1054,6 +1076,12 @@ const SchedulesPage: React.FC = () => {
|
||||
<strong>时段:</strong>
|
||||
{s.startTime} ~ {s.endTime}
|
||||
</div>
|
||||
{!isMaskedSchedule(s) && (
|
||||
<div>
|
||||
<strong>签到窗口:</strong>
|
||||
课前 {s.attendanceAdvanceMinutes ?? 30} 分钟至下课
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<strong>日期:</strong>
|
||||
{s.startDate} ~ {s.endDate}
|
||||
|
||||
@@ -16,11 +16,13 @@ describe('schedule edit form mapping', () => {
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
notes: '需要投影设备',
|
||||
attendanceAdvanceMinutes: 45,
|
||||
});
|
||||
|
||||
expect(values.classroomId).toBe(1);
|
||||
expect(values.weekDay).toBe(5);
|
||||
expect(values.notes).toBe('需要投影设备');
|
||||
expect(values.attendanceAdvanceMinutes).toBe(45);
|
||||
expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
|
||||
expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
|
||||
'2026-07-01',
|
||||
@@ -39,6 +41,7 @@ describe('schedule edit form mapping', () => {
|
||||
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
|
||||
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
|
||||
notes: ' 临时调整教室 ',
|
||||
attendanceAdvanceMinutes: 20,
|
||||
}),
|
||||
).toEqual({
|
||||
classId: 1,
|
||||
@@ -51,6 +54,7 @@ describe('schedule edit form mapping', () => {
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-31',
|
||||
notes: '临时调整教室',
|
||||
attendanceAdvanceMinutes: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -67,6 +71,7 @@ describe('schedule notes normalization', () => {
|
||||
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
|
||||
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
|
||||
notes: ' ',
|
||||
attendanceAdvanceMinutes: 30,
|
||||
}).notes,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface ScheduleFormValues {
|
||||
subject: string;
|
||||
teacherId?: number;
|
||||
notes?: string;
|
||||
attendanceAdvanceMinutes: number;
|
||||
timeRange: [Dayjs, Dayjs];
|
||||
dateRange: [Dayjs, Dayjs];
|
||||
}
|
||||
@@ -19,6 +20,7 @@ export interface EditableSchedule {
|
||||
subject: string;
|
||||
teacherId: number | null;
|
||||
notes?: string | null;
|
||||
attendanceAdvanceMinutes?: number | null;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
startDate: string;
|
||||
@@ -32,6 +34,7 @@ export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormVa
|
||||
subject: schedule.subject,
|
||||
teacherId: schedule.teacherId ?? undefined,
|
||||
notes: schedule.notes ?? undefined,
|
||||
attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes ?? 30,
|
||||
timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
|
||||
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
|
||||
});
|
||||
@@ -43,6 +46,7 @@ export const buildSchedulePayload = (values: ScheduleFormValues) => ({
|
||||
subject: values.subject,
|
||||
teacherId: values.teacherId,
|
||||
notes: values.notes?.trim() || undefined,
|
||||
attendanceAdvanceMinutes: values.attendanceAdvanceMinutes,
|
||||
startTime: values.timeRange[0].format('HH:mm'),
|
||||
endTime: values.timeRange[1].format('HH:mm'),
|
||||
startDate: values.dateRange[0].format('YYYY-MM-DD'),
|
||||
|
||||
102
apps/admin/src/pages/Wallets/index.tsx
Normal file
102
apps/admin/src/pages/Wallets/index.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Drawer, Form, Input, InputNumber, Modal, Radio, Space, Switch, Table, Tag } from 'antd';
|
||||
import { HistoryOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface WalletRow {
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
studentNo?: string;
|
||||
balance: number;
|
||||
outstandingAmount: number;
|
||||
}
|
||||
|
||||
const transactionNames: Record<string, string> = {
|
||||
recharge: '充值', adjustment: '调账', bill_payment: '账单扣款', bill_refund: '账单冲正',
|
||||
};
|
||||
|
||||
const WalletsPage: React.FC = () => {
|
||||
const [rows, setRows] = useState<WalletRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [debtOnly, setDebtOnly] = useState(false);
|
||||
const [selected, setSelected] = useState<WalletRow | null>(null);
|
||||
const [transactions, setTransactions] = useState<any[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchRows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get('/wallets', { params: { keyword: keyword || undefined, debtOnly } });
|
||||
setRows(data as WalletRow[]);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载学生余额失败');
|
||||
} finally { setLoading(false); }
|
||||
}, [keyword, debtOnly]);
|
||||
|
||||
useEffect(() => { void fetchRows(); }, [fetchRows]);
|
||||
|
||||
const openChange = (row: WalletRow) => {
|
||||
setSelected(row);
|
||||
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
|
||||
};
|
||||
|
||||
const submitChange = async () => {
|
||||
if (!selected) return;
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const result: any = await api.post('/wallets/change-balance', { studentId: selected.studentId, ...values });
|
||||
const paid = (result.payments || []).reduce((sum: number, bill: any) => sum + Number(bill.paidAmount || 0), 0);
|
||||
message.success(paid > 0 ? `余额已更新,并自动补扣历史账单` : '余额已更新');
|
||||
setSelected(null);
|
||||
await fetchRows();
|
||||
} catch (error: any) { message.error(error?.message || '余额操作失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const showTransactions = async (row: WalletRow) => {
|
||||
setSelected(row); setDrawerOpen(true);
|
||||
try { setTransactions(await api.get('/wallets/transactions', { params: { studentId: row.studentId } }) as any[]); }
|
||||
catch (error: any) { message.error(error?.message || '加载流水失败'); }
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: '学生', render: (_: unknown, row: WalletRow) => <><strong>{row.studentName}</strong><div style={{ color: '#999' }}>{row.studentNo || `#${row.studentId}`}</div></> },
|
||||
{ title: '可用余额', dataIndex: 'balance', render: (value: number) => <strong style={{ color: Number(value) > 0 ? '#1677ff' : undefined }}>¥{Number(value).toFixed(2)}</strong> },
|
||||
{ title: '未付账单', dataIndex: 'outstandingAmount', render: (value: number) => Number(value) > 0 ? <Tag color="red">¥{Number(value).toFixed(2)}</Tag> : <Tag color="green">无欠费</Tag> },
|
||||
{ title: '操作', render: (_: unknown, row: WalletRow) => <Space><PermissionButton permission="wallet:edit" type="primary" size="small" icon={<PlusOutlined />} onClick={() => openChange(row)}>充值/调账</PermissionButton><Button size="small" icon={<HistoryOutlined />} onClick={() => showTransactions(row)}>流水</Button></Space> },
|
||||
], []);
|
||||
|
||||
return <div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<Space wrap><Input.Search allowClear placeholder="搜索姓名或学号" style={{ width: 240 }} onSearch={setKeyword} onChange={(event) => !event.target.value && setKeyword('')} /><span>仅看欠费</span><Switch checked={debtOnly} onChange={setDebtOnly} /></Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchRows}>刷新</Button>
|
||||
</div>
|
||||
<Table rowKey="studentId" loading={loading} dataSource={rows} columns={columns} pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 人` }} />
|
||||
<Modal title={`${selected?.studentName || ''} - 余额操作`} open={!!selected && !drawerOpen} onCancel={() => setSelected(null)} onOk={submitChange} confirmLoading={saving} okText="确认">
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="type" label="操作类型" rules={[{ required: true }]}><Radio.Group options={[{ label: '充值', value: 'recharge' }, { label: '调账', value: 'adjustment' }]} /></Form.Item>
|
||||
<Form.Item name="amount" label="变动金额" extra="充值填正数;调减余额时填写负数。充值后会按最早账单优先自动补扣。" rules={[{ required: true, message: '请输入金额' }]}><InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" /></Form.Item>
|
||||
<Form.Item name="description" label="备注"><Input.TextArea maxLength={300} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Drawer title={`${selected?.studentName || ''} - 余额流水`} width={680} open={drawerOpen} onClose={() => { setDrawerOpen(false); setSelected(null); }}>
|
||||
<Table rowKey="id" dataSource={transactions} pagination={{ pageSize: 10 }} columns={[
|
||||
{ title: '时间', dataIndex: 'createdAt', render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm') },
|
||||
{ title: '类型', dataIndex: 'type', render: (value: string) => transactionNames[value] || value },
|
||||
{ title: '金额', dataIndex: 'amount', render: (value: number) => <span style={{ color: Number(value) >= 0 ? '#389e0d' : '#cf1322' }}>{Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)}</span> },
|
||||
{ title: '变动后余额', dataIndex: 'balanceAfter', render: (value: number) => `¥${Number(value).toFixed(2)}` },
|
||||
{ title: '关联账单', dataIndex: 'billId', render: (value: number) => value ? `#${value}` : '-' },
|
||||
{ title: '说明', dataIndex: 'description' },
|
||||
]} />
|
||||
</Drawer>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default WalletsPage;
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
ArchiveAttachment,
|
||||
StudentDingMapping,
|
||||
AiConfig,
|
||||
StudentWallet,
|
||||
WalletTransaction,
|
||||
} from './entities';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AuthorizationModule } from './authorization';
|
||||
@@ -71,6 +73,7 @@ import { ExpenseTypesModule } from './expense-types/expense-types.module';
|
||||
import { DatabaseMigrationsModule } from './database/database-migrations.module';
|
||||
import { AgentToolsModule } from './agent-tools';
|
||||
import { AiConfigModule } from './ai-config/ai-config.module';
|
||||
import { WalletsModule } from './wallets/wallets.module';
|
||||
|
||||
import {
|
||||
IntegrationConfig,
|
||||
@@ -135,6 +138,8 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
IntegrationConfig,
|
||||
IntegrationConfigDetail,
|
||||
AiConfig,
|
||||
StudentWallet,
|
||||
WalletTransaction,
|
||||
];
|
||||
if (dbType === 'mysql') {
|
||||
return {
|
||||
@@ -168,6 +173,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
DashboardModule,
|
||||
OperationLogsModule,
|
||||
DepositsModule,
|
||||
WalletsModule,
|
||||
ClassroomsModule,
|
||||
AttendanceModule,
|
||||
ClassesModule,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
Res,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Request as ExpressRequest, Response } from 'express';
|
||||
@@ -47,15 +48,15 @@ export class ArchiveController {
|
||||
|
||||
@Get(':studentId')
|
||||
@RequirePermission('student:view')
|
||||
async getProfile(@Param('studentId') studentId: string, @Request() req: AuthenticatedRequest) {
|
||||
async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.getProfile(+studentId);
|
||||
const result = await this.archiveService.getProfile(studentId);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '查看档案',
|
||||
targetId: +studentId,
|
||||
targetId: studentId,
|
||||
targetType: 'archive',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -66,18 +67,18 @@ export class ArchiveController {
|
||||
@Put(':studentId/profile')
|
||||
@RequirePermission('student:edit')
|
||||
async upsertProfile(
|
||||
@Param('studentId') studentId: string,
|
||||
@Param('studentId', ParseIntPipe) studentId: number,
|
||||
@Body() dto: UpsertProfileDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.upsertProfile(+studentId, dto);
|
||||
const result = await this.archiveService.upsertProfile(studentId, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '更新档案信息',
|
||||
targetId: +studentId,
|
||||
targetId: studentId,
|
||||
targetType: 'student_profile',
|
||||
detail: JSON.stringify(dto),
|
||||
ipAddress,
|
||||
@@ -89,12 +90,12 @@ export class ArchiveController {
|
||||
@Post(':studentId/enrollments')
|
||||
@RequirePermission('student:edit')
|
||||
async addEnrollment(
|
||||
@Param('studentId') studentId: string,
|
||||
@Param('studentId', ParseIntPipe) studentId: number,
|
||||
@Body() dto: CreateEnrollmentDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.addEnrollment(+studentId, dto);
|
||||
const result = await this.archiveService.addEnrollment(studentId, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
@@ -112,18 +113,18 @@ export class ArchiveController {
|
||||
@Put('enrollments/:id')
|
||||
@RequirePermission('student:edit')
|
||||
async updateEnrollment(
|
||||
@Param('id') id: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateEnrollmentDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.updateEnrollment(+id, dto);
|
||||
const result = await this.archiveService.updateEnrollment(id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '编辑报名记录',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'student_enrollment',
|
||||
detail: JSON.stringify(dto),
|
||||
ipAddress,
|
||||
@@ -134,15 +135,15 @@ export class ArchiveController {
|
||||
|
||||
@Delete('enrollments/:id')
|
||||
@RequirePermission('student:edit')
|
||||
async deleteEnrollment(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.deleteEnrollment(+id);
|
||||
const result = await this.archiveService.deleteEnrollment(id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '删除报名记录',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'student_enrollment',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -153,12 +154,12 @@ export class ArchiveController {
|
||||
@Post(':studentId/exam-scores')
|
||||
@RequirePermission('student:edit')
|
||||
async addExamScore(
|
||||
@Param('studentId') studentId: string,
|
||||
@Param('studentId', ParseIntPipe) studentId: number,
|
||||
@Body() dto: CreateExamScoreDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.addExamScore(+studentId, dto);
|
||||
const result = await this.archiveService.addExamScore(studentId, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
@@ -176,18 +177,18 @@ export class ArchiveController {
|
||||
@Put('exam-scores/:id')
|
||||
@RequirePermission('student:edit')
|
||||
async updateExamScore(
|
||||
@Param('id') id: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateExamScoreDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.updateExamScore(+id, dto);
|
||||
const result = await this.archiveService.updateExamScore(id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '编辑考试成绩',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'exam_score',
|
||||
detail: JSON.stringify(dto),
|
||||
ipAddress,
|
||||
@@ -198,15 +199,15 @@ export class ArchiveController {
|
||||
|
||||
@Delete('exam-scores/:id')
|
||||
@RequirePermission('student:edit')
|
||||
async deleteExamScore(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.deleteExamScore(+id);
|
||||
const result = await this.archiveService.deleteExamScore(id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '删除考试成绩',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'exam_score',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -217,12 +218,12 @@ export class ArchiveController {
|
||||
@Post(':studentId/learning-records')
|
||||
@RequirePermission('student:edit')
|
||||
async addLearningRecord(
|
||||
@Param('studentId') studentId: string,
|
||||
@Param('studentId', ParseIntPipe) studentId: number,
|
||||
@Body() dto: CreateLearningRecordDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.addLearningRecord(+studentId, dto);
|
||||
const result = await this.archiveService.addLearningRecord(studentId, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
@@ -240,18 +241,18 @@ export class ArchiveController {
|
||||
@Put('learning-records/:id')
|
||||
@RequirePermission('student:edit')
|
||||
async updateLearningRecord(
|
||||
@Param('id') id: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateLearningRecordDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.updateLearningRecord(+id, dto);
|
||||
const result = await this.archiveService.updateLearningRecord(id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '编辑学习记录',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'learning_record',
|
||||
detail: JSON.stringify(dto),
|
||||
ipAddress,
|
||||
@@ -262,15 +263,15 @@ export class ArchiveController {
|
||||
|
||||
@Delete('learning-records/:id')
|
||||
@RequirePermission('student:edit')
|
||||
async deleteLearningRecord(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.deleteLearningRecord(+id);
|
||||
const result = await this.archiveService.deleteLearningRecord(id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '删除学习记录',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'learning_record',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -281,18 +282,18 @@ export class ArchiveController {
|
||||
@Put(':studentId/result')
|
||||
@RequirePermission('student:edit')
|
||||
async upsertResult(
|
||||
@Param('studentId') studentId: string,
|
||||
@Param('studentId', ParseIntPipe) studentId: number,
|
||||
@Body() dto: UpsertResultDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.upsertResult(+studentId, dto);
|
||||
const result = await this.archiveService.upsertResult(studentId, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '更新录取结果',
|
||||
targetId: +studentId,
|
||||
targetId: studentId,
|
||||
targetType: 'result_archive',
|
||||
detail: JSON.stringify(dto),
|
||||
ipAddress,
|
||||
@@ -305,13 +306,13 @@ export class ArchiveController {
|
||||
@RequirePermission('student:edit')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadAttachment(
|
||||
@Param('studentId') studentId: string,
|
||||
@Param('studentId', ParseIntPipe) studentId: number,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('category') category: string,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.addAttachment(+studentId, file, category || 'other');
|
||||
const result = await this.archiveService.addAttachment(studentId, file, category || 'other');
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
@@ -329,13 +330,13 @@ export class ArchiveController {
|
||||
@Get(':studentId/attachments/:id')
|
||||
@RequirePermission('student:view')
|
||||
async downloadAttachment(
|
||||
@Param('studentId') studentId: string,
|
||||
@Param('id') id: string,
|
||||
@Param('studentId', ParseIntPipe) studentId: number,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(
|
||||
+studentId,
|
||||
+id,
|
||||
studentId,
|
||||
id,
|
||||
);
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`);
|
||||
@@ -345,15 +346,15 @@ export class ArchiveController {
|
||||
|
||||
@Delete('attachments/:id')
|
||||
@RequirePermission('student:edit')
|
||||
async deleteAttachment(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.archiveService.deleteAttachment(+id);
|
||||
const result = await this.archiveService.deleteAttachment(id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '删除附件',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'archive_attachment',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -364,7 +365,7 @@ export class ArchiveController {
|
||||
@Get(':studentId/report-html')
|
||||
@RequirePermission('student:view')
|
||||
async generateReportHtml(
|
||||
@Param('studentId') studentId: string,
|
||||
@Param('studentId', ParseIntPipe) studentId: number,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
@@ -373,12 +374,12 @@ export class ArchiveController {
|
||||
username: req.user?.username,
|
||||
module: 'archive',
|
||||
action: 'generate_report_html',
|
||||
targetId: +studentId,
|
||||
targetId: studentId,
|
||||
targetType: 'student',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
const html = await this.reportService.generateReportHtml(+studentId);
|
||||
const html = await this.reportService.generateReportHtml(studentId);
|
||||
return { html };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('ArchiveService.getProfile', () => {
|
||||
const learningRecordRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const resultRepo = { findOne: jest.fn().mockResolvedValue(result) };
|
||||
const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const attendanceRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
const service = new ArchiveService(
|
||||
studentRepo as never,
|
||||
@@ -26,12 +27,13 @@ describe('ArchiveService.getProfile', () => {
|
||||
learningRecordRepo as never,
|
||||
resultRepo as never,
|
||||
attachmentRepo as never,
|
||||
attendanceRepo as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const response = await service.getProfile(7);
|
||||
|
||||
expect(response).toMatchObject({ student, result });
|
||||
expect(response).toMatchObject({ student, result, attendances: [] });
|
||||
expect(response).not.toHaveProperty('resultArchive');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ExamScore } from '../entities/exam-score.entity';
|
||||
import { LearningRecord } from '../entities/learning-record.entity';
|
||||
import { ResultArchive } from '../entities/result-archive.entity';
|
||||
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import {
|
||||
UpsertProfileDto,
|
||||
CreateEnrollmentDto,
|
||||
@@ -33,6 +34,7 @@ export class ArchiveService {
|
||||
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
|
||||
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
|
||||
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
|
||||
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
@@ -59,7 +61,7 @@ export class ArchiveService {
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments] =
|
||||
const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments, attendances] =
|
||||
await Promise.all([
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
@@ -67,6 +69,11 @@ export class ArchiveService {
|
||||
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.attendanceRepo.find({
|
||||
where: { studentId },
|
||||
relations: ['schedule', 'class'],
|
||||
order: { attendanceDate: 'DESC', punchTime: 'DESC' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -77,6 +84,7 @@ export class ArchiveService {
|
||||
learningRecords,
|
||||
result: resultArchive,
|
||||
attachments,
|
||||
attendances,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,31 @@ describe('AttendanceImportService', () => {
|
||||
expect(entity.userName).toBe('张三');
|
||||
});
|
||||
|
||||
it('stores DingTalk punch source and attendance machine metadata', async () => {
|
||||
const entity = await (service as any).mapToEntity({
|
||||
userId: 'ding-1',
|
||||
userName: '张三',
|
||||
workDate: '2026-07-01',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||
checkId: 'check-1',
|
||||
checkType: 'OnDuty',
|
||||
sourceType: 'ATM',
|
||||
deviceName: '东门考勤机',
|
||||
deviceId: 'ATM-01',
|
||||
});
|
||||
|
||||
expect(entity).toEqual(
|
||||
expect.objectContaining({
|
||||
punchSource: 'ATM',
|
||||
punchDeviceName: '东门考勤机',
|
||||
punchDeviceId: 'ATM-01',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||
{
|
||||
@@ -138,9 +163,19 @@ describe('AttendanceImportService', () => {
|
||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||
checkId: 'check-1',
|
||||
checkType: 'OnDuty',
|
||||
sourceType: 'ATM',
|
||||
deviceName: '东门考勤机',
|
||||
deviceId: 'ATM-01',
|
||||
},
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([{ dingId: 'check-1' }]);
|
||||
dingRawRepo.find.mockResolvedValue([{
|
||||
dingId: 'check-1',
|
||||
punchSource: null,
|
||||
punchDeviceName: null,
|
||||
punchDeviceId: null,
|
||||
rawData: '',
|
||||
}]);
|
||||
dingRawRepo.save.mockImplementation(async (entities) => entities);
|
||||
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
|
||||
|
||||
const result = await service.importFromDingTalk({
|
||||
@@ -150,10 +185,57 @@ describe('AttendanceImportService', () => {
|
||||
autoMatch: true,
|
||||
});
|
||||
|
||||
expect(dingRawRepo.save).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({
|
||||
dingId: 'check-1',
|
||||
punchSource: 'ATM',
|
||||
punchDeviceName: '东门考勤机',
|
||||
punchDeviceId: 'ATM-01',
|
||||
})],
|
||||
{ chunk: 50 },
|
||||
);
|
||||
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
|
||||
expect(result.matched).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves existing device metadata when a duplicate response omits it', async () => {
|
||||
const existing = {
|
||||
dingId: 'check-keep-device',
|
||||
punchSource: 'ATM',
|
||||
punchDeviceName: '东门考勤机',
|
||||
punchDeviceId: 'ATM-01',
|
||||
rawData: '{}',
|
||||
};
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([{
|
||||
userId: 'ding-1',
|
||||
userName: '张三',
|
||||
workDate: '2026-07-01',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||
checkId: 'check-keep-device',
|
||||
checkType: 'OnDuty',
|
||||
sourceType: '',
|
||||
}]);
|
||||
dingRawRepo.find.mockResolvedValue([existing]);
|
||||
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 0, total: 1 });
|
||||
|
||||
await service.importFromDingTalk({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: ['ding-1'],
|
||||
autoMatch: true,
|
||||
});
|
||||
|
||||
expect(existing).toEqual(expect.objectContaining({
|
||||
punchSource: 'ATM',
|
||||
punchDeviceName: '东门考勤机',
|
||||
punchDeviceId: 'ATM-01',
|
||||
}));
|
||||
expect(dingRawRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scopes SSE progress events to the importing user', async () => {
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -104,8 +104,10 @@ export class AttendanceImportService {
|
||||
|
||||
// Stage 2: Parse & deduplicate
|
||||
this.emit('parsing', 0, total, `Parsing ${total} records...`);
|
||||
const existingDingIds = await this.getExistingDingIds(rawResults);
|
||||
const newRecords = rawResults.filter((r) => !existingDingIds.has(r.checkId));
|
||||
const existingByDingId = await this.getExistingRecordsByDingId(rawResults);
|
||||
const newRecords = rawResults.filter((r) => !existingByDingId.has(r.checkId));
|
||||
const duplicateRecords = rawResults.filter((r) => existingByDingId.has(r.checkId));
|
||||
await this.refreshDuplicatePunchMetadata(duplicateRecords, existingByDingId);
|
||||
skipped = rawResults.length - newRecords.length;
|
||||
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
|
||||
|
||||
@@ -246,17 +248,45 @@ export class AttendanceImportService {
|
||||
/**
|
||||
* Query which dingIds already exist to skip duplicates.
|
||||
*/
|
||||
private async getExistingDingIds(
|
||||
private async getExistingRecordsByDingId(
|
||||
results: DingTalkAttendanceResult[],
|
||||
): Promise<Set<string>> {
|
||||
): Promise<Map<string, DingAttendanceRaw>> {
|
||||
const dingIds = results.map((r) => r.checkId).filter(Boolean);
|
||||
if (dingIds.length === 0) return new Set();
|
||||
if (dingIds.length === 0) return new Map();
|
||||
|
||||
const existing = await this.dingRawRepo.find({
|
||||
where: { dingId: In(dingIds) },
|
||||
select: ['dingId'],
|
||||
});
|
||||
return new Set(existing.map((e) => e.dingId));
|
||||
return new Map(existing.map((entity) => [entity.dingId, entity]));
|
||||
}
|
||||
|
||||
private async refreshDuplicatePunchMetadata(
|
||||
results: DingTalkAttendanceResult[],
|
||||
existingByDingId: Map<string, DingAttendanceRaw>,
|
||||
): Promise<void> {
|
||||
const changed: DingAttendanceRaw[] = [];
|
||||
for (const result of results) {
|
||||
const entity = existingByDingId.get(result.checkId);
|
||||
if (!entity) continue;
|
||||
const punchSource = result.sourceType || entity.punchSource || null;
|
||||
const punchDeviceName = result.deviceName || entity.punchDeviceName || null;
|
||||
const punchDeviceId = result.deviceId || entity.punchDeviceId || null;
|
||||
if (
|
||||
entity.punchSource === punchSource &&
|
||||
entity.punchDeviceName === punchDeviceName &&
|
||||
entity.punchDeviceId === punchDeviceId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
entity.punchSource = punchSource;
|
||||
entity.punchDeviceName = punchDeviceName;
|
||||
entity.punchDeviceId = punchDeviceId;
|
||||
entity.rawData = JSON.stringify(result);
|
||||
changed.push(entity);
|
||||
}
|
||||
if (changed.length > 0) {
|
||||
await this.dingRawRepo.save(changed, { chunk: 50 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,6 +301,9 @@ export class AttendanceImportService {
|
||||
entity.attendanceType = r.checkType || 'OnDuty';
|
||||
entity.timeResult = r.timeResult;
|
||||
entity.locationResult = r.locationResult || '';
|
||||
entity.punchSource = r.sourceType || null;
|
||||
entity.punchDeviceName = r.deviceName || null;
|
||||
entity.punchDeviceId = r.deviceId || null;
|
||||
|
||||
// Parse check-in/out times
|
||||
if (r.actualCheckTime) {
|
||||
|
||||
@@ -20,6 +20,14 @@ const createService = () => {
|
||||
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
const attendanceService = {
|
||||
getLessonAttendanceImportDateRange: jest.fn().mockImplementation((targetSchedule, lessonDate: string) => {
|
||||
if (targetSchedule.endTime > targetSchedule.startTime) {
|
||||
return { startDate: lessonDate, endDate: lessonDate };
|
||||
}
|
||||
const next = new Date(`${lessonDate}T00:00:00.000Z`);
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
return { startDate: lessonDate, endDate: next.toISOString().slice(0, 10) };
|
||||
}),
|
||||
getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']),
|
||||
createLessonAttendanceFromDingTalk: jest.fn().mockImplementation(
|
||||
async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({
|
||||
|
||||
@@ -110,9 +110,12 @@ export class AttendanceSettlementService {
|
||||
schedule.teacherId,
|
||||
schedule.classId,
|
||||
);
|
||||
const importRange = this.attendanceService.getLessonAttendanceImportDateRange(
|
||||
schedule,
|
||||
lessonDate,
|
||||
);
|
||||
const imported = await this.importService.importFromDingTalk({
|
||||
startDate: lessonDate,
|
||||
endDate: this.isOvernight(schedule) ? this.shiftDate(lessonDate, 1) : lessonDate,
|
||||
...importRange,
|
||||
userIds,
|
||||
autoMatch: true,
|
||||
userId: schedule.teacherId,
|
||||
|
||||
@@ -162,6 +162,9 @@ describe('AttendanceController — write data scope', () => {
|
||||
assertClassAccess: jest.fn(),
|
||||
getAccessibleClassIds: jest.fn(),
|
||||
getTeacherClassDingUserIds: jest.fn(),
|
||||
getLessonAttendanceImportDateRange: jest.fn().mockImplementation(
|
||||
(_schedule, lessonDate: string) => ({ startDate: lessonDate, endDate: lessonDate }),
|
||||
),
|
||||
batchCreate: jest.fn(),
|
||||
generateFromSchedules: jest.fn(),
|
||||
findAttendanceRecord: jest.fn(),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Res,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, filter } from 'rxjs';
|
||||
import type { Request as ExpressRequest, Response } from 'express';
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
QueryDingRawDto,
|
||||
MatchDingRecordDto,
|
||||
AttendanceReportQueryDto,
|
||||
AttendanceAlertsQueryDto,
|
||||
UpdateAttendanceRecordDto,
|
||||
GenerateFromSchedulesDto,
|
||||
LessonAttendanceQueryDto,
|
||||
@@ -93,11 +95,11 @@ export class AttendanceController {
|
||||
@Get('attendance-lessons/schedules/:scheduleId')
|
||||
@RequirePermission('attendance:view')
|
||||
async getLessonAttendance(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Param('scheduleId', ParseIntPipe) scheduleId: number,
|
||||
@Query() query: LessonAttendanceQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const result = await this.service.getLessonAttendance(+scheduleId, query.date);
|
||||
const result = await this.service.getLessonAttendance(scheduleId, query.date);
|
||||
await this.assertClassAccess(req, result.schedule.classId!);
|
||||
return result;
|
||||
}
|
||||
@@ -105,26 +107,29 @@ export class AttendanceController {
|
||||
@Post('attendance-lessons/schedules/:scheduleId/pull')
|
||||
@RequirePermission('attendance:create')
|
||||
async pullLessonAttendance(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Param('scheduleId', ParseIntPipe) scheduleId: number,
|
||||
@Body() dto: StartLessonAttendanceDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const schedule = await this.service.getLessonAttendance(+scheduleId, dto.date);
|
||||
const schedule = await this.service.getLessonAttendance(scheduleId, dto.date);
|
||||
await this.assertClassAccess(req, schedule.schedule.classId!);
|
||||
const importClassIds = await this.service.getTeacherClassDingUserIds(
|
||||
req.user.id,
|
||||
schedule.schedule.classId!,
|
||||
this.canManageAllAttendance(req),
|
||||
);
|
||||
const importRange = this.service.getLessonAttendanceImportDateRange(
|
||||
schedule.schedule,
|
||||
dto.date,
|
||||
);
|
||||
const importResult = await this.importService.importFromDingTalk({
|
||||
startDate: dto.date,
|
||||
endDate: dto.date,
|
||||
...importRange,
|
||||
userIds: importClassIds,
|
||||
autoMatch: true,
|
||||
userId: req.user.id,
|
||||
});
|
||||
const result = await this.service.createLessonAttendanceFromDingTalk(
|
||||
+scheduleId,
|
||||
scheduleId,
|
||||
dto.date,
|
||||
req.user.id,
|
||||
);
|
||||
@@ -143,18 +148,18 @@ export class AttendanceController {
|
||||
@Post('attendance-lessons/:sessionId/complete')
|
||||
@RequirePermission('attendance:create')
|
||||
async completeLessonAttendance(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Param('sessionId', ParseIntPipe) sessionId: number,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const session = await this.service.findAttendanceSession(+sessionId);
|
||||
const session = await this.service.findAttendanceSession(sessionId);
|
||||
await this.assertClassAccess(req, session.classId);
|
||||
const result = await this.service.completeLessonAttendance(+sessionId, req.user.id);
|
||||
const result = await this.service.completeLessonAttendance(sessionId, req.user.id);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考勤管理',
|
||||
action: '完成课程点名',
|
||||
targetId: +sessionId,
|
||||
targetId: sessionId,
|
||||
targetType: 'attendanceSession',
|
||||
detail: `班级${session.classId} 日期${session.lessonDate}`,
|
||||
});
|
||||
@@ -278,23 +283,23 @@ export class AttendanceController {
|
||||
@Put('attendance-records/:id')
|
||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateAttendanceRecordDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const existing = await this.service.findAttendanceRecord(+id);
|
||||
const existing = await this.service.findAttendanceRecord(id);
|
||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||
throw new ForbiddenException('无权修改未关联班级的考勤记录');
|
||||
}
|
||||
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
||||
const result = await this.service.update(+id, dto);
|
||||
const result = await this.service.update(id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '考勤管理',
|
||||
action: '编辑考勤记录',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'attendanceRecord',
|
||||
detail: `状态=${result.status}, 备注=${result.remark || ''}`,
|
||||
ipAddress,
|
||||
@@ -306,20 +311,20 @@ export class AttendanceController {
|
||||
// ── Delete a single attendance record ──
|
||||
@Delete('attendance-records/:id')
|
||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const existing = await this.service.findAttendanceRecord(+id);
|
||||
const existing = await this.service.findAttendanceRecord(id);
|
||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||
throw new ForbiddenException('无权删除未关联班级的考勤记录');
|
||||
}
|
||||
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
||||
const result = await this.service.remove(+id);
|
||||
const result = await this.service.remove(id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '考勤管理',
|
||||
action: '删除考勤记录',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'attendanceRecord',
|
||||
detail: `删除考勤记录 ${id}`,
|
||||
ipAddress,
|
||||
@@ -369,18 +374,18 @@ export class AttendanceController {
|
||||
@Post('ding-attendance-raw/:id/match')
|
||||
@RequirePermission('attendance:edit')
|
||||
async matchDingRecord(
|
||||
@Param('id') id: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: MatchDingRecordDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.matchDingRecord(+id, dto);
|
||||
const result = await this.service.matchDingRecord(id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '考勤管理',
|
||||
action: '匹配考勤记录',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'dingAttendanceRaw',
|
||||
detail: `匹配到学生 ${dto.studentId}`,
|
||||
ipAddress,
|
||||
@@ -458,12 +463,11 @@ export class AttendanceController {
|
||||
@RequirePermission('attendance:view')
|
||||
async getAlerts(
|
||||
@Request() req: { user: RequestUser },
|
||||
@Query('days') days?: string,
|
||||
@Query('threshold') threshold?: string,
|
||||
@Query() query: AttendanceAlertsQueryDto,
|
||||
) {
|
||||
return this.service.getAlerts(
|
||||
days ? +days : 14,
|
||||
threshold ? +threshold : 3,
|
||||
query.days ?? 14,
|
||||
query.threshold ?? 3,
|
||||
await this.getAccessibleClassIds(req),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ const endedSchedule = {
|
||||
subject: '\u6570\u5B66',
|
||||
status: 'active',
|
||||
scheduleType: 'INTERNAL',
|
||||
attendanceAdvanceMinutes: 30,
|
||||
};
|
||||
|
||||
describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
@@ -79,6 +80,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
attendanceType: 'OnDuty',
|
||||
timeResult: 'Normal',
|
||||
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||
punchSource: 'ATM',
|
||||
punchDeviceName: '东门考勤机',
|
||||
punchDeviceId: 'ATM-01',
|
||||
},
|
||||
{
|
||||
matchedStudentId: 2,
|
||||
@@ -100,7 +104,15 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
}),
|
||||
);
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, status: 'present', source: 'dingtalk' }),
|
||||
expect.objectContaining({
|
||||
studentId: 1,
|
||||
status: 'present',
|
||||
source: 'dingtalk',
|
||||
punchSource: 'ATM',
|
||||
punchDeviceName: '东门考勤机',
|
||||
punchDeviceId: 'ATM-01',
|
||||
punchTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||
}),
|
||||
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
|
||||
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
|
||||
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
|
||||
@@ -175,6 +187,40 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts both OnDuty and OffDuty punches only inside the configured window', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue({ ...endedSchedule, attendanceAdvanceMinutes: 20 });
|
||||
sessionRepo.findOne.mockResolvedValue(null);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||
{ studentId: 2, student: { id: 2, name: '李四' } },
|
||||
{ studentId: 3, student: { id: 3, name: '王五' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{ matchedStudentId: 1, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T08:40:00+08:00') },
|
||||
{ matchedStudentId: 2, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:00+08:00') },
|
||||
{ matchedStudentId: 3, attendanceType: 'OnDuty', checkInTime: new Date('2026-07-11T08:39:59+08:00') },
|
||||
{ matchedStudentId: 3, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:01+08:00') },
|
||||
]);
|
||||
|
||||
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, status: 'present' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'present' }),
|
||||
expect.objectContaining({ studentId: 3, status: 'absent' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands import dates when the pre-class window crosses midnight', () => {
|
||||
const { service } = createService();
|
||||
expect(service.getLessonAttendanceImportDateRange(
|
||||
{ startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 },
|
||||
'2026-07-11',
|
||||
)).toEqual({ startDate: '2026-07-10', endDate: '2026-07-11' });
|
||||
});
|
||||
|
||||
it('creates local attendance after the lesson starts', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
|
||||
@@ -175,24 +175,45 @@ export class AttendanceService {
|
||||
return { schedule, session, records };
|
||||
}
|
||||
|
||||
private getLessonAttendanceWindow(
|
||||
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||
lessonDate: string,
|
||||
): { start: number; end: number; dateFrom: string; dateTo: string } {
|
||||
const startMinuteOfDay = this.toMinutes(schedule.startTime);
|
||||
const endMinuteOfDay = this.toMinutes(schedule.endTime);
|
||||
const advanceMinutes = Math.max(0, schedule.attendanceAdvanceMinutes ?? 30);
|
||||
const lessonStart = new Date(`${lessonDate}T${schedule.startTime}:00+08:00`).getTime();
|
||||
let lessonEnd = new Date(`${lessonDate}T${schedule.endTime}:00+08:00`).getTime();
|
||||
const overnight = endMinuteOfDay <= startMinuteOfDay;
|
||||
if (overnight) lessonEnd += 24 * 60 * 60 * 1000;
|
||||
|
||||
return {
|
||||
start: lessonStart - advanceMinutes * 60 * 1000,
|
||||
end: lessonEnd,
|
||||
dateFrom: advanceMinutes > startMinuteOfDay ? this.shiftDate(lessonDate, -1) : lessonDate,
|
||||
dateTo: overnight ? this.shiftDate(lessonDate, 1) : lessonDate,
|
||||
};
|
||||
}
|
||||
|
||||
getLessonAttendanceImportDateRange(
|
||||
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||
lessonDate: string,
|
||||
): { startDate: string; endDate: string } {
|
||||
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
|
||||
return { startDate: window.dateFrom, endDate: window.dateTo };
|
||||
}
|
||||
|
||||
private selectDingTalkRecordsForLesson(
|
||||
records: DingAttendanceRaw[],
|
||||
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||
lessonDate: string,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
): DingAttendanceRaw[] {
|
||||
const [startHour, startMinute] = startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = endTime.split(':').map(Number);
|
||||
const start = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
|
||||
let end = new Date(`${lessonDate}T${endTime}:00+08:00`).getTime();
|
||||
if (endHour * 60 + endMinute <= startHour * 60 + startMinute) end += 24 * 60 * 60 * 1000;
|
||||
const windowStart = start - 3 * 60 * 60 * 1000;
|
||||
const windowEnd = end + 3 * 60 * 60 * 1000;
|
||||
const timed = records.filter((record) => {
|
||||
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
|
||||
return records.filter((record) => {
|
||||
// 上班、下班打卡都有效,按原始记录中实际存在的时间判断。
|
||||
const time = record.checkInTime ?? record.checkOutTime;
|
||||
return time && time.getTime() >= windowStart && time.getTime() <= windowEnd;
|
||||
return time && time.getTime() >= window.start && time.getTime() <= window.end;
|
||||
});
|
||||
return timed.length > 0 ? timed : records.filter((record) => !record.checkInTime && !record.checkOutTime);
|
||||
}
|
||||
|
||||
private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
|
||||
@@ -200,6 +221,53 @@ export class AttendanceService {
|
||||
if (hasPunch) return 'present';
|
||||
return finalize ? 'absent' : 'pending';
|
||||
}
|
||||
|
||||
private getLessonPunchMetadata(
|
||||
records: DingAttendanceRaw[],
|
||||
lessonDate: string,
|
||||
startTime: string,
|
||||
): Pick<AttendanceRecord, 'punchTime' | 'punchSource' | 'punchDeviceName' | 'punchDeviceId'> {
|
||||
const punches = records
|
||||
.map((record) => ({ record, time: record.checkInTime ?? record.checkOutTime }))
|
||||
.filter((item): item is { record: DingAttendanceRaw; time: Date } => !!item.time);
|
||||
if (punches.length === 0) {
|
||||
return {
|
||||
punchTime: null,
|
||||
punchSource: null,
|
||||
punchDeviceName: null,
|
||||
punchDeviceId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const lessonStart = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
|
||||
punches.sort(
|
||||
(left, right) =>
|
||||
Math.abs(left.time.getTime() - lessonStart) - Math.abs(right.time.getTime() - lessonStart),
|
||||
);
|
||||
const primary = punches[0];
|
||||
const metadataRecord = [...punches]
|
||||
.filter(({ record }) =>
|
||||
!!(record.punchSource || record.punchDeviceName || record.punchDeviceId) ||
|
||||
!['OnDuty', 'OffDuty'].includes(record.attendanceType),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Math.abs(left.time.getTime() - primary.time.getTime()) -
|
||||
Math.abs(right.time.getTime() - primary.time.getTime()),
|
||||
)[0]?.record;
|
||||
const source =
|
||||
metadataRecord?.punchSource ||
|
||||
(metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType)
|
||||
? metadataRecord.attendanceType
|
||||
: primary.record.punchSource);
|
||||
|
||||
return {
|
||||
punchTime: primary.time,
|
||||
punchSource: source || null,
|
||||
punchDeviceName: metadataRecord?.punchDeviceName || primary.record.punchDeviceName || null,
|
||||
punchDeviceId: metadataRecord?.punchDeviceId || primary.record.punchDeviceId || null,
|
||||
};
|
||||
}
|
||||
async createLessonAttendanceFromDingTalk(
|
||||
scheduleId: number,
|
||||
lessonDate: string,
|
||||
@@ -244,7 +312,7 @@ export class AttendanceService {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const sessionRepo = manager.getRepository(AttendanceSession);
|
||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
|
||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
|
||||
const existingRecords = await recordRepo.find({
|
||||
where: { attendanceSessionId: existing.id },
|
||||
order: { studentId: 'ASC' },
|
||||
@@ -265,11 +333,15 @@ export class AttendanceService {
|
||||
|
||||
const raw = this.selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(record.studentId) ?? [],
|
||||
schedule,
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
schedule.endTime,
|
||||
);
|
||||
record.status = this.mapDingTalkStatus(raw, finalize);
|
||||
Object.assign(record, this.getLessonPunchMetadata(
|
||||
raw,
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
));
|
||||
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||
? null
|
||||
: finalize
|
||||
@@ -281,9 +353,8 @@ export class AttendanceService {
|
||||
if (existingStudentIds.has(classStudent.studentId)) continue;
|
||||
const raw = this.selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(classStudent.studentId) ?? [],
|
||||
schedule,
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
schedule.endTime,
|
||||
);
|
||||
updatedRecords.push(
|
||||
recordRepo.create({
|
||||
@@ -296,6 +367,11 @@ export class AttendanceService {
|
||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||
status: this.mapDingTalkStatus(raw, finalize),
|
||||
source: 'dingtalk',
|
||||
...this.getLessonPunchMetadata(
|
||||
raw,
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
),
|
||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||
? undefined
|
||||
: finalize
|
||||
@@ -320,7 +396,7 @@ export class AttendanceService {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const sessionRepo = manager.getRepository(AttendanceSession);
|
||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
|
||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
|
||||
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId: schedule.classId!, status: 'active' },
|
||||
@@ -364,9 +440,8 @@ export class AttendanceService {
|
||||
const records = classStudents.map((classStudent) => {
|
||||
const raw = this.selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(classStudent.studentId) ?? [],
|
||||
schedule,
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
schedule.endTime,
|
||||
);
|
||||
return recordRepo.create({
|
||||
studentId: classStudent.studentId,
|
||||
@@ -378,6 +453,11 @@ export class AttendanceService {
|
||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||
status: this.mapDingTalkStatus(raw, finalize),
|
||||
source: 'dingtalk',
|
||||
...this.getLessonPunchMetadata(
|
||||
raw,
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
),
|
||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||
? undefined
|
||||
: finalize
|
||||
@@ -398,6 +478,7 @@ export class AttendanceService {
|
||||
|
||||
private async fetchDingTalkRawByStudent(
|
||||
classId: number,
|
||||
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||
lessonDate: string,
|
||||
): Promise<Map<number, DingAttendanceRaw[]>> {
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
@@ -405,9 +486,10 @@ export class AttendanceService {
|
||||
});
|
||||
if (classStudents.length === 0) return new Map();
|
||||
const studentIds = classStudents.map((cs) => cs.studentId);
|
||||
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
|
||||
const rawRecords = await this.dingRawRepo.find({
|
||||
where: {
|
||||
attendanceDate: lessonDate,
|
||||
attendanceDate: Between(window.dateFrom, window.dateTo),
|
||||
matchedStudentId: In(studentIds),
|
||||
},
|
||||
});
|
||||
@@ -589,6 +671,17 @@ export class AttendanceService {
|
||||
});
|
||||
}
|
||||
|
||||
private toMinutes(time: string): number {
|
||||
const [hour, minute] = time.split(':').map(Number);
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
private shiftDate(date: string, days: number): string {
|
||||
const shifted = new Date(`${date}T00:00:00.000Z`);
|
||||
shifted.setUTCDate(shifted.getUTCDate() + days);
|
||||
return shifted.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private mapScheduleTimeToSession(startTime: string): string {
|
||||
const hour = parseInt(startTime.slice(0, 2), 10);
|
||||
if (hour < 8) return 'morning_reading';
|
||||
@@ -908,6 +1001,10 @@ export class AttendanceService {
|
||||
if (dto.status !== undefined) {
|
||||
record.status = dto.status;
|
||||
record.source = 'manual';
|
||||
record.punchTime = null;
|
||||
record.punchSource = null;
|
||||
record.punchDeviceName = null;
|
||||
record.punchDeviceId = null;
|
||||
}
|
||||
if (dto.remark !== undefined) {
|
||||
record.remark = dto.remark;
|
||||
@@ -933,6 +1030,10 @@ export class AttendanceService {
|
||||
if (dto.status !== undefined) {
|
||||
freshRecord.status = dto.status;
|
||||
freshRecord.source = 'manual';
|
||||
freshRecord.punchTime = null;
|
||||
freshRecord.punchSource = null;
|
||||
freshRecord.punchDeviceName = null;
|
||||
freshRecord.punchDeviceId = null;
|
||||
}
|
||||
if (dto.remark !== undefined) {
|
||||
freshRecord.remark = dto.remark;
|
||||
|
||||
@@ -68,8 +68,10 @@ describe('DingTalkService — attendance records', () => {
|
||||
userId: 'ding-1',
|
||||
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
|
||||
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
|
||||
sourceType: 'USER',
|
||||
sourceType: 'ATM',
|
||||
checkType: 'OnDuty',
|
||||
deviceName: '东门考勤机',
|
||||
deviceId: 'ATM-01',
|
||||
timeResult: 'Normal',
|
||||
},
|
||||
],
|
||||
@@ -83,6 +85,13 @@ describe('DingTalkService — attendance records', () => {
|
||||
});
|
||||
|
||||
expect(record.workDate).toBe('2026-07-12');
|
||||
expect(record).toEqual(
|
||||
expect.objectContaining({
|
||||
checkType: 'OnDuty',
|
||||
sourceType: 'ATM',
|
||||
deviceName: '东门考勤机',
|
||||
deviceId: 'ATM-01',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
IsIn,
|
||||
ValidateNested,
|
||||
IsNotEmpty,
|
||||
ArrayNotEmpty,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@@ -44,6 +47,7 @@ export class AttendanceRecordItem {
|
||||
|
||||
export class BatchCreateAttendanceDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AttendanceRecordItem)
|
||||
records: AttendanceRecordItem[];
|
||||
@@ -96,11 +100,14 @@ export class QueryDingRawDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
@Type(() => Number)
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -139,11 +146,14 @@ export class QueryAttendanceRecordsDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
@Type(() => Number)
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -165,6 +175,22 @@ export class UpdateAttendanceRecordDto {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
|
||||
export class AttendanceAlertsQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(365)
|
||||
days?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export class AttendanceReportQueryDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
|
||||
@@ -34,20 +34,6 @@ export class BillsExportService {
|
||||
if (query.status) qb.andWhere('b.status = :status', { status: query.status });
|
||||
const bills = await qb.getMany();
|
||||
|
||||
// 查询涉及学生的"已缴未退"押金,用于导出押金抵扣字段
|
||||
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
||||
const depMap = new Map<number, number>();
|
||||
if (studentIds.length > 0) {
|
||||
const deposits = await this.depositRepo
|
||||
.createQueryBuilder('d')
|
||||
.where('d.studentId IN (:...ids)', { ids: studentIds })
|
||||
.andWhere('d.status = :status', { status: 'paid' })
|
||||
.getMany();
|
||||
for (const d of deposits) {
|
||||
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0));
|
||||
}
|
||||
}
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = '恭学教育基地管理系统';
|
||||
|
||||
@@ -60,9 +46,8 @@ export class BillsExportService {
|
||||
{ header: '分摊费用', key: 'shared', width: 12 },
|
||||
{ header: '个人费用', key: 'personal', width: 12 },
|
||||
{ header: '总金额', key: 'total', width: 12 },
|
||||
{ header: '可用押金', key: 'deposit', width: 12 },
|
||||
{ header: '押金抵扣', key: 'depositApplied', width: 12 },
|
||||
{ header: '抵扣后应付', key: 'afterDeposit', width: 14 },
|
||||
{ header: '已扣余额', key: 'paidAmount', width: 12 },
|
||||
{ header: '待补缴', key: 'outstandingAmount', width: 12 },
|
||||
{ header: '状态', key: 'status', width: 10 },
|
||||
{ header: '生成时间', key: 'generatedAt', width: 20 },
|
||||
];
|
||||
@@ -71,15 +56,13 @@ export class BillsExportService {
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
confirmed: '已确认',
|
||||
unpaid: '待支付',
|
||||
partially_paid: '部分支付',
|
||||
paid: '已结清',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
for (const bill of bills) {
|
||||
const total = Number(bill.totalAmount || 0);
|
||||
const dep = Number((depMap.get(bill.studentId) || 0).toFixed(2));
|
||||
const applied = Number(Math.min(dep, total).toFixed(2));
|
||||
const after = Number(Math.max(0, total - applied).toFixed(2));
|
||||
ws.addRow({
|
||||
id: bill.id,
|
||||
studentName: (bill as any).student?.name || '-',
|
||||
@@ -87,9 +70,8 @@ export class BillsExportService {
|
||||
shared: Number(bill.sharedAmount),
|
||||
personal: Number(bill.personalAmount),
|
||||
total,
|
||||
deposit: dep,
|
||||
depositApplied: applied,
|
||||
afterDeposit: after,
|
||||
paidAmount: Number(bill.paidAmount || 0),
|
||||
outstandingAmount: Number(bill.outstandingAmount || 0),
|
||||
status: statusMap[bill.status] || bill.status,
|
||||
generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '',
|
||||
});
|
||||
@@ -147,16 +129,9 @@ export class BillsExportService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 查询该学生的可用押金(已缴未退)
|
||||
const deposits = await this.depositRepo
|
||||
.createQueryBuilder('d')
|
||||
.where('d.studentId = :sid', { sid: bill.studentId })
|
||||
.andWhere('d.status = :status', { status: 'paid' })
|
||||
.getMany();
|
||||
const availableDeposit = deposits.reduce((s, d) => s + Number(d.amount || 0), 0);
|
||||
const totalAmount = Number(bill.totalAmount || 0);
|
||||
const depositApplied = Math.min(availableDeposit, totalAmount);
|
||||
const amountAfterDeposit = Math.max(0, totalAmount - depositApplied);
|
||||
const paidAmount = Number(bill.paidAmount || 0);
|
||||
const outstandingAmount = Number(bill.outstandingAmount || 0);
|
||||
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
@@ -191,9 +166,10 @@ export class BillsExportService {
|
||||
}
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
confirmed: '已确认',
|
||||
unpaid: '待支付',
|
||||
partially_paid: '部分支付',
|
||||
paid: '已结清',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
|
||||
// 标题
|
||||
@@ -223,20 +199,8 @@ export class BillsExportService {
|
||||
.fillColor('#007AFF')
|
||||
.text(`应付总额: ¥${totalAmount.toFixed(2)}`);
|
||||
doc.moveDown(0.3);
|
||||
if (availableDeposit > 0) {
|
||||
doc
|
||||
.fontSize(11)
|
||||
.fillColor('#52C41A')
|
||||
.text(`可用押金: ¥${availableDeposit.toFixed(2)}`);
|
||||
doc
|
||||
.fontSize(11)
|
||||
.fillColor('#FA8C16')
|
||||
.text(`押金抵扣: -¥${depositApplied.toFixed(2)}`);
|
||||
doc
|
||||
.fontSize(14)
|
||||
.fillColor('#FF3B30')
|
||||
.text(`抵扣后应付: ¥${amountAfterDeposit.toFixed(2)}`);
|
||||
}
|
||||
doc.fontSize(11).fillColor('#389E0D').text(`已扣余额: ¥${paidAmount.toFixed(2)}`);
|
||||
doc.fontSize(14).fillColor(outstandingAmount > 0 ? '#FF3B30' : '#389E0D').text(`待补缴: ¥${outstandingAmount.toFixed(2)}`);
|
||||
doc.moveDown(1);
|
||||
|
||||
// 明细表格
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Request,
|
||||
Res,
|
||||
Req,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
@@ -20,7 +21,7 @@ import { NotificationType } from '../entities/notification.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { BillsExportService } from './bills-export.service';
|
||||
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||||
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
@@ -49,7 +50,7 @@ export class BillsController {
|
||||
username: req.user?.username,
|
||||
module: '账单管理',
|
||||
action: '生成账单',
|
||||
detail: `周期 ${dto.periodStart}~${dto.periodEnd}, 生成 ${result.count} 条`,
|
||||
detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count} 条`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
@@ -62,7 +63,7 @@ export class BillsController {
|
||||
recipientIds: [student.userId],
|
||||
type: NotificationType.BILL_GENERATED,
|
||||
title: '新账单',
|
||||
content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${dto.periodStart}~${dto.periodEnd}`,
|
||||
content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${result.periodStart}~${result.periodEnd}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -75,38 +76,38 @@ export class BillsController {
|
||||
findAll(
|
||||
@Query('periodStart') periodStart?: string,
|
||||
@Query('periodEnd') periodEnd?: string,
|
||||
@Query('studentId') studentId?: string,
|
||||
@Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
|
||||
@Query('status') status?: string,
|
||||
@Query('expenseType') expenseType?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
periodStart, periodEnd,
|
||||
studentId: studentId ? +studentId : undefined,
|
||||
studentId,
|
||||
status, expenseType,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('bill:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(+id);
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@RequirePermission('bill:confirm')
|
||||
async updateStatus(
|
||||
@Param('id') id: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateBillStatusDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.updateStatus(+id, dto);
|
||||
const result = await this.service.updateStatus(id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '账单管理',
|
||||
action: '确认账单',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'bill',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -158,17 +159,36 @@ export class BillsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@RequirePermission('bill:delete')
|
||||
async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: any) {
|
||||
const result = await this.service.cancel(id, dto, req.user?.id);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '账单管理',
|
||||
action: '取消账单并冲正',
|
||||
targetId: id,
|
||||
targetType: 'bill',
|
||||
detail: dto.reason,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('bill:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(+id);
|
||||
const result = await this.service.remove(id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '账单管理',
|
||||
action: '删除账单',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'bill',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -198,7 +218,7 @@ export class BillsController {
|
||||
async exportExcel(
|
||||
@Query('periodStart') periodStart?: string,
|
||||
@Query('periodEnd') periodEnd?: string,
|
||||
@Query('studentId') studentId?: string,
|
||||
@Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
|
||||
@Query('status') status?: string,
|
||||
@Res() res?: Response,
|
||||
@Req() req?: any,
|
||||
@@ -217,7 +237,7 @@ export class BillsController {
|
||||
{
|
||||
periodStart,
|
||||
periodEnd,
|
||||
studentId: studentId ? +studentId : undefined,
|
||||
studentId,
|
||||
status,
|
||||
},
|
||||
res!,
|
||||
@@ -226,18 +246,18 @@ export class BillsController {
|
||||
|
||||
@Get('export/pdf/:id')
|
||||
@RequirePermission('bill:export-pdf')
|
||||
async exportPdf(@Param('id') id: string, @Res() res: Response, @Req() req: any) {
|
||||
async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req?.user?.id,
|
||||
username: req?.user?.username,
|
||||
module: '账单管理',
|
||||
action: '导出账单',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'bill',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return this.exportService.exportStudentPdf(+id, res);
|
||||
return this.exportService.exportStudentPdf(id, res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { WalletsModule } from '../wallets/wallets.module';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { BillItem } from '../entities/bill-item.entity';
|
||||
@@ -7,8 +8,8 @@ import { RoomExpense } from '../entities/room-expense.entity';
|
||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { BillsService } from './bills.service';
|
||||
import { BillsExportService } from './bills-export.service';
|
||||
import { BillsController } from './bills.controller';
|
||||
@@ -22,10 +23,11 @@ import { BillsController } from './bills.controller';
|
||||
PersonalExpense,
|
||||
Occupancy,
|
||||
Room,
|
||||
Deposit,
|
||||
Student,
|
||||
Deposit,
|
||||
]),
|
||||
NotificationsModule,
|
||||
WalletsModule,
|
||||
],
|
||||
controllers: [BillsController],
|
||||
providers: [BillsService, BillsExportService],
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { WalletsService } from '../wallets/wallets.service';
|
||||
|
||||
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
|
||||
|
||||
@@ -56,7 +57,23 @@ describe('BillsService — generateBills', () => {
|
||||
occRepo = mockRepo<Occupancy>();
|
||||
roomRepo = mockRepo<Room>();
|
||||
depositRepo = mockRepo<Deposit>();
|
||||
dataSource = { transaction: jest.fn(), query: jest.fn().mockResolvedValue([]) };
|
||||
let nextBillId = 0;
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (callback) => callback({
|
||||
create: (_entity: unknown, value: unknown) => value,
|
||||
save: jest.fn(async (value: any) => {
|
||||
if ('totalAmount' in value && 'studentId' in value) {
|
||||
const saved = { id: ++nextBillId, ...value };
|
||||
await (billRepo.save as jest.Mock)(saved);
|
||||
return saved;
|
||||
}
|
||||
await (itemRepo.save as jest.Mock)(value);
|
||||
return { id: value.id || 1, ...value };
|
||||
}),
|
||||
createQueryBuilder: jest.fn(() => ({ update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })),
|
||||
})),
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -69,6 +86,7 @@ describe('BillsService — generateBills', () => {
|
||||
{ provide: getRepositoryToken(Room), useValue: roomRepo },
|
||||
{ provide: getRepositoryToken(Deposit), useValue: depositRepo },
|
||||
{ provide: DataSource, useValue: dataSource },
|
||||
{ provide: WalletsService, useValue: { debitBill: jest.fn(async (_manager, bill) => bill), refundBill: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -166,6 +184,43 @@ describe('BillsService — generateBills', () => {
|
||||
).toBeCloseTo(300, 0);
|
||||
});
|
||||
|
||||
it('includes room expenses whose periods are inside the generated bill period', async () => {
|
||||
const qb = mockQueryBuilder<RoomExpense>([
|
||||
{
|
||||
id: 1, roomId: 1, expenseType: 'water',
|
||||
amount: '300' as unknown as number, periodStart: '2026-07-01', periodEnd: '2026-07-31',
|
||||
} as RoomExpense,
|
||||
]);
|
||||
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
|
||||
|
||||
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<Occupancy>([
|
||||
{
|
||||
id: 1, studentId: 10, roomId: 1,
|
||||
billingStartDate: '2026-07-01', billingEndDate: null as unknown as string,
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
]),
|
||||
);
|
||||
|
||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<PersonalExpense>([]),
|
||||
);
|
||||
|
||||
const result = await service.generateBills({
|
||||
periodStart: '2026-06-29',
|
||||
periodEnd: '2026-07-31',
|
||||
});
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
expect(qb.where).toHaveBeenCalledWith(
|
||||
'e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd',
|
||||
{ periodStart: '2026-06-29', periodEnd: '2026-07-31' },
|
||||
);
|
||||
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
|
||||
expect(Number(savedCalls[0][0].sharedAmount)).toBeCloseTo(300, 0);
|
||||
});
|
||||
|
||||
it('mixed → long-term get individual bills, short-term share expenses', async () => {
|
||||
// Room 1: two expenses
|
||||
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In, DataSource } from 'typeorm';
|
||||
import { Repository, In, DataSource, EntityManager } from 'typeorm';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { BillItem } from '../entities/bill-item.entity';
|
||||
import { RoomExpense } from '../entities/room-expense.entity';
|
||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||||
import { StudentWallet } from '../entities/student-wallet.entity';
|
||||
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||||
import { WalletsService } from '../wallets/wallets.service';
|
||||
|
||||
|
||||
@Injectable()
|
||||
@@ -20,24 +21,34 @@ export class BillsService {
|
||||
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
|
||||
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
|
||||
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
||||
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
|
||||
private dataSource: DataSource,
|
||||
private walletsService: WalletsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 核心计费引擎:按"人天数"加权分摊
|
||||
*/
|
||||
async generateBills(dto: GenerateBillsDto) {
|
||||
const { periodStart, periodEnd } = dto;
|
||||
const { periodStart, periodEnd } = dto.billingMonth
|
||||
? this.resolveBillingPeriod(dto.billingMonth)
|
||||
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
|
||||
const pStart = new Date(periodStart);
|
||||
const pEnd = new Date(periodEnd);
|
||||
|
||||
// 删除该周期已有的草稿账单
|
||||
const existingDrafts = await this.billRepo.find({
|
||||
where: { periodStart, periodEnd, status: 'draft' },
|
||||
});
|
||||
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
|
||||
if (existingBills.length > 0) {
|
||||
throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`);
|
||||
}
|
||||
|
||||
const existingDrafts: Bill[] = [];
|
||||
if (existingDrafts.length > 0) {
|
||||
const draftIds = existingDrafts.map((b) => b.id);
|
||||
await this.personalExpRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ billId: null })
|
||||
.where('billId IN (:...ids)', { ids: draftIds })
|
||||
.execute();
|
||||
await this.itemRepo
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
@@ -53,7 +64,7 @@ export class BillsService {
|
||||
// 获取所有有费用的宿舍
|
||||
const roomExpenses = await this.roomExpRepo
|
||||
.createQueryBuilder('e')
|
||||
.where('e.periodStart = :periodStart AND e.periodEnd = :periodEnd', {
|
||||
.where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
})
|
||||
@@ -158,6 +169,7 @@ export class BillsService {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
})
|
||||
.andWhere('pe.billId IS NULL')
|
||||
.getMany();
|
||||
|
||||
const personalMap = new Map<number, number>();
|
||||
@@ -186,29 +198,100 @@ export class BillsService {
|
||||
const personal = personalMap.get(studentId) || 0;
|
||||
const total = Number((shared + personal).toFixed(2));
|
||||
|
||||
const bill = this.billRepo.create({
|
||||
studentId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
sharedAmount: Number(shared.toFixed(2)),
|
||||
personalAmount: personal,
|
||||
totalAmount: total,
|
||||
status: 'draft',
|
||||
const savedBill = await this.dataSource.transaction(async (manager) => {
|
||||
let bill = await manager.save(
|
||||
manager.create(Bill, {
|
||||
studentId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
sharedAmount: Number(shared.toFixed(2)),
|
||||
personalAmount: personal,
|
||||
totalAmount: total,
|
||||
source: 'batch',
|
||||
paidAmount: 0,
|
||||
outstandingAmount: total,
|
||||
status: 'unpaid',
|
||||
}),
|
||||
);
|
||||
const items = [
|
||||
...(studentBillData.get(studentId)?.items || []),
|
||||
...(personalItems.get(studentId) || []),
|
||||
];
|
||||
for (const item of items) {
|
||||
await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));
|
||||
}
|
||||
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
|
||||
if (includedPersonal.length) {
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.update(PersonalExpense)
|
||||
.set({ billId: bill.id })
|
||||
.where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) })
|
||||
.execute();
|
||||
}
|
||||
bill = await this.walletsService.debitBill(manager, bill);
|
||||
return bill;
|
||||
});
|
||||
const savedBill = await this.billRepo.save(bill);
|
||||
|
||||
// 保存明细
|
||||
const items = [
|
||||
...(studentBillData.get(studentId)?.items || []),
|
||||
...(personalItems.get(studentId) || []),
|
||||
];
|
||||
for (const item of items) {
|
||||
await this.itemRepo.save(this.itemRepo.create({ ...item, billId: savedBill.id }));
|
||||
}
|
||||
bills.push(savedBill);
|
||||
}
|
||||
|
||||
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills };
|
||||
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd };
|
||||
}
|
||||
|
||||
private resolveBillingPeriod(billingMonth: string) {
|
||||
const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || '');
|
||||
if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
|
||||
const year = Number(matched[1]);
|
||||
const month = Number(matched[2]);
|
||||
if (month < 1 || month > 12) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
|
||||
const targetMonthStart = new Date(year, month - 1, 1);
|
||||
const currentMonthStart = new Date();
|
||||
currentMonthStart.setDate(1);
|
||||
currentMonthStart.setHours(0, 0, 0, 0);
|
||||
if (targetMonthStart >= currentMonthStart) throw new BadRequestException('只能生成已结束月份的账单');
|
||||
const targetMonthEnd = new Date(year, month, 0);
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return { periodStart: `${year}-${pad(month)}-01`, periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}` };
|
||||
}
|
||||
|
||||
async createImmediatePersonalBill(
|
||||
expense: PersonalExpense,
|
||||
periodStart: string,
|
||||
periodEnd: string,
|
||||
recordedBy?: number,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
let bill = await manager.save(
|
||||
manager.create(Bill, {
|
||||
studentId: expense.studentId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
sharedAmount: 0,
|
||||
personalAmount: Number(expense.amount),
|
||||
totalAmount: Number(expense.amount),
|
||||
source: 'student_utility',
|
||||
paidAmount: 0,
|
||||
outstandingAmount: Number(expense.amount),
|
||||
status: 'unpaid',
|
||||
}),
|
||||
);
|
||||
await manager.save(
|
||||
manager.create(BillItem, {
|
||||
billId: bill.id,
|
||||
roomId: expense.roomId,
|
||||
expenseType: expense.expenseType,
|
||||
description: expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'),
|
||||
days: 0,
|
||||
totalRoomDays: 0,
|
||||
roomTotalAmount: expense.amount,
|
||||
studentAmount: expense.amount,
|
||||
}),
|
||||
);
|
||||
expense.billId = bill.id;
|
||||
await manager.save(expense);
|
||||
bill = await this.walletsService.debitBill(manager, bill, recordedBy);
|
||||
return bill;
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(query?: {
|
||||
@@ -240,46 +323,39 @@ export class BillsService {
|
||||
return withDeposit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给账单挂上"押金联动"信息:
|
||||
* - availableDeposit: 当前学生处于已缴未退状态(paid)的押金总额
|
||||
* - depositApplied: 本张账单可从押金抵扣的金额(min(押金, 应付总额))
|
||||
* - amountAfterDeposit: 抵扣押金后学生需另外支付的金额
|
||||
*/
|
||||
/** 查询时附加钱包余额和实际支付数据。 */
|
||||
private async attachDepositInfo(bills: Bill[]): Promise<any[]> {
|
||||
if (!bills || bills.length === 0) return bills;
|
||||
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
||||
if (studentIds.length === 0) return bills;
|
||||
const deposits = await this.depositRepo
|
||||
.createQueryBuilder('d')
|
||||
.where('d.studentId IN (:...ids)', { ids: studentIds })
|
||||
.andWhere('d.status = :status', { status: 'paid' })
|
||||
if (!bills?.length) return bills;
|
||||
const studentIds = Array.from(new Set(bills.map((bill) => bill.studentId)));
|
||||
const wallets = await this.dataSource
|
||||
.getRepository(StudentWallet)
|
||||
.createQueryBuilder('wallet')
|
||||
.where('wallet.studentId IN (:...ids)', { ids: studentIds })
|
||||
.getMany();
|
||||
const depMap = new Map<number, number>();
|
||||
for (const d of deposits) {
|
||||
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0));
|
||||
}
|
||||
return bills.map((b) => {
|
||||
const total = Number(b.totalAmount || 0);
|
||||
const available = Number((depMap.get(b.studentId) || 0).toFixed(2));
|
||||
const applied = Number(Math.min(available, total).toFixed(2));
|
||||
const afterDeposit = Number(Math.max(0, total - applied).toFixed(2));
|
||||
return Object.assign({}, b, {
|
||||
availableDeposit: available,
|
||||
depositApplied: applied,
|
||||
amountAfterDeposit: afterDeposit,
|
||||
});
|
||||
});
|
||||
const balanceMap = new Map(wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]));
|
||||
return bills.map((bill) => ({
|
||||
...bill,
|
||||
walletBalance: Number((balanceMap.get(bill.studentId) || 0).toFixed(2)),
|
||||
paidAmount: Number(bill.paidAmount || 0),
|
||||
outstandingAmount: Number(bill.outstandingAmount || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
async updateStatus(id: number, dto: UpdateBillStatusDto) {
|
||||
const bill = await this.billRepo.findOne({ where: { id } });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
if (dto.status === 'paid' && Number(bill.outstandingAmount) > 0) {
|
||||
throw new BadRequestException('存在未付金额,不能直接标记为已支付');
|
||||
}
|
||||
bill.status = dto.status;
|
||||
return this.billRepo.save(bill);
|
||||
}
|
||||
|
||||
async batchUpdateStatus(ids: number[], status: string) {
|
||||
const bills = await this.billRepo.find({ where: { id: In(ids) } });
|
||||
if (status === 'paid' && bills.some((bill) => Number(bill.outstandingAmount) > 0)) {
|
||||
throw new BadRequestException('选中账单存在未付金额,不能直接标记为已支付');
|
||||
}
|
||||
await this.billRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
@@ -289,18 +365,38 @@ export class BillsService {
|
||||
return { message: `成功更新 ${ids.length} 条账单状态` };
|
||||
}
|
||||
|
||||
async cancel(id: number, dto: CancelBillDto, recordedBy?: number) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const bill = await manager.findOne(Bill, { where: { id } });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消');
|
||||
await manager.update(PersonalExpense, { billId: id }, { billId: null });
|
||||
return this.walletsService.refundBill(manager, bill, dto.reason, recordedBy);
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const exists = await this.billRepo.findOne({ where: { id } });
|
||||
if (!exists) throw new NotFoundException('账单不存在');
|
||||
if (Number(exists.paidAmount) > 0 || exists.status === 'cancelled') {
|
||||
throw new BadRequestException('已发生资金流水的账单不能删除,请使用取消账单');
|
||||
}
|
||||
await this.itemRepo.delete({ billId: id });
|
||||
await this.personalExpRepo.update({ billId: id }, { billId: null });
|
||||
await this.billRepo.delete(id);
|
||||
return { message: '账单已删除' };
|
||||
}
|
||||
|
||||
async batchRemove(ids: number[]) {
|
||||
await this.itemRepo
|
||||
const bills = await this.billRepo.find({ where: { id: In(ids) } });
|
||||
if (bills.some((bill) => Number(bill.paidAmount) > 0 || bill.status === 'cancelled')) {
|
||||
throw new BadRequestException('选中账单包含资金流水,不能批量删除');
|
||||
}
|
||||
await this.itemRepo.createQueryBuilder().delete().where('billId IN (:...ids)', { ids }).execute();
|
||||
await this.personalExpRepo
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.update()
|
||||
.set({ billId: null })
|
||||
.where('billId IN (:...ids)', { ids })
|
||||
.execute();
|
||||
await this.billRepo.createQueryBuilder().delete().where('id IN (:...ids)', { ids }).execute();
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class GenerateBillsDto {
|
||||
@IsString()
|
||||
periodStart: string; // YYYY-MM-DD
|
||||
@Matches(/^\d{4}-\d{2}$/)
|
||||
billingMonth: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
periodEnd: string; // YYYY-MM-DD
|
||||
periodStart?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
periodEnd?: string;
|
||||
}
|
||||
|
||||
export class UpdateBillStatusDto {
|
||||
@IsString()
|
||||
status: 'draft' | 'confirmed' | 'paid';
|
||||
@IsIn(['unpaid', 'partially_paid', 'paid'])
|
||||
status: 'unpaid' | 'partially_paid' | 'paid';
|
||||
}
|
||||
|
||||
export class CancelBillDto {
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class BatchUpdateBillStatusDto extends UpdateBillStatusDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsInt({ each: true })
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.ensureAiConfigTable();
|
||||
await this.ensureCourseAttendanceSchema();
|
||||
await this.ensureStudentWalletSchema();
|
||||
await this.backfillOrganizations();
|
||||
await this.normalizeClassDates();
|
||||
await this.protectAttendanceHistory();
|
||||
@@ -21,6 +22,49 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
await this.normalizeClassroomStatuses();
|
||||
}
|
||||
|
||||
private async ensureStudentWalletSchema(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
||||
const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
|
||||
await runner.query(`CREATE TABLE IF NOT EXISTS student_wallets (
|
||||
id ${pk}, student_id INTEGER NOT NULL UNIQUE, balance DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
await runner.query(`CREATE TABLE IF NOT EXISTS wallet_transactions (
|
||||
id ${pk}, student_id INTEGER NOT NULL, bill_id INTEGER, type VARCHAR(30) NOT NULL,
|
||||
amount DECIMAL(12,2) NOT NULL, balance_after DECIMAL(12,2) NOT NULL,
|
||||
description VARCHAR(300), recorded_by INTEGER,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
const bills = await runner.getTable('bills');
|
||||
if (bills) {
|
||||
const columns = new Set(bills.columns.map((column) => column.name));
|
||||
const additions = [
|
||||
['source', "VARCHAR(30) NOT NULL DEFAULT 'batch'"],
|
||||
['paid_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'],
|
||||
['outstanding_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'],
|
||||
['cancelled_at', 'DATETIME'],
|
||||
['cancel_reason', 'VARCHAR(300)'],
|
||||
];
|
||||
for (const [name, definition] of additions) {
|
||||
if (!columns.has(name)) await runner.query(`ALTER TABLE bills ADD COLUMN ${name} ${definition}`);
|
||||
}
|
||||
await runner.query("UPDATE bills SET outstanding_amount = total_amount WHERE outstanding_amount = 0 AND status <> 'paid'");
|
||||
await runner.query("UPDATE bills SET paid_amount = total_amount, outstanding_amount = 0 WHERE status = 'paid'");
|
||||
await runner.query("UPDATE bills SET status = 'unpaid' WHERE status IN ('draft', 'confirmed')");
|
||||
}
|
||||
const personalExpenses = await runner.getTable('personal_expenses');
|
||||
if (personalExpenses && !personalExpenses.columns.some((column) => column.name === 'bill_id')) {
|
||||
await runner.query('ALTER TABLE personal_expenses ADD COLUMN bill_id INTEGER');
|
||||
}
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async removeUnusedClassroomColumns(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
@@ -216,7 +260,11 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const tables = await runner.getTables(['attendance_records', 'attendance_sessions']);
|
||||
const tables = await runner.getTables([
|
||||
'class_schedule',
|
||||
'attendance_records',
|
||||
'attendance_sessions',
|
||||
]);
|
||||
const tableNames = new Set(tables.map((table) => table.name));
|
||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
||||
|
||||
@@ -243,6 +291,17 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
`);
|
||||
}
|
||||
|
||||
if (tableNames.has('class_schedule')) {
|
||||
const scheduleTable = await runner.getTable('class_schedule');
|
||||
const scheduleColumns = new Set(scheduleTable?.columns.map((column) => column.name) ?? []);
|
||||
if (!scheduleColumns.has('attendance_advance_minutes')) {
|
||||
await runner.query(
|
||||
'ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes INTEGER NOT NULL DEFAULT 30',
|
||||
);
|
||||
this.logger.log('已为排课添加课前签到分钟配置');
|
||||
}
|
||||
}
|
||||
|
||||
const attendanceTable = await runner.getTable('attendance_records');
|
||||
const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []);
|
||||
if (!columnNames.has('schedule_id')) {
|
||||
|
||||
@@ -209,6 +209,28 @@ describe('DatabaseMigrationsService — course attendance schema', () => {
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds the configurable attendance window to existing schedules', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [
|
||||
{ name: 'class_schedule', columns: [{ name: 'id' }] },
|
||||
{ name: 'attendance_records', columns: [{ name: 'id' }] },
|
||||
{ name: 'attendance_sessions', columns: [{ name: 'id' }] },
|
||||
],
|
||||
});
|
||||
runner.getTable.mockImplementation(async (name: string) =>
|
||||
name === 'class_schedule'
|
||||
? { name, columns: [{ name: 'id' }] }
|
||||
: { name, columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }] },
|
||||
);
|
||||
await bootstrapCourseAttendance(runner);
|
||||
|
||||
await service.ensureCourseAttendanceSchema();
|
||||
|
||||
expect(runner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes'),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -16,7 +17,12 @@ import { Student } from '../entities/student.entity';
|
||||
import { DepositsService } from './deposits.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationType } from '../entities/notification.entity';
|
||||
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||
import {
|
||||
CreateDepositDto,
|
||||
CreateDepositInstallmentDto,
|
||||
RefundDepositDto,
|
||||
UpdateDepositInstallmentDto,
|
||||
} from './dto/deposit.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
@@ -40,9 +46,12 @@ export class DepositsController {
|
||||
|
||||
@Get()
|
||||
@RequirePermission('deposit:view')
|
||||
findAll(@Query('studentId') studentId?: string, @Query('status') status?: string) {
|
||||
findAll(
|
||||
@Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
studentId: studentId ? +studentId : undefined,
|
||||
studentId,
|
||||
status: status || undefined,
|
||||
});
|
||||
}
|
||||
@@ -55,8 +64,8 @@ export class DepositsController {
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('deposit:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(+id);
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -93,12 +102,12 @@ export class DepositsController {
|
||||
@Post(':id/installments')
|
||||
@RequirePermission('deposit:edit')
|
||||
async addInstallment(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { amount: number; dueDate: string },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: CreateDepositInstallmentDto,
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.addInstallment(+id, body.amount, body.dueDate);
|
||||
const result = await this.service.addInstallment(id, body.amount, body.dueDate);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
@@ -116,18 +125,18 @@ export class DepositsController {
|
||||
@Put('installments/:installmentId')
|
||||
@RequirePermission('deposit:edit')
|
||||
async updateInstallment(
|
||||
@Param('installmentId') installmentId: string,
|
||||
@Body() body: { paidDate?: string; status?: string },
|
||||
@Param('installmentId', ParseIntPipe) installmentId: number,
|
||||
@Body() body: UpdateDepositInstallmentDto,
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.updateInstallment(+installmentId, body);
|
||||
const result = await this.service.updateInstallment(installmentId, body);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '更新分期',
|
||||
targetId: +installmentId,
|
||||
targetId: installmentId,
|
||||
targetType: 'deposit-installment',
|
||||
detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
|
||||
ipAddress,
|
||||
@@ -139,17 +148,17 @@ export class DepositsController {
|
||||
@Delete('installments/:installmentId')
|
||||
@RequirePermission('deposit:delete')
|
||||
async deleteInstallment(
|
||||
@Param('installmentId') installmentId: string,
|
||||
@Param('installmentId', ParseIntPipe) installmentId: number,
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.deleteInstallment(+installmentId);
|
||||
const result = await this.service.deleteInstallment(installmentId);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '删除分期',
|
||||
targetId: +installmentId,
|
||||
targetId: installmentId,
|
||||
targetType: 'deposit-installment',
|
||||
detail: `删除分期${installmentId}`,
|
||||
ipAddress,
|
||||
@@ -160,17 +169,17 @@ export class DepositsController {
|
||||
|
||||
@Put(':id/refund')
|
||||
@RequirePermission('deposit:refund')
|
||||
async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) {
|
||||
async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.refund(+id, dto, req.user?.id);
|
||||
const result = await this.service.refund(id, dto, req.user?.id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '退还押金',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'deposit',
|
||||
detail: `退还¥${result.refundAmount}, 扣除¥${result.deductionAmount}`,
|
||||
detail: `退还全部可用押金 ¥${result.refundAmount}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
@@ -182,7 +191,7 @@ export class DepositsController {
|
||||
recipientIds: [student.userId],
|
||||
type: 'deposit_refunded',
|
||||
title: '押金已退还',
|
||||
content: `您的押金已退还,退还¥${result.refundAmount},扣除¥${result.deductionAmount}`,
|
||||
content: `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`,
|
||||
});
|
||||
}
|
||||
} catch (_) { /* don't block response */ }
|
||||
@@ -191,15 +200,15 @@ export class DepositsController {
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('deposit:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(+id);
|
||||
const result = await this.service.remove(id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '删除押金记录',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'deposit',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
|
||||
@@ -5,7 +5,7 @@ describe('DepositsService permission-scoped lookups', () => {
|
||||
const studentRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 2, name: '张三', studentNo: 'S2' }]),
|
||||
};
|
||||
const service = new DepositsService({} as never, {} as never, studentRepo as never);
|
||||
const service = new DepositsService({} as never, {} as never, studentRepo as never, {} as never);
|
||||
|
||||
await expect(service.getStudentLookups()).resolves.toEqual([
|
||||
{ id: 2, name: '张三', studentNo: 'S2' },
|
||||
|
||||
@@ -3,13 +3,18 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
import { DepositsService } from './deposits.service';
|
||||
import { DepositsController } from './deposits.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Deposit, DepositInstallment, Student, PersonalExpense]),
|
||||
OperationLogsModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [DepositsController],
|
||||
providers: [DepositsService],
|
||||
exports: [DepositsService],
|
||||
|
||||
@@ -2,9 +2,10 @@ import { DepositsService } from './deposits.service';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
|
||||
describe('DepositsService — direct refund', () => {
|
||||
it('stores the refund result on the main status and renamed audit fields', async () => {
|
||||
it('deducts unbilled personal expenses before refunding the remaining balance', async () => {
|
||||
const deposit = {
|
||||
id: 1,
|
||||
studentId: 10,
|
||||
amount: 500,
|
||||
status: 'paid',
|
||||
} as Deposit;
|
||||
@@ -12,24 +13,32 @@ describe('DepositsService — direct refund', () => {
|
||||
findOne: jest.fn().mockResolvedValue(deposit),
|
||||
save: jest.fn().mockImplementation(async (value: Deposit) => value),
|
||||
};
|
||||
const service = new DepositsService(repo as never, {} as never, {} as never);
|
||||
const personalExpenseRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue({
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([{ studentId: 10, amount: '120' }]),
|
||||
}),
|
||||
};
|
||||
const service = new DepositsService(repo as never, {} as never, {} as never, personalExpenseRepo as never);
|
||||
|
||||
const result = await service.refund(
|
||||
1,
|
||||
{
|
||||
refundDate: '2026-07-13',
|
||||
deductionAmount: 100,
|
||||
deductionReason: '物品损坏',
|
||||
},
|
||||
{ refundDate: '2026-07-13', notes: '退还剩余押金' },
|
||||
42,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
refundDate: '2026-07-13',
|
||||
refundAmount: 400,
|
||||
deductionAmount: 100,
|
||||
deductionReason: '物品损坏',
|
||||
status: 'partial_refund',
|
||||
amount: 0,
|
||||
refundAmount: 380,
|
||||
deductionAmount: 120,
|
||||
deductionReason: '自动扣除个人附加费用 ¥120.00',
|
||||
notes: '退还剩余押金',
|
||||
status: 'refunded',
|
||||
refundedBy: 42,
|
||||
});
|
||||
expect(result.refundedAt).toBeInstanceOf(Date);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Repository } from 'typeorm';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
|
||||
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||
|
||||
@@ -16,6 +17,8 @@ export class DepositsService {
|
||||
private installmentRepo: Repository<DepositInstallment>,
|
||||
@InjectRepository(Student)
|
||||
private studentRepo: Repository<Student>,
|
||||
@InjectRepository(PersonalExpense)
|
||||
private personalExpenseRepo: Repository<PersonalExpense>,
|
||||
) {}
|
||||
|
||||
async getStudentLookups() {
|
||||
@@ -34,28 +37,42 @@ export class DepositsService {
|
||||
.orderBy('d.createdAt', 'DESC');
|
||||
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
|
||||
if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
|
||||
return qb.getMany();
|
||||
const deposits = await qb.getMany();
|
||||
return this.attachPersonalExpenseAmount(deposits);
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
return deposit;
|
||||
const [withPersonalExpense] = await this.attachPersonalExpenseAmount([deposit]);
|
||||
return withPersonalExpense;
|
||||
}
|
||||
|
||||
async create(dto: CreateDepositDto, userId?: number) {
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
const deposit = this.repo.create({
|
||||
studentId: dto.studentId,
|
||||
amount: dto.amount,
|
||||
paidDate: dto.paidDate,
|
||||
notes: dto.notes,
|
||||
status: 'paid',
|
||||
recordedBy: userId,
|
||||
});
|
||||
if (Number(dto.amount) <= 0) throw new BadRequestException('收取金额必须大于0');
|
||||
|
||||
return this.repo.save(deposit);
|
||||
const existing = await this.repo.findOne({ where: { studentId: dto.studentId } });
|
||||
if (existing) {
|
||||
existing.amount = Number((Number(existing.amount || 0) + Number(dto.amount)).toFixed(2));
|
||||
existing.paidDate = dto.paidDate;
|
||||
existing.status = 'paid';
|
||||
existing.recordedBy = userId ?? null;
|
||||
if (dto.notes) existing.notes = dto.notes;
|
||||
return this.repo.save(existing);
|
||||
}
|
||||
|
||||
return this.repo.save(
|
||||
this.repo.create({
|
||||
studentId: dto.studentId,
|
||||
amount: dto.amount,
|
||||
paidDate: dto.paidDate,
|
||||
notes: dto.notes,
|
||||
status: 'paid',
|
||||
recordedBy: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async addInstallment(depositId: number, amount: number, dueDate: string) {
|
||||
@@ -90,18 +107,22 @@ export class DepositsService {
|
||||
async refund(id: number, dto: RefundDepositDto, userId?: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
if (deposit.status !== 'paid') throw new BadRequestException('该押金已处理');
|
||||
if (deposit.status !== 'paid' || Number(deposit.amount) <= 0) {
|
||||
throw new BadRequestException('该学生当前没有可退押金');
|
||||
}
|
||||
|
||||
const deduction = dto.deductionAmount || 0;
|
||||
const refundAmount = Number(deposit.amount) - deduction;
|
||||
if (refundAmount < 0) throw new BadRequestException('扣除金额不能大于押金金额');
|
||||
const depositAmount = Number(deposit.amount);
|
||||
const personalExpenseAmount = await this.getPersonalExpenseAmount(deposit.studentId);
|
||||
const deductionAmount = Number(Math.min(depositAmount, personalExpenseAmount).toFixed(2));
|
||||
const refundAmount = Number((depositAmount - deductionAmount).toFixed(2));
|
||||
|
||||
deposit.refundDate = dto.refundDate;
|
||||
deposit.deductionAmount = deduction;
|
||||
deposit.deductionReason = dto.deductionReason || '';
|
||||
deposit.refundAmount = refundAmount;
|
||||
deposit.status =
|
||||
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
|
||||
deposit.deductionAmount = deductionAmount;
|
||||
deposit.deductionReason =
|
||||
deductionAmount > 0 ? `自动扣除个人附加费用 ¥${deductionAmount.toFixed(2)}` : '';
|
||||
deposit.amount = 0;
|
||||
deposit.status = refundAmount > 0 ? 'refunded' : 'depleted';
|
||||
if (dto.notes) deposit.notes = dto.notes;
|
||||
deposit.refundedBy = userId ?? null;
|
||||
deposit.refundedAt = new Date();
|
||||
@@ -125,4 +146,37 @@ export class DepositsService {
|
||||
qb.groupBy('d.status');
|
||||
return qb.getRawMany();
|
||||
}
|
||||
|
||||
private async attachPersonalExpenseAmount(deposits: Deposit[]) {
|
||||
if (!deposits.length) return deposits;
|
||||
const studentIds = Array.from(new Set(deposits.map((deposit) => deposit.studentId)));
|
||||
const amountMap = await this.getPersonalExpenseAmountMap(studentIds);
|
||||
return deposits.map((deposit) =>
|
||||
Object.assign({}, deposit, {
|
||||
personalExpenseAmount: amountMap.get(deposit.studentId) || 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async getPersonalExpenseAmount(studentId: number) {
|
||||
const amountMap = await this.getPersonalExpenseAmountMap([studentId]);
|
||||
return amountMap.get(studentId) || 0;
|
||||
}
|
||||
|
||||
private async getPersonalExpenseAmountMap(studentIds: number[]) {
|
||||
const amountMap = new Map<number, number>();
|
||||
if (!studentIds.length) return amountMap;
|
||||
const rows = await this.personalExpenseRepo
|
||||
.createQueryBuilder('pe')
|
||||
.select('pe.studentId', 'studentId')
|
||||
.addSelect('SUM(pe.amount)', 'amount')
|
||||
.where('pe.studentId IN (:...studentIds)', { studentIds })
|
||||
.andWhere('pe.billId IS NULL')
|
||||
.groupBy('pe.studentId')
|
||||
.getRawMany<{ studentId: number | string; amount: string | number | null }>();
|
||||
for (const row of rows) {
|
||||
amountMap.set(Number(row.studentId), Number(Number(row.amount || 0).toFixed(2)));
|
||||
}
|
||||
return amountMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { IsInt, IsNumber, IsString, IsOptional } from 'class-validator';
|
||||
import { IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class CreateDepositDto {
|
||||
@IsInt()
|
||||
studentId: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0.01)
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
@IsDateString()
|
||||
paidDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -16,18 +17,29 @@ export class CreateDepositDto {
|
||||
}
|
||||
|
||||
export class RefundDepositDto {
|
||||
@IsString()
|
||||
@IsDateString()
|
||||
refundDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
deductionAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deductionReason?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class CreateDepositInstallmentDto {
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0.01)
|
||||
amount: number;
|
||||
|
||||
@IsDateString()
|
||||
dueDate: string;
|
||||
}
|
||||
|
||||
export class UpdateDepositInstallmentDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
paidDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['pending', 'paid', 'overdue'])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,18 @@ export class AttendanceRecord {
|
||||
@Column({ name: 'source', length: 20, default: 'manual' })
|
||||
source: string;
|
||||
|
||||
@Column({ name: 'punch_time', type: 'datetime', nullable: true })
|
||||
punchTime: Date | null;
|
||||
|
||||
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
|
||||
punchSource: string | null;
|
||||
|
||||
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
|
||||
punchDeviceName: string | null;
|
||||
|
||||
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
|
||||
punchDeviceId: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -33,9 +33,24 @@ export class Bill {
|
||||
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
totalAmount: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'draft' })
|
||||
@Column({ type: 'varchar', length: 30, default: 'batch' })
|
||||
source: 'batch' | 'student_utility';
|
||||
|
||||
@Column({ name: 'paid_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
paidAmount: number;
|
||||
|
||||
@Column({ name: 'outstanding_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
outstandingAmount: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'unpaid' })
|
||||
status: string;
|
||||
|
||||
@Column({ name: 'cancelled_at', type: 'datetime', nullable: true })
|
||||
cancelledAt: Date | null;
|
||||
|
||||
@Column({ name: 'cancel_reason', type: 'varchar', length: 300, nullable: true })
|
||||
cancelReason: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'generated_at' })
|
||||
generatedAt: Date;
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ export class ClassSchedule {
|
||||
@Column({ name: 'end_time', length: 5 })
|
||||
endTime: string;
|
||||
|
||||
/** 课程开始前允许计入签到的分钟数。 */
|
||||
@Column({ name: 'attendance_advance_minutes', type: 'integer', default: 30 })
|
||||
attendanceAdvanceMinutes: number;
|
||||
|
||||
@Column({ name: 'start_date', type: 'date' })
|
||||
startDate: string;
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export class Deposit {
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 500 })
|
||||
amount: number;
|
||||
|
||||
// paid: 已缴 | refunded: 已退 | deducted: 已扣除(部分或全部)
|
||||
// paid: 有可用余额 | refunded: 余额已全部退还 | depleted: 余额已被账单扣完
|
||||
@Column({ type: 'varchar', length: 20, default: 'paid' })
|
||||
status: string;
|
||||
|
||||
@@ -43,8 +43,8 @@ export class Deposit {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@Column({ name: 'recorded_by', nullable: true })
|
||||
recordedBy: number;
|
||||
@Column({ name: 'recorded_by', type: 'integer', nullable: true })
|
||||
recordedBy: number | null;
|
||||
|
||||
@Column({ name: 'refunded_by', type: 'integer', nullable: true })
|
||||
refundedBy: number | null;
|
||||
|
||||
@@ -44,6 +44,15 @@ export class DingAttendanceRaw {
|
||||
@Column({ name: 'location_result', length: 20, nullable: true })
|
||||
locationResult: string;
|
||||
|
||||
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
|
||||
punchSource: string | null;
|
||||
|
||||
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
|
||||
punchDeviceName: string | null;
|
||||
|
||||
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
|
||||
punchDeviceId: string | null;
|
||||
|
||||
@Column({ name: 'match_status', length: 20, default: 'unmatched' })
|
||||
matchStatus: string;
|
||||
|
||||
|
||||
@@ -35,3 +35,6 @@ export { ResultArchive } from './result-archive.entity';
|
||||
export { ArchiveAttachment } from './archive-attachment.entity';
|
||||
export { StudentDingMapping } from './student-ding-mapping.entity';
|
||||
export { AiConfig } from '../ai-config/ai-config.entity';
|
||||
|
||||
export * from './student-wallet.entity';
|
||||
export * from './wallet-transaction.entity';
|
||||
|
||||
@@ -34,6 +34,9 @@ export class PersonalExpense {
|
||||
@Column({ name: 'recorded_by', nullable: true })
|
||||
recordedBy: number;
|
||||
|
||||
@Column({ name: 'bill_id', type: 'integer', nullable: true })
|
||||
billId: number | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
32
apps/server/src/entities/student-wallet.entity.ts
Normal file
32
apps/server/src/entities/student-wallet.entity.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
|
||||
@Entity('student_wallets')
|
||||
export class StudentWallet {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer', unique: true })
|
||||
studentId: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
balance: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToOne(() => Student, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
}
|
||||
32
apps/server/src/entities/wallet-transaction.entity.ts
Normal file
32
apps/server/src/entities/wallet-transaction.entity.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
@Entity('wallet_transactions')
|
||||
@Index(['studentId', 'createdAt'])
|
||||
export class WalletTransaction {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer' })
|
||||
studentId: number;
|
||||
|
||||
@Column({ name: 'bill_id', type: 'integer', nullable: true })
|
||||
billId: number | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 30 })
|
||||
type: 'recharge' | 'adjustment' | 'bill_payment' | 'bill_refund';
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||||
amount: number;
|
||||
|
||||
@Column({ name: 'balance_after', type: 'decimal', precision: 12, scale: 2 })
|
||||
balanceAfter: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 300, nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ name: 'recorded_by', type: 'integer', nullable: true })
|
||||
recordedBy: number | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { IsInt, IsString, IsNumber, IsOptional } from 'class-validator';
|
||||
import { IsDateString, IsIn, IsInt, IsString, IsNumber, IsOptional, Matches, Min } from 'class-validator';
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateRoomExpenseDto {
|
||||
@IsInt()
|
||||
@@ -7,13 +9,14 @@ export class CreateRoomExpenseDto {
|
||||
@IsString()
|
||||
expenseType: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0.01)
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
@IsDateString()
|
||||
periodStart: string;
|
||||
|
||||
@IsString()
|
||||
@IsDateString()
|
||||
periodEnd: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -32,10 +35,11 @@ export class CreatePersonalExpenseDto {
|
||||
@IsString()
|
||||
expenseType: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0.01)
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
@IsDateString()
|
||||
expenseDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -43,6 +47,33 @@ export class CreatePersonalExpenseDto {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
|
||||
export class UpdateRoomExpenseDto extends PartialType(CreateRoomExpenseDto) {}
|
||||
|
||||
export class UpdatePersonalExpenseDto extends PartialType(CreatePersonalExpenseDto) {}
|
||||
|
||||
export class QueryRoomExpenseDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
roomId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
periodStart?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
periodEnd?: string;
|
||||
}
|
||||
|
||||
export class QueryPersonalExpenseDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
studentId?: number;
|
||||
}
|
||||
|
||||
export class BatchRoomExpenseDto {
|
||||
@IsString()
|
||||
periodStart: string;
|
||||
@@ -52,3 +83,28 @@ export class BatchRoomExpenseDto {
|
||||
|
||||
expenses: { roomId: number; expenseType: string; amount: number; description?: string }[];
|
||||
}
|
||||
|
||||
|
||||
export class CreateStudentUtilityBillDto {
|
||||
@IsInt()
|
||||
studentId: number;
|
||||
|
||||
@IsIn(['water', 'electricity'])
|
||||
expenseType: 'water' | 'electricity';
|
||||
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0.01)
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
periodStart: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
periodEnd: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Res,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
@@ -20,6 +21,11 @@ import {
|
||||
CreateRoomExpenseDto,
|
||||
CreatePersonalExpenseDto,
|
||||
BatchRoomExpenseDto,
|
||||
CreateStudentUtilityBillDto,
|
||||
QueryPersonalExpenseDto,
|
||||
QueryRoomExpenseDto,
|
||||
UpdatePersonalExpenseDto,
|
||||
UpdateRoomExpenseDto,
|
||||
} from './dto/expense.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
@@ -78,6 +84,25 @@ export class ExpensesController {
|
||||
return this.service.getFormLookups();
|
||||
}
|
||||
|
||||
@Post('student-utility')
|
||||
@RequirePermission('expense:create')
|
||||
async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: any) {
|
||||
const result = await this.service.createStudentUtilityBill(dto, req.user?.id);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '费用管理',
|
||||
action: '录入学生水电费并出账',
|
||||
targetId: result.bill.id,
|
||||
targetType: 'bill',
|
||||
detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('room')
|
||||
@RequirePermission('expense:create')
|
||||
async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) {
|
||||
@@ -116,29 +141,21 @@ export class ExpensesController {
|
||||
|
||||
@Get('room')
|
||||
@RequirePermission('expense:view')
|
||||
findRoomExpenses(
|
||||
@Query('roomId') roomId?: string,
|
||||
@Query('periodStart') periodStart?: string,
|
||||
@Query('periodEnd') periodEnd?: string,
|
||||
) {
|
||||
return this.service.findRoomExpenses({
|
||||
roomId: roomId ? +roomId : undefined,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
});
|
||||
findRoomExpenses(@Query() query: QueryRoomExpenseDto) {
|
||||
return this.service.findRoomExpenses(query);
|
||||
}
|
||||
|
||||
@Delete('room/:id')
|
||||
@RequirePermission('expense:delete')
|
||||
async deleteRoomExpense(@Param('id') id: string, @Request() req: any) {
|
||||
async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.deleteRoomExpense(+id);
|
||||
const result = await this.service.deleteRoomExpense(id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '费用管理',
|
||||
action: '删除费用',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'room_expense',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -166,18 +183,18 @@ export class ExpensesController {
|
||||
@Put('room/:id')
|
||||
@RequirePermission('expense:edit')
|
||||
async updateRoomExpense(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateRoomExpenseDto,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateRoomExpenseDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.updateRoomExpense(+id, dto);
|
||||
const result = await this.service.updateRoomExpense(id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '费用管理',
|
||||
action: '编辑费用',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'room_expense',
|
||||
detail: `¥${dto.amount} ${dto.expenseType}`,
|
||||
ipAddress,
|
||||
@@ -205,21 +222,21 @@ export class ExpensesController {
|
||||
|
||||
@Get('personal')
|
||||
@RequirePermission('expense:view')
|
||||
findPersonalExpenses(@Query('studentId') studentId?: string) {
|
||||
return this.service.findPersonalExpenses({ studentId: studentId ? +studentId : undefined });
|
||||
findPersonalExpenses(@Query() query: QueryPersonalExpenseDto) {
|
||||
return this.service.findPersonalExpenses(query);
|
||||
}
|
||||
|
||||
@Delete('personal/:id')
|
||||
@RequirePermission('expense:delete')
|
||||
async deletePersonalExpense(@Param('id') id: string, @Request() req: any) {
|
||||
async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.deletePersonalExpense(+id);
|
||||
const result = await this.service.deletePersonalExpense(id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '费用管理',
|
||||
action: '删除费用',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
@@ -246,18 +263,18 @@ export class ExpensesController {
|
||||
@Put('personal/:id')
|
||||
@RequirePermission('expense:edit')
|
||||
async updatePersonalExpense(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreatePersonalExpenseDto,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdatePersonalExpenseDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.updatePersonalExpense(+id, dto);
|
||||
const result = await this.service.updatePersonalExpense(id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '费用管理',
|
||||
action: '编辑费用',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
detail: `¥${dto.amount} ${dto.expenseType}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
|
||||
@@ -7,11 +7,13 @@ import { Student } from '../entities/student.entity';
|
||||
import { ExpensesService } from './expenses.service';
|
||||
import { ExpensesController } from './expenses.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { BillsModule } from '../bills/bills.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([RoomExpense, PersonalExpense, Room, Student]),
|
||||
OperationLogsModule,
|
||||
BillsModule,
|
||||
],
|
||||
controllers: [ExpensesController],
|
||||
providers: [ExpensesService],
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
CreateRoomExpenseDto,
|
||||
CreatePersonalExpenseDto,
|
||||
BatchRoomExpenseDto,
|
||||
CreateStudentUtilityBillDto,
|
||||
} from './dto/expense.dto';
|
||||
import { RoomsService } from '../rooms/rooms.service';
|
||||
import { BillsService } from '../bills/bills.service';
|
||||
|
||||
|
||||
@Injectable()
|
||||
@@ -20,6 +22,7 @@ export class ExpensesService {
|
||||
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
|
||||
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||
private billsService: BillsService,
|
||||
) {}
|
||||
|
||||
async getFormLookups() {
|
||||
@@ -96,6 +99,30 @@ export class ExpensesService {
|
||||
return this.roomExpRepo.save(e);
|
||||
}
|
||||
|
||||
async createStudentUtilityBill(dto: CreateStudentUtilityBillDto, userId?: number) {
|
||||
if (dto.periodEnd < dto.periodStart) throw new BadRequestException('账期结束日期不能早于开始日期');
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
const expense = await this.personalExpRepo.save(
|
||||
this.personalExpRepo.create({
|
||||
studentId: dto.studentId,
|
||||
expenseType: dto.expenseType,
|
||||
amount: dto.amount,
|
||||
expenseDate: dto.periodEnd,
|
||||
description: dto.description || (dto.expenseType === 'water' ? '学生水费' : '学生电费'),
|
||||
recordedBy: userId,
|
||||
billId: null,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const bill = await this.billsService.createImmediatePersonalBill(expense, dto.periodStart, dto.periodEnd, userId);
|
||||
return { expense, bill };
|
||||
} catch (error) {
|
||||
await this.personalExpRepo.delete(expense.id);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 个人附加费
|
||||
async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) {
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
|
||||
@@ -51,6 +51,11 @@ export interface DingTalkAttendanceResult {
|
||||
actualCheckTime: string;
|
||||
checkId: string;
|
||||
checkType: string;
|
||||
/** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */
|
||||
sourceType: string;
|
||||
/** 部分钉钉租户会额外返回考勤机名称或编号。 */
|
||||
deviceName?: string;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
// ── 组织架构 API 类型 ──
|
||||
@@ -505,6 +510,8 @@ export class DingTalkService {
|
||||
checkType?: string; timeResult?: string;
|
||||
locationResult?: string; locationMethod?: string;
|
||||
userAddress?: string; userLongitude?: number; userLatitude?: number;
|
||||
deviceName?: string; deviceId?: string | number;
|
||||
attendanceMachineName?: string; attendanceMachineId?: string | number;
|
||||
}>;
|
||||
};
|
||||
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
|
||||
@@ -520,7 +527,10 @@ export class DingTalkService {
|
||||
planCheckTime: '',
|
||||
actualCheckTime: new Date(r.userCheckTime).toISOString(),
|
||||
checkId: String(r.id),
|
||||
checkType: r.checkType ?? r.sourceType ?? '',
|
||||
checkType: r.checkType ?? '',
|
||||
sourceType: r.sourceType ?? '',
|
||||
deviceName: r.deviceName ?? r.attendanceMachineName,
|
||||
deviceId: String(r.deviceId ?? r.attendanceMachineId ?? '') || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { validate } from 'class-validator';
|
||||
import { CheckInDto, TransferRoomDto } from './occupancy.dto';
|
||||
|
||||
@@ -14,6 +15,50 @@ describe('manual occupancy DTO bed requirements', () => {
|
||||
expect(errors.some((error) => error.property === 'bedId')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts optional deposit collection details for manual check-in', async () => {
|
||||
const dto = Object.assign(new CheckInDto(), {
|
||||
studentId: 1,
|
||||
roomId: 2,
|
||||
checkInDate: '2026-07-13',
|
||||
bedId: 3,
|
||||
collectDeposit: true,
|
||||
depositAmount: 500,
|
||||
});
|
||||
|
||||
await expect(validate(dto)).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a non-positive deposit amount', async () => {
|
||||
const dto = Object.assign(new CheckInDto(), {
|
||||
studentId: 1,
|
||||
roomId: 2,
|
||||
checkInDate: '2026-07-13',
|
||||
bedId: 3,
|
||||
collectDeposit: true,
|
||||
depositAmount: 0,
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
|
||||
expect(errors.some((error) => error.property === 'depositAmount')).toBe(true);
|
||||
});
|
||||
|
||||
it('strips a manually supplied responsible organization', async () => {
|
||||
const pipe = new ValidationPipe({ transform: true, whitelist: true });
|
||||
const dto = await pipe.transform(
|
||||
{
|
||||
studentId: 1,
|
||||
roomId: 2,
|
||||
checkInDate: '2026-07-13',
|
||||
bedId: 3,
|
||||
responsibleOrganizationId: 99,
|
||||
},
|
||||
{ type: 'body', metatype: CheckInDto },
|
||||
);
|
||||
|
||||
expect(dto).not.toHaveProperty('responsibleOrganizationId');
|
||||
});
|
||||
|
||||
it('requires a new bed for a room transfer while keeping the locker optional', async () => {
|
||||
const dto = Object.assign(new TransferRoomDto(), {
|
||||
newRoomId: 3,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsInt, IsString, IsOptional, IsArray } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
|
||||
export class CheckInDto {
|
||||
@IsInt()
|
||||
@@ -23,8 +23,14 @@ export class CheckInDto {
|
||||
stayType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
responsibleOrganizationId?: number;
|
||||
@IsBoolean()
|
||||
collectDeposit?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
depositAmount?: number;
|
||||
|
||||
@IsInt()
|
||||
bedId: number;
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import {
|
||||
createOccupancyImportTemplateWorkbook,
|
||||
parseOccupancyImportWorksheet,
|
||||
} from './occupancy-import-template';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('occupancies')
|
||||
@@ -73,7 +77,7 @@ export class OccupanciesController {
|
||||
@RequirePermission('occupancy:checkin')
|
||||
async checkIn(@Body() dto: CheckInDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.checkIn(dto);
|
||||
const result = await this.service.checkIn(dto, req.user?.id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
@@ -197,17 +201,20 @@ export class OccupanciesController {
|
||||
ws.columns = [
|
||||
{ header: '宿舍号', key: 'roomNumber', width: 12 },
|
||||
{ header: '楼栋', key: 'building', width: 12 },
|
||||
{ header: '床位号', key: 'bedNumber', width: 10 },
|
||||
{ header: '柜子号', key: 'lockerNumber', width: 10 },
|
||||
{ header: '学生姓名', key: 'studentName', width: 12 },
|
||||
{ header: '性别', key: 'gender', width: 8 },
|
||||
{ header: '电话', key: 'phone', width: 18 },
|
||||
{ header: '学号/身份证', key: 'idNumber', width: 22 },
|
||||
{ header: '所属机构', key: 'organization', width: 18 },
|
||||
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
||||
{ header: '入住日期', key: 'checkInDate', width: 14 },
|
||||
{ header: '退宿日期', key: 'checkOutDate', width: 14 },
|
||||
{ header: '计费起始', key: 'billingStartDate', width: 14 },
|
||||
{ header: '计费截止', key: 'billingEndDate', width: 14 },
|
||||
{ header: '退宿原因', key: 'checkOutReason', width: 12 },
|
||||
{ header: '入住类型', key: 'stayType', width: 10 },
|
||||
{ header: '退宿原因', key: 'checkOutReason', width: 16 },
|
||||
{ header: '备注', key: 'notes', width: 24 },
|
||||
];
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
@@ -215,17 +222,20 @@ export class OccupanciesController {
|
||||
ws.addRow({
|
||||
roomNumber: r.room?.roomNumber || '',
|
||||
building: r.room?.building || '',
|
||||
bedNumber: r.bed?.bedNumber || '',
|
||||
lockerNumber: r.locker?.lockerNumber || '',
|
||||
studentName: r.student?.name || '',
|
||||
gender: r.student?.gender || '',
|
||||
phone: r.student?.phone || '',
|
||||
idNumber: r.student?.idNumber || '',
|
||||
organization: r.student?.organization?.name || '',
|
||||
supervisor: r.student?.supervisor || '',
|
||||
checkInDate: r.checkInDate || '',
|
||||
checkOutDate: r.checkOutDate || '',
|
||||
billingStartDate: r.billingStartDate || '',
|
||||
billingEndDate: r.billingEndDate || '',
|
||||
stayType: r.stayType === 'long' ? '长租' : '短租',
|
||||
checkOutReason: r.checkOutReason || '',
|
||||
notes: r.notes || '',
|
||||
});
|
||||
}
|
||||
res!.setHeader(
|
||||
@@ -240,68 +250,7 @@ export class OccupanciesController {
|
||||
@Get('template')
|
||||
@RequirePermission('occupancy:view')
|
||||
async downloadTemplate(@Res() res: Response) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('入住名单导入模板');
|
||||
ws.columns = [
|
||||
{ header: '宿舍号', key: 'roomNumber', width: 12 },
|
||||
{ header: '床位号', key: 'bedNumber', width: 8 },
|
||||
{ header: '姓名', key: 'name', width: 12 },
|
||||
{ header: '性别', key: 'gender', width: 8 },
|
||||
{ header: '民族', key: 'ethnicity', width: 10 },
|
||||
{ header: '电话', key: 'phone', width: 15 },
|
||||
{ header: '学号/身份证', key: 'idNumber', width: 22 },
|
||||
{ header: '入住时间', key: 'checkInDate', width: 14 },
|
||||
{ header: '离宿时间', key: 'checkOutDate', width: 14 },
|
||||
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
||||
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
||||
{ header: '所属机构', key: 'organization', width: 18 },
|
||||
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
||||
];
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
// 添加说明行
|
||||
ws.addRow({
|
||||
roomNumber: '4-102',
|
||||
bedNumber: 1,
|
||||
name: '张三',
|
||||
gender: '男',
|
||||
ethnicity: '汉族',
|
||||
phone: '13800138000',
|
||||
idNumber: '2024001',
|
||||
checkInDate: '2026-04-21',
|
||||
checkOutDate: '',
|
||||
emergencyContact: '张父',
|
||||
emergencyPhone: '13900000000',
|
||||
organization: '',
|
||||
supervisor: '',
|
||||
});
|
||||
ws.addRow({
|
||||
roomNumber: '4-102',
|
||||
bedNumber: 2,
|
||||
name: '李四',
|
||||
gender: '男',
|
||||
ethnicity: '汉族',
|
||||
phone: '13800138001',
|
||||
idNumber: '2024002',
|
||||
checkInDate: '2026-04-21',
|
||||
checkOutDate: '',
|
||||
emergencyContact: '',
|
||||
emergencyPhone: '',
|
||||
organization: 'XXX教育科技',
|
||||
supervisor: '王老师',
|
||||
});
|
||||
// 添加使用说明sheet
|
||||
const helpWs = workbook.addWorksheet('使用说明');
|
||||
helpWs.getColumn(1).width = 60;
|
||||
helpWs.addRow(['【入住名单导入说明】']);
|
||||
helpWs.addRow(['1. 导入入住名单会自动创建不存在的学生和宿舍,无需单独导入学生或宿舍']);
|
||||
helpWs.addRow(['2. 宿舍号会智能解析楼栋、楼层和房间类型(如4-102自动识别为4号楼1层四人间)']);
|
||||
helpWs.addRow(['3. 同一宿舍号的多个学生可合并宿舍号单元格,系统会自动继承上一行的宿舍号']);
|
||||
helpWs.addRow(['4. 已存在的学生(按姓名匹配)会自动补充缺失信息(性别、民族等)']);
|
||||
helpWs.addRow(['5. 已有在住记录的学生会自动跳过,不会重复入住']);
|
||||
helpWs.addRow(['6. 填了离宿时间的记录会直接标记为已退宿(用于导入历史数据)']);
|
||||
helpWs.addRow(['7. 床位号仅做标识参考,不影响入住逻辑']);
|
||||
helpWs.getRow(1).font = { bold: true, size: 14 };
|
||||
const workbook = createOccupancyImportTemplateWorkbook();
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
@@ -324,47 +273,7 @@ export class OccupanciesController {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer as any);
|
||||
const ws = workbook.worksheets[0];
|
||||
const rows: any[] = [];
|
||||
let lastRoomNumber = '';
|
||||
|
||||
ws.eachRow((row, idx) => {
|
||||
if (idx === 1) return; // 跳过表头
|
||||
|
||||
// 宿舍号可能是合并单元格,需要继承上一行
|
||||
const roomNumberVal = row.getCell(1).value;
|
||||
const roomNumber = roomNumberVal ? String(roomNumberVal).trim() : '';
|
||||
if (roomNumber) lastRoomNumber = roomNumber;
|
||||
|
||||
const name = String(row.getCell(3).value || '').trim();
|
||||
if (!name) return; // 无姓名则跳过空行
|
||||
|
||||
// 解析日期
|
||||
const parseDate = (cell: any): string => {
|
||||
const val = cell.value;
|
||||
if (!val) return '';
|
||||
if (val instanceof Date) return val.toISOString().split('T')[0];
|
||||
const s = String(val).trim();
|
||||
// 处理 "YYYY/MM/DD" 或 "YYYY-MM-DD" 或 "YYYY.MM.DD"
|
||||
const m = s.match(/(\d{4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})/);
|
||||
if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
|
||||
return s;
|
||||
};
|
||||
|
||||
rows.push({
|
||||
name,
|
||||
roomNumber: lastRoomNumber,
|
||||
gender: String(row.getCell(4).value || '').trim() || undefined,
|
||||
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
|
||||
phone: String(row.getCell(6).value || '').trim() || undefined,
|
||||
idNumber: String(row.getCell(7).value || '').trim() || undefined,
|
||||
checkInDate: parseDate(row.getCell(8)),
|
||||
checkOutDate: parseDate(row.getCell(9)) || undefined,
|
||||
emergencyContact: String(row.getCell(10).value || '').trim() || undefined,
|
||||
emergencyPhone: String(row.getCell(11).value || '').trim() || undefined,
|
||||
organization: String(row.getCell(12).value || '').trim() || undefined,
|
||||
supervisor: String(row.getCell(13).value || '').trim() || undefined,
|
||||
});
|
||||
});
|
||||
const rows = parseOccupancyImportWorksheet(ws);
|
||||
const result = await this.service.batchImportCheckIn(rows, {
|
||||
autoDeposit: autoDeposit === 'true',
|
||||
depositAmount: depositAmount ? +depositAmount : undefined,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Bed } from '../entities/bed.entity';
|
||||
import { Locker } from '../entities/locker.entity';
|
||||
|
||||
describe('OccupanciesService — responsible organization', () => {
|
||||
it('defaults the responsible organization to the student organization', async () => {
|
||||
it('always takes the responsible organization from the student', async () => {
|
||||
const occupancyRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
@@ -42,10 +42,235 @@ describe('OccupanciesService — responsible organization', () => {
|
||||
roomId: 2,
|
||||
checkInDate: '2026-07-10',
|
||||
bedId: 4,
|
||||
});
|
||||
responsibleOrganizationId: 99,
|
||||
} as any);
|
||||
|
||||
expect(occupancyRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ responsibleOrganizationId: 7 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OccupanciesService — manual check-in deposit', () => {
|
||||
const createService = (existingDeposit: Deposit | null = null) => {
|
||||
const occupancyRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => ({ ...value, id: 10 })),
|
||||
} as any as Repository<Occupancy>;
|
||||
const roomRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4 }),
|
||||
update: jest.fn(),
|
||||
} as any as Repository<Room>;
|
||||
const studentRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 3, organizationId: 7 }),
|
||||
} as any as Repository<Student>;
|
||||
const depositRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(existingDeposit),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => ({ ...value, id: 20 })),
|
||||
} as any as Repository<Deposit>;
|
||||
const bedRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }),
|
||||
update: jest.fn(),
|
||||
} as any as Repository<Bed>;
|
||||
|
||||
return {
|
||||
service: new OccupanciesService(
|
||||
occupancyRepo,
|
||||
roomRepo,
|
||||
studentRepo,
|
||||
depositRepo,
|
||||
bedRepo,
|
||||
{} as Repository<Locker>,
|
||||
{} as Repository<any>,
|
||||
{} as DataSource,
|
||||
),
|
||||
depositRepo,
|
||||
};
|
||||
};
|
||||
|
||||
it('creates a paid deposit together with manual check-in', async () => {
|
||||
const { service, depositRepo } = createService();
|
||||
|
||||
await service.checkIn(
|
||||
{
|
||||
studentId: 3,
|
||||
roomId: 2,
|
||||
checkInDate: '2026-07-14',
|
||||
bedId: 4,
|
||||
collectDeposit: true,
|
||||
depositAmount: 800,
|
||||
},
|
||||
11,
|
||||
);
|
||||
|
||||
expect(depositRepo.create).toHaveBeenCalledWith({
|
||||
studentId: 3,
|
||||
amount: 800,
|
||||
paidDate: '2026-07-14',
|
||||
status: 'paid',
|
||||
recordedBy: 11,
|
||||
notes: '入住登记自动收取',
|
||||
});
|
||||
expect(depositRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not create another paid deposit when one already exists', async () => {
|
||||
const { service, depositRepo } = createService({ id: 99 } as Deposit);
|
||||
|
||||
await service.checkIn({
|
||||
studentId: 3,
|
||||
roomId: 2,
|
||||
checkInDate: '2026-07-14',
|
||||
bedId: 4,
|
||||
collectDeposit: true,
|
||||
depositAmount: 800,
|
||||
});
|
||||
|
||||
expect(depositRepo.create).not.toHaveBeenCalled();
|
||||
expect(depositRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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: '张三',
|
||||
phone: '13800138000',
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OccupanciesService — import student matching', () => {
|
||||
it('associates an existing student by phone and keeps the student organization', async () => {
|
||||
const existingStudent = {
|
||||
id: 3,
|
||||
name: '学生档案姓名',
|
||||
phone: '13800138000',
|
||||
organizationId: 7,
|
||||
};
|
||||
const occupancyRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => ({ ...value, id: 10 })),
|
||||
} 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(existingStudent),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
} as any as Repository<Student>;
|
||||
const bedRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 4,
|
||||
roomId: 2,
|
||||
bedNumber: '1号床',
|
||||
status: 'available',
|
||||
}),
|
||||
count: jest.fn(),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
} as any as Repository<Bed>;
|
||||
const organizationRepo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(),
|
||||
} as any as Repository<any>;
|
||||
|
||||
const service = new OccupanciesService(
|
||||
occupancyRepo,
|
||||
roomRepo,
|
||||
studentRepo,
|
||||
{ findOne: 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: 'Excel姓名',
|
||||
phone: '13800138000',
|
||||
roomNumber: '4-102',
|
||||
bedNumber: '1号床',
|
||||
checkInDate: '2026-07-14',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(studentRepo.findOne).toHaveBeenCalledWith({ where: { phone: '13800138000' } });
|
||||
expect(studentRepo.save).not.toHaveBeenCalled();
|
||||
expect(organizationRepo.findOne).not.toHaveBeenCalled();
|
||||
expect(occupancyRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ studentId: 3, responsibleOrganizationId: 7 }),
|
||||
);
|
||||
expect(result).toEqual(expect.objectContaining({ imported: 1, skipped: 0 }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ import { Bed } from '../entities/bed.entity';
|
||||
import { Locker } from '../entities/locker.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { uuidV7 } from '../common/uuid-v7';
|
||||
import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
|
||||
import { RoomsService } from '../rooms/rooms.service';
|
||||
|
||||
@@ -40,7 +39,6 @@ export class OccupanciesService {
|
||||
.leftJoinAndSelect('o.room', 'room')
|
||||
.leftJoinAndSelect('o.bed', 'bed')
|
||||
.leftJoinAndSelect('o.locker', 'locker')
|
||||
.leftJoinAndSelect('o.responsibleOrganization', 'responsibleOrganization')
|
||||
.orderBy('o.checkInDate', 'DESC');
|
||||
if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId });
|
||||
if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId });
|
||||
@@ -48,7 +46,7 @@ export class OccupanciesService {
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
async checkIn(dto: CheckInDto) {
|
||||
async checkIn(dto: CheckInDto, userId?: number) {
|
||||
// 检查学生是否已有活跃入住
|
||||
const existing = await this.repo.findOne({
|
||||
where: { studentId: dto.studentId, checkOutDate: IsNull() },
|
||||
@@ -86,7 +84,7 @@ export class OccupanciesService {
|
||||
checkInDate: dto.checkInDate,
|
||||
billingStartDate: dto.billingStartDate || dto.checkInDate,
|
||||
stayType: dto.stayType,
|
||||
responsibleOrganizationId: dto.responsibleOrganizationId ?? student.organizationId,
|
||||
responsibleOrganizationId: student.organizationId,
|
||||
notes: dto.notes,
|
||||
bedId: dto.bedId,
|
||||
lockerId: dto.lockerId,
|
||||
@@ -105,6 +103,34 @@ export class OccupanciesService {
|
||||
if (count + 1 >= room.capacity) {
|
||||
await this.roomRepo.update(room.id, { status: 'full' });
|
||||
}
|
||||
|
||||
if (dto.collectDeposit) {
|
||||
const existingDeposit = await this.depositRepo.findOne({
|
||||
where: { studentId: dto.studentId },
|
||||
});
|
||||
if (existingDeposit) {
|
||||
existingDeposit.amount = Number(
|
||||
(Number(existingDeposit.amount || 0) + Number(dto.depositAmount ?? 500)).toFixed(2),
|
||||
);
|
||||
existingDeposit.status = 'paid';
|
||||
existingDeposit.paidDate = dto.checkInDate;
|
||||
existingDeposit.recordedBy = userId ?? null;
|
||||
existingDeposit.notes = '入住登记自动收取';
|
||||
await this.depositRepo.save(existingDeposit);
|
||||
} else {
|
||||
await this.depositRepo.save(
|
||||
this.depositRepo.create({
|
||||
studentId: dto.studentId,
|
||||
amount: dto.depositAmount ?? 500,
|
||||
paidDate: dto.checkInDate,
|
||||
status: 'paid',
|
||||
recordedBy: userId,
|
||||
notes: '入住登记自动收取',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -336,12 +362,16 @@ export class OccupanciesService {
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organization?: string;
|
||||
supervisor?: string;
|
||||
roomNumber: string;
|
||||
building?: string;
|
||||
checkInDate: string;
|
||||
billingStartDate?: string;
|
||||
checkOutDate?: string;
|
||||
bedNumber?: string;
|
||||
lockerNumber?: string;
|
||||
stayType?: string;
|
||||
notes?: string;
|
||||
}[],
|
||||
options?: { autoDeposit?: boolean; depositAmount?: number },
|
||||
) {
|
||||
@@ -360,42 +390,33 @@ export class OccupanciesService {
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 解析所属机构;未填写时默认本机构
|
||||
let organization = row.organization?.trim()
|
||||
? await this.organizationRepo.findOne({ where: { name: row.organization.trim() } })
|
||||
: await this.organizationRepo.findOne({ where: { isHost: true, status: 'active' } });
|
||||
if (!organization && row.organization?.trim()) {
|
||||
organization = await this.organizationRepo.save(
|
||||
this.organizationRepo.create({
|
||||
publicId: uuidV7(),
|
||||
code: `ORG_${Date.now()}_${i}`,
|
||||
name: row.organization.trim(),
|
||||
isHost: false,
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!organization) throw new BadRequestException('尚未配置本机构');
|
||||
// 1. 通过手机号关联学生;未找到时创建学生并归入本机构
|
||||
const phone = row.phone?.trim();
|
||||
if (!phone) throw new BadRequestException('手机号不能为空,无法关联学生');
|
||||
|
||||
let student = await this.studentRepo.findOne({ where: { name: row.name.trim() } });
|
||||
let student = await this.studentRepo.findOne({ where: { phone } });
|
||||
if (!student) {
|
||||
const hostOrganization = await this.organizationRepo.findOne({
|
||||
where: { isHost: true, status: 'active' },
|
||||
});
|
||||
if (!hostOrganization) throw new BadRequestException('尚未配置本机构');
|
||||
|
||||
student = await this.studentRepo.save(
|
||||
this.studentRepo.create({
|
||||
name: row.name.trim(),
|
||||
phone: row.phone?.trim() || undefined,
|
||||
phone,
|
||||
idNumber: row.idNumber?.trim() || undefined,
|
||||
gender: row.gender?.trim() || undefined,
|
||||
ethnicity: row.ethnicity?.trim() || undefined,
|
||||
emergencyContact: row.emergencyContact?.trim() || undefined,
|
||||
emergencyPhone: row.emergencyPhone?.trim() || undefined,
|
||||
organizationId: organization.id,
|
||||
organizationId: hostOrganization.id,
|
||||
supervisor: row.supervisor?.trim() || undefined,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// 更新已有学生的缺失信息
|
||||
const updates: any = {};
|
||||
if (!student.phone && row.phone?.trim()) updates.phone = row.phone.trim();
|
||||
if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
|
||||
if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim();
|
||||
if (!student.ethnicity && row.ethnicity?.trim()) updates.ethnicity = row.ethnicity.trim();
|
||||
@@ -403,7 +424,6 @@ export class OccupanciesService {
|
||||
updates.emergencyContact = row.emergencyContact.trim();
|
||||
if (!student.emergencyPhone && row.emergencyPhone?.trim())
|
||||
updates.emergencyPhone = row.emergencyPhone.trim();
|
||||
if (!student.organizationId) updates.organizationId = organization.id;
|
||||
if (!student.supervisor && row.supervisor?.trim())
|
||||
updates.supervisor = row.supervisor.trim();
|
||||
if (Object.keys(updates).length > 0) {
|
||||
@@ -450,14 +470,54 @@ export class OccupanciesService {
|
||||
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. 创建入住记录
|
||||
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
|
||||
const occData: any = {
|
||||
studentId: student.id,
|
||||
roomId: room.id,
|
||||
checkInDate,
|
||||
billingStartDate: checkInDate,
|
||||
responsibleOrganizationId: student.organizationId || organization.id,
|
||||
billingStartDate: row.billingStartDate?.trim() || checkInDate,
|
||||
stayType: row.stayType || undefined,
|
||||
responsibleOrganizationId: student.organizationId,
|
||||
notes: row.notes || undefined,
|
||||
bedId: bed?.id,
|
||||
lockerId: locker?.id,
|
||||
};
|
||||
// 如果有退宿日期,直接记录
|
||||
if (row.checkOutDate?.trim()) {
|
||||
@@ -466,17 +526,30 @@ export class OccupanciesService {
|
||||
}
|
||||
await this.repo.save(this.repo.create(occData));
|
||||
|
||||
// 8. 更新宿舍状态
|
||||
if (!row.checkOutDate?.trim() && count + 1 >= room.capacity) {
|
||||
await this.roomRepo.update(room.id, { status: 'full' });
|
||||
// 7. 更新床位、柜子和宿舍状态
|
||||
if (!isHistoricalRecord) {
|
||||
if (bed) await this.bedRepo.update(bed.id, { status: 'occupied' });
|
||||
if (locker) await this.lockerRepo.update(locker.id, { status: 'occupied' });
|
||||
if (count + 1 >= room.capacity) {
|
||||
await this.roomRepo.update(room.id, { status: 'full' });
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 自动收取押金(仅对新入住且非历史记录的学生)
|
||||
if (options?.autoDeposit && !row.checkOutDate?.trim()) {
|
||||
const existingDeposit = await this.depositRepo.findOne({
|
||||
where: { studentId: student.id, status: 'paid' },
|
||||
where: { studentId: student.id },
|
||||
});
|
||||
if (!existingDeposit) {
|
||||
if (existingDeposit) {
|
||||
existingDeposit.amount = Number(
|
||||
(Number(existingDeposit.amount || 0) + Number(options.depositAmount || 500)).toFixed(2),
|
||||
);
|
||||
existingDeposit.status = 'paid';
|
||||
existingDeposit.paidDate = checkInDate;
|
||||
existingDeposit.notes = '入住导入自动收取';
|
||||
await this.depositRepo.save(existingDeposit);
|
||||
depositsCreated++;
|
||||
} else {
|
||||
await this.depositRepo.save(
|
||||
this.depositRepo.create({
|
||||
studentId: student.id,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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(headers).not.toContain('所属机构');
|
||||
expect(ws.columnCount).toBe(OCCUPANCY_IMPORT_COLUMNS.length);
|
||||
});
|
||||
|
||||
it('documents the current phone matching, organization, and deposit behavior', () => {
|
||||
const workbook = createOccupancyImportTemplateWorkbook();
|
||||
const helpWs = workbook.getWorksheet('使用说明')!;
|
||||
const instructions = helpWs.getColumn(1).values.join('\n');
|
||||
|
||||
expect(instructions).toContain('按手机号关联已有学生');
|
||||
expect(instructions).toContain('所属机构自动取学生档案');
|
||||
expect(instructions).toContain('导入时自动收押金');
|
||||
expect(instructions).toContain('历史入住不会自动收取');
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
222
apps/server/src/occupancies/occupancy-import-template.ts
Normal file
222
apps/server/src/occupancies/occupancy-import-template.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
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;
|
||||
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: '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: ['紧急联系人电话', '紧急联系电话'],
|
||||
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,
|
||||
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: `${ws.getColumn(OCCUPANCY_IMPORT_COLUMNS.length).letter}1`,
|
||||
};
|
||||
|
||||
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',
|
||||
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: '',
|
||||
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. 押金不在表格中逐行填写;请在上传前使用页面上的“导入时自动收押金”和金额设置,历史入住不会自动收取,已有已缴押金不会重复创建。',
|
||||
'10. 模板中的两行示例数据仅用于说明,正式导入前请删除或替换。',
|
||||
];
|
||||
instructions.forEach((instruction) => helpWs.addRow([instruction]));
|
||||
helpWs.getRow(1).font = { bold: true, size: 14 };
|
||||
return workbook;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ describe('preset role permissions', () => {
|
||||
expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'deposit']),
|
||||
);
|
||||
expect(accommodation.extras).toContain('student:basic-view');
|
||||
expect(accommodation.extras).not.toContain('organization:view');
|
||||
});
|
||||
|
||||
it('keeps classroom rental operations separate from accommodation operations', () => {
|
||||
|
||||
@@ -51,6 +51,8 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'deposit:edit', name: '编辑押金', group: 'deposit' },
|
||||
{ code: 'deposit:delete', name: '删除押金', group: 'deposit' },
|
||||
{ code: 'deposit:refund', name: '直接退还押金', group: 'deposit' },
|
||||
{ code: 'wallet:view', name: '查看学生余额', group: 'wallet' },
|
||||
{ code: 'wallet:edit', name: '充值和调账', group: 'wallet' },
|
||||
{ code: 'classroom:view', name: '查看教室', group: 'classroom' },
|
||||
{ code: 'classroom:create', name: '新增教室', group: 'classroom' },
|
||||
{ code: 'classroom:edit', name: '编辑教室', group: 'classroom' },
|
||||
@@ -176,6 +178,7 @@ export const PRESET_ROLES: Array<{
|
||||
'expense',
|
||||
'bill',
|
||||
'deposit',
|
||||
'wallet',
|
||||
'dashboard',
|
||||
'notification',
|
||||
'profile',
|
||||
|
||||
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 { 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 { Occupancy } from '../entities/occupancy.entity';
|
||||
@@ -19,6 +28,7 @@ export class RoomsService {
|
||||
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
||||
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
|
||||
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -116,9 +126,54 @@ export class RoomsService {
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateRoomDto) {
|
||||
await this.findOne(id);
|
||||
await this.repo.update(id, dto);
|
||||
return this.repo.findOne({ where: { id } });
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const roomRepo = manager.getRepository(Room);
|
||||
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) {
|
||||
@@ -413,6 +468,14 @@ export class RoomsService {
|
||||
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);
|
||||
@@ -421,7 +484,9 @@ export class RoomsService {
|
||||
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} 张`);
|
||||
throw new BadRequestException(
|
||||
`床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining} 张`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,21 @@ const createSchedule = (notes: string) =>
|
||||
notes,
|
||||
});
|
||||
|
||||
describe('schedule attendance window validation', () => {
|
||||
it('accepts a configurable number of minutes before class', async () => {
|
||||
const dto = createSchedule('');
|
||||
dto.attendanceAdvanceMinutes = 45;
|
||||
const errors = await validate(dto);
|
||||
expect(errors.some((error) => error.property === 'attendanceAdvanceMinutes')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects values outside 0 to 1440 minutes', async () => {
|
||||
const dto = Object.assign(new UpdateScheduleDto(), { attendanceAdvanceMinutes: 1441 });
|
||||
const errors = await validate(dto);
|
||||
expect(errors.some((error) => error.property === 'attendanceAdvanceMinutes')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schedule notes validation', () => {
|
||||
it('rejects notes longer than 500 characters when creating', async () => {
|
||||
const errors = await validate(createSchedule('a'.repeat(501)));
|
||||
|
||||
@@ -34,6 +34,12 @@ export class CreateScheduleDto {
|
||||
@IsNotEmpty()
|
||||
endTime: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1440)
|
||||
attendanceAdvanceMinutes?: number;
|
||||
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
startDate: string;
|
||||
@@ -88,6 +94,12 @@ export class UpdateScheduleDto {
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
endTime?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1440)
|
||||
attendanceAdvanceMinutes?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@@ -110,15 +110,47 @@ describe('SchedulesService — checkConflict', () => {
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('same classroom + same weekday + non-overlapping times → no conflict', async () => {
|
||||
it('rejects schedules separated by less than 10 minutes', async () => {
|
||||
const qb = mockQueryBuilder<ClassSchedule>([
|
||||
{ id: 1, subject: '数学', startTime: '08:00', endTime: '10:00' } as ClassSchedule,
|
||||
]);
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
|
||||
await expect(
|
||||
service.checkConflict(1, 3, '10:09', '12:00', '2026-03-01', '2026-06-30'),
|
||||
).rejects.toThrow('排课之间必须至少间隔 10 分钟');
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('cs.endTime > :bufferedStartTime', {
|
||||
bufferedStartTime: '09:59',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows adjacent schedules when there is exactly a 10-minute gap', async () => {
|
||||
const qb = mockQueryBuilder<ClassSchedule>([]);
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
|
||||
await expect(
|
||||
service.checkConflict(1, 3, '10:00', '12:00', '2026-03-01', '2026-06-30'),
|
||||
service.checkConflict(1, 3, '10:10', '12:00', '2026-03-01', '2026-06-30'),
|
||||
).resolves.toEqual([]);
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('cs.endTime > :bufferedStartTime', {
|
||||
bufferedStartTime: '10:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('reserves 10 minutes after the new schedule when checking the next schedule', async () => {
|
||||
const qb = mockQueryBuilder<ClassSchedule>([]);
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
|
||||
await service.checkConflict(1, 3, '08:00', '09:50', '2026-03-01', '2026-06-30');
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('cs.startTime < :bufferedEndTime', {
|
||||
bufferedEndTime: '10:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('same classroom + same weekday + overlapping times but disjoint date ranges → no conflict', async () => {
|
||||
|
||||
@@ -23,6 +23,16 @@ import {
|
||||
WeeklyViewQueryDto,
|
||||
} from './dto/schedule.dto';
|
||||
|
||||
const SCHEDULE_GAP_MINUTES = 10;
|
||||
|
||||
function shiftTime(time: string, minutes: number): string {
|
||||
const [hours, minutePart] = time.split(':').map(Number);
|
||||
const shifted = Math.min(24 * 60, Math.max(0, hours * 60 + minutePart + minutes));
|
||||
const shiftedHours = Math.floor(shifted / 60);
|
||||
const shiftedMinutes = shifted % 60;
|
||||
return `${String(shiftedHours).padStart(2, '0')}:${String(shiftedMinutes).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
constructor(
|
||||
@@ -58,6 +68,7 @@ export class SchedulesService {
|
||||
weekDay: schedule.weekDay,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes,
|
||||
startDate: schedule.startDate,
|
||||
endDate: schedule.endDate,
|
||||
subject: '已占用',
|
||||
@@ -243,13 +254,16 @@ export class SchedulesService {
|
||||
endDate: string,
|
||||
excludeId?: number,
|
||||
) {
|
||||
// 为教室换场、整理和人员进出预留时间;恰好间隔 10 分钟允许排课。
|
||||
const bufferedStartTime = shiftTime(startTime, -SCHEDULE_GAP_MINUTES);
|
||||
const bufferedEndTime = shiftTime(endTime, SCHEDULE_GAP_MINUTES);
|
||||
const qb = this.scheduleRepo
|
||||
.createQueryBuilder('cs')
|
||||
.where('cs.classroomId = :classroomId', { classroomId })
|
||||
.andWhere('cs.weekDay = :weekDay', { weekDay })
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.andWhere('cs.startTime < :endTime', { endTime })
|
||||
.andWhere('cs.endTime > :startTime', { startTime })
|
||||
.andWhere('cs.startTime < :bufferedEndTime', { bufferedEndTime })
|
||||
.andWhere('cs.endTime > :bufferedStartTime', { bufferedStartTime })
|
||||
.andWhere('cs.startDate <= :endDate', { endDate })
|
||||
.andWhere('cs.endDate >= :startDate', { startDate });
|
||||
|
||||
@@ -258,7 +272,7 @@ export class SchedulesService {
|
||||
const conflicts = await qb.getMany();
|
||||
if (conflicts.length > 0) {
|
||||
throw new ConflictException(
|
||||
`该时间段与已有排课冲突: ${conflicts.map((c) => `${c.subject}(${c.startTime}-${c.endTime})`).join(', ')}`,
|
||||
`排课之间必须至少间隔 ${SCHEDULE_GAP_MINUTES} 分钟,与以下排课时间过近: ${conflicts.map((c) => `${c.subject}(${c.startTime}-${c.endTime})`).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsString, IsOptional, IsEnum, IsInt } from 'class-validator';
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
|
||||
export class CreateStudentDto {
|
||||
@IsString()
|
||||
@@ -82,6 +83,31 @@ export class UpdateStudentDto {
|
||||
supervisor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'graduated', 'withdrawn'])
|
||||
@IsIn(['active', 'graduated', 'withdrawn'])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class QueryStudentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['active', 'graduated', 'withdrawn', 'archived'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true' || value === '1') return true;
|
||||
if (value === 'false' || value === '0') return false;
|
||||
return value;
|
||||
})
|
||||
@IsBoolean()
|
||||
includeArchived?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
organizationId?: number;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
Inject,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -21,7 +22,7 @@ import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { StudentsService } from './students.service';
|
||||
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
|
||||
import { CreateStudentDto, QueryStudentDto, UpdateStudentDto } from './dto/student.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
@@ -61,10 +62,7 @@ export class StudentsController {
|
||||
@Get()
|
||||
@RequirePermission('student:view')
|
||||
async findAll(
|
||||
@Query('name') name: string | undefined,
|
||||
@Query('status') status: string | undefined,
|
||||
@Query('includeArchived') includeArchived: string | undefined,
|
||||
@Query('organizationId') organizationId: string | undefined,
|
||||
@Query() query: QueryStudentDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
@@ -72,12 +70,7 @@ export class StudentsController {
|
||||
this.canManageAllStudents(req),
|
||||
);
|
||||
return this.service.findAll(
|
||||
{
|
||||
name,
|
||||
status,
|
||||
includeArchived: includeArchived === 'true',
|
||||
organizationId: organizationId ? +organizationId : undefined,
|
||||
},
|
||||
query,
|
||||
classIds,
|
||||
);
|
||||
}
|
||||
@@ -192,8 +185,8 @@ export class StudentsController {
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('student:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(+id);
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -217,15 +210,15 @@ export class StudentsController {
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('student:edit')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateStudentDto, @Request() req: any) {
|
||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStudentDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.update(+id, dto);
|
||||
const result = await this.service.update(id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生管理',
|
||||
action: '编辑学生',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'student',
|
||||
detail: JSON.stringify(dto),
|
||||
ipAddress,
|
||||
@@ -236,15 +229,15 @@ export class StudentsController {
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('student:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(+id);
|
||||
const result = await this.service.remove(id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生管理',
|
||||
action: '删除学生',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'student',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -271,15 +264,15 @@ export class StudentsController {
|
||||
|
||||
@Put(':id/restore')
|
||||
@RequirePermission('student:edit')
|
||||
async restore(@Param('id') id: string, @Request() req: any) {
|
||||
async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.restore(+id);
|
||||
const result = await this.service.restore(id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生管理',
|
||||
action: '恢复学生',
|
||||
targetId: +id,
|
||||
targetId: id,
|
||||
targetType: 'student',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -403,7 +396,7 @@ export class StudentsController {
|
||||
|
||||
@Get(':id/compare-classes')
|
||||
@RequirePermission('student:view')
|
||||
compareClasses(@Param('id') id: string) {
|
||||
return this.service.compareClasses(+id);
|
||||
compareClasses(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.service.compareClasses(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,3 +462,90 @@ describe('ScheduleSyncService — all shifts fail', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScheduleSyncService — multiple lessons per student per day', () => {
|
||||
it('combines daily lessons into one DingTalk shift with multiple sections', async () => {
|
||||
const scheduleRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 2,
|
||||
classId: 10,
|
||||
classroomId: 1,
|
||||
weekDay: 1,
|
||||
startTime: '22:10',
|
||||
endTime: '23:10',
|
||||
startDate: '2026-07-13',
|
||||
endDate: '2026-07-13',
|
||||
status: 'active',
|
||||
} as ClassSchedule,
|
||||
{
|
||||
id: 1,
|
||||
classId: 10,
|
||||
classroomId: 2,
|
||||
weekDay: 1,
|
||||
startTime: '20:00',
|
||||
endTime: '21:00',
|
||||
startDate: '2026-07-13',
|
||||
endDate: '2026-07-13',
|
||||
status: 'active',
|
||||
} as ClassSchedule,
|
||||
]),
|
||||
};
|
||||
const classStudentRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
|
||||
};
|
||||
const mappingRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
|
||||
};
|
||||
const classRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺一班' }]),
|
||||
};
|
||||
const scheduleUsers = jest.fn().mockResolvedValue(undefined);
|
||||
const upsertShift = jest.fn().mockResolvedValue(2022);
|
||||
const dingTalkService = {
|
||||
queryShifts: jest.fn().mockResolvedValue([]),
|
||||
upsertShift,
|
||||
queryAttendanceGroups: jest.fn().mockResolvedValue([
|
||||
{ group_id: 1495610001, group_name: '排课_冲刺一班', type: 'TURN', member_count: 1 },
|
||||
]),
|
||||
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
|
||||
createAttendanceGroup: jest.fn(),
|
||||
scheduleUsers,
|
||||
};
|
||||
|
||||
const service = new ScheduleSyncService(
|
||||
scheduleRepo as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classRepo as never,
|
||||
dingTalkService as never,
|
||||
);
|
||||
|
||||
const result = await service.syncAll('2026-07-13', 1);
|
||||
|
||||
expect(upsertShift).toHaveBeenCalledTimes(1);
|
||||
expect(upsertShift).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: '冲刺一班_20:00-21:00+22:10-23:10',
|
||||
sections: [
|
||||
expect.objectContaining({
|
||||
times: expect.arrayContaining([
|
||||
expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 20:00:00' }),
|
||||
expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 21:00:00' }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
times: expect.arrayContaining([
|
||||
expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 22:10:00' }),
|
||||
expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 23:10:00' }),
|
||||
]),
|
||||
}),
|
||||
],
|
||||
}));
|
||||
expect(scheduleUsers).toHaveBeenCalledTimes(1);
|
||||
expect(scheduleUsers.mock.calls[0][1]).toEqual([
|
||||
expect.objectContaining({ userid: 'student-1', shift_id: 2022 }),
|
||||
]);
|
||||
expect(result.syncedItems).toBe(1);
|
||||
expect(result.failedBatchCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import {
|
||||
ClassSchedule,
|
||||
ClassStudent,
|
||||
StudentDingMapping,
|
||||
Class,
|
||||
} from '../entities';
|
||||
import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities';
|
||||
import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service';
|
||||
|
||||
interface DailySchedulePeriod {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
scheduleId: number;
|
||||
}
|
||||
|
||||
interface DailySchedulePlan {
|
||||
classId: number;
|
||||
date: string;
|
||||
shiftKey: string;
|
||||
periods: DailySchedulePeriod[];
|
||||
}
|
||||
|
||||
/** 单次排班同步的结果 */
|
||||
export interface ScheduleSyncResult {
|
||||
/** 参与同步的排课记录数 */
|
||||
@@ -89,9 +97,14 @@ export class ScheduleSyncService {
|
||||
const endDate = this.addDays(startDate, days);
|
||||
|
||||
const empty: ScheduleSyncResult = {
|
||||
scheduleCount: 0, shiftCount: 0, groupCount: 0,
|
||||
syncedItems: 0, skippedNoMapping: 0,
|
||||
failedBatchCount: 0, failedItems: 0, errors: [],
|
||||
scheduleCount: 0,
|
||||
shiftCount: 0,
|
||||
groupCount: 0,
|
||||
syncedItems: 0,
|
||||
skippedNoMapping: 0,
|
||||
failedBatchCount: 0,
|
||||
failedItems: 0,
|
||||
errors: [],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
@@ -110,63 +123,77 @@ export class ScheduleSyncService {
|
||||
const classDingUsers = await this.buildClassDingUserMap(classIds);
|
||||
const classNameMap = await this.loadClassNames(classIds);
|
||||
|
||||
// ── Step 3: 班次(按时间段去重,班次列表只查一次) ──
|
||||
const shiftKey = (classId: number, start: string, end: string) =>
|
||||
`${classId}|${start}-${end}`;
|
||||
// ── Step 3: 将每天的多节课合并成一个钉钉班次 ──
|
||||
// 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为
|
||||
// 同一个班次的多个 sections 写入,不能拆成多条 schedule item。
|
||||
const dailyPlans = this.buildDailySchedulePlans(schedules, startDate, endDate);
|
||||
const uniqueShifts = new Map<
|
||||
string,
|
||||
{ className: string; startTime: string; endTime: string }
|
||||
{ className: string; periods: DailySchedulePeriod[] }
|
||||
>();
|
||||
const shiftScheduleCount = new Map<string, number>();
|
||||
for (const schedule of schedules) {
|
||||
const classId = schedule.classId as number;
|
||||
const key = shiftKey(classId, schedule.startTime, schedule.endTime);
|
||||
if (!uniqueShifts.has(key)) {
|
||||
uniqueShifts.set(key, {
|
||||
className: classNameMap.get(classId) || `班级${classId}`,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
const shiftPlanCount = new Map<string, number>();
|
||||
for (const plan of dailyPlans) {
|
||||
if (!uniqueShifts.has(plan.shiftKey)) {
|
||||
uniqueShifts.set(plan.shiftKey, {
|
||||
className: classNameMap.get(plan.classId) || `班级${plan.classId}`,
|
||||
periods: plan.periods,
|
||||
});
|
||||
}
|
||||
shiftScheduleCount.set(key, (shiftScheduleCount.get(key) || 0) + 1);
|
||||
shiftPlanCount.set(plan.shiftKey, (shiftPlanCount.get(plan.shiftKey) || 0) + 1);
|
||||
}
|
||||
|
||||
const existingShifts = await this.dingTalkService.queryShifts(opUserId);
|
||||
const shiftByName = new Map(existingShifts.map((s) => [s.name, s.id]));
|
||||
const timeToShiftId = new Map<string, number>();
|
||||
const shiftByName = new Map(existingShifts.map((shift) => [shift.name, shift.id]));
|
||||
const planToShiftId = new Map<string, number>();
|
||||
const errors: string[] = [];
|
||||
let failedBatchCount = 0;
|
||||
let failedItems = 0;
|
||||
let shiftCount = 0;
|
||||
for (const [key, { className, startTime, endTime }] of uniqueShifts) {
|
||||
const shiftName = `${className}_${startTime}-${endTime}`;
|
||||
for (const [key, { className, periods }] of uniqueShifts) {
|
||||
const periodLabel = periods
|
||||
.map((period) => `${period.startTime}-${period.endTime}`)
|
||||
.join('+');
|
||||
const shiftName = `${className}_${periodLabel}`;
|
||||
try {
|
||||
let shiftId = shiftByName.get(shiftName);
|
||||
const shiftParams = {
|
||||
...(shiftId === undefined ? {} : { id: shiftId }),
|
||||
name: shiftName,
|
||||
owner: opUserId,
|
||||
sections: [{
|
||||
sections: periods.map((period) => ({
|
||||
times: [
|
||||
{ check_type: 'OnDuty' as const, across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false },
|
||||
{ check_type: 'OffDuty' as const, across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false },
|
||||
{
|
||||
check_type: 'OnDuty' as const,
|
||||
across: 0,
|
||||
check_time: `1970-01-01 ${period.startTime}:00`,
|
||||
free_check: false,
|
||||
},
|
||||
{
|
||||
check_type: 'OffDuty' as const,
|
||||
across: 0,
|
||||
check_time: `1970-01-01 ${period.endTime}:00`,
|
||||
free_check: false,
|
||||
},
|
||||
],
|
||||
}],
|
||||
})),
|
||||
setting: {
|
||||
is_flexible: false,
|
||||
serious_late_minutes: -1,
|
||||
absenteeism_late_minutes: this.minutesBetween(startTime, endTime),
|
||||
absenteeism_late_minutes: Math.max(
|
||||
...periods.map((period) => this.minutesBetween(period.startTime, period.endTime)),
|
||||
),
|
||||
},
|
||||
};
|
||||
shiftId = await this.dingTalkService.upsertShift(shiftParams);
|
||||
shiftByName.set(shiftName, shiftId);
|
||||
timeToShiftId.set(key, shiftId);
|
||||
planToShiftId.set(key, shiftId);
|
||||
shiftCount++;
|
||||
} catch (e) {
|
||||
const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`;
|
||||
this.logger.error(msg);
|
||||
errors.push(msg);
|
||||
failedBatchCount++;
|
||||
failedItems += shiftScheduleCount.get(key) || 0;
|
||||
failedItems += shiftPlanCount.get(key) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,10 +203,15 @@ export class ScheduleSyncService {
|
||||
|
||||
// ── Step 5: 按班级同步 ──
|
||||
const schedulesByClass = new Map<number, ClassSchedule[]>();
|
||||
for (const s of schedules) {
|
||||
const cid = s.classId as number;
|
||||
if (!schedulesByClass.has(cid)) schedulesByClass.set(cid, []);
|
||||
schedulesByClass.get(cid)!.push(s);
|
||||
for (const schedule of schedules) {
|
||||
const classId = schedule.classId as number;
|
||||
if (!schedulesByClass.has(classId)) schedulesByClass.set(classId, []);
|
||||
schedulesByClass.get(classId)!.push(schedule);
|
||||
}
|
||||
const dailyPlansByClass = new Map<number, DailySchedulePlan[]>();
|
||||
for (const plan of dailyPlans) {
|
||||
if (!dailyPlansByClass.has(plan.classId)) dailyPlansByClass.set(plan.classId, []);
|
||||
dailyPlansByClass.get(plan.classId)!.push(plan);
|
||||
}
|
||||
|
||||
let syncedItems = 0;
|
||||
@@ -196,11 +228,12 @@ export class ScheduleSyncService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 该班级用到的班次
|
||||
const classDailyPlans = dailyPlansByClass.get(classId) ?? [];
|
||||
// 该班级在同步日期范围内用到的合并班次
|
||||
const classShiftIds = new Set<number>();
|
||||
for (const s of classSchedules) {
|
||||
const sid = timeToShiftId.get(shiftKey(classId, s.startTime, s.endTime));
|
||||
if (sid) classShiftIds.add(sid);
|
||||
for (const plan of classDailyPlans) {
|
||||
const shiftId = planToShiftId.get(plan.shiftKey);
|
||||
if (shiftId) classShiftIds.add(shiftId);
|
||||
}
|
||||
if (classShiftIds.size === 0) {
|
||||
this.logger.warn(`班级 ${className} 无可用班次,跳过(班次创建已计入 failure)`);
|
||||
@@ -208,9 +241,7 @@ export class ScheduleSyncService {
|
||||
}
|
||||
|
||||
// 先展开排班以计算受影响条数
|
||||
const items = this.expandSchedules(
|
||||
classSchedules, dingUserIds, timeToShiftId, startDate, endDate,
|
||||
);
|
||||
const items = this.expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId);
|
||||
|
||||
if (items.length === 0) {
|
||||
this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`);
|
||||
@@ -227,7 +258,11 @@ export class ScheduleSyncService {
|
||||
name: groupName,
|
||||
type: 'TURN' as const,
|
||||
owner: opUserId,
|
||||
members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember' as const, user_id: uid })),
|
||||
members: dingUserIds.map((uid) => ({
|
||||
role: 'Attendance',
|
||||
type: 'StaffMember' as const,
|
||||
user_id: uid,
|
||||
})),
|
||||
shift_ids: [...classShiftIds],
|
||||
enable_emp_select_class: true,
|
||||
disable_check_without_schedule: false,
|
||||
@@ -276,8 +311,8 @@ export class ScheduleSyncService {
|
||||
|
||||
this.logger.log(
|
||||
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
|
||||
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` +
|
||||
(failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''),
|
||||
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` +
|
||||
(failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -333,53 +368,87 @@ export class ScheduleSyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将排课记录展开为每个学生的每日排班数组。
|
||||
* 每条 ClassSchedule(weekDay, startDate-endDate) × 班级每个学生 →
|
||||
* 该日期范围内所有 weekDay 对应日期的排班。
|
||||
* 把本地排课转换为“班级 + 日期”的日排班计划。
|
||||
* 同一天相同时间段会去重,多节课按开始时间排序并合并为一个钉钉班次。
|
||||
*/
|
||||
private expandSchedules(
|
||||
private buildDailySchedulePlans(
|
||||
schedules: ClassSchedule[],
|
||||
dingUserIds: string[],
|
||||
timeToShiftId: Map<string, number>,
|
||||
syncFrom: string,
|
||||
syncTo: string,
|
||||
): DingTalkScheduleItem[] {
|
||||
const seen = new Set<string>();
|
||||
const items: DingTalkScheduleItem[] = [];
|
||||
): DailySchedulePlan[] {
|
||||
const periodMapByClassDate = new Map<string, Map<string, DailySchedulePeriod>>();
|
||||
const fromDate = new Date(syncFrom);
|
||||
const toDate = new Date(syncTo);
|
||||
|
||||
// 预计算日期范围内每一天是星期几(周日=7)
|
||||
const dateWeekDays = new Map<string, number>();
|
||||
for (let d = new Date(fromDate); d <= toDate; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = d.toISOString().slice(0, 10);
|
||||
dateWeekDays.set(dateStr, d.getDay() === 0 ? 7 : d.getDay());
|
||||
}
|
||||
for (let date = new Date(fromDate); date <= toDate; date.setDate(date.getDate() + 1)) {
|
||||
const dateStr = date.toISOString().slice(0, 10);
|
||||
const weekDay = date.getDay() === 0 ? 7 : date.getDay();
|
||||
|
||||
for (const s of schedules) {
|
||||
const shiftId = timeToShiftId.get(`${s.classId}|${s.startTime}-${s.endTime}`);
|
||||
if (!shiftId) continue;
|
||||
for (const schedule of schedules) {
|
||||
if (schedule.classId == null || schedule.weekDay !== weekDay) continue;
|
||||
if (dateStr < schedule.startDate || dateStr > schedule.endDate) continue;
|
||||
|
||||
const scheduleStart = s.startDate > syncFrom ? s.startDate : syncFrom;
|
||||
const scheduleEnd = s.endDate < syncTo ? s.endDate : syncTo;
|
||||
|
||||
for (const [dateStr, weekDay] of dateWeekDays) {
|
||||
if (dateStr < scheduleStart || dateStr > scheduleEnd) continue;
|
||||
if (weekDay !== s.weekDay) continue;
|
||||
|
||||
const workDate = new Date(dateStr + 'T00:00:00+08:00').getTime();
|
||||
for (const userid of dingUserIds) {
|
||||
const dedupKey = `${userid}|${workDate}|${shiftId}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false });
|
||||
const classDateKey = `${schedule.classId}|${dateStr}`;
|
||||
if (!periodMapByClassDate.has(classDateKey)) {
|
||||
periodMapByClassDate.set(classDateKey, new Map());
|
||||
}
|
||||
const periods = periodMapByClassDate.get(classDateKey)!;
|
||||
const periodKey = `${schedule.startTime}-${schedule.endTime}`;
|
||||
const existing = periods.get(periodKey);
|
||||
if (!existing || schedule.id < existing.scheduleId) {
|
||||
periods.set(periodKey, {
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
scheduleId: schedule.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
const plans: DailySchedulePlan[] = [];
|
||||
for (const [classDateKey, periodMap] of periodMapByClassDate) {
|
||||
const separator = classDateKey.indexOf('|');
|
||||
const classId = Number(classDateKey.slice(0, separator));
|
||||
const date = classDateKey.slice(separator + 1);
|
||||
const periods = [...periodMap.values()].sort(
|
||||
(left, right) =>
|
||||
left.startTime.localeCompare(right.startTime) ||
|
||||
left.endTime.localeCompare(right.endTime) ||
|
||||
left.scheduleId - right.scheduleId,
|
||||
);
|
||||
const periodSignature = periods
|
||||
.map((period) => `${period.startTime}-${period.endTime}`)
|
||||
.join('+');
|
||||
plans.push({
|
||||
classId,
|
||||
date,
|
||||
shiftKey: `${classId}|${periodSignature}`,
|
||||
periods,
|
||||
});
|
||||
}
|
||||
|
||||
return plans.sort(
|
||||
(left, right) => left.date.localeCompare(right.date) || left.classId - right.classId,
|
||||
);
|
||||
}
|
||||
|
||||
/** 每个学生每天仅生成一条钉钉排班,shift 内可包含多个课程卡段。 */
|
||||
private expandDailySchedulePlans(
|
||||
plans: DailySchedulePlan[],
|
||||
dingUserIds: string[],
|
||||
planToShiftId: Map<string, number>,
|
||||
): DingTalkScheduleItem[] {
|
||||
const items: DingTalkScheduleItem[] = [];
|
||||
for (const plan of plans) {
|
||||
const shiftId = planToShiftId.get(plan.shiftKey);
|
||||
if (!shiftId) continue;
|
||||
const workDate = new Date(`${plan.date}T00:00:00+08:00`).getTime();
|
||||
for (const userid of dingUserIds) {
|
||||
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private minutesBetween(startTime: string, endTime: string): number {
|
||||
const [startHour, startMinute] = startTime.split(':').map(Number);
|
||||
|
||||
18
apps/server/src/wallets/dto/wallet.dto.ts
Normal file
18
apps/server/src/wallets/dto/wallet.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IsIn, IsInt, IsNumber, IsOptional, IsString, MaxLength, NotEquals } from 'class-validator';
|
||||
|
||||
export class ChangeWalletBalanceDto {
|
||||
@IsInt()
|
||||
studentId: number;
|
||||
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@NotEquals(0)
|
||||
amount: number;
|
||||
|
||||
@IsIn(['recharge', 'adjustment'])
|
||||
type: 'recharge' | 'adjustment';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
description?: string;
|
||||
}
|
||||
44
apps/server/src/wallets/wallets.controller.ts
Normal file
44
apps/server/src/wallets/wallets.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Body, Controller, Get, Post, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { ChangeWalletBalanceDto } from './dto/wallet.dto';
|
||||
import { WalletsService } from './wallets.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('wallets')
|
||||
export class WalletsController {
|
||||
constructor(private service: WalletsService, private logService: OperationLogsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('wallet:view')
|
||||
findAll(@Query('keyword') keyword?: string, @Query('debtOnly') debtOnly?: string) {
|
||||
return this.service.findAll({ keyword, debtOnly: debtOnly === 'true' });
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
@RequirePermission('wallet:view')
|
||||
findTransactions(@Query('studentId') studentId: string) {
|
||||
return this.service.findTransactions(Number(studentId));
|
||||
}
|
||||
|
||||
@Post('change-balance')
|
||||
@RequirePermission('wallet:edit')
|
||||
async changeBalance(@Body() dto: ChangeWalletBalanceDto, @Request() req: any) {
|
||||
const result = await this.service.changeBalance(dto, req.user?.id);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生余额',
|
||||
action: dto.type === 'recharge' ? '余额充值' : '余额调账',
|
||||
targetId: dto.studentId,
|
||||
targetType: 'student_wallet',
|
||||
detail: `金额 ¥${dto.amount}${dto.description ? `,${dto.description}` : ''}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
17
apps/server/src/wallets/wallets.module.ts
Normal file
17
apps/server/src/wallets/wallets.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentWallet } from '../entities/student-wallet.entity';
|
||||
import { WalletTransaction } from '../entities/wallet-transaction.entity';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { WalletsController } from './wallets.controller';
|
||||
import { WalletsService } from './wallets.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([StudentWallet, WalletTransaction, Student, Bill]), OperationLogsModule],
|
||||
controllers: [WalletsController],
|
||||
providers: [WalletsService],
|
||||
exports: [WalletsService],
|
||||
})
|
||||
export class WalletsModule {}
|
||||
52
apps/server/src/wallets/wallets.service.spec.ts
Normal file
52
apps/server/src/wallets/wallets.service.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { WalletsService } from './wallets.service';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
|
||||
const manager = (walletBalance: number) => {
|
||||
const wallet = { id: 1, studentId: 10, balance: walletBalance };
|
||||
const saved: any[] = [];
|
||||
return {
|
||||
wallet,
|
||||
saved,
|
||||
value: {
|
||||
findOne: jest.fn(async () => wallet),
|
||||
findOneByOrFail: jest.fn(async () => wallet),
|
||||
save: jest.fn(async (value: any) => { saved.push(value); return value; }),
|
||||
create: jest.fn((_entity: unknown, value: unknown) => value),
|
||||
createQueryBuilder: jest.fn(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('WalletsService payment rules', () => {
|
||||
const service = new WalletsService({} as any, {} as any, {} as any, {} as any);
|
||||
|
||||
it('partially pays a bill when balance is insufficient', async () => {
|
||||
const ctx = manager(40);
|
||||
const bill = { id: 9, studentId: 10, totalAmount: 100, paidAmount: 0, outstandingAmount: 100, status: 'unpaid' } as Bill;
|
||||
const result = await service.debitBill(ctx.value as any, bill, 1);
|
||||
expect(result.status).toBe('partially_paid');
|
||||
expect(Number(result.paidAmount)).toBe(40);
|
||||
expect(Number(result.outstandingAmount)).toBe(60);
|
||||
expect(Number(ctx.wallet.balance)).toBe(0);
|
||||
expect(ctx.saved.some((row) => row.type === 'bill_payment' && Number(row.amount) === -40)).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a bill paid when balance covers it', async () => {
|
||||
const ctx = manager(120);
|
||||
const bill = { id: 9, studentId: 10, totalAmount: 100, paidAmount: 0, outstandingAmount: 100, status: 'unpaid' } as Bill;
|
||||
const result = await service.debitBill(ctx.value as any, bill);
|
||||
expect(result.status).toBe('paid');
|
||||
expect(Number(result.outstandingAmount)).toBe(0);
|
||||
expect(Number(ctx.wallet.balance)).toBe(20);
|
||||
});
|
||||
|
||||
it('refunds paid amount and cancels the bill', async () => {
|
||||
const ctx = manager(10);
|
||||
const bill = { id: 9, studentId: 10, totalAmount: 100, paidAmount: 40, outstandingAmount: 60, status: 'partially_paid' } as Bill;
|
||||
const result = await service.refundBill(ctx.value as any, bill, '录入错误', 1);
|
||||
expect(result.status).toBe('cancelled');
|
||||
expect(Number(ctx.wallet.balance)).toBe(50);
|
||||
expect(ctx.saved.some((row) => row.type === 'bill_refund' && Number(row.amount) === 40)).toBe(true);
|
||||
});
|
||||
});
|
||||
167
apps/server/src/wallets/wallets.service.ts
Normal file
167
apps/server/src/wallets/wallets.service.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentWallet } from '../entities/student-wallet.entity';
|
||||
import { WalletTransaction } from '../entities/wallet-transaction.entity';
|
||||
import { In } from 'typeorm';
|
||||
import { ChangeWalletBalanceDto } from './dto/wallet.dto';
|
||||
|
||||
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
|
||||
|
||||
@Injectable()
|
||||
export class WalletsService {
|
||||
constructor(
|
||||
@InjectRepository(StudentWallet) private walletRepo: Repository<StudentWallet>,
|
||||
@InjectRepository(WalletTransaction) private transactionRepo: Repository<WalletTransaction>,
|
||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async findAll(query?: { keyword?: string; debtOnly?: boolean }) {
|
||||
const students = await this.studentRepo
|
||||
.createQueryBuilder('student')
|
||||
.where('student.status = :status', { status: 'active' })
|
||||
.andWhere(
|
||||
query?.keyword
|
||||
? '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)'
|
||||
: '1 = 1',
|
||||
query?.keyword ? { keyword: `%${query.keyword}%` } : {},
|
||||
)
|
||||
.orderBy('student.name', 'ASC')
|
||||
.getMany();
|
||||
if (!students.length) return [];
|
||||
|
||||
const ids = students.map((student) => student.id);
|
||||
const wallets = await this.walletRepo.find({ where: { studentId: In(ids) } });
|
||||
const bills = await this.dataSource.getRepository(Bill)
|
||||
.createQueryBuilder('bill')
|
||||
.select('bill.studentId', 'studentId')
|
||||
.addSelect('SUM(bill.outstandingAmount)', 'outstandingAmount')
|
||||
.where('bill.studentId IN (:...ids)', { ids })
|
||||
.andWhere('bill.status IN (:...statuses)', { statuses: ['unpaid', 'partially_paid'] })
|
||||
.groupBy('bill.studentId')
|
||||
.getRawMany<{ studentId: number; outstandingAmount: string }>();
|
||||
const walletMap = new Map(wallets.map((wallet) => [wallet.studentId, wallet]));
|
||||
const debtMap = new Map(bills.map((bill) => [Number(bill.studentId), money(bill.outstandingAmount)]));
|
||||
return students
|
||||
.map((student) => ({
|
||||
studentId: student.id,
|
||||
studentName: student.name,
|
||||
studentNo: student.studentNo,
|
||||
balance: money(walletMap.get(student.id)?.balance),
|
||||
outstandingAmount: debtMap.get(student.id) || 0,
|
||||
}))
|
||||
.filter((row) => !query?.debtOnly || row.outstandingAmount > 0);
|
||||
}
|
||||
|
||||
async findTransactions(studentId: number) {
|
||||
return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) {
|
||||
if (dto.type === 'recharge' && dto.amount <= 0) throw new BadRequestException('充值金额必须大于 0');
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const wallet = await this.getOrCreateWallet(manager, dto.studentId);
|
||||
const nextBalance = money(Number(wallet.balance) + dto.amount);
|
||||
if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0');
|
||||
wallet.balance = nextBalance;
|
||||
await manager.save(wallet);
|
||||
await manager.save(
|
||||
manager.create(WalletTransaction, {
|
||||
studentId: dto.studentId,
|
||||
billId: null,
|
||||
type: dto.type,
|
||||
amount: money(dto.amount),
|
||||
balanceAfter: nextBalance,
|
||||
description: dto.description || (dto.type === 'recharge' ? '财务充值' : '余额调账'),
|
||||
recordedBy: recordedBy || null,
|
||||
}),
|
||||
);
|
||||
const payments = dto.amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : [];
|
||||
const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId });
|
||||
return { wallet: finalWallet, payments };
|
||||
});
|
||||
}
|
||||
|
||||
async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) {
|
||||
if (bill.status === 'cancelled' || money(bill.outstandingAmount) <= 0) return bill;
|
||||
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
|
||||
const amount = money(Math.min(Number(wallet.balance), Number(bill.outstandingAmount)));
|
||||
if (amount <= 0) {
|
||||
bill.status = money(bill.paidAmount) > 0 ? 'partially_paid' : 'unpaid';
|
||||
return manager.save(bill);
|
||||
}
|
||||
wallet.balance = money(Number(wallet.balance) - amount);
|
||||
bill.paidAmount = money(Number(bill.paidAmount) + amount);
|
||||
bill.outstandingAmount = money(Number(bill.totalAmount) - Number(bill.paidAmount));
|
||||
bill.status = bill.outstandingAmount <= 0 ? 'paid' : 'partially_paid';
|
||||
await manager.save(wallet);
|
||||
await manager.save(bill);
|
||||
await manager.save(
|
||||
manager.create(WalletTransaction, {
|
||||
studentId: bill.studentId,
|
||||
billId: bill.id,
|
||||
type: 'bill_payment',
|
||||
amount: -amount,
|
||||
balanceAfter: wallet.balance,
|
||||
description: `账单 #${bill.id} 自动扣款`,
|
||||
recordedBy: recordedBy || null,
|
||||
}),
|
||||
);
|
||||
return bill;
|
||||
}
|
||||
|
||||
async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) {
|
||||
const paid = money(bill.paidAmount);
|
||||
if (paid > 0) {
|
||||
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
|
||||
wallet.balance = money(Number(wallet.balance) + paid);
|
||||
await manager.save(wallet);
|
||||
await manager.save(
|
||||
manager.create(WalletTransaction, {
|
||||
studentId: bill.studentId,
|
||||
billId: bill.id,
|
||||
type: 'bill_refund',
|
||||
amount: paid,
|
||||
balanceAfter: wallet.balance,
|
||||
description: `取消账单 #${bill.id} 冲正:${reason}`,
|
||||
recordedBy: recordedBy || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
bill.status = 'cancelled';
|
||||
bill.paidAmount = 0;
|
||||
bill.outstandingAmount = 0;
|
||||
bill.cancelledAt = new Date();
|
||||
bill.cancelReason = reason;
|
||||
return manager.save(bill);
|
||||
}
|
||||
|
||||
private async settleOutstandingBills(manager: EntityManager, studentId: number, recordedBy?: number) {
|
||||
const bills = await manager
|
||||
.createQueryBuilder(Bill, 'bill')
|
||||
.where('bill.studentId = :studentId', { studentId })
|
||||
.andWhere('bill.status IN (:...statuses)', { statuses: ['unpaid', 'partially_paid'] })
|
||||
.andWhere('bill.outstandingAmount > 0')
|
||||
.orderBy('bill.periodStart', 'ASC')
|
||||
.addOrderBy('bill.id', 'ASC')
|
||||
.getMany();
|
||||
const settled: Bill[] = [];
|
||||
for (const bill of bills) {
|
||||
const wallet = await manager.findOne(StudentWallet, { where: { studentId } });
|
||||
if (!wallet || money(wallet.balance) <= 0) break;
|
||||
settled.push(await this.debitBill(manager, bill, recordedBy));
|
||||
}
|
||||
return settled;
|
||||
}
|
||||
|
||||
private async getOrCreateWallet(manager: EntityManager, studentId: number) {
|
||||
let wallet = await manager.findOne(StudentWallet, { where: { studentId } });
|
||||
if (!wallet) wallet = await manager.save(manager.create(StudentWallet, { studentId, balance: 0 }));
|
||||
return wallet;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user