forked from wangziqi/gongxue-base
Compare commits
14 Commits
337d25e370
...
b9e4caae99
| Author | SHA1 | Date | |
|---|---|---|---|
| b9e4caae99 | |||
| f98c0ccaa8 | |||
| aa1ed7db56 | |||
| 0533c30ece | |||
| 1f32d1285b | |||
| 7b08560aef | |||
| 04ef5c42d9 | |||
| 0515e6ed27 | |||
| effa34434b | |||
| 0377acd33b | |||
| 6396d3e934 | |||
| dfd3cf6772 | |||
| 2874ab7bee | |||
| 8cadf8970e |
@@ -19,6 +19,7 @@ interface ClassStudent {
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
joinDate: string;
|
||||
leaveDate: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -305,6 +306,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
{ title: '姓名', dataIndex: 'studentName' },
|
||||
{ title: '学号', dataIndex: 'studentNo' },
|
||||
{ title: '加入日期', dataIndex: 'joinDate' },
|
||||
{ title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -316,11 +318,12 @@ const ClassDetailPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, r: ClassStudent) => (
|
||||
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
|
||||
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
||||
</Popconfirm>
|
||||
),
|
||||
render: (_: unknown, r: ClassStudent) =>
|
||||
r.status === 'active' ? (
|
||||
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
|
||||
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
||||
</Popconfirm>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Tooltip,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined, StopOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
@@ -39,6 +39,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>();
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [unavailableDates, setUnavailableDates] = useState<Set<string>>(new Set());
|
||||
@@ -48,20 +49,22 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
const selectedClassroomId = Form.useWatch('classroomId', form);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchText) return data;
|
||||
const s = searchText.toLowerCase();
|
||||
return data.filter((r: any) => {
|
||||
if (filterStatus && r.effectiveStatus !== filterStatus) return false;
|
||||
if (!searchText) return true;
|
||||
const s = searchText.toLowerCase();
|
||||
const matchClassroom = r.classroom?.name?.toLowerCase().includes(s);
|
||||
const matchOrganization = r.lesseeOrganization?.name?.toLowerCase().includes(s);
|
||||
return matchClassroom || matchOrganization;
|
||||
});
|
||||
}, [data, searchText]);
|
||||
}, [data, searchText, filterStatus]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
|
||||
params.includeEnded = true;
|
||||
const res: any = await api.get('/classroom-rentals', { params });
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
@@ -215,6 +218,16 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRentalAction = async (id: number, action: 'cancel' | 'end') => {
|
||||
try {
|
||||
await api.put(`/classroom-rentals/${id}/${action}`);
|
||||
message.success(action === 'cancel' ? '租赁已取消' : '租赁已结束');
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadContract = async (id: number, filename?: string) => {
|
||||
try {
|
||||
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
|
||||
@@ -308,6 +321,19 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
width: 100,
|
||||
render: (v: any) => (v ? `¥${v}` : '-'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'effectiveStatus',
|
||||
width: 90,
|
||||
render: (status: string) => {
|
||||
const config: Record<string, { text: string; color: string }> = {
|
||||
active: { text: '进行中', color: 'green' },
|
||||
ended: { text: '已结束', color: 'default' },
|
||||
cancelled: { text: '已取消', color: 'red' },
|
||||
};
|
||||
return <Tag color={config[status]?.color}>{config[status]?.text || status}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '合同',
|
||||
width: 120,
|
||||
@@ -364,21 +390,30 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
width: 150,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="rental:edit"
|
||||
size="small"
|
||||
onClick={() => openEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
<Popconfirm
|
||||
title="确定删除该租赁订单?合同文件将一并删除。"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<PermissionButton permission="rental:delete" size="small" danger>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
{record.effectiveStatus === 'active' && (
|
||||
<>
|
||||
<PermissionButton permission="rental:edit" size="small" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
<Popconfirm title="确定取消该租赁?" onConfirm={() => handleRentalAction(record.id, 'cancel')}>
|
||||
<PermissionButton permission="rental:edit" size="small" danger icon={<StopOutlined />}>
|
||||
取消
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
{!dayjs(record.startDate).isAfter(dayjs(), 'day') && (
|
||||
<Popconfirm title="确定今天结束该租赁?" onConfirm={() => handleRentalAction(record.id, 'end')}>
|
||||
<PermissionButton permission="rental:edit" size="small" icon={<CheckOutlined />}>
|
||||
结束
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{record.effectiveStatus !== 'active' && (
|
||||
<Popconfirm title="确定删除该租赁订单?合同文件将一并删除。" onConfirm={() => handleDelete(record.id)}>
|
||||
<PermissionButton permission="rental:delete" size="small" danger>删除</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -415,6 +450,18 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
allowClear
|
||||
format="YYYY-MM"
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
allowClear
|
||||
style={{ width: 110 }}
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
options={[
|
||||
{ value: 'active', label: '进行中' },
|
||||
{ value: 'ended', label: '已结束' },
|
||||
{ value: 'cancelled', label: '已取消' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<PermissionButton
|
||||
permission="rental:create"
|
||||
@@ -459,7 +506,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
optionFilterProp="label"
|
||||
placeholder="选择教室"
|
||||
onChange={handleClassroomChange}
|
||||
options={classrooms.map((c) => ({
|
||||
options={classrooms.filter((c) => c.status === 'available').map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.building ? c.building + ' · ' : ''}${c.name}(${c.roomType})`,
|
||||
}))}
|
||||
|
||||
@@ -61,7 +61,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data;
|
||||
if (searchText) { const s = searchText.toLowerCase(); result = result.filter((d: Record<string, unknown>) => (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || (typeof d.building === 'string' && d.building.toLowerCase().includes(s))); }
|
||||
if (filterStatus) result = result.filter((d: Record<string, unknown>) => d.status === filterStatus);
|
||||
if (filterStatus) result = result.filter((d: Record<string, unknown>) => d.effectiveStatus === filterStatus);
|
||||
return result;
|
||||
}, [data, searchText, filterStatus]);
|
||||
|
||||
@@ -157,11 +157,11 @@ const ClassroomsPage: React.FC = () => {
|
||||
{
|
||||
title: '状态', width: 100,
|
||||
dataIndex: 'status',
|
||||
render: (s: string, record: { currentUsage?: CurrentUsage | null }) => {
|
||||
const effectiveStatus = record.currentUsage ? 'in_use' : s;
|
||||
render: (_s: string, record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null }) => {
|
||||
const effectiveStatus = record.effectiveStatus || record.status;
|
||||
return (
|
||||
<Tooltip title={record.currentUsage ? `${record.currentUsage.title} (${record.currentUsage.startTime}-${record.currentUsage.endTime})` : undefined}>
|
||||
<Tag color={statusMap[effectiveStatus]?.color}>{statusMap[effectiveStatus]?.text || s}</Tag>
|
||||
<Tag color={statusMap[effectiveStatus]?.color}>{statusMap[effectiveStatus]?.text || effectiveStatus}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
@@ -228,7 +228,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
if (!e.target.value) setSearchText('');
|
||||
}}
|
||||
/>
|
||||
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus} options={[{value:'available',label:'可用'},{value:'in_use',label:'使用中'},{value:'reserved',label:'已预留'},{value:'maintenance',label:'维护中'}]} />
|
||||
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus} options={[{value:'available',label:'可用'},{value:'in_use',label:'使用中'},{value:'reserved',label:'已预留'},{value:'maintenance',label:'维护中'},{value:'archived',label:'已归档'}]} />
|
||||
<Button
|
||||
type={showArchived ? 'primary' : 'default'}
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
@@ -323,6 +323,16 @@ const ClassroomsPage: React.FC = () => {
|
||||
<Form.Item name="capacity" label="容量">
|
||||
<InputNumber min={1} max={500} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
{editing && (
|
||||
<Form.Item name="status" label="基础状态">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'available', label: '可用' },
|
||||
{ value: 'maintenance', label: '维护中' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDepositStudentOption } 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)',
|
||||
});
|
||||
});
|
||||
});
|
||||
10
apps/admin/src/pages/Deposits/deposit-student-option.ts
Normal file
10
apps/admin/src/pages/Deposits/deposit-student-option.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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}`})`,
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { buildDepositStudentOption } from './deposit-student-option';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
paid: { text: '已缴', color: 'green' },
|
||||
@@ -27,13 +28,6 @@ const statusMap: Record<string, { text: string; color: string }> = {
|
||||
deducted: { text: '已全扣', color: 'red' },
|
||||
};
|
||||
|
||||
const refundStatusMap: Record<string, { text: string; color: string }> = {
|
||||
pending: { text: '历史退款处理中', color: 'orange' },
|
||||
head_teacher_approved: { text: '历史退款处理中', color: 'blue' },
|
||||
finance_approved: { text: '已退款', color: 'green' },
|
||||
refunded: { text: '已退款', color: 'green' },
|
||||
};
|
||||
|
||||
const installmentStatusMap: Record<string, { text: string; color: string }> = {
|
||||
pending: { text: '待缴', color: 'orange' },
|
||||
paid: { text: '已缴', color: 'green' },
|
||||
@@ -90,10 +84,8 @@ const DepositsPage: React.FC = () => {
|
||||
const studentOptions = useMemo(
|
||||
() =>
|
||||
students
|
||||
.map((s: any) => ({
|
||||
value: s.id,
|
||||
label: s.studentNo ? `${s.name} (${s.studentNo})` : s.name,
|
||||
})),
|
||||
.filter((s: any) => s.status === 'active')
|
||||
.map(buildDepositStudentOption),
|
||||
[students],
|
||||
);
|
||||
|
||||
@@ -188,12 +180,6 @@ const DepositsPage: React.FC = () => {
|
||||
dataIndex: 'status',
|
||||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '退款状态',
|
||||
dataIndex: 'refundStatus',
|
||||
render: (s: string) =>
|
||||
s ? <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag> : '-',
|
||||
},
|
||||
{
|
||||
title: '退还金额',
|
||||
dataIndex: 'refundAmount',
|
||||
@@ -221,7 +207,7 @@ const DepositsPage: React.FC = () => {
|
||||
>
|
||||
详情
|
||||
</PermissionButton>
|
||||
{record.status === 'paid' && !record.refundStatus && (
|
||||
{record.status === 'paid' && (
|
||||
<>
|
||||
<PermissionButton
|
||||
permission="deposit:refund"
|
||||
@@ -408,14 +394,6 @@ const DepositsPage: React.FC = () => {
|
||||
{statusMap[detailModal.status]?.text || detailModal.status}
|
||||
</Tag>
|
||||
</p>
|
||||
{detailModal.refundStatus && (
|
||||
<p>
|
||||
<strong>退款状态:</strong>{' '}
|
||||
<Tag color={refundStatusMap[detailModal.refundStatus]?.color}>
|
||||
{refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus}
|
||||
</Tag>
|
||||
</p>
|
||||
)}
|
||||
{detailModal.notes && <p><strong>备注:</strong> {detailModal.notes}</p>}
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Card, Form, Input, Button, Space, Spin, Switch, Alert, Descriptions, Tag,
|
||||
Tabs, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
|
||||
Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider,
|
||||
Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
|
||||
Row, Col, List,
|
||||
} from 'antd';
|
||||
import {
|
||||
@@ -15,12 +15,15 @@ import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import {
|
||||
buildDingTalkConfigPayload,
|
||||
isAppSecretRequired,
|
||||
type DingTalkConfigFormValues,
|
||||
} from './integration-config-form';
|
||||
|
||||
interface DingTalkConfig {
|
||||
agentId: string;
|
||||
appSecret: string;
|
||||
corpId: string;
|
||||
startEnable: boolean;
|
||||
}
|
||||
|
||||
interface DingOrgTreeNodeExt {
|
||||
@@ -94,9 +97,9 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [config, setConfig] = useState<DingTalkConfig | null>(null);
|
||||
const [verified, setVerified] = useState<boolean | null>(null);
|
||||
const [form] = Form.useForm<DingTalkConfig>();
|
||||
const [form] = Form.useForm<DingTalkConfigFormValues>();
|
||||
|
||||
// ── Sync Users Tab ──
|
||||
// ── Manual organization sync ──
|
||||
const [syncRootDeptId, setSyncRootDeptId] = useState<number | undefined>(undefined);
|
||||
const [orgTree, setOrgTree] = useState<DingOrgTreeNodeExt[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
@@ -137,9 +140,10 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
const payload = buildDingTalkConfigPayload(values);
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post('/integration/config', { type: 'DINGTALK', config: values });
|
||||
await api.post('/integration/config', { type: 'DINGTALK', config: payload });
|
||||
message.success('配置已保存');
|
||||
await fetchConfig();
|
||||
} catch (e: unknown) {
|
||||
@@ -152,11 +156,12 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
|
||||
const handleTest = async () => {
|
||||
const values = await form.validateFields();
|
||||
const payload = buildDingTalkConfigPayload(values);
|
||||
setTesting(true);
|
||||
try {
|
||||
const res = await api.post<{ success: boolean; message: string }>('/integration/config/test', {
|
||||
type: 'DINGTALK',
|
||||
config: values,
|
||||
config: payload,
|
||||
});
|
||||
setVerified(res.success);
|
||||
message.success(res.message);
|
||||
@@ -345,12 +350,8 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const syncTabItems = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
|
||||
? [
|
||||
{
|
||||
key: 'sync-users',
|
||||
label: '同步用户',
|
||||
children: (
|
||||
const syncPanel = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
|
||||
? (
|
||||
<div>
|
||||
<Alert
|
||||
type="info"
|
||||
@@ -519,77 +520,100 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'config',
|
||||
label: '配置',
|
||||
children: (
|
||||
<Spin spinning={loading}>
|
||||
{config && (
|
||||
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="启用同步">
|
||||
<Tag color={config.startEnable ? 'green' : 'default'}>
|
||||
{config.startEnable ? '已启用' : '未启用'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
message="配置钉钉应用凭证后,可使用组织架构同步、考勤导入和排班同步功能。"
|
||||
style={{ marginBottom: 24 }}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 480 }}>
|
||||
<Form.Item name="corpId" label="CorpId(企业ID)" rules={[{ required: true, message: '请输入 CorpId' }]}>
|
||||
<Input placeholder="dingxxxxxxxx" />
|
||||
</Form.Item>
|
||||
<Form.Item name="agentId" label="AppKey(应用凭证)" rules={[{ required: true, message: '请输入 AppKey' }]}>
|
||||
<Input placeholder="从钉钉开放平台获取" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="appSecret"
|
||||
label="AppSecret(应用密钥)"
|
||||
rules={[{ required: true, message: '请输入 AppSecret' }]}
|
||||
extra="保存后仅返回脱敏信息,重新编辑时需再次输入完整密钥"
|
||||
>
|
||||
<Input.Password placeholder="从钉钉开放平台获取" />
|
||||
</Form.Item>
|
||||
<Form.Item name="startEnable" label="启用同步" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<PermissionButton permission="integration:trigger" type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
|
||||
保存配置
|
||||
</PermissionButton>
|
||||
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
|
||||
测试连接
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Spin>
|
||||
),
|
||||
},
|
||||
...syncTabItems,
|
||||
];
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card title="钉钉集成配置" extra={
|
||||
<Space>
|
||||
{verified === true && <Tag icon={<CheckCircleOutlined />} color="success">已连接</Tag>}
|
||||
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error">未连接</Tag>}
|
||||
</Space>
|
||||
}>
|
||||
<Tabs items={tabItems} />
|
||||
<Card
|
||||
title="钉钉集成配置"
|
||||
extra={
|
||||
<Space>
|
||||
{verified === true && (
|
||||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||
已连接
|
||||
</Tag>
|
||||
)}
|
||||
{verified === false && (
|
||||
<Tag icon={<CloseCircleOutlined />} color="error">
|
||||
未连接
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{config && (
|
||||
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="同步方式">手动触发</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
message="配置钉钉应用凭证后,可在本页手动获取组织架构并导入用户。排班同步仍在排课管理中手动触发。"
|
||||
style={{ marginBottom: 24 }}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 520 }}>
|
||||
<Form.Item
|
||||
name="corpId"
|
||||
label="CorpId(企业ID)"
|
||||
rules={[{ required: true, message: '请输入 CorpId' }]}
|
||||
>
|
||||
<Input placeholder="dingxxxxxxxx" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="agentId"
|
||||
label="AppKey(应用凭证)"
|
||||
rules={[{ required: true, message: '请输入 AppKey' }]}
|
||||
>
|
||||
<Input placeholder="从钉钉开放平台获取" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="appSecret"
|
||||
label="AppSecret(应用密钥)"
|
||||
rules={[
|
||||
{
|
||||
required: isAppSecretRequired(!!config),
|
||||
message: '首次配置请输入 AppSecret',
|
||||
},
|
||||
]}
|
||||
extra={
|
||||
config
|
||||
? '已保存密钥;留空保持原值,输入新值将替换原密钥'
|
||||
: '首次配置需要填写完整 AppSecret'
|
||||
}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={config ? '留空保持已保存的密钥' : '从钉钉开放平台获取'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="integration:trigger"
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
保存配置
|
||||
</PermissionButton>
|
||||
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
|
||||
测试连接
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
{syncPanel && (
|
||||
<>
|
||||
<Divider titlePlacement="start">组织用户导入</Divider>
|
||||
{syncPanel}
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import { downloadBlob } from '../../utils/download';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { buildTransferPayload } from './occupancy-form';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -59,6 +60,8 @@ const OccupanciesPage: React.FC = () => {
|
||||
const [batchCheckOutForm] = Form.useForm();
|
||||
const [availableBeds, setAvailableBeds] = useState<any[]>([]);
|
||||
const [availableLockers, setAvailableLockers] = useState<any[]>([]);
|
||||
const [transferAvailableBeds, setTransferAvailableBeds] = useState<any[]>([]);
|
||||
const [transferAvailableLockers, setTransferAvailableLockers] = useState<any[]>([]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -110,6 +113,30 @@ const OccupanciesPage: React.FC = () => {
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleTransferRoomChange = async (roomId: number) => {
|
||||
transferForm.setFieldValue('newBedId', undefined);
|
||||
transferForm.setFieldValue('newLockerId', undefined);
|
||||
if (!roomId) {
|
||||
setTransferAvailableBeds([]);
|
||||
setTransferAvailableLockers([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [beds, lockers] = await Promise.all([
|
||||
api.get<any[]>(`/rooms/${roomId}/beds/available`),
|
||||
api.get<any[]>(`/rooms/${roomId}/lockers/available`),
|
||||
]);
|
||||
setTransferAvailableBeds(beds);
|
||||
setTransferAvailableLockers(lockers);
|
||||
if (beds.length === 1) transferForm.setFieldValue('newBedId', beds[0].id);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setTransferAvailableBeds([]);
|
||||
setTransferAvailableLockers([]);
|
||||
message.error('目标宿舍床位和柜子加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchText) return data;
|
||||
const keyword = searchText.toLowerCase();
|
||||
@@ -170,13 +197,10 @@ const OccupanciesPage: React.FC = () => {
|
||||
const values = await transferForm.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(`/occupancies/${transferModal.id}/transfer`, {
|
||||
newRoomId: values.newRoomId,
|
||||
transferDate: values.transferDate.format('YYYY-MM-DD'),
|
||||
oldBillingEndDate: values.oldBillingEndDate?.format('YYYY-MM-DD'),
|
||||
newBillingStartDate: values.newBillingStartDate?.format('YYYY-MM-DD'),
|
||||
reason: values.reason,
|
||||
});
|
||||
await api.put(
|
||||
`/occupancies/${transferModal.id}/transfer`,
|
||||
buildTransferPayload(values),
|
||||
);
|
||||
message.success('换房成功');
|
||||
setTransferModal(null);
|
||||
transferForm.resetFields();
|
||||
@@ -261,6 +285,9 @@ const OccupanciesPage: React.FC = () => {
|
||||
size="small"
|
||||
icon={<SwapOutlined />}
|
||||
onClick={() => {
|
||||
setTransferAvailableBeds([]);
|
||||
setTransferAvailableLockers([]);
|
||||
transferForm.resetFields();
|
||||
setTransferModal(record);
|
||||
transferForm.setFieldsValue({ transferDate: dayjs() });
|
||||
}}
|
||||
@@ -708,7 +735,12 @@ const OccupanciesPage: React.FC = () => {
|
||||
title={`换房 - ${transferModal?.student?.name}`}
|
||||
open={!!transferModal}
|
||||
onOk={handleTransfer}
|
||||
onCancel={() => setTransferModal(null)}
|
||||
onCancel={() => {
|
||||
setTransferModal(null);
|
||||
transferForm.resetFields();
|
||||
setTransferAvailableBeds([]);
|
||||
setTransferAvailableLockers([]);
|
||||
}}
|
||||
okText="确认换房"
|
||||
confirmLoading={saving}
|
||||
width={500}
|
||||
@@ -719,6 +751,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择目标宿舍"
|
||||
onChange={handleTransferRoomChange}
|
||||
options={rooms
|
||||
.filter((r: any) => r.id !== transferModal?.roomId)
|
||||
.map((r: any) => ({
|
||||
@@ -728,6 +761,38 @@ const OccupanciesPage: React.FC = () => {
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="newBedId"
|
||||
label="目标床位"
|
||||
rules={[{ required: true, message: '请选择目标床位' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请先选择目标宿舍"
|
||||
disabled={transferAvailableBeds.length === 0}
|
||||
options={transferAvailableBeds.map((bed) => ({
|
||||
value: bed.id,
|
||||
label: bed.bedNumber,
|
||||
}))}
|
||||
notFoundContent="目标宿舍暂无可用床位"
|
||||
/>
|
||||
</Form.Item>
|
||||
{transferAvailableBeds.length > 0 && (
|
||||
<div style={{ marginTop: -16, marginBottom: 16, color: '#888', fontSize: 12 }}>
|
||||
空闲 {transferAvailableBeds.length} 张床位
|
||||
</div>
|
||||
)}
|
||||
<Form.Item name="newLockerId" label="目标柜子(可选)">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="可选分配目标宿舍柜子"
|
||||
disabled={transferAvailableLockers.length === 0}
|
||||
options={transferAvailableLockers.map((locker) => ({
|
||||
value: locker.id,
|
||||
label: locker.lockerNumber,
|
||||
}))}
|
||||
notFoundContent="目标宿舍暂无可用柜子"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="transferDate" label="换房日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -159,13 +159,14 @@ const RoomsPage: React.FC = () => {
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
const payload = values;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await api.put(`/rooms/${editing.id}`, values);
|
||||
await api.put(`/rooms/${editing.id}`, payload);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await api.post('/rooms', values);
|
||||
await api.post('/rooms', payload);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
@@ -337,12 +338,6 @@ const RoomsPage: React.FC = () => {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '性别',
|
||||
dataIndex: 'gender',
|
||||
width: 80,
|
||||
render: (v: any) => (v ? <Tag color={v === '男' ? 'blue' : 'pink'}>{v}</Tag> : '-'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
|
||||
@@ -947,6 +947,19 @@ const SchedulesPage: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="notes"
|
||||
label="备注"
|
||||
rules={[{ max: 500, message: '备注不能超过500字' }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
placeholder="可填写排课说明、设备需求或临时调整原因"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="timeRange"
|
||||
label="上课时段"
|
||||
|
||||
@@ -15,10 +15,12 @@ describe('schedule edit form mapping', () => {
|
||||
endTime: '18:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
notes: '需要投影设备',
|
||||
});
|
||||
|
||||
expect(values.classroomId).toBe(1);
|
||||
expect(values.weekDay).toBe(5);
|
||||
expect(values.notes).toBe('需要投影设备');
|
||||
expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
|
||||
expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
|
||||
'2026-07-01',
|
||||
@@ -36,6 +38,7 @@ describe('schedule edit form mapping', () => {
|
||||
teacherId: 4,
|
||||
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
|
||||
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
|
||||
notes: ' 临时调整教室 ',
|
||||
}),
|
||||
).toEqual({
|
||||
classId: 1,
|
||||
@@ -47,6 +50,24 @@ describe('schedule edit form mapping', () => {
|
||||
endTime: '17:20',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-31',
|
||||
notes: '临时调整教室',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('schedule notes normalization', () => {
|
||||
it('omits whitespace-only notes from the payload', () => {
|
||||
expect(
|
||||
buildSchedulePayload({
|
||||
classId: 1,
|
||||
classroomId: 2,
|
||||
weekDay: 6,
|
||||
subject: '作文',
|
||||
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
|
||||
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
|
||||
notes: ' ',
|
||||
}).notes,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface ScheduleFormValues {
|
||||
weekDay: number;
|
||||
subject: string;
|
||||
teacherId?: number;
|
||||
notes?: string;
|
||||
timeRange: [Dayjs, Dayjs];
|
||||
dateRange: [Dayjs, Dayjs];
|
||||
}
|
||||
@@ -17,6 +18,7 @@ export interface EditableSchedule {
|
||||
weekDay: number;
|
||||
subject: string;
|
||||
teacherId: number | null;
|
||||
notes?: string | null;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
startDate: string;
|
||||
@@ -29,6 +31,7 @@ export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormVa
|
||||
weekDay: schedule.weekDay,
|
||||
subject: schedule.subject,
|
||||
teacherId: schedule.teacherId ?? undefined,
|
||||
notes: schedule.notes ?? undefined,
|
||||
timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
|
||||
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
|
||||
});
|
||||
@@ -39,6 +42,7 @@ export const buildSchedulePayload = (values: ScheduleFormValues) => ({
|
||||
weekDay: values.weekDay,
|
||||
subject: values.subject,
|
||||
teacherId: values.teacherId,
|
||||
notes: values.notes?.trim() || undefined,
|
||||
startTime: values.timeRange[0].format('HH:mm'),
|
||||
endTime: values.timeRange[1].format('HH:mm'),
|
||||
startDate: values.dateRange[0].format('YYYY-MM-DD'),
|
||||
|
||||
@@ -15,6 +15,10 @@ import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
import {
|
||||
userProfileResponseToFormValues,
|
||||
type UserProfileResponse,
|
||||
} from './user-profile-form';
|
||||
|
||||
const UsersPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
@@ -36,8 +40,8 @@ const UsersPage: React.FC = () => {
|
||||
const handleOpenProfile = async (record: any) => {
|
||||
setProfileUser(record);
|
||||
try {
|
||||
const res: any = await api.get(`/rbac/users/${record.id}/profile`);
|
||||
profileForm.setFieldsValue(res);
|
||||
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
|
||||
profileForm.setFieldsValue(userProfileResponseToFormValues(res));
|
||||
} catch {
|
||||
profileForm.setFieldsValue({});
|
||||
}
|
||||
|
||||
@@ -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 || {};
|
||||
@@ -28,6 +28,7 @@
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/event-emitter": "^3.1.0",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/mapped-types": "^2.1.1",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
|
||||
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.emergencyContact || '-')}</span></div>
|
||||
<div class="summary-row"><span>紧急电话</span><span>${this.esc(student.emergencyPhone || '-')}</span></div>
|
||||
<div class="summary-row"><span>校区</span><span>${this.esc(profile?.campusLocation || '-')}</span></div>
|
||||
<div class="summary-row"><span>年级</span><span>${this.esc(profile?.grade || '-')}</span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
@@ -20,8 +20,11 @@ import { ArchiveService } from './archive.service';
|
||||
import {
|
||||
UpsertProfileDto,
|
||||
CreateEnrollmentDto,
|
||||
UpdateEnrollmentDto,
|
||||
CreateExamScoreDto,
|
||||
UpdateExamScoreDto,
|
||||
CreateLearningRecordDto,
|
||||
UpdateLearningRecordDto,
|
||||
UpsertResultDto,
|
||||
} from './dto/archive.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
@@ -110,7 +113,7 @@ export class ArchiveController {
|
||||
@RequirePermission('student:edit')
|
||||
async updateEnrollment(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: Partial<CreateEnrollmentDto>,
|
||||
@Body() dto: UpdateEnrollmentDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
@@ -174,7 +177,7 @@ export class ArchiveController {
|
||||
@RequirePermission('student:edit')
|
||||
async updateExamScore(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: Partial<CreateExamScoreDto>,
|
||||
@Body() dto: UpdateExamScoreDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
@@ -238,7 +241,7 @@ export class ArchiveController {
|
||||
@RequirePermission('student:edit')
|
||||
async updateLearningRecord(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: Partial<CreateLearningRecordDto>,
|
||||
@Body() dto: UpdateLearningRecordDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
@@ -330,12 +333,12 @@ export class ArchiveController {
|
||||
@Param('id') id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(+studentId, +id);
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`inline; filename="${encodeURIComponent(fileName)}"`,
|
||||
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(
|
||||
+studentId,
|
||||
+id,
|
||||
);
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`);
|
||||
const stream = fs.createReadStream(fullPath);
|
||||
stream.pipe(res);
|
||||
}
|
||||
@@ -358,7 +361,6 @@ export class ArchiveController {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@Get(':studentId/report-html')
|
||||
@RequirePermission('student:view')
|
||||
async generateReportHtml(
|
||||
|
||||
37
apps/server/src/archive/archive.service.spec.ts
Normal file
37
apps/server/src/archive/archive.service.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ArchiveService } from './archive.service';
|
||||
|
||||
describe('ArchiveService.getProfile', () => {
|
||||
it('returns the admission archive under the public result field', async () => {
|
||||
const student = { id: 7, name: '测试学生' };
|
||||
const result = {
|
||||
id: 3,
|
||||
studentId: 7,
|
||||
cultureFinalScore: 450,
|
||||
admittedCollege: '测试学院',
|
||||
};
|
||||
|
||||
const studentRepo = { findOne: jest.fn().mockResolvedValue(student) };
|
||||
const profileRepo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
const enrollmentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const examScoreRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const learningRecordRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const resultRepo = { findOne: jest.fn().mockResolvedValue(result) };
|
||||
const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
const service = new ArchiveService(
|
||||
studentRepo as never,
|
||||
profileRepo as never,
|
||||
enrollmentRepo as never,
|
||||
examScoreRepo as never,
|
||||
learningRecordRepo as never,
|
||||
resultRepo as never,
|
||||
attachmentRepo as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const response = await service.getProfile(7);
|
||||
|
||||
expect(response).toMatchObject({ student, result });
|
||||
expect(response).not.toHaveProperty('resultArchive');
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,11 @@ import { ArchiveAttachment } from '../entities/archive-attachment.entity';
|
||||
import {
|
||||
UpsertProfileDto,
|
||||
CreateEnrollmentDto,
|
||||
UpdateEnrollmentDto,
|
||||
CreateExamScoreDto,
|
||||
UpdateExamScoreDto,
|
||||
CreateLearningRecordDto,
|
||||
UpdateLearningRecordDto,
|
||||
UpsertResultDto,
|
||||
} from './dto/archive.dto';
|
||||
|
||||
@@ -44,7 +47,9 @@ export class ArchiveService {
|
||||
? path.resolve(process.cwd(), normalizedPath)
|
||||
: path.resolve(this.uploadDir, normalizedPath);
|
||||
const allowedRoots = [this.uploadDir, path.resolve(process.cwd(), 'uploads', 'archive')];
|
||||
if (!allowedRoots.some((root) => fullPath === root || fullPath.startsWith(`${root}${path.sep}`))) {
|
||||
if (
|
||||
!allowedRoots.some((root) => fullPath === root || fullPath.startsWith(`${root}${path.sep}`))
|
||||
) {
|
||||
throw new BadRequestException('路径非法');
|
||||
}
|
||||
return fullPath;
|
||||
@@ -54,21 +59,15 @@ export class ArchiveService {
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
const [
|
||||
profileRaw,
|
||||
enrollments,
|
||||
examScores,
|
||||
learningRecords,
|
||||
resultArchive,
|
||||
attachments,
|
||||
] = await Promise.all([
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
|
||||
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
]);
|
||||
const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments] =
|
||||
await Promise.all([
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
|
||||
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
student,
|
||||
@@ -76,7 +75,7 @@ export class ArchiveService {
|
||||
enrollments,
|
||||
examScores,
|
||||
learningRecords,
|
||||
resultArchive,
|
||||
result: resultArchive,
|
||||
attachments,
|
||||
};
|
||||
}
|
||||
@@ -102,7 +101,7 @@ export class ArchiveService {
|
||||
return this.enrollmentRepo.save(entity);
|
||||
}
|
||||
|
||||
async updateEnrollment(id: number, dto: Partial<CreateEnrollmentDto>) {
|
||||
async updateEnrollment(id: number, dto: UpdateEnrollmentDto) {
|
||||
const entity = await this.enrollmentRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('报名记录不存在');
|
||||
Object.assign(entity, dto);
|
||||
@@ -124,7 +123,7 @@ export class ArchiveService {
|
||||
return this.examScoreRepo.save(entity);
|
||||
}
|
||||
|
||||
async updateExamScore(id: number, dto: Partial<CreateExamScoreDto>) {
|
||||
async updateExamScore(id: number, dto: UpdateExamScoreDto) {
|
||||
const entity = await this.examScoreRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('考试成绩不存在');
|
||||
Object.assign(entity, dto);
|
||||
@@ -146,7 +145,7 @@ export class ArchiveService {
|
||||
return this.learningRecordRepo.save(entity);
|
||||
}
|
||||
|
||||
async updateLearningRecord(id: number, dto: Partial<CreateLearningRecordDto>) {
|
||||
async updateLearningRecord(id: number, dto: UpdateLearningRecordDto) {
|
||||
const entity = await this.learningRecordRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('学习记录不存在');
|
||||
Object.assign(entity, dto);
|
||||
@@ -225,4 +224,3 @@ export class ArchiveService {
|
||||
return { message: '已删除' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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';
|
||||
|
||||
export class UpsertProfileDto {
|
||||
@@ -5,7 +6,6 @@ export class UpsertProfileDto {
|
||||
@IsOptional() @IsString() targetMajor?: string;
|
||||
@IsOptional() @IsString() subjectDirection?: string;
|
||||
@IsOptional() @IsString() grade?: string;
|
||||
@IsOptional() @IsString() campusLocation?: string;
|
||||
@IsOptional() @IsDateString() profileDate?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export class CreateEnrollmentDto {
|
||||
@IsOptional() @IsString() status?: string;
|
||||
}
|
||||
|
||||
export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {}
|
||||
|
||||
export class CreateExamScoreDto {
|
||||
@IsString() examType: string;
|
||||
@IsOptional() @IsString() examName?: string;
|
||||
@@ -32,6 +34,8 @@ export class CreateExamScoreDto {
|
||||
@IsOptional() @IsNumber() enrollmentId?: number;
|
||||
}
|
||||
|
||||
export class UpdateExamScoreDto extends PartialType(CreateExamScoreDto) {}
|
||||
|
||||
export class CreateLearningRecordDto {
|
||||
@IsDateString() recordDate: string;
|
||||
@IsString() recordType: string;
|
||||
@@ -40,6 +44,8 @@ export class CreateLearningRecordDto {
|
||||
@IsOptional() @IsString() nextStep?: string;
|
||||
}
|
||||
|
||||
export class UpdateLearningRecordDto extends PartialType(CreateLearningRecordDto) {}
|
||||
|
||||
export class UpsertResultDto {
|
||||
@IsOptional() @IsNumber() cultureFinalScore?: number;
|
||||
@IsOptional() @IsNumber() professionalFinalScore?: number;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
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
|
||||
const allStudentIds = Array.from(dingToStudentId.values());
|
||||
const alreadyInClass = new Set<number>();
|
||||
if (allStudentIds.length > 0) {
|
||||
const existingClassStudents = await this.classStudentRepo.find({
|
||||
where: { classId, studentId: In(allStudentIds) },
|
||||
});
|
||||
for (const cs of existingClassStudents) {
|
||||
alreadyInClass.add(cs.studentId);
|
||||
const allStudentIds = Array.from(new Set(dingToStudentId.values()));
|
||||
const existingClassStudents =
|
||||
allStudentIds.length > 0
|
||||
? await this.classStudentRepo.find({
|
||||
where: { classId, studentId: In(allStudentIds) },
|
||||
})
|
||||
: [];
|
||||
const existingByStudentId = new Map(
|
||||
existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]),
|
||||
);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let skipped = 0;
|
||||
const memberships = allStudentIds.flatMap((studentId) => {
|
||||
const existing = existingByStudentId.get(studentId);
|
||||
if (existing?.status === 'active') {
|
||||
skipped++;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Batch insert new class-student records
|
||||
const newClassStudents = allStudentIds
|
||||
.filter((sid) => !alreadyInClass.has(sid))
|
||||
.map((studentId) =>
|
||||
if (existing) {
|
||||
existing.status = 'active';
|
||||
existing.joinDate = today;
|
||||
existing.leaveDate = null;
|
||||
return [existing];
|
||||
}
|
||||
return [
|
||||
this.classStudentRepo.create({
|
||||
classId,
|
||||
studentId,
|
||||
status: 'active',
|
||||
joinDate: new Date().toISOString().slice(0, 10),
|
||||
joinDate: today,
|
||||
}),
|
||||
);
|
||||
];
|
||||
});
|
||||
|
||||
if (newClassStudents.length > 0) {
|
||||
await this.classStudentRepo.save(newClassStudents);
|
||||
if (memberships.length > 0) {
|
||||
await this.classStudentRepo.save(memberships);
|
||||
}
|
||||
|
||||
return { imported: newClassStudents.length, skipped: alreadyInClass.size };
|
||||
return { imported: memberships.length, skipped };
|
||||
}
|
||||
async update(id: number, dto: UpdateClassDto) {
|
||||
const cls = await this.classRepo.findOne({ where: { id } });
|
||||
@@ -315,26 +326,61 @@ export class ClassesService {
|
||||
}
|
||||
|
||||
async addStudents(classId: number, studentIds: number[]) {
|
||||
const uniqueStudentIds = [...new Set(studentIds)];
|
||||
if (uniqueStudentIds.length === 0) return { added: 0, skipped: 0 };
|
||||
|
||||
const cls = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
|
||||
const students = await this.studentRepo.find({ where: { id: In(uniqueStudentIds) } });
|
||||
if (students.length !== uniqueStudentIds.length) {
|
||||
throw new NotFoundException('部分学生不存在');
|
||||
}
|
||||
|
||||
const existing = await this.classStudentRepo.find({
|
||||
where: { classId, studentId: In(studentIds) },
|
||||
where: { classId, studentId: In(uniqueStudentIds) },
|
||||
});
|
||||
const existingIds = new Set(existing.map((e) => e.studentId));
|
||||
const newIds = studentIds.filter((id) => !existingIds.has(id));
|
||||
|
||||
const entries = newIds.map((sid) =>
|
||||
this.classStudentRepo.create({
|
||||
classId,
|
||||
studentId: sid,
|
||||
joinDate: new Date().toISOString().split('T')[0],
|
||||
}),
|
||||
const existingByStudentId = new Map(
|
||||
existing.map((classStudent) => [classStudent.studentId, classStudent]),
|
||||
);
|
||||
if (entries.length) await this.classStudentRepo.save(entries);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
let skipped = 0;
|
||||
const memberships = uniqueStudentIds.flatMap((studentId) => {
|
||||
const current = existingByStudentId.get(studentId);
|
||||
if (current?.status === 'active') {
|
||||
skipped++;
|
||||
return [];
|
||||
}
|
||||
if (current) {
|
||||
current.status = 'active';
|
||||
current.joinDate = today;
|
||||
current.leaveDate = null;
|
||||
return [current];
|
||||
}
|
||||
return [
|
||||
this.classStudentRepo.create({
|
||||
classId,
|
||||
studentId,
|
||||
status: 'active',
|
||||
joinDate: today,
|
||||
}),
|
||||
];
|
||||
});
|
||||
if (memberships.length) await this.classStudentRepo.save(memberships);
|
||||
|
||||
return { added: entries.length, skipped: studentIds.length - entries.length };
|
||||
return { added: memberships.length, skipped };
|
||||
}
|
||||
|
||||
async removeStudent(classId: number, studentId: number) {
|
||||
await this.classStudentRepo.delete({ classId, studentId });
|
||||
const membership = await this.classStudentRepo.findOne({
|
||||
where: { classId, studentId },
|
||||
});
|
||||
if (!membership) throw new NotFoundException('学生不在该班级');
|
||||
if (membership.status !== 'active') throw new BadRequestException('学生已离班');
|
||||
|
||||
membership.status = 'left';
|
||||
membership.leaveDate = new Date().toISOString().split('T')[0];
|
||||
await this.classStudentRepo.save(membership);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,42 @@ export class ClassroomRentalsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/cancel')
|
||||
@RequirePermission('rental:edit')
|
||||
async cancel(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.cancel(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '教室租赁',
|
||||
action: '取消租赁',
|
||||
targetId: +id,
|
||||
targetType: 'classroom-rental',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/end')
|
||||
@RequirePermission('rental:edit')
|
||||
async end(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.end(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '教室租赁',
|
||||
action: '结束租赁',
|
||||
targetId: +id,
|
||||
targetType: 'classroom-rental',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('rental:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
|
||||
@@ -243,7 +243,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
};
|
||||
const classroom = { id: 1, departmentId: 10 } as Classroom;
|
||||
const classroom = { id: 1, status: 'available' } as Classroom;
|
||||
const hostOrganization = {
|
||||
id: 1,
|
||||
name: 'Host',
|
||||
@@ -315,8 +315,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
id: 1,
|
||||
classroomId: 1,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2099-03-31',
|
||||
status: 'active',
|
||||
notes: '',
|
||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||
@@ -324,8 +324,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
} as ClassroomRental;
|
||||
const updatedRental = {
|
||||
...existingRental,
|
||||
startDate: '2026-04-01',
|
||||
endDate: '2026-04-30',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2099-04-30',
|
||||
};
|
||||
const existingSchedule = {
|
||||
id: 50,
|
||||
@@ -339,12 +339,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
|
||||
scheduleRepo.findOne.mockResolvedValue(existingSchedule);
|
||||
|
||||
const dto: UpdateRentalDto = { startDate: '2026-04-01', endDate: '2026-04-30' };
|
||||
const dto: UpdateRentalDto = { startDate: '2026-08-01', endDate: '2099-04-30' };
|
||||
await service.update(1, dto);
|
||||
|
||||
expect(rentalRepo.update).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ startDate: '2026-04-01', endDate: '2026-04-30' }),
|
||||
expect.objectContaining({ startDate: '2026-08-01', endDate: '2099-04-30' }),
|
||||
);
|
||||
expect(scheduleRepo.update).toHaveBeenCalledWith(
|
||||
50,
|
||||
@@ -352,8 +352,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
scheduleType: 'RENTAL',
|
||||
rentalId: 1,
|
||||
classroomId: 1,
|
||||
startDate: '2026-04-01',
|
||||
endDate: '2026-04-30',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2099-04-30',
|
||||
status: 'active',
|
||||
subject: 'Organization A 租赁',
|
||||
}),
|
||||
@@ -362,27 +362,59 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
expect(scheduleRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes the RENTAL schedule row when status changes to cancelled', async () => {
|
||||
it('deletes the RENTAL schedule row when the rental is cancelled', async () => {
|
||||
const rental = {
|
||||
id: 1,
|
||||
classroomId: 1,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2099-03-31',
|
||||
status: 'active',
|
||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||
} as ClassroomRental;
|
||||
const cancelledRental = { ...rental, status: 'cancelled' };
|
||||
const cancelledRental = { ...rental, status: 'cancelled' } as ClassroomRental;
|
||||
|
||||
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(cancelledRental);
|
||||
|
||||
await service.update(1, { status: 'cancelled' });
|
||||
await service.cancel(1);
|
||||
|
||||
expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' });
|
||||
expect(scheduleRepo.delete).toHaveBeenCalledWith({ rentalId: 1, scheduleType: 'RENTAL' });
|
||||
expect(scheduleRepo.findOne).not.toHaveBeenCalled();
|
||||
expect(scheduleRepo.update).not.toHaveBeenCalled();
|
||||
expect(scheduleRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle actions', () => {
|
||||
it('ends an active rental and shortens a future end date', async () => {
|
||||
const rental = {
|
||||
id: 1,
|
||||
classroomId: 1,
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2099-12-31',
|
||||
status: 'active',
|
||||
lesseeOrganization: { name: 'Organization A' },
|
||||
} as ClassroomRental;
|
||||
const ended = { ...rental, status: 'ended', endDate: '2026-07-13' } as ClassroomRental;
|
||||
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(ended);
|
||||
scheduleRepo.findOne.mockResolvedValue({ id: 50 } as ClassSchedule);
|
||||
|
||||
await service.end(1);
|
||||
|
||||
expect(rentalRepo.update).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ status: 'ended' }),
|
||||
);
|
||||
expect(scheduleRepo.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects ending a future rental', async () => {
|
||||
rentalRepo.findOne.mockResolvedValue({
|
||||
id: 1,
|
||||
startDate: '2099-01-01',
|
||||
endDate: '2099-12-31',
|
||||
status: 'active',
|
||||
} as ClassroomRental);
|
||||
|
||||
await expect(service.end(1)).rejects.toThrow('租赁尚未开始');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -394,7 +426,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
status: 'active',
|
||||
status: 'cancelled',
|
||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||
} as ClassroomRental;
|
||||
|
||||
@@ -419,7 +451,7 @@ describe('ClassroomRentalsService — organization roles', () => {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassroomRental>([])),
|
||||
} as any;
|
||||
const classroomRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1, departmentId: 10 }),
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1, status: 'available' }),
|
||||
} as any;
|
||||
const organizationRepo = {
|
||||
findOne: jest
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
|
||||
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
|
||||
@@ -70,8 +70,11 @@ export class ClassroomRentalsService {
|
||||
const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last });
|
||||
}
|
||||
if (!query?.includeEnded) qb.andWhere('r.status != :cancelled', { cancelled: 'cancelled' });
|
||||
return qb.getMany();
|
||||
if (!query?.includeEnded) {
|
||||
qb.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE });
|
||||
}
|
||||
const rentals = await qb.getMany();
|
||||
return rentals.map((rental) => this.withEffectiveStatus(rental));
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
@@ -80,7 +83,7 @@ export class ClassroomRentalsService {
|
||||
relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'],
|
||||
});
|
||||
if (!rental) throw new NotFoundException('租赁订单不存在');
|
||||
return rental;
|
||||
return this.withEffectiveStatus(rental);
|
||||
}
|
||||
|
||||
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
|
||||
@@ -93,7 +96,7 @@ export class ClassroomRentalsService {
|
||||
where: {
|
||||
...(excludeId ? { id: Not(excludeId) } : {}),
|
||||
classroomId,
|
||||
status: Not('cancelled'),
|
||||
status: ClassroomRentalStatus.ACTIVE,
|
||||
startDate: LessThanOrEqual(monthEnd),
|
||||
endDate: MoreThanOrEqual(monthStart),
|
||||
},
|
||||
@@ -101,7 +104,7 @@ export class ClassroomRentalsService {
|
||||
this.scheduleRepo.find({
|
||||
where: {
|
||||
classroomId,
|
||||
status: 'active',
|
||||
status: ClassroomRentalStatus.ACTIVE,
|
||||
scheduleType: 'INTERNAL',
|
||||
startDate: LessThanOrEqual(monthEnd),
|
||||
endDate: MoreThanOrEqual(monthStart),
|
||||
@@ -133,7 +136,7 @@ export class ClassroomRentalsService {
|
||||
.createQueryBuilder('r')
|
||||
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
||||
.where('r.classroomId = :cid', { cid: classroomId })
|
||||
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
|
||||
.andWhere('r.startDate <= :end', { end: endDate })
|
||||
.andWhere('r.endDate >= :start', { start: startDate });
|
||||
if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId });
|
||||
@@ -222,6 +225,9 @@ export class ClassroomRentalsService {
|
||||
if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期');
|
||||
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
if (classroom.status !== ClassroomStatus.AVAILABLE) {
|
||||
throw new BadRequestException('仅可用教室可以创建租赁');
|
||||
}
|
||||
const lessorOrganization = dto.lessorOrganizationId
|
||||
? await this.organizationRepo.findOne({
|
||||
where: { id: dto.lessorOrganizationId, status: 'active' },
|
||||
@@ -253,7 +259,7 @@ export class ClassroomRentalsService {
|
||||
lessorOrganizationId: lessorOrganization.id,
|
||||
lesseeOrganizationId: lesseeOrganization.id,
|
||||
createdBy: userId,
|
||||
status: 'active',
|
||||
status: ClassroomRentalStatus.ACTIVE,
|
||||
});
|
||||
const saved = await this.repo.save(rental);
|
||||
await this.syncScheduleFromRental(saved, lesseeOrganization.name);
|
||||
@@ -262,11 +268,21 @@ export class ClassroomRentalsService {
|
||||
|
||||
async update(id: number, dto: UpdateRentalDto) {
|
||||
const rental = await this.findOne(id);
|
||||
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
|
||||
throw new BadRequestException('已结束或已取消的租赁不能编辑');
|
||||
}
|
||||
// 若修改了教室/日期,重新冲突检查
|
||||
const newClassroomId = dto.classroomId ?? rental.classroomId;
|
||||
const newStart = dto.startDate ?? rental.startDate;
|
||||
const newEnd = dto.endDate ?? rental.endDate;
|
||||
if (newStart > newEnd) throw new BadRequestException('起始日期不能晚于结束日期');
|
||||
if (dto.classroomId && dto.classroomId !== rental.classroomId) {
|
||||
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
if (classroom.status !== ClassroomStatus.AVAILABLE) {
|
||||
throw new BadRequestException('仅可用教室可以承接租赁');
|
||||
}
|
||||
}
|
||||
if (dto.classroomId || dto.startDate || dto.endDate) {
|
||||
const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id);
|
||||
if (conflicts.length > 0) {
|
||||
@@ -300,16 +316,46 @@ export class ClassroomRentalsService {
|
||||
}
|
||||
await this.repo.update(id, dto);
|
||||
const updated = await this.findOne(id);
|
||||
if (dto.status === 'cancelled') {
|
||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||
} else {
|
||||
await this.syncScheduleFromRental(updated);
|
||||
}
|
||||
await this.syncScheduleFromRental(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async cancel(id: number) {
|
||||
const rental = await this.findOne(id);
|
||||
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
|
||||
throw new BadRequestException('仅有效租赁可以取消');
|
||||
}
|
||||
await this.repo.update(id, { status: ClassroomRentalStatus.CANCELLED });
|
||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async end(id: number) {
|
||||
const rental = await this.findOne(id);
|
||||
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
|
||||
throw new BadRequestException('仅有效租赁可以结束');
|
||||
}
|
||||
const today = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
if (rental.startDate > today) throw new BadRequestException('租赁尚未开始,不能结束');
|
||||
await this.repo.update(id, {
|
||||
status: ClassroomRentalStatus.ENDED,
|
||||
endDate: rental.endDate > today ? today : rental.endDate,
|
||||
});
|
||||
const ended = await this.findOne(id);
|
||||
await this.syncScheduleFromRental(ended);
|
||||
return ended;
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const rental = await this.findOne(id);
|
||||
if (rental.effectiveStatus === ClassroomRentalStatus.ACTIVE) {
|
||||
throw new BadRequestException('进行中的租赁请先取消或结束');
|
||||
}
|
||||
// 同步删除对应排课记录
|
||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||
// 同时删除合同文件
|
||||
@@ -327,6 +373,20 @@ export class ClassroomRentalsService {
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
private withEffectiveStatus(rental: ClassroomRental) {
|
||||
const today = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
const effectiveStatus =
|
||||
rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today
|
||||
? ClassroomRentalStatus.ENDED
|
||||
: rental.status;
|
||||
return Object.assign(rental, { effectiveStatus });
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步租赁订单到 class_schedules(schedule_type = 'RENTAL')
|
||||
*/
|
||||
@@ -348,7 +408,7 @@ export class ClassroomRentalsService {
|
||||
teacherId: null,
|
||||
scheduleType: 'RENTAL',
|
||||
rentalId: rental.id,
|
||||
status: 'active',
|
||||
status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active',
|
||||
notes: rental.notes,
|
||||
};
|
||||
if (schedule) {
|
||||
@@ -437,14 +497,16 @@ export class ClassroomRentalsService {
|
||||
const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const classrooms = await this.classroomRepo.find({
|
||||
where: { status: Not('archived') },
|
||||
where: { status: Not(ClassroomStatus.ARCHIVED) },
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
const rentals = await this.repo
|
||||
.createQueryBuilder('r')
|
||||
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
||||
.leftJoinAndSelect('r.classroom', 'classroom')
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.where('r.status IN (:...statuses)', {
|
||||
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
|
||||
})
|
||||
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
|
||||
.getMany();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsOptional, IsString, IsInt, IsNumber, IsEnum, IsDateString } from 'class-validator';
|
||||
import { IsOptional, IsString, IsInt, IsNumber, IsDateString } from 'class-validator';
|
||||
|
||||
export class CreateRentalDto {
|
||||
@IsInt()
|
||||
@@ -62,8 +62,4 @@ export class UpdateRentalDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'ended', 'cancelled'])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Not } from 'typeorm';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Repository, Not, MoreThanOrEqual } from 'typeorm';
|
||||
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
|
||||
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ClassroomsService {
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Classroom) private repo: Repository<Classroom>,
|
||||
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
|
||||
@@ -21,49 +20,148 @@ export class ClassroomsService {
|
||||
if (query?.roomType) where.roomType = query.roomType;
|
||||
if (!query?.includeArchived) where.status = Not('archived');
|
||||
const list = await this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } });
|
||||
const usageMap = await this.getCurrentUsageForClassrooms(list.map((c) => c.id));
|
||||
return list.map((c) => ({ ...c, currentUsage: usageMap.get(c.id) ?? null }));
|
||||
const usageMap = await this.getUsageForClassrooms(list.map((c) => c.id));
|
||||
return list.map((classroom) => this.withEffectiveStatus(classroom, usageMap.get(classroom.id)));
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const cls = await this.repo.findOne({ where: { id } });
|
||||
if (!cls) throw new NotFoundException('教室不存在');
|
||||
const usageMap = await this.getCurrentUsageForClassrooms([id]);
|
||||
return { ...cls, currentUsage: usageMap.get(id) ?? null };
|
||||
const usageMap = await this.getUsageForClassrooms([id]);
|
||||
return this.withEffectiveStatus(cls, usageMap.get(id));
|
||||
}
|
||||
|
||||
async create(dto: CreateClassroomDto) {
|
||||
const exists = await this.repo.findOne({ where: { name: dto.name } });
|
||||
if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
|
||||
return this.repo.save(this.repo.create(dto));
|
||||
return this.repo.save(this.repo.create({ ...dto, status: ClassroomStatus.AVAILABLE }));
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateClassroomDto) {
|
||||
await this.findOne(id);
|
||||
const classroom = await this.repo.findOne({ where: { id } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
if (dto.status === ClassroomStatus.MAINTENANCE && classroom.status !== dto.status) {
|
||||
await this.assertNoActiveAllocations(id);
|
||||
}
|
||||
await this.repo.update(id, dto);
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
await this.findOne(id);
|
||||
await this.repo.update(id, { status: 'archived' });
|
||||
const classroom = await this.repo.findOne({ where: { id } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
await this.assertNoActiveAllocations(id);
|
||||
await this.repo.update(id, { status: ClassroomStatus.ARCHIVED });
|
||||
return { message: '已归档' };
|
||||
}
|
||||
|
||||
async restore(id: number) {
|
||||
await this.findOne(id);
|
||||
await this.repo.update(id, { status: 'reserved' });
|
||||
const classroom = await this.repo.findOne({ where: { id } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
await this.repo.update(id, { status: ClassroomStatus.AVAILABLE });
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
private async getCurrentUsageForClassrooms(classroomIds: number[]): Promise<Map<number, { type: 'schedule' | 'rental'; title: string; startTime: string; endTime: string }>> {
|
||||
const result = new Map<number, { type: 'schedule' | 'rental'; title: string; startTime: string; endTime: string }>();
|
||||
private withEffectiveStatus(
|
||||
classroom: Classroom,
|
||||
usage?: {
|
||||
state: 'in_use' | 'reserved';
|
||||
currentUsage: {
|
||||
type: 'schedule' | 'rental';
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
} | null;
|
||||
},
|
||||
) {
|
||||
const effectiveStatus =
|
||||
classroom.status === ClassroomStatus.ARCHIVED ||
|
||||
classroom.status === ClassroomStatus.MAINTENANCE
|
||||
? classroom.status
|
||||
: (usage?.state ?? ClassroomStatus.AVAILABLE);
|
||||
return { ...classroom, currentUsage: usage?.currentUsage ?? null, effectiveStatus };
|
||||
}
|
||||
|
||||
private async assertNoActiveAllocations(classroomId: number) {
|
||||
const today = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
const scheduleCount = await this.scheduleRepo
|
||||
.createQueryBuilder('schedule')
|
||||
.where('schedule.classroomId = :classroomId', { classroomId })
|
||||
.andWhere('schedule.status = :active', { active: 'active' })
|
||||
.andWhere('schedule.endDate >= :today', { today })
|
||||
.getCount();
|
||||
const rentalCount = await this.rentalRepo.count({
|
||||
where: {
|
||||
classroomId,
|
||||
status: ClassroomRentalStatus.ACTIVE,
|
||||
endDate: MoreThanOrEqual(today),
|
||||
},
|
||||
});
|
||||
if (rentalCount > 0 || scheduleCount > 0) {
|
||||
throw new BadRequestException('该教室存在有效排课或租赁,无法维护或归档');
|
||||
}
|
||||
}
|
||||
|
||||
private async getUsageForClassrooms(classroomIds: number[]): Promise<
|
||||
Map<
|
||||
number,
|
||||
{
|
||||
state: 'in_use' | 'reserved';
|
||||
currentUsage: {
|
||||
type: 'schedule' | 'rental';
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
} | null;
|
||||
}
|
||||
>
|
||||
> {
|
||||
const result = new Map<
|
||||
number,
|
||||
{
|
||||
state: 'in_use' | 'reserved';
|
||||
currentUsage: {
|
||||
type: 'schedule' | 'rental';
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
} | null;
|
||||
}
|
||||
>();
|
||||
if (classroomIds.length === 0) return result;
|
||||
|
||||
const now = new Date();
|
||||
const todayStr = now.toISOString().slice(0, 10);
|
||||
const currentTime = now.toTimeString().slice(0, 5);
|
||||
const weekDay = now.getDay() || 7;
|
||||
const todayStr = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(now);
|
||||
const currentTime = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
const shanghaiParts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
weekday: 'short',
|
||||
}).format(now);
|
||||
const weekDayMap: Record<string, number> = {
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
Sun: 7,
|
||||
};
|
||||
const weekDay = weekDayMap[shanghaiParts];
|
||||
|
||||
const schedules = await this.scheduleRepo
|
||||
.createQueryBuilder('s')
|
||||
@@ -71,26 +169,37 @@ export class ClassroomsService {
|
||||
.select('s.classroomId', 'classroomId')
|
||||
.addSelect('s.startTime', 'startTime')
|
||||
.addSelect('s.endTime', 'endTime')
|
||||
.addSelect('s.startDate', 'startDate')
|
||||
.addSelect('s.endDate', 'endDate')
|
||||
.addSelect('s.weekDay', 'weekDay')
|
||||
.addSelect('s.subject', 'subject')
|
||||
.addSelect('c.name', 'className')
|
||||
.where('s.classroomId IN (:...ids)', { ids: classroomIds })
|
||||
.andWhere('s.status = :active', { active: 'active' })
|
||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||
.andWhere('s.startDate <= :today', { today: todayStr })
|
||||
.andWhere('s.endDate >= :today', { today: todayStr })
|
||||
.andWhere('s.weekDay = :weekDay', { weekDay })
|
||||
.andWhere('s.startTime <= :currentTime', { currentTime })
|
||||
.andWhere('s.endTime >= :currentTime', { currentTime })
|
||||
.getRawMany();
|
||||
|
||||
for (const s of schedules) {
|
||||
const classroomId = Number(s.classroomId);
|
||||
if (!result.has(classroomId)) {
|
||||
for (const schedule of schedules) {
|
||||
const classroomId = Number(schedule.classroomId);
|
||||
const isCurrent =
|
||||
String(schedule.startDate) <= todayStr &&
|
||||
String(schedule.endDate) >= todayStr &&
|
||||
Number(schedule.weekDay) === weekDay &&
|
||||
String(schedule.startTime) <= currentTime &&
|
||||
String(schedule.endTime) >= currentTime;
|
||||
const existing = result.get(classroomId);
|
||||
if (!existing || isCurrent) {
|
||||
result.set(classroomId, {
|
||||
type: 'schedule',
|
||||
title: `${s.className || ''} ${s.subject || ''}`.trim() || '内部课程',
|
||||
startTime: String(s.startTime),
|
||||
endTime: String(s.endTime),
|
||||
state: isCurrent ? 'in_use' : 'reserved',
|
||||
currentUsage: isCurrent
|
||||
? {
|
||||
type: 'schedule',
|
||||
title: `${schedule.className || ''} ${schedule.subject || ''}`.trim() || '内部课程',
|
||||
startTime: String(schedule.startTime),
|
||||
endTime: String(schedule.endTime),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -103,19 +212,25 @@ export class ClassroomsService {
|
||||
.addSelect('r.endDate', 'endDate')
|
||||
.addSelect('t.name', 'tenantName')
|
||||
.where('r.classroomId IN (:...ids)', { ids: classroomIds })
|
||||
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.startDate <= :today', { today: todayStr })
|
||||
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
|
||||
.andWhere('r.endDate >= :today', { today: todayStr })
|
||||
.getRawMany();
|
||||
|
||||
for (const r of rentals) {
|
||||
const classroomId = Number(r.classroomId);
|
||||
if (!result.has(classroomId)) {
|
||||
for (const rental of rentals) {
|
||||
const classroomId = Number(rental.classroomId);
|
||||
const isCurrent = String(rental.startDate) <= todayStr && String(rental.endDate) >= todayStr;
|
||||
const existing = result.get(classroomId);
|
||||
if (!existing || isCurrent) {
|
||||
result.set(classroomId, {
|
||||
type: 'rental',
|
||||
title: r.tenantName ? `${r.tenantName} 租赁` : '外部租赁',
|
||||
startTime: '00:00',
|
||||
endTime: '23:59',
|
||||
state: isCurrent ? 'in_use' : 'reserved',
|
||||
currentUsage: isCurrent
|
||||
? {
|
||||
type: 'rental',
|
||||
title: rental.tenantName ? `${rental.tenantName} 租赁` : '外部租赁',
|
||||
startTime: '00:00',
|
||||
endTime: '23:59',
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -136,24 +251,38 @@ export class ClassroomsService {
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (!row.name?.trim()) { skipped++; continue; }
|
||||
if (!row.name?.trim()) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
|
||||
if (exists) { errors.push(`教室 ${row.name} 已存在`); skipped++; continue; }
|
||||
if (exists) {
|
||||
errors.push(`教室 ${row.name} 已存在`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
|
||||
imported++;
|
||||
}
|
||||
return { message: `成功导入 ${imported} 间教室,跳过 ${skipped} 间`, imported, skipped, errors: errors.length > 0 ? errors : undefined };
|
||||
return {
|
||||
message: `成功导入 ${imported} 间教室,跳过 ${skipped} 间`,
|
||||
imported,
|
||||
skipped,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async getUsageReport(dateFrom: string, dateTo: string) {
|
||||
const classrooms = await this.repo.find({
|
||||
where: { status: Not('archived') },
|
||||
where: { status: Not(ClassroomStatus.ARCHIVED) },
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
|
||||
const rentals = await this.rentalRepo
|
||||
.createQueryBuilder('r')
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.where('r.status IN (:...statuses)', {
|
||||
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
|
||||
})
|
||||
.andWhere('r.startDate <= :dateTo AND r.endDate >= :dateFrom', { dateFrom, dateTo })
|
||||
.getMany();
|
||||
|
||||
|
||||
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 { ClassroomStatus } from '../../entities/classroom.entity';
|
||||
|
||||
export class CreateClassroomDto {
|
||||
@IsString()
|
||||
@@ -21,11 +22,9 @@ export class CreateClassroomDto {
|
||||
@IsString()
|
||||
roomType?: string; // 大 / 次大 / 小
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
}
|
||||
|
||||
export class UpdateClassroomDto {
|
||||
@@ -49,12 +48,11 @@ export class UpdateClassroomDto {
|
||||
@IsString()
|
||||
roomType?: string;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['available', 'archived'])
|
||||
status?: string;
|
||||
@IsEnum([ClassroomStatus.AVAILABLE, ClassroomStatus.MAINTENANCE])
|
||||
status?: ClassroomStatus;
|
||||
}
|
||||
|
||||
@@ -119,13 +119,12 @@ export class DashboardService {
|
||||
const pendingQb = this.depositRepo
|
||||
.createQueryBuilder('d')
|
||||
.select('SUM(d.amount)', 'total')
|
||||
.where('d.status = :paid', { paid: 'paid' })
|
||||
.andWhere('d.refundStatus IS NULL');
|
||||
.where('d.status = :paid', { paid: 'paid' });
|
||||
const pendingResult = await pendingQb.getRawOne();
|
||||
const pendingDeposits = parseFloat(pendingResult?.total || '0');
|
||||
|
||||
const activeRentals = await this.rentalRepo.count({
|
||||
where: { endDate: MoreThanOrEqual(todayStr) },
|
||||
where: { status: 'active' as const, endDate: MoreThanOrEqual(todayStr) },
|
||||
});
|
||||
|
||||
const occByBldQb = this.occRepo
|
||||
@@ -366,7 +365,7 @@ export class DashboardService {
|
||||
|
||||
async getClassroomOccupancy() {
|
||||
const classrooms = await this.classroomRepo.find({
|
||||
where: { status: Not('archived') },
|
||||
where: { status: 'available' as const },
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
@@ -383,7 +382,7 @@ export class DashboardService {
|
||||
.createQueryBuilder('r')
|
||||
.select('r.classroomId', 'classroomId')
|
||||
.addSelect('COUNT(*)', 'rentalCount')
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.where('r.status = :active', { active: 'active' })
|
||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
||||
.groupBy('r.classroomId');
|
||||
const rentals = await rentalQb.getRawMany();
|
||||
@@ -403,7 +402,7 @@ export class DashboardService {
|
||||
|
||||
async getClassroomUtilizationStats() {
|
||||
const totalClassrooms = await this.classroomRepo.count({
|
||||
where: { status: Not('archived') },
|
||||
where: { status: 'available' as const },
|
||||
});
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
@@ -421,7 +420,7 @@ export class DashboardService {
|
||||
const rentalQb = this.rentalRepo
|
||||
.createQueryBuilder('r')
|
||||
.select('COUNT(DISTINCT r.classroomId)', 'cnt')
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.where('r.status = :active', { active: 'active' })
|
||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
|
||||
const rentalResult = await rentalQb.getRawOne();
|
||||
|
||||
@@ -438,7 +437,7 @@ export class DashboardService {
|
||||
const combinedRentalQb = this.rentalRepo
|
||||
.createQueryBuilder('r')
|
||||
.select('r.classroomId')
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.where('r.status = :active', { active: 'active' })
|
||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
||||
.groupBy('r.classroomId');
|
||||
const rentalIds = await combinedRentalQb.getRawMany();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,10 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
await this.normalizeClassDates();
|
||||
await this.protectAttendanceHistory();
|
||||
await this.removeUnusedClassroomColumns();
|
||||
await this.removeUnusedRoomColumns();
|
||||
await this.cleanupDepositRefundColumns();
|
||||
await this.removeUnusedClassStudentColumns();
|
||||
await this.normalizeClassroomStatuses();
|
||||
}
|
||||
|
||||
private async removeUnusedClassroomColumns(): Promise<void> {
|
||||
@@ -36,6 +40,92 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
private async removeUnusedRoomColumns(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const tables = await runner.getTables(['rooms']);
|
||||
if (tables.length === 0) return;
|
||||
|
||||
const table = await runner.getTable('rooms');
|
||||
if (table?.columns.some((column) => column.name === 'gender')) {
|
||||
await runner.dropColumn('rooms', 'gender');
|
||||
}
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupDepositRefundColumns(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const tables = await runner.getTables(['deposits']);
|
||||
if (tables.length === 0) return;
|
||||
|
||||
const table = await runner.getTable('deposits');
|
||||
const columnNames = new Set(table?.columns.map((column) => column.name) ?? []);
|
||||
for (const [legacyName, currentName] of [
|
||||
['refund_approved_by', 'refunded_by'],
|
||||
['refund_approved_at', 'refunded_at'],
|
||||
] as const) {
|
||||
if (!columnNames.has(legacyName)) continue;
|
||||
|
||||
if (columnNames.has(currentName)) {
|
||||
await runner.query(
|
||||
`UPDATE deposits SET ${currentName} = COALESCE(${currentName}, ${legacyName})`,
|
||||
);
|
||||
await runner.dropColumn('deposits', legacyName);
|
||||
} else {
|
||||
await runner.renameColumn('deposits', legacyName, currentName);
|
||||
columnNames.add(currentName);
|
||||
}
|
||||
columnNames.delete(legacyName);
|
||||
}
|
||||
|
||||
for (const columnName of ['refund_status', 'refund_requested_at', 'refund_rejected_reason']) {
|
||||
if (columnNames.has(columnName)) {
|
||||
await runner.dropColumn('deposits', columnName);
|
||||
columnNames.delete(columnName);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async removeUnusedClassStudentColumns(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const tables = await runner.getTables(['class_student']);
|
||||
if (tables.length === 0) return;
|
||||
|
||||
const table = await runner.getTable('class_student');
|
||||
if (table?.columns.some((column) => column.name === 'enrollment_id')) {
|
||||
await runner.dropColumn('class_student', 'enrollment_id');
|
||||
}
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async normalizeClassroomStatuses(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const tables = await runner.getTables(['classrooms']);
|
||||
if (tables.length === 0) return;
|
||||
await runner.query(`
|
||||
UPDATE classrooms
|
||||
SET status = 'available'
|
||||
WHERE status IS NULL OR status NOT IN ('available', 'maintenance', 'archived')
|
||||
`);
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAiConfigTable(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
@@ -354,14 +444,17 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
// Drop any existing FK constraint on schedule_id or class_id
|
||||
const fkColumns = ['schedule_id', 'class_id'];
|
||||
for (const col of fkColumns) {
|
||||
const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(`
|
||||
const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(
|
||||
`
|
||||
SELECT CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'attendance_sessions'
|
||||
AND COLUMN_NAME = ?
|
||||
AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||
`, [col]);
|
||||
`,
|
||||
[col],
|
||||
);
|
||||
|
||||
for (const row of fkRows) {
|
||||
try {
|
||||
@@ -381,13 +474,16 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
];
|
||||
for (const c of constraints) {
|
||||
// Only skip if RESTRICT constraint is already confirmed via information_schema
|
||||
const existing: Array<{ DELETE_RULE: string }> = await runner.query(`
|
||||
const existing: Array<{ DELETE_RULE: string }> = await runner.query(
|
||||
`
|
||||
SELECT DELETE_RULE
|
||||
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'attendance_sessions'
|
||||
AND CONSTRAINT_NAME = ?
|
||||
`, [c.name]);
|
||||
`,
|
||||
[c.name],
|
||||
);
|
||||
|
||||
if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') {
|
||||
this.logger.log(`考勤场次删除保护约束已存在: ${c.name}`);
|
||||
@@ -449,17 +545,13 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
FROM attendance_sessions
|
||||
`);
|
||||
await runner.query('DROP TABLE attendance_sessions');
|
||||
await runner.query(
|
||||
'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions',
|
||||
);
|
||||
await runner.query('ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions');
|
||||
await runner.query(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
|
||||
);
|
||||
|
||||
// Rebuild attendance_records to add/protect FK on attendance_session_id
|
||||
const recordsFk = await runner.query(
|
||||
"PRAGMA foreign_key_list('attendance_records')",
|
||||
);
|
||||
const recordsFk = await runner.query("PRAGMA foreign_key_list('attendance_records')");
|
||||
const hasSessionFk = recordsFk.some(
|
||||
(r: { from: string }) => r.from === 'attendance_session_id',
|
||||
);
|
||||
@@ -492,9 +584,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
FROM attendance_records
|
||||
`);
|
||||
await runner.query('DROP TABLE attendance_records');
|
||||
await runner.query(
|
||||
'ALTER TABLE attendance_records_new RENAME TO attendance_records',
|
||||
);
|
||||
await runner.query('ALTER TABLE attendance_records_new RENAME TO attendance_records');
|
||||
await runner.query(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
|
||||
);
|
||||
@@ -504,9 +594,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
// If violations exist, the transaction rolls back and old tables are preserved.
|
||||
const checkRows = await runner.query('PRAGMA foreign_key_check');
|
||||
if (checkRows.length > 0) {
|
||||
throw new Error(
|
||||
`外键一致性检查失败: ${checkRows.length} 行违反外键约束`,
|
||||
);
|
||||
throw new Error(`外键一致性检查失败: ${checkRows.length} 行违反外键约束`);
|
||||
}
|
||||
|
||||
await runner.query('COMMIT');
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Student } from '../entities/student.entity';
|
||||
import { DepositsService } from './deposits.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationType } from '../entities/notification.entity';
|
||||
import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto';
|
||||
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
@@ -61,7 +61,7 @@ export class DepositsController {
|
||||
|
||||
@Post()
|
||||
@RequirePermission('deposit:create')
|
||||
async create(@Body() dto: CreateDepositDto | CreateDepositWithInstallmentsDto, @Request() req: any) {
|
||||
async create(@Body() dto: CreateDepositDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.create(dto, req.user?.id);
|
||||
await this.logService.log({
|
||||
|
||||
38
apps/server/src/deposits/deposits.refund.spec.ts
Normal file
38
apps/server/src/deposits/deposits.refund.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { DepositsService } from './deposits.service';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
|
||||
describe('DepositsService — direct refund', () => {
|
||||
it('stores the refund result on the main status and renamed audit fields', async () => {
|
||||
const deposit = {
|
||||
id: 1,
|
||||
amount: 500,
|
||||
status: 'paid',
|
||||
} as Deposit;
|
||||
const repo = {
|
||||
findOne: jest.fn().mockResolvedValue(deposit),
|
||||
save: jest.fn().mockImplementation(async (value: Deposit) => value),
|
||||
};
|
||||
const service = new DepositsService(repo as never, {} as never, {} as never);
|
||||
|
||||
const result = await service.refund(
|
||||
1,
|
||||
{
|
||||
refundDate: '2026-07-13',
|
||||
deductionAmount: 100,
|
||||
deductionReason: '物品损坏',
|
||||
},
|
||||
42,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
refundDate: '2026-07-13',
|
||||
refundAmount: 400,
|
||||
deductionAmount: 100,
|
||||
deductionReason: '物品损坏',
|
||||
status: 'partial_refund',
|
||||
refundedBy: 42,
|
||||
});
|
||||
expect(result.refundedAt).toBeInstanceOf(Date);
|
||||
expect(repo.save).toHaveBeenCalledWith(deposit);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { Deposit } from '../entities/deposit.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||
|
||||
import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto';
|
||||
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DepositsService {
|
||||
@@ -55,17 +55,6 @@ export class DepositsService {
|
||||
recordedBy: userId,
|
||||
});
|
||||
|
||||
if (dto instanceof CreateDepositWithInstallmentsDto && dto.installments?.length) {
|
||||
deposit.installments = dto.installments.map((i) => {
|
||||
const inst = this.installmentRepo.create({
|
||||
amount: i.amount,
|
||||
dueDate: i.dueDate,
|
||||
status: 'pending',
|
||||
});
|
||||
return inst;
|
||||
});
|
||||
}
|
||||
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
|
||||
@@ -114,9 +103,8 @@ export class DepositsService {
|
||||
deposit.status =
|
||||
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
|
||||
if (dto.notes) deposit.notes = dto.notes;
|
||||
deposit.refundStatus = 'refunded';
|
||||
deposit.refundApprovedBy = userId ?? null as unknown as number;
|
||||
deposit.refundApprovedAt = new Date();
|
||||
deposit.refundedBy = userId ?? null;
|
||||
deposit.refundedAt = new Date();
|
||||
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
|
||||
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,5 +1,4 @@
|
||||
import { IsInt, IsNumber, IsString, IsOptional, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsNumber, IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateDepositDto {
|
||||
@IsInt()
|
||||
@@ -16,16 +15,6 @@ export class CreateDepositDto {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class CreateInstallmentDto {
|
||||
@IsNumber()
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
dueDate: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export class RefundDepositDto {
|
||||
@IsString()
|
||||
refundDate: string;
|
||||
@@ -42,9 +31,3 @@ export class RefundDepositDto {
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
export class CreateDepositWithInstallmentsDto extends CreateDepositDto {
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateInstallmentDto)
|
||||
installments?: CreateInstallmentDto[];
|
||||
}
|
||||
|
||||
@@ -30,14 +30,11 @@ export class ClassStudent {
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@Column({ name: 'enrollment_id', type: 'integer', nullable: true })
|
||||
enrollmentId: number;
|
||||
|
||||
@Column({ name: 'join_date', type: 'date', nullable: true })
|
||||
joinDate: string;
|
||||
joinDate: string | null;
|
||||
|
||||
@Column({ name: 'leave_date', type: 'date', nullable: true })
|
||||
leaveDate: string;
|
||||
leaveDate: string | null;
|
||||
|
||||
@Column({ name: 'status', length: 10, default: 'active' })
|
||||
status: string;
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
import { Classroom } from './classroom.entity';
|
||||
import { Organization } from './organization.entity';
|
||||
|
||||
export enum ClassroomRentalStatus {
|
||||
ACTIVE = 'active',
|
||||
ENDED = 'ended',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
@Entity('classroom_rentals')
|
||||
@Index(['classroomId', 'startDate', 'endDate'])
|
||||
export class ClassroomRental {
|
||||
@@ -57,8 +63,8 @@ export class ClassroomRental {
|
||||
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
totalAmount: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||
status: string; // active / ended / cancelled
|
||||
@Column({ type: 'varchar', length: 20, default: ClassroomRentalStatus.ACTIVE })
|
||||
status: ClassroomRentalStatus | 'active' | 'ended' | 'cancelled';
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
export enum ClassroomStatus {
|
||||
AVAILABLE = 'available',
|
||||
MAINTENANCE = 'maintenance',
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
@Entity('classrooms')
|
||||
export class Classroom {
|
||||
@@ -20,14 +26,12 @@ export class Classroom {
|
||||
@Column({ name: 'room_type', type: 'varchar', length: 20, default: '大' })
|
||||
roomType: string; // 大 / 次大 / 小
|
||||
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'reserved' })
|
||||
status: string;
|
||||
@Column({ type: 'varchar', length: 20, default: ClassroomStatus.AVAILABLE })
|
||||
status: ClassroomStatus | 'available' | 'maintenance' | 'archived';
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
}
|
||||
|
||||
@@ -46,20 +46,11 @@ export class Deposit {
|
||||
@Column({ name: 'recorded_by', nullable: true })
|
||||
recordedBy: number;
|
||||
|
||||
@Column({ name: 'refund_status', length: 30, nullable: true })
|
||||
refundStatus: string; // pending | head_teacher_approved | finance_approved | refunded
|
||||
@Column({ name: 'refunded_by', type: 'integer', nullable: true })
|
||||
refundedBy: number | null;
|
||||
|
||||
@Column({ name: 'refund_requested_at', type: 'datetime', nullable: true })
|
||||
refundRequestedAt: Date;
|
||||
|
||||
@Column({ name: 'refund_approved_by', type: 'integer', nullable: true })
|
||||
refundApprovedBy: number;
|
||||
|
||||
@Column({ name: 'refund_approved_at', type: 'datetime', nullable: true })
|
||||
refundApprovedAt: Date;
|
||||
|
||||
@Column({ name: 'refund_rejected_reason', length: 500, nullable: true })
|
||||
refundRejectedReason: string;
|
||||
@Column({ name: 'refunded_at', type: 'datetime', nullable: true })
|
||||
refundedAt: Date | null;
|
||||
|
||||
@OneToMany(() => DepositInstallment, (i) => i.deposit, { cascade: true, eager: true })
|
||||
installments: DepositInstallment[];
|
||||
|
||||
@@ -11,9 +11,9 @@ export { User } from './user.entity';
|
||||
export { OperationLog } from './operation-log.entity';
|
||||
export { Deposit } from './deposit.entity';
|
||||
export { DepositInstallment } from './deposit-installment.entity';
|
||||
export { Classroom } from './classroom.entity';
|
||||
export { Classroom, ClassroomStatus } from './classroom.entity';
|
||||
export { Organization } from './organization.entity';
|
||||
export { ClassroomRental } from './classroom-rental.entity';
|
||||
export { ClassroomRental, ClassroomRentalStatus } from './classroom-rental.entity';
|
||||
export { Permission } from './permission.entity';
|
||||
export { Role } from './role.entity';
|
||||
export { Class, ClassType, ClassStatus } from './class.entity';
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
OneToMany,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Occupancy } from './occupancy.entity';
|
||||
import { RoomExpense } from './room-expense.entity';
|
||||
|
||||
@@ -25,9 +33,6 @@ export class Room {
|
||||
@Column({ name: 'room_type', length: 20, nullable: true })
|
||||
roomType: string;
|
||||
|
||||
@Column({ length: 10, nullable: true })
|
||||
gender: string;
|
||||
|
||||
@Column({ name: 'rental_category', length: 10, default: 'short' })
|
||||
rentalCategory: string;
|
||||
|
||||
@@ -42,5 +47,4 @@ export class Room {
|
||||
|
||||
@OneToMany(() => RoomExpense, (e) => e.room)
|
||||
roomExpenses: RoomExpense[];
|
||||
|
||||
}
|
||||
|
||||
@@ -33,9 +33,6 @@ export class StudentProfile {
|
||||
@Column({ length: 20, nullable: true })
|
||||
grade: string;
|
||||
|
||||
@Column({ name: 'campus_location', length: 100, nullable: true })
|
||||
campusLocation: string;
|
||||
|
||||
@Column({ name: 'profile_date', type: 'date', nullable: true })
|
||||
profileDate: string;
|
||||
|
||||
|
||||
48
apps/server/src/integration/config/dto/config.dto.spec.ts
Normal file
48
apps/server/src/integration/config/dto/config.dto.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import {
|
||||
DingTalkThirdConfigDto,
|
||||
IntegrationType,
|
||||
SaveIntegrationConfigDto,
|
||||
WeComThirdConfigDto,
|
||||
} from './config.dto';
|
||||
|
||||
const pipe = new ValidationPipe({ transform: true, whitelist: true });
|
||||
|
||||
const transform = (value: unknown) =>
|
||||
pipe.transform(value, { type: 'body', metatype: SaveIntegrationConfigDto });
|
||||
|
||||
describe('integration config request DTO', () => {
|
||||
it('validates and transforms DingTalk configuration', async () => {
|
||||
const result = await transform({
|
||||
type: 'DINGTALK',
|
||||
config: {
|
||||
agentId: 'app-key',
|
||||
corpId: 'corp-id',
|
||||
appSecret: '',
|
||||
appId: 'app-id',
|
||||
ignored: 'value',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.type).toBe(IntegrationType.DINGTALK);
|
||||
expect(result.config).toBeInstanceOf(DingTalkThirdConfigDto);
|
||||
expect(result.config).toMatchObject({ agentId: 'app-key', corpId: 'corp-id', appSecret: '' });
|
||||
expect(result.config).not.toHaveProperty('ignored');
|
||||
});
|
||||
|
||||
it('uses the WeCom nested DTO and removes DingTalk-only fields', async () => {
|
||||
const result = await transform({
|
||||
type: 'WECOM',
|
||||
config: { agentId: 'agent', corpId: 'corp', appId: 'not-supported' },
|
||||
});
|
||||
|
||||
expect(result.config).toBeInstanceOf(WeComThirdConfigDto);
|
||||
expect(result.config).not.toHaveProperty('appId');
|
||||
});
|
||||
|
||||
it('rejects invalid platform types and incomplete nested config', async () => {
|
||||
await expect(transform({ type: 'UNKNOWN', config: {} })).rejects.toThrow();
|
||||
await expect(transform({ type: 'DINGTALK', config: { corpId: 'corp' } })).rejects.toThrow();
|
||||
await expect(transform({ type: 'DINGTALK' })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,71 @@
|
||||
/** 钉钉配置 */
|
||||
export interface DingTalkThirdConfig {
|
||||
agentId: string; // AppKey
|
||||
appSecret: string; // AppSecret
|
||||
corpId: string; // CorpId
|
||||
startEnable: boolean; // 是否启用同步
|
||||
appId?: string; // 内部应用ID,用于消息推送(可选)
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsDefined,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export enum IntegrationType {
|
||||
WECOM = 'WECOM',
|
||||
DINGTALK = 'DINGTALK',
|
||||
}
|
||||
|
||||
/** 企微配置 */
|
||||
export interface WeComThirdConfig {
|
||||
export class DingTalkThirdConfigDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
agentId: string;
|
||||
appSecret: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
appSecret?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
corpId: string;
|
||||
startEnable: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
export class WeComThirdConfigDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
agentId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
appSecret?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
corpId: string;
|
||||
}
|
||||
|
||||
export class IntegrationConfigRequestDto {
|
||||
@IsEnum(IntegrationType)
|
||||
type: IntegrationType;
|
||||
|
||||
@IsDefined()
|
||||
@ValidateNested()
|
||||
@Type((options) =>
|
||||
options?.object?.type === IntegrationType.DINGTALK
|
||||
? DingTalkThirdConfigDto
|
||||
: WeComThirdConfigDto,
|
||||
)
|
||||
config: DingTalkThirdConfigDto | WeComThirdConfigDto;
|
||||
}
|
||||
|
||||
export class SaveIntegrationConfigDto extends IntegrationConfigRequestDto {}
|
||||
|
||||
export class TestIntegrationConfigDto extends IntegrationConfigRequestDto {}
|
||||
|
||||
/** 对外返回的配置(脱敏后,不含 appSecret) */
|
||||
export interface ThirdConfigBaseDTO<T = unknown> {
|
||||
type: string;
|
||||
verify?: boolean;
|
||||
config: T;
|
||||
}
|
||||
|
||||
/** 保存配置的请求体 */
|
||||
export interface SaveConfigRequest {
|
||||
type: 'WECOM' | 'DINGTALK';
|
||||
config: DingTalkThirdConfig | WeComThirdConfig;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../../auth/decorators/permission.decorator';
|
||||
import { IntegrationConfigService } from './integration-config.service';
|
||||
import type { SaveConfigRequest } from './dto/config.dto';
|
||||
import { SaveIntegrationConfigDto, TestIntegrationConfigDto } from './dto/config.dto';
|
||||
|
||||
@Controller('integration/config')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -31,7 +31,7 @@ export class IntegrationConfigController {
|
||||
/** 保存配置 */
|
||||
@Post()
|
||||
@RequirePermission('integration:trigger')
|
||||
async saveConfig(@Body() body: SaveConfigRequest) {
|
||||
async saveConfig(@Body() body: SaveIntegrationConfigDto) {
|
||||
await this.service.saveConfig(body);
|
||||
return { success: true, message: '配置已保存' };
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export class IntegrationConfigController {
|
||||
/** 测试连接 */
|
||||
@Post('test')
|
||||
@RequirePermission('integration:read')
|
||||
async testConnection(@Body() body: SaveConfigRequest) {
|
||||
async testConnection(@Body() body: TestIntegrationConfigDto) {
|
||||
const success = await this.service.testConnection(body.type, body.config);
|
||||
return { success, message: success ? '连接成功' : '连接失败,请检查配置信息' };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { IntegrationConfigService } from './integration-config.service';
|
||||
|
||||
describe('IntegrationConfigService.testConnection', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses the saved AppSecret when testing an existing configuration with a blank secret', async () => {
|
||||
const configRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }),
|
||||
};
|
||||
const detailRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
configId: 1,
|
||||
type: 'DINGTALK_SYNC',
|
||||
content: JSON.stringify({
|
||||
config: {
|
||||
corpId: 'ding-corp',
|
||||
agentId: 'saved-key',
|
||||
appSecret: 'saved-secret',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({ accessToken: 'token' }),
|
||||
}) as never;
|
||||
|
||||
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
|
||||
|
||||
await expect(
|
||||
service.testConnection('DINGTALK', {
|
||||
corpId: 'ding-corp',
|
||||
agentId: 'saved-key',
|
||||
appSecret: '',
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://api.dingtalk.com/v1.0/oauth2/accessToken',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ appKey: 'saved-key', appSecret: 'saved-secret' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,13 @@
|
||||
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
IntegrationConfig,
|
||||
IntegrationConfigDetail,
|
||||
} from '../entities/integration-config.entity';
|
||||
import { IntegrationConfig, IntegrationConfigDetail } from '../entities/integration-config.entity';
|
||||
import {
|
||||
ThirdConfigBaseDTO,
|
||||
DingTalkThirdConfig,
|
||||
WeComThirdConfig,
|
||||
SaveConfigRequest,
|
||||
DingTalkThirdConfigDto,
|
||||
WeComThirdConfigDto,
|
||||
IntegrationType,
|
||||
SaveIntegrationConfigDto,
|
||||
} from './dto/config.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -67,7 +65,7 @@ export class IntegrationConfigService {
|
||||
}
|
||||
|
||||
/** 保存/更新配置 */
|
||||
async saveConfig(request: SaveConfigRequest): Promise<void> {
|
||||
async saveConfig(request: SaveIntegrationConfigDto): Promise<void> {
|
||||
const config = await this.ensureConfig();
|
||||
const detailType = this.getDetailType(request.type);
|
||||
|
||||
@@ -121,11 +119,16 @@ export class IntegrationConfigService {
|
||||
|
||||
/** 测试连接 */
|
||||
async testConnection(
|
||||
type: string,
|
||||
config: DingTalkThirdConfig | WeComThirdConfig,
|
||||
type: IntegrationType,
|
||||
config: DingTalkThirdConfigDto | WeComThirdConfigDto,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const token = await this.getTokenForTest(type, config as unknown as Record<string, unknown>);
|
||||
const finalConfig = { ...config } as Record<string, unknown>;
|
||||
if (!finalConfig.appSecret) {
|
||||
const savedConfig = await this.getRawConfig(type);
|
||||
if (savedConfig?.appSecret) finalConfig.appSecret = savedConfig.appSecret;
|
||||
}
|
||||
const token = await this.getTokenForTest(type, finalConfig);
|
||||
return !!token;
|
||||
} catch (e) {
|
||||
this.logger.error(`连接测试失败: ${(e as Error).message}`);
|
||||
|
||||
28
apps/server/src/occupancies/dto/occupancy.dto.spec.ts
Normal file
28
apps/server/src/occupancies/dto/occupancy.dto.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { validate } from 'class-validator';
|
||||
import { CheckInDto, TransferRoomDto } from './occupancy.dto';
|
||||
|
||||
describe('manual occupancy DTO bed requirements', () => {
|
||||
it('requires a bed for manual check-in', async () => {
|
||||
const dto = Object.assign(new CheckInDto(), {
|
||||
studentId: 1,
|
||||
roomId: 2,
|
||||
checkInDate: '2026-07-13',
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
|
||||
expect(errors.some((error) => error.property === 'bedId')).toBe(true);
|
||||
});
|
||||
|
||||
it('requires a new bed for a room transfer while keeping the locker optional', async () => {
|
||||
const dto = Object.assign(new TransferRoomDto(), {
|
||||
newRoomId: 3,
|
||||
transferDate: '2026-07-13',
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
|
||||
expect(errors.some((error) => error.property === 'newBedId')).toBe(true);
|
||||
expect(errors.some((error) => error.property === 'newLockerId')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,8 @@ export class CheckInDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
responsibleOrganizationId?: number;
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
bedId?: number; // 后续改 required
|
||||
bedId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@@ -58,9 +57,8 @@ export class TransferRoomDto {
|
||||
@IsString()
|
||||
oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
newBedId?: number;
|
||||
newBedId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
|
||||
@@ -300,8 +300,7 @@ export class OccupanciesController {
|
||||
helpWs.addRow(['4. 已存在的学生(按姓名匹配)会自动补充缺失信息(性别、民族等)']);
|
||||
helpWs.addRow(['5. 已有在住记录的学生会自动跳过,不会重复入住']);
|
||||
helpWs.addRow(['6. 填了离宿时间的记录会直接标记为已退宿(用于导入历史数据)']);
|
||||
helpWs.addRow(['7. 性别约束:同一宿舍只能住同性别学生,首位入住者确定宿舍性别']);
|
||||
helpWs.addRow(['8. 床位号仅做标识参考,不影响入住逻辑']);
|
||||
helpWs.addRow(['7. 床位号仅做标识参考,不影响入住逻辑']);
|
||||
helpWs.getRow(1).font = { bold: true, size: 14 };
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('OccupanciesService — responsible organization', () => {
|
||||
save: jest.fn(async (value) => ({ ...value, id: 10 })),
|
||||
} as any as Repository<Occupancy>;
|
||||
const roomRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4, gender: null }),
|
||||
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4 }),
|
||||
update: jest.fn(),
|
||||
} as any as Repository<Room>;
|
||||
const studentRepo = {
|
||||
@@ -28,7 +28,10 @@ describe('OccupanciesService — responsible organization', () => {
|
||||
roomRepo,
|
||||
studentRepo,
|
||||
{} as Repository<Deposit>,
|
||||
{} as Repository<Bed>,
|
||||
{
|
||||
findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }),
|
||||
update: jest.fn(),
|
||||
} as any as Repository<Bed>,
|
||||
{} as Repository<Locker>,
|
||||
{} as Repository<any>,
|
||||
{} as DataSource,
|
||||
@@ -38,6 +41,7 @@ describe('OccupanciesService — responsible organization', () => {
|
||||
studentId: 3,
|
||||
roomId: 2,
|
||||
checkInDate: '2026-07-10',
|
||||
bedId: 4,
|
||||
});
|
||||
|
||||
expect(occupancyRepo.create).toHaveBeenCalledWith(
|
||||
|
||||
@@ -61,14 +61,8 @@ export class OccupanciesService {
|
||||
const count = await this.repo.count({ where: { roomId: dto.roomId, checkOutDate: IsNull() } });
|
||||
if (count >= room.capacity) throw new BadRequestException('宿舍已满');
|
||||
|
||||
// 房间级别性别约束
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
if (student.gender && room.gender && student.gender !== room.gender) {
|
||||
throw new BadRequestException(
|
||||
`该宿舍当前为${room.gender}生寝室,${student.gender}生无法入住`,
|
||||
);
|
||||
}
|
||||
|
||||
// 床位校验
|
||||
if (dto.bedId) {
|
||||
@@ -107,11 +101,6 @@ export class OccupanciesService {
|
||||
await this.lockerRepo.update(dto.lockerId, { status: 'occupied' });
|
||||
}
|
||||
|
||||
// 首位入住者确定房间性别
|
||||
if (student.gender && !room.gender) {
|
||||
await this.roomRepo.update(room.id, { gender: student.gender });
|
||||
}
|
||||
|
||||
// 更新宿舍状态
|
||||
if (count + 1 >= room.capacity) {
|
||||
await this.roomRepo.update(room.id, { status: 'full' });
|
||||
@@ -140,14 +129,6 @@ export class OccupanciesService {
|
||||
// 更新宿舍状态
|
||||
await this.roomRepo.update(occ.roomId, { status: 'available' });
|
||||
|
||||
// 如果房间已无在住人员,重置房间性别
|
||||
const remaining = await this.repo.count({
|
||||
where: { roomId: occ.roomId, checkOutDate: IsNull() },
|
||||
});
|
||||
if (remaining === 0) {
|
||||
await this.roomRepo.update(occ.roomId, { gender: null as any });
|
||||
}
|
||||
|
||||
return occ;
|
||||
}
|
||||
|
||||
@@ -173,14 +154,6 @@ export class OccupanciesService {
|
||||
await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' });
|
||||
}
|
||||
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
|
||||
// 旧房如果已无在住人员,重置性别
|
||||
const oldRemaining = await runner.manager.count(Occupancy, {
|
||||
where: { roomId: oldOcc.roomId, checkOutDate: IsNull() },
|
||||
});
|
||||
if (oldRemaining === 0) {
|
||||
await runner.manager.update(Room, oldOcc.roomId, { gender: null as any });
|
||||
}
|
||||
|
||||
// 检查新房容量
|
||||
const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } });
|
||||
if (!newRoom) throw new NotFoundException('目标宿舍不存在');
|
||||
@@ -189,12 +162,6 @@ export class OccupanciesService {
|
||||
});
|
||||
if (count >= newRoom.capacity) throw new BadRequestException('目标宿舍已满');
|
||||
|
||||
// 换房性别约束检查
|
||||
const student = await runner.manager.findOne(Student, { where: { id: oldOcc.studentId } });
|
||||
if (student?.gender && newRoom.gender && student.gender !== newRoom.gender) {
|
||||
throw new BadRequestException(`目标宿舍为${newRoom.gender}生寝室,无法换入`);
|
||||
}
|
||||
|
||||
// 新床位校验
|
||||
if (dto.newBedId) {
|
||||
const newBed = await runner.manager.findOne(Bed, {
|
||||
@@ -239,11 +206,6 @@ export class OccupanciesService {
|
||||
await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' });
|
||||
}
|
||||
|
||||
// 首位入住者确定新房性别
|
||||
if (student?.gender && !newRoom.gender) {
|
||||
await runner.manager.update(Room, newRoom.id, { gender: student.gender });
|
||||
}
|
||||
|
||||
if (count + 1 >= newRoom.capacity) {
|
||||
await runner.manager.update(Room, newRoom.id, { status: 'full' });
|
||||
}
|
||||
@@ -339,13 +301,6 @@ export class OccupanciesService {
|
||||
await runner.manager.save(occ);
|
||||
// 更新房间状态
|
||||
await runner.manager.update(Room, occ.roomId, { status: 'available' });
|
||||
// 如果房间已无在住人员,重置性别
|
||||
const remaining = await runner.manager.count(Occupancy, {
|
||||
where: { roomId: occ.roomId, checkOutDate: IsNull() },
|
||||
});
|
||||
if (remaining === 0) {
|
||||
await runner.manager.update(Room, occ.roomId, { gender: null as any });
|
||||
}
|
||||
// 释放床位/柜子
|
||||
if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' });
|
||||
if (occ.lockerId)
|
||||
@@ -495,15 +450,6 @@ export class OccupanciesService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. 房间级别性别约束
|
||||
if (student.gender && room.gender && student.gender !== room.gender) {
|
||||
errors.push(
|
||||
`第${rowNum}行: 宿舍 ${row.roomNumber} 为${room.gender}生寝室,${row.name}(${student.gender})无法入住,跳过`,
|
||||
);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 6. 创建入住记录
|
||||
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
|
||||
const occData: any = {
|
||||
@@ -520,12 +466,6 @@ export class OccupanciesService {
|
||||
}
|
||||
await this.repo.save(this.repo.create(occData));
|
||||
|
||||
// 7. 首位入住者确定房间性别
|
||||
if (student.gender && !room.gender) {
|
||||
await this.roomRepo.update(room.id, { gender: student.gender });
|
||||
room.gender = student.gender;
|
||||
}
|
||||
|
||||
// 8. 更新宿舍状态
|
||||
if (!row.checkOutDate?.trim() && count + 1 >= room.capacity) {
|
||||
await this.roomRepo.update(room.id, { status: 'full' });
|
||||
|
||||
@@ -27,7 +27,6 @@ export class CreateRoomDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
monthlyRate?: number;
|
||||
|
||||
}
|
||||
|
||||
export class UpdateRoomDto {
|
||||
@@ -52,10 +51,6 @@ export class UpdateRoomDto {
|
||||
@IsString()
|
||||
roomType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gender?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['available', 'full', 'maintenance'])
|
||||
status?: string;
|
||||
|
||||
@@ -110,7 +110,6 @@ export class RoomsController {
|
||||
{ header: '宿舍类型', key: 'roomType', width: 12 },
|
||||
{ header: '额定人数', key: 'capacity', width: 10 },
|
||||
{ header: '当前入住', key: 'currentCount', width: 10 },
|
||||
{ header: '性别', key: 'gender', width: 8 },
|
||||
{ header: '状态', key: 'status', width: 10 },
|
||||
{ header: '租赁类型', key: 'rentalCategory', width: 12 },
|
||||
{ header: '月租金', key: 'monthlyRate', width: 10 },
|
||||
@@ -131,7 +130,6 @@ export class RoomsController {
|
||||
roomType: r.roomType || '',
|
||||
capacity: r.capacity,
|
||||
currentCount: r.currentCount,
|
||||
gender: r.gender || '',
|
||||
status: statusMap[r.status] || r.status,
|
||||
rentalCategory: r.rentalCategory === 'long' ? '长租' : '短租',
|
||||
monthlyRate: r.monthlyRate ?? '',
|
||||
@@ -168,11 +166,7 @@ export class RoomsController {
|
||||
|
||||
@Put(':roomId/beds/:id')
|
||||
@RequirePermission('room:edit')
|
||||
updateBed(
|
||||
@Param('roomId') roomId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateBedDto,
|
||||
) {
|
||||
updateBed(@Param('roomId') roomId: string, @Param('id') id: string, @Body() dto: UpdateBedDto) {
|
||||
return this.service.updateBed(+roomId, +id, dto);
|
||||
}
|
||||
|
||||
@@ -343,7 +337,9 @@ export class RoomsController {
|
||||
}[] = [];
|
||||
ws.eachRow((row, idx) => {
|
||||
if (idx === 1) return;
|
||||
const rentalCategoryRaw = String(row.getCell(6).value || '').trim().toLowerCase();
|
||||
const rentalCategoryRaw = String(row.getCell(6).value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const rentalCategory =
|
||||
rentalCategoryRaw === 'long' || rentalCategoryRaw === 'short'
|
||||
? rentalCategoryRaw
|
||||
|
||||
@@ -202,10 +202,7 @@ export class RoomsService {
|
||||
for (const occ of occupancies) {
|
||||
if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []);
|
||||
const checkIn = new Date(occ.checkInDate);
|
||||
const days = Math.max(
|
||||
1,
|
||||
Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)),
|
||||
);
|
||||
const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)));
|
||||
occMap.get(occ.roomId)!.push({
|
||||
studentId: occ.studentId,
|
||||
studentName: occ.student?.name || '未知',
|
||||
@@ -250,8 +247,11 @@ export class RoomsService {
|
||||
const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]);
|
||||
orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`;
|
||||
}
|
||||
const organizationColors = [...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean))];
|
||||
const organizationColor: string | null = organizationColors.length === 1 ? organizationColors[0] : null;
|
||||
const organizationColors = [
|
||||
...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean)),
|
||||
];
|
||||
const organizationColor: string | null =
|
||||
organizationColors.length === 1 ? organizationColors[0] : null;
|
||||
const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))];
|
||||
return {
|
||||
id: room.id,
|
||||
@@ -274,7 +274,14 @@ export class RoomsService {
|
||||
...new Map(
|
||||
occupancies
|
||||
.filter((o) => o.responsibleOrganizationId && o.responsibleOrganization)
|
||||
.map((o) => [o.responsibleOrganizationId, { id: o.responsibleOrganizationId, name: o.responsibleOrganization.name, color: o.responsibleOrganization.color || null }]),
|
||||
.map((o) => [
|
||||
o.responsibleOrganizationId,
|
||||
{
|
||||
id: o.responsibleOrganizationId,
|
||||
name: o.responsibleOrganization.name,
|
||||
color: o.responsibleOrganization.color || null,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
};
|
||||
@@ -336,7 +343,10 @@ export class RoomsService {
|
||||
async getRoomAvailableBeds(roomId: number): Promise<Bed[]> {
|
||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
return this.bedRepo.find({ where: { roomId, status: 'available' }, order: { bedNumber: 'ASC' } });
|
||||
return this.bedRepo.find({
|
||||
where: { roomId, status: 'available' },
|
||||
order: { bedNumber: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async createBed(roomId: number, dto: CreateBedDto): Promise<Bed> {
|
||||
@@ -377,7 +387,7 @@ export class RoomsService {
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
|
||||
const existing = await this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } });
|
||||
const numbers = existing.map(b => {
|
||||
const numbers = existing.map((b) => {
|
||||
const match = b.bedNumber.match(/^\d+/);
|
||||
return match ? parseInt(match[0]) : 0;
|
||||
});
|
||||
@@ -400,14 +410,19 @@ export class RoomsService {
|
||||
async getRoomAvailableLockers(roomId: number): Promise<Locker[]> {
|
||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
return this.lockerRepo.find({ where: { roomId, status: 'available' }, order: { lockerNumber: 'ASC' } });
|
||||
return this.lockerRepo.find({
|
||||
where: { roomId, status: 'available' },
|
||||
order: { lockerNumber: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async createLocker(roomId: number, dto: CreateLockerDto): Promise<Locker> {
|
||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
|
||||
const existing = await this.lockerRepo.findOne({ where: { roomId, lockerNumber: dto.lockerNumber } });
|
||||
const existing = await this.lockerRepo.findOne({
|
||||
where: { roomId, lockerNumber: dto.lockerNumber },
|
||||
});
|
||||
if (existing) throw new BadRequestException('该柜子编号已存在');
|
||||
const locker = this.lockerRepo.create({ ...dto, roomId });
|
||||
return this.lockerRepo.save(locker);
|
||||
@@ -420,7 +435,9 @@ export class RoomsService {
|
||||
throw new BadRequestException('该柜子有人占用,请先释放');
|
||||
}
|
||||
if (dto.lockerNumber && dto.lockerNumber !== locker.lockerNumber) {
|
||||
const dup = await this.lockerRepo.findOne({ where: { roomId, lockerNumber: dto.lockerNumber } });
|
||||
const dup = await this.lockerRepo.findOne({
|
||||
where: { roomId, lockerNumber: dto.lockerNumber },
|
||||
});
|
||||
if (dup) throw new BadRequestException('该柜子编号已存在');
|
||||
}
|
||||
Object.assign(locker, dto);
|
||||
@@ -438,8 +455,11 @@ export class RoomsService {
|
||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
|
||||
const existing = await this.lockerRepo.find({ where: { roomId }, order: { lockerNumber: 'ASC' } });
|
||||
const numbers = existing.map(b => {
|
||||
const existing = await this.lockerRepo.find({
|
||||
where: { roomId },
|
||||
order: { lockerNumber: 'ASC' },
|
||||
});
|
||||
const numbers = existing.map((b) => {
|
||||
const match = b.lockerNumber.match(/^\d+/);
|
||||
return match ? parseInt(match[0]) : 0;
|
||||
});
|
||||
|
||||
47
apps/server/src/schedules/dto/schedule.dto.spec.ts
Normal file
47
apps/server/src/schedules/dto/schedule.dto.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'reflect-metadata';
|
||||
import { validate } from 'class-validator';
|
||||
import { CreateScheduleDto, UpdateScheduleDto } from './schedule.dto';
|
||||
|
||||
const createSchedule = (notes: string) =>
|
||||
Object.assign(new CreateScheduleDto(), {
|
||||
classId: 1,
|
||||
classroomId: 2,
|
||||
weekDay: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '语文',
|
||||
notes,
|
||||
});
|
||||
|
||||
describe('schedule notes validation', () => {
|
||||
it('rejects notes longer than 500 characters when creating', async () => {
|
||||
const errors = await validate(createSchedule('a'.repeat(501)));
|
||||
expect(errors.some((error) => error.property === 'notes')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects notes longer than 500 characters when updating', async () => {
|
||||
const dto = Object.assign(new UpdateScheduleDto(), { notes: 'a'.repeat(501) });
|
||||
const errors = await validate(dto);
|
||||
expect(errors.some((error) => error.property === 'notes')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes the retired departmentId field from create requests', async () => {
|
||||
const dto = Object.assign(new CreateScheduleDto(), {
|
||||
classId: 1,
|
||||
classroomId: 2,
|
||||
weekDay: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '语文',
|
||||
departmentId: 99,
|
||||
});
|
||||
|
||||
await validate(dto, { whitelist: true });
|
||||
|
||||
expect(dto).not.toHaveProperty('departmentId');
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Matches,
|
||||
Min,
|
||||
Max,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@@ -59,11 +60,9 @@ export class CreateScheduleDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
departmentId?: number;
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
@@ -115,6 +114,7 @@ export class UpdateScheduleDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ClassSchedule,
|
||||
Class,
|
||||
Classroom,
|
||||
ClassroomStatus,
|
||||
ClassroomRental,
|
||||
ClassTeacher,
|
||||
AttendanceSession,
|
||||
@@ -83,7 +84,7 @@ export class SchedulesService {
|
||||
});
|
||||
|
||||
const classrooms = await this.classroomRepo.find({
|
||||
where: { status: Not('archived') },
|
||||
where: { status: ClassroomStatus.AVAILABLE },
|
||||
select: ['id', 'name', 'building', 'floor', 'roomType'],
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
@@ -159,7 +160,16 @@ export class SchedulesService {
|
||||
return schedule;
|
||||
}
|
||||
|
||||
private async assertClassroomAvailable(classroomId: number) {
|
||||
const classroom = await this.classroomRepo.findOne({ where: { id: classroomId } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
if (classroom.status !== ClassroomStatus.AVAILABLE) {
|
||||
throw new BadRequestException('仅可用教室可以排课');
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateScheduleDto) {
|
||||
await this.assertClassroomAvailable(dto.classroomId);
|
||||
await this.normalizeTeacherForSchedule(dto);
|
||||
await this.assertTeacherAssignedToClass(dto.classId, dto.teacherId);
|
||||
await this.checkConflict(
|
||||
@@ -182,6 +192,9 @@ export class SchedulesService {
|
||||
|
||||
// If classroom, weekDay, or times are changing, check conflicts excluding self
|
||||
const classroomId = dto.classroomId ?? existing.classroomId;
|
||||
if (dto.classroomId !== undefined && dto.classroomId !== existing.classroomId) {
|
||||
await this.assertClassroomAvailable(dto.classroomId);
|
||||
}
|
||||
const weekDay = dto.weekDay ?? existing.weekDay;
|
||||
const startTime = dto.startTime ?? existing.startTime;
|
||||
const endTime = dto.endTime ?? existing.endTime;
|
||||
@@ -253,7 +266,7 @@ export class SchedulesService {
|
||||
const rentalConflicts = await this.rentalRepo
|
||||
.createQueryBuilder('r')
|
||||
.where('r.classroomId = :classroomId', { classroomId })
|
||||
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.status = :activeRental', { activeRental: 'active' })
|
||||
.andWhere('r.startDate <= :endDate', { endDate })
|
||||
.andWhere('r.endDate >= :startDate', { startDate })
|
||||
.getMany();
|
||||
|
||||
18
apps/server/src/students/dto/student.dto.spec.ts
Normal file
18
apps/server/src/students/dto/student.dto.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'reflect-metadata';
|
||||
import { validate } from 'class-validator';
|
||||
import { CreateStudentDto } from './student.dto';
|
||||
|
||||
describe('CreateStudentDto relationship boundaries', () => {
|
||||
it('removes classId because class membership is managed by the classes API', async () => {
|
||||
const dto = Object.assign(new CreateStudentDto(), {
|
||||
name: '测试学生',
|
||||
organizationId: 1,
|
||||
classId: 9,
|
||||
});
|
||||
|
||||
await validate(dto, { whitelist: true });
|
||||
|
||||
expect(dto).toMatchObject({ name: '测试学生', organizationId: 1 });
|
||||
expect(dto).not.toHaveProperty('classId');
|
||||
});
|
||||
});
|
||||
@@ -38,9 +38,6 @@ export class CreateStudentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
supervisor?: string;
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
classId?: number;
|
||||
}
|
||||
|
||||
export class UpdateStudentDto {
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -62,6 +62,7 @@
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/event-emitter": "^3.1.0",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/mapped-types": "^2.1.1",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
@@ -3101,6 +3102,26 @@
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/mapped-types": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz",
|
||||
"integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"class-transformer": "^0.4.0 || ^0.5.0",
|
||||
"class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0",
|
||||
"reflect-metadata": "^0.1.12 || ^0.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"class-transformer": {
|
||||
"optional": true
|
||||
},
|
||||
"class-validator": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/passport": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/@nestjs/passport/-/passport-11.0.5.tgz",
|
||||
|
||||
Reference in New Issue
Block a user