forked from wangziqi/gongxue-base
Compare commits
49 Commits
codex/wzq
...
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 | |||
| 7bfd30b513 | |||
| ceb0e2bf14 | |||
| acb11b87a3 | |||
| b9e4caae99 | |||
| f98c0ccaa8 | |||
| aa1ed7db56 | |||
| 0533c30ece | |||
| 1f32d1285b | |||
| 7b08560aef | |||
| 04ef5c42d9 | |||
| 0515e6ed27 | |||
| effa34434b | |||
| 0377acd33b | |||
| 6396d3e934 | |||
| dfd3cf6772 | |||
| 2874ab7bee | |||
| 8cadf8970e | |||
| 337d25e370 | |||
| 7860703672 | |||
| ebce2463f0 | |||
| 910a4263f6 | |||
| 6d42241918 | |||
| 70283d9dfe | |||
| 187e4f2af0 | |||
| 619b3b22bd | |||
| b5ef91bf2d |
@@ -14,6 +14,7 @@ const RoomsPage = lazy(() => import('./pages/Rooms'));
|
|||||||
const OccupanciesPage = lazy(() => import('./pages/Occupancies'));
|
const OccupanciesPage = lazy(() => import('./pages/Occupancies'));
|
||||||
const ExpensesPage = lazy(() => import('./pages/Expenses'));
|
const ExpensesPage = lazy(() => import('./pages/Expenses'));
|
||||||
const BillsPage = lazy(() => import('./pages/Bills'));
|
const BillsPage = lazy(() => import('./pages/Bills'));
|
||||||
|
const WalletsPage = lazy(() => import('./pages/Wallets'));
|
||||||
const RoomVisualPage = lazy(() => import('./pages/RoomVisual'));
|
const RoomVisualPage = lazy(() => import('./pages/RoomVisual'));
|
||||||
const OperationLogsPage = lazy(() => import('./pages/OperationLogs'));
|
const OperationLogsPage = lazy(() => import('./pages/OperationLogs'));
|
||||||
const UsersPage = lazy(() => import('./pages/Users'));
|
const UsersPage = lazy(() => import('./pages/Users'));
|
||||||
@@ -134,6 +135,14 @@ const App: React.FC = () => {
|
|||||||
</PermissionRoute>
|
</PermissionRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="wallets"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="wallet:view">
|
||||||
|
<WalletsPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="bills"
|
path="bills"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ instance.interceptors.request.use((config) => {
|
|||||||
instance.interceptors.response.use(
|
instance.interceptors.response.use(
|
||||||
(res) => res.data,
|
(res) => res.data,
|
||||||
(err) => {
|
(err) => {
|
||||||
if (err.response?.status === 401) {
|
const isLoginRequest =
|
||||||
|
err.config?.url === '/auth/login' || err.config?.url === 'auth/login';
|
||||||
|
|
||||||
|
if (err.response?.status === 401 && !isLoginRequest) {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
localStorage.removeItem('permissions');
|
localStorage.removeItem('permissions');
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const SECTIONS: MenuSection[] = [
|
|||||||
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
|
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
|
||||||
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
|
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
|
||||||
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
|
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
|
||||||
|
{ key: '/wallets', label: '学生余额', icon: 'wallet', permission: 'wallet:view' },
|
||||||
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
|
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -111,8 +112,9 @@ export function getRoleDomains(roles: readonly string[], permissions: readonly s
|
|||||||
normalized.add('academic');
|
normalized.add('academic');
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
permissions.includes('room:view') &&
|
(permissions.includes('room:view') &&
|
||||||
(permissions.includes('occupancy:view') || permissions.includes('expense:view'))
|
(permissions.includes('occupancy:view') || permissions.includes('expense:view'))) ||
|
||||||
|
permissions.includes('wallet:view')
|
||||||
) {
|
) {
|
||||||
normalized.add('accommodation');
|
normalized.add('accommodation');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Badge, Popover, Button, List, Typography, Empty } from 'antd';
|
|||||||
import { BellOutlined } from '@ant-design/icons';
|
import { BellOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
|
||||||
|
|
||||||
interface NotificationItem {
|
interface NotificationItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -14,18 +15,6 @@ interface NotificationItem {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
|
||||||
bill_generated: '账单',
|
|
||||||
bill_paid: '账单',
|
|
||||||
check_in: '入住',
|
|
||||||
check_out: '退宿',
|
|
||||||
deposit_due: '押金',
|
|
||||||
deposit_refunded: '押金',
|
|
||||||
class_change: '班级',
|
|
||||||
schedule_conflict: '排课',
|
|
||||||
announcement: '公告',
|
|
||||||
};
|
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
const diff = Date.now() - new Date(dateStr).getTime();
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
const mins = Math.floor(diff / 60000);
|
const mins = Math.floor(diff / 60000);
|
||||||
@@ -163,7 +152,7 @@ const NotificationBell: React.FC = () => {
|
|||||||
strong={!item.isRead}
|
strong={!item.isRead}
|
||||||
style={{ fontSize: 14 }}
|
style={{ fontSize: 14 }}
|
||||||
>
|
>
|
||||||
[{typeLabels[item.type] || item.type}] {item.title}
|
[{notificationTypeLabels[item.type] || item.type}] {formatNotificationText(item.title)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
|
|||||||
@@ -105,6 +105,20 @@ interface AttachmentRecord {
|
|||||||
fileSize: number;
|
fileSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AttendanceRecordItem {
|
||||||
|
id: number;
|
||||||
|
attendanceDate: string;
|
||||||
|
session: string;
|
||||||
|
status: string;
|
||||||
|
source?: string;
|
||||||
|
remark?: string | null;
|
||||||
|
punchTime?: string | null;
|
||||||
|
punchDeviceName?: string | null;
|
||||||
|
punchDeviceId?: string | null;
|
||||||
|
schedule?: { subject?: string } | null;
|
||||||
|
class?: { name?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface StudentProfileAggregate {
|
interface StudentProfileAggregate {
|
||||||
student: StudentInfo;
|
student: StudentInfo;
|
||||||
profile: ProfileData | null;
|
profile: ProfileData | null;
|
||||||
@@ -113,6 +127,7 @@ interface StudentProfileAggregate {
|
|||||||
learningRecords: LearningRecord[];
|
learningRecords: LearningRecord[];
|
||||||
result: ResultData | null;
|
result: ResultData | null;
|
||||||
attachments: AttachmentRecord[];
|
attachments: AttachmentRecord[];
|
||||||
|
attendances: AttendanceRecordItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StudentProfileContentProps {
|
export interface StudentProfileContentProps {
|
||||||
@@ -178,6 +193,58 @@ const formatFileSize = (bytes: number): string => {
|
|||||||
|
|
||||||
// ---- Tab Components ----
|
// ---- Tab Components ----
|
||||||
|
|
||||||
|
const ATTENDANCE_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||||
|
present: { text: '出勤', color: 'green' },
|
||||||
|
late: { text: '迟到', color: 'orange' },
|
||||||
|
absent: { text: '缺勤', color: 'red' },
|
||||||
|
leave: { text: '请假', color: 'blue' },
|
||||||
|
pending: { text: '待确认', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SESSION_LABELS: Record<string, string> = {
|
||||||
|
morning_reading: '早自习',
|
||||||
|
morning: '上午',
|
||||||
|
afternoon: '下午',
|
||||||
|
evening_study: '晚自习',
|
||||||
|
night_check: '晚寝',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => {
|
||||||
|
const columns: ColumnsType<AttendanceRecordItem> = [
|
||||||
|
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
|
||||||
|
{ title: '课程', render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤' },
|
||||||
|
{ title: '时段', dataIndex: 'session', width: 100, render: (value: string) => SESSION_LABELS[value] || value || '-' },
|
||||||
|
{
|
||||||
|
title: '结果', dataIndex: 'status', width: 90,
|
||||||
|
render: (value: string) => {
|
||||||
|
const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' };
|
||||||
|
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '打卡时间', dataIndex: 'punchTime', width: 170, render: (value?: string | null) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-' },
|
||||||
|
{
|
||||||
|
title: '打卡设备',
|
||||||
|
render: (_: unknown, record) => {
|
||||||
|
const name = record.punchDeviceName?.trim();
|
||||||
|
const id = record.punchDeviceId?.trim();
|
||||||
|
if (name && id && name !== id) return `${name}(${id})`;
|
||||||
|
return name || id || (record.source === 'manual' ? '老师手动标记' : '-');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '备注', dataIndex: 'remark', render: (value?: string | null) => value || '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return data.length > 0 ? (
|
||||||
|
<Table<AttendanceRecordItem>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }}
|
||||||
|
/>
|
||||||
|
) : <Empty description="暂无出勤记录" />;
|
||||||
|
};
|
||||||
|
|
||||||
interface TabProps {
|
interface TabProps {
|
||||||
studentId: number;
|
studentId: number;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
@@ -321,7 +388,11 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 15 }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="添加报读记录"
|
title="添加报读记录"
|
||||||
@@ -428,7 +499,11 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 15 }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="添加考试成绩"
|
title="添加考试成绩"
|
||||||
@@ -529,7 +604,11 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({ data, st
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 15 }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="添加学情记录"
|
title="添加学情记录"
|
||||||
@@ -712,7 +791,11 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ dat
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 15 }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50],
|
||||||
|
}}
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -763,7 +846,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
|||||||
|
|
||||||
const tabItems = useMemo(() => {
|
const tabItems = useMemo(() => {
|
||||||
if (!aggregateData) return [];
|
if (!aggregateData) return [];
|
||||||
const { profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData;
|
const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } = aggregateData;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: 'profile',
|
key: 'profile',
|
||||||
@@ -791,8 +874,8 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'attendance',
|
key: 'attendance',
|
||||||
label: '出勤记录',
|
label: `出勤记录 (${attendances.length})`,
|
||||||
children: <Empty description="暂无出勤记录" />,
|
children: <AttendanceTab data={attendances} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'learning',
|
key: 'learning',
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const iconMap: Record<string, React.ReactNode> = {
|
|||||||
expense: <DollarOutlined />,
|
expense: <DollarOutlined />,
|
||||||
bill: <FileTextOutlined />,
|
bill: <FileTextOutlined />,
|
||||||
deposit: <WalletOutlined />,
|
deposit: <WalletOutlined />,
|
||||||
|
wallet: <WalletOutlined />,
|
||||||
classroom: <ReadOutlined />,
|
classroom: <ReadOutlined />,
|
||||||
rental: <FileProtectOutlined />,
|
rental: <FileProtectOutlined />,
|
||||||
organization: <TagsOutlined />,
|
organization: <TagsOutlined />,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
canPullAttendance,
|
canPullAttendance,
|
||||||
getAttendanceExperience,
|
getAttendanceExperience,
|
||||||
|
getPunchDisplayInfo,
|
||||||
getSchedulePhase,
|
getSchedulePhase,
|
||||||
summarizeAttendance,
|
summarizeAttendance,
|
||||||
summarizeLessonCheckins,
|
summarizeLessonCheckins,
|
||||||
@@ -65,3 +66,34 @@ describe('lesson check-in summary', () => {
|
|||||||
).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 });
|
).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('lesson punch device display', () => {
|
||||||
|
it('labels attendance machine punches with the machine name and id', () => {
|
||||||
|
expect(
|
||||||
|
getPunchDisplayInfo({
|
||||||
|
status: 'present',
|
||||||
|
source: 'dingtalk',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
punchTime: '2026-07-11T00:55:00.000Z',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
label: '考勤机打卡',
|
||||||
|
machine: true,
|
||||||
|
detail: '东门考勤机(ATM-01)',
|
||||||
|
time: '2026-07-11T00:55:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes mobile punches and manual teacher markings', () => {
|
||||||
|
expect(
|
||||||
|
getPunchDisplayInfo({ status: 'present', source: 'dingtalk', punchSource: 'USER' }),
|
||||||
|
).toEqual({ label: '手机打卡', machine: false, detail: undefined, time: undefined });
|
||||||
|
expect(getPunchDisplayInfo({ status: 'present', source: 'manual' })).toEqual({
|
||||||
|
label: '老师手动标记',
|
||||||
|
machine: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -82,3 +82,56 @@ export function summarizeLessonCheckins(
|
|||||||
notCheckedIn: records.length - checkedIn,
|
notCheckedIn: records.length - checkedIn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface PunchDisplayRecord {
|
||||||
|
status: string;
|
||||||
|
source?: string;
|
||||||
|
punchTime?: string | null;
|
||||||
|
punchSource?: string | null;
|
||||||
|
punchDeviceName?: string | null;
|
||||||
|
punchDeviceId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PunchDisplayInfo {
|
||||||
|
label: string;
|
||||||
|
machine: boolean;
|
||||||
|
detail?: string;
|
||||||
|
time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPunchDisplayInfo(record: PunchDisplayRecord): PunchDisplayInfo | null {
|
||||||
|
if (record.status !== 'present' && record.status !== 'late') return null;
|
||||||
|
if (record.source === 'manual') return { label: '老师手动标记', machine: false };
|
||||||
|
|
||||||
|
const source = (record.punchSource || '').trim().toUpperCase();
|
||||||
|
const machine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some(
|
||||||
|
(value) => source === value || source.includes(value),
|
||||||
|
);
|
||||||
|
const label = machine
|
||||||
|
? '考勤机打卡'
|
||||||
|
: source === 'USER'
|
||||||
|
? '手机打卡'
|
||||||
|
: source.includes('BEACON') || source.includes('BLE')
|
||||||
|
? '蓝牙打卡'
|
||||||
|
: source.includes('WIFI')
|
||||||
|
? 'Wi-Fi 打卡'
|
||||||
|
: source.includes('APPROVE')
|
||||||
|
? '审批补卡'
|
||||||
|
: source
|
||||||
|
? `其他打卡(${record.punchSource})`
|
||||||
|
: '打卡来源未知';
|
||||||
|
const device = record.punchDeviceName?.trim();
|
||||||
|
const deviceId = record.punchDeviceId?.trim();
|
||||||
|
const detail = device
|
||||||
|
? deviceId && deviceId !== device
|
||||||
|
? `${device}(${deviceId})`
|
||||||
|
: device
|
||||||
|
: deviceId || undefined;
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
machine,
|
||||||
|
detail,
|
||||||
|
time: record.punchTime || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -502,3 +502,25 @@
|
|||||||
.attendance-marking-actions .ant-btn {
|
.attendance-marking-actions .ant-btn {
|
||||||
min-width: 54px;
|
min-width: 54px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.punch-device-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punch-device-cell .ant-tag {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punch-device-cell strong {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punch-device-cell span {
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { message } from '../../ui/app-message';
|
|||||||
import {
|
import {
|
||||||
canPullAttendance,
|
canPullAttendance,
|
||||||
getAttendanceExperience,
|
getAttendanceExperience,
|
||||||
|
getPunchDisplayInfo,
|
||||||
getSchedulePhase,
|
getSchedulePhase,
|
||||||
summarizeLessonCheckins,
|
summarizeLessonCheckins,
|
||||||
type AttendanceSummary,
|
type AttendanceSummary,
|
||||||
@@ -96,6 +97,10 @@ interface AttendanceRecordItem {
|
|||||||
class: { id: number; name: string } | null;
|
class: { id: number; name: string } | null;
|
||||||
scheduleId?: number | null;
|
scheduleId?: number | null;
|
||||||
attendanceSessionId?: number | null;
|
attendanceSessionId?: number | null;
|
||||||
|
punchTime?: string | null;
|
||||||
|
punchSource?: string | null;
|
||||||
|
punchDeviceName?: string | null;
|
||||||
|
punchDeviceId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssignedClass {
|
interface AssignedClass {
|
||||||
@@ -288,6 +293,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
`/attendance-lessons/schedules/${schedule.id}/pull`,
|
`/attendance-lessons/schedules/${schedule.id}/pull`,
|
||||||
{ date: today },
|
{ date: today },
|
||||||
);
|
);
|
||||||
|
setSelectedSchedule(data.schedule);
|
||||||
setLessonSession(data.session);
|
setLessonSession(data.session);
|
||||||
setLessonRecords(data.records);
|
setLessonRecords(data.records);
|
||||||
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
||||||
@@ -386,7 +392,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
|
|
||||||
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={820} title={null} className="attendance-drawer">
|
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
|
||||||
<div className="lesson-record-header">
|
<div className="lesson-record-header">
|
||||||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||||||
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
||||||
@@ -438,6 +444,21 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
|
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
|
||||||
|
{
|
||||||
|
title: '打卡设备',
|
||||||
|
width: 220,
|
||||||
|
render: (_: unknown, record: AttendanceRecordItem) => {
|
||||||
|
const info = getPunchDisplayInfo(record);
|
||||||
|
if (!info) return <span className="muted-text">—</span>;
|
||||||
|
return (
|
||||||
|
<div className="punch-device-cell">
|
||||||
|
<Tag color={info.machine ? 'green' : 'blue'}>{info.label}</Tag>
|
||||||
|
{info.detail && <strong>{info.detail}</strong>}
|
||||||
|
{info.time && <span>{dayjs(info.time).format('HH:mm:ss')}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">—</span> },
|
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">—</span> },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Popconfirm,
|
Popconfirm,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Tooltip,
|
|
||||||
Spin,
|
Spin,
|
||||||
Empty,
|
Empty,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -26,12 +25,12 @@ import PermissionButton from '../../components/PermissionButton';
|
|||||||
import { downloadBlob } from '../../utils/download';
|
import { downloadBlob } from '../../utils/download';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
|
||||||
|
|
||||||
const statusMap: Record<string, { text: string; color: string }> = {
|
const statusMap: Record<string, { text: string; color: string }> = {
|
||||||
draft: { text: '草稿', color: 'default' },
|
unpaid: { text: '待支付', color: 'orange' },
|
||||||
confirmed: { text: '已确认', color: 'blue' },
|
partially_paid: { text: '部分支付', color: 'gold' },
|
||||||
paid: { text: '已支付', color: 'green' },
|
paid: { text: '已支付', color: 'green' },
|
||||||
|
cancelled: { text: '已取消', color: 'default' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
@@ -93,8 +92,7 @@ const BillsPage: React.FC = () => {
|
|||||||
const values = await generateForm.validateFields();
|
const values = await generateForm.validateFields();
|
||||||
try {
|
try {
|
||||||
const res: any = await api.post('/bills/generate', {
|
const res: any = await api.post('/bills/generate', {
|
||||||
periodStart: values.period[0].format('YYYY-MM-DD'),
|
billingMonth: values.billingMonth.format('YYYY-MM'),
|
||||||
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
|
||||||
});
|
});
|
||||||
message.success(res.message || '生成成功');
|
message.success(res.message || '生成成功');
|
||||||
setGenerateModal(false);
|
setGenerateModal(false);
|
||||||
@@ -119,43 +117,29 @@ const BillsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateStatus = async (id: number, status: string) => {
|
|
||||||
try {
|
|
||||||
await api.put(`/bills/${id}/status`, { status });
|
|
||||||
message.success('状态更新成功');
|
|
||||||
fetchData();
|
|
||||||
if (detailModal?.id === id) {
|
|
||||||
setDetailModal({ ...detailModal, status });
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '操作失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const batchUpdateStatus = async (status: string) => {
|
|
||||||
if (selectedRows.length === 0) return message.warning('请先选择账单');
|
const handleCancel = async (id: number) => {
|
||||||
if (batchLoading) return;
|
let reason = '';
|
||||||
setBatchLoading(true);
|
Modal.confirm({
|
||||||
try {
|
title: '取消账单并退回已扣余额',
|
||||||
await api.put('/bills/batch/status', { ids: selectedRows, status });
|
content: <Input.TextArea placeholder="请输入取消原因" maxLength={300} onChange={(event) => { reason = event.target.value; }} />,
|
||||||
message.success(`已批量更新 ${selectedRows.length} 条账单`);
|
okText: '确认取消', cancelText: '返回',
|
||||||
setSelectedRows([]);
|
onOk: async () => {
|
||||||
fetchData();
|
if (!reason.trim()) { message.error('请输入取消原因'); throw new Error('reason required'); }
|
||||||
} catch (e: any) {
|
await api.post(`/bills/${id}/cancel`, { reason: reason.trim() });
|
||||||
message.error(e?.message || '操作失败');
|
message.success('账单已取消,已扣余额已冲正退回');
|
||||||
} finally {
|
fetchData();
|
||||||
setBatchLoading(false);
|
},
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/bills/${id}`);
|
await api.delete(`/bills/${id}`);
|
||||||
message.success('账单已删除');
|
message.success('删除成功');
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (e: any) {
|
} catch (error: any) { message.error(error?.message || '删除失败'); }
|
||||||
message.error(e?.message || '删除失败');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const batchDelete = async () => {
|
const batchDelete = async () => {
|
||||||
@@ -212,31 +196,16 @@ const BillsPage: React.FC = () => {
|
|||||||
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
|
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '可用押金',
|
title: '已扣余额', dataIndex: 'paidAmount', width: 110,
|
||||||
dataIndex: 'availableDeposit',
|
render: (value: number) => <span style={{ color: '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</span>,
|
||||||
width: 120,
|
|
||||||
render: (v: number) =>
|
|
||||||
v > 0 ? (
|
|
||||||
<span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: '#999' }}>-</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '抵扣后应付',
|
title: '待补缴', dataIndex: 'outstandingAmount', width: 110,
|
||||||
dataIndex: 'amountAfterDeposit',
|
render: (value: number) => <strong style={{ color: Number(value) > 0 ? '#cf1322' : '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</strong>,
|
||||||
width: 130,
|
},
|
||||||
render: (v: number, r: any) => {
|
{
|
||||||
const has = Number(r.availableDeposit || 0) > 0;
|
title: '钱包余额', dataIndex: 'walletBalance', width: 110,
|
||||||
if (!has) return <span style={{ color: '#999' }}>-</span>;
|
render: (value: number) => `¥${Number(value || 0).toFixed(2)}`,
|
||||||
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: '状态',
|
title: '状态',
|
||||||
@@ -263,25 +232,6 @@ const BillsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
{record.status === 'draft' && (
|
|
||||||
<PermissionButton
|
|
||||||
permission="bill:confirm"
|
|
||||||
size="small"
|
|
||||||
onClick={() => updateStatus(record.id, 'confirmed')}
|
|
||||||
>
|
|
||||||
确认
|
|
||||||
</PermissionButton>
|
|
||||||
)}
|
|
||||||
{record.status === 'confirmed' && (
|
|
||||||
<PermissionButton
|
|
||||||
permission="bill:confirm"
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
onClick={() => updateStatus(record.id, 'paid')}
|
|
||||||
>
|
|
||||||
标记已付
|
|
||||||
</PermissionButton>
|
|
||||||
)}
|
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="bill:export-pdf"
|
permission="bill:export-pdf"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -290,20 +240,20 @@ const BillsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
PDF
|
PDF
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
<Popconfirm
|
{record.status !== 'cancelled' && (
|
||||||
title="确定删除此账单?"
|
<PermissionButton permission="bill:delete" size="small" danger onClick={() => handleCancel(record.id)}>
|
||||||
onConfirm={() => handleDelete(record.id)}
|
取消并冲正
|
||||||
okText="删除"
|
|
||||||
cancelText="取消"
|
|
||||||
>
|
|
||||||
<PermissionButton permission="bill:delete" size="small" danger icon={<DeleteOutlined />}>
|
|
||||||
删除
|
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</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>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
], [showDetail, updateStatus, handleDelete, handleExportPdf]);
|
], [showDetail, handleDelete, handleCancel, handleExportPdf]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -333,28 +283,14 @@ const BillsPage: React.FC = () => {
|
|||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={(v) => setFilterStatus(v)}
|
onChange={(v) => setFilterStatus(v)}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'draft', label: '草稿' },
|
{ value: 'unpaid', label: '待支付' },
|
||||||
{ value: 'confirmed', label: '已确认' },
|
{ value: 'partially_paid', label: '部分支付' },
|
||||||
{ value: 'paid', label: '已支付' },
|
{ value: 'paid', label: '已支付' },
|
||||||
|
{ value: 'cancelled', label: '已取消' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Select placeholder="费用类型" allowClear style={{ width: 120 }} value={filterExpenseType} onChange={setFilterExpenseType}
|
<Select placeholder="费用类型" allowClear style={{ width: 120 }} value={filterExpenseType} onChange={setFilterExpenseType}
|
||||||
options={[{value:'water',label:'水费'},{value:'electricity',label:'电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} />
|
options={[{value:'water',label:'水费'},{value:'electricity',label:'电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} />
|
||||||
<PermissionButton
|
|
||||||
permission="bill:confirm"
|
|
||||||
onClick={() => batchUpdateStatus('confirmed')}
|
|
||||||
disabled={selectedRows.length === 0}
|
|
||||||
>
|
|
||||||
批量确认
|
|
||||||
</PermissionButton>
|
|
||||||
<PermissionButton
|
|
||||||
permission="bill:confirm"
|
|
||||||
type="primary"
|
|
||||||
onClick={() => batchUpdateStatus('paid')}
|
|
||||||
disabled={selectedRows.length === 0}
|
|
||||||
>
|
|
||||||
批量标记已付
|
|
||||||
</PermissionButton>
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={`确定删除选中的 ${selectedRows.length} 条账单?`}
|
title={`确定删除选中的 ${selectedRows.length} 条账单?`}
|
||||||
onConfirm={batchDelete}
|
onConfirm={batchDelete}
|
||||||
@@ -417,15 +353,17 @@ const BillsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Form form={generateForm} layout="vertical">
|
<Form form={generateForm} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="period"
|
name="billingMonth"
|
||||||
label="账单周期"
|
label="账单月份"
|
||||||
rules={[{ required: true, message: '请选择账单周期' }]}
|
rules={[{ required: true, message: '请选择账单月份' }]}
|
||||||
extra="选择费用对应的时间段,系统将自动计算每个学生的分摊费用"
|
extra="只能选择已结束月份,每个月只能生成一次账单"
|
||||||
>
|
>
|
||||||
<RangePicker
|
<DatePicker
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
placeholder={['开始日期', '结束日期']}
|
picker="month"
|
||||||
format="YYYY-MM-DD"
|
placeholder="选择月份"
|
||||||
|
format="YYYY-MM"
|
||||||
|
disabledDate={(current) => !!current && !current.endOf('month').isBefore(dayjs(), 'day')}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -465,42 +403,11 @@ const BillsPage: React.FC = () => {
|
|||||||
</strong>
|
</strong>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
{Number(detailModal.availableDeposit || 0) > 0 && (
|
<Descriptions bordered size="small" column={3} style={{ marginBottom: 16 }}>
|
||||||
<div
|
<Descriptions.Item label="已扣余额">¥{Number(detailModal.paidAmount || 0).toFixed(2)}</Descriptions.Item>
|
||||||
style={{
|
<Descriptions.Item label="待补缴">¥{Number(detailModal.outstandingAmount || 0).toFixed(2)}</Descriptions.Item>
|
||||||
marginBottom: 16,
|
<Descriptions.Item label="当前钱包余额">¥{Number(detailModal.walletBalance || 0).toFixed(2)}</Descriptions.Item>
|
||||||
padding: 12,
|
</Descriptions>
|
||||||
background: '#f6ffed',
|
|
||||||
border: '1px solid #b7eb8f',
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>
|
|
||||||
押金联动(不影响实际押金状态,仅作收款参考)
|
|
||||||
</div>
|
|
||||||
<Space size={24} wrap>
|
|
||||||
<span>
|
|
||||||
当前可用押金:
|
|
||||||
<strong style={{ color: '#52c41a' }}>
|
|
||||||
¥{Number(detailModal.availableDeposit).toFixed(2)}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
本账单可抵扣:
|
|
||||||
<strong style={{ color: '#fa8c16' }}>
|
|
||||||
-¥{Number(detailModal.depositApplied || 0).toFixed(2)}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
抵扣后实付:
|
|
||||||
<strong style={{ color: '#fa541c', fontSize: 16 }}>
|
|
||||||
¥
|
|
||||||
{Number(detailModal.amountAfterDeposit ?? detailModal.totalAmount).toFixed(2)}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<h4>费用明细</h4>
|
<h4>费用明细</h4>
|
||||||
<Table
|
<Table
|
||||||
scroll={{ x: 700 }}
|
scroll={{ x: 700 }}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface ClassStudent {
|
|||||||
studentName: string;
|
studentName: string;
|
||||||
studentNo: string;
|
studentNo: string;
|
||||||
joinDate: string;
|
joinDate: string;
|
||||||
|
leaveDate: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ interface ClassScheduleItem {
|
|||||||
weekDay: number;
|
weekDay: number;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
attendanceAdvanceMinutes: number;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -305,6 +307,7 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
{ title: '姓名', dataIndex: 'studentName' },
|
{ title: '姓名', dataIndex: 'studentName' },
|
||||||
{ title: '学号', dataIndex: 'studentNo' },
|
{ title: '学号', dataIndex: 'studentNo' },
|
||||||
{ title: '加入日期', dataIndex: 'joinDate' },
|
{ title: '加入日期', dataIndex: 'joinDate' },
|
||||||
|
{ title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -316,11 +319,12 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
render: (_: unknown, r: ClassStudent) => (
|
render: (_: unknown, r: ClassStudent) =>
|
||||||
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
|
r.status === 'active' ? (
|
||||||
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
|
||||||
</Popconfirm>
|
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
||||||
),
|
</Popconfirm>
|
||||||
|
) : null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -350,6 +354,7 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
|
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
|
||||||
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
|
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
|
||||||
{ title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` },
|
{ title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` },
|
||||||
|
{ title: '签到窗口', render: (_: unknown, r: ClassScheduleItem) => `课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课` },
|
||||||
{ title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` },
|
{ title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` },
|
||||||
{ title: '科目', dataIndex: 'subject' },
|
{ title: '科目', dataIndex: 'subject' },
|
||||||
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },
|
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },
|
||||||
@@ -527,7 +532,11 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
columns={studentColumns}
|
columns={studentColumns}
|
||||||
dataSource={students}
|
dataSource={students}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="添加学员"
|
title="添加学员"
|
||||||
@@ -571,7 +580,11 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
columns={teacherColumns}
|
columns={teacherColumns}
|
||||||
dataSource={teachers}
|
dataSource={teachers}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="添加教师"
|
title="添加教师"
|
||||||
@@ -630,7 +643,11 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
columns={scheduleColumns}
|
columns={scheduleColumns}
|
||||||
dataSource={schedules}
|
dataSource={schedules}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -264,7 +264,11 @@ const ClassesPage: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
}}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1100 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Empty,
|
Empty,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
|
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined, StopOutlined, CheckOutlined } from '@ant-design/icons';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { downloadBlob } from '../../utils/download';
|
import { downloadBlob } from '../../utils/download';
|
||||||
@@ -39,6 +39,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
const [editing, setEditing] = useState<any>(null);
|
const [editing, setEditing] = useState<any>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
|
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string | undefined>();
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [unavailableDates, setUnavailableDates] = useState<Set<string>>(new Set());
|
const [unavailableDates, setUnavailableDates] = useState<Set<string>>(new Set());
|
||||||
@@ -48,20 +49,22 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
const selectedClassroomId = Form.useWatch('classroomId', form);
|
const selectedClassroomId = Form.useWatch('classroomId', form);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!searchText) return data;
|
|
||||||
const s = searchText.toLowerCase();
|
|
||||||
return data.filter((r: any) => {
|
return data.filter((r: any) => {
|
||||||
|
if (filterStatus && r.effectiveStatus !== filterStatus) return false;
|
||||||
|
if (!searchText) return true;
|
||||||
|
const s = searchText.toLowerCase();
|
||||||
const matchClassroom = r.classroom?.name?.toLowerCase().includes(s);
|
const matchClassroom = r.classroom?.name?.toLowerCase().includes(s);
|
||||||
const matchOrganization = r.lesseeOrganization?.name?.toLowerCase().includes(s);
|
const matchOrganization = r.lesseeOrganization?.name?.toLowerCase().includes(s);
|
||||||
return matchClassroom || matchOrganization;
|
return matchClassroom || matchOrganization;
|
||||||
});
|
});
|
||||||
}, [data, searchText]);
|
}, [data, searchText, filterStatus]);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
|
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
|
||||||
|
params.includeEnded = true;
|
||||||
const res: any = await api.get('/classroom-rentals', { params });
|
const res: any = await api.get('/classroom-rentals', { params });
|
||||||
setData(res);
|
setData(res);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -215,6 +218,16 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRentalAction = async (id: number, action: 'cancel' | 'end') => {
|
||||||
|
try {
|
||||||
|
await api.put(`/classroom-rentals/${id}/${action}`);
|
||||||
|
message.success(action === 'cancel' ? '租赁已取消' : '租赁已结束');
|
||||||
|
fetchData();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDownloadContract = async (id: number, filename?: string) => {
|
const handleDownloadContract = async (id: number, filename?: string) => {
|
||||||
try {
|
try {
|
||||||
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
|
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
|
||||||
@@ -308,6 +321,19 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
width: 100,
|
width: 100,
|
||||||
render: (v: any) => (v ? `¥${v}` : '-'),
|
render: (v: any) => (v ? `¥${v}` : '-'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'effectiveStatus',
|
||||||
|
width: 90,
|
||||||
|
render: (status: string) => {
|
||||||
|
const config: Record<string, { text: string; color: string }> = {
|
||||||
|
active: { text: '进行中', color: 'green' },
|
||||||
|
ended: { text: '已结束', color: 'default' },
|
||||||
|
cancelled: { text: '已取消', color: 'red' },
|
||||||
|
};
|
||||||
|
return <Tag color={config[status]?.color}>{config[status]?.text || status}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '合同',
|
title: '合同',
|
||||||
width: 120,
|
width: 120,
|
||||||
@@ -364,21 +390,30 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
width: 150,
|
width: 150,
|
||||||
render: (_: any, record: any) => (
|
render: (_: any, record: any) => (
|
||||||
<Space>
|
<Space>
|
||||||
<PermissionButton
|
{record.effectiveStatus === 'active' && (
|
||||||
permission="rental:edit"
|
<>
|
||||||
size="small"
|
<PermissionButton permission="rental:edit" size="small" onClick={() => openEdit(record)}>
|
||||||
onClick={() => openEdit(record)}
|
编辑
|
||||||
>
|
</PermissionButton>
|
||||||
编辑
|
<Popconfirm title="确定取消该租赁?" onConfirm={() => handleRentalAction(record.id, 'cancel')}>
|
||||||
</PermissionButton>
|
<PermissionButton permission="rental:edit" size="small" danger icon={<StopOutlined />}>
|
||||||
<Popconfirm
|
取消
|
||||||
title="确定删除该租赁订单?合同文件将一并删除。"
|
</PermissionButton>
|
||||||
onConfirm={() => handleDelete(record.id)}
|
</Popconfirm>
|
||||||
>
|
{!dayjs(record.startDate).isAfter(dayjs(), 'day') && (
|
||||||
<PermissionButton permission="rental:delete" size="small" danger>
|
<Popconfirm title="确定今天结束该租赁?" onConfirm={() => handleRentalAction(record.id, 'end')}>
|
||||||
删除
|
<PermissionButton permission="rental:edit" size="small" icon={<CheckOutlined />}>
|
||||||
</PermissionButton>
|
结束
|
||||||
</Popconfirm>
|
</PermissionButton>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{record.effectiveStatus !== 'active' && (
|
||||||
|
<Popconfirm title="确定删除该租赁订单?合同文件将一并删除。" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<PermissionButton permission="rental:delete" size="small" danger>删除</PermissionButton>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -415,6 +450,18 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
allowClear
|
allowClear
|
||||||
format="YYYY-MM"
|
format="YYYY-MM"
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="状态"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 110 }}
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={setFilterStatus}
|
||||||
|
options={[
|
||||||
|
{ value: 'active', label: '进行中' },
|
||||||
|
{ value: 'ended', label: '已结束' },
|
||||||
|
{ value: 'cancelled', label: '已取消' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="rental:create"
|
permission="rental:create"
|
||||||
@@ -436,7 +483,12 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1200 }}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -459,7 +511,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
placeholder="选择教室"
|
placeholder="选择教室"
|
||||||
onChange={handleClassroomChange}
|
onChange={handleClassroomChange}
|
||||||
options={classrooms.map((c) => ({
|
options={classrooms.filter((c) => c.status === 'available').map((c) => ({
|
||||||
value: c.id,
|
value: c.id,
|
||||||
label: `${c.building ? c.building + ' · ' : ''}${c.name}(${c.roomType})`,
|
label: `${c.building ? c.building + ' · ' : ''}${c.name}(${c.roomType})`,
|
||||||
}))}
|
}))}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
let result = data;
|
let result = data;
|
||||||
if (searchText) { const s = searchText.toLowerCase(); result = result.filter((d: Record<string, unknown>) => (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || (typeof d.building === 'string' && d.building.toLowerCase().includes(s))); }
|
if (searchText) { const s = searchText.toLowerCase(); result = result.filter((d: Record<string, unknown>) => (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || (typeof d.building === 'string' && d.building.toLowerCase().includes(s))); }
|
||||||
if (filterStatus) result = result.filter((d: Record<string, unknown>) => d.status === filterStatus);
|
if (filterStatus) result = result.filter((d: Record<string, unknown>) => d.effectiveStatus === filterStatus);
|
||||||
return result;
|
return result;
|
||||||
}, [data, searchText, filterStatus]);
|
}, [data, searchText, filterStatus]);
|
||||||
|
|
||||||
@@ -157,11 +157,11 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: '状态', width: 100,
|
title: '状态', width: 100,
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
render: (s: string, record: { currentUsage?: CurrentUsage | null }) => {
|
render: (_s: string, record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null }) => {
|
||||||
const effectiveStatus = record.currentUsage ? 'in_use' : s;
|
const effectiveStatus = record.effectiveStatus || record.status;
|
||||||
return (
|
return (
|
||||||
<Tooltip title={record.currentUsage ? `${record.currentUsage.title} (${record.currentUsage.startTime}-${record.currentUsage.endTime})` : undefined}>
|
<Tooltip title={record.currentUsage ? `${record.currentUsage.title} (${record.currentUsage.startTime}-${record.currentUsage.endTime})` : undefined}>
|
||||||
<Tag color={statusMap[effectiveStatus]?.color}>{statusMap[effectiveStatus]?.text || s}</Tag>
|
<Tag color={statusMap[effectiveStatus]?.color}>{statusMap[effectiveStatus]?.text || effectiveStatus}</Tag>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -228,7 +228,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
if (!e.target.value) setSearchText('');
|
if (!e.target.value) setSearchText('');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus} options={[{value:'available',label:'可用'},{value:'in_use',label:'使用中'},{value:'reserved',label:'已预留'},{value:'maintenance',label:'维护中'}]} />
|
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus} options={[{value:'available',label:'可用'},{value:'in_use',label:'使用中'},{value:'reserved',label:'已预留'},{value:'maintenance',label:'维护中'},{value:'archived',label:'已归档'}]} />
|
||||||
<Button
|
<Button
|
||||||
type={showArchived ? 'primary' : 'default'}
|
type={showArchived ? 'primary' : 'default'}
|
||||||
onClick={() => setShowArchived(!showArchived)}
|
onClick={() => setShowArchived(!showArchived)}
|
||||||
@@ -287,7 +287,12 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? '编辑教室' : '添加教室'}
|
title={editing ? '编辑教室' : '添加教室'}
|
||||||
@@ -323,6 +328,16 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
<Form.Item name="capacity" label="容量">
|
<Form.Item name="capacity" label="容量">
|
||||||
<InputNumber min={1} max={500} style={{ width: '100%' }} />
|
<InputNumber min={1} max={500} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{editing && (
|
||||||
|
<Form.Item name="status" label="基础状态">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'available', label: '可用' },
|
||||||
|
{ value: 'maintenance', label: '维护中' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
<Form.Item name="notes" label="备注">
|
<Form.Item name="notes" label="备注">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildDepositStudentOption, buildDepositStudentOptions } from './deposit-student-option';
|
||||||
|
|
||||||
|
describe('deposit student option', () => {
|
||||||
|
it('uses the student number as the non-sensitive identifier', () => {
|
||||||
|
expect(
|
||||||
|
buildDepositStudentOption({ id: 23, name: '张三', studentNo: 'S2026001' }),
|
||||||
|
).toEqual({
|
||||||
|
value: 23,
|
||||||
|
label: '张三 (S2026001)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the internal id when the student number is missing', () => {
|
||||||
|
expect(buildDepositStudentOption({ id: 23, name: '张三', studentNo: null })).toEqual({
|
||||||
|
value: 23,
|
||||||
|
label: '张三 (#23)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses lookup rows without requiring a status field', () => {
|
||||||
|
expect(buildDepositStudentOptions([{ id: 23, name: '张三', studentNo: 'S2026001' }])).toEqual([
|
||||||
|
{
|
||||||
|
value: 23,
|
||||||
|
label: '张三 (S2026001)',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
apps/admin/src/pages/Deposits/deposit-student-option.ts
Normal file
13
apps/admin/src/pages/Deposits/deposit-student-option.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export interface DepositStudentLookup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
studentNo?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildDepositStudentOption = (student: DepositStudentLookup) => ({
|
||||||
|
value: student.id,
|
||||||
|
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buildDepositStudentOptions = (students: DepositStudentLookup[]) =>
|
||||||
|
students.map(buildDepositStudentOption);
|
||||||
@@ -17,22 +17,14 @@ import {
|
|||||||
import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
|
import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { buildDepositStudentOptions } from './deposit-student-option';
|
||||||
|
|
||||||
const statusMap: Record<string, { text: string; color: string }> = {
|
const statusMap: Record<string, { text: string; color: string }> = {
|
||||||
paid: { text: '已缴', color: 'green' },
|
paid: { text: '有余额', color: 'green' },
|
||||||
refunded: { text: '已全退', color: 'blue' },
|
refunded: { text: '已全退', color: 'blue' },
|
||||||
partial_refund: { text: '部分退还', color: 'orange' },
|
depleted: { text: '已扣完', color: 'red' },
|
||||||
deducted: { text: '已全扣', color: 'red' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const refundStatusMap: Record<string, { text: string; color: string }> = {
|
|
||||||
pending: { text: '历史退款处理中', color: 'orange' },
|
|
||||||
head_teacher_approved: { text: '历史退款处理中', color: 'blue' },
|
|
||||||
finance_approved: { text: '已退款', color: 'green' },
|
|
||||||
refunded: { text: '已退款', color: 'green' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const installmentStatusMap: Record<string, { text: string; color: string }> = {
|
const installmentStatusMap: Record<string, { text: string; color: string }> = {
|
||||||
@@ -40,6 +32,12 @@ const installmentStatusMap: Record<string, { text: string; color: string }> = {
|
|||||||
paid: { text: '已缴', color: 'green' },
|
paid: { text: '已缴', color: 'green' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isFormValidationError = (error: unknown) =>
|
||||||
|
typeof error === 'object'
|
||||||
|
&& error !== null
|
||||||
|
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
|
||||||
|
|
||||||
|
const moneyNumber = (value: unknown) => Number(Number(value || 0).toFixed(2));
|
||||||
|
|
||||||
const DepositsPage: React.FC = () => {
|
const DepositsPage: React.FC = () => {
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
@@ -89,32 +87,28 @@ const DepositsPage: React.FC = () => {
|
|||||||
}, [data, searchText, filterStatus]);
|
}, [data, searchText, filterStatus]);
|
||||||
|
|
||||||
const studentOptions = useMemo(
|
const studentOptions = useMemo(
|
||||||
() =>
|
() => buildDepositStudentOptions(students),
|
||||||
students
|
|
||||||
.filter((s: any) => s.status === 'active')
|
|
||||||
.map((s: any) => ({
|
|
||||||
value: s.id,
|
|
||||||
label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`,
|
|
||||||
})),
|
|
||||||
[students],
|
[students],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const values = await createForm.validateFields();
|
|
||||||
try {
|
try {
|
||||||
|
const values = await createForm.validateFields();
|
||||||
await api.post('/deposits', {
|
await api.post('/deposits', {
|
||||||
studentId: values.studentId,
|
studentId: values.studentId,
|
||||||
amount: values.amount,
|
amount: values.amount,
|
||||||
paidDate: values.paidDate.format('YYYY-MM-DD'),
|
paidDate: values.paidDate.format('YYYY-MM-DD'),
|
||||||
notes: values.notes,
|
notes: values.notes,
|
||||||
});
|
});
|
||||||
message.success('押金记录已创建');
|
message.success('押金金额已增加');
|
||||||
setCreateModal(false);
|
setCreateModal(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '操作失败');
|
if (!isFormValidationError(e)) {
|
||||||
|
message.error(e?.message || '操作失败');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -122,12 +116,10 @@ const DepositsPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleRefund = async () => {
|
const handleRefund = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const values = await refundForm.validateFields();
|
|
||||||
try {
|
try {
|
||||||
|
const values = await refundForm.validateFields();
|
||||||
await api.put(`/deposits/${refundModal.id}/refund`, {
|
await api.put(`/deposits/${refundModal.id}/refund`, {
|
||||||
refundDate: values.refundDate.format('YYYY-MM-DD'),
|
refundDate: values.refundDate.format('YYYY-MM-DD'),
|
||||||
deductionAmount: values.deductionAmount || 0,
|
|
||||||
deductionReason: values.deductionReason,
|
|
||||||
notes: values.notes,
|
notes: values.notes,
|
||||||
});
|
});
|
||||||
message.success('退还操作完成');
|
message.success('退还操作完成');
|
||||||
@@ -135,7 +127,9 @@ const DepositsPage: React.FC = () => {
|
|||||||
refundForm.resetFields();
|
refundForm.resetFields();
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '操作失败');
|
if (!isFormValidationError(e)) {
|
||||||
|
message.error(e?.message || '操作失败');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -143,8 +137,8 @@ const DepositsPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleAddInstallment = async () => {
|
const handleAddInstallment = async () => {
|
||||||
if (installmentModal == null) return;
|
if (installmentModal == null) return;
|
||||||
const values = await installmentForm.validateFields();
|
|
||||||
try {
|
try {
|
||||||
|
const values = await installmentForm.validateFields();
|
||||||
await api.post(`/deposits/${installmentModal}/installments`, {
|
await api.post(`/deposits/${installmentModal}/installments`, {
|
||||||
amount: values.amount,
|
amount: values.amount,
|
||||||
dueDate: values.dueDate.format('YYYY-MM-DD'),
|
dueDate: values.dueDate.format('YYYY-MM-DD'),
|
||||||
@@ -154,7 +148,9 @@ const DepositsPage: React.FC = () => {
|
|||||||
installmentForm.resetFields();
|
installmentForm.resetFields();
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '操作失败');
|
if (!isFormValidationError(e)) {
|
||||||
|
message.error(e?.message || '操作失败');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,30 +179,13 @@ const DepositsPage: React.FC = () => {
|
|||||||
|
|
||||||
const columns = useMemo(() => [
|
const columns = useMemo(() => [
|
||||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||||||
{ title: '押金金额', dataIndex: 'amount', width: 110, render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
{ title: '当前可用押金', dataIndex: 'amount', width: 130, render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||||
{ title: '缴纳日期', dataIndex: 'paidDate', width: 110 },
|
{ title: '最近收取日期', dataIndex: 'paidDate', width: 120 },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '退款状态',
|
|
||||||
dataIndex: 'refundStatus',
|
|
||||||
render: (s: string) =>
|
|
||||||
s ? <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag> : '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '退还金额',
|
|
||||||
dataIndex: 'refundAmount',
|
|
||||||
render: (v: any) => (v != null ? `¥${Number(v).toFixed(2)}` : '-'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '扣除金额',
|
|
||||||
dataIndex: 'deductionAmount',
|
|
||||||
render: (v: any) => (v > 0 ? `¥${Number(v).toFixed(2)}` : '-'),
|
|
||||||
},
|
|
||||||
{ title: '扣除原因', dataIndex: 'deductionReason', width: 120, render: (v: any) => v || '-' },
|
|
||||||
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: any) => v || '-' },
|
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: any) => v || '-' },
|
||||||
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: any) => v || '-' },
|
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: any) => v || '-' },
|
||||||
{
|
{
|
||||||
@@ -223,7 +202,7 @@ const DepositsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
{record.status === 'paid' && !record.refundStatus && (
|
{record.status === 'paid' && (
|
||||||
<>
|
<>
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="deposit:refund"
|
permission="deposit:refund"
|
||||||
@@ -231,7 +210,7 @@ const DepositsPage: React.FC = () => {
|
|||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRefundModal(record);
|
setRefundModal(record);
|
||||||
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 });
|
refundForm.setFieldsValue({ refundDate: dayjs() });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
退还
|
退还
|
||||||
@@ -294,10 +273,9 @@ const DepositsPage: React.FC = () => {
|
|||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={(v) => setFilterStatus(v)}
|
onChange={(v) => setFilterStatus(v)}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'paid', label: '已缴' },
|
{ value: 'paid', label: '有余额' },
|
||||||
{ value: 'refunded', label: '已全退' },
|
{ value: 'refunded', label: '已全退' },
|
||||||
{ value: 'partial_refund', label: '部分退还' },
|
{ value: 'depleted', label: '已扣完' },
|
||||||
{ value: 'deducted', label: '已全扣' },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -320,7 +298,12 @@ const DepositsPage: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1200 }}
|
||||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -346,11 +329,11 @@ const DepositsPage: React.FC = () => {
|
|||||||
options={studentOptions}
|
options={studentOptions}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="amount" label="押金金额(元)" rules={[{ required: true }]}>
|
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
|
||||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="paidDate" label="缴纳日期" rules={[{ required: true }]}>
|
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true }]}>
|
||||||
<DatePicker style={{ width: '100%' }} placeholder="选择缴纳日期" format="YYYY-MM-DD" />
|
<DatePicker style={{ width: '100%' }} placeholder="选择收取日期" format="YYYY-MM-DD" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="notes" label="备注">
|
<Form.Item name="notes" label="备注">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
@@ -369,22 +352,42 @@ const DepositsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Form form={refundForm} layout="vertical">
|
<Form form={refundForm} layout="vertical">
|
||||||
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
||||||
押金金额: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
|
<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>
|
</div>
|
||||||
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
|
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
|
||||||
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
|
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="deductionAmount" label="扣除金额(元)" extra="如无扣除填0">
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
max={Number(refundModal?.amount || 500)}
|
|
||||||
precision={2}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="deductionReason" label="扣除原因">
|
|
||||||
<Input placeholder="如:房间损坏赔偿" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="notes" label="备注">
|
<Form.Item name="notes" label="备注">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -402,22 +405,14 @@ const DepositsPage: React.FC = () => {
|
|||||||
{detailModal && (
|
{detailModal && (
|
||||||
<div>
|
<div>
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
<p><strong>押金金额:</strong> ¥{Number(detailModal.amount).toFixed(2)}</p>
|
<p><strong>当前可用押金:</strong> ¥{Number(detailModal.amount).toFixed(2)}</p>
|
||||||
<p><strong>缴纳日期:</strong> {detailModal.paidDate}</p>
|
<p><strong>最近收取日期:</strong> {detailModal.paidDate}</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>状态:</strong>{' '}
|
<strong>状态:</strong>{' '}
|
||||||
<Tag color={statusMap[detailModal.status]?.color}>
|
<Tag color={statusMap[detailModal.status]?.color}>
|
||||||
{statusMap[detailModal.status]?.text || detailModal.status}
|
{statusMap[detailModal.status]?.text || detailModal.status}
|
||||||
</Tag>
|
</Tag>
|
||||||
</p>
|
</p>
|
||||||
{detailModal.refundStatus && (
|
|
||||||
<p>
|
|
||||||
<strong>退款状态:</strong>{' '}
|
|
||||||
<Tag color={refundStatusMap[detailModal.refundStatus]?.color}>
|
|
||||||
{refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus}
|
|
||||||
</Tag>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{detailModal.notes && <p><strong>备注:</strong> {detailModal.notes}</p>}
|
{detailModal.notes && <p><strong>备注:</strong> {detailModal.notes}</p>}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ import { message } from '../../ui/app-message';
|
|||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
const isFormValidationError = (error: unknown) =>
|
||||||
|
typeof error === 'object'
|
||||||
|
&& error !== null
|
||||||
|
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const ExpensesPage: React.FC = () => {
|
const ExpensesPage: React.FC = () => {
|
||||||
@@ -41,10 +46,12 @@ const ExpensesPage: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [roomModal, setRoomModal] = useState(false);
|
const [roomModal, setRoomModal] = useState(false);
|
||||||
const [personalModal, setPersonalModal] = useState(false);
|
const [personalModal, setPersonalModal] = useState(false);
|
||||||
|
const [utilityModal, setUtilityModal] = useState(false);
|
||||||
const [editingRoom, setEditingRoom] = useState<any>(null);
|
const [editingRoom, setEditingRoom] = useState<any>(null);
|
||||||
const [editingPersonal, setEditingPersonal] = useState<any>(null);
|
const [editingPersonal, setEditingPersonal] = useState<any>(null);
|
||||||
const [roomForm] = Form.useForm();
|
const [roomForm] = Form.useForm();
|
||||||
const [personalForm] = Form.useForm();
|
const [personalForm] = Form.useForm();
|
||||||
|
const [utilityForm] = Form.useForm();
|
||||||
const [roomSearch, setRoomSearch] = useState('');
|
const [roomSearch, setRoomSearch] = useState('');
|
||||||
const [roomTypeFilter, setRoomTypeFilter] = useState<string | undefined>(undefined);
|
const [roomTypeFilter, setRoomTypeFilter] = useState<string | undefined>(undefined);
|
||||||
const [personalSearch, setPersonalSearch] = useState('');
|
const [personalSearch, setPersonalSearch] = useState('');
|
||||||
@@ -157,16 +164,16 @@ const ExpensesPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleRoomExpense = async () => {
|
const handleRoomExpense = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const values = await roomForm.validateFields();
|
|
||||||
const payload = {
|
|
||||||
roomId: values.roomId,
|
|
||||||
expenseType: values.expenseType,
|
|
||||||
amount: values.amount,
|
|
||||||
periodStart: values.period[0].format('YYYY-MM-DD'),
|
|
||||||
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
|
||||||
description: values.description,
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
|
const values = await roomForm.validateFields();
|
||||||
|
const payload = {
|
||||||
|
roomId: values.roomId,
|
||||||
|
expenseType: values.expenseType,
|
||||||
|
amount: values.amount,
|
||||||
|
periodStart: values.period[0].format('YYYY-MM-DD'),
|
||||||
|
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
||||||
|
description: values.description,
|
||||||
|
};
|
||||||
if (editingRoom) {
|
if (editingRoom) {
|
||||||
await api.put(`/expenses/room/${editingRoom.id}`, payload);
|
await api.put(`/expenses/room/${editingRoom.id}`, payload);
|
||||||
message.success('更新成功');
|
message.success('更新成功');
|
||||||
@@ -179,24 +186,47 @@ const ExpensesPage: React.FC = () => {
|
|||||||
roomForm.resetFields();
|
roomForm.resetFields();
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '操作失败');
|
if (!isFormValidationError(e)) {
|
||||||
|
message.error(e?.message || '操作失败');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStudentUtility = async () => {
|
||||||
|
const values = await utilityForm.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const result: any = await api.post('/expenses/student-utility', {
|
||||||
|
studentId: values.studentId,
|
||||||
|
expenseType: values.expenseType,
|
||||||
|
amount: values.amount,
|
||||||
|
periodStart: values.period[0].format('YYYY-MM-DD'),
|
||||||
|
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
||||||
|
description: values.description,
|
||||||
|
});
|
||||||
|
const bill = result.bill;
|
||||||
|
message.success(`账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`);
|
||||||
|
setUtilityModal(false);
|
||||||
|
utilityForm.resetFields();
|
||||||
|
fetchData();
|
||||||
|
} catch (e: any) { message.error(e?.message || '水电费出账失败'); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
const handlePersonalExpense = async () => {
|
const handlePersonalExpense = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const values = await personalForm.validateFields();
|
|
||||||
const payload = {
|
|
||||||
studentId: values.studentId,
|
|
||||||
roomId: values.roomId,
|
|
||||||
expenseType: values.expenseType,
|
|
||||||
amount: values.amount,
|
|
||||||
expenseDate: values.expenseDate.format('YYYY-MM-DD'),
|
|
||||||
description: values.description,
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
|
const values = await personalForm.validateFields();
|
||||||
|
const payload = {
|
||||||
|
studentId: values.studentId,
|
||||||
|
roomId: values.roomId,
|
||||||
|
expenseType: values.expenseType,
|
||||||
|
amount: values.amount,
|
||||||
|
expenseDate: values.expenseDate.format('YYYY-MM-DD'),
|
||||||
|
description: values.description,
|
||||||
|
};
|
||||||
if (editingPersonal) {
|
if (editingPersonal) {
|
||||||
await api.put(`/expenses/personal/${editingPersonal.id}`, payload);
|
await api.put(`/expenses/personal/${editingPersonal.id}`, payload);
|
||||||
message.success('更新成功');
|
message.success('更新成功');
|
||||||
@@ -209,7 +239,9 @@ const ExpensesPage: React.FC = () => {
|
|||||||
personalForm.resetFields();
|
personalForm.resetFields();
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '操作失败');
|
if (!isFormValidationError(e)) {
|
||||||
|
message.error(e?.message || '操作失败');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -443,7 +475,12 @@ const ExpensesPage: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1200 }}
|
||||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedRoomKeys,
|
selectedRowKeys: selectedRoomKeys,
|
||||||
@@ -546,6 +583,13 @@ const ExpensesPage: React.FC = () => {
|
|||||||
批量删除
|
批量删除
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
<PermissionButton
|
||||||
|
permission="expense:create"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => { utilityForm.resetFields(); setUtilityModal(true); }}
|
||||||
|
>
|
||||||
|
添加学生水电费
|
||||||
|
</PermissionButton>
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="expense:create"
|
permission="expense:create"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -566,7 +610,12 @@ const ExpensesPage: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1200 }}
|
||||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedPersonalKeys,
|
selectedRowKeys: selectedPersonalKeys,
|
||||||
@@ -620,6 +669,19 @@ const ExpensesPage: React.FC = () => {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|
||||||
|
<Modal title="添加学生水电费并立即出账" open={utilityModal} onOk={handleStudentUtility} onCancel={() => setUtilityModal(false)} okText="生成账单并扣余额" confirmLoading={saving}>
|
||||||
|
<Form form={utilityForm} layout="vertical">
|
||||||
|
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
|
||||||
|
<Select showSearch optionFilterProp="label" options={students.map((student: any) => ({ value: student.id, label: `${student.name} (${student.studentNo || `#${student.id}`})` }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}><Select options={[{ value: 'water', label: '水费' }, { value: 'electricity', label: '电费' }]} /></Form.Item>
|
||||||
|
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}><RangePicker style={{ width: '100%' }} format="YYYY-MM-DD" /></Form.Item>
|
||||||
|
<Form.Item name="description" label="说明"><Input.TextArea rows={2} maxLength={300} /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editingPersonal ? '编辑个人费用' : '录入个人附加费'}
|
title={editingPersonal ? '编辑个人费用' : '录入个人附加费'}
|
||||||
open={personalModal}
|
open={personalModal}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Card, Form, Input, Button, Space, Spin, Switch, Alert, Descriptions, Tag,
|
Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider,
|
||||||
Tabs, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
|
Drawer, Tree, Select, TreeSelect, Modal, DatePicker,
|
||||||
Row, Col, List,
|
Row, Col, List,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -15,12 +15,15 @@ import api from '../../api';
|
|||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import {
|
||||||
|
buildDingTalkConfigPayload,
|
||||||
|
isAppSecretRequired,
|
||||||
|
type DingTalkConfigFormValues,
|
||||||
|
} from './integration-config-form';
|
||||||
|
|
||||||
interface DingTalkConfig {
|
interface DingTalkConfig {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
appSecret: string;
|
|
||||||
corpId: string;
|
corpId: string;
|
||||||
startEnable: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DingOrgTreeNodeExt {
|
interface DingOrgTreeNodeExt {
|
||||||
@@ -56,7 +59,6 @@ interface ClassItem {
|
|||||||
classType?: string;
|
classType?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
maxStudents?: number;
|
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,9 +96,9 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [config, setConfig] = useState<DingTalkConfig | null>(null);
|
const [config, setConfig] = useState<DingTalkConfig | null>(null);
|
||||||
const [verified, setVerified] = useState<boolean | null>(null);
|
const [verified, setVerified] = useState<boolean | null>(null);
|
||||||
const [form] = Form.useForm<DingTalkConfig>();
|
const [form] = Form.useForm<DingTalkConfigFormValues>();
|
||||||
|
|
||||||
// ── Sync Users Tab ──
|
// ── Manual organization sync ──
|
||||||
const [syncRootDeptId, setSyncRootDeptId] = useState<number | undefined>(undefined);
|
const [syncRootDeptId, setSyncRootDeptId] = useState<number | undefined>(undefined);
|
||||||
const [orgTree, setOrgTree] = useState<DingOrgTreeNodeExt[]>([]);
|
const [orgTree, setOrgTree] = useState<DingOrgTreeNodeExt[]>([]);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
@@ -137,9 +139,10 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
const payload = buildDingTalkConfigPayload(values);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.post('/integration/config', { type: 'DINGTALK', config: values });
|
await api.post('/integration/config', { type: 'DINGTALK', config: payload });
|
||||||
message.success('配置已保存');
|
message.success('配置已保存');
|
||||||
await fetchConfig();
|
await fetchConfig();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -152,11 +155,12 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleTest = async () => {
|
const handleTest = async () => {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
const payload = buildDingTalkConfigPayload(values);
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<{ success: boolean; message: string }>('/integration/config/test', {
|
const res = await api.post<{ success: boolean; message: string }>('/integration/config/test', {
|
||||||
type: 'DINGTALK',
|
type: 'DINGTALK',
|
||||||
config: values,
|
config: payload,
|
||||||
});
|
});
|
||||||
setVerified(res.success);
|
setVerified(res.success);
|
||||||
message.success(res.message);
|
message.success(res.message);
|
||||||
@@ -345,12 +349,8 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncTabItems = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
|
const syncPanel = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
|
||||||
? [
|
? (
|
||||||
{
|
|
||||||
key: 'sync-users',
|
|
||||||
label: '同步用户',
|
|
||||||
children: (
|
|
||||||
<div>
|
<div>
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
@@ -476,9 +476,6 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
<Form.Item name="endDate" label="结束日期">
|
<Form.Item name="endDate" label="结束日期">
|
||||||
<DatePicker style={{ width: '100%' }} />
|
<DatePicker style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="maxStudents" label="最大人数">
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="0=不限制" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="notes" label="备注">
|
<Form.Item name="notes" label="备注">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -519,77 +516,100 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
),
|
)
|
||||||
},
|
: null;
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const tabItems = [
|
|
||||||
{
|
|
||||||
key: 'config',
|
|
||||||
label: '配置',
|
|
||||||
children: (
|
|
||||||
<Spin spinning={loading}>
|
|
||||||
{config && (
|
|
||||||
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
|
||||||
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="启用同步">
|
|
||||||
<Tag color={config.startEnable ? 'green' : 'default'}>
|
|
||||||
{config.startEnable ? '已启用' : '未启用'}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Alert
|
|
||||||
type="info"
|
|
||||||
message="配置钉钉应用凭证后,可使用组织架构同步、考勤导入和排班同步功能。"
|
|
||||||
style={{ marginBottom: 24 }}
|
|
||||||
showIcon
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form form={form} layout="vertical" style={{ maxWidth: 480 }}>
|
|
||||||
<Form.Item name="corpId" label="CorpId(企业ID)" rules={[{ required: true, message: '请输入 CorpId' }]}>
|
|
||||||
<Input placeholder="dingxxxxxxxx" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="agentId" label="AppKey(应用凭证)" rules={[{ required: true, message: '请输入 AppKey' }]}>
|
|
||||||
<Input placeholder="从钉钉开放平台获取" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="appSecret"
|
|
||||||
label="AppSecret(应用密钥)"
|
|
||||||
rules={[{ required: true, message: '请输入 AppSecret' }]}
|
|
||||||
extra="保存后仅返回脱敏信息,重新编辑时需再次输入完整密钥"
|
|
||||||
>
|
|
||||||
<Input.Password placeholder="从钉钉开放平台获取" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="startEnable" label="启用同步" valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Space>
|
|
||||||
<PermissionButton permission="integration:trigger" type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
|
|
||||||
保存配置
|
|
||||||
</PermissionButton>
|
|
||||||
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
|
|
||||||
测试连接
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Form>
|
|
||||||
</Spin>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...syncTabItems,
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="钉钉集成配置" extra={
|
<Card
|
||||||
<Space>
|
title="钉钉集成配置"
|
||||||
{verified === true && <Tag icon={<CheckCircleOutlined />} color="success">已连接</Tag>}
|
extra={
|
||||||
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error">未连接</Tag>}
|
<Space>
|
||||||
</Space>
|
{verified === true && (
|
||||||
}>
|
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||||
<Tabs items={tabItems} />
|
已连接
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{verified === false && (
|
||||||
|
<Tag icon={<CloseCircleOutlined />} color="error">
|
||||||
|
未连接
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{config && (
|
||||||
|
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
||||||
|
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="同步方式">手动触发</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
message="配置钉钉应用凭证后,可在本页手动获取组织架构并导入用户。排班同步仍在排课管理中手动触发。"
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form form={form} layout="vertical" style={{ maxWidth: 520 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="corpId"
|
||||||
|
label="CorpId(企业ID)"
|
||||||
|
rules={[{ required: true, message: '请输入 CorpId' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="dingxxxxxxxx" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="agentId"
|
||||||
|
label="AppKey(应用凭证)"
|
||||||
|
rules={[{ required: true, message: '请输入 AppKey' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="从钉钉开放平台获取" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="appSecret"
|
||||||
|
label="AppSecret(应用密钥)"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: isAppSecretRequired(!!config),
|
||||||
|
message: '首次配置请输入 AppSecret',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
extra={
|
||||||
|
config
|
||||||
|
? '已保存密钥;留空保持原值,输入新值将替换原密钥'
|
||||||
|
: '首次配置需要填写完整 AppSecret'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
placeholder={config ? '留空保持已保存的密钥' : '从钉钉开放平台获取'}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<PermissionButton
|
||||||
|
permission="integration:trigger"
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
loading={saving}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
保存配置
|
||||||
|
</PermissionButton>
|
||||||
|
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
|
||||||
|
测试连接
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{syncPanel && (
|
||||||
|
<>
|
||||||
|
<Divider titlePlacement="start">组织用户导入</Divider>
|
||||||
|
{syncPanel}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildDingTalkConfigPayload,
|
||||||
|
isAppSecretRequired,
|
||||||
|
} from './integration-config-form';
|
||||||
|
|
||||||
|
describe('DingTalk integration config form', () => {
|
||||||
|
it('requires AppSecret only for the first configuration', () => {
|
||||||
|
expect(isAppSecretRequired(false)).toBe(true);
|
||||||
|
expect(isAppSecretRequired(true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a manual-sync config without the retired startEnable flag', () => {
|
||||||
|
expect(
|
||||||
|
buildDingTalkConfigPayload({
|
||||||
|
corpId: 'ding-corp',
|
||||||
|
agentId: 'app-key',
|
||||||
|
appSecret: '',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
corpId: 'ding-corp',
|
||||||
|
agentId: 'app-key',
|
||||||
|
appSecret: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface DingTalkConfigFormValues {
|
||||||
|
agentId: string;
|
||||||
|
appSecret?: string;
|
||||||
|
corpId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isAppSecretRequired = (hasSavedConfig: boolean) => !hasSavedConfig;
|
||||||
|
|
||||||
|
export const buildDingTalkConfigPayload = (values: DingTalkConfigFormValues) => ({
|
||||||
|
corpId: values.corpId.trim(),
|
||||||
|
agentId: values.agentId.trim(),
|
||||||
|
appSecret: values.appSecret?.trim() || undefined,
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { formatNotificationText } from '../../utils/notification-display';
|
||||||
|
|
||||||
const { Sider, Content } = Layout;
|
const { Sider, Content } = Layout;
|
||||||
|
|
||||||
@@ -169,7 +170,7 @@ const NotificationsPage: React.FC = () => {
|
|||||||
strong={!item.isRead}
|
strong={!item.isRead}
|
||||||
style={{ fontSize: 15 }}
|
style={{ fontSize: 15 }}
|
||||||
>
|
>
|
||||||
{item.title}
|
{formatNotificationText(item.title)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{timeAgo(item.createdAt)}
|
{timeAgo(item.createdAt)}
|
||||||
@@ -183,7 +184,7 @@ const NotificationsPage: React.FC = () => {
|
|||||||
ellipsis={{ rows: 1 }}
|
ellipsis={{ rows: 1 }}
|
||||||
style={{ marginBottom: 0 }}
|
style={{ marginBottom: 0 }}
|
||||||
>
|
>
|
||||||
{item.content}
|
{formatNotificationText(item.content)}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { downloadBlob } from '../../utils/download';
|
|||||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { buildTransferPayload } from './occupancy-form';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
@@ -39,7 +40,6 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const [students, setStudents] = useState<any[]>([]);
|
const [students, setStudents] = useState<any[]>([]);
|
||||||
const [rooms, setRooms] = useState<any[]>([]);
|
const [rooms, setRooms] = useState<any[]>([]);
|
||||||
const [organizations, setOrganizations] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [checkInModal, setCheckInModal] = useState(false);
|
const [checkInModal, setCheckInModal] = useState(false);
|
||||||
const [checkOutModal, setCheckOutModal] = useState<any>(null);
|
const [checkOutModal, setCheckOutModal] = useState<any>(null);
|
||||||
@@ -59,18 +59,19 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
const [batchCheckOutForm] = Form.useForm();
|
const [batchCheckOutForm] = Form.useForm();
|
||||||
const [availableBeds, setAvailableBeds] = useState<any[]>([]);
|
const [availableBeds, setAvailableBeds] = useState<any[]>([]);
|
||||||
const [availableLockers, setAvailableLockers] = useState<any[]>([]);
|
const [availableLockers, setAvailableLockers] = useState<any[]>([]);
|
||||||
|
const [transferAvailableBeds, setTransferAvailableBeds] = useState<any[]>([]);
|
||||||
|
const [transferAvailableLockers, setTransferAvailableLockers] = useState<any[]>([]);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [occRes, stuRes, rmRes, tnRes] = (await Promise.allSettled([
|
const [occRes, stuRes, rmRes] = (await Promise.allSettled([
|
||||||
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
|
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
|
||||||
api.get('/students/basic-lookups'),
|
api.get('/students/basic-lookups'),
|
||||||
api.get('/rooms/overview'),
|
api.get('/rooms/overview'),
|
||||||
api.get('/organizations'),
|
|
||||||
])) as PromiseSettledResult<any>[];
|
])) as PromiseSettledResult<any>[];
|
||||||
const labels = ['入住数据', '学生列表', '房间列表', '机构列表'];
|
const labels = ['入住数据', '学生列表', '房间列表'];
|
||||||
[occRes, stuRes, rmRes, tnRes].forEach((res, i) => {
|
[occRes, stuRes, rmRes].forEach((res, i) => {
|
||||||
if (res.status === 'rejected') {
|
if (res.status === 'rejected') {
|
||||||
message.warning(`${labels[i]}加载失败`);
|
message.warning(`${labels[i]}加载失败`);
|
||||||
}
|
}
|
||||||
@@ -78,7 +79,6 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
setData(occRes.status === 'fulfilled' ? occRes.value : []);
|
setData(occRes.status === 'fulfilled' ? occRes.value : []);
|
||||||
setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []);
|
setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []);
|
||||||
setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []);
|
setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []);
|
||||||
setOrganizations(tnRes.status === 'fulfilled' ? tnRes.value : []);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
message.error('数据加载异常');
|
message.error('数据加载异常');
|
||||||
@@ -110,6 +110,30 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTransferRoomChange = async (roomId: number) => {
|
||||||
|
transferForm.setFieldValue('newBedId', undefined);
|
||||||
|
transferForm.setFieldValue('newLockerId', undefined);
|
||||||
|
if (!roomId) {
|
||||||
|
setTransferAvailableBeds([]);
|
||||||
|
setTransferAvailableLockers([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [beds, lockers] = await Promise.all([
|
||||||
|
api.get<any[]>(`/rooms/${roomId}/beds/available`),
|
||||||
|
api.get<any[]>(`/rooms/${roomId}/lockers/available`),
|
||||||
|
]);
|
||||||
|
setTransferAvailableBeds(beds);
|
||||||
|
setTransferAvailableLockers(lockers);
|
||||||
|
if (beds.length === 1) transferForm.setFieldValue('newBedId', beds[0].id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setTransferAvailableBeds([]);
|
||||||
|
setTransferAvailableLockers([]);
|
||||||
|
message.error('目标宿舍床位和柜子加载失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!searchText) return data;
|
if (!searchText) return data;
|
||||||
const keyword = searchText.toLowerCase();
|
const keyword = searchText.toLowerCase();
|
||||||
@@ -130,7 +154,8 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
checkInDate: values.checkInDate.format('YYYY-MM-DD'),
|
checkInDate: values.checkInDate.format('YYYY-MM-DD'),
|
||||||
billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'),
|
billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'),
|
||||||
stayType: values.stayType,
|
stayType: values.stayType,
|
||||||
responsibleOrganizationId: values.responsibleOrganizationId,
|
collectDeposit: values.collectDeposit,
|
||||||
|
depositAmount: values.collectDeposit ? values.depositAmount : undefined,
|
||||||
notes: values.notes,
|
notes: values.notes,
|
||||||
bedId: values.bedId,
|
bedId: values.bedId,
|
||||||
lockerId: values.lockerId || undefined,
|
lockerId: values.lockerId || undefined,
|
||||||
@@ -170,13 +195,10 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
const values = await transferForm.validateFields();
|
const values = await transferForm.validateFields();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.put(`/occupancies/${transferModal.id}/transfer`, {
|
await api.put(
|
||||||
newRoomId: values.newRoomId,
|
`/occupancies/${transferModal.id}/transfer`,
|
||||||
transferDate: values.transferDate.format('YYYY-MM-DD'),
|
buildTransferPayload(values),
|
||||||
oldBillingEndDate: values.oldBillingEndDate?.format('YYYY-MM-DD'),
|
);
|
||||||
newBillingStartDate: values.newBillingStartDate?.format('YYYY-MM-DD'),
|
|
||||||
reason: values.reason,
|
|
||||||
});
|
|
||||||
message.success('换房成功');
|
message.success('换房成功');
|
||||||
setTransferModal(null);
|
setTransferModal(null);
|
||||||
transferForm.resetFields();
|
transferForm.resetFields();
|
||||||
@@ -261,6 +283,9 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
icon={<SwapOutlined />}
|
icon={<SwapOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setTransferAvailableBeds([]);
|
||||||
|
setTransferAvailableLockers([]);
|
||||||
|
transferForm.resetFields();
|
||||||
setTransferModal(record);
|
setTransferModal(record);
|
||||||
transferForm.setFieldsValue({ transferDate: dayjs() });
|
transferForm.setFieldsValue({ transferDate: dayjs() });
|
||||||
}}
|
}}
|
||||||
@@ -303,7 +328,7 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<Alert
|
<Alert
|
||||||
title="一站式导入"
|
title="一站式导入"
|
||||||
description="导入入住名单时会自动创建学生和宿舍,无需单独在「学生管理」或「宿舍管理」中手动添加。后续仅需在此页面处理换房/退宿等日常操作即可。"
|
description="导入入住名单时会优先按手机号关联已有学生,所属机构自动取学生档案;未找到学生或宿舍时会自动创建。后续仅需在此页面处理换房/退宿等日常操作即可。"
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
closable
|
closable
|
||||||
@@ -340,7 +365,7 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
checkInForm.resetFields();
|
checkInForm.resetFields();
|
||||||
checkInForm.setFieldsValue({ checkInDate: dayjs() });
|
checkInForm.setFieldsValue({ checkInDate: dayjs(), collectDeposit: true, depositAmount: 500 });
|
||||||
setCheckInModal(true);
|
setCheckInModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -380,7 +405,7 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip title="导入时自动创建学生、宿舍和入住记录">
|
<Tooltip title="按手机号关联学生,并自动创建缺失的学生、宿舍和入住记录">
|
||||||
<Button type="primary" ghost icon={<UploadOutlined />}>
|
<Button type="primary" ghost icon={<UploadOutlined />}>
|
||||||
导入入住名单
|
导入入住名单
|
||||||
</Button>
|
</Button>
|
||||||
@@ -495,7 +520,12 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
scroll={{ x: 1300 }}
|
scroll={{ x: 1300 }}
|
||||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -566,18 +596,6 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
placeholder="默认为短租"
|
placeholder="默认为短租"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="responsibleOrganizationId" label="负责机构">
|
|
||||||
<Select
|
|
||||||
showSearch
|
|
||||||
allowClear
|
|
||||||
optionFilterProp="label"
|
|
||||||
placeholder="默认取学生所属机构"
|
|
||||||
options={organizations.map((t: { id: number; name: string }) => ({
|
|
||||||
value: t.id,
|
|
||||||
label: t.name,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="bedId"
|
name="bedId"
|
||||||
label="床位"
|
label="床位"
|
||||||
@@ -598,7 +616,10 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
空闲 {availableBeds.length} 张床位
|
空闲 {availableBeds.length} 张床位
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Form.Item name="lockerId" label="柜子(可选)">
|
<Form.Item
|
||||||
|
name="lockerId"
|
||||||
|
label="柜子(可选)"
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="可选分配柜子"
|
placeholder="可选分配柜子"
|
||||||
@@ -609,6 +630,32 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="collectDeposit"
|
||||||
|
label="押金缴纳"
|
||||||
|
valuePropName="checked"
|
||||||
|
extra="开启后,确认入住时同步生成已缴押金记录;已有已缴押金时不会重复创建"
|
||||||
|
>
|
||||||
|
<Switch checkedChildren="已缴" unCheckedChildren="不缴" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, current) => prev.collectDeposit !== current.collectDeposit}>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('collectDeposit') ? (
|
||||||
|
<Form.Item
|
||||||
|
name="depositAmount"
|
||||||
|
label="押金金额"
|
||||||
|
rules={[{ required: true, message: '请输入押金金额' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0.01}
|
||||||
|
precision={2}
|
||||||
|
addonAfter="元"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="notes" label="备注">
|
<Form.Item name="notes" label="备注">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -708,7 +755,12 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
title={`换房 - ${transferModal?.student?.name}`}
|
title={`换房 - ${transferModal?.student?.name}`}
|
||||||
open={!!transferModal}
|
open={!!transferModal}
|
||||||
onOk={handleTransfer}
|
onOk={handleTransfer}
|
||||||
onCancel={() => setTransferModal(null)}
|
onCancel={() => {
|
||||||
|
setTransferModal(null);
|
||||||
|
transferForm.resetFields();
|
||||||
|
setTransferAvailableBeds([]);
|
||||||
|
setTransferAvailableLockers([]);
|
||||||
|
}}
|
||||||
okText="确认换房"
|
okText="确认换房"
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
width={500}
|
width={500}
|
||||||
@@ -719,6 +771,7 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
showSearch
|
showSearch
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
placeholder="选择目标宿舍"
|
placeholder="选择目标宿舍"
|
||||||
|
onChange={handleTransferRoomChange}
|
||||||
options={rooms
|
options={rooms
|
||||||
.filter((r: any) => r.id !== transferModal?.roomId)
|
.filter((r: any) => r.id !== transferModal?.roomId)
|
||||||
.map((r: any) => ({
|
.map((r: any) => ({
|
||||||
@@ -728,6 +781,38 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="newBedId"
|
||||||
|
label="目标床位"
|
||||||
|
rules={[{ required: true, message: '请选择目标床位' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="请先选择目标宿舍"
|
||||||
|
disabled={transferAvailableBeds.length === 0}
|
||||||
|
options={transferAvailableBeds.map((bed) => ({
|
||||||
|
value: bed.id,
|
||||||
|
label: bed.bedNumber,
|
||||||
|
}))}
|
||||||
|
notFoundContent="目标宿舍暂无可用床位"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{transferAvailableBeds.length > 0 && (
|
||||||
|
<div style={{ marginTop: -16, marginBottom: 16, color: '#888', fontSize: 12 }}>
|
||||||
|
空闲 {transferAvailableBeds.length} 张床位
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Form.Item name="newLockerId" label="目标柜子(可选)">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="可选分配目标宿舍柜子"
|
||||||
|
disabled={transferAvailableLockers.length === 0}
|
||||||
|
options={transferAvailableLockers.map((locker) => ({
|
||||||
|
value: locker.id,
|
||||||
|
label: locker.lockerNumber,
|
||||||
|
}))}
|
||||||
|
notFoundContent="目标宿舍暂无可用柜子"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="transferDate" label="换房日期" rules={[{ required: true }]}>
|
<Form.Item name="transferDate" label="换房日期" rules={[{ required: true }]}>
|
||||||
<DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" />
|
<DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { buildTransferPayload } from './occupancy-form';
|
||||||
|
|
||||||
|
describe('occupancy transfer form', () => {
|
||||||
|
it('submits the target room resources with the transfer dates', () => {
|
||||||
|
expect(
|
||||||
|
buildTransferPayload({
|
||||||
|
newRoomId: 5,
|
||||||
|
newBedId: 12,
|
||||||
|
newLockerId: 18,
|
||||||
|
transferDate: dayjs('2026-07-13'),
|
||||||
|
oldBillingEndDate: dayjs('2026-07-13'),
|
||||||
|
newBillingStartDate: dayjs('2026-07-14'),
|
||||||
|
reason: '调整宿舍',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
newRoomId: 5,
|
||||||
|
newBedId: 12,
|
||||||
|
newLockerId: 18,
|
||||||
|
transferDate: '2026-07-13',
|
||||||
|
oldBillingEndDate: '2026-07-13',
|
||||||
|
newBillingStartDate: '2026-07-14',
|
||||||
|
reason: '调整宿舍',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
21
apps/admin/src/pages/Occupancies/occupancy-form.ts
Normal file
21
apps/admin/src/pages/Occupancies/occupancy-form.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
export interface TransferFormValues {
|
||||||
|
newRoomId: number;
|
||||||
|
newBedId: number;
|
||||||
|
newLockerId?: number;
|
||||||
|
transferDate: Dayjs;
|
||||||
|
oldBillingEndDate?: Dayjs;
|
||||||
|
newBillingStartDate?: Dayjs;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildTransferPayload = (values: TransferFormValues) => ({
|
||||||
|
newRoomId: values.newRoomId,
|
||||||
|
newBedId: values.newBedId,
|
||||||
|
newLockerId: values.newLockerId || undefined,
|
||||||
|
transferDate: values.transferDate.format('YYYY-MM-DD'),
|
||||||
|
oldBillingEndDate: values.oldBillingEndDate?.format('YYYY-MM-DD'),
|
||||||
|
newBillingStartDate: values.newBillingStartDate?.format('YYYY-MM-DD'),
|
||||||
|
reason: values.reason,
|
||||||
|
});
|
||||||
@@ -26,13 +26,14 @@ const OperationLogsPage: React.FC = () => {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
const [filterModule, setFilterModule] = useState<string | undefined>();
|
const [filterModule, setFilterModule] = useState<string | undefined>();
|
||||||
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
|
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: any = { page, pageSize: 20 };
|
const params: any = { page, pageSize };
|
||||||
if (filterModule) params.module = filterModule;
|
if (filterModule) params.module = filterModule;
|
||||||
if (dateRange) {
|
if (dateRange) {
|
||||||
params.startDate = dateRange[0];
|
params.startDate = dateRange[0];
|
||||||
@@ -46,7 +47,7 @@ const OperationLogsPage: React.FC = () => {
|
|||||||
message.error(err?.message || '加载失败,请稍后重试');
|
message.error(err?.message || '加载失败,请稍后重试');
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [page, filterModule, dateRange]);
|
}, [page, pageSize, filterModule, dateRange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -159,8 +160,13 @@ const OperationLogsPage: React.FC = () => {
|
|||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
total,
|
total,
|
||||||
pageSize: 20,
|
pageSize,
|
||||||
onChange: setPage,
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage);
|
||||||
|
setPageSize(nextPageSize);
|
||||||
|
},
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -235,7 +235,12 @@ const OrganizationsPage: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无机构" /> }}
|
locale={{ emptyText: <Empty description="暂无机构" /> }}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1100 }}
|
||||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 个机构` }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 个机构`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
|
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
|
||||||
|
|||||||
@@ -156,17 +156,23 @@ const RoomsPage: React.FC = () => {
|
|||||||
if (filterStatus) result = result.filter((r: Record<string, unknown>) => r.status === filterStatus);
|
if (filterStatus) result = result.filter((r: Record<string, unknown>) => r.status === filterStatus);
|
||||||
return result;
|
return result;
|
||||||
}, [data, searchText, filterBuilding, filterStatus]);
|
}, [data, searchText, filterBuilding, filterStatus]);
|
||||||
|
const remainingBedSlots = useMemo(() => {
|
||||||
|
const capacity = Number(drawerRoom?.capacity) || 0;
|
||||||
|
return Math.max(capacity - beds.length, 0);
|
||||||
|
}, [drawerRoom?.capacity, beds.length]);
|
||||||
|
const defaultBatchBedCount = Math.min(4, Math.max(remainingBedSlots, 1));
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
const payload = values;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await api.put(`/rooms/${editing.id}`, values);
|
await api.put(`/rooms/${editing.id}`, payload);
|
||||||
message.success('更新成功');
|
message.success('更新成功');
|
||||||
} else {
|
} else {
|
||||||
await api.post('/rooms', values);
|
await api.post('/rooms', payload);
|
||||||
message.success('创建成功');
|
message.success(`创建成功,已自动生成 ${values.capacity} 张床位`);
|
||||||
}
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
@@ -337,12 +343,6 @@ const RoomsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '性别',
|
|
||||||
dataIndex: 'gender',
|
|
||||||
width: 80,
|
|
||||||
render: (v: any) => (v ? <Tag color={v === '男' ? 'blue' : 'pink'}>{v}</Tag> : '-'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -519,7 +519,12 @@ const RoomsPage: React.FC = () => {
|
|||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1200 }}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 间` }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 间`,
|
||||||
|
}}
|
||||||
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
|
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
@@ -634,24 +639,26 @@ const RoomsPage: React.FC = () => {
|
|||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
disabled={drawerRoom?.status === 'archived'}
|
disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}
|
||||||
onClick={() => { setBedEditing(null); bedForm.resetFields(); setBedModalOpen(true); }}
|
onClick={() => { setBedEditing(null); bedForm.resetFields(); setBedModalOpen(true); }}
|
||||||
>
|
>
|
||||||
添加床位
|
添加床位
|
||||||
</Button>
|
</Button>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="批量生成床位"
|
title={remainingBedSlots > 0 ? '批量生成床位' : '床位已达到额定人数'}
|
||||||
description={
|
description={
|
||||||
<InputNumber min={1} max={20} defaultValue={4} id="batch-bed-count" style={{ width: 80 }} />
|
remainingBedSlots > 0
|
||||||
|
? <InputNumber min={1} max={remainingBedSlots} defaultValue={defaultBatchBedCount} id="batch-bed-count" style={{ width: 80 }} />
|
||||||
|
: '如需增加床位,请先调整宿舍额定人数'
|
||||||
}
|
}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
const input = document.getElementById('batch-bed-count') as HTMLInputElement;
|
const input = document.getElementById('batch-bed-count') as HTMLInputElement;
|
||||||
handleBatchBeds(input ? parseInt(input.value) || 4 : 4);
|
handleBatchBeds(input ? parseInt(input.value) || defaultBatchBedCount : defaultBatchBedCount);
|
||||||
}}
|
}}
|
||||||
okText="生成"
|
okText="生成"
|
||||||
disabled={drawerRoom?.status === 'archived'}
|
disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}
|
||||||
>
|
>
|
||||||
<Button size="small" disabled={drawerRoom?.status === 'archived'}>批量生成</Button>
|
<Button size="small" disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}>批量生成</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
InputNumber,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
TimePicker,
|
TimePicker,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
@@ -53,6 +54,7 @@ interface ClassScheduleItem {
|
|||||||
weekDay: number;
|
weekDay: number;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
attendanceAdvanceMinutes: number;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -351,7 +353,7 @@ const SchedulesPage: React.FC = () => {
|
|||||||
setEditingSchedule(null);
|
setEditingSchedule(null);
|
||||||
setModalMode('create');
|
setModalMode('create');
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({ classroomId, weekDay });
|
form.setFieldsValue({ classroomId, weekDay, attendanceAdvanceMinutes: 30 });
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -947,9 +949,41 @@ const SchedulesPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="notes"
|
||||||
|
label="备注"
|
||||||
|
rules={[{ max: 500, message: '备注不能超过500字' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
showCount
|
||||||
|
placeholder="可填写排课说明、设备需求或临时调整原因"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="attendanceAdvanceMinutes"
|
||||||
|
label="课前签到时间"
|
||||||
|
tooltip="从上课前指定分钟开始,到下课时间结束;期间任意上班或下班打卡都计为出勤"
|
||||||
|
initialValue={30}
|
||||||
|
rules={[{ required: true, message: '请设置课前签到时间' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={1440}
|
||||||
|
step={5}
|
||||||
|
addonAfter="分钟"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="例如 30"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="timeRange"
|
name="timeRange"
|
||||||
label="上课时段"
|
label="上课时段"
|
||||||
|
tooltip="同一教室的前后两节排课必须至少间隔10分钟"
|
||||||
|
extra="系统按10分钟选择时间,并为相邻排课强制预留至少10分钟。"
|
||||||
rules={[{ required: true, message: '请选择时段' }]}
|
rules={[{ required: true, message: '请选择时段' }]}
|
||||||
>
|
>
|
||||||
<TimePicker.RangePicker
|
<TimePicker.RangePicker
|
||||||
@@ -995,6 +1029,7 @@ const SchedulesPage: React.FC = () => {
|
|||||||
weekDay:
|
weekDay:
|
||||||
selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined),
|
selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined),
|
||||||
dateRange: selectedDate ? [selectedDate, selectedDate] : undefined,
|
dateRange: selectedDate ? [selectedDate, selectedDate] : undefined,
|
||||||
|
attendanceAdvanceMinutes: 30,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1041,6 +1076,12 @@ const SchedulesPage: React.FC = () => {
|
|||||||
<strong>时段:</strong>
|
<strong>时段:</strong>
|
||||||
{s.startTime} ~ {s.endTime}
|
{s.startTime} ~ {s.endTime}
|
||||||
</div>
|
</div>
|
||||||
|
{!isMaskedSchedule(s) && (
|
||||||
|
<div>
|
||||||
|
<strong>签到窗口:</strong>
|
||||||
|
课前 {s.attendanceAdvanceMinutes ?? 30} 分钟至下课
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<strong>日期:</strong>
|
<strong>日期:</strong>
|
||||||
{s.startDate} ~ {s.endDate}
|
{s.startDate} ~ {s.endDate}
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ describe('schedule edit form mapping', () => {
|
|||||||
endTime: '18:00',
|
endTime: '18:00',
|
||||||
startDate: '2026-07-01',
|
startDate: '2026-07-01',
|
||||||
endDate: '2026-07-31',
|
endDate: '2026-07-31',
|
||||||
|
notes: '需要投影设备',
|
||||||
|
attendanceAdvanceMinutes: 45,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(values.classroomId).toBe(1);
|
expect(values.classroomId).toBe(1);
|
||||||
expect(values.weekDay).toBe(5);
|
expect(values.weekDay).toBe(5);
|
||||||
|
expect(values.notes).toBe('需要投影设备');
|
||||||
|
expect(values.attendanceAdvanceMinutes).toBe(45);
|
||||||
expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
|
expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
|
||||||
expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
|
expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
|
||||||
'2026-07-01',
|
'2026-07-01',
|
||||||
@@ -36,6 +40,8 @@ describe('schedule edit form mapping', () => {
|
|||||||
teacherId: 4,
|
teacherId: 4,
|
||||||
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
|
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
|
||||||
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
|
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
|
||||||
|
notes: ' 临时调整教室 ',
|
||||||
|
attendanceAdvanceMinutes: 20,
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
classId: 1,
|
classId: 1,
|
||||||
@@ -47,6 +53,26 @@ describe('schedule edit form mapping', () => {
|
|||||||
endTime: '17:20',
|
endTime: '17:20',
|
||||||
startDate: '2026-08-01',
|
startDate: '2026-08-01',
|
||||||
endDate: '2026-08-31',
|
endDate: '2026-08-31',
|
||||||
|
notes: '临时调整教室',
|
||||||
|
attendanceAdvanceMinutes: 20,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('schedule notes normalization', () => {
|
||||||
|
it('omits whitespace-only notes from the payload', () => {
|
||||||
|
expect(
|
||||||
|
buildSchedulePayload({
|
||||||
|
classId: 1,
|
||||||
|
classroomId: 2,
|
||||||
|
weekDay: 6,
|
||||||
|
subject: '作文',
|
||||||
|
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
|
||||||
|
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
|
||||||
|
notes: ' ',
|
||||||
|
attendanceAdvanceMinutes: 30,
|
||||||
|
}).notes,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface ScheduleFormValues {
|
|||||||
weekDay: number;
|
weekDay: number;
|
||||||
subject: string;
|
subject: string;
|
||||||
teacherId?: number;
|
teacherId?: number;
|
||||||
|
notes?: string;
|
||||||
|
attendanceAdvanceMinutes: number;
|
||||||
timeRange: [Dayjs, Dayjs];
|
timeRange: [Dayjs, Dayjs];
|
||||||
dateRange: [Dayjs, Dayjs];
|
dateRange: [Dayjs, Dayjs];
|
||||||
}
|
}
|
||||||
@@ -17,6 +19,8 @@ export interface EditableSchedule {
|
|||||||
weekDay: number;
|
weekDay: number;
|
||||||
subject: string;
|
subject: string;
|
||||||
teacherId: number | null;
|
teacherId: number | null;
|
||||||
|
notes?: string | null;
|
||||||
|
attendanceAdvanceMinutes?: number | null;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
@@ -29,6 +33,8 @@ export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormVa
|
|||||||
weekDay: schedule.weekDay,
|
weekDay: schedule.weekDay,
|
||||||
subject: schedule.subject,
|
subject: schedule.subject,
|
||||||
teacherId: schedule.teacherId ?? undefined,
|
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}`)],
|
timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
|
||||||
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
|
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
|
||||||
});
|
});
|
||||||
@@ -39,6 +45,8 @@ export const buildSchedulePayload = (values: ScheduleFormValues) => ({
|
|||||||
weekDay: values.weekDay,
|
weekDay: values.weekDay,
|
||||||
subject: values.subject,
|
subject: values.subject,
|
||||||
teacherId: values.teacherId,
|
teacherId: values.teacherId,
|
||||||
|
notes: values.notes?.trim() || undefined,
|
||||||
|
attendanceAdvanceMinutes: values.attendanceAdvanceMinutes,
|
||||||
startTime: values.timeRange[0].format('HH:mm'),
|
startTime: values.timeRange[0].format('HH:mm'),
|
||||||
endTime: values.timeRange[1].format('HH:mm'),
|
endTime: values.timeRange[1].format('HH:mm'),
|
||||||
startDate: values.dateRange[0].format('YYYY-MM-DD'),
|
startDate: values.dateRange[0].format('YYYY-MM-DD'),
|
||||||
|
|||||||
@@ -322,7 +322,28 @@ const StudentsPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{ title: '民族', dataIndex: 'ethnicity', width: 90 },
|
{ title: '民族', dataIndex: 'ethnicity', width: 90 },
|
||||||
{ title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
|
{ title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
|
||||||
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 },
|
{
|
||||||
|
title: '紧急联系人电话',
|
||||||
|
dataIndex: 'emergencyPhone',
|
||||||
|
width: 150,
|
||||||
|
render: (v: string, record: any) => {
|
||||||
|
if (!v) return '-';
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
style={{ padding: '8px 4px' }}
|
||||||
|
onClick={() => handleViewSensitive(record.id, '紧急联系人电话', v)}
|
||||||
|
title="点击查看完整号码"
|
||||||
|
>
|
||||||
|
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '所属机构',
|
title: '所属机构',
|
||||||
dataIndex: 'organization',
|
dataIndex: 'organization',
|
||||||
@@ -556,7 +577,12 @@ const StudentsPage: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
scroll={{ x: 1410 }}
|
scroll={{ x: 1410 }}
|
||||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 人` }}
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 人`,
|
||||||
|
}}
|
||||||
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
|
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
|
|||||||
@@ -146,7 +146,12 @@ const TeacherWorkspacePage: React.FC = () => {
|
|||||||
columns={classColumns}
|
columns={classColumns}
|
||||||
dataSource={data.assignedClasses}
|
dataSource={data.assignedClasses}
|
||||||
rowKey="classId"
|
rowKey="classId"
|
||||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 个班级` }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 个班级`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Empty description="暂无分配的班级" />
|
<Empty description="暂无分配的班级" />
|
||||||
@@ -160,7 +165,12 @@ const TeacherWorkspacePage: React.FC = () => {
|
|||||||
columns={scheduleColumns}
|
columns={scheduleColumns}
|
||||||
dataSource={data.todaySchedules}
|
dataSource={data.todaySchedules}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 节` }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 节`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Empty description="今日无排课" />
|
<Empty description="今日无排课" />
|
||||||
@@ -174,7 +184,12 @@ const TeacherWorkspacePage: React.FC = () => {
|
|||||||
columns={studentColumns}
|
columns={studentColumns}
|
||||||
dataSource={data.myStudents}
|
dataSource={data.myStudents}
|
||||||
rowKey="studentId"
|
rowKey="studentId"
|
||||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 人` }}
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 人`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Empty description="暂无学生" />
|
<Empty description="暂无学生" />
|
||||||
|
|||||||
@@ -46,13 +46,14 @@ const ROLE_TYPE_LABELS: Record<string, string> = {
|
|||||||
academic_teacher: '教务老师',
|
academic_teacher: '教务老师',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
const TeachersPage: React.FC = () => {
|
const TeachersPage: React.FC = () => {
|
||||||
const [data, setData] = useState<TeacherRow[]>([]);
|
const [data, setData] = useState<TeacherRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [profileModal, setProfileModal] = useState<TeacherRow | null>(null);
|
const [profileModal, setProfileModal] = useState<TeacherRow | null>(null);
|
||||||
const [form] = Form.useForm<ProfileFormValues>();
|
const [form] = Form.useForm<ProfileFormValues>();
|
||||||
@@ -62,7 +63,7 @@ const TeachersPage: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get<TeacherListResponse>('/rbac/teachers', {
|
const res = await api.get<TeacherListResponse>('/rbac/teachers', {
|
||||||
params: { search: search || undefined, page, pageSize: PAGE_SIZE },
|
params: { search: search || undefined, page, pageSize },
|
||||||
});
|
});
|
||||||
setData(res.list);
|
setData(res.list);
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
@@ -70,7 +71,7 @@ const TeachersPage: React.FC = () => {
|
|||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [page, search]);
|
}, [page, pageSize, search]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -199,9 +200,14 @@ const TeachersPage: React.FC = () => {
|
|||||||
scroll={{ x: 1300 }}
|
scroll={{ x: 1300 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize,
|
||||||
total,
|
total,
|
||||||
onChange: setPage,
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage);
|
||||||
|
setPageSize(nextPageSize);
|
||||||
|
},
|
||||||
showTotal: (t) => `共 ${t} 人`,
|
showTotal: (t) => `共 ${t} 人`,
|
||||||
}}
|
}}
|
||||||
expandable={{
|
expandable={{
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import dayjs from 'dayjs';
|
|||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import {
|
||||||
|
userProfileResponseToFormValues,
|
||||||
|
type UserProfileResponse,
|
||||||
|
} from './user-profile-form';
|
||||||
|
|
||||||
const UsersPage: React.FC = () => {
|
const UsersPage: React.FC = () => {
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
@@ -36,8 +40,8 @@ const UsersPage: React.FC = () => {
|
|||||||
const handleOpenProfile = async (record: any) => {
|
const handleOpenProfile = async (record: any) => {
|
||||||
setProfileUser(record);
|
setProfileUser(record);
|
||||||
try {
|
try {
|
||||||
const res: any = await api.get(`/rbac/users/${record.id}/profile`);
|
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
|
||||||
profileForm.setFieldsValue(res);
|
profileForm.setFieldsValue(userProfileResponseToFormValues(res));
|
||||||
} catch {
|
} catch {
|
||||||
profileForm.setFieldsValue({});
|
profileForm.setFieldsValue({});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { userProfileResponseToFormValues } from './user-profile-form';
|
||||||
|
|
||||||
|
describe('user profile form mapping', () => {
|
||||||
|
it('unwraps the nested profile returned by the user profile endpoint', () => {
|
||||||
|
expect(
|
||||||
|
userProfileResponseToFormValues({
|
||||||
|
id: 9,
|
||||||
|
username: 'teacher01',
|
||||||
|
name: '测试教师',
|
||||||
|
profile: {
|
||||||
|
joinedAt: '2026-07-01',
|
||||||
|
qualifications: '教师资格证',
|
||||||
|
subjects: ['语文', '历史'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
joinedAt: '2026-07-01',
|
||||||
|
qualifications: '教师资格证',
|
||||||
|
subjects: ['语文', '历史'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty form values when the user has no profile', () => {
|
||||||
|
expect(userProfileResponseToFormValues({ profile: null })).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
16
apps/admin/src/pages/Users/user-profile-form.ts
Normal file
16
apps/admin/src/pages/Users/user-profile-form.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface UserProfileFormValues {
|
||||||
|
joinedAt?: string;
|
||||||
|
qualifications?: string;
|
||||||
|
subjects?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfileResponse {
|
||||||
|
id?: number;
|
||||||
|
username?: string;
|
||||||
|
name?: string;
|
||||||
|
profile?: UserProfileFormValues | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userProfileResponseToFormValues = (
|
||||||
|
response: UserProfileResponse,
|
||||||
|
): UserProfileFormValues => response.profile || {};
|
||||||
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;
|
||||||
25
apps/admin/src/utils/notification-display.ts
Normal file
25
apps/admin/src/utils/notification-display.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export const notificationTypeLabels: Record<string, string> = {
|
||||||
|
bill_generated: '账单',
|
||||||
|
bill_paid: '账单',
|
||||||
|
check_in: '入住',
|
||||||
|
check_out: '退宿',
|
||||||
|
deposit_due: '押金',
|
||||||
|
deposit_refunded: '押金',
|
||||||
|
class_change: '班级',
|
||||||
|
schedule_conflict: '排课',
|
||||||
|
announcement: '公告',
|
||||||
|
};
|
||||||
|
|
||||||
|
const teacherRoleLabels: Record<string, string> = {
|
||||||
|
subject_teacher: '任课老师',
|
||||||
|
head_teacher: '班主任',
|
||||||
|
life_teacher: '生活老师',
|
||||||
|
academic_teacher: '学服老师',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatNotificationText(text: string): string {
|
||||||
|
return Object.entries(teacherRoleLabels).reduce(
|
||||||
|
(result, [roleType, label]) => result.replaceAll(roleType, label),
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"author": "",
|
"author": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "SEED_DEV=true nest start --watch -p tsconfig.build.json",
|
"dev": "cross-env SEED_DEV=true nest start --watch -p tsconfig.build.json",
|
||||||
"build": "nest build -p tsconfig.build.json",
|
"build": "nest build -p tsconfig.build.json",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"format": "oxfmt",
|
"format": "oxfmt",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/event-emitter": "^3.1.0",
|
"@nestjs/event-emitter": "^3.1.0",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/mapped-types": "^2.1.1",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.1.19",
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"jest": "^30.0.0",
|
"jest": "^30.0.0",
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import {
|
|||||||
ArchiveAttachment,
|
ArchiveAttachment,
|
||||||
StudentDingMapping,
|
StudentDingMapping,
|
||||||
AiConfig,
|
AiConfig,
|
||||||
|
StudentWallet,
|
||||||
|
WalletTransaction,
|
||||||
} from './entities';
|
} from './entities';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { AuthorizationModule } from './authorization';
|
import { AuthorizationModule } from './authorization';
|
||||||
@@ -71,6 +73,7 @@ import { ExpenseTypesModule } from './expense-types/expense-types.module';
|
|||||||
import { DatabaseMigrationsModule } from './database/database-migrations.module';
|
import { DatabaseMigrationsModule } from './database/database-migrations.module';
|
||||||
import { AgentToolsModule } from './agent-tools';
|
import { AgentToolsModule } from './agent-tools';
|
||||||
import { AiConfigModule } from './ai-config/ai-config.module';
|
import { AiConfigModule } from './ai-config/ai-config.module';
|
||||||
|
import { WalletsModule } from './wallets/wallets.module';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IntegrationConfig,
|
IntegrationConfig,
|
||||||
@@ -135,6 +138,8 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
|||||||
IntegrationConfig,
|
IntegrationConfig,
|
||||||
IntegrationConfigDetail,
|
IntegrationConfigDetail,
|
||||||
AiConfig,
|
AiConfig,
|
||||||
|
StudentWallet,
|
||||||
|
WalletTransaction,
|
||||||
];
|
];
|
||||||
if (dbType === 'mysql') {
|
if (dbType === 'mysql') {
|
||||||
return {
|
return {
|
||||||
@@ -168,6 +173,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
|||||||
DashboardModule,
|
DashboardModule,
|
||||||
OperationLogsModule,
|
OperationLogsModule,
|
||||||
DepositsModule,
|
DepositsModule,
|
||||||
|
WalletsModule,
|
||||||
ClassroomsModule,
|
ClassroomsModule,
|
||||||
AttendanceModule,
|
AttendanceModule,
|
||||||
ClassesModule,
|
ClassesModule,
|
||||||
|
|||||||
31
apps/server/src/archive/archive-report.service.spec.ts
Normal file
31
apps/server/src/archive/archive-report.service.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { ArchiveReportService } from './archive-report.service';
|
||||||
|
|
||||||
|
describe('ArchiveReportService retired profile fields', () => {
|
||||||
|
it('does not render the retired campus field in a student report', async () => {
|
||||||
|
const service = new ArchiveReportService(
|
||||||
|
{ findOne: jest.fn().mockResolvedValue({ campusLocation: '旧校区', grade: '高三' }) } as never,
|
||||||
|
{ find: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ find: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ find: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findOne: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ find: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
name: '测试学生',
|
||||||
|
gender: '男',
|
||||||
|
phone: '',
|
||||||
|
ethnicity: '',
|
||||||
|
emergencyContact: '',
|
||||||
|
emergencyPhone: '',
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = await service.generateReportHtml(1);
|
||||||
|
|
||||||
|
expect(html).not.toContain('旧校区');
|
||||||
|
expect(html).not.toContain('<span>校区</span>');
|
||||||
|
expect(html).toContain('高三');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -308,7 +308,6 @@ ${this.buildLearningAndResult(learnings, result, now)}
|
|||||||
<div class="summary-row"><span>民族</span><span>${this.esc(student.ethnicity || '-')}</span></div>
|
<div class="summary-row"><span>民族</span><span>${this.esc(student.ethnicity || '-')}</span></div>
|
||||||
<div class="summary-row"><span>紧急联系人</span><span>${this.esc(student.emergencyContact || '-')}</span></div>
|
<div class="summary-row"><span>紧急联系人</span><span>${this.esc(student.emergencyContact || '-')}</span></div>
|
||||||
<div class="summary-row"><span>紧急电话</span><span>${this.esc(student.emergencyPhone || '-')}</span></div>
|
<div class="summary-row"><span>紧急电话</span><span>${this.esc(student.emergencyPhone || '-')}</span></div>
|
||||||
<div class="summary-row"><span>校区</span><span>${this.esc(profile?.campusLocation || '-')}</span></div>
|
|
||||||
<div class="summary-row"><span>年级</span><span>${this.esc(profile?.grade || '-')}</span></div>
|
<div class="summary-row"><span>年级</span><span>${this.esc(profile?.grade || '-')}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
Res,
|
Res,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import type { Request as ExpressRequest, Response } from 'express';
|
import type { Request as ExpressRequest, Response } from 'express';
|
||||||
@@ -20,8 +21,11 @@ import { ArchiveService } from './archive.service';
|
|||||||
import {
|
import {
|
||||||
UpsertProfileDto,
|
UpsertProfileDto,
|
||||||
CreateEnrollmentDto,
|
CreateEnrollmentDto,
|
||||||
|
UpdateEnrollmentDto,
|
||||||
CreateExamScoreDto,
|
CreateExamScoreDto,
|
||||||
|
UpdateExamScoreDto,
|
||||||
CreateLearningRecordDto,
|
CreateLearningRecordDto,
|
||||||
|
UpdateLearningRecordDto,
|
||||||
UpsertResultDto,
|
UpsertResultDto,
|
||||||
} from './dto/archive.dto';
|
} from './dto/archive.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
@@ -44,15 +48,15 @@ export class ArchiveController {
|
|||||||
|
|
||||||
@Get(':studentId')
|
@Get(':studentId')
|
||||||
@RequirePermission('student:view')
|
@RequirePermission('student:view')
|
||||||
async getProfile(@Param('studentId') studentId: string, @Request() req: AuthenticatedRequest) {
|
async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.getProfile(+studentId);
|
const result = await this.archiveService.getProfile(studentId);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '查看档案',
|
action: '查看档案',
|
||||||
targetId: +studentId,
|
targetId: studentId,
|
||||||
targetType: 'archive',
|
targetType: 'archive',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -63,18 +67,18 @@ export class ArchiveController {
|
|||||||
@Put(':studentId/profile')
|
@Put(':studentId/profile')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async upsertProfile(
|
async upsertProfile(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Body() dto: UpsertProfileDto,
|
@Body() dto: UpsertProfileDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.upsertProfile(+studentId, dto);
|
const result = await this.archiveService.upsertProfile(studentId, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '更新档案信息',
|
action: '更新档案信息',
|
||||||
targetId: +studentId,
|
targetId: studentId,
|
||||||
targetType: 'student_profile',
|
targetType: 'student_profile',
|
||||||
detail: JSON.stringify(dto),
|
detail: JSON.stringify(dto),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -86,12 +90,12 @@ export class ArchiveController {
|
|||||||
@Post(':studentId/enrollments')
|
@Post(':studentId/enrollments')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async addEnrollment(
|
async addEnrollment(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Body() dto: CreateEnrollmentDto,
|
@Body() dto: CreateEnrollmentDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.addEnrollment(+studentId, dto);
|
const result = await this.archiveService.addEnrollment(studentId, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
@@ -109,18 +113,18 @@ export class ArchiveController {
|
|||||||
@Put('enrollments/:id')
|
@Put('enrollments/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async updateEnrollment(
|
async updateEnrollment(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: Partial<CreateEnrollmentDto>,
|
@Body() dto: UpdateEnrollmentDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.updateEnrollment(+id, dto);
|
const result = await this.archiveService.updateEnrollment(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '编辑报名记录',
|
action: '编辑报名记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'student_enrollment',
|
targetType: 'student_enrollment',
|
||||||
detail: JSON.stringify(dto),
|
detail: JSON.stringify(dto),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -131,15 +135,15 @@ export class ArchiveController {
|
|||||||
|
|
||||||
@Delete('enrollments/:id')
|
@Delete('enrollments/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async deleteEnrollment(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.deleteEnrollment(+id);
|
const result = await this.archiveService.deleteEnrollment(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '删除报名记录',
|
action: '删除报名记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'student_enrollment',
|
targetType: 'student_enrollment',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -150,12 +154,12 @@ export class ArchiveController {
|
|||||||
@Post(':studentId/exam-scores')
|
@Post(':studentId/exam-scores')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async addExamScore(
|
async addExamScore(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Body() dto: CreateExamScoreDto,
|
@Body() dto: CreateExamScoreDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.addExamScore(+studentId, dto);
|
const result = await this.archiveService.addExamScore(studentId, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
@@ -173,18 +177,18 @@ export class ArchiveController {
|
|||||||
@Put('exam-scores/:id')
|
@Put('exam-scores/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async updateExamScore(
|
async updateExamScore(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: Partial<CreateExamScoreDto>,
|
@Body() dto: UpdateExamScoreDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.updateExamScore(+id, dto);
|
const result = await this.archiveService.updateExamScore(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '编辑考试成绩',
|
action: '编辑考试成绩',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'exam_score',
|
targetType: 'exam_score',
|
||||||
detail: JSON.stringify(dto),
|
detail: JSON.stringify(dto),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -195,15 +199,15 @@ export class ArchiveController {
|
|||||||
|
|
||||||
@Delete('exam-scores/:id')
|
@Delete('exam-scores/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async deleteExamScore(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.deleteExamScore(+id);
|
const result = await this.archiveService.deleteExamScore(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '删除考试成绩',
|
action: '删除考试成绩',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'exam_score',
|
targetType: 'exam_score',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -214,12 +218,12 @@ export class ArchiveController {
|
|||||||
@Post(':studentId/learning-records')
|
@Post(':studentId/learning-records')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async addLearningRecord(
|
async addLearningRecord(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Body() dto: CreateLearningRecordDto,
|
@Body() dto: CreateLearningRecordDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.addLearningRecord(+studentId, dto);
|
const result = await this.archiveService.addLearningRecord(studentId, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
@@ -237,18 +241,18 @@ export class ArchiveController {
|
|||||||
@Put('learning-records/:id')
|
@Put('learning-records/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async updateLearningRecord(
|
async updateLearningRecord(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: Partial<CreateLearningRecordDto>,
|
@Body() dto: UpdateLearningRecordDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.updateLearningRecord(+id, dto);
|
const result = await this.archiveService.updateLearningRecord(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '编辑学习记录',
|
action: '编辑学习记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'learning_record',
|
targetType: 'learning_record',
|
||||||
detail: JSON.stringify(dto),
|
detail: JSON.stringify(dto),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -259,15 +263,15 @@ export class ArchiveController {
|
|||||||
|
|
||||||
@Delete('learning-records/:id')
|
@Delete('learning-records/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async deleteLearningRecord(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.deleteLearningRecord(+id);
|
const result = await this.archiveService.deleteLearningRecord(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '删除学习记录',
|
action: '删除学习记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'learning_record',
|
targetType: 'learning_record',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -278,18 +282,18 @@ export class ArchiveController {
|
|||||||
@Put(':studentId/result')
|
@Put(':studentId/result')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async upsertResult(
|
async upsertResult(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Body() dto: UpsertResultDto,
|
@Body() dto: UpsertResultDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.upsertResult(+studentId, dto);
|
const result = await this.archiveService.upsertResult(studentId, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '更新录取结果',
|
action: '更新录取结果',
|
||||||
targetId: +studentId,
|
targetId: studentId,
|
||||||
targetType: 'result_archive',
|
targetType: 'result_archive',
|
||||||
detail: JSON.stringify(dto),
|
detail: JSON.stringify(dto),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -302,13 +306,13 @@ export class ArchiveController {
|
|||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
async uploadAttachment(
|
async uploadAttachment(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@Body('category') category: string,
|
@Body('category') category: string,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.addAttachment(+studentId, file, category || 'other');
|
const result = await this.archiveService.addAttachment(studentId, file, category || 'other');
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
@@ -326,31 +330,31 @@ export class ArchiveController {
|
|||||||
@Get(':studentId/attachments/:id')
|
@Get(':studentId/attachments/:id')
|
||||||
@RequirePermission('student:view')
|
@RequirePermission('student:view')
|
||||||
async downloadAttachment(
|
async downloadAttachment(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(+studentId, +id);
|
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(
|
||||||
res.setHeader('Content-Type', mimeType);
|
studentId,
|
||||||
res.setHeader(
|
id,
|
||||||
'Content-Disposition',
|
|
||||||
`inline; filename="${encodeURIComponent(fileName)}"`,
|
|
||||||
);
|
);
|
||||||
|
res.setHeader('Content-Type', mimeType);
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`);
|
||||||
const stream = fs.createReadStream(fullPath);
|
const stream = fs.createReadStream(fullPath);
|
||||||
stream.pipe(res);
|
stream.pipe(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('attachments/:id')
|
@Delete('attachments/:id')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async deleteAttachment(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.archiveService.deleteAttachment(+id);
|
const result = await this.archiveService.deleteAttachment(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生档案',
|
module: '学生档案',
|
||||||
action: '删除附件',
|
action: '删除附件',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'archive_attachment',
|
targetType: 'archive_attachment',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -358,11 +362,10 @@ export class ArchiveController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Get(':studentId/report-html')
|
@Get(':studentId/report-html')
|
||||||
@RequirePermission('student:view')
|
@RequirePermission('student:view')
|
||||||
async generateReportHtml(
|
async generateReportHtml(
|
||||||
@Param('studentId') studentId: string,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
@@ -371,12 +374,12 @@ export class ArchiveController {
|
|||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: 'archive',
|
module: 'archive',
|
||||||
action: 'generate_report_html',
|
action: 'generate_report_html',
|
||||||
targetId: +studentId,
|
targetId: studentId,
|
||||||
targetType: 'student',
|
targetType: 'student',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
});
|
});
|
||||||
const html = await this.reportService.generateReportHtml(+studentId);
|
const html = await this.reportService.generateReportHtml(studentId);
|
||||||
return { html };
|
return { html };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
apps/server/src/archive/archive.service.spec.ts
Normal file
39
apps/server/src/archive/archive.service.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { ArchiveService } from './archive.service';
|
||||||
|
|
||||||
|
describe('ArchiveService.getProfile', () => {
|
||||||
|
it('returns the admission archive under the public result field', async () => {
|
||||||
|
const student = { id: 7, name: '测试学生' };
|
||||||
|
const result = {
|
||||||
|
id: 3,
|
||||||
|
studentId: 7,
|
||||||
|
cultureFinalScore: 450,
|
||||||
|
admittedCollege: '测试学院',
|
||||||
|
};
|
||||||
|
|
||||||
|
const studentRepo = { findOne: jest.fn().mockResolvedValue(student) };
|
||||||
|
const profileRepo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||||
|
const enrollmentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
const examScoreRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
const learningRecordRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
const resultRepo = { findOne: jest.fn().mockResolvedValue(result) };
|
||||||
|
const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
const attendanceRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
|
||||||
|
const service = new ArchiveService(
|
||||||
|
studentRepo as never,
|
||||||
|
profileRepo as never,
|
||||||
|
enrollmentRepo as never,
|
||||||
|
examScoreRepo as never,
|
||||||
|
learningRecordRepo as never,
|
||||||
|
resultRepo as never,
|
||||||
|
attachmentRepo as never,
|
||||||
|
attendanceRepo as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await service.getProfile(7);
|
||||||
|
|
||||||
|
expect(response).toMatchObject({ student, result, attendances: [] });
|
||||||
|
expect(response).not.toHaveProperty('resultArchive');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,11 +12,15 @@ import { ExamScore } from '../entities/exam-score.entity';
|
|||||||
import { LearningRecord } from '../entities/learning-record.entity';
|
import { LearningRecord } from '../entities/learning-record.entity';
|
||||||
import { ResultArchive } from '../entities/result-archive.entity';
|
import { ResultArchive } from '../entities/result-archive.entity';
|
||||||
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
|
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
|
||||||
|
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||||
import {
|
import {
|
||||||
UpsertProfileDto,
|
UpsertProfileDto,
|
||||||
CreateEnrollmentDto,
|
CreateEnrollmentDto,
|
||||||
|
UpdateEnrollmentDto,
|
||||||
CreateExamScoreDto,
|
CreateExamScoreDto,
|
||||||
|
UpdateExamScoreDto,
|
||||||
CreateLearningRecordDto,
|
CreateLearningRecordDto,
|
||||||
|
UpdateLearningRecordDto,
|
||||||
UpsertResultDto,
|
UpsertResultDto,
|
||||||
} from './dto/archive.dto';
|
} from './dto/archive.dto';
|
||||||
|
|
||||||
@@ -30,6 +34,7 @@ export class ArchiveService {
|
|||||||
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
|
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
|
||||||
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
|
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
|
||||||
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
|
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
|
||||||
|
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||||||
private readonly notificationsService: NotificationsService,
|
private readonly notificationsService: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -44,7 +49,9 @@ export class ArchiveService {
|
|||||||
? path.resolve(process.cwd(), normalizedPath)
|
? path.resolve(process.cwd(), normalizedPath)
|
||||||
: path.resolve(this.uploadDir, normalizedPath);
|
: path.resolve(this.uploadDir, normalizedPath);
|
||||||
const allowedRoots = [this.uploadDir, path.resolve(process.cwd(), 'uploads', 'archive')];
|
const allowedRoots = [this.uploadDir, path.resolve(process.cwd(), 'uploads', 'archive')];
|
||||||
if (!allowedRoots.some((root) => fullPath === root || fullPath.startsWith(`${root}${path.sep}`))) {
|
if (
|
||||||
|
!allowedRoots.some((root) => fullPath === root || fullPath.startsWith(`${root}${path.sep}`))
|
||||||
|
) {
|
||||||
throw new BadRequestException('路径非法');
|
throw new BadRequestException('路径非法');
|
||||||
}
|
}
|
||||||
return fullPath;
|
return fullPath;
|
||||||
@@ -54,21 +61,20 @@ export class ArchiveService {
|
|||||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||||
if (!student) throw new NotFoundException('学生不存在');
|
if (!student) throw new NotFoundException('学生不存在');
|
||||||
|
|
||||||
const [
|
const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments, attendances] =
|
||||||
profileRaw,
|
await Promise.all([
|
||||||
enrollments,
|
this.profileRepo.findOne({ where: { studentId } }),
|
||||||
examScores,
|
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||||
learningRecords,
|
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
|
||||||
resultArchive,
|
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||||
attachments,
|
this.resultRepo.findOne({ where: { studentId } }),
|
||||||
] = await Promise.all([
|
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||||
this.profileRepo.findOne({ where: { studentId } }),
|
this.attendanceRepo.find({
|
||||||
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
where: { studentId },
|
||||||
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
|
relations: ['schedule', 'class'],
|
||||||
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
order: { attendanceDate: 'DESC', punchTime: 'DESC' },
|
||||||
this.resultRepo.findOne({ where: { studentId } }),
|
}),
|
||||||
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
]);
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
student,
|
student,
|
||||||
@@ -76,8 +82,9 @@ export class ArchiveService {
|
|||||||
enrollments,
|
enrollments,
|
||||||
examScores,
|
examScores,
|
||||||
learningRecords,
|
learningRecords,
|
||||||
resultArchive,
|
result: resultArchive,
|
||||||
attachments,
|
attachments,
|
||||||
|
attendances,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +109,7 @@ export class ArchiveService {
|
|||||||
return this.enrollmentRepo.save(entity);
|
return this.enrollmentRepo.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateEnrollment(id: number, dto: Partial<CreateEnrollmentDto>) {
|
async updateEnrollment(id: number, dto: UpdateEnrollmentDto) {
|
||||||
const entity = await this.enrollmentRepo.findOne({ where: { id } });
|
const entity = await this.enrollmentRepo.findOne({ where: { id } });
|
||||||
if (!entity) throw new NotFoundException('报名记录不存在');
|
if (!entity) throw new NotFoundException('报名记录不存在');
|
||||||
Object.assign(entity, dto);
|
Object.assign(entity, dto);
|
||||||
@@ -124,7 +131,7 @@ export class ArchiveService {
|
|||||||
return this.examScoreRepo.save(entity);
|
return this.examScoreRepo.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateExamScore(id: number, dto: Partial<CreateExamScoreDto>) {
|
async updateExamScore(id: number, dto: UpdateExamScoreDto) {
|
||||||
const entity = await this.examScoreRepo.findOne({ where: { id } });
|
const entity = await this.examScoreRepo.findOne({ where: { id } });
|
||||||
if (!entity) throw new NotFoundException('考试成绩不存在');
|
if (!entity) throw new NotFoundException('考试成绩不存在');
|
||||||
Object.assign(entity, dto);
|
Object.assign(entity, dto);
|
||||||
@@ -146,7 +153,7 @@ export class ArchiveService {
|
|||||||
return this.learningRecordRepo.save(entity);
|
return this.learningRecordRepo.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLearningRecord(id: number, dto: Partial<CreateLearningRecordDto>) {
|
async updateLearningRecord(id: number, dto: UpdateLearningRecordDto) {
|
||||||
const entity = await this.learningRecordRepo.findOne({ where: { id } });
|
const entity = await this.learningRecordRepo.findOne({ where: { id } });
|
||||||
if (!entity) throw new NotFoundException('学习记录不存在');
|
if (!entity) throw new NotFoundException('学习记录不存在');
|
||||||
Object.assign(entity, dto);
|
Object.assign(entity, dto);
|
||||||
@@ -225,4 +232,3 @@ export class ArchiveService {
|
|||||||
return { message: '已删除' };
|
return { message: '已删除' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
57
apps/server/src/archive/dto/archive.dto.spec.ts
Normal file
57
apps/server/src/archive/dto/archive.dto.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
import {
|
||||||
|
UpdateEnrollmentDto,
|
||||||
|
UpdateExamScoreDto,
|
||||||
|
UpdateLearningRecordDto,
|
||||||
|
UpsertProfileDto,
|
||||||
|
} from './archive.dto';
|
||||||
|
|
||||||
|
const pipe = new ValidationPipe({ transform: true, whitelist: true });
|
||||||
|
|
||||||
|
async function transform<T extends object>(metatype: new () => T, value: unknown) {
|
||||||
|
return pipe.transform(value, { type: 'body', metatype });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UpsertProfileDto retired fields', () => {
|
||||||
|
it('removes the retired campusLocation field under whitelist validation', async () => {
|
||||||
|
const dto = plainToInstance(UpsertProfileDto, {
|
||||||
|
grade: '高三',
|
||||||
|
campusLocation: '旧校区',
|
||||||
|
});
|
||||||
|
|
||||||
|
await validate(dto, { whitelist: true });
|
||||||
|
|
||||||
|
expect(dto).toMatchObject({ grade: '高三' });
|
||||||
|
expect(dto).not.toHaveProperty('campusLocation');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('archive update DTOs', () => {
|
||||||
|
it('allows partial enrollment updates and strips unknown fields', async () => {
|
||||||
|
await expect(
|
||||||
|
transform(UpdateEnrollmentDto, { className: '新班级', ignored: 'value' }),
|
||||||
|
).resolves.toEqual(expect.objectContaining({ className: '新班级' }));
|
||||||
|
const result = await transform(UpdateEnrollmentDto, {
|
||||||
|
className: '新班级',
|
||||||
|
ignored: 'value',
|
||||||
|
});
|
||||||
|
expect(result).not.toHaveProperty('ignored');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains create DTO validation rules for exam scores', async () => {
|
||||||
|
await expect(transform(UpdateExamScoreDto, { score: '90' })).rejects.toThrow();
|
||||||
|
await expect(transform(UpdateExamScoreDto, { score: 90 })).resolves.toMatchObject({
|
||||||
|
score: 90,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains create DTO date validation for learning records', async () => {
|
||||||
|
await expect(
|
||||||
|
transform(UpdateLearningRecordDto, { recordDate: 'not-a-date' }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(transform(UpdateLearningRecordDto, {})).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator';
|
import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator';
|
||||||
|
|
||||||
export class UpsertProfileDto {
|
export class UpsertProfileDto {
|
||||||
@@ -5,7 +6,6 @@ export class UpsertProfileDto {
|
|||||||
@IsOptional() @IsString() targetMajor?: string;
|
@IsOptional() @IsString() targetMajor?: string;
|
||||||
@IsOptional() @IsString() subjectDirection?: string;
|
@IsOptional() @IsString() subjectDirection?: string;
|
||||||
@IsOptional() @IsString() grade?: string;
|
@IsOptional() @IsString() grade?: string;
|
||||||
@IsOptional() @IsString() campusLocation?: string;
|
|
||||||
@IsOptional() @IsDateString() profileDate?: string;
|
@IsOptional() @IsDateString() profileDate?: string;
|
||||||
@IsOptional() @IsString() notes?: string;
|
@IsOptional() @IsString() notes?: string;
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,8 @@ export class CreateEnrollmentDto {
|
|||||||
@IsOptional() @IsString() status?: string;
|
@IsOptional() @IsString() status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {}
|
||||||
|
|
||||||
export class CreateExamScoreDto {
|
export class CreateExamScoreDto {
|
||||||
@IsString() examType: string;
|
@IsString() examType: string;
|
||||||
@IsOptional() @IsString() examName?: string;
|
@IsOptional() @IsString() examName?: string;
|
||||||
@@ -32,6 +34,8 @@ export class CreateExamScoreDto {
|
|||||||
@IsOptional() @IsNumber() enrollmentId?: number;
|
@IsOptional() @IsNumber() enrollmentId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateExamScoreDto extends PartialType(CreateExamScoreDto) {}
|
||||||
|
|
||||||
export class CreateLearningRecordDto {
|
export class CreateLearningRecordDto {
|
||||||
@IsDateString() recordDate: string;
|
@IsDateString() recordDate: string;
|
||||||
@IsString() recordType: string;
|
@IsString() recordType: string;
|
||||||
@@ -40,6 +44,8 @@ export class CreateLearningRecordDto {
|
|||||||
@IsOptional() @IsString() nextStep?: string;
|
@IsOptional() @IsString() nextStep?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateLearningRecordDto extends PartialType(CreateLearningRecordDto) {}
|
||||||
|
|
||||||
export class UpsertResultDto {
|
export class UpsertResultDto {
|
||||||
@IsOptional() @IsNumber() cultureFinalScore?: number;
|
@IsOptional() @IsNumber() cultureFinalScore?: number;
|
||||||
@IsOptional() @IsNumber() professionalFinalScore?: number;
|
@IsOptional() @IsNumber() professionalFinalScore?: number;
|
||||||
|
|||||||
@@ -93,6 +93,31 @@ describe('AttendanceImportService', () => {
|
|||||||
expect(entity.userName).toBe('张三');
|
expect(entity.userName).toBe('张三');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores DingTalk punch source and attendance machine metadata', async () => {
|
||||||
|
const entity = await (service as any).mapToEntity({
|
||||||
|
userId: 'ding-1',
|
||||||
|
userName: '张三',
|
||||||
|
workDate: '2026-07-01',
|
||||||
|
timeResult: 'Normal',
|
||||||
|
locationResult: '',
|
||||||
|
planCheckTime: '',
|
||||||
|
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||||
|
checkId: 'check-1',
|
||||||
|
checkType: 'OnDuty',
|
||||||
|
sourceType: 'ATM',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entity).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
|
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
|
||||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -138,9 +163,19 @@ describe('AttendanceImportService', () => {
|
|||||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||||
checkId: 'check-1',
|
checkId: 'check-1',
|
||||||
checkType: 'OnDuty',
|
checkType: 'OnDuty',
|
||||||
|
sourceType: 'ATM',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
dingRawRepo.find.mockResolvedValue([{ dingId: 'check-1' }]);
|
dingRawRepo.find.mockResolvedValue([{
|
||||||
|
dingId: 'check-1',
|
||||||
|
punchSource: null,
|
||||||
|
punchDeviceName: null,
|
||||||
|
punchDeviceId: null,
|
||||||
|
rawData: '',
|
||||||
|
}]);
|
||||||
|
dingRawRepo.save.mockImplementation(async (entities) => entities);
|
||||||
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
|
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
|
||||||
|
|
||||||
const result = await service.importFromDingTalk({
|
const result = await service.importFromDingTalk({
|
||||||
@@ -150,10 +185,57 @@ describe('AttendanceImportService', () => {
|
|||||||
autoMatch: true,
|
autoMatch: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(dingRawRepo.save).toHaveBeenCalledWith(
|
||||||
|
[expect.objectContaining({
|
||||||
|
dingId: 'check-1',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
})],
|
||||||
|
{ chunk: 50 },
|
||||||
|
);
|
||||||
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
|
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
|
||||||
expect(result.matched).toBe(1);
|
expect(result.matched).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves existing device metadata when a duplicate response omits it', async () => {
|
||||||
|
const existing = {
|
||||||
|
dingId: 'check-keep-device',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
rawData: '{}',
|
||||||
|
};
|
||||||
|
dingTalkService.fetchAttendanceResults.mockResolvedValue([{
|
||||||
|
userId: 'ding-1',
|
||||||
|
userName: '张三',
|
||||||
|
workDate: '2026-07-01',
|
||||||
|
timeResult: 'Normal',
|
||||||
|
locationResult: '',
|
||||||
|
planCheckTime: '',
|
||||||
|
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||||
|
checkId: 'check-keep-device',
|
||||||
|
checkType: 'OnDuty',
|
||||||
|
sourceType: '',
|
||||||
|
}]);
|
||||||
|
dingRawRepo.find.mockResolvedValue([existing]);
|
||||||
|
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 0, total: 1 });
|
||||||
|
|
||||||
|
await service.importFromDingTalk({
|
||||||
|
startDate: '2026-07-01',
|
||||||
|
endDate: '2026-07-01',
|
||||||
|
userIds: ['ding-1'],
|
||||||
|
autoMatch: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(existing).toEqual(expect.objectContaining({
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
}));
|
||||||
|
expect(dingRawRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('scopes SSE progress events to the importing user', async () => {
|
it('scopes SSE progress events to the importing user', async () => {
|
||||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -104,8 +104,10 @@ export class AttendanceImportService {
|
|||||||
|
|
||||||
// Stage 2: Parse & deduplicate
|
// Stage 2: Parse & deduplicate
|
||||||
this.emit('parsing', 0, total, `Parsing ${total} records...`);
|
this.emit('parsing', 0, total, `Parsing ${total} records...`);
|
||||||
const existingDingIds = await this.getExistingDingIds(rawResults);
|
const existingByDingId = await this.getExistingRecordsByDingId(rawResults);
|
||||||
const newRecords = rawResults.filter((r) => !existingDingIds.has(r.checkId));
|
const newRecords = rawResults.filter((r) => !existingByDingId.has(r.checkId));
|
||||||
|
const duplicateRecords = rawResults.filter((r) => existingByDingId.has(r.checkId));
|
||||||
|
await this.refreshDuplicatePunchMetadata(duplicateRecords, existingByDingId);
|
||||||
skipped = rawResults.length - newRecords.length;
|
skipped = rawResults.length - newRecords.length;
|
||||||
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
|
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
|
||||||
|
|
||||||
@@ -246,17 +248,45 @@ export class AttendanceImportService {
|
|||||||
/**
|
/**
|
||||||
* Query which dingIds already exist to skip duplicates.
|
* Query which dingIds already exist to skip duplicates.
|
||||||
*/
|
*/
|
||||||
private async getExistingDingIds(
|
private async getExistingRecordsByDingId(
|
||||||
results: DingTalkAttendanceResult[],
|
results: DingTalkAttendanceResult[],
|
||||||
): Promise<Set<string>> {
|
): Promise<Map<string, DingAttendanceRaw>> {
|
||||||
const dingIds = results.map((r) => r.checkId).filter(Boolean);
|
const dingIds = results.map((r) => r.checkId).filter(Boolean);
|
||||||
if (dingIds.length === 0) return new Set();
|
if (dingIds.length === 0) return new Map();
|
||||||
|
|
||||||
const existing = await this.dingRawRepo.find({
|
const existing = await this.dingRawRepo.find({
|
||||||
where: { dingId: In(dingIds) },
|
where: { dingId: In(dingIds) },
|
||||||
select: ['dingId'],
|
|
||||||
});
|
});
|
||||||
return new Set(existing.map((e) => e.dingId));
|
return new Map(existing.map((entity) => [entity.dingId, entity]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshDuplicatePunchMetadata(
|
||||||
|
results: DingTalkAttendanceResult[],
|
||||||
|
existingByDingId: Map<string, DingAttendanceRaw>,
|
||||||
|
): Promise<void> {
|
||||||
|
const changed: DingAttendanceRaw[] = [];
|
||||||
|
for (const result of results) {
|
||||||
|
const entity = existingByDingId.get(result.checkId);
|
||||||
|
if (!entity) continue;
|
||||||
|
const punchSource = result.sourceType || entity.punchSource || null;
|
||||||
|
const punchDeviceName = result.deviceName || entity.punchDeviceName || null;
|
||||||
|
const punchDeviceId = result.deviceId || entity.punchDeviceId || null;
|
||||||
|
if (
|
||||||
|
entity.punchSource === punchSource &&
|
||||||
|
entity.punchDeviceName === punchDeviceName &&
|
||||||
|
entity.punchDeviceId === punchDeviceId
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entity.punchSource = punchSource;
|
||||||
|
entity.punchDeviceName = punchDeviceName;
|
||||||
|
entity.punchDeviceId = punchDeviceId;
|
||||||
|
entity.rawData = JSON.stringify(result);
|
||||||
|
changed.push(entity);
|
||||||
|
}
|
||||||
|
if (changed.length > 0) {
|
||||||
|
await this.dingRawRepo.save(changed, { chunk: 50 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -271,6 +301,9 @@ export class AttendanceImportService {
|
|||||||
entity.attendanceType = r.checkType || 'OnDuty';
|
entity.attendanceType = r.checkType || 'OnDuty';
|
||||||
entity.timeResult = r.timeResult;
|
entity.timeResult = r.timeResult;
|
||||||
entity.locationResult = r.locationResult || '';
|
entity.locationResult = r.locationResult || '';
|
||||||
|
entity.punchSource = r.sourceType || null;
|
||||||
|
entity.punchDeviceName = r.deviceName || null;
|
||||||
|
entity.punchDeviceId = r.deviceId || null;
|
||||||
|
|
||||||
// Parse check-in/out times
|
// Parse check-in/out times
|
||||||
if (r.actualCheckTime) {
|
if (r.actualCheckTime) {
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ const createService = () => {
|
|||||||
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||||
};
|
};
|
||||||
const attendanceService = {
|
const attendanceService = {
|
||||||
|
getLessonAttendanceImportDateRange: jest.fn().mockImplementation((targetSchedule, lessonDate: string) => {
|
||||||
|
if (targetSchedule.endTime > targetSchedule.startTime) {
|
||||||
|
return { startDate: lessonDate, endDate: lessonDate };
|
||||||
|
}
|
||||||
|
const next = new Date(`${lessonDate}T00:00:00.000Z`);
|
||||||
|
next.setUTCDate(next.getUTCDate() + 1);
|
||||||
|
return { startDate: lessonDate, endDate: next.toISOString().slice(0, 10) };
|
||||||
|
}),
|
||||||
getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']),
|
getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']),
|
||||||
createLessonAttendanceFromDingTalk: jest.fn().mockImplementation(
|
createLessonAttendanceFromDingTalk: jest.fn().mockImplementation(
|
||||||
async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({
|
async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({
|
||||||
|
|||||||
@@ -110,9 +110,12 @@ export class AttendanceSettlementService {
|
|||||||
schedule.teacherId,
|
schedule.teacherId,
|
||||||
schedule.classId,
|
schedule.classId,
|
||||||
);
|
);
|
||||||
|
const importRange = this.attendanceService.getLessonAttendanceImportDateRange(
|
||||||
|
schedule,
|
||||||
|
lessonDate,
|
||||||
|
);
|
||||||
const imported = await this.importService.importFromDingTalk({
|
const imported = await this.importService.importFromDingTalk({
|
||||||
startDate: lessonDate,
|
...importRange,
|
||||||
endDate: this.isOvernight(schedule) ? this.shiftDate(lessonDate, 1) : lessonDate,
|
|
||||||
userIds,
|
userIds,
|
||||||
autoMatch: true,
|
autoMatch: true,
|
||||||
userId: schedule.teacherId,
|
userId: schedule.teacherId,
|
||||||
|
|||||||
@@ -162,6 +162,9 @@ describe('AttendanceController — write data scope', () => {
|
|||||||
assertClassAccess: jest.fn(),
|
assertClassAccess: jest.fn(),
|
||||||
getAccessibleClassIds: jest.fn(),
|
getAccessibleClassIds: jest.fn(),
|
||||||
getTeacherClassDingUserIds: jest.fn(),
|
getTeacherClassDingUserIds: jest.fn(),
|
||||||
|
getLessonAttendanceImportDateRange: jest.fn().mockImplementation(
|
||||||
|
(_schedule, lessonDate: string) => ({ startDate: lessonDate, endDate: lessonDate }),
|
||||||
|
),
|
||||||
batchCreate: jest.fn(),
|
batchCreate: jest.fn(),
|
||||||
generateFromSchedules: jest.fn(),
|
generateFromSchedules: jest.fn(),
|
||||||
findAttendanceRecord: jest.fn(),
|
findAttendanceRecord: jest.fn(),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Res,
|
Res,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Observable, filter } from 'rxjs';
|
import { Observable, filter } from 'rxjs';
|
||||||
import type { Request as ExpressRequest, Response } from 'express';
|
import type { Request as ExpressRequest, Response } from 'express';
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
QueryDingRawDto,
|
QueryDingRawDto,
|
||||||
MatchDingRecordDto,
|
MatchDingRecordDto,
|
||||||
AttendanceReportQueryDto,
|
AttendanceReportQueryDto,
|
||||||
|
AttendanceAlertsQueryDto,
|
||||||
UpdateAttendanceRecordDto,
|
UpdateAttendanceRecordDto,
|
||||||
GenerateFromSchedulesDto,
|
GenerateFromSchedulesDto,
|
||||||
LessonAttendanceQueryDto,
|
LessonAttendanceQueryDto,
|
||||||
@@ -93,11 +95,11 @@ export class AttendanceController {
|
|||||||
@Get('attendance-lessons/schedules/:scheduleId')
|
@Get('attendance-lessons/schedules/:scheduleId')
|
||||||
@RequirePermission('attendance:view')
|
@RequirePermission('attendance:view')
|
||||||
async getLessonAttendance(
|
async getLessonAttendance(
|
||||||
@Param('scheduleId') scheduleId: string,
|
@Param('scheduleId', ParseIntPipe) scheduleId: number,
|
||||||
@Query() query: LessonAttendanceQueryDto,
|
@Query() query: LessonAttendanceQueryDto,
|
||||||
@Request() req: { user: RequestUser },
|
@Request() req: { user: RequestUser },
|
||||||
) {
|
) {
|
||||||
const result = await this.service.getLessonAttendance(+scheduleId, query.date);
|
const result = await this.service.getLessonAttendance(scheduleId, query.date);
|
||||||
await this.assertClassAccess(req, result.schedule.classId!);
|
await this.assertClassAccess(req, result.schedule.classId!);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -105,26 +107,29 @@ export class AttendanceController {
|
|||||||
@Post('attendance-lessons/schedules/:scheduleId/pull')
|
@Post('attendance-lessons/schedules/:scheduleId/pull')
|
||||||
@RequirePermission('attendance:create')
|
@RequirePermission('attendance:create')
|
||||||
async pullLessonAttendance(
|
async pullLessonAttendance(
|
||||||
@Param('scheduleId') scheduleId: string,
|
@Param('scheduleId', ParseIntPipe) scheduleId: number,
|
||||||
@Body() dto: StartLessonAttendanceDto,
|
@Body() dto: StartLessonAttendanceDto,
|
||||||
@Request() req: { user: RequestUser },
|
@Request() req: { user: RequestUser },
|
||||||
) {
|
) {
|
||||||
const schedule = await this.service.getLessonAttendance(+scheduleId, dto.date);
|
const schedule = await this.service.getLessonAttendance(scheduleId, dto.date);
|
||||||
await this.assertClassAccess(req, schedule.schedule.classId!);
|
await this.assertClassAccess(req, schedule.schedule.classId!);
|
||||||
const importClassIds = await this.service.getTeacherClassDingUserIds(
|
const importClassIds = await this.service.getTeacherClassDingUserIds(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
schedule.schedule.classId!,
|
schedule.schedule.classId!,
|
||||||
this.canManageAllAttendance(req),
|
this.canManageAllAttendance(req),
|
||||||
);
|
);
|
||||||
|
const importRange = this.service.getLessonAttendanceImportDateRange(
|
||||||
|
schedule.schedule,
|
||||||
|
dto.date,
|
||||||
|
);
|
||||||
const importResult = await this.importService.importFromDingTalk({
|
const importResult = await this.importService.importFromDingTalk({
|
||||||
startDate: dto.date,
|
...importRange,
|
||||||
endDate: dto.date,
|
|
||||||
userIds: importClassIds,
|
userIds: importClassIds,
|
||||||
autoMatch: true,
|
autoMatch: true,
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
});
|
});
|
||||||
const result = await this.service.createLessonAttendanceFromDingTalk(
|
const result = await this.service.createLessonAttendanceFromDingTalk(
|
||||||
+scheduleId,
|
scheduleId,
|
||||||
dto.date,
|
dto.date,
|
||||||
req.user.id,
|
req.user.id,
|
||||||
);
|
);
|
||||||
@@ -143,18 +148,18 @@ export class AttendanceController {
|
|||||||
@Post('attendance-lessons/:sessionId/complete')
|
@Post('attendance-lessons/:sessionId/complete')
|
||||||
@RequirePermission('attendance:create')
|
@RequirePermission('attendance:create')
|
||||||
async completeLessonAttendance(
|
async completeLessonAttendance(
|
||||||
@Param('sessionId') sessionId: string,
|
@Param('sessionId', ParseIntPipe) sessionId: number,
|
||||||
@Request() req: { user: RequestUser },
|
@Request() req: { user: RequestUser },
|
||||||
) {
|
) {
|
||||||
const session = await this.service.findAttendanceSession(+sessionId);
|
const session = await this.service.findAttendanceSession(sessionId);
|
||||||
await this.assertClassAccess(req, session.classId);
|
await this.assertClassAccess(req, session.classId);
|
||||||
const result = await this.service.completeLessonAttendance(+sessionId, req.user.id);
|
const result = await this.service.completeLessonAttendance(sessionId, req.user.id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
username: req.user.username,
|
username: req.user.username,
|
||||||
module: '考勤管理',
|
module: '考勤管理',
|
||||||
action: '完成课程点名',
|
action: '完成课程点名',
|
||||||
targetId: +sessionId,
|
targetId: sessionId,
|
||||||
targetType: 'attendanceSession',
|
targetType: 'attendanceSession',
|
||||||
detail: `班级${session.classId} 日期${session.lessonDate}`,
|
detail: `班级${session.classId} 日期${session.lessonDate}`,
|
||||||
});
|
});
|
||||||
@@ -278,23 +283,23 @@ export class AttendanceController {
|
|||||||
@Put('attendance-records/:id')
|
@Put('attendance-records/:id')
|
||||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||||
async update(
|
async update(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateAttendanceRecordDto,
|
@Body() dto: UpdateAttendanceRecordDto,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const existing = await this.service.findAttendanceRecord(+id);
|
const existing = await this.service.findAttendanceRecord(id);
|
||||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||||
throw new ForbiddenException('无权修改未关联班级的考勤记录');
|
throw new ForbiddenException('无权修改未关联班级的考勤记录');
|
||||||
}
|
}
|
||||||
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
||||||
const result = await this.service.update(+id, dto);
|
const result = await this.service.update(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '考勤管理',
|
module: '考勤管理',
|
||||||
action: '编辑考勤记录',
|
action: '编辑考勤记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'attendanceRecord',
|
targetType: 'attendanceRecord',
|
||||||
detail: `状态=${result.status}, 备注=${result.remark || ''}`,
|
detail: `状态=${result.status}, 备注=${result.remark || ''}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -306,20 +311,20 @@ export class AttendanceController {
|
|||||||
// ── Delete a single attendance record ──
|
// ── Delete a single attendance record ──
|
||||||
@Delete('attendance-records/:id')
|
@Delete('attendance-records/:id')
|
||||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||||
async remove(@Param('id') id: string, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const existing = await this.service.findAttendanceRecord(+id);
|
const existing = await this.service.findAttendanceRecord(id);
|
||||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||||
throw new ForbiddenException('无权删除未关联班级的考勤记录');
|
throw new ForbiddenException('无权删除未关联班级的考勤记录');
|
||||||
}
|
}
|
||||||
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
||||||
const result = await this.service.remove(+id);
|
const result = await this.service.remove(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '考勤管理',
|
module: '考勤管理',
|
||||||
action: '删除考勤记录',
|
action: '删除考勤记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'attendanceRecord',
|
targetType: 'attendanceRecord',
|
||||||
detail: `删除考勤记录 ${id}`,
|
detail: `删除考勤记录 ${id}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -369,18 +374,18 @@ export class AttendanceController {
|
|||||||
@Post('ding-attendance-raw/:id/match')
|
@Post('ding-attendance-raw/:id/match')
|
||||||
@RequirePermission('attendance:edit')
|
@RequirePermission('attendance:edit')
|
||||||
async matchDingRecord(
|
async matchDingRecord(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: MatchDingRecordDto,
|
@Body() dto: MatchDingRecordDto,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.matchDingRecord(+id, dto);
|
const result = await this.service.matchDingRecord(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '考勤管理',
|
module: '考勤管理',
|
||||||
action: '匹配考勤记录',
|
action: '匹配考勤记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'dingAttendanceRaw',
|
targetType: 'dingAttendanceRaw',
|
||||||
detail: `匹配到学生 ${dto.studentId}`,
|
detail: `匹配到学生 ${dto.studentId}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -458,12 +463,11 @@ export class AttendanceController {
|
|||||||
@RequirePermission('attendance:view')
|
@RequirePermission('attendance:view')
|
||||||
async getAlerts(
|
async getAlerts(
|
||||||
@Request() req: { user: RequestUser },
|
@Request() req: { user: RequestUser },
|
||||||
@Query('days') days?: string,
|
@Query() query: AttendanceAlertsQueryDto,
|
||||||
@Query('threshold') threshold?: string,
|
|
||||||
) {
|
) {
|
||||||
return this.service.getAlerts(
|
return this.service.getAlerts(
|
||||||
days ? +days : 14,
|
query.days ?? 14,
|
||||||
threshold ? +threshold : 3,
|
query.threshold ?? 3,
|
||||||
await this.getAccessibleClassIds(req),
|
await this.getAccessibleClassIds(req),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const endedSchedule = {
|
|||||||
subject: '\u6570\u5B66',
|
subject: '\u6570\u5B66',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
scheduleType: 'INTERNAL',
|
scheduleType: 'INTERNAL',
|
||||||
|
attendanceAdvanceMinutes: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||||
@@ -79,6 +80,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
attendanceType: 'OnDuty',
|
attendanceType: 'OnDuty',
|
||||||
timeResult: 'Normal',
|
timeResult: 'Normal',
|
||||||
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matchedStudentId: 2,
|
matchedStudentId: 2,
|
||||||
@@ -100,7 +104,15 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||||
expect.objectContaining({ studentId: 1, status: 'present', source: 'dingtalk' }),
|
expect.objectContaining({
|
||||||
|
studentId: 1,
|
||||||
|
status: 'present',
|
||||||
|
source: 'dingtalk',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
punchTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||||
|
}),
|
||||||
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
|
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
|
||||||
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
|
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
|
||||||
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
|
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
|
||||||
@@ -175,6 +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 () => {
|
it('creates local attendance after the lesson starts', async () => {
|
||||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||||
createService();
|
createService();
|
||||||
|
|||||||
@@ -175,24 +175,45 @@ export class AttendanceService {
|
|||||||
return { schedule, session, records };
|
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(
|
private selectDingTalkRecordsForLesson(
|
||||||
records: DingAttendanceRaw[],
|
records: DingAttendanceRaw[],
|
||||||
|
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||||
lessonDate: string,
|
lessonDate: string,
|
||||||
startTime: string,
|
|
||||||
endTime: string,
|
|
||||||
): DingAttendanceRaw[] {
|
): DingAttendanceRaw[] {
|
||||||
const [startHour, startMinute] = startTime.split(':').map(Number);
|
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
|
||||||
const [endHour, endMinute] = endTime.split(':').map(Number);
|
return records.filter((record) => {
|
||||||
const start = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
|
// 上班、下班打卡都有效,按原始记录中实际存在的时间判断。
|
||||||
let end = new Date(`${lessonDate}T${endTime}:00+08:00`).getTime();
|
|
||||||
if (endHour * 60 + endMinute <= startHour * 60 + startMinute) end += 24 * 60 * 60 * 1000;
|
|
||||||
const windowStart = start - 3 * 60 * 60 * 1000;
|
|
||||||
const windowEnd = end + 3 * 60 * 60 * 1000;
|
|
||||||
const timed = records.filter((record) => {
|
|
||||||
const time = record.checkInTime ?? record.checkOutTime;
|
const time = record.checkInTime ?? record.checkOutTime;
|
||||||
return time && time.getTime() >= windowStart && time.getTime() <= windowEnd;
|
return time && time.getTime() >= window.start && time.getTime() <= window.end;
|
||||||
});
|
});
|
||||||
return timed.length > 0 ? timed : records.filter((record) => !record.checkInTime && !record.checkOutTime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
|
private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
|
||||||
@@ -200,6 +221,53 @@ export class AttendanceService {
|
|||||||
if (hasPunch) return 'present';
|
if (hasPunch) return 'present';
|
||||||
return finalize ? 'absent' : 'pending';
|
return finalize ? 'absent' : 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getLessonPunchMetadata(
|
||||||
|
records: DingAttendanceRaw[],
|
||||||
|
lessonDate: string,
|
||||||
|
startTime: string,
|
||||||
|
): Pick<AttendanceRecord, 'punchTime' | 'punchSource' | 'punchDeviceName' | 'punchDeviceId'> {
|
||||||
|
const punches = records
|
||||||
|
.map((record) => ({ record, time: record.checkInTime ?? record.checkOutTime }))
|
||||||
|
.filter((item): item is { record: DingAttendanceRaw; time: Date } => !!item.time);
|
||||||
|
if (punches.length === 0) {
|
||||||
|
return {
|
||||||
|
punchTime: null,
|
||||||
|
punchSource: null,
|
||||||
|
punchDeviceName: null,
|
||||||
|
punchDeviceId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lessonStart = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
|
||||||
|
punches.sort(
|
||||||
|
(left, right) =>
|
||||||
|
Math.abs(left.time.getTime() - lessonStart) - Math.abs(right.time.getTime() - lessonStart),
|
||||||
|
);
|
||||||
|
const primary = punches[0];
|
||||||
|
const metadataRecord = [...punches]
|
||||||
|
.filter(({ record }) =>
|
||||||
|
!!(record.punchSource || record.punchDeviceName || record.punchDeviceId) ||
|
||||||
|
!['OnDuty', 'OffDuty'].includes(record.attendanceType),
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
Math.abs(left.time.getTime() - primary.time.getTime()) -
|
||||||
|
Math.abs(right.time.getTime() - primary.time.getTime()),
|
||||||
|
)[0]?.record;
|
||||||
|
const source =
|
||||||
|
metadataRecord?.punchSource ||
|
||||||
|
(metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType)
|
||||||
|
? metadataRecord.attendanceType
|
||||||
|
: primary.record.punchSource);
|
||||||
|
|
||||||
|
return {
|
||||||
|
punchTime: primary.time,
|
||||||
|
punchSource: source || null,
|
||||||
|
punchDeviceName: metadataRecord?.punchDeviceName || primary.record.punchDeviceName || null,
|
||||||
|
punchDeviceId: metadataRecord?.punchDeviceId || primary.record.punchDeviceId || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
async createLessonAttendanceFromDingTalk(
|
async createLessonAttendanceFromDingTalk(
|
||||||
scheduleId: number,
|
scheduleId: number,
|
||||||
lessonDate: string,
|
lessonDate: string,
|
||||||
@@ -244,7 +312,7 @@ export class AttendanceService {
|
|||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const sessionRepo = manager.getRepository(AttendanceSession);
|
const sessionRepo = manager.getRepository(AttendanceSession);
|
||||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
|
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
|
||||||
const existingRecords = await recordRepo.find({
|
const existingRecords = await recordRepo.find({
|
||||||
where: { attendanceSessionId: existing.id },
|
where: { attendanceSessionId: existing.id },
|
||||||
order: { studentId: 'ASC' },
|
order: { studentId: 'ASC' },
|
||||||
@@ -265,11 +333,15 @@ export class AttendanceService {
|
|||||||
|
|
||||||
const raw = this.selectDingTalkRecordsForLesson(
|
const raw = this.selectDingTalkRecordsForLesson(
|
||||||
rawByStudent.get(record.studentId) ?? [],
|
rawByStudent.get(record.studentId) ?? [],
|
||||||
|
schedule,
|
||||||
lessonDate,
|
lessonDate,
|
||||||
schedule.startTime,
|
|
||||||
schedule.endTime,
|
|
||||||
);
|
);
|
||||||
record.status = this.mapDingTalkStatus(raw, finalize);
|
record.status = this.mapDingTalkStatus(raw, finalize);
|
||||||
|
Object.assign(record, this.getLessonPunchMetadata(
|
||||||
|
raw,
|
||||||
|
lessonDate,
|
||||||
|
schedule.startTime,
|
||||||
|
));
|
||||||
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||||
? null
|
? null
|
||||||
: finalize
|
: finalize
|
||||||
@@ -281,9 +353,8 @@ export class AttendanceService {
|
|||||||
if (existingStudentIds.has(classStudent.studentId)) continue;
|
if (existingStudentIds.has(classStudent.studentId)) continue;
|
||||||
const raw = this.selectDingTalkRecordsForLesson(
|
const raw = this.selectDingTalkRecordsForLesson(
|
||||||
rawByStudent.get(classStudent.studentId) ?? [],
|
rawByStudent.get(classStudent.studentId) ?? [],
|
||||||
|
schedule,
|
||||||
lessonDate,
|
lessonDate,
|
||||||
schedule.startTime,
|
|
||||||
schedule.endTime,
|
|
||||||
);
|
);
|
||||||
updatedRecords.push(
|
updatedRecords.push(
|
||||||
recordRepo.create({
|
recordRepo.create({
|
||||||
@@ -296,6 +367,11 @@ export class AttendanceService {
|
|||||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||||
status: this.mapDingTalkStatus(raw, finalize),
|
status: this.mapDingTalkStatus(raw, finalize),
|
||||||
source: 'dingtalk',
|
source: 'dingtalk',
|
||||||
|
...this.getLessonPunchMetadata(
|
||||||
|
raw,
|
||||||
|
lessonDate,
|
||||||
|
schedule.startTime,
|
||||||
|
),
|
||||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||||
? undefined
|
? undefined
|
||||||
: finalize
|
: finalize
|
||||||
@@ -320,7 +396,7 @@ export class AttendanceService {
|
|||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const sessionRepo = manager.getRepository(AttendanceSession);
|
const sessionRepo = manager.getRepository(AttendanceSession);
|
||||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
|
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
|
||||||
|
|
||||||
const classStudents = await this.classStudentRepo.find({
|
const classStudents = await this.classStudentRepo.find({
|
||||||
where: { classId: schedule.classId!, status: 'active' },
|
where: { classId: schedule.classId!, status: 'active' },
|
||||||
@@ -364,9 +440,8 @@ export class AttendanceService {
|
|||||||
const records = classStudents.map((classStudent) => {
|
const records = classStudents.map((classStudent) => {
|
||||||
const raw = this.selectDingTalkRecordsForLesson(
|
const raw = this.selectDingTalkRecordsForLesson(
|
||||||
rawByStudent.get(classStudent.studentId) ?? [],
|
rawByStudent.get(classStudent.studentId) ?? [],
|
||||||
|
schedule,
|
||||||
lessonDate,
|
lessonDate,
|
||||||
schedule.startTime,
|
|
||||||
schedule.endTime,
|
|
||||||
);
|
);
|
||||||
return recordRepo.create({
|
return recordRepo.create({
|
||||||
studentId: classStudent.studentId,
|
studentId: classStudent.studentId,
|
||||||
@@ -378,6 +453,11 @@ export class AttendanceService {
|
|||||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||||
status: this.mapDingTalkStatus(raw, finalize),
|
status: this.mapDingTalkStatus(raw, finalize),
|
||||||
source: 'dingtalk',
|
source: 'dingtalk',
|
||||||
|
...this.getLessonPunchMetadata(
|
||||||
|
raw,
|
||||||
|
lessonDate,
|
||||||
|
schedule.startTime,
|
||||||
|
),
|
||||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||||
? undefined
|
? undefined
|
||||||
: finalize
|
: finalize
|
||||||
@@ -398,6 +478,7 @@ export class AttendanceService {
|
|||||||
|
|
||||||
private async fetchDingTalkRawByStudent(
|
private async fetchDingTalkRawByStudent(
|
||||||
classId: number,
|
classId: number,
|
||||||
|
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||||
lessonDate: string,
|
lessonDate: string,
|
||||||
): Promise<Map<number, DingAttendanceRaw[]>> {
|
): Promise<Map<number, DingAttendanceRaw[]>> {
|
||||||
const classStudents = await this.classStudentRepo.find({
|
const classStudents = await this.classStudentRepo.find({
|
||||||
@@ -405,9 +486,10 @@ export class AttendanceService {
|
|||||||
});
|
});
|
||||||
if (classStudents.length === 0) return new Map();
|
if (classStudents.length === 0) return new Map();
|
||||||
const studentIds = classStudents.map((cs) => cs.studentId);
|
const studentIds = classStudents.map((cs) => cs.studentId);
|
||||||
|
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
|
||||||
const rawRecords = await this.dingRawRepo.find({
|
const rawRecords = await this.dingRawRepo.find({
|
||||||
where: {
|
where: {
|
||||||
attendanceDate: lessonDate,
|
attendanceDate: Between(window.dateFrom, window.dateTo),
|
||||||
matchedStudentId: In(studentIds),
|
matchedStudentId: In(studentIds),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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 {
|
private mapScheduleTimeToSession(startTime: string): string {
|
||||||
const hour = parseInt(startTime.slice(0, 2), 10);
|
const hour = parseInt(startTime.slice(0, 2), 10);
|
||||||
if (hour < 8) return 'morning_reading';
|
if (hour < 8) return 'morning_reading';
|
||||||
@@ -908,6 +1001,10 @@ export class AttendanceService {
|
|||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
record.status = dto.status;
|
record.status = dto.status;
|
||||||
record.source = 'manual';
|
record.source = 'manual';
|
||||||
|
record.punchTime = null;
|
||||||
|
record.punchSource = null;
|
||||||
|
record.punchDeviceName = null;
|
||||||
|
record.punchDeviceId = null;
|
||||||
}
|
}
|
||||||
if (dto.remark !== undefined) {
|
if (dto.remark !== undefined) {
|
||||||
record.remark = dto.remark;
|
record.remark = dto.remark;
|
||||||
@@ -933,6 +1030,10 @@ export class AttendanceService {
|
|||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
freshRecord.status = dto.status;
|
freshRecord.status = dto.status;
|
||||||
freshRecord.source = 'manual';
|
freshRecord.source = 'manual';
|
||||||
|
freshRecord.punchTime = null;
|
||||||
|
freshRecord.punchSource = null;
|
||||||
|
freshRecord.punchDeviceName = null;
|
||||||
|
freshRecord.punchDeviceId = null;
|
||||||
}
|
}
|
||||||
if (dto.remark !== undefined) {
|
if (dto.remark !== undefined) {
|
||||||
freshRecord.remark = dto.remark;
|
freshRecord.remark = dto.remark;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ describe('DingTalkService — attendance records', () => {
|
|||||||
service = new DingTalkService({} as never, {} as never);
|
service = new DingTalkService({} as never, {} as never);
|
||||||
Object.assign(service, {
|
Object.assign(service, {
|
||||||
accessToken: 'test-token',
|
accessToken: 'test-token',
|
||||||
|
accessTokenCredentialKey: 'test-app-key:test-app-secret',
|
||||||
tokenExpiresAt: Date.now() + 3_600_000,
|
tokenExpiresAt: Date.now() + 3_600_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -67,8 +68,10 @@ describe('DingTalkService — attendance records', () => {
|
|||||||
userId: 'ding-1',
|
userId: 'ding-1',
|
||||||
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
|
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
|
||||||
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
|
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
|
||||||
sourceType: 'USER',
|
sourceType: 'ATM',
|
||||||
checkType: 'OnDuty',
|
checkType: 'OnDuty',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
timeResult: 'Normal',
|
timeResult: 'Normal',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -82,6 +85,13 @@ describe('DingTalkService — attendance records', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(record.workDate).toBe('2026-07-12');
|
expect(record.workDate).toBe('2026-07-12');
|
||||||
|
expect(record).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
checkType: 'OnDuty',
|
||||||
|
sourceType: 'ATM',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
IsIn,
|
IsIn,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
ArrayNotEmpty,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
@@ -44,6 +47,7 @@ export class AttendanceRecordItem {
|
|||||||
|
|
||||||
export class BatchCreateAttendanceDto {
|
export class BatchCreateAttendanceDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => AttendanceRecordItem)
|
@Type(() => AttendanceRecordItem)
|
||||||
records: AttendanceRecordItem[];
|
records: AttendanceRecordItem[];
|
||||||
@@ -96,11 +100,14 @@ export class QueryDingRawDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
@@ -139,11 +146,14 @@ export class QueryAttendanceRecordsDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
@@ -165,6 +175,22 @@ export class UpdateAttendanceRecordDto {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class AttendanceAlertsQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(365)
|
||||||
|
days?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
threshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class AttendanceReportQueryDto {
|
export class AttendanceReportQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -34,20 +34,6 @@ export class BillsExportService {
|
|||||||
if (query.status) qb.andWhere('b.status = :status', { status: query.status });
|
if (query.status) qb.andWhere('b.status = :status', { status: query.status });
|
||||||
const bills = await qb.getMany();
|
const bills = await qb.getMany();
|
||||||
|
|
||||||
// 查询涉及学生的"已缴未退"押金,用于导出押金抵扣字段
|
|
||||||
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
|
||||||
const depMap = new Map<number, number>();
|
|
||||||
if (studentIds.length > 0) {
|
|
||||||
const deposits = await this.depositRepo
|
|
||||||
.createQueryBuilder('d')
|
|
||||||
.where('d.studentId IN (:...ids)', { ids: studentIds })
|
|
||||||
.andWhere('d.status = :status', { status: 'paid' })
|
|
||||||
.getMany();
|
|
||||||
for (const d of deposits) {
|
|
||||||
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
workbook.creator = '恭学教育基地管理系统';
|
workbook.creator = '恭学教育基地管理系统';
|
||||||
|
|
||||||
@@ -60,9 +46,8 @@ export class BillsExportService {
|
|||||||
{ header: '分摊费用', key: 'shared', width: 12 },
|
{ header: '分摊费用', key: 'shared', width: 12 },
|
||||||
{ header: '个人费用', key: 'personal', width: 12 },
|
{ header: '个人费用', key: 'personal', width: 12 },
|
||||||
{ header: '总金额', key: 'total', width: 12 },
|
{ header: '总金额', key: 'total', width: 12 },
|
||||||
{ header: '可用押金', key: 'deposit', width: 12 },
|
{ header: '已扣余额', key: 'paidAmount', width: 12 },
|
||||||
{ header: '押金抵扣', key: 'depositApplied', width: 12 },
|
{ header: '待补缴', key: 'outstandingAmount', width: 12 },
|
||||||
{ header: '抵扣后应付', key: 'afterDeposit', width: 14 },
|
|
||||||
{ header: '状态', key: 'status', width: 10 },
|
{ header: '状态', key: 'status', width: 10 },
|
||||||
{ header: '生成时间', key: 'generatedAt', width: 20 },
|
{ header: '生成时间', key: 'generatedAt', width: 20 },
|
||||||
];
|
];
|
||||||
@@ -71,15 +56,13 @@ export class BillsExportService {
|
|||||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||||
|
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
draft: '草稿',
|
unpaid: '待支付',
|
||||||
confirmed: '已确认',
|
partially_paid: '部分支付',
|
||||||
paid: '已结清',
|
paid: '已结清',
|
||||||
|
cancelled: '已取消',
|
||||||
};
|
};
|
||||||
for (const bill of bills) {
|
for (const bill of bills) {
|
||||||
const total = Number(bill.totalAmount || 0);
|
const total = Number(bill.totalAmount || 0);
|
||||||
const dep = Number((depMap.get(bill.studentId) || 0).toFixed(2));
|
|
||||||
const applied = Number(Math.min(dep, total).toFixed(2));
|
|
||||||
const after = Number(Math.max(0, total - applied).toFixed(2));
|
|
||||||
ws.addRow({
|
ws.addRow({
|
||||||
id: bill.id,
|
id: bill.id,
|
||||||
studentName: (bill as any).student?.name || '-',
|
studentName: (bill as any).student?.name || '-',
|
||||||
@@ -87,9 +70,8 @@ export class BillsExportService {
|
|||||||
shared: Number(bill.sharedAmount),
|
shared: Number(bill.sharedAmount),
|
||||||
personal: Number(bill.personalAmount),
|
personal: Number(bill.personalAmount),
|
||||||
total,
|
total,
|
||||||
deposit: dep,
|
paidAmount: Number(bill.paidAmount || 0),
|
||||||
depositApplied: applied,
|
outstandingAmount: Number(bill.outstandingAmount || 0),
|
||||||
afterDeposit: after,
|
|
||||||
status: statusMap[bill.status] || bill.status,
|
status: statusMap[bill.status] || bill.status,
|
||||||
generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '',
|
generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '',
|
||||||
});
|
});
|
||||||
@@ -147,16 +129,9 @@ export class BillsExportService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询该学生的可用押金(已缴未退)
|
|
||||||
const deposits = await this.depositRepo
|
|
||||||
.createQueryBuilder('d')
|
|
||||||
.where('d.studentId = :sid', { sid: bill.studentId })
|
|
||||||
.andWhere('d.status = :status', { status: 'paid' })
|
|
||||||
.getMany();
|
|
||||||
const availableDeposit = deposits.reduce((s, d) => s + Number(d.amount || 0), 0);
|
|
||||||
const totalAmount = Number(bill.totalAmount || 0);
|
const totalAmount = Number(bill.totalAmount || 0);
|
||||||
const depositApplied = Math.min(availableDeposit, totalAmount);
|
const paidAmount = Number(bill.paidAmount || 0);
|
||||||
const amountAfterDeposit = Math.max(0, totalAmount - depositApplied);
|
const outstandingAmount = Number(bill.outstandingAmount || 0);
|
||||||
|
|
||||||
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
@@ -191,9 +166,10 @@ export class BillsExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
draft: '草稿',
|
unpaid: '待支付',
|
||||||
confirmed: '已确认',
|
partially_paid: '部分支付',
|
||||||
paid: '已结清',
|
paid: '已结清',
|
||||||
|
cancelled: '已取消',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 标题
|
// 标题
|
||||||
@@ -223,20 +199,8 @@ export class BillsExportService {
|
|||||||
.fillColor('#007AFF')
|
.fillColor('#007AFF')
|
||||||
.text(`应付总额: ¥${totalAmount.toFixed(2)}`);
|
.text(`应付总额: ¥${totalAmount.toFixed(2)}`);
|
||||||
doc.moveDown(0.3);
|
doc.moveDown(0.3);
|
||||||
if (availableDeposit > 0) {
|
doc.fontSize(11).fillColor('#389E0D').text(`已扣余额: ¥${paidAmount.toFixed(2)}`);
|
||||||
doc
|
doc.fontSize(14).fillColor(outstandingAmount > 0 ? '#FF3B30' : '#389E0D').text(`待补缴: ¥${outstandingAmount.toFixed(2)}`);
|
||||||
.fontSize(11)
|
|
||||||
.fillColor('#52C41A')
|
|
||||||
.text(`可用押金: ¥${availableDeposit.toFixed(2)}`);
|
|
||||||
doc
|
|
||||||
.fontSize(11)
|
|
||||||
.fillColor('#FA8C16')
|
|
||||||
.text(`押金抵扣: -¥${depositApplied.toFixed(2)}`);
|
|
||||||
doc
|
|
||||||
.fontSize(14)
|
|
||||||
.fillColor('#FF3B30')
|
|
||||||
.text(`抵扣后应付: ¥${amountAfterDeposit.toFixed(2)}`);
|
|
||||||
}
|
|
||||||
doc.moveDown(1);
|
doc.moveDown(1);
|
||||||
|
|
||||||
// 明细表格
|
// 明细表格
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Request,
|
Request,
|
||||||
Res,
|
Res,
|
||||||
Req,
|
Req,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, In } from 'typeorm';
|
import { Repository, In } from 'typeorm';
|
||||||
@@ -20,7 +21,7 @@ import { NotificationType } from '../entities/notification.entity';
|
|||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
import { Bill } from '../entities/bill.entity';
|
import { Bill } from '../entities/bill.entity';
|
||||||
import { BillsExportService } from './bills-export.service';
|
import { BillsExportService } from './bills-export.service';
|
||||||
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
@@ -49,7 +50,7 @@ export class BillsController {
|
|||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '账单管理',
|
module: '账单管理',
|
||||||
action: '生成账单',
|
action: '生成账单',
|
||||||
detail: `周期 ${dto.periodStart}~${dto.periodEnd}, 生成 ${result.count} 条`,
|
detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count} 条`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
});
|
});
|
||||||
@@ -62,7 +63,7 @@ export class BillsController {
|
|||||||
recipientIds: [student.userId],
|
recipientIds: [student.userId],
|
||||||
type: NotificationType.BILL_GENERATED,
|
type: NotificationType.BILL_GENERATED,
|
||||||
title: '新账单',
|
title: '新账单',
|
||||||
content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${dto.periodStart}~${dto.periodEnd}`,
|
content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${result.periodStart}~${result.periodEnd}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,38 +76,38 @@ export class BillsController {
|
|||||||
findAll(
|
findAll(
|
||||||
@Query('periodStart') periodStart?: string,
|
@Query('periodStart') periodStart?: string,
|
||||||
@Query('periodEnd') periodEnd?: string,
|
@Query('periodEnd') periodEnd?: string,
|
||||||
@Query('studentId') studentId?: string,
|
@Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Query('expenseType') expenseType?: string,
|
@Query('expenseType') expenseType?: string,
|
||||||
) {
|
) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
periodStart, periodEnd,
|
periodStart, periodEnd,
|
||||||
studentId: studentId ? +studentId : undefined,
|
studentId,
|
||||||
status, expenseType,
|
status, expenseType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePermission('bill:view')
|
@RequirePermission('bill:view')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.service.findOne(+id);
|
return this.service.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/status')
|
@Put(':id/status')
|
||||||
@RequirePermission('bill:confirm')
|
@RequirePermission('bill:confirm')
|
||||||
async updateStatus(
|
async updateStatus(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateBillStatusDto,
|
@Body() dto: UpdateBillStatusDto,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.updateStatus(+id, dto);
|
const result = await this.service.updateStatus(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '账单管理',
|
module: '账单管理',
|
||||||
action: '确认账单',
|
action: '确认账单',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'bill',
|
targetType: 'bill',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -158,17 +159,36 @@ export class BillsController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/cancel')
|
||||||
|
@RequirePermission('bill:delete')
|
||||||
|
async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: any) {
|
||||||
|
const result = await this.service.cancel(id, dto, req.user?.id);
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '账单管理',
|
||||||
|
action: '取消账单并冲正',
|
||||||
|
targetId: id,
|
||||||
|
targetType: 'bill',
|
||||||
|
detail: dto.reason,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('bill:delete')
|
@RequirePermission('bill:delete')
|
||||||
async remove(@Param('id') id: string, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.remove(+id);
|
const result = await this.service.remove(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '账单管理',
|
module: '账单管理',
|
||||||
action: '删除账单',
|
action: '删除账单',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'bill',
|
targetType: 'bill',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -198,7 +218,7 @@ export class BillsController {
|
|||||||
async exportExcel(
|
async exportExcel(
|
||||||
@Query('periodStart') periodStart?: string,
|
@Query('periodStart') periodStart?: string,
|
||||||
@Query('periodEnd') periodEnd?: string,
|
@Query('periodEnd') periodEnd?: string,
|
||||||
@Query('studentId') studentId?: string,
|
@Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Res() res?: Response,
|
@Res() res?: Response,
|
||||||
@Req() req?: any,
|
@Req() req?: any,
|
||||||
@@ -217,7 +237,7 @@ export class BillsController {
|
|||||||
{
|
{
|
||||||
periodStart,
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
studentId: studentId ? +studentId : undefined,
|
studentId,
|
||||||
status,
|
status,
|
||||||
},
|
},
|
||||||
res!,
|
res!,
|
||||||
@@ -226,18 +246,18 @@ export class BillsController {
|
|||||||
|
|
||||||
@Get('export/pdf/:id')
|
@Get('export/pdf/:id')
|
||||||
@RequirePermission('bill:export-pdf')
|
@RequirePermission('bill:export-pdf')
|
||||||
async exportPdf(@Param('id') id: string, @Res() res: Response, @Req() req: any) {
|
async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req?.user?.id,
|
userId: req?.user?.id,
|
||||||
username: req?.user?.username,
|
username: req?.user?.username,
|
||||||
module: '账单管理',
|
module: '账单管理',
|
||||||
action: '导出账单',
|
action: '导出账单',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'bill',
|
targetType: 'bill',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
});
|
});
|
||||||
return this.exportService.exportStudentPdf(+id, res);
|
return this.exportService.exportStudentPdf(id, res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { WalletsModule } from '../wallets/wallets.module';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Bill } from '../entities/bill.entity';
|
import { Bill } from '../entities/bill.entity';
|
||||||
import { BillItem } from '../entities/bill-item.entity';
|
import { BillItem } from '../entities/bill-item.entity';
|
||||||
@@ -7,8 +8,8 @@ import { RoomExpense } from '../entities/room-expense.entity';
|
|||||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||||
import { Occupancy } from '../entities/occupancy.entity';
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
import { Room } from '../entities/room.entity';
|
import { Room } from '../entities/room.entity';
|
||||||
import { Deposit } from '../entities/deposit.entity';
|
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
import { BillsService } from './bills.service';
|
import { BillsService } from './bills.service';
|
||||||
import { BillsExportService } from './bills-export.service';
|
import { BillsExportService } from './bills-export.service';
|
||||||
import { BillsController } from './bills.controller';
|
import { BillsController } from './bills.controller';
|
||||||
@@ -22,10 +23,11 @@ import { BillsController } from './bills.controller';
|
|||||||
PersonalExpense,
|
PersonalExpense,
|
||||||
Occupancy,
|
Occupancy,
|
||||||
Room,
|
Room,
|
||||||
Deposit,
|
|
||||||
Student,
|
Student,
|
||||||
|
Deposit,
|
||||||
]),
|
]),
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
WalletsModule,
|
||||||
],
|
],
|
||||||
controllers: [BillsController],
|
controllers: [BillsController],
|
||||||
providers: [BillsService, BillsExportService],
|
providers: [BillsService, BillsExportService],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PersonalExpense } from '../entities/personal-expense.entity';
|
|||||||
import { Occupancy } from '../entities/occupancy.entity';
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
import { Room } from '../entities/room.entity';
|
import { Room } from '../entities/room.entity';
|
||||||
import { Deposit } from '../entities/deposit.entity';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
|
import { WalletsService } from '../wallets/wallets.service';
|
||||||
|
|
||||||
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
|
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
|
||||||
|
|
||||||
@@ -56,7 +57,23 @@ describe('BillsService — generateBills', () => {
|
|||||||
occRepo = mockRepo<Occupancy>();
|
occRepo = mockRepo<Occupancy>();
|
||||||
roomRepo = mockRepo<Room>();
|
roomRepo = mockRepo<Room>();
|
||||||
depositRepo = mockRepo<Deposit>();
|
depositRepo = mockRepo<Deposit>();
|
||||||
dataSource = { transaction: jest.fn(), query: jest.fn().mockResolvedValue([]) };
|
let nextBillId = 0;
|
||||||
|
dataSource = {
|
||||||
|
transaction: jest.fn(async (callback) => callback({
|
||||||
|
create: (_entity: unknown, value: unknown) => value,
|
||||||
|
save: jest.fn(async (value: any) => {
|
||||||
|
if ('totalAmount' in value && 'studentId' in value) {
|
||||||
|
const saved = { id: ++nextBillId, ...value };
|
||||||
|
await (billRepo.save as jest.Mock)(saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
await (itemRepo.save as jest.Mock)(value);
|
||||||
|
return { id: value.id || 1, ...value };
|
||||||
|
}),
|
||||||
|
createQueryBuilder: jest.fn(() => ({ update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })),
|
||||||
|
})),
|
||||||
|
query: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -69,6 +86,7 @@ describe('BillsService — generateBills', () => {
|
|||||||
{ provide: getRepositoryToken(Room), useValue: roomRepo },
|
{ provide: getRepositoryToken(Room), useValue: roomRepo },
|
||||||
{ provide: getRepositoryToken(Deposit), useValue: depositRepo },
|
{ provide: getRepositoryToken(Deposit), useValue: depositRepo },
|
||||||
{ provide: DataSource, useValue: dataSource },
|
{ provide: DataSource, useValue: dataSource },
|
||||||
|
{ provide: WalletsService, useValue: { debitBill: jest.fn(async (_manager, bill) => bill), refundBill: jest.fn() } },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -166,6 +184,43 @@ describe('BillsService — generateBills', () => {
|
|||||||
).toBeCloseTo(300, 0);
|
).toBeCloseTo(300, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes room expenses whose periods are inside the generated bill period', async () => {
|
||||||
|
const qb = mockQueryBuilder<RoomExpense>([
|
||||||
|
{
|
||||||
|
id: 1, roomId: 1, expenseType: 'water',
|
||||||
|
amount: '300' as unknown as number, periodStart: '2026-07-01', periodEnd: '2026-07-31',
|
||||||
|
} as RoomExpense,
|
||||||
|
]);
|
||||||
|
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
|
||||||
|
|
||||||
|
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||||
|
mockQueryBuilder<Occupancy>([
|
||||||
|
{
|
||||||
|
id: 1, studentId: 10, roomId: 1,
|
||||||
|
billingStartDate: '2026-07-01', billingEndDate: null as unknown as string,
|
||||||
|
stayType: 'short', room: undefined,
|
||||||
|
} as Occupancy,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||||
|
mockQueryBuilder<PersonalExpense>([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.generateBills({
|
||||||
|
periodStart: '2026-06-29',
|
||||||
|
periodEnd: '2026-07-31',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
expect(qb.where).toHaveBeenCalledWith(
|
||||||
|
'e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd',
|
||||||
|
{ periodStart: '2026-06-29', periodEnd: '2026-07-31' },
|
||||||
|
);
|
||||||
|
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
|
||||||
|
expect(Number(savedCalls[0][0].sharedAmount)).toBeCloseTo(300, 0);
|
||||||
|
});
|
||||||
|
|
||||||
it('mixed → long-term get individual bills, short-term share expenses', async () => {
|
it('mixed → long-term get individual bills, short-term share expenses', async () => {
|
||||||
// Room 1: two expenses
|
// Room 1: two expenses
|
||||||
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, In, DataSource } from 'typeorm';
|
import { Repository, In, DataSource, EntityManager } from 'typeorm';
|
||||||
import { Bill } from '../entities/bill.entity';
|
import { Bill } from '../entities/bill.entity';
|
||||||
import { BillItem } from '../entities/bill-item.entity';
|
import { BillItem } from '../entities/bill-item.entity';
|
||||||
import { RoomExpense } from '../entities/room-expense.entity';
|
import { RoomExpense } from '../entities/room-expense.entity';
|
||||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||||
import { Occupancy } from '../entities/occupancy.entity';
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
import { Room } from '../entities/room.entity';
|
import { Room } from '../entities/room.entity';
|
||||||
import { Deposit } from '../entities/deposit.entity';
|
import { StudentWallet } from '../entities/student-wallet.entity';
|
||||||
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||||||
|
import { WalletsService } from '../wallets/wallets.service';
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -20,24 +21,34 @@ export class BillsService {
|
|||||||
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
|
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
|
||||||
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
|
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
|
||||||
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
||||||
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
|
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
|
private walletsService: WalletsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 核心计费引擎:按"人天数"加权分摊
|
* 核心计费引擎:按"人天数"加权分摊
|
||||||
*/
|
*/
|
||||||
async generateBills(dto: GenerateBillsDto) {
|
async generateBills(dto: GenerateBillsDto) {
|
||||||
const { periodStart, periodEnd } = dto;
|
const { periodStart, periodEnd } = dto.billingMonth
|
||||||
|
? this.resolveBillingPeriod(dto.billingMonth)
|
||||||
|
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
|
||||||
const pStart = new Date(periodStart);
|
const pStart = new Date(periodStart);
|
||||||
const pEnd = new Date(periodEnd);
|
const pEnd = new Date(periodEnd);
|
||||||
|
|
||||||
// 删除该周期已有的草稿账单
|
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
|
||||||
const existingDrafts = await this.billRepo.find({
|
if (existingBills.length > 0) {
|
||||||
where: { periodStart, periodEnd, status: 'draft' },
|
throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const existingDrafts: Bill[] = [];
|
||||||
if (existingDrafts.length > 0) {
|
if (existingDrafts.length > 0) {
|
||||||
const draftIds = existingDrafts.map((b) => b.id);
|
const draftIds = existingDrafts.map((b) => b.id);
|
||||||
|
await this.personalExpRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update()
|
||||||
|
.set({ billId: null })
|
||||||
|
.where('billId IN (:...ids)', { ids: draftIds })
|
||||||
|
.execute();
|
||||||
await this.itemRepo
|
await this.itemRepo
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.delete()
|
.delete()
|
||||||
@@ -53,7 +64,7 @@ export class BillsService {
|
|||||||
// 获取所有有费用的宿舍
|
// 获取所有有费用的宿舍
|
||||||
const roomExpenses = await this.roomExpRepo
|
const roomExpenses = await this.roomExpRepo
|
||||||
.createQueryBuilder('e')
|
.createQueryBuilder('e')
|
||||||
.where('e.periodStart = :periodStart AND e.periodEnd = :periodEnd', {
|
.where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', {
|
||||||
periodStart,
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
})
|
})
|
||||||
@@ -158,6 +169,7 @@ export class BillsService {
|
|||||||
periodStart,
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
})
|
})
|
||||||
|
.andWhere('pe.billId IS NULL')
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
const personalMap = new Map<number, number>();
|
const personalMap = new Map<number, number>();
|
||||||
@@ -186,29 +198,100 @@ export class BillsService {
|
|||||||
const personal = personalMap.get(studentId) || 0;
|
const personal = personalMap.get(studentId) || 0;
|
||||||
const total = Number((shared + personal).toFixed(2));
|
const total = Number((shared + personal).toFixed(2));
|
||||||
|
|
||||||
const bill = this.billRepo.create({
|
const savedBill = await this.dataSource.transaction(async (manager) => {
|
||||||
studentId,
|
let bill = await manager.save(
|
||||||
periodStart,
|
manager.create(Bill, {
|
||||||
periodEnd,
|
studentId,
|
||||||
sharedAmount: Number(shared.toFixed(2)),
|
periodStart,
|
||||||
personalAmount: personal,
|
periodEnd,
|
||||||
totalAmount: total,
|
sharedAmount: Number(shared.toFixed(2)),
|
||||||
status: 'draft',
|
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);
|
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?: {
|
async findAll(query?: {
|
||||||
@@ -240,46 +323,39 @@ export class BillsService {
|
|||||||
return withDeposit;
|
return withDeposit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 查询时附加钱包余额和实际支付数据。 */
|
||||||
* 给账单挂上"押金联动"信息:
|
|
||||||
* - availableDeposit: 当前学生处于已缴未退状态(paid)的押金总额
|
|
||||||
* - depositApplied: 本张账单可从押金抵扣的金额(min(押金, 应付总额))
|
|
||||||
* - amountAfterDeposit: 抵扣押金后学生需另外支付的金额
|
|
||||||
*/
|
|
||||||
private async attachDepositInfo(bills: Bill[]): Promise<any[]> {
|
private async attachDepositInfo(bills: Bill[]): Promise<any[]> {
|
||||||
if (!bills || bills.length === 0) return bills;
|
if (!bills?.length) return bills;
|
||||||
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
const studentIds = Array.from(new Set(bills.map((bill) => bill.studentId)));
|
||||||
if (studentIds.length === 0) return bills;
|
const wallets = await this.dataSource
|
||||||
const deposits = await this.depositRepo
|
.getRepository(StudentWallet)
|
||||||
.createQueryBuilder('d')
|
.createQueryBuilder('wallet')
|
||||||
.where('d.studentId IN (:...ids)', { ids: studentIds })
|
.where('wallet.studentId IN (:...ids)', { ids: studentIds })
|
||||||
.andWhere('d.status = :status', { status: 'paid' })
|
|
||||||
.getMany();
|
.getMany();
|
||||||
const depMap = new Map<number, number>();
|
const balanceMap = new Map(wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]));
|
||||||
for (const d of deposits) {
|
return bills.map((bill) => ({
|
||||||
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0));
|
...bill,
|
||||||
}
|
walletBalance: Number((balanceMap.get(bill.studentId) || 0).toFixed(2)),
|
||||||
return bills.map((b) => {
|
paidAmount: Number(bill.paidAmount || 0),
|
||||||
const total = Number(b.totalAmount || 0);
|
outstandingAmount: Number(bill.outstandingAmount || 0),
|
||||||
const available = Number((depMap.get(b.studentId) || 0).toFixed(2));
|
}));
|
||||||
const applied = Number(Math.min(available, total).toFixed(2));
|
|
||||||
const afterDeposit = Number(Math.max(0, total - applied).toFixed(2));
|
|
||||||
return Object.assign({}, b, {
|
|
||||||
availableDeposit: available,
|
|
||||||
depositApplied: applied,
|
|
||||||
amountAfterDeposit: afterDeposit,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateStatus(id: number, dto: UpdateBillStatusDto) {
|
async updateStatus(id: number, dto: UpdateBillStatusDto) {
|
||||||
const bill = await this.billRepo.findOne({ where: { id } });
|
const bill = await this.billRepo.findOne({ where: { id } });
|
||||||
if (!bill) throw new NotFoundException('账单不存在');
|
if (!bill) throw new NotFoundException('账单不存在');
|
||||||
|
if (dto.status === 'paid' && Number(bill.outstandingAmount) > 0) {
|
||||||
|
throw new BadRequestException('存在未付金额,不能直接标记为已支付');
|
||||||
|
}
|
||||||
bill.status = dto.status;
|
bill.status = dto.status;
|
||||||
return this.billRepo.save(bill);
|
return this.billRepo.save(bill);
|
||||||
}
|
}
|
||||||
|
|
||||||
async batchUpdateStatus(ids: number[], status: string) {
|
async batchUpdateStatus(ids: number[], status: string) {
|
||||||
|
const 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
|
await this.billRepo
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.update()
|
.update()
|
||||||
@@ -289,18 +365,38 @@ export class BillsService {
|
|||||||
return { message: `成功更新 ${ids.length} 条账单状态` };
|
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) {
|
async remove(id: number) {
|
||||||
const exists = await this.billRepo.findOne({ where: { id } });
|
const exists = await this.billRepo.findOne({ where: { id } });
|
||||||
if (!exists) throw new NotFoundException('账单不存在');
|
if (!exists) throw new NotFoundException('账单不存在');
|
||||||
|
if (Number(exists.paidAmount) > 0 || exists.status === 'cancelled') {
|
||||||
|
throw new BadRequestException('已发生资金流水的账单不能删除,请使用取消账单');
|
||||||
|
}
|
||||||
await this.itemRepo.delete({ billId: id });
|
await this.itemRepo.delete({ billId: id });
|
||||||
|
await this.personalExpRepo.update({ billId: id }, { billId: null });
|
||||||
await this.billRepo.delete(id);
|
await this.billRepo.delete(id);
|
||||||
return { message: '账单已删除' };
|
return { message: '账单已删除' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async batchRemove(ids: number[]) {
|
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()
|
.createQueryBuilder()
|
||||||
.delete()
|
.update()
|
||||||
|
.set({ billId: null })
|
||||||
.where('billId IN (:...ids)', { ids })
|
.where('billId IN (:...ids)', { ids })
|
||||||
.execute();
|
.execute();
|
||||||
await this.billRepo.createQueryBuilder().delete().where('id 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 {
|
export class GenerateBillsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
periodStart: string; // YYYY-MM-DD
|
@Matches(/^\d{4}-\d{2}$/)
|
||||||
|
billingMonth: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
periodEnd: string; // YYYY-MM-DD
|
periodStart?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
periodEnd?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateBillStatusDto {
|
export class UpdateBillStatusDto {
|
||||||
@IsString()
|
@IsIn(['unpaid', 'partially_paid', 'paid'])
|
||||||
status: 'draft' | 'confirmed' | 'paid';
|
status: 'unpaid' | 'partially_paid' | 'paid';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CancelBillDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(300)
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BatchUpdateBillStatusDto extends UpdateBillStatusDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
ids: number[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ClassesService } from './classes.service';
|
||||||
|
import { ClassStudent } from '../entities';
|
||||||
|
|
||||||
|
describe('ClassesService — DingTalk class import membership lifecycle', () => {
|
||||||
|
it('reactivates left memberships and skips active memberships', async () => {
|
||||||
|
const left = {
|
||||||
|
classId: 3,
|
||||||
|
studentId: 8,
|
||||||
|
status: 'left',
|
||||||
|
joinDate: '2026-01-01',
|
||||||
|
leaveDate: '2026-02-01',
|
||||||
|
} as ClassStudent;
|
||||||
|
const active = { classId: 3, studentId: 9, status: 'active' } as ClassStudent;
|
||||||
|
const classStudentRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([left, active]),
|
||||||
|
create: jest.fn().mockImplementation((value: Partial<ClassStudent>) => value),
|
||||||
|
save: jest.fn().mockImplementation(async (value: ClassStudent[]) => value),
|
||||||
|
};
|
||||||
|
const service = new ClassesService(
|
||||||
|
{ findOne: jest.fn().mockResolvedValue({ id: 3 }) } as never,
|
||||||
|
classStudentRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ create: jest.fn(), save: jest.fn() } as never,
|
||||||
|
{
|
||||||
|
find: jest.fn().mockResolvedValue([
|
||||||
|
{ dingUserId: 'd8', studentId: 8 },
|
||||||
|
{ dingUserId: 'd9', studentId: 9 },
|
||||||
|
]),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.batchImportStudents(3, [
|
||||||
|
{ dingUserId: 'd8', name: '学生8' },
|
||||||
|
{ dingUserId: 'd9', name: '学生9' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ imported: 1, skipped: 1 });
|
||||||
|
expect(left).toMatchObject({ status: 'active', leaveDate: null });
|
||||||
|
expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
expect(classStudentRepo.save).toHaveBeenCalledWith([left]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ import { extractRequestInfo } from '../common/request-utils';
|
|||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationType } from '../entities/notification.entity';
|
import { NotificationType } from '../entities/notification.entity';
|
||||||
|
import { TeacherRoleType } from '../entities';
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
import { AuthorizationService, CaslAction, SubjectName, AuthenticatedUser } from '../authorization';
|
import { AuthorizationService, CaslAction, SubjectName, AuthenticatedUser } from '../authorization';
|
||||||
|
|
||||||
@@ -36,6 +37,13 @@ interface AuthenticatedRequest {
|
|||||||
user: AuthenticatedUser;
|
user: AuthenticatedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const teacherRoleLabels: Record<string, string> = {
|
||||||
|
[TeacherRoleType.SUBJECT_TEACHER]: '任课老师',
|
||||||
|
[TeacherRoleType.HEAD_TEACHER]: '班主任',
|
||||||
|
[TeacherRoleType.LIFE_TEACHER]: '生活老师',
|
||||||
|
[TeacherRoleType.ACADEMIC_TEACHER]: '学服老师',
|
||||||
|
};
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('classes')
|
@Controller('classes')
|
||||||
export class ClassesController {
|
export class ClassesController {
|
||||||
@@ -302,7 +310,7 @@ export class ClassesController {
|
|||||||
recipientIds: [dto.userId],
|
recipientIds: [dto.userId],
|
||||||
type: NotificationType.CLASS_CHANGE,
|
type: NotificationType.CLASS_CHANGE,
|
||||||
title: '班级分配',
|
title: '班级分配',
|
||||||
content: `您已被分配到班级担任${dto.roleType}角色`,
|
content: `您已被分配到班级担任${teacherRoleLabels[dto.roleType] ?? dto.roleType}角色`,
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
95
apps/server/src/classes/classes.membership.spec.ts
Normal file
95
apps/server/src/classes/classes.membership.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ClassesService } from './classes.service';
|
||||||
|
import { ClassStudent } from '../entities';
|
||||||
|
|
||||||
|
function createService(
|
||||||
|
classStudentRepo: Record<string, jest.Mock>,
|
||||||
|
classRepo: Record<string, jest.Mock> = { findOne: jest.fn().mockResolvedValue({ id: 3 }) },
|
||||||
|
studentRepo: Record<string, jest.Mock> = {
|
||||||
|
find: jest.fn().mockResolvedValue([{ id: 8 }, { id: 9 }, { id: 10 }]),
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return new ClassesService(
|
||||||
|
classRepo as never,
|
||||||
|
classStudentRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
studentRepo as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ClassesService — student membership lifecycle', () => {
|
||||||
|
it('marks an active membership as left instead of deleting it', async () => {
|
||||||
|
const membership = {
|
||||||
|
classId: 3,
|
||||||
|
studentId: 8,
|
||||||
|
status: 'active',
|
||||||
|
leaveDate: null,
|
||||||
|
} as ClassStudent;
|
||||||
|
const repo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(membership),
|
||||||
|
save: jest.fn().mockImplementation(async (value: ClassStudent) => value),
|
||||||
|
};
|
||||||
|
const service = createService(repo);
|
||||||
|
|
||||||
|
await service.removeStudent(3, 8);
|
||||||
|
|
||||||
|
expect(membership.status).toBe('left');
|
||||||
|
expect(membership.leaveDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
expect(repo.save).toHaveBeenCalledWith(membership);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects removing an already-left membership', async () => {
|
||||||
|
const repo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ status: 'left' }),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
const service = createService(repo);
|
||||||
|
|
||||||
|
await expect(service.removeStudent(3, 8)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects removing a student without a membership', async () => {
|
||||||
|
const repo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
const service = createService(repo);
|
||||||
|
|
||||||
|
await expect(service.removeStudent(3, 8)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reactivates left memberships, creates new ones, and skips active ones', async () => {
|
||||||
|
const left = {
|
||||||
|
id: 1,
|
||||||
|
classId: 3,
|
||||||
|
studentId: 8,
|
||||||
|
status: 'left',
|
||||||
|
joinDate: '2026-01-01',
|
||||||
|
leaveDate: '2026-02-01',
|
||||||
|
} as ClassStudent;
|
||||||
|
const active = { id: 2, classId: 3, studentId: 9, status: 'active' } as ClassStudent;
|
||||||
|
const repo = {
|
||||||
|
find: jest.fn().mockResolvedValue([left, active]),
|
||||||
|
create: jest.fn().mockImplementation((value: Partial<ClassStudent>) => value),
|
||||||
|
save: jest.fn().mockImplementation(async (value: ClassStudent[]) => value),
|
||||||
|
};
|
||||||
|
const service = createService(repo);
|
||||||
|
|
||||||
|
const result = await service.addStudents(3, [8, 9, 10, 10]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ added: 2, skipped: 1 });
|
||||||
|
expect(left).toMatchObject({ status: 'active', leaveDate: null });
|
||||||
|
expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
expect(repo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ classId: 3, studentId: 10, status: 'active' }),
|
||||||
|
);
|
||||||
|
expect(repo.save).toHaveBeenCalledWith(
|
||||||
|
expect.arrayContaining([left, expect.objectContaining({ studentId: 10 })]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -227,34 +227,45 @@ export class ClassesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fetch existing class-student links in one query
|
// 3. Fetch existing class-student links in one query
|
||||||
const allStudentIds = Array.from(dingToStudentId.values());
|
const allStudentIds = Array.from(new Set(dingToStudentId.values()));
|
||||||
const alreadyInClass = new Set<number>();
|
const existingClassStudents =
|
||||||
if (allStudentIds.length > 0) {
|
allStudentIds.length > 0
|
||||||
const existingClassStudents = await this.classStudentRepo.find({
|
? await this.classStudentRepo.find({
|
||||||
where: { classId, studentId: In(allStudentIds) },
|
where: { classId, studentId: In(allStudentIds) },
|
||||||
});
|
})
|
||||||
for (const cs of existingClassStudents) {
|
: [];
|
||||||
alreadyInClass.add(cs.studentId);
|
const existingByStudentId = new Map(
|
||||||
|
existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]),
|
||||||
|
);
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
let skipped = 0;
|
||||||
|
const memberships = allStudentIds.flatMap((studentId) => {
|
||||||
|
const existing = existingByStudentId.get(studentId);
|
||||||
|
if (existing?.status === 'active') {
|
||||||
|
skipped++;
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
if (existing) {
|
||||||
|
existing.status = 'active';
|
||||||
// 4. Batch insert new class-student records
|
existing.joinDate = today;
|
||||||
const newClassStudents = allStudentIds
|
existing.leaveDate = null;
|
||||||
.filter((sid) => !alreadyInClass.has(sid))
|
return [existing];
|
||||||
.map((studentId) =>
|
}
|
||||||
|
return [
|
||||||
this.classStudentRepo.create({
|
this.classStudentRepo.create({
|
||||||
classId,
|
classId,
|
||||||
studentId,
|
studentId,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
joinDate: new Date().toISOString().slice(0, 10),
|
joinDate: today,
|
||||||
}),
|
}),
|
||||||
);
|
];
|
||||||
|
});
|
||||||
|
|
||||||
if (newClassStudents.length > 0) {
|
if (memberships.length > 0) {
|
||||||
await this.classStudentRepo.save(newClassStudents);
|
await this.classStudentRepo.save(memberships);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { imported: newClassStudents.length, skipped: alreadyInClass.size };
|
return { imported: memberships.length, skipped };
|
||||||
}
|
}
|
||||||
async update(id: number, dto: UpdateClassDto) {
|
async update(id: number, dto: UpdateClassDto) {
|
||||||
const cls = await this.classRepo.findOne({ where: { id } });
|
const cls = await this.classRepo.findOne({ where: { id } });
|
||||||
@@ -315,26 +326,61 @@ export class ClassesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async addStudents(classId: number, studentIds: number[]) {
|
async addStudents(classId: number, studentIds: number[]) {
|
||||||
|
const uniqueStudentIds = [...new Set(studentIds)];
|
||||||
|
if (uniqueStudentIds.length === 0) return { added: 0, skipped: 0 };
|
||||||
|
|
||||||
|
const cls = await this.classRepo.findOne({ where: { id: classId } });
|
||||||
|
if (!cls) throw new NotFoundException('班级不存在');
|
||||||
|
|
||||||
|
const students = await this.studentRepo.find({ where: { id: In(uniqueStudentIds) } });
|
||||||
|
if (students.length !== uniqueStudentIds.length) {
|
||||||
|
throw new NotFoundException('部分学生不存在');
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await this.classStudentRepo.find({
|
const existing = await this.classStudentRepo.find({
|
||||||
where: { classId, studentId: In(studentIds) },
|
where: { classId, studentId: In(uniqueStudentIds) },
|
||||||
});
|
});
|
||||||
const existingIds = new Set(existing.map((e) => e.studentId));
|
const existingByStudentId = new Map(
|
||||||
const newIds = studentIds.filter((id) => !existingIds.has(id));
|
existing.map((classStudent) => [classStudent.studentId, classStudent]),
|
||||||
|
|
||||||
const entries = newIds.map((sid) =>
|
|
||||||
this.classStudentRepo.create({
|
|
||||||
classId,
|
|
||||||
studentId: sid,
|
|
||||||
joinDate: new Date().toISOString().split('T')[0],
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
if (entries.length) await this.classStudentRepo.save(entries);
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
let skipped = 0;
|
||||||
|
const memberships = uniqueStudentIds.flatMap((studentId) => {
|
||||||
|
const current = existingByStudentId.get(studentId);
|
||||||
|
if (current?.status === 'active') {
|
||||||
|
skipped++;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
current.status = 'active';
|
||||||
|
current.joinDate = today;
|
||||||
|
current.leaveDate = null;
|
||||||
|
return [current];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
this.classStudentRepo.create({
|
||||||
|
classId,
|
||||||
|
studentId,
|
||||||
|
status: 'active',
|
||||||
|
joinDate: today,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
if (memberships.length) await this.classStudentRepo.save(memberships);
|
||||||
|
|
||||||
return { added: entries.length, skipped: studentIds.length - entries.length };
|
return { added: memberships.length, skipped };
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeStudent(classId: number, studentId: number) {
|
async removeStudent(classId: number, studentId: number) {
|
||||||
await this.classStudentRepo.delete({ classId, studentId });
|
const membership = await this.classStudentRepo.findOne({
|
||||||
|
where: { classId, studentId },
|
||||||
|
});
|
||||||
|
if (!membership) throw new NotFoundException('学生不在该班级');
|
||||||
|
if (membership.status !== 'active') throw new BadRequestException('学生已离班');
|
||||||
|
|
||||||
|
membership.status = 'left';
|
||||||
|
membership.leaveDate = new Date().toISOString().split('T')[0];
|
||||||
|
await this.classStudentRepo.save(membership);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,42 @@ export class ClassroomRentalsController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id/cancel')
|
||||||
|
@RequirePermission('rental:edit')
|
||||||
|
async cancel(@Param('id') id: string, @Request() req: any) {
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
const result = await this.service.cancel(+id);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '教室租赁',
|
||||||
|
action: '取消租赁',
|
||||||
|
targetId: +id,
|
||||||
|
targetType: 'classroom-rental',
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/end')
|
||||||
|
@RequirePermission('rental:edit')
|
||||||
|
async end(@Param('id') id: string, @Request() req: any) {
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
const result = await this.service.end(+id);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '教室租赁',
|
||||||
|
action: '结束租赁',
|
||||||
|
targetId: +id,
|
||||||
|
targetType: 'classroom-rental',
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('rental:delete')
|
@RequirePermission('rental:delete')
|
||||||
async remove(@Param('id') id: string, @Request() req: any) {
|
async remove(@Param('id') id: string, @Request() req: any) {
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
startDate: '2026-03-01',
|
startDate: '2026-03-01',
|
||||||
endDate: '2026-03-31',
|
endDate: '2026-03-31',
|
||||||
};
|
};
|
||||||
const classroom = { id: 1, departmentId: 10 } as Classroom;
|
const classroom = { id: 1, status: 'available' } as Classroom;
|
||||||
const hostOrganization = {
|
const hostOrganization = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Host',
|
name: 'Host',
|
||||||
@@ -315,8 +315,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
classroomId: 1,
|
classroomId: 1,
|
||||||
lesseeOrganizationId: 2,
|
lesseeOrganizationId: 2,
|
||||||
startDate: '2026-03-01',
|
startDate: '2026-07-01',
|
||||||
endDate: '2026-03-31',
|
endDate: '2099-03-31',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
notes: '',
|
notes: '',
|
||||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||||
@@ -324,8 +324,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
} as ClassroomRental;
|
} as ClassroomRental;
|
||||||
const updatedRental = {
|
const updatedRental = {
|
||||||
...existingRental,
|
...existingRental,
|
||||||
startDate: '2026-04-01',
|
startDate: '2026-08-01',
|
||||||
endDate: '2026-04-30',
|
endDate: '2099-04-30',
|
||||||
};
|
};
|
||||||
const existingSchedule = {
|
const existingSchedule = {
|
||||||
id: 50,
|
id: 50,
|
||||||
@@ -339,12 +339,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
|
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
|
||||||
scheduleRepo.findOne.mockResolvedValue(existingSchedule);
|
scheduleRepo.findOne.mockResolvedValue(existingSchedule);
|
||||||
|
|
||||||
const dto: UpdateRentalDto = { startDate: '2026-04-01', endDate: '2026-04-30' };
|
const dto: UpdateRentalDto = { startDate: '2026-08-01', endDate: '2099-04-30' };
|
||||||
await service.update(1, dto);
|
await service.update(1, dto);
|
||||||
|
|
||||||
expect(rentalRepo.update).toHaveBeenCalledWith(
|
expect(rentalRepo.update).toHaveBeenCalledWith(
|
||||||
1,
|
1,
|
||||||
expect.objectContaining({ startDate: '2026-04-01', endDate: '2026-04-30' }),
|
expect.objectContaining({ startDate: '2026-08-01', endDate: '2099-04-30' }),
|
||||||
);
|
);
|
||||||
expect(scheduleRepo.update).toHaveBeenCalledWith(
|
expect(scheduleRepo.update).toHaveBeenCalledWith(
|
||||||
50,
|
50,
|
||||||
@@ -352,8 +352,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
scheduleType: 'RENTAL',
|
scheduleType: 'RENTAL',
|
||||||
rentalId: 1,
|
rentalId: 1,
|
||||||
classroomId: 1,
|
classroomId: 1,
|
||||||
startDate: '2026-04-01',
|
startDate: '2026-08-01',
|
||||||
endDate: '2026-04-30',
|
endDate: '2099-04-30',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
subject: 'Organization A 租赁',
|
subject: 'Organization A 租赁',
|
||||||
}),
|
}),
|
||||||
@@ -362,27 +362,59 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
expect(scheduleRepo.delete).not.toHaveBeenCalled();
|
expect(scheduleRepo.delete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes the RENTAL schedule row when status changes to cancelled', async () => {
|
it('deletes the RENTAL schedule row when the rental is cancelled', async () => {
|
||||||
const rental = {
|
const rental = {
|
||||||
id: 1,
|
id: 1,
|
||||||
classroomId: 1,
|
classroomId: 1,
|
||||||
lesseeOrganizationId: 2,
|
lesseeOrganizationId: 2,
|
||||||
startDate: '2026-03-01',
|
startDate: '2026-07-01',
|
||||||
endDate: '2026-03-31',
|
endDate: '2099-03-31',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||||
} as ClassroomRental;
|
} as ClassroomRental;
|
||||||
const cancelledRental = { ...rental, status: 'cancelled' };
|
const cancelledRental = { ...rental, status: 'cancelled' } as ClassroomRental;
|
||||||
|
|
||||||
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(cancelledRental);
|
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(cancelledRental);
|
||||||
|
|
||||||
await service.update(1, { status: 'cancelled' });
|
await service.cancel(1);
|
||||||
|
|
||||||
expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' });
|
expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' });
|
||||||
expect(scheduleRepo.delete).toHaveBeenCalledWith({ rentalId: 1, scheduleType: 'RENTAL' });
|
expect(scheduleRepo.delete).toHaveBeenCalledWith({ rentalId: 1, scheduleType: 'RENTAL' });
|
||||||
expect(scheduleRepo.findOne).not.toHaveBeenCalled();
|
});
|
||||||
expect(scheduleRepo.update).not.toHaveBeenCalled();
|
});
|
||||||
expect(scheduleRepo.create).not.toHaveBeenCalled();
|
|
||||||
|
describe('lifecycle actions', () => {
|
||||||
|
it('ends an active rental and shortens a future end date', async () => {
|
||||||
|
const rental = {
|
||||||
|
id: 1,
|
||||||
|
classroomId: 1,
|
||||||
|
startDate: '2026-07-01',
|
||||||
|
endDate: '2099-12-31',
|
||||||
|
status: 'active',
|
||||||
|
lesseeOrganization: { name: 'Organization A' },
|
||||||
|
} as ClassroomRental;
|
||||||
|
const ended = { ...rental, status: 'ended', endDate: '2026-07-13' } as ClassroomRental;
|
||||||
|
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(ended);
|
||||||
|
scheduleRepo.findOne.mockResolvedValue({ id: 50 } as ClassSchedule);
|
||||||
|
|
||||||
|
await service.end(1);
|
||||||
|
|
||||||
|
expect(rentalRepo.update).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({ status: 'ended' }),
|
||||||
|
);
|
||||||
|
expect(scheduleRepo.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ending a future rental', async () => {
|
||||||
|
rentalRepo.findOne.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
startDate: '2099-01-01',
|
||||||
|
endDate: '2099-12-31',
|
||||||
|
status: 'active',
|
||||||
|
} as ClassroomRental);
|
||||||
|
|
||||||
|
await expect(service.end(1)).rejects.toThrow('租赁尚未开始');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -394,7 +426,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
lesseeOrganizationId: 2,
|
lesseeOrganizationId: 2,
|
||||||
startDate: '2026-03-01',
|
startDate: '2026-03-01',
|
||||||
endDate: '2026-03-31',
|
endDate: '2026-03-31',
|
||||||
status: 'active',
|
status: 'cancelled',
|
||||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||||
} as ClassroomRental;
|
} as ClassroomRental;
|
||||||
|
|
||||||
@@ -419,7 +451,7 @@ describe('ClassroomRentalsService — organization roles', () => {
|
|||||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassroomRental>([])),
|
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassroomRental>([])),
|
||||||
} as any;
|
} as any;
|
||||||
const classroomRepo = {
|
const classroomRepo = {
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 1, departmentId: 10 }),
|
findOne: jest.fn().mockResolvedValue({ id: 1, status: 'available' }),
|
||||||
} as any;
|
} as any;
|
||||||
const organizationRepo = {
|
const organizationRepo = {
|
||||||
findOne: jest
|
findOne: jest
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
|
||||||
import { Classroom } from '../entities/classroom.entity';
|
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
|
||||||
import { Organization } from '../entities/organization.entity';
|
import { Organization } from '../entities/organization.entity';
|
||||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||||
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
|
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
|
||||||
@@ -70,8 +70,11 @@ export class ClassroomRentalsService {
|
|||||||
const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||||
qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last });
|
qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last });
|
||||||
}
|
}
|
||||||
if (!query?.includeEnded) qb.andWhere('r.status != :cancelled', { cancelled: 'cancelled' });
|
if (!query?.includeEnded) {
|
||||||
return qb.getMany();
|
qb.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE });
|
||||||
|
}
|
||||||
|
const rentals = await qb.getMany();
|
||||||
|
return rentals.map((rental) => this.withEffectiveStatus(rental));
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: number) {
|
async findOne(id: number) {
|
||||||
@@ -80,7 +83,7 @@ export class ClassroomRentalsService {
|
|||||||
relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'],
|
relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'],
|
||||||
});
|
});
|
||||||
if (!rental) throw new NotFoundException('租赁订单不存在');
|
if (!rental) throw new NotFoundException('租赁订单不存在');
|
||||||
return rental;
|
return this.withEffectiveStatus(rental);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
|
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
|
||||||
@@ -93,7 +96,7 @@ export class ClassroomRentalsService {
|
|||||||
where: {
|
where: {
|
||||||
...(excludeId ? { id: Not(excludeId) } : {}),
|
...(excludeId ? { id: Not(excludeId) } : {}),
|
||||||
classroomId,
|
classroomId,
|
||||||
status: Not('cancelled'),
|
status: ClassroomRentalStatus.ACTIVE,
|
||||||
startDate: LessThanOrEqual(monthEnd),
|
startDate: LessThanOrEqual(monthEnd),
|
||||||
endDate: MoreThanOrEqual(monthStart),
|
endDate: MoreThanOrEqual(monthStart),
|
||||||
},
|
},
|
||||||
@@ -101,7 +104,7 @@ export class ClassroomRentalsService {
|
|||||||
this.scheduleRepo.find({
|
this.scheduleRepo.find({
|
||||||
where: {
|
where: {
|
||||||
classroomId,
|
classroomId,
|
||||||
status: 'active',
|
status: ClassroomRentalStatus.ACTIVE,
|
||||||
scheduleType: 'INTERNAL',
|
scheduleType: 'INTERNAL',
|
||||||
startDate: LessThanOrEqual(monthEnd),
|
startDate: LessThanOrEqual(monthEnd),
|
||||||
endDate: MoreThanOrEqual(monthStart),
|
endDate: MoreThanOrEqual(monthStart),
|
||||||
@@ -133,7 +136,7 @@ export class ClassroomRentalsService {
|
|||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
||||||
.where('r.classroomId = :cid', { cid: classroomId })
|
.where('r.classroomId = :cid', { cid: classroomId })
|
||||||
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
|
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
|
||||||
.andWhere('r.startDate <= :end', { end: endDate })
|
.andWhere('r.startDate <= :end', { end: endDate })
|
||||||
.andWhere('r.endDate >= :start', { start: startDate });
|
.andWhere('r.endDate >= :start', { start: startDate });
|
||||||
if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId });
|
if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId });
|
||||||
@@ -222,6 +225,9 @@ export class ClassroomRentalsService {
|
|||||||
if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期');
|
if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期');
|
||||||
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
||||||
if (!classroom) throw new NotFoundException('教室不存在');
|
if (!classroom) throw new NotFoundException('教室不存在');
|
||||||
|
if (classroom.status !== ClassroomStatus.AVAILABLE) {
|
||||||
|
throw new BadRequestException('仅可用教室可以创建租赁');
|
||||||
|
}
|
||||||
const lessorOrganization = dto.lessorOrganizationId
|
const lessorOrganization = dto.lessorOrganizationId
|
||||||
? await this.organizationRepo.findOne({
|
? await this.organizationRepo.findOne({
|
||||||
where: { id: dto.lessorOrganizationId, status: 'active' },
|
where: { id: dto.lessorOrganizationId, status: 'active' },
|
||||||
@@ -253,7 +259,7 @@ export class ClassroomRentalsService {
|
|||||||
lessorOrganizationId: lessorOrganization.id,
|
lessorOrganizationId: lessorOrganization.id,
|
||||||
lesseeOrganizationId: lesseeOrganization.id,
|
lesseeOrganizationId: lesseeOrganization.id,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
status: 'active',
|
status: ClassroomRentalStatus.ACTIVE,
|
||||||
});
|
});
|
||||||
const saved = await this.repo.save(rental);
|
const saved = await this.repo.save(rental);
|
||||||
await this.syncScheduleFromRental(saved, lesseeOrganization.name);
|
await this.syncScheduleFromRental(saved, lesseeOrganization.name);
|
||||||
@@ -262,11 +268,21 @@ export class ClassroomRentalsService {
|
|||||||
|
|
||||||
async update(id: number, dto: UpdateRentalDto) {
|
async update(id: number, dto: UpdateRentalDto) {
|
||||||
const rental = await this.findOne(id);
|
const rental = await this.findOne(id);
|
||||||
|
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
|
||||||
|
throw new BadRequestException('已结束或已取消的租赁不能编辑');
|
||||||
|
}
|
||||||
// 若修改了教室/日期,重新冲突检查
|
// 若修改了教室/日期,重新冲突检查
|
||||||
const newClassroomId = dto.classroomId ?? rental.classroomId;
|
const newClassroomId = dto.classroomId ?? rental.classroomId;
|
||||||
const newStart = dto.startDate ?? rental.startDate;
|
const newStart = dto.startDate ?? rental.startDate;
|
||||||
const newEnd = dto.endDate ?? rental.endDate;
|
const newEnd = dto.endDate ?? rental.endDate;
|
||||||
if (newStart > newEnd) throw new BadRequestException('起始日期不能晚于结束日期');
|
if (newStart > newEnd) throw new BadRequestException('起始日期不能晚于结束日期');
|
||||||
|
if (dto.classroomId && dto.classroomId !== rental.classroomId) {
|
||||||
|
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
||||||
|
if (!classroom) throw new NotFoundException('教室不存在');
|
||||||
|
if (classroom.status !== ClassroomStatus.AVAILABLE) {
|
||||||
|
throw new BadRequestException('仅可用教室可以承接租赁');
|
||||||
|
}
|
||||||
|
}
|
||||||
if (dto.classroomId || dto.startDate || dto.endDate) {
|
if (dto.classroomId || dto.startDate || dto.endDate) {
|
||||||
const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id);
|
const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id);
|
||||||
if (conflicts.length > 0) {
|
if (conflicts.length > 0) {
|
||||||
@@ -300,16 +316,46 @@ export class ClassroomRentalsService {
|
|||||||
}
|
}
|
||||||
await this.repo.update(id, dto);
|
await this.repo.update(id, dto);
|
||||||
const updated = await this.findOne(id);
|
const updated = await this.findOne(id);
|
||||||
if (dto.status === 'cancelled') {
|
await this.syncScheduleFromRental(updated);
|
||||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
|
||||||
} else {
|
|
||||||
await this.syncScheduleFromRental(updated);
|
|
||||||
}
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancel(id: number) {
|
||||||
|
const rental = await this.findOne(id);
|
||||||
|
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
|
||||||
|
throw new BadRequestException('仅有效租赁可以取消');
|
||||||
|
}
|
||||||
|
await this.repo.update(id, { status: ClassroomRentalStatus.CANCELLED });
|
||||||
|
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||||
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async end(id: number) {
|
||||||
|
const rental = await this.findOne(id);
|
||||||
|
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
|
||||||
|
throw new BadRequestException('仅有效租赁可以结束');
|
||||||
|
}
|
||||||
|
const today = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(new Date());
|
||||||
|
if (rental.startDate > today) throw new BadRequestException('租赁尚未开始,不能结束');
|
||||||
|
await this.repo.update(id, {
|
||||||
|
status: ClassroomRentalStatus.ENDED,
|
||||||
|
endDate: rental.endDate > today ? today : rental.endDate,
|
||||||
|
});
|
||||||
|
const ended = await this.findOne(id);
|
||||||
|
await this.syncScheduleFromRental(ended);
|
||||||
|
return ended;
|
||||||
|
}
|
||||||
|
|
||||||
async remove(id: number) {
|
async remove(id: number) {
|
||||||
const rental = await this.findOne(id);
|
const rental = await this.findOne(id);
|
||||||
|
if (rental.effectiveStatus === ClassroomRentalStatus.ACTIVE) {
|
||||||
|
throw new BadRequestException('进行中的租赁请先取消或结束');
|
||||||
|
}
|
||||||
// 同步删除对应排课记录
|
// 同步删除对应排课记录
|
||||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||||
// 同时删除合同文件
|
// 同时删除合同文件
|
||||||
@@ -327,6 +373,20 @@ export class ClassroomRentalsService {
|
|||||||
return { message: '删除成功' };
|
return { message: '删除成功' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private withEffectiveStatus(rental: ClassroomRental) {
|
||||||
|
const today = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(new Date());
|
||||||
|
const effectiveStatus =
|
||||||
|
rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today
|
||||||
|
? ClassroomRentalStatus.ENDED
|
||||||
|
: rental.status;
|
||||||
|
return Object.assign(rental, { effectiveStatus });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步租赁订单到 class_schedules(schedule_type = 'RENTAL')
|
* 同步租赁订单到 class_schedules(schedule_type = 'RENTAL')
|
||||||
*/
|
*/
|
||||||
@@ -348,7 +408,7 @@ export class ClassroomRentalsService {
|
|||||||
teacherId: null,
|
teacherId: null,
|
||||||
scheduleType: 'RENTAL',
|
scheduleType: 'RENTAL',
|
||||||
rentalId: rental.id,
|
rentalId: rental.id,
|
||||||
status: 'active',
|
status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active',
|
||||||
notes: rental.notes,
|
notes: rental.notes,
|
||||||
};
|
};
|
||||||
if (schedule) {
|
if (schedule) {
|
||||||
@@ -437,14 +497,16 @@ export class ClassroomRentalsService {
|
|||||||
const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||||
|
|
||||||
const classrooms = await this.classroomRepo.find({
|
const classrooms = await this.classroomRepo.find({
|
||||||
where: { status: Not('archived') },
|
where: { status: Not(ClassroomStatus.ARCHIVED) },
|
||||||
order: { building: 'ASC', name: 'ASC' },
|
order: { building: 'ASC', name: 'ASC' },
|
||||||
});
|
});
|
||||||
const rentals = await this.repo
|
const rentals = await this.repo
|
||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
||||||
.leftJoinAndSelect('r.classroom', 'classroom')
|
.leftJoinAndSelect('r.classroom', 'classroom')
|
||||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
.where('r.status IN (:...statuses)', {
|
||||||
|
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
|
||||||
|
})
|
||||||
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
|
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsOptional, IsString, IsInt, IsNumber, IsEnum, IsDateString } from 'class-validator';
|
import { IsOptional, IsString, IsInt, IsNumber, IsDateString } from 'class-validator';
|
||||||
|
|
||||||
export class CreateRentalDto {
|
export class CreateRentalDto {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -62,8 +62,4 @@ export class UpdateRentalDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(['active', 'ended', 'cancelled'])
|
|
||||||
status?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, Not } from 'typeorm';
|
import { Repository, Not, MoreThanOrEqual } from 'typeorm';
|
||||||
import { Classroom } from '../entities/classroom.entity';
|
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
|
||||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
|
||||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||||
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
|
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClassroomsService {
|
export class ClassroomsService {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Classroom) private repo: Repository<Classroom>,
|
@InjectRepository(Classroom) private repo: Repository<Classroom>,
|
||||||
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
|
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
|
||||||
@@ -21,49 +20,148 @@ export class ClassroomsService {
|
|||||||
if (query?.roomType) where.roomType = query.roomType;
|
if (query?.roomType) where.roomType = query.roomType;
|
||||||
if (!query?.includeArchived) where.status = Not('archived');
|
if (!query?.includeArchived) where.status = Not('archived');
|
||||||
const list = await this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } });
|
const list = await this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } });
|
||||||
const usageMap = await this.getCurrentUsageForClassrooms(list.map((c) => c.id));
|
const usageMap = await this.getUsageForClassrooms(list.map((c) => c.id));
|
||||||
return list.map((c) => ({ ...c, currentUsage: usageMap.get(c.id) ?? null }));
|
return list.map((classroom) => this.withEffectiveStatus(classroom, usageMap.get(classroom.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: number) {
|
async findOne(id: number) {
|
||||||
const cls = await this.repo.findOne({ where: { id } });
|
const cls = await this.repo.findOne({ where: { id } });
|
||||||
if (!cls) throw new NotFoundException('教室不存在');
|
if (!cls) throw new NotFoundException('教室不存在');
|
||||||
const usageMap = await this.getCurrentUsageForClassrooms([id]);
|
const usageMap = await this.getUsageForClassrooms([id]);
|
||||||
return { ...cls, currentUsage: usageMap.get(id) ?? null };
|
return this.withEffectiveStatus(cls, usageMap.get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateClassroomDto) {
|
async create(dto: CreateClassroomDto) {
|
||||||
const exists = await this.repo.findOne({ where: { name: dto.name } });
|
const exists = await this.repo.findOne({ where: { name: dto.name } });
|
||||||
if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
|
if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
|
||||||
return this.repo.save(this.repo.create(dto));
|
return this.repo.save(this.repo.create({ ...dto, status: ClassroomStatus.AVAILABLE }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, dto: UpdateClassroomDto) {
|
async update(id: number, dto: UpdateClassroomDto) {
|
||||||
await this.findOne(id);
|
const classroom = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!classroom) throw new NotFoundException('教室不存在');
|
||||||
|
if (dto.status === ClassroomStatus.MAINTENANCE && classroom.status !== dto.status) {
|
||||||
|
await this.assertNoActiveAllocations(id);
|
||||||
|
}
|
||||||
await this.repo.update(id, dto);
|
await this.repo.update(id, dto);
|
||||||
return this.repo.findOne({ where: { id } });
|
return this.repo.findOne({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number) {
|
async remove(id: number) {
|
||||||
await this.findOne(id);
|
const classroom = await this.repo.findOne({ where: { id } });
|
||||||
await this.repo.update(id, { status: 'archived' });
|
if (!classroom) throw new NotFoundException('教室不存在');
|
||||||
|
await this.assertNoActiveAllocations(id);
|
||||||
|
await this.repo.update(id, { status: ClassroomStatus.ARCHIVED });
|
||||||
return { message: '已归档' };
|
return { message: '已归档' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async restore(id: number) {
|
async restore(id: number) {
|
||||||
await this.findOne(id);
|
const classroom = await this.repo.findOne({ where: { id } });
|
||||||
await this.repo.update(id, { status: 'reserved' });
|
if (!classroom) throw new NotFoundException('教室不存在');
|
||||||
|
await this.repo.update(id, { status: ClassroomStatus.AVAILABLE });
|
||||||
return this.repo.findOne({ where: { id } });
|
return this.repo.findOne({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getCurrentUsageForClassrooms(classroomIds: number[]): Promise<Map<number, { type: 'schedule' | 'rental'; title: string; startTime: string; endTime: string }>> {
|
private withEffectiveStatus(
|
||||||
const result = new Map<number, { type: 'schedule' | 'rental'; title: string; startTime: string; endTime: string }>();
|
classroom: Classroom,
|
||||||
|
usage?: {
|
||||||
|
state: 'in_use' | 'reserved';
|
||||||
|
currentUsage: {
|
||||||
|
type: 'schedule' | 'rental';
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
} | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const effectiveStatus =
|
||||||
|
classroom.status === ClassroomStatus.ARCHIVED ||
|
||||||
|
classroom.status === ClassroomStatus.MAINTENANCE
|
||||||
|
? classroom.status
|
||||||
|
: (usage?.state ?? ClassroomStatus.AVAILABLE);
|
||||||
|
return { ...classroom, currentUsage: usage?.currentUsage ?? null, effectiveStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertNoActiveAllocations(classroomId: number) {
|
||||||
|
const today = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(new Date());
|
||||||
|
const scheduleCount = await this.scheduleRepo
|
||||||
|
.createQueryBuilder('schedule')
|
||||||
|
.where('schedule.classroomId = :classroomId', { classroomId })
|
||||||
|
.andWhere('schedule.status = :active', { active: 'active' })
|
||||||
|
.andWhere('schedule.endDate >= :today', { today })
|
||||||
|
.getCount();
|
||||||
|
const rentalCount = await this.rentalRepo.count({
|
||||||
|
where: {
|
||||||
|
classroomId,
|
||||||
|
status: ClassroomRentalStatus.ACTIVE,
|
||||||
|
endDate: MoreThanOrEqual(today),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (rentalCount > 0 || scheduleCount > 0) {
|
||||||
|
throw new BadRequestException('该教室存在有效排课或租赁,无法维护或归档');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUsageForClassrooms(classroomIds: number[]): Promise<
|
||||||
|
Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
state: 'in_use' | 'reserved';
|
||||||
|
currentUsage: {
|
||||||
|
type: 'schedule' | 'rental';
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
const result = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
state: 'in_use' | 'reserved';
|
||||||
|
currentUsage: {
|
||||||
|
type: 'schedule' | 'rental';
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
>();
|
||||||
if (classroomIds.length === 0) return result;
|
if (classroomIds.length === 0) return result;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStr = now.toISOString().slice(0, 10);
|
const todayStr = new Intl.DateTimeFormat('en-CA', {
|
||||||
const currentTime = now.toTimeString().slice(0, 5);
|
timeZone: 'Asia/Shanghai',
|
||||||
const weekDay = now.getDay() || 7;
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(now);
|
||||||
|
const currentTime = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(now);
|
||||||
|
const shanghaiParts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
weekday: 'short',
|
||||||
|
}).format(now);
|
||||||
|
const weekDayMap: Record<string, number> = {
|
||||||
|
Mon: 1,
|
||||||
|
Tue: 2,
|
||||||
|
Wed: 3,
|
||||||
|
Thu: 4,
|
||||||
|
Fri: 5,
|
||||||
|
Sat: 6,
|
||||||
|
Sun: 7,
|
||||||
|
};
|
||||||
|
const weekDay = weekDayMap[shanghaiParts];
|
||||||
|
|
||||||
const schedules = await this.scheduleRepo
|
const schedules = await this.scheduleRepo
|
||||||
.createQueryBuilder('s')
|
.createQueryBuilder('s')
|
||||||
@@ -71,26 +169,37 @@ export class ClassroomsService {
|
|||||||
.select('s.classroomId', 'classroomId')
|
.select('s.classroomId', 'classroomId')
|
||||||
.addSelect('s.startTime', 'startTime')
|
.addSelect('s.startTime', 'startTime')
|
||||||
.addSelect('s.endTime', 'endTime')
|
.addSelect('s.endTime', 'endTime')
|
||||||
|
.addSelect('s.startDate', 'startDate')
|
||||||
|
.addSelect('s.endDate', 'endDate')
|
||||||
|
.addSelect('s.weekDay', 'weekDay')
|
||||||
.addSelect('s.subject', 'subject')
|
.addSelect('s.subject', 'subject')
|
||||||
.addSelect('c.name', 'className')
|
.addSelect('c.name', 'className')
|
||||||
.where('s.classroomId IN (:...ids)', { ids: classroomIds })
|
.where('s.classroomId IN (:...ids)', { ids: classroomIds })
|
||||||
.andWhere('s.status = :active', { active: 'active' })
|
.andWhere('s.status = :active', { active: 'active' })
|
||||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||||
.andWhere('s.startDate <= :today', { today: todayStr })
|
|
||||||
.andWhere('s.endDate >= :today', { today: todayStr })
|
.andWhere('s.endDate >= :today', { today: todayStr })
|
||||||
.andWhere('s.weekDay = :weekDay', { weekDay })
|
|
||||||
.andWhere('s.startTime <= :currentTime', { currentTime })
|
|
||||||
.andWhere('s.endTime >= :currentTime', { currentTime })
|
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
for (const s of schedules) {
|
for (const schedule of schedules) {
|
||||||
const classroomId = Number(s.classroomId);
|
const classroomId = Number(schedule.classroomId);
|
||||||
if (!result.has(classroomId)) {
|
const isCurrent =
|
||||||
|
String(schedule.startDate) <= todayStr &&
|
||||||
|
String(schedule.endDate) >= todayStr &&
|
||||||
|
Number(schedule.weekDay) === weekDay &&
|
||||||
|
String(schedule.startTime) <= currentTime &&
|
||||||
|
String(schedule.endTime) >= currentTime;
|
||||||
|
const existing = result.get(classroomId);
|
||||||
|
if (!existing || isCurrent) {
|
||||||
result.set(classroomId, {
|
result.set(classroomId, {
|
||||||
type: 'schedule',
|
state: isCurrent ? 'in_use' : 'reserved',
|
||||||
title: `${s.className || ''} ${s.subject || ''}`.trim() || '内部课程',
|
currentUsage: isCurrent
|
||||||
startTime: String(s.startTime),
|
? {
|
||||||
endTime: String(s.endTime),
|
type: 'schedule',
|
||||||
|
title: `${schedule.className || ''} ${schedule.subject || ''}`.trim() || '内部课程',
|
||||||
|
startTime: String(schedule.startTime),
|
||||||
|
endTime: String(schedule.endTime),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,19 +212,25 @@ export class ClassroomsService {
|
|||||||
.addSelect('r.endDate', 'endDate')
|
.addSelect('r.endDate', 'endDate')
|
||||||
.addSelect('t.name', 'tenantName')
|
.addSelect('t.name', 'tenantName')
|
||||||
.where('r.classroomId IN (:...ids)', { ids: classroomIds })
|
.where('r.classroomId IN (:...ids)', { ids: classroomIds })
|
||||||
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
|
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
|
||||||
.andWhere('r.startDate <= :today', { today: todayStr })
|
|
||||||
.andWhere('r.endDate >= :today', { today: todayStr })
|
.andWhere('r.endDate >= :today', { today: todayStr })
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
for (const r of rentals) {
|
for (const rental of rentals) {
|
||||||
const classroomId = Number(r.classroomId);
|
const classroomId = Number(rental.classroomId);
|
||||||
if (!result.has(classroomId)) {
|
const isCurrent = String(rental.startDate) <= todayStr && String(rental.endDate) >= todayStr;
|
||||||
|
const existing = result.get(classroomId);
|
||||||
|
if (!existing || isCurrent) {
|
||||||
result.set(classroomId, {
|
result.set(classroomId, {
|
||||||
type: 'rental',
|
state: isCurrent ? 'in_use' : 'reserved',
|
||||||
title: r.tenantName ? `${r.tenantName} 租赁` : '外部租赁',
|
currentUsage: isCurrent
|
||||||
startTime: '00:00',
|
? {
|
||||||
endTime: '23:59',
|
type: 'rental',
|
||||||
|
title: rental.tenantName ? `${rental.tenantName} 租赁` : '外部租赁',
|
||||||
|
startTime: '00:00',
|
||||||
|
endTime: '23:59',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,24 +251,38 @@ export class ClassroomsService {
|
|||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row.name?.trim()) { skipped++; continue; }
|
if (!row.name?.trim()) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
|
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
|
||||||
if (exists) { errors.push(`教室 ${row.name} 已存在`); skipped++; continue; }
|
if (exists) {
|
||||||
|
errors.push(`教室 ${row.name} 已存在`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
|
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
|
||||||
imported++;
|
imported++;
|
||||||
}
|
}
|
||||||
return { message: `成功导入 ${imported} 间教室,跳过 ${skipped} 间`, imported, skipped, errors: errors.length > 0 ? errors : undefined };
|
return {
|
||||||
|
message: `成功导入 ${imported} 间教室,跳过 ${skipped} 间`,
|
||||||
|
imported,
|
||||||
|
skipped,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUsageReport(dateFrom: string, dateTo: string) {
|
async getUsageReport(dateFrom: string, dateTo: string) {
|
||||||
const classrooms = await this.repo.find({
|
const classrooms = await this.repo.find({
|
||||||
where: { status: Not('archived') },
|
where: { status: Not(ClassroomStatus.ARCHIVED) },
|
||||||
order: { building: 'ASC', name: 'ASC' },
|
order: { building: 'ASC', name: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const rentals = await this.rentalRepo
|
const rentals = await this.rentalRepo
|
||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
.where('r.status IN (:...statuses)', {
|
||||||
|
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
|
||||||
|
})
|
||||||
.andWhere('r.startDate <= :dateTo AND r.endDate >= :dateFrom', { dateFrom, dateTo })
|
.andWhere('r.startDate <= :dateTo AND r.endDate >= :dateFrom', { dateFrom, dateTo })
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
|
|||||||
39
apps/server/src/classrooms/classrooms.status.spec.ts
Normal file
39
apps/server/src/classrooms/classrooms.status.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { ClassroomStatus } from '../entities/classroom.entity';
|
||||||
|
import { ClassroomsService } from './classrooms.service';
|
||||||
|
|
||||||
|
function createService(options?: { rentals?: number; schedules?: number }) {
|
||||||
|
const repo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 1, name: 'A101', status: ClassroomStatus.ARCHIVED }),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
const rentalRepo = { count: jest.fn().mockResolvedValue(options?.rentals ?? 0) };
|
||||||
|
const scheduleRepo = {
|
||||||
|
createQueryBuilder: jest.fn().mockReturnValue({
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getCount: jest.fn().mockResolvedValue(options?.schedules ?? 0),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
service: new ClassroomsService(repo as never, rentalRepo as never, scheduleRepo as never),
|
||||||
|
repo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ClassroomsService — persisted classroom status', () => {
|
||||||
|
it('restores an archived classroom to available', async () => {
|
||||||
|
const { service, repo } = createService();
|
||||||
|
|
||||||
|
await service.restore(1);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith(1, { status: ClassroomStatus.AVAILABLE });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects archiving a classroom with active allocations', async () => {
|
||||||
|
const { service, repo } = createService({ rentals: 1 });
|
||||||
|
|
||||||
|
await expect(service.remove(1)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsOptional, IsString, IsNotEmpty, IsInt, IsEnum } from 'class-validator';
|
import { IsOptional, IsString, IsNotEmpty, IsInt, IsEnum } from 'class-validator';
|
||||||
|
import { ClassroomStatus } from '../../entities/classroom.entity';
|
||||||
|
|
||||||
export class CreateClassroomDto {
|
export class CreateClassroomDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -21,11 +22,9 @@ export class CreateClassroomDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
roomType?: string; // 大 / 次大 / 小
|
roomType?: string; // 大 / 次大 / 小
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateClassroomDto {
|
export class UpdateClassroomDto {
|
||||||
@@ -49,12 +48,11 @@ export class UpdateClassroomDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
roomType?: string;
|
roomType?: string;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(['available', 'archived'])
|
@IsEnum([ClassroomStatus.AVAILABLE, ClassroomStatus.MAINTENANCE])
|
||||||
status?: string;
|
status?: ClassroomStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,13 +119,12 @@ export class DashboardService {
|
|||||||
const pendingQb = this.depositRepo
|
const pendingQb = this.depositRepo
|
||||||
.createQueryBuilder('d')
|
.createQueryBuilder('d')
|
||||||
.select('SUM(d.amount)', 'total')
|
.select('SUM(d.amount)', 'total')
|
||||||
.where('d.status = :paid', { paid: 'paid' })
|
.where('d.status = :paid', { paid: 'paid' });
|
||||||
.andWhere('d.refundStatus IS NULL');
|
|
||||||
const pendingResult = await pendingQb.getRawOne();
|
const pendingResult = await pendingQb.getRawOne();
|
||||||
const pendingDeposits = parseFloat(pendingResult?.total || '0');
|
const pendingDeposits = parseFloat(pendingResult?.total || '0');
|
||||||
|
|
||||||
const activeRentals = await this.rentalRepo.count({
|
const activeRentals = await this.rentalRepo.count({
|
||||||
where: { endDate: MoreThanOrEqual(todayStr) },
|
where: { status: 'active' as const, endDate: MoreThanOrEqual(todayStr) },
|
||||||
});
|
});
|
||||||
|
|
||||||
const occByBldQb = this.occRepo
|
const occByBldQb = this.occRepo
|
||||||
@@ -366,7 +365,7 @@ export class DashboardService {
|
|||||||
|
|
||||||
async getClassroomOccupancy() {
|
async getClassroomOccupancy() {
|
||||||
const classrooms = await this.classroomRepo.find({
|
const classrooms = await this.classroomRepo.find({
|
||||||
where: { status: Not('archived') },
|
where: { status: 'available' as const },
|
||||||
order: { building: 'ASC', name: 'ASC' },
|
order: { building: 'ASC', name: 'ASC' },
|
||||||
});
|
});
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
@@ -383,7 +382,7 @@ export class DashboardService {
|
|||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.select('r.classroomId', 'classroomId')
|
.select('r.classroomId', 'classroomId')
|
||||||
.addSelect('COUNT(*)', 'rentalCount')
|
.addSelect('COUNT(*)', 'rentalCount')
|
||||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
.where('r.status = :active', { active: 'active' })
|
||||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
||||||
.groupBy('r.classroomId');
|
.groupBy('r.classroomId');
|
||||||
const rentals = await rentalQb.getRawMany();
|
const rentals = await rentalQb.getRawMany();
|
||||||
@@ -403,7 +402,7 @@ export class DashboardService {
|
|||||||
|
|
||||||
async getClassroomUtilizationStats() {
|
async getClassroomUtilizationStats() {
|
||||||
const totalClassrooms = await this.classroomRepo.count({
|
const totalClassrooms = await this.classroomRepo.count({
|
||||||
where: { status: Not('archived') },
|
where: { status: 'available' as const },
|
||||||
});
|
});
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
@@ -421,7 +420,7 @@ export class DashboardService {
|
|||||||
const rentalQb = this.rentalRepo
|
const rentalQb = this.rentalRepo
|
||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.select('COUNT(DISTINCT r.classroomId)', 'cnt')
|
.select('COUNT(DISTINCT r.classroomId)', 'cnt')
|
||||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
.where('r.status = :active', { active: 'active' })
|
||||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
|
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
|
||||||
const rentalResult = await rentalQb.getRawOne();
|
const rentalResult = await rentalQb.getRawOne();
|
||||||
|
|
||||||
@@ -438,7 +437,7 @@ export class DashboardService {
|
|||||||
const combinedRentalQb = this.rentalRepo
|
const combinedRentalQb = this.rentalRepo
|
||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.select('r.classroomId')
|
.select('r.classroomId')
|
||||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
.where('r.status = :active', { active: 'active' })
|
||||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
||||||
.groupBy('r.classroomId');
|
.groupBy('r.classroomId');
|
||||||
const rentalIds = await combinedRentalQb.getRawMany();
|
const rentalIds = await combinedRentalQb.getRawMany();
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import { DatabaseMigrationsService } from './database-migrations.service';
|
||||||
|
|
||||||
|
function createRunner(tableExists: boolean, columns: string[] = []) {
|
||||||
|
return {
|
||||||
|
connect: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
getTables: jest.fn().mockResolvedValue(tableExists ? [{ name: 'class_student' }] : []),
|
||||||
|
getTable: jest.fn().mockResolvedValue({
|
||||||
|
name: 'class_student',
|
||||||
|
columns: columns.map((name) => ({ name })),
|
||||||
|
}),
|
||||||
|
dropColumn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createService(runner: ReturnType<typeof createRunner>) {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
DatabaseMigrationsService,
|
||||||
|
{
|
||||||
|
provide: getDataSourceToken(),
|
||||||
|
useValue: {
|
||||||
|
options: { type: 'better-sqlite3' },
|
||||||
|
createQueryRunner: jest.fn().mockReturnValue(runner),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
return module.get(DatabaseMigrationsService) as DatabaseMigrationsService & {
|
||||||
|
removeUnusedClassStudentColumns(): Promise<void>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DatabaseMigrationsService — class student cleanup', () => {
|
||||||
|
it('drops the unused enrollment_id column', async () => {
|
||||||
|
const runner = createRunner(true, ['id', 'enrollment_id']);
|
||||||
|
const service = await createService(runner);
|
||||||
|
|
||||||
|
await service.removeUnusedClassStudentColumns();
|
||||||
|
|
||||||
|
expect(runner.dropColumn).toHaveBeenCalledWith('class_student', 'enrollment_id');
|
||||||
|
expect(runner.release).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the table is absent', async () => {
|
||||||
|
const runner = createRunner(false);
|
||||||
|
const service = await createService(runner);
|
||||||
|
|
||||||
|
await service.removeUnusedClassStudentColumns();
|
||||||
|
|
||||||
|
expect(runner.dropColumn).not.toHaveBeenCalled();
|
||||||
|
expect(runner.release).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import { DatabaseMigrationsService } from './database-migrations.service';
|
||||||
|
|
||||||
|
describe('DatabaseMigrationsService — classroom status normalization', () => {
|
||||||
|
it('normalizes legacy persisted statuses to available', async () => {
|
||||||
|
const runner = {
|
||||||
|
connect: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
getTables: jest.fn().mockResolvedValue([{ name: 'classrooms' }]),
|
||||||
|
query: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
DatabaseMigrationsService,
|
||||||
|
{
|
||||||
|
provide: getDataSourceToken(),
|
||||||
|
useValue: {
|
||||||
|
options: { type: 'better-sqlite3' },
|
||||||
|
createQueryRunner: jest.fn().mockReturnValue(runner),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
const service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & {
|
||||||
|
normalizeClassroomStatuses(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.normalizeClassroomStatuses();
|
||||||
|
|
||||||
|
expect(runner.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("status NOT IN ('available', 'maintenance', 'archived')"),
|
||||||
|
);
|
||||||
|
expect(runner.release).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import { DatabaseMigrationsService } from './database-migrations.service';
|
||||||
|
|
||||||
|
function createRunner(columns: string[]) {
|
||||||
|
return {
|
||||||
|
connect: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
query: jest.fn().mockResolvedValue([]),
|
||||||
|
getTables: jest.fn().mockResolvedValue(columns.length ? [{ name: 'deposits' }] : []),
|
||||||
|
getTable: jest.fn().mockResolvedValue({
|
||||||
|
name: 'deposits',
|
||||||
|
columns: columns.map((name) => ({ name })),
|
||||||
|
}),
|
||||||
|
renameColumn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
dropColumn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createService(runner: ReturnType<typeof createRunner>) {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
DatabaseMigrationsService,
|
||||||
|
{
|
||||||
|
provide: getDataSourceToken(),
|
||||||
|
useValue: {
|
||||||
|
options: { type: 'better-sqlite3' },
|
||||||
|
createQueryRunner: jest.fn().mockReturnValue(runner),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
return module.get(DatabaseMigrationsService) as DatabaseMigrationsService & {
|
||||||
|
cleanupDepositRefundColumns(): Promise<void>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DatabaseMigrationsService — deposit refund cleanup', () => {
|
||||||
|
it('renames refund audit fields and drops approval-flow remnants', async () => {
|
||||||
|
const runner = createRunner([
|
||||||
|
'id',
|
||||||
|
'refund_status',
|
||||||
|
'refund_requested_at',
|
||||||
|
'refund_approved_by',
|
||||||
|
'refund_approved_at',
|
||||||
|
'refund_rejected_reason',
|
||||||
|
]);
|
||||||
|
const service = await createService(runner);
|
||||||
|
|
||||||
|
await service.cleanupDepositRefundColumns();
|
||||||
|
|
||||||
|
expect(runner.renameColumn).toHaveBeenCalledWith(
|
||||||
|
'deposits',
|
||||||
|
'refund_approved_by',
|
||||||
|
'refunded_by',
|
||||||
|
);
|
||||||
|
expect(runner.renameColumn).toHaveBeenCalledWith(
|
||||||
|
'deposits',
|
||||||
|
'refund_approved_at',
|
||||||
|
'refunded_at',
|
||||||
|
);
|
||||||
|
expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_status');
|
||||||
|
expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_requested_at');
|
||||||
|
expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_rejected_reason');
|
||||||
|
expect(runner.release).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges legacy audit values before dropping duplicate legacy columns', async () => {
|
||||||
|
const runner = createRunner([
|
||||||
|
'id',
|
||||||
|
'refund_approved_by',
|
||||||
|
'refund_approved_at',
|
||||||
|
'refunded_by',
|
||||||
|
'refunded_at',
|
||||||
|
]);
|
||||||
|
const service = await createService(runner);
|
||||||
|
|
||||||
|
await service.cleanupDepositRefundColumns();
|
||||||
|
|
||||||
|
expect(runner.query).toHaveBeenCalledWith(
|
||||||
|
'UPDATE deposits SET refunded_by = COALESCE(refunded_by, refund_approved_by)',
|
||||||
|
);
|
||||||
|
expect(runner.query).toHaveBeenCalledWith(
|
||||||
|
'UPDATE deposits SET refunded_at = COALESCE(refunded_at, refund_approved_at)',
|
||||||
|
);
|
||||||
|
expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_approved_by');
|
||||||
|
expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_approved_at');
|
||||||
|
expect(runner.renameColumn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the deposits table is absent', async () => {
|
||||||
|
const runner = createRunner([]);
|
||||||
|
const service = await createService(runner);
|
||||||
|
|
||||||
|
await service.cleanupDepositRefundColumns();
|
||||||
|
|
||||||
|
expect(runner.renameColumn).not.toHaveBeenCalled();
|
||||||
|
expect(runner.dropColumn).not.toHaveBeenCalled();
|
||||||
|
expect(runner.release).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import { DatabaseMigrationsService } from './database-migrations.service';
|
||||||
|
|
||||||
|
describe('DatabaseMigrationsService — room gender cleanup', () => {
|
||||||
|
it('drops the retired rooms.gender column', async () => {
|
||||||
|
const runner = {
|
||||||
|
connect: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
getTables: jest.fn().mockResolvedValue([{ name: 'rooms' }]),
|
||||||
|
getTable: jest.fn().mockResolvedValue({
|
||||||
|
name: 'rooms',
|
||||||
|
columns: [{ name: 'id' }, { name: 'gender' }],
|
||||||
|
}),
|
||||||
|
dropColumn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
DatabaseMigrationsService,
|
||||||
|
{
|
||||||
|
provide: getDataSourceToken(),
|
||||||
|
useValue: {
|
||||||
|
options: { type: 'better-sqlite3' },
|
||||||
|
createQueryRunner: jest.fn().mockReturnValue(runner),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
const service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & {
|
||||||
|
removeUnusedRoomColumns(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.removeUnusedRoomColumns();
|
||||||
|
|
||||||
|
expect(runner.dropColumn).toHaveBeenCalledWith('rooms', 'gender');
|
||||||
|
expect(runner.release).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,10 +11,58 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
async onApplicationBootstrap(): Promise<void> {
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
await this.ensureAiConfigTable();
|
await this.ensureAiConfigTable();
|
||||||
await this.ensureCourseAttendanceSchema();
|
await this.ensureCourseAttendanceSchema();
|
||||||
|
await this.ensureStudentWalletSchema();
|
||||||
await this.backfillOrganizations();
|
await this.backfillOrganizations();
|
||||||
await this.normalizeClassDates();
|
await this.normalizeClassDates();
|
||||||
await this.protectAttendanceHistory();
|
await this.protectAttendanceHistory();
|
||||||
await this.removeUnusedClassroomColumns();
|
await this.removeUnusedClassroomColumns();
|
||||||
|
await this.removeUnusedRoomColumns();
|
||||||
|
await this.cleanupDepositRefundColumns();
|
||||||
|
await this.removeUnusedClassStudentColumns();
|
||||||
|
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> {
|
private async removeUnusedClassroomColumns(): Promise<void> {
|
||||||
@@ -36,6 +84,92 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async removeUnusedRoomColumns(): Promise<void> {
|
||||||
|
const runner = this.dataSource.createQueryRunner();
|
||||||
|
await runner.connect();
|
||||||
|
try {
|
||||||
|
const tables = await runner.getTables(['rooms']);
|
||||||
|
if (tables.length === 0) return;
|
||||||
|
|
||||||
|
const table = await runner.getTable('rooms');
|
||||||
|
if (table?.columns.some((column) => column.name === 'gender')) {
|
||||||
|
await runner.dropColumn('rooms', 'gender');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await runner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupDepositRefundColumns(): Promise<void> {
|
||||||
|
const runner = this.dataSource.createQueryRunner();
|
||||||
|
await runner.connect();
|
||||||
|
try {
|
||||||
|
const tables = await runner.getTables(['deposits']);
|
||||||
|
if (tables.length === 0) return;
|
||||||
|
|
||||||
|
const table = await runner.getTable('deposits');
|
||||||
|
const columnNames = new Set(table?.columns.map((column) => column.name) ?? []);
|
||||||
|
for (const [legacyName, currentName] of [
|
||||||
|
['refund_approved_by', 'refunded_by'],
|
||||||
|
['refund_approved_at', 'refunded_at'],
|
||||||
|
] as const) {
|
||||||
|
if (!columnNames.has(legacyName)) continue;
|
||||||
|
|
||||||
|
if (columnNames.has(currentName)) {
|
||||||
|
await runner.query(
|
||||||
|
`UPDATE deposits SET ${currentName} = COALESCE(${currentName}, ${legacyName})`,
|
||||||
|
);
|
||||||
|
await runner.dropColumn('deposits', legacyName);
|
||||||
|
} else {
|
||||||
|
await runner.renameColumn('deposits', legacyName, currentName);
|
||||||
|
columnNames.add(currentName);
|
||||||
|
}
|
||||||
|
columnNames.delete(legacyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const columnName of ['refund_status', 'refund_requested_at', 'refund_rejected_reason']) {
|
||||||
|
if (columnNames.has(columnName)) {
|
||||||
|
await runner.dropColumn('deposits', columnName);
|
||||||
|
columnNames.delete(columnName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await runner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeUnusedClassStudentColumns(): Promise<void> {
|
||||||
|
const runner = this.dataSource.createQueryRunner();
|
||||||
|
await runner.connect();
|
||||||
|
try {
|
||||||
|
const tables = await runner.getTables(['class_student']);
|
||||||
|
if (tables.length === 0) return;
|
||||||
|
|
||||||
|
const table = await runner.getTable('class_student');
|
||||||
|
if (table?.columns.some((column) => column.name === 'enrollment_id')) {
|
||||||
|
await runner.dropColumn('class_student', 'enrollment_id');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await runner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async normalizeClassroomStatuses(): Promise<void> {
|
||||||
|
const runner = this.dataSource.createQueryRunner();
|
||||||
|
await runner.connect();
|
||||||
|
try {
|
||||||
|
const tables = await runner.getTables(['classrooms']);
|
||||||
|
if (tables.length === 0) return;
|
||||||
|
await runner.query(`
|
||||||
|
UPDATE classrooms
|
||||||
|
SET status = 'available'
|
||||||
|
WHERE status IS NULL OR status NOT IN ('available', 'maintenance', 'archived')
|
||||||
|
`);
|
||||||
|
} finally {
|
||||||
|
await runner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureAiConfigTable(): Promise<void> {
|
private async ensureAiConfigTable(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
const runner = this.dataSource.createQueryRunner();
|
||||||
await runner.connect();
|
await runner.connect();
|
||||||
@@ -126,7 +260,11 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
const runner = this.dataSource.createQueryRunner();
|
const runner = this.dataSource.createQueryRunner();
|
||||||
await runner.connect();
|
await runner.connect();
|
||||||
try {
|
try {
|
||||||
const tables = await runner.getTables(['attendance_records', 'attendance_sessions']);
|
const tables = await runner.getTables([
|
||||||
|
'class_schedule',
|
||||||
|
'attendance_records',
|
||||||
|
'attendance_sessions',
|
||||||
|
]);
|
||||||
const tableNames = new Set(tables.map((table) => table.name));
|
const tableNames = new Set(tables.map((table) => table.name));
|
||||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
const isMySQL = this.dataSource.options.type === 'mysql';
|
||||||
|
|
||||||
@@ -153,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 attendanceTable = await runner.getTable('attendance_records');
|
||||||
const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []);
|
const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []);
|
||||||
if (!columnNames.has('schedule_id')) {
|
if (!columnNames.has('schedule_id')) {
|
||||||
@@ -354,14 +503,17 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
// Drop any existing FK constraint on schedule_id or class_id
|
// Drop any existing FK constraint on schedule_id or class_id
|
||||||
const fkColumns = ['schedule_id', 'class_id'];
|
const fkColumns = ['schedule_id', 'class_id'];
|
||||||
for (const col of fkColumns) {
|
for (const col of fkColumns) {
|
||||||
const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(`
|
const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(
|
||||||
|
`
|
||||||
SELECT CONSTRAINT_NAME
|
SELECT CONSTRAINT_NAME
|
||||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||||
WHERE TABLE_SCHEMA = DATABASE()
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
AND TABLE_NAME = 'attendance_sessions'
|
AND TABLE_NAME = 'attendance_sessions'
|
||||||
AND COLUMN_NAME = ?
|
AND COLUMN_NAME = ?
|
||||||
AND REFERENCED_TABLE_NAME IS NOT NULL
|
AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||||
`, [col]);
|
`,
|
||||||
|
[col],
|
||||||
|
);
|
||||||
|
|
||||||
for (const row of fkRows) {
|
for (const row of fkRows) {
|
||||||
try {
|
try {
|
||||||
@@ -381,13 +533,16 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
];
|
];
|
||||||
for (const c of constraints) {
|
for (const c of constraints) {
|
||||||
// Only skip if RESTRICT constraint is already confirmed via information_schema
|
// Only skip if RESTRICT constraint is already confirmed via information_schema
|
||||||
const existing: Array<{ DELETE_RULE: string }> = await runner.query(`
|
const existing: Array<{ DELETE_RULE: string }> = await runner.query(
|
||||||
|
`
|
||||||
SELECT DELETE_RULE
|
SELECT DELETE_RULE
|
||||||
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
|
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
|
||||||
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
||||||
AND TABLE_NAME = 'attendance_sessions'
|
AND TABLE_NAME = 'attendance_sessions'
|
||||||
AND CONSTRAINT_NAME = ?
|
AND CONSTRAINT_NAME = ?
|
||||||
`, [c.name]);
|
`,
|
||||||
|
[c.name],
|
||||||
|
);
|
||||||
|
|
||||||
if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') {
|
if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') {
|
||||||
this.logger.log(`考勤场次删除保护约束已存在: ${c.name}`);
|
this.logger.log(`考勤场次删除保护约束已存在: ${c.name}`);
|
||||||
@@ -449,17 +604,13 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
FROM attendance_sessions
|
FROM attendance_sessions
|
||||||
`);
|
`);
|
||||||
await runner.query('DROP TABLE attendance_sessions');
|
await runner.query('DROP TABLE attendance_sessions');
|
||||||
await runner.query(
|
await runner.query('ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions');
|
||||||
'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions',
|
|
||||||
);
|
|
||||||
await runner.query(
|
await runner.query(
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
|
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Rebuild attendance_records to add/protect FK on attendance_session_id
|
// Rebuild attendance_records to add/protect FK on attendance_session_id
|
||||||
const recordsFk = await runner.query(
|
const recordsFk = await runner.query("PRAGMA foreign_key_list('attendance_records')");
|
||||||
"PRAGMA foreign_key_list('attendance_records')",
|
|
||||||
);
|
|
||||||
const hasSessionFk = recordsFk.some(
|
const hasSessionFk = recordsFk.some(
|
||||||
(r: { from: string }) => r.from === 'attendance_session_id',
|
(r: { from: string }) => r.from === 'attendance_session_id',
|
||||||
);
|
);
|
||||||
@@ -492,9 +643,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
FROM attendance_records
|
FROM attendance_records
|
||||||
`);
|
`);
|
||||||
await runner.query('DROP TABLE attendance_records');
|
await runner.query('DROP TABLE attendance_records');
|
||||||
await runner.query(
|
await runner.query('ALTER TABLE attendance_records_new RENAME TO attendance_records');
|
||||||
'ALTER TABLE attendance_records_new RENAME TO attendance_records',
|
|
||||||
);
|
|
||||||
await runner.query(
|
await runner.query(
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
|
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
|
||||||
);
|
);
|
||||||
@@ -504,9 +653,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
// If violations exist, the transaction rolls back and old tables are preserved.
|
// If violations exist, the transaction rolls back and old tables are preserved.
|
||||||
const checkRows = await runner.query('PRAGMA foreign_key_check');
|
const checkRows = await runner.query('PRAGMA foreign_key_check');
|
||||||
if (checkRows.length > 0) {
|
if (checkRows.length > 0) {
|
||||||
throw new Error(
|
throw new Error(`外键一致性检查失败: ${checkRows.length} 行违反外键约束`);
|
||||||
`外键一致性检查失败: ${checkRows.length} 行违反外键约束`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await runner.query('COMMIT');
|
await runner.query('COMMIT');
|
||||||
|
|||||||
@@ -209,6 +209,28 @@ describe('DatabaseMigrationsService — course attendance schema', () => {
|
|||||||
expect(runner.release).toHaveBeenCalled();
|
expect(runner.release).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds the configurable attendance window to existing schedules', async () => {
|
||||||
|
const runner = mockRunner({
|
||||||
|
getTables: [
|
||||||
|
{ name: 'class_schedule', columns: [{ name: 'id' }] },
|
||||||
|
{ name: 'attendance_records', columns: [{ name: 'id' }] },
|
||||||
|
{ name: 'attendance_sessions', columns: [{ name: 'id' }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
runner.getTable.mockImplementation(async (name: string) =>
|
||||||
|
name === 'class_schedule'
|
||||||
|
? { name, columns: [{ name: 'id' }] }
|
||||||
|
: { name, columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }] },
|
||||||
|
);
|
||||||
|
await bootstrapCourseAttendance(runner);
|
||||||
|
|
||||||
|
await service.ensureCourseAttendanceSchema();
|
||||||
|
|
||||||
|
expect(runner.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => {
|
it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => {
|
||||||
const runner = mockRunner({
|
const runner = mockRunner({
|
||||||
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],
|
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
@@ -16,7 +17,12 @@ import { Student } from '../entities/student.entity';
|
|||||||
import { DepositsService } from './deposits.service';
|
import { DepositsService } from './deposits.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationType } from '../entities/notification.entity';
|
import { NotificationType } from '../entities/notification.entity';
|
||||||
import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto';
|
import {
|
||||||
|
CreateDepositDto,
|
||||||
|
CreateDepositInstallmentDto,
|
||||||
|
RefundDepositDto,
|
||||||
|
UpdateDepositInstallmentDto,
|
||||||
|
} from './dto/deposit.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
@@ -40,9 +46,12 @@ export class DepositsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermission('deposit:view')
|
@RequirePermission('deposit:view')
|
||||||
findAll(@Query('studentId') studentId?: string, @Query('status') status?: string) {
|
findAll(
|
||||||
|
@Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
studentId: studentId ? +studentId : undefined,
|
studentId,
|
||||||
status: status || undefined,
|
status: status || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,13 +64,13 @@ export class DepositsController {
|
|||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePermission('deposit:view')
|
@RequirePermission('deposit:view')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.service.findOne(+id);
|
return this.service.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission('deposit:create')
|
@RequirePermission('deposit:create')
|
||||||
async create(@Body() dto: CreateDepositDto | CreateDepositWithInstallmentsDto, @Request() req: any) {
|
async create(@Body() dto: CreateDepositDto, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.create(dto, req.user?.id);
|
const result = await this.service.create(dto, req.user?.id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
@@ -93,12 +102,12 @@ export class DepositsController {
|
|||||||
@Post(':id/installments')
|
@Post(':id/installments')
|
||||||
@RequirePermission('deposit:edit')
|
@RequirePermission('deposit:edit')
|
||||||
async addInstallment(
|
async addInstallment(
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() body: { amount: number; dueDate: string },
|
@Body() body: CreateDepositInstallmentDto,
|
||||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.addInstallment(+id, body.amount, body.dueDate);
|
const result = await this.service.addInstallment(id, body.amount, body.dueDate);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
@@ -116,18 +125,18 @@ export class DepositsController {
|
|||||||
@Put('installments/:installmentId')
|
@Put('installments/:installmentId')
|
||||||
@RequirePermission('deposit:edit')
|
@RequirePermission('deposit:edit')
|
||||||
async updateInstallment(
|
async updateInstallment(
|
||||||
@Param('installmentId') installmentId: string,
|
@Param('installmentId', ParseIntPipe) installmentId: number,
|
||||||
@Body() body: { paidDate?: string; status?: string },
|
@Body() body: UpdateDepositInstallmentDto,
|
||||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.updateInstallment(+installmentId, body);
|
const result = await this.service.updateInstallment(installmentId, body);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '押金管理',
|
module: '押金管理',
|
||||||
action: '更新分期',
|
action: '更新分期',
|
||||||
targetId: +installmentId,
|
targetId: installmentId,
|
||||||
targetType: 'deposit-installment',
|
targetType: 'deposit-installment',
|
||||||
detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
|
detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -139,17 +148,17 @@ export class DepositsController {
|
|||||||
@Delete('installments/:installmentId')
|
@Delete('installments/:installmentId')
|
||||||
@RequirePermission('deposit:delete')
|
@RequirePermission('deposit:delete')
|
||||||
async deleteInstallment(
|
async deleteInstallment(
|
||||||
@Param('installmentId') installmentId: string,
|
@Param('installmentId', ParseIntPipe) installmentId: number,
|
||||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.deleteInstallment(+installmentId);
|
const result = await this.service.deleteInstallment(installmentId);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '押金管理',
|
module: '押金管理',
|
||||||
action: '删除分期',
|
action: '删除分期',
|
||||||
targetId: +installmentId,
|
targetId: installmentId,
|
||||||
targetType: 'deposit-installment',
|
targetType: 'deposit-installment',
|
||||||
detail: `删除分期${installmentId}`,
|
detail: `删除分期${installmentId}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
@@ -160,17 +169,17 @@ export class DepositsController {
|
|||||||
|
|
||||||
@Put(':id/refund')
|
@Put(':id/refund')
|
||||||
@RequirePermission('deposit:refund')
|
@RequirePermission('deposit:refund')
|
||||||
async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) {
|
async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.refund(+id, dto, req.user?.id);
|
const result = await this.service.refund(id, dto, req.user?.id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '押金管理',
|
module: '押金管理',
|
||||||
action: '退还押金',
|
action: '退还押金',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'deposit',
|
targetType: 'deposit',
|
||||||
detail: `退还¥${result.refundAmount}, 扣除¥${result.deductionAmount}`,
|
detail: `退还全部可用押金 ¥${result.refundAmount}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
});
|
});
|
||||||
@@ -182,7 +191,7 @@ export class DepositsController {
|
|||||||
recipientIds: [student.userId],
|
recipientIds: [student.userId],
|
||||||
type: 'deposit_refunded',
|
type: 'deposit_refunded',
|
||||||
title: '押金已退还',
|
title: '押金已退还',
|
||||||
content: `您的押金已退还,退还¥${result.refundAmount},扣除¥${result.deductionAmount}`,
|
content: `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_) { /* don't block response */ }
|
} catch (_) { /* don't block response */ }
|
||||||
@@ -191,15 +200,15 @@ export class DepositsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('deposit:delete')
|
@RequirePermission('deposit:delete')
|
||||||
async remove(@Param('id') id: string, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.remove(+id);
|
const result = await this.service.remove(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '押金管理',
|
module: '押金管理',
|
||||||
action: '删除押金记录',
|
action: '删除押金记录',
|
||||||
targetId: +id,
|
targetId: id,
|
||||||
targetType: 'deposit',
|
targetType: 'deposit',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe('DepositsService permission-scoped lookups', () => {
|
|||||||
const studentRepo = {
|
const studentRepo = {
|
||||||
find: jest.fn().mockResolvedValue([{ id: 2, name: '张三', studentNo: 'S2' }]),
|
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([
|
await expect(service.getStudentLookups()).resolves.toEqual([
|
||||||
{ id: 2, name: '张三', studentNo: 'S2' },
|
{ id: 2, name: '张三', studentNo: 'S2' },
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
import { Deposit } from '../entities/deposit.entity';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||||
|
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||||
import { DepositsService } from './deposits.service';
|
import { DepositsService } from './deposits.service';
|
||||||
import { DepositsController } from './deposits.controller';
|
import { DepositsController } from './deposits.controller';
|
||||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Deposit, DepositInstallment, Student, PersonalExpense]),
|
||||||
|
OperationLogsModule,
|
||||||
|
NotificationsModule,
|
||||||
|
],
|
||||||
controllers: [DepositsController],
|
controllers: [DepositsController],
|
||||||
providers: [DepositsService],
|
providers: [DepositsService],
|
||||||
exports: [DepositsService],
|
exports: [DepositsService],
|
||||||
|
|||||||
47
apps/server/src/deposits/deposits.refund.spec.ts
Normal file
47
apps/server/src/deposits/deposits.refund.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { DepositsService } from './deposits.service';
|
||||||
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
|
|
||||||
|
describe('DepositsService — direct refund', () => {
|
||||||
|
it('deducts unbilled personal expenses before refunding the remaining balance', async () => {
|
||||||
|
const deposit = {
|
||||||
|
id: 1,
|
||||||
|
studentId: 10,
|
||||||
|
amount: 500,
|
||||||
|
status: 'paid',
|
||||||
|
} as Deposit;
|
||||||
|
const repo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(deposit),
|
||||||
|
save: jest.fn().mockImplementation(async (value: Deposit) => value),
|
||||||
|
};
|
||||||
|
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', notes: '退还剩余押金' },
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
refundDate: '2026-07-13',
|
||||||
|
amount: 0,
|
||||||
|
refundAmount: 380,
|
||||||
|
deductionAmount: 120,
|
||||||
|
deductionReason: '自动扣除个人附加费用 ¥120.00',
|
||||||
|
notes: '退还剩余押金',
|
||||||
|
status: 'refunded',
|
||||||
|
refundedBy: 42,
|
||||||
|
});
|
||||||
|
expect(result.refundedAt).toBeInstanceOf(Date);
|
||||||
|
expect(repo.save).toHaveBeenCalledWith(deposit);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,8 +4,9 @@ import { Repository } from 'typeorm';
|
|||||||
import { Deposit } from '../entities/deposit.entity';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||||
|
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||||
|
|
||||||
import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto';
|
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DepositsService {
|
export class DepositsService {
|
||||||
@@ -16,6 +17,8 @@ export class DepositsService {
|
|||||||
private installmentRepo: Repository<DepositInstallment>,
|
private installmentRepo: Repository<DepositInstallment>,
|
||||||
@InjectRepository(Student)
|
@InjectRepository(Student)
|
||||||
private studentRepo: Repository<Student>,
|
private studentRepo: Repository<Student>,
|
||||||
|
@InjectRepository(PersonalExpense)
|
||||||
|
private personalExpenseRepo: Repository<PersonalExpense>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getStudentLookups() {
|
async getStudentLookups() {
|
||||||
@@ -34,39 +37,42 @@ export class DepositsService {
|
|||||||
.orderBy('d.createdAt', 'DESC');
|
.orderBy('d.createdAt', 'DESC');
|
||||||
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
|
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
|
||||||
if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
|
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) {
|
async findOne(id: number) {
|
||||||
const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] });
|
const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] });
|
||||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||||
return deposit;
|
const [withPersonalExpense] = await this.attachPersonalExpenseAmount([deposit]);
|
||||||
|
return withPersonalExpense;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateDepositDto, userId?: number) {
|
async create(dto: CreateDepositDto, userId?: number) {
|
||||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||||
if (!student) throw new NotFoundException('学生不存在');
|
if (!student) throw new NotFoundException('学生不存在');
|
||||||
const deposit = this.repo.create({
|
if (Number(dto.amount) <= 0) throw new BadRequestException('收取金额必须大于0');
|
||||||
studentId: dto.studentId,
|
|
||||||
amount: dto.amount,
|
|
||||||
paidDate: dto.paidDate,
|
|
||||||
notes: dto.notes,
|
|
||||||
status: 'paid',
|
|
||||||
recordedBy: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (dto instanceof CreateDepositWithInstallmentsDto && dto.installments?.length) {
|
const existing = await this.repo.findOne({ where: { studentId: dto.studentId } });
|
||||||
deposit.installments = dto.installments.map((i) => {
|
if (existing) {
|
||||||
const inst = this.installmentRepo.create({
|
existing.amount = Number((Number(existing.amount || 0) + Number(dto.amount)).toFixed(2));
|
||||||
amount: i.amount,
|
existing.paidDate = dto.paidDate;
|
||||||
dueDate: i.dueDate,
|
existing.status = 'paid';
|
||||||
status: 'pending',
|
existing.recordedBy = userId ?? null;
|
||||||
});
|
if (dto.notes) existing.notes = dto.notes;
|
||||||
return inst;
|
return this.repo.save(existing);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.repo.save(deposit);
|
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) {
|
async addInstallment(depositId: number, amount: number, dueDate: string) {
|
||||||
@@ -101,22 +107,25 @@ export class DepositsService {
|
|||||||
async refund(id: number, dto: RefundDepositDto, userId?: number) {
|
async refund(id: number, dto: RefundDepositDto, userId?: number) {
|
||||||
const deposit = await this.repo.findOne({ where: { id } });
|
const deposit = await this.repo.findOne({ where: { id } });
|
||||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||||
if (deposit.status !== 'paid') throw new BadRequestException('该押金已处理');
|
if (deposit.status !== 'paid' || Number(deposit.amount) <= 0) {
|
||||||
|
throw new BadRequestException('该学生当前没有可退押金');
|
||||||
|
}
|
||||||
|
|
||||||
const deduction = dto.deductionAmount || 0;
|
const depositAmount = Number(deposit.amount);
|
||||||
const refundAmount = Number(deposit.amount) - deduction;
|
const personalExpenseAmount = await this.getPersonalExpenseAmount(deposit.studentId);
|
||||||
if (refundAmount < 0) throw new BadRequestException('扣除金额不能大于押金金额');
|
const deductionAmount = Number(Math.min(depositAmount, personalExpenseAmount).toFixed(2));
|
||||||
|
const refundAmount = Number((depositAmount - deductionAmount).toFixed(2));
|
||||||
|
|
||||||
deposit.refundDate = dto.refundDate;
|
deposit.refundDate = dto.refundDate;
|
||||||
deposit.deductionAmount = deduction;
|
|
||||||
deposit.deductionReason = dto.deductionReason || '';
|
|
||||||
deposit.refundAmount = refundAmount;
|
deposit.refundAmount = refundAmount;
|
||||||
deposit.status =
|
deposit.deductionAmount = deductionAmount;
|
||||||
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
|
deposit.deductionReason =
|
||||||
|
deductionAmount > 0 ? `自动扣除个人附加费用 ¥${deductionAmount.toFixed(2)}` : '';
|
||||||
|
deposit.amount = 0;
|
||||||
|
deposit.status = refundAmount > 0 ? 'refunded' : 'depleted';
|
||||||
if (dto.notes) deposit.notes = dto.notes;
|
if (dto.notes) deposit.notes = dto.notes;
|
||||||
deposit.refundStatus = 'refunded';
|
deposit.refundedBy = userId ?? null;
|
||||||
deposit.refundApprovedBy = userId ?? null as unknown as number;
|
deposit.refundedAt = new Date();
|
||||||
deposit.refundApprovedAt = new Date();
|
|
||||||
|
|
||||||
return this.repo.save(deposit);
|
return this.repo.save(deposit);
|
||||||
}
|
}
|
||||||
@@ -137,4 +146,37 @@ export class DepositsService {
|
|||||||
qb.groupBy('d.status');
|
qb.groupBy('d.status');
|
||||||
return qb.getRawMany();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
apps/server/src/deposits/dto/deposit.dto.spec.ts
Normal file
19
apps/server/src/deposits/dto/deposit.dto.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
import { CreateDepositDto } from './deposit.dto';
|
||||||
|
|
||||||
|
describe('CreateDepositDto boundaries', () => {
|
||||||
|
it('removes inline installments because they are managed after deposit creation', async () => {
|
||||||
|
const dto = Object.assign(new CreateDepositDto(), {
|
||||||
|
studentId: 1,
|
||||||
|
amount: 500,
|
||||||
|
paidDate: '2026-07-13',
|
||||||
|
installments: [{ amount: 250, dueDate: '2026-08-01' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await validate(dto, { whitelist: true });
|
||||||
|
|
||||||
|
expect(dto).toMatchObject({ studentId: 1, amount: 500, paidDate: '2026-07-13' });
|
||||||
|
expect(dto).not.toHaveProperty('installments');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { IsInt, IsNumber, IsString, IsOptional, ValidateNested } from 'class-validator';
|
import { IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
|
|
||||||
export class CreateDepositDto {
|
export class CreateDepositDto {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
studentId: number;
|
studentId: number;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0.01)
|
||||||
amount: number;
|
amount: number;
|
||||||
|
|
||||||
@IsString()
|
@IsDateString()
|
||||||
paidDate: string;
|
paidDate: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -16,35 +16,30 @@ export class CreateDepositDto {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateInstallmentDto {
|
|
||||||
@IsNumber()
|
|
||||||
amount: number;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
dueDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export class RefundDepositDto {
|
export class RefundDepositDto {
|
||||||
@IsString()
|
@IsDateString()
|
||||||
refundDate: string;
|
refundDate: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
deductionAmount?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
deductionReason?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
export class CreateDepositWithInstallmentsDto extends CreateDepositDto {
|
|
||||||
@IsOptional()
|
export class CreateDepositInstallmentDto {
|
||||||
@ValidateNested({ each: true })
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
@Type(() => CreateInstallmentDto)
|
@Min(0.01)
|
||||||
installments?: CreateInstallmentDto[];
|
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' })
|
@Column({ name: 'source', length: 20, default: 'manual' })
|
||||||
source: string;
|
source: string;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_time', type: 'datetime', nullable: true })
|
||||||
|
punchTime: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
|
||||||
|
punchSource: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
punchDeviceName: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
|
||||||
|
punchDeviceId: string | null;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,24 @@ export class Bill {
|
|||||||
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 20, default: 'draft' })
|
@Column({ type: 'varchar', length: 30, default: 'batch' })
|
||||||
|
source: 'batch' | 'student_utility';
|
||||||
|
|
||||||
|
@Column({ name: 'paid_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||||
|
paidAmount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'outstanding_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||||
|
outstandingAmount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'unpaid' })
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
|
@Column({ name: 'cancelled_at', type: 'datetime', nullable: true })
|
||||||
|
cancelledAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'cancel_reason', type: 'varchar', length: 300, nullable: true })
|
||||||
|
cancelReason: string | null;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'generated_at' })
|
@CreateDateColumn({ name: 'generated_at' })
|
||||||
generatedAt: Date;
|
generatedAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export class ClassSchedule {
|
|||||||
@Column({ name: 'end_time', length: 5 })
|
@Column({ name: 'end_time', length: 5 })
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
|
||||||
|
/** 课程开始前允许计入签到的分钟数。 */
|
||||||
|
@Column({ name: 'attendance_advance_minutes', type: 'integer', default: 30 })
|
||||||
|
attendanceAdvanceMinutes: number;
|
||||||
|
|
||||||
@Column({ name: 'start_date', type: 'date' })
|
@Column({ name: 'start_date', type: 'date' })
|
||||||
startDate: string;
|
startDate: string;
|
||||||
|
|
||||||
|
|||||||
@@ -30,14 +30,11 @@ export class ClassStudent {
|
|||||||
@JoinColumn({ name: 'student_id' })
|
@JoinColumn({ name: 'student_id' })
|
||||||
student: Student;
|
student: Student;
|
||||||
|
|
||||||
@Column({ name: 'enrollment_id', type: 'integer', nullable: true })
|
|
||||||
enrollmentId: number;
|
|
||||||
|
|
||||||
@Column({ name: 'join_date', type: 'date', nullable: true })
|
@Column({ name: 'join_date', type: 'date', nullable: true })
|
||||||
joinDate: string;
|
joinDate: string | null;
|
||||||
|
|
||||||
@Column({ name: 'leave_date', type: 'date', nullable: true })
|
@Column({ name: 'leave_date', type: 'date', nullable: true })
|
||||||
leaveDate: string;
|
leaveDate: string | null;
|
||||||
|
|
||||||
@Column({ name: 'status', length: 10, default: 'active' })
|
@Column({ name: 'status', length: 10, default: 'active' })
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import {
|
|||||||
import { Classroom } from './classroom.entity';
|
import { Classroom } from './classroom.entity';
|
||||||
import { Organization } from './organization.entity';
|
import { Organization } from './organization.entity';
|
||||||
|
|
||||||
|
export enum ClassroomRentalStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
ENDED = 'ended',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
@Entity('classroom_rentals')
|
@Entity('classroom_rentals')
|
||||||
@Index(['classroomId', 'startDate', 'endDate'])
|
@Index(['classroomId', 'startDate', 'endDate'])
|
||||||
export class ClassroomRental {
|
export class ClassroomRental {
|
||||||
@@ -57,8 +63,8 @@ export class ClassroomRental {
|
|||||||
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 20, default: 'active' })
|
@Column({ type: 'varchar', length: 20, default: ClassroomRentalStatus.ACTIVE })
|
||||||
status: string; // active / ended / cancelled
|
status: ClassroomRentalStatus | 'active' | 'ended' | 'cancelled';
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
notes: string;
|
notes: string;
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
export enum ClassroomStatus {
|
||||||
|
AVAILABLE = 'available',
|
||||||
|
MAINTENANCE = 'maintenance',
|
||||||
|
ARCHIVED = 'archived',
|
||||||
|
}
|
||||||
|
|
||||||
@Entity('classrooms')
|
@Entity('classrooms')
|
||||||
export class Classroom {
|
export class Classroom {
|
||||||
@@ -20,14 +26,12 @@ export class Classroom {
|
|||||||
@Column({ name: 'room_type', type: 'varchar', length: 20, default: '大' })
|
@Column({ name: 'room_type', type: 'varchar', length: 20, default: '大' })
|
||||||
roomType: string; // 大 / 次大 / 小
|
roomType: string; // 大 / 次大 / 小
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: ClassroomStatus.AVAILABLE })
|
||||||
@Column({ type: 'varchar', length: 20, default: 'reserved' })
|
status: ClassroomStatus | 'available' | 'maintenance' | 'archived';
|
||||||
status: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
notes: string;
|
notes: string;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export class Deposit {
|
|||||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 500 })
|
@Column({ type: 'decimal', precision: 10, scale: 2, default: 500 })
|
||||||
amount: number;
|
amount: number;
|
||||||
|
|
||||||
// paid: 已缴 | refunded: 已退 | deducted: 已扣除(部分或全部)
|
// paid: 有可用余额 | refunded: 余额已全部退还 | depleted: 余额已被账单扣完
|
||||||
@Column({ type: 'varchar', length: 20, default: 'paid' })
|
@Column({ type: 'varchar', length: 20, default: 'paid' })
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
@@ -43,23 +43,14 @@ export class Deposit {
|
|||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
notes: string;
|
notes: string;
|
||||||
|
|
||||||
@Column({ name: 'recorded_by', nullable: true })
|
@Column({ name: 'recorded_by', type: 'integer', nullable: true })
|
||||||
recordedBy: number;
|
recordedBy: number | null;
|
||||||
|
|
||||||
@Column({ name: 'refund_status', length: 30, nullable: true })
|
@Column({ name: 'refunded_by', type: 'integer', nullable: true })
|
||||||
refundStatus: string; // pending | head_teacher_approved | finance_approved | refunded
|
refundedBy: number | null;
|
||||||
|
|
||||||
@Column({ name: 'refund_requested_at', type: 'datetime', nullable: true })
|
@Column({ name: 'refunded_at', type: 'datetime', nullable: true })
|
||||||
refundRequestedAt: Date;
|
refundedAt: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'refund_approved_by', type: 'integer', nullable: true })
|
|
||||||
refundApprovedBy: number;
|
|
||||||
|
|
||||||
@Column({ name: 'refund_approved_at', type: 'datetime', nullable: true })
|
|
||||||
refundApprovedAt: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'refund_rejected_reason', length: 500, nullable: true })
|
|
||||||
refundRejectedReason: string;
|
|
||||||
|
|
||||||
@OneToMany(() => DepositInstallment, (i) => i.deposit, { cascade: true, eager: true })
|
@OneToMany(() => DepositInstallment, (i) => i.deposit, { cascade: true, eager: true })
|
||||||
installments: DepositInstallment[];
|
installments: DepositInstallment[];
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ export class DingAttendanceRaw {
|
|||||||
@Column({ name: 'location_result', length: 20, nullable: true })
|
@Column({ name: 'location_result', length: 20, nullable: true })
|
||||||
locationResult: string;
|
locationResult: string;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
|
||||||
|
punchSource: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
punchDeviceName: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
|
||||||
|
punchDeviceId: string | null;
|
||||||
|
|
||||||
@Column({ name: 'match_status', length: 20, default: 'unmatched' })
|
@Column({ name: 'match_status', length: 20, default: 'unmatched' })
|
||||||
matchStatus: string;
|
matchStatus: string;
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ export { User } from './user.entity';
|
|||||||
export { OperationLog } from './operation-log.entity';
|
export { OperationLog } from './operation-log.entity';
|
||||||
export { Deposit } from './deposit.entity';
|
export { Deposit } from './deposit.entity';
|
||||||
export { DepositInstallment } from './deposit-installment.entity';
|
export { DepositInstallment } from './deposit-installment.entity';
|
||||||
export { Classroom } from './classroom.entity';
|
export { Classroom, ClassroomStatus } from './classroom.entity';
|
||||||
export { Organization } from './organization.entity';
|
export { Organization } from './organization.entity';
|
||||||
export { ClassroomRental } from './classroom-rental.entity';
|
export { ClassroomRental, ClassroomRentalStatus } from './classroom-rental.entity';
|
||||||
export { Permission } from './permission.entity';
|
export { Permission } from './permission.entity';
|
||||||
export { Role } from './role.entity';
|
export { Role } from './role.entity';
|
||||||
export { Class, ClassType, ClassStatus } from './class.entity';
|
export { Class, ClassType, ClassStatus } from './class.entity';
|
||||||
@@ -35,3 +35,6 @@ export { ResultArchive } from './result-archive.entity';
|
|||||||
export { ArchiveAttachment } from './archive-attachment.entity';
|
export { ArchiveAttachment } from './archive-attachment.entity';
|
||||||
export { StudentDingMapping } from './student-ding-mapping.entity';
|
export { StudentDingMapping } from './student-ding-mapping.entity';
|
||||||
export { AiConfig } from '../ai-config/ai-config.entity';
|
export { AiConfig } from '../ai-config/ai-config.entity';
|
||||||
|
|
||||||
|
export * from './student-wallet.entity';
|
||||||
|
export * from './wallet-transaction.entity';
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export class PersonalExpense {
|
|||||||
@Column({ name: 'recorded_by', nullable: true })
|
@Column({ name: 'recorded_by', nullable: true })
|
||||||
recordedBy: number;
|
recordedBy: number;
|
||||||
|
|
||||||
|
@Column({ name: 'bill_id', type: 'integer', nullable: true })
|
||||||
|
billId: number | null;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user