refactor: resolve remaining field audit issues
This commit is contained in:
@@ -15,7 +15,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Empty,
|
Empty,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
|
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined, StopOutlined, CheckOutlined } from '@ant-design/icons';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { downloadBlob } from '../../utils/download';
|
import { downloadBlob } from '../../utils/download';
|
||||||
@@ -39,6 +39,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
const [editing, setEditing] = useState<any>(null);
|
const [editing, setEditing] = useState<any>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
|
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string | undefined>();
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [unavailableDates, setUnavailableDates] = useState<Set<string>>(new Set());
|
const [unavailableDates, setUnavailableDates] = useState<Set<string>>(new Set());
|
||||||
@@ -48,20 +49,22 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
const selectedClassroomId = Form.useWatch('classroomId', form);
|
const selectedClassroomId = Form.useWatch('classroomId', form);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!searchText) return data;
|
|
||||||
const s = searchText.toLowerCase();
|
|
||||||
return data.filter((r: any) => {
|
return data.filter((r: any) => {
|
||||||
|
if (filterStatus && r.effectiveStatus !== filterStatus) return false;
|
||||||
|
if (!searchText) return true;
|
||||||
|
const s = searchText.toLowerCase();
|
||||||
const matchClassroom = r.classroom?.name?.toLowerCase().includes(s);
|
const matchClassroom = r.classroom?.name?.toLowerCase().includes(s);
|
||||||
const matchOrganization = r.lesseeOrganization?.name?.toLowerCase().includes(s);
|
const matchOrganization = r.lesseeOrganization?.name?.toLowerCase().includes(s);
|
||||||
return matchClassroom || matchOrganization;
|
return matchClassroom || matchOrganization;
|
||||||
});
|
});
|
||||||
}, [data, searchText]);
|
}, [data, searchText, filterStatus]);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
|
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
|
||||||
|
params.includeEnded = true;
|
||||||
const res: any = await api.get('/classroom-rentals', { params });
|
const res: any = await api.get('/classroom-rentals', { params });
|
||||||
setData(res);
|
setData(res);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -215,6 +218,16 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRentalAction = async (id: number, action: 'cancel' | 'end') => {
|
||||||
|
try {
|
||||||
|
await api.put(`/classroom-rentals/${id}/${action}`);
|
||||||
|
message.success(action === 'cancel' ? '租赁已取消' : '租赁已结束');
|
||||||
|
fetchData();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDownloadContract = async (id: number, filename?: string) => {
|
const handleDownloadContract = async (id: number, filename?: string) => {
|
||||||
try {
|
try {
|
||||||
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
|
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
|
||||||
@@ -308,6 +321,19 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
width: 100,
|
width: 100,
|
||||||
render: (v: any) => (v ? `¥${v}` : '-'),
|
render: (v: any) => (v ? `¥${v}` : '-'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'effectiveStatus',
|
||||||
|
width: 90,
|
||||||
|
render: (status: string) => {
|
||||||
|
const config: Record<string, { text: string; color: string }> = {
|
||||||
|
active: { text: '进行中', color: 'green' },
|
||||||
|
ended: { text: '已结束', color: 'default' },
|
||||||
|
cancelled: { text: '已取消', color: 'red' },
|
||||||
|
};
|
||||||
|
return <Tag color={config[status]?.color}>{config[status]?.text || status}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '合同',
|
title: '合同',
|
||||||
width: 120,
|
width: 120,
|
||||||
@@ -364,21 +390,30 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
width: 150,
|
width: 150,
|
||||||
render: (_: any, record: any) => (
|
render: (_: any, record: any) => (
|
||||||
<Space>
|
<Space>
|
||||||
<PermissionButton
|
{record.effectiveStatus === 'active' && (
|
||||||
permission="rental:edit"
|
<>
|
||||||
size="small"
|
<PermissionButton permission="rental:edit" size="small" onClick={() => openEdit(record)}>
|
||||||
onClick={() => openEdit(record)}
|
|
||||||
>
|
|
||||||
编辑
|
编辑
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
<Popconfirm
|
<Popconfirm title="确定取消该租赁?" onConfirm={() => handleRentalAction(record.id, 'cancel')}>
|
||||||
title="确定删除该租赁订单?合同文件将一并删除。"
|
<PermissionButton permission="rental:edit" size="small" danger icon={<StopOutlined />}>
|
||||||
onConfirm={() => handleDelete(record.id)}
|
取消
|
||||||
>
|
|
||||||
<PermissionButton permission="rental:delete" size="small" danger>
|
|
||||||
删除
|
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</Popconfirm>
|
</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>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -415,6 +450,18 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
allowClear
|
allowClear
|
||||||
format="YYYY-MM"
|
format="YYYY-MM"
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="状态"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 110 }}
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={setFilterStatus}
|
||||||
|
options={[
|
||||||
|
{ value: 'active', label: '进行中' },
|
||||||
|
{ value: 'ended', label: '已结束' },
|
||||||
|
{ value: 'cancelled', label: '已取消' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="rental:create"
|
permission="rental:create"
|
||||||
@@ -459,7 +506,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
placeholder="选择教室"
|
placeholder="选择教室"
|
||||||
onChange={handleClassroomChange}
|
onChange={handleClassroomChange}
|
||||||
options={classrooms.map((c) => ({
|
options={classrooms.filter((c) => c.status === 'available').map((c) => ({
|
||||||
value: c.id,
|
value: c.id,
|
||||||
label: `${c.building ? c.building + ' · ' : ''}${c.name}(${c.roomType})`,
|
label: `${c.building ? c.building + ' · ' : ''}${c.name}(${c.roomType})`,
|
||||||
}))}
|
}))}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
let result = data;
|
let result = data;
|
||||||
if (searchText) { const s = searchText.toLowerCase(); result = result.filter((d: Record<string, unknown>) => (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || (typeof d.building === 'string' && d.building.toLowerCase().includes(s))); }
|
if (searchText) { const s = searchText.toLowerCase(); result = result.filter((d: Record<string, unknown>) => (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || (typeof d.building === 'string' && d.building.toLowerCase().includes(s))); }
|
||||||
if (filterStatus) result = result.filter((d: Record<string, unknown>) => d.status === filterStatus);
|
if (filterStatus) result = result.filter((d: Record<string, unknown>) => d.effectiveStatus === filterStatus);
|
||||||
return result;
|
return result;
|
||||||
}, [data, searchText, filterStatus]);
|
}, [data, searchText, filterStatus]);
|
||||||
|
|
||||||
@@ -157,11 +157,11 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: '状态', width: 100,
|
title: '状态', width: 100,
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
render: (s: string, record: { currentUsage?: CurrentUsage | null }) => {
|
render: (_s: string, record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null }) => {
|
||||||
const effectiveStatus = record.currentUsage ? 'in_use' : s;
|
const effectiveStatus = record.effectiveStatus || record.status;
|
||||||
return (
|
return (
|
||||||
<Tooltip title={record.currentUsage ? `${record.currentUsage.title} (${record.currentUsage.startTime}-${record.currentUsage.endTime})` : undefined}>
|
<Tooltip title={record.currentUsage ? `${record.currentUsage.title} (${record.currentUsage.startTime}-${record.currentUsage.endTime})` : undefined}>
|
||||||
<Tag color={statusMap[effectiveStatus]?.color}>{statusMap[effectiveStatus]?.text || s}</Tag>
|
<Tag color={statusMap[effectiveStatus]?.color}>{statusMap[effectiveStatus]?.text || effectiveStatus}</Tag>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -228,7 +228,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
if (!e.target.value) setSearchText('');
|
if (!e.target.value) setSearchText('');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus} options={[{value:'available',label:'可用'},{value:'in_use',label:'使用中'},{value:'reserved',label:'已预留'},{value:'maintenance',label:'维护中'}]} />
|
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus} options={[{value:'available',label:'可用'},{value:'in_use',label:'使用中'},{value:'reserved',label:'已预留'},{value:'maintenance',label:'维护中'},{value:'archived',label:'已归档'}]} />
|
||||||
<Button
|
<Button
|
||||||
type={showArchived ? 'primary' : 'default'}
|
type={showArchived ? 'primary' : 'default'}
|
||||||
onClick={() => setShowArchived(!showArchived)}
|
onClick={() => setShowArchived(!showArchived)}
|
||||||
@@ -323,6 +323,16 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
<Form.Item name="capacity" label="容量">
|
<Form.Item name="capacity" label="容量">
|
||||||
<InputNumber min={1} max={500} style={{ width: '100%' }} />
|
<InputNumber min={1} max={500} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{editing && (
|
||||||
|
<Form.Item name="status" label="基础状态">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'available', label: '可用' },
|
||||||
|
{ value: 'maintenance', label: '维护中' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
<Form.Item name="notes" label="备注">
|
<Form.Item name="notes" label="备注">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -159,13 +159,17 @@ const RoomsPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
const payload = {
|
||||||
|
...values,
|
||||||
|
gender: values.gender === '__unset__' ? null : values.gender,
|
||||||
|
};
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await api.put(`/rooms/${editing.id}`, values);
|
await api.put(`/rooms/${editing.id}`, payload);
|
||||||
message.success('更新成功');
|
message.success('更新成功');
|
||||||
} else {
|
} else {
|
||||||
await api.post('/rooms', values);
|
await api.post('/rooms', payload);
|
||||||
message.success('创建成功');
|
message.success('创建成功');
|
||||||
}
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
@@ -341,7 +345,8 @@ const RoomsPage: React.FC = () => {
|
|||||||
title: '性别',
|
title: '性别',
|
||||||
dataIndex: 'gender',
|
dataIndex: 'gender',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (v: any) => (v ? <Tag color={v === '男' ? 'blue' : 'pink'}>{v}</Tag> : '-'),
|
render: (v: string | null) =>
|
||||||
|
v ? <Tag color={v === '男' ? 'blue' : 'pink'}>{v}生宿舍</Tag> : '未指定',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
@@ -386,7 +391,7 @@ const RoomsPage: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const rec = record as { id: number };
|
const rec = record as { id: number };
|
||||||
setEditing(rec);
|
setEditing(rec);
|
||||||
form.setFieldsValue(rec);
|
form.setFieldsValue({ ...rec, gender: (record as any).gender ?? '__unset__' });
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -468,6 +473,7 @@ const RoomsPage: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
|
form.setFieldsValue({ gender: '__unset__' });
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -571,6 +577,15 @@ const RoomsPage: React.FC = () => {
|
|||||||
placeholder="留空自动解析"
|
placeholder="留空自动解析"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="gender" label="宿舍性别">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: '男', label: '男生宿舍' },
|
||||||
|
{ value: '女', label: '女生宿舍' },
|
||||||
|
{ value: '__unset__', label: '未指定(首位入住者确定)' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="rentalCategory" label="租赁类别">
|
<Form.Item name="rentalCategory" label="租赁类别">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
@@ -617,6 +632,7 @@ const RoomsPage: React.FC = () => {
|
|||||||
<div><strong>楼栋:</strong>{drawerRoom.building || '-'}</div>
|
<div><strong>楼栋:</strong>{drawerRoom.building || '-'}</div>
|
||||||
<div><strong>楼层:</strong>{drawerRoom.floor ?? '-'}</div>
|
<div><strong>楼层:</strong>{drawerRoom.floor ?? '-'}</div>
|
||||||
<div><strong>类型:</strong>{drawerRoom.roomType || '-'}</div>
|
<div><strong>类型:</strong>{drawerRoom.roomType || '-'}</div>
|
||||||
|
<div><strong>宿舍性别:</strong>{drawerRoom.gender ? `${drawerRoom.gender}生宿舍` : '未指定'}</div>
|
||||||
<div><strong>额定人数:</strong>{drawerRoom.capacity}</div>
|
<div><strong>额定人数:</strong>{drawerRoom.capacity}</div>
|
||||||
<div><strong>租赁类别:</strong>{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}</div>
|
<div><strong>租赁类别:</strong>{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}</div>
|
||||||
<div><strong>月租金:</strong>{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}</div>
|
<div><strong>月租金:</strong>{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/event-emitter": "^3.1.0",
|
"@nestjs/event-emitter": "^3.1.0",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/mapped-types": "^2.1.1",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.1.19",
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ import { ArchiveService } from './archive.service';
|
|||||||
import {
|
import {
|
||||||
UpsertProfileDto,
|
UpsertProfileDto,
|
||||||
CreateEnrollmentDto,
|
CreateEnrollmentDto,
|
||||||
|
UpdateEnrollmentDto,
|
||||||
CreateExamScoreDto,
|
CreateExamScoreDto,
|
||||||
|
UpdateExamScoreDto,
|
||||||
CreateLearningRecordDto,
|
CreateLearningRecordDto,
|
||||||
|
UpdateLearningRecordDto,
|
||||||
UpsertResultDto,
|
UpsertResultDto,
|
||||||
} from './dto/archive.dto';
|
} from './dto/archive.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
@@ -110,7 +113,7 @@ export class ArchiveController {
|
|||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async updateEnrollment(
|
async updateEnrollment(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: Partial<CreateEnrollmentDto>,
|
@Body() dto: UpdateEnrollmentDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
@@ -174,7 +177,7 @@ export class ArchiveController {
|
|||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async updateExamScore(
|
async updateExamScore(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: Partial<CreateExamScoreDto>,
|
@Body() dto: UpdateExamScoreDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
@@ -238,7 +241,7 @@ export class ArchiveController {
|
|||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async updateLearningRecord(
|
async updateLearningRecord(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: Partial<CreateLearningRecordDto>,
|
@Body() dto: UpdateLearningRecordDto,
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
@@ -330,12 +333,12 @@ export class ArchiveController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(+studentId, +id);
|
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(
|
||||||
res.setHeader('Content-Type', mimeType);
|
+studentId,
|
||||||
res.setHeader(
|
+id,
|
||||||
'Content-Disposition',
|
|
||||||
`inline; filename="${encodeURIComponent(fileName)}"`,
|
|
||||||
);
|
);
|
||||||
|
res.setHeader('Content-Type', mimeType);
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`);
|
||||||
const stream = fs.createReadStream(fullPath);
|
const stream = fs.createReadStream(fullPath);
|
||||||
stream.pipe(res);
|
stream.pipe(res);
|
||||||
}
|
}
|
||||||
@@ -358,7 +361,6 @@ export class ArchiveController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Get(':studentId/report-html')
|
@Get(':studentId/report-html')
|
||||||
@RequirePermission('student:view')
|
@RequirePermission('student:view')
|
||||||
async generateReportHtml(
|
async generateReportHtml(
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ import { ArchiveAttachment } from '../entities/archive-attachment.entity';
|
|||||||
import {
|
import {
|
||||||
UpsertProfileDto,
|
UpsertProfileDto,
|
||||||
CreateEnrollmentDto,
|
CreateEnrollmentDto,
|
||||||
|
UpdateEnrollmentDto,
|
||||||
CreateExamScoreDto,
|
CreateExamScoreDto,
|
||||||
|
UpdateExamScoreDto,
|
||||||
CreateLearningRecordDto,
|
CreateLearningRecordDto,
|
||||||
|
UpdateLearningRecordDto,
|
||||||
UpsertResultDto,
|
UpsertResultDto,
|
||||||
} from './dto/archive.dto';
|
} from './dto/archive.dto';
|
||||||
|
|
||||||
@@ -44,7 +47,9 @@ export class ArchiveService {
|
|||||||
? path.resolve(process.cwd(), normalizedPath)
|
? path.resolve(process.cwd(), normalizedPath)
|
||||||
: path.resolve(this.uploadDir, normalizedPath);
|
: path.resolve(this.uploadDir, normalizedPath);
|
||||||
const allowedRoots = [this.uploadDir, path.resolve(process.cwd(), 'uploads', 'archive')];
|
const allowedRoots = [this.uploadDir, path.resolve(process.cwd(), 'uploads', 'archive')];
|
||||||
if (!allowedRoots.some((root) => fullPath === root || fullPath.startsWith(`${root}${path.sep}`))) {
|
if (
|
||||||
|
!allowedRoots.some((root) => fullPath === root || fullPath.startsWith(`${root}${path.sep}`))
|
||||||
|
) {
|
||||||
throw new BadRequestException('路径非法');
|
throw new BadRequestException('路径非法');
|
||||||
}
|
}
|
||||||
return fullPath;
|
return fullPath;
|
||||||
@@ -54,14 +59,8 @@ export class ArchiveService {
|
|||||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||||
if (!student) throw new NotFoundException('学生不存在');
|
if (!student) throw new NotFoundException('学生不存在');
|
||||||
|
|
||||||
const [
|
const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments] =
|
||||||
profileRaw,
|
await Promise.all([
|
||||||
enrollments,
|
|
||||||
examScores,
|
|
||||||
learningRecords,
|
|
||||||
resultArchive,
|
|
||||||
attachments,
|
|
||||||
] = await Promise.all([
|
|
||||||
this.profileRepo.findOne({ where: { studentId } }),
|
this.profileRepo.findOne({ where: { studentId } }),
|
||||||
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||||
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
|
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
|
||||||
@@ -102,7 +101,7 @@ export class ArchiveService {
|
|||||||
return this.enrollmentRepo.save(entity);
|
return this.enrollmentRepo.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateEnrollment(id: number, dto: Partial<CreateEnrollmentDto>) {
|
async updateEnrollment(id: number, dto: UpdateEnrollmentDto) {
|
||||||
const entity = await this.enrollmentRepo.findOne({ where: { id } });
|
const entity = await this.enrollmentRepo.findOne({ where: { id } });
|
||||||
if (!entity) throw new NotFoundException('报名记录不存在');
|
if (!entity) throw new NotFoundException('报名记录不存在');
|
||||||
Object.assign(entity, dto);
|
Object.assign(entity, dto);
|
||||||
@@ -124,7 +123,7 @@ export class ArchiveService {
|
|||||||
return this.examScoreRepo.save(entity);
|
return this.examScoreRepo.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateExamScore(id: number, dto: Partial<CreateExamScoreDto>) {
|
async updateExamScore(id: number, dto: UpdateExamScoreDto) {
|
||||||
const entity = await this.examScoreRepo.findOne({ where: { id } });
|
const entity = await this.examScoreRepo.findOne({ where: { id } });
|
||||||
if (!entity) throw new NotFoundException('考试成绩不存在');
|
if (!entity) throw new NotFoundException('考试成绩不存在');
|
||||||
Object.assign(entity, dto);
|
Object.assign(entity, dto);
|
||||||
@@ -146,7 +145,7 @@ export class ArchiveService {
|
|||||||
return this.learningRecordRepo.save(entity);
|
return this.learningRecordRepo.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLearningRecord(id: number, dto: Partial<CreateLearningRecordDto>) {
|
async updateLearningRecord(id: number, dto: UpdateLearningRecordDto) {
|
||||||
const entity = await this.learningRecordRepo.findOne({ where: { id } });
|
const entity = await this.learningRecordRepo.findOne({ where: { id } });
|
||||||
if (!entity) throw new NotFoundException('学习记录不存在');
|
if (!entity) throw new NotFoundException('学习记录不存在');
|
||||||
Object.assign(entity, dto);
|
Object.assign(entity, dto);
|
||||||
@@ -225,4 +224,3 @@ export class ArchiveService {
|
|||||||
return { message: '已删除' };
|
return { message: '已删除' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
import { UpsertProfileDto } from './archive.dto';
|
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', () => {
|
describe('UpsertProfileDto retired fields', () => {
|
||||||
it('removes the retired campusLocation field under whitelist validation', async () => {
|
it('removes the retired campusLocation field under whitelist validation', async () => {
|
||||||
@@ -16,3 +28,30 @@ describe('UpsertProfileDto retired fields', () => {
|
|||||||
expect(dto).not.toHaveProperty('campusLocation');
|
expect(dto).not.toHaveProperty('campusLocation');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('archive update DTOs', () => {
|
||||||
|
it('allows partial enrollment updates and strips unknown fields', async () => {
|
||||||
|
await expect(
|
||||||
|
transform(UpdateEnrollmentDto, { className: '新班级', ignored: 'value' }),
|
||||||
|
).resolves.toEqual(expect.objectContaining({ className: '新班级' }));
|
||||||
|
const result = await transform(UpdateEnrollmentDto, {
|
||||||
|
className: '新班级',
|
||||||
|
ignored: 'value',
|
||||||
|
});
|
||||||
|
expect(result).not.toHaveProperty('ignored');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains create DTO validation rules for exam scores', async () => {
|
||||||
|
await expect(transform(UpdateExamScoreDto, { score: '90' })).rejects.toThrow();
|
||||||
|
await expect(transform(UpdateExamScoreDto, { score: 90 })).resolves.toMatchObject({
|
||||||
|
score: 90,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains create DTO date validation for learning records', async () => {
|
||||||
|
await expect(
|
||||||
|
transform(UpdateLearningRecordDto, { recordDate: 'not-a-date' }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(transform(UpdateLearningRecordDto, {})).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator';
|
import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator';
|
||||||
|
|
||||||
export class UpsertProfileDto {
|
export class UpsertProfileDto {
|
||||||
@@ -20,6 +21,8 @@ export class CreateEnrollmentDto {
|
|||||||
@IsOptional() @IsString() status?: string;
|
@IsOptional() @IsString() status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {}
|
||||||
|
|
||||||
export class CreateExamScoreDto {
|
export class CreateExamScoreDto {
|
||||||
@IsString() examType: string;
|
@IsString() examType: string;
|
||||||
@IsOptional() @IsString() examName?: string;
|
@IsOptional() @IsString() examName?: string;
|
||||||
@@ -31,6 +34,8 @@ export class CreateExamScoreDto {
|
|||||||
@IsOptional() @IsNumber() enrollmentId?: number;
|
@IsOptional() @IsNumber() enrollmentId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateExamScoreDto extends PartialType(CreateExamScoreDto) {}
|
||||||
|
|
||||||
export class CreateLearningRecordDto {
|
export class CreateLearningRecordDto {
|
||||||
@IsDateString() recordDate: string;
|
@IsDateString() recordDate: string;
|
||||||
@IsString() recordType: string;
|
@IsString() recordType: string;
|
||||||
@@ -39,6 +44,8 @@ export class CreateLearningRecordDto {
|
|||||||
@IsOptional() @IsString() nextStep?: string;
|
@IsOptional() @IsString() nextStep?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateLearningRecordDto extends PartialType(CreateLearningRecordDto) {}
|
||||||
|
|
||||||
export class UpsertResultDto {
|
export class UpsertResultDto {
|
||||||
@IsOptional() @IsNumber() cultureFinalScore?: number;
|
@IsOptional() @IsNumber() cultureFinalScore?: number;
|
||||||
@IsOptional() @IsNumber() professionalFinalScore?: number;
|
@IsOptional() @IsNumber() professionalFinalScore?: number;
|
||||||
|
|||||||
@@ -137,6 +137,42 @@ export class ClassroomRentalsController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id/cancel')
|
||||||
|
@RequirePermission('rental:edit')
|
||||||
|
async cancel(@Param('id') id: string, @Request() req: any) {
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
const result = await this.service.cancel(+id);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '教室租赁',
|
||||||
|
action: '取消租赁',
|
||||||
|
targetId: +id,
|
||||||
|
targetType: 'classroom-rental',
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/end')
|
||||||
|
@RequirePermission('rental:edit')
|
||||||
|
async end(@Param('id') id: string, @Request() req: any) {
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
const result = await this.service.end(+id);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '教室租赁',
|
||||||
|
action: '结束租赁',
|
||||||
|
targetId: +id,
|
||||||
|
targetType: 'classroom-rental',
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('rental:delete')
|
@RequirePermission('rental:delete')
|
||||||
async remove(@Param('id') id: string, @Request() req: any) {
|
async remove(@Param('id') id: string, @Request() req: any) {
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
startDate: '2026-03-01',
|
startDate: '2026-03-01',
|
||||||
endDate: '2026-03-31',
|
endDate: '2026-03-31',
|
||||||
};
|
};
|
||||||
const classroom = { id: 1, departmentId: 10 } as Classroom;
|
const classroom = { id: 1, status: 'available' } as Classroom;
|
||||||
const hostOrganization = {
|
const hostOrganization = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Host',
|
name: 'Host',
|
||||||
@@ -315,8 +315,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
classroomId: 1,
|
classroomId: 1,
|
||||||
lesseeOrganizationId: 2,
|
lesseeOrganizationId: 2,
|
||||||
startDate: '2026-03-01',
|
startDate: '2026-07-01',
|
||||||
endDate: '2026-03-31',
|
endDate: '2099-03-31',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
notes: '',
|
notes: '',
|
||||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||||
@@ -324,8 +324,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
} as ClassroomRental;
|
} as ClassroomRental;
|
||||||
const updatedRental = {
|
const updatedRental = {
|
||||||
...existingRental,
|
...existingRental,
|
||||||
startDate: '2026-04-01',
|
startDate: '2026-08-01',
|
||||||
endDate: '2026-04-30',
|
endDate: '2099-04-30',
|
||||||
};
|
};
|
||||||
const existingSchedule = {
|
const existingSchedule = {
|
||||||
id: 50,
|
id: 50,
|
||||||
@@ -339,12 +339,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
|
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
|
||||||
scheduleRepo.findOne.mockResolvedValue(existingSchedule);
|
scheduleRepo.findOne.mockResolvedValue(existingSchedule);
|
||||||
|
|
||||||
const dto: UpdateRentalDto = { startDate: '2026-04-01', endDate: '2026-04-30' };
|
const dto: UpdateRentalDto = { startDate: '2026-08-01', endDate: '2099-04-30' };
|
||||||
await service.update(1, dto);
|
await service.update(1, dto);
|
||||||
|
|
||||||
expect(rentalRepo.update).toHaveBeenCalledWith(
|
expect(rentalRepo.update).toHaveBeenCalledWith(
|
||||||
1,
|
1,
|
||||||
expect.objectContaining({ startDate: '2026-04-01', endDate: '2026-04-30' }),
|
expect.objectContaining({ startDate: '2026-08-01', endDate: '2099-04-30' }),
|
||||||
);
|
);
|
||||||
expect(scheduleRepo.update).toHaveBeenCalledWith(
|
expect(scheduleRepo.update).toHaveBeenCalledWith(
|
||||||
50,
|
50,
|
||||||
@@ -352,8 +352,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
scheduleType: 'RENTAL',
|
scheduleType: 'RENTAL',
|
||||||
rentalId: 1,
|
rentalId: 1,
|
||||||
classroomId: 1,
|
classroomId: 1,
|
||||||
startDate: '2026-04-01',
|
startDate: '2026-08-01',
|
||||||
endDate: '2026-04-30',
|
endDate: '2099-04-30',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
subject: 'Organization A 租赁',
|
subject: 'Organization A 租赁',
|
||||||
}),
|
}),
|
||||||
@@ -362,27 +362,59 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
expect(scheduleRepo.delete).not.toHaveBeenCalled();
|
expect(scheduleRepo.delete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes the RENTAL schedule row when status changes to cancelled', async () => {
|
it('deletes the RENTAL schedule row when the rental is cancelled', async () => {
|
||||||
const rental = {
|
const rental = {
|
||||||
id: 1,
|
id: 1,
|
||||||
classroomId: 1,
|
classroomId: 1,
|
||||||
lesseeOrganizationId: 2,
|
lesseeOrganizationId: 2,
|
||||||
startDate: '2026-03-01',
|
startDate: '2026-07-01',
|
||||||
endDate: '2026-03-31',
|
endDate: '2099-03-31',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||||
} as ClassroomRental;
|
} as ClassroomRental;
|
||||||
const cancelledRental = { ...rental, status: 'cancelled' };
|
const cancelledRental = { ...rental, status: 'cancelled' } as ClassroomRental;
|
||||||
|
|
||||||
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(cancelledRental);
|
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(cancelledRental);
|
||||||
|
|
||||||
await service.update(1, { status: 'cancelled' });
|
await service.cancel(1);
|
||||||
|
|
||||||
expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' });
|
expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' });
|
||||||
expect(scheduleRepo.delete).toHaveBeenCalledWith({ rentalId: 1, scheduleType: 'RENTAL' });
|
expect(scheduleRepo.delete).toHaveBeenCalledWith({ rentalId: 1, scheduleType: 'RENTAL' });
|
||||||
expect(scheduleRepo.findOne).not.toHaveBeenCalled();
|
});
|
||||||
expect(scheduleRepo.update).not.toHaveBeenCalled();
|
});
|
||||||
expect(scheduleRepo.create).not.toHaveBeenCalled();
|
|
||||||
|
describe('lifecycle actions', () => {
|
||||||
|
it('ends an active rental and shortens a future end date', async () => {
|
||||||
|
const rental = {
|
||||||
|
id: 1,
|
||||||
|
classroomId: 1,
|
||||||
|
startDate: '2026-07-01',
|
||||||
|
endDate: '2099-12-31',
|
||||||
|
status: 'active',
|
||||||
|
lesseeOrganization: { name: 'Organization A' },
|
||||||
|
} as ClassroomRental;
|
||||||
|
const ended = { ...rental, status: 'ended', endDate: '2026-07-13' } as ClassroomRental;
|
||||||
|
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(ended);
|
||||||
|
scheduleRepo.findOne.mockResolvedValue({ id: 50 } as ClassSchedule);
|
||||||
|
|
||||||
|
await service.end(1);
|
||||||
|
|
||||||
|
expect(rentalRepo.update).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({ status: 'ended' }),
|
||||||
|
);
|
||||||
|
expect(scheduleRepo.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ending a future rental', async () => {
|
||||||
|
rentalRepo.findOne.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
startDate: '2099-01-01',
|
||||||
|
endDate: '2099-12-31',
|
||||||
|
status: 'active',
|
||||||
|
} as ClassroomRental);
|
||||||
|
|
||||||
|
await expect(service.end(1)).rejects.toThrow('租赁尚未开始');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -394,7 +426,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
lesseeOrganizationId: 2,
|
lesseeOrganizationId: 2,
|
||||||
startDate: '2026-03-01',
|
startDate: '2026-03-01',
|
||||||
endDate: '2026-03-31',
|
endDate: '2026-03-31',
|
||||||
status: 'active',
|
status: 'cancelled',
|
||||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||||
} as ClassroomRental;
|
} as ClassroomRental;
|
||||||
|
|
||||||
@@ -419,7 +451,7 @@ describe('ClassroomRentalsService — organization roles', () => {
|
|||||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassroomRental>([])),
|
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassroomRental>([])),
|
||||||
} as any;
|
} as any;
|
||||||
const classroomRepo = {
|
const classroomRepo = {
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 1, departmentId: 10 }),
|
findOne: jest.fn().mockResolvedValue({ id: 1, status: 'available' }),
|
||||||
} as any;
|
} as any;
|
||||||
const organizationRepo = {
|
const organizationRepo = {
|
||||||
findOne: jest
|
findOne: jest
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
|
||||||
import { Classroom } from '../entities/classroom.entity';
|
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
|
||||||
import { Organization } from '../entities/organization.entity';
|
import { Organization } from '../entities/organization.entity';
|
||||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||||
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
|
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
|
||||||
@@ -70,8 +70,11 @@ export class ClassroomRentalsService {
|
|||||||
const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||||
qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last });
|
qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last });
|
||||||
}
|
}
|
||||||
if (!query?.includeEnded) qb.andWhere('r.status != :cancelled', { cancelled: 'cancelled' });
|
if (!query?.includeEnded) {
|
||||||
return qb.getMany();
|
qb.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE });
|
||||||
|
}
|
||||||
|
const rentals = await qb.getMany();
|
||||||
|
return rentals.map((rental) => this.withEffectiveStatus(rental));
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: number) {
|
async findOne(id: number) {
|
||||||
@@ -80,7 +83,7 @@ export class ClassroomRentalsService {
|
|||||||
relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'],
|
relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'],
|
||||||
});
|
});
|
||||||
if (!rental) throw new NotFoundException('租赁订单不存在');
|
if (!rental) throw new NotFoundException('租赁订单不存在');
|
||||||
return rental;
|
return this.withEffectiveStatus(rental);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
|
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
|
||||||
@@ -93,7 +96,7 @@ export class ClassroomRentalsService {
|
|||||||
where: {
|
where: {
|
||||||
...(excludeId ? { id: Not(excludeId) } : {}),
|
...(excludeId ? { id: Not(excludeId) } : {}),
|
||||||
classroomId,
|
classroomId,
|
||||||
status: Not('cancelled'),
|
status: ClassroomRentalStatus.ACTIVE,
|
||||||
startDate: LessThanOrEqual(monthEnd),
|
startDate: LessThanOrEqual(monthEnd),
|
||||||
endDate: MoreThanOrEqual(monthStart),
|
endDate: MoreThanOrEqual(monthStart),
|
||||||
},
|
},
|
||||||
@@ -101,7 +104,7 @@ export class ClassroomRentalsService {
|
|||||||
this.scheduleRepo.find({
|
this.scheduleRepo.find({
|
||||||
where: {
|
where: {
|
||||||
classroomId,
|
classroomId,
|
||||||
status: 'active',
|
status: ClassroomRentalStatus.ACTIVE,
|
||||||
scheduleType: 'INTERNAL',
|
scheduleType: 'INTERNAL',
|
||||||
startDate: LessThanOrEqual(monthEnd),
|
startDate: LessThanOrEqual(monthEnd),
|
||||||
endDate: MoreThanOrEqual(monthStart),
|
endDate: MoreThanOrEqual(monthStart),
|
||||||
@@ -133,7 +136,7 @@ export class ClassroomRentalsService {
|
|||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
||||||
.where('r.classroomId = :cid', { cid: classroomId })
|
.where('r.classroomId = :cid', { cid: classroomId })
|
||||||
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
|
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
|
||||||
.andWhere('r.startDate <= :end', { end: endDate })
|
.andWhere('r.startDate <= :end', { end: endDate })
|
||||||
.andWhere('r.endDate >= :start', { start: startDate });
|
.andWhere('r.endDate >= :start', { start: startDate });
|
||||||
if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId });
|
if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId });
|
||||||
@@ -222,6 +225,9 @@ export class ClassroomRentalsService {
|
|||||||
if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期');
|
if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期');
|
||||||
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
||||||
if (!classroom) throw new NotFoundException('教室不存在');
|
if (!classroom) throw new NotFoundException('教室不存在');
|
||||||
|
if (classroom.status !== ClassroomStatus.AVAILABLE) {
|
||||||
|
throw new BadRequestException('仅可用教室可以创建租赁');
|
||||||
|
}
|
||||||
const lessorOrganization = dto.lessorOrganizationId
|
const lessorOrganization = dto.lessorOrganizationId
|
||||||
? await this.organizationRepo.findOne({
|
? await this.organizationRepo.findOne({
|
||||||
where: { id: dto.lessorOrganizationId, status: 'active' },
|
where: { id: dto.lessorOrganizationId, status: 'active' },
|
||||||
@@ -253,7 +259,7 @@ export class ClassroomRentalsService {
|
|||||||
lessorOrganizationId: lessorOrganization.id,
|
lessorOrganizationId: lessorOrganization.id,
|
||||||
lesseeOrganizationId: lesseeOrganization.id,
|
lesseeOrganizationId: lesseeOrganization.id,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
status: 'active',
|
status: ClassroomRentalStatus.ACTIVE,
|
||||||
});
|
});
|
||||||
const saved = await this.repo.save(rental);
|
const saved = await this.repo.save(rental);
|
||||||
await this.syncScheduleFromRental(saved, lesseeOrganization.name);
|
await this.syncScheduleFromRental(saved, lesseeOrganization.name);
|
||||||
@@ -262,11 +268,21 @@ export class ClassroomRentalsService {
|
|||||||
|
|
||||||
async update(id: number, dto: UpdateRentalDto) {
|
async update(id: number, dto: UpdateRentalDto) {
|
||||||
const rental = await this.findOne(id);
|
const rental = await this.findOne(id);
|
||||||
|
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
|
||||||
|
throw new BadRequestException('已结束或已取消的租赁不能编辑');
|
||||||
|
}
|
||||||
// 若修改了教室/日期,重新冲突检查
|
// 若修改了教室/日期,重新冲突检查
|
||||||
const newClassroomId = dto.classroomId ?? rental.classroomId;
|
const newClassroomId = dto.classroomId ?? rental.classroomId;
|
||||||
const newStart = dto.startDate ?? rental.startDate;
|
const newStart = dto.startDate ?? rental.startDate;
|
||||||
const newEnd = dto.endDate ?? rental.endDate;
|
const newEnd = dto.endDate ?? rental.endDate;
|
||||||
if (newStart > newEnd) throw new BadRequestException('起始日期不能晚于结束日期');
|
if (newStart > newEnd) throw new BadRequestException('起始日期不能晚于结束日期');
|
||||||
|
if (dto.classroomId && dto.classroomId !== rental.classroomId) {
|
||||||
|
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
||||||
|
if (!classroom) throw new NotFoundException('教室不存在');
|
||||||
|
if (classroom.status !== ClassroomStatus.AVAILABLE) {
|
||||||
|
throw new BadRequestException('仅可用教室可以承接租赁');
|
||||||
|
}
|
||||||
|
}
|
||||||
if (dto.classroomId || dto.startDate || dto.endDate) {
|
if (dto.classroomId || dto.startDate || dto.endDate) {
|
||||||
const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id);
|
const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id);
|
||||||
if (conflicts.length > 0) {
|
if (conflicts.length > 0) {
|
||||||
@@ -300,16 +316,46 @@ export class ClassroomRentalsService {
|
|||||||
}
|
}
|
||||||
await this.repo.update(id, dto);
|
await this.repo.update(id, dto);
|
||||||
const updated = await this.findOne(id);
|
const updated = await this.findOne(id);
|
||||||
if (dto.status === 'cancelled') {
|
|
||||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
|
||||||
} else {
|
|
||||||
await this.syncScheduleFromRental(updated);
|
await this.syncScheduleFromRental(updated);
|
||||||
}
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancel(id: number) {
|
||||||
|
const rental = await this.findOne(id);
|
||||||
|
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
|
||||||
|
throw new BadRequestException('仅有效租赁可以取消');
|
||||||
|
}
|
||||||
|
await this.repo.update(id, { status: ClassroomRentalStatus.CANCELLED });
|
||||||
|
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||||
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async end(id: number) {
|
||||||
|
const rental = await this.findOne(id);
|
||||||
|
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
|
||||||
|
throw new BadRequestException('仅有效租赁可以结束');
|
||||||
|
}
|
||||||
|
const today = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(new Date());
|
||||||
|
if (rental.startDate > today) throw new BadRequestException('租赁尚未开始,不能结束');
|
||||||
|
await this.repo.update(id, {
|
||||||
|
status: ClassroomRentalStatus.ENDED,
|
||||||
|
endDate: rental.endDate > today ? today : rental.endDate,
|
||||||
|
});
|
||||||
|
const ended = await this.findOne(id);
|
||||||
|
await this.syncScheduleFromRental(ended);
|
||||||
|
return ended;
|
||||||
|
}
|
||||||
|
|
||||||
async remove(id: number) {
|
async remove(id: number) {
|
||||||
const rental = await this.findOne(id);
|
const rental = await this.findOne(id);
|
||||||
|
if (rental.effectiveStatus === ClassroomRentalStatus.ACTIVE) {
|
||||||
|
throw new BadRequestException('进行中的租赁请先取消或结束');
|
||||||
|
}
|
||||||
// 同步删除对应排课记录
|
// 同步删除对应排课记录
|
||||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||||
// 同时删除合同文件
|
// 同时删除合同文件
|
||||||
@@ -327,6 +373,20 @@ export class ClassroomRentalsService {
|
|||||||
return { message: '删除成功' };
|
return { message: '删除成功' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private withEffectiveStatus(rental: ClassroomRental) {
|
||||||
|
const today = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(new Date());
|
||||||
|
const effectiveStatus =
|
||||||
|
rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today
|
||||||
|
? ClassroomRentalStatus.ENDED
|
||||||
|
: rental.status;
|
||||||
|
return Object.assign(rental, { effectiveStatus });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步租赁订单到 class_schedules(schedule_type = 'RENTAL')
|
* 同步租赁订单到 class_schedules(schedule_type = 'RENTAL')
|
||||||
*/
|
*/
|
||||||
@@ -348,7 +408,7 @@ export class ClassroomRentalsService {
|
|||||||
teacherId: null,
|
teacherId: null,
|
||||||
scheduleType: 'RENTAL',
|
scheduleType: 'RENTAL',
|
||||||
rentalId: rental.id,
|
rentalId: rental.id,
|
||||||
status: 'active',
|
status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active',
|
||||||
notes: rental.notes,
|
notes: rental.notes,
|
||||||
};
|
};
|
||||||
if (schedule) {
|
if (schedule) {
|
||||||
@@ -437,14 +497,16 @@ export class ClassroomRentalsService {
|
|||||||
const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||||
|
|
||||||
const classrooms = await this.classroomRepo.find({
|
const classrooms = await this.classroomRepo.find({
|
||||||
where: { status: Not('archived') },
|
where: { status: Not(ClassroomStatus.ARCHIVED) },
|
||||||
order: { building: 'ASC', name: 'ASC' },
|
order: { building: 'ASC', name: 'ASC' },
|
||||||
});
|
});
|
||||||
const rentals = await this.repo
|
const rentals = await this.repo
|
||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
||||||
.leftJoinAndSelect('r.classroom', 'classroom')
|
.leftJoinAndSelect('r.classroom', 'classroom')
|
||||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
.where('r.status IN (:...statuses)', {
|
||||||
|
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
|
||||||
|
})
|
||||||
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
|
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsOptional, IsString, IsInt, IsNumber, IsEnum, IsDateString } from 'class-validator';
|
import { IsOptional, IsString, IsInt, IsNumber, IsDateString } from 'class-validator';
|
||||||
|
|
||||||
export class CreateRentalDto {
|
export class CreateRentalDto {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -62,8 +62,4 @@ export class UpdateRentalDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(['active', 'ended', 'cancelled'])
|
|
||||||
status?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, Not } from 'typeorm';
|
import { Repository, Not, MoreThanOrEqual } from 'typeorm';
|
||||||
import { Classroom } from '../entities/classroom.entity';
|
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
|
||||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
|
||||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||||
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
|
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClassroomsService {
|
export class ClassroomsService {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Classroom) private repo: Repository<Classroom>,
|
@InjectRepository(Classroom) private repo: Repository<Classroom>,
|
||||||
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
|
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
|
||||||
@@ -21,49 +20,148 @@ export class ClassroomsService {
|
|||||||
if (query?.roomType) where.roomType = query.roomType;
|
if (query?.roomType) where.roomType = query.roomType;
|
||||||
if (!query?.includeArchived) where.status = Not('archived');
|
if (!query?.includeArchived) where.status = Not('archived');
|
||||||
const list = await this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } });
|
const list = await this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } });
|
||||||
const usageMap = await this.getCurrentUsageForClassrooms(list.map((c) => c.id));
|
const usageMap = await this.getUsageForClassrooms(list.map((c) => c.id));
|
||||||
return list.map((c) => ({ ...c, currentUsage: usageMap.get(c.id) ?? null }));
|
return list.map((classroom) => this.withEffectiveStatus(classroom, usageMap.get(classroom.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: number) {
|
async findOne(id: number) {
|
||||||
const cls = await this.repo.findOne({ where: { id } });
|
const cls = await this.repo.findOne({ where: { id } });
|
||||||
if (!cls) throw new NotFoundException('教室不存在');
|
if (!cls) throw new NotFoundException('教室不存在');
|
||||||
const usageMap = await this.getCurrentUsageForClassrooms([id]);
|
const usageMap = await this.getUsageForClassrooms([id]);
|
||||||
return { ...cls, currentUsage: usageMap.get(id) ?? null };
|
return this.withEffectiveStatus(cls, usageMap.get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateClassroomDto) {
|
async create(dto: CreateClassroomDto) {
|
||||||
const exists = await this.repo.findOne({ where: { name: dto.name } });
|
const exists = await this.repo.findOne({ where: { name: dto.name } });
|
||||||
if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
|
if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
|
||||||
return this.repo.save(this.repo.create(dto));
|
return this.repo.save(this.repo.create({ ...dto, status: ClassroomStatus.AVAILABLE }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, dto: UpdateClassroomDto) {
|
async update(id: number, dto: UpdateClassroomDto) {
|
||||||
await this.findOne(id);
|
const classroom = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!classroom) throw new NotFoundException('教室不存在');
|
||||||
|
if (dto.status === ClassroomStatus.MAINTENANCE && classroom.status !== dto.status) {
|
||||||
|
await this.assertNoActiveAllocations(id);
|
||||||
|
}
|
||||||
await this.repo.update(id, dto);
|
await this.repo.update(id, dto);
|
||||||
return this.repo.findOne({ where: { id } });
|
return this.repo.findOne({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number) {
|
async remove(id: number) {
|
||||||
await this.findOne(id);
|
const classroom = await this.repo.findOne({ where: { id } });
|
||||||
await this.repo.update(id, { status: 'archived' });
|
if (!classroom) throw new NotFoundException('教室不存在');
|
||||||
|
await this.assertNoActiveAllocations(id);
|
||||||
|
await this.repo.update(id, { status: ClassroomStatus.ARCHIVED });
|
||||||
return { message: '已归档' };
|
return { message: '已归档' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async restore(id: number) {
|
async restore(id: number) {
|
||||||
await this.findOne(id);
|
const classroom = await this.repo.findOne({ where: { id } });
|
||||||
await this.repo.update(id, { status: 'reserved' });
|
if (!classroom) throw new NotFoundException('教室不存在');
|
||||||
|
await this.repo.update(id, { status: ClassroomStatus.AVAILABLE });
|
||||||
return this.repo.findOne({ where: { id } });
|
return this.repo.findOne({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getCurrentUsageForClassrooms(classroomIds: number[]): Promise<Map<number, { type: 'schedule' | 'rental'; title: string; startTime: string; endTime: string }>> {
|
private withEffectiveStatus(
|
||||||
const result = new Map<number, { type: 'schedule' | 'rental'; title: string; startTime: string; endTime: string }>();
|
classroom: Classroom,
|
||||||
|
usage?: {
|
||||||
|
state: 'in_use' | 'reserved';
|
||||||
|
currentUsage: {
|
||||||
|
type: 'schedule' | 'rental';
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
} | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const effectiveStatus =
|
||||||
|
classroom.status === ClassroomStatus.ARCHIVED ||
|
||||||
|
classroom.status === ClassroomStatus.MAINTENANCE
|
||||||
|
? classroom.status
|
||||||
|
: (usage?.state ?? ClassroomStatus.AVAILABLE);
|
||||||
|
return { ...classroom, currentUsage: usage?.currentUsage ?? null, effectiveStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertNoActiveAllocations(classroomId: number) {
|
||||||
|
const today = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(new Date());
|
||||||
|
const scheduleCount = await this.scheduleRepo
|
||||||
|
.createQueryBuilder('schedule')
|
||||||
|
.where('schedule.classroomId = :classroomId', { classroomId })
|
||||||
|
.andWhere('schedule.status = :active', { active: 'active' })
|
||||||
|
.andWhere('schedule.endDate >= :today', { today })
|
||||||
|
.getCount();
|
||||||
|
const rentalCount = await this.rentalRepo.count({
|
||||||
|
where: {
|
||||||
|
classroomId,
|
||||||
|
status: ClassroomRentalStatus.ACTIVE,
|
||||||
|
endDate: MoreThanOrEqual(today),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (rentalCount > 0 || scheduleCount > 0) {
|
||||||
|
throw new BadRequestException('该教室存在有效排课或租赁,无法维护或归档');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUsageForClassrooms(classroomIds: number[]): Promise<
|
||||||
|
Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
state: 'in_use' | 'reserved';
|
||||||
|
currentUsage: {
|
||||||
|
type: 'schedule' | 'rental';
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
const result = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
state: 'in_use' | 'reserved';
|
||||||
|
currentUsage: {
|
||||||
|
type: 'schedule' | 'rental';
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
>();
|
||||||
if (classroomIds.length === 0) return result;
|
if (classroomIds.length === 0) return result;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStr = now.toISOString().slice(0, 10);
|
const todayStr = new Intl.DateTimeFormat('en-CA', {
|
||||||
const currentTime = now.toTimeString().slice(0, 5);
|
timeZone: 'Asia/Shanghai',
|
||||||
const weekDay = now.getDay() || 7;
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(now);
|
||||||
|
const currentTime = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(now);
|
||||||
|
const shanghaiParts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
weekday: 'short',
|
||||||
|
}).format(now);
|
||||||
|
const weekDayMap: Record<string, number> = {
|
||||||
|
Mon: 1,
|
||||||
|
Tue: 2,
|
||||||
|
Wed: 3,
|
||||||
|
Thu: 4,
|
||||||
|
Fri: 5,
|
||||||
|
Sat: 6,
|
||||||
|
Sun: 7,
|
||||||
|
};
|
||||||
|
const weekDay = weekDayMap[shanghaiParts];
|
||||||
|
|
||||||
const schedules = await this.scheduleRepo
|
const schedules = await this.scheduleRepo
|
||||||
.createQueryBuilder('s')
|
.createQueryBuilder('s')
|
||||||
@@ -71,26 +169,37 @@ export class ClassroomsService {
|
|||||||
.select('s.classroomId', 'classroomId')
|
.select('s.classroomId', 'classroomId')
|
||||||
.addSelect('s.startTime', 'startTime')
|
.addSelect('s.startTime', 'startTime')
|
||||||
.addSelect('s.endTime', 'endTime')
|
.addSelect('s.endTime', 'endTime')
|
||||||
|
.addSelect('s.startDate', 'startDate')
|
||||||
|
.addSelect('s.endDate', 'endDate')
|
||||||
|
.addSelect('s.weekDay', 'weekDay')
|
||||||
.addSelect('s.subject', 'subject')
|
.addSelect('s.subject', 'subject')
|
||||||
.addSelect('c.name', 'className')
|
.addSelect('c.name', 'className')
|
||||||
.where('s.classroomId IN (:...ids)', { ids: classroomIds })
|
.where('s.classroomId IN (:...ids)', { ids: classroomIds })
|
||||||
.andWhere('s.status = :active', { active: 'active' })
|
.andWhere('s.status = :active', { active: 'active' })
|
||||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||||
.andWhere('s.startDate <= :today', { today: todayStr })
|
|
||||||
.andWhere('s.endDate >= :today', { today: todayStr })
|
.andWhere('s.endDate >= :today', { today: todayStr })
|
||||||
.andWhere('s.weekDay = :weekDay', { weekDay })
|
|
||||||
.andWhere('s.startTime <= :currentTime', { currentTime })
|
|
||||||
.andWhere('s.endTime >= :currentTime', { currentTime })
|
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
for (const s of schedules) {
|
for (const schedule of schedules) {
|
||||||
const classroomId = Number(s.classroomId);
|
const classroomId = Number(schedule.classroomId);
|
||||||
if (!result.has(classroomId)) {
|
const isCurrent =
|
||||||
|
String(schedule.startDate) <= todayStr &&
|
||||||
|
String(schedule.endDate) >= todayStr &&
|
||||||
|
Number(schedule.weekDay) === weekDay &&
|
||||||
|
String(schedule.startTime) <= currentTime &&
|
||||||
|
String(schedule.endTime) >= currentTime;
|
||||||
|
const existing = result.get(classroomId);
|
||||||
|
if (!existing || isCurrent) {
|
||||||
result.set(classroomId, {
|
result.set(classroomId, {
|
||||||
|
state: isCurrent ? 'in_use' : 'reserved',
|
||||||
|
currentUsage: isCurrent
|
||||||
|
? {
|
||||||
type: 'schedule',
|
type: 'schedule',
|
||||||
title: `${s.className || ''} ${s.subject || ''}`.trim() || '内部课程',
|
title: `${schedule.className || ''} ${schedule.subject || ''}`.trim() || '内部课程',
|
||||||
startTime: String(s.startTime),
|
startTime: String(schedule.startTime),
|
||||||
endTime: String(s.endTime),
|
endTime: String(schedule.endTime),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,19 +212,25 @@ export class ClassroomsService {
|
|||||||
.addSelect('r.endDate', 'endDate')
|
.addSelect('r.endDate', 'endDate')
|
||||||
.addSelect('t.name', 'tenantName')
|
.addSelect('t.name', 'tenantName')
|
||||||
.where('r.classroomId IN (:...ids)', { ids: classroomIds })
|
.where('r.classroomId IN (:...ids)', { ids: classroomIds })
|
||||||
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
|
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
|
||||||
.andWhere('r.startDate <= :today', { today: todayStr })
|
|
||||||
.andWhere('r.endDate >= :today', { today: todayStr })
|
.andWhere('r.endDate >= :today', { today: todayStr })
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
for (const r of rentals) {
|
for (const rental of rentals) {
|
||||||
const classroomId = Number(r.classroomId);
|
const classroomId = Number(rental.classroomId);
|
||||||
if (!result.has(classroomId)) {
|
const isCurrent = String(rental.startDate) <= todayStr && String(rental.endDate) >= todayStr;
|
||||||
|
const existing = result.get(classroomId);
|
||||||
|
if (!existing || isCurrent) {
|
||||||
result.set(classroomId, {
|
result.set(classroomId, {
|
||||||
|
state: isCurrent ? 'in_use' : 'reserved',
|
||||||
|
currentUsage: isCurrent
|
||||||
|
? {
|
||||||
type: 'rental',
|
type: 'rental',
|
||||||
title: r.tenantName ? `${r.tenantName} 租赁` : '外部租赁',
|
title: rental.tenantName ? `${rental.tenantName} 租赁` : '外部租赁',
|
||||||
startTime: '00:00',
|
startTime: '00:00',
|
||||||
endTime: '23:59',
|
endTime: '23:59',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,24 +251,38 @@ export class ClassroomsService {
|
|||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row.name?.trim()) { skipped++; continue; }
|
if (!row.name?.trim()) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
|
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
|
||||||
if (exists) { errors.push(`教室 ${row.name} 已存在`); skipped++; continue; }
|
if (exists) {
|
||||||
|
errors.push(`教室 ${row.name} 已存在`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
|
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
|
||||||
imported++;
|
imported++;
|
||||||
}
|
}
|
||||||
return { message: `成功导入 ${imported} 间教室,跳过 ${skipped} 间`, imported, skipped, errors: errors.length > 0 ? errors : undefined };
|
return {
|
||||||
|
message: `成功导入 ${imported} 间教室,跳过 ${skipped} 间`,
|
||||||
|
imported,
|
||||||
|
skipped,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUsageReport(dateFrom: string, dateTo: string) {
|
async getUsageReport(dateFrom: string, dateTo: string) {
|
||||||
const classrooms = await this.repo.find({
|
const classrooms = await this.repo.find({
|
||||||
where: { status: Not('archived') },
|
where: { status: Not(ClassroomStatus.ARCHIVED) },
|
||||||
order: { building: 'ASC', name: 'ASC' },
|
order: { building: 'ASC', name: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const rentals = await this.rentalRepo
|
const rentals = await this.rentalRepo
|
||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
.where('r.status IN (:...statuses)', {
|
||||||
|
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
|
||||||
|
})
|
||||||
.andWhere('r.startDate <= :dateTo AND r.endDate >= :dateFrom', { dateFrom, dateTo })
|
.andWhere('r.startDate <= :dateTo AND r.endDate >= :dateFrom', { dateFrom, dateTo })
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
|
|||||||
39
apps/server/src/classrooms/classrooms.status.spec.ts
Normal file
39
apps/server/src/classrooms/classrooms.status.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { ClassroomStatus } from '../entities/classroom.entity';
|
||||||
|
import { ClassroomsService } from './classrooms.service';
|
||||||
|
|
||||||
|
function createService(options?: { rentals?: number; schedules?: number }) {
|
||||||
|
const repo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 1, name: 'A101', status: ClassroomStatus.ARCHIVED }),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
const rentalRepo = { count: jest.fn().mockResolvedValue(options?.rentals ?? 0) };
|
||||||
|
const scheduleRepo = {
|
||||||
|
createQueryBuilder: jest.fn().mockReturnValue({
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getCount: jest.fn().mockResolvedValue(options?.schedules ?? 0),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
service: new ClassroomsService(repo as never, rentalRepo as never, scheduleRepo as never),
|
||||||
|
repo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ClassroomsService — persisted classroom status', () => {
|
||||||
|
it('restores an archived classroom to available', async () => {
|
||||||
|
const { service, repo } = createService();
|
||||||
|
|
||||||
|
await service.restore(1);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith(1, { status: ClassroomStatus.AVAILABLE });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects archiving a classroom with active allocations', async () => {
|
||||||
|
const { service, repo } = createService({ rentals: 1 });
|
||||||
|
|
||||||
|
await expect(service.remove(1)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsOptional, IsString, IsNotEmpty, IsInt, IsEnum } from 'class-validator';
|
import { IsOptional, IsString, IsNotEmpty, IsInt, IsEnum } from 'class-validator';
|
||||||
|
import { ClassroomStatus } from '../../entities/classroom.entity';
|
||||||
|
|
||||||
export class CreateClassroomDto {
|
export class CreateClassroomDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -21,11 +22,9 @@ export class CreateClassroomDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
roomType?: string; // 大 / 次大 / 小
|
roomType?: string; // 大 / 次大 / 小
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateClassroomDto {
|
export class UpdateClassroomDto {
|
||||||
@@ -49,12 +48,11 @@ export class UpdateClassroomDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
roomType?: string;
|
roomType?: string;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(['available', 'archived'])
|
@IsEnum([ClassroomStatus.AVAILABLE, ClassroomStatus.MAINTENANCE])
|
||||||
status?: string;
|
status?: ClassroomStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export class DashboardService {
|
|||||||
const pendingDeposits = parseFloat(pendingResult?.total || '0');
|
const pendingDeposits = parseFloat(pendingResult?.total || '0');
|
||||||
|
|
||||||
const activeRentals = await this.rentalRepo.count({
|
const activeRentals = await this.rentalRepo.count({
|
||||||
where: { endDate: MoreThanOrEqual(todayStr) },
|
where: { status: 'active' as const, endDate: MoreThanOrEqual(todayStr) },
|
||||||
});
|
});
|
||||||
|
|
||||||
const occByBldQb = this.occRepo
|
const occByBldQb = this.occRepo
|
||||||
@@ -365,7 +365,7 @@ export class DashboardService {
|
|||||||
|
|
||||||
async getClassroomOccupancy() {
|
async getClassroomOccupancy() {
|
||||||
const classrooms = await this.classroomRepo.find({
|
const classrooms = await this.classroomRepo.find({
|
||||||
where: { status: Not('archived') },
|
where: { status: 'available' as const },
|
||||||
order: { building: 'ASC', name: 'ASC' },
|
order: { building: 'ASC', name: 'ASC' },
|
||||||
});
|
});
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
@@ -382,7 +382,7 @@ export class DashboardService {
|
|||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.select('r.classroomId', 'classroomId')
|
.select('r.classroomId', 'classroomId')
|
||||||
.addSelect('COUNT(*)', 'rentalCount')
|
.addSelect('COUNT(*)', 'rentalCount')
|
||||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
.where('r.status = :active', { active: 'active' })
|
||||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
||||||
.groupBy('r.classroomId');
|
.groupBy('r.classroomId');
|
||||||
const rentals = await rentalQb.getRawMany();
|
const rentals = await rentalQb.getRawMany();
|
||||||
@@ -402,7 +402,7 @@ export class DashboardService {
|
|||||||
|
|
||||||
async getClassroomUtilizationStats() {
|
async getClassroomUtilizationStats() {
|
||||||
const totalClassrooms = await this.classroomRepo.count({
|
const totalClassrooms = await this.classroomRepo.count({
|
||||||
where: { status: Not('archived') },
|
where: { status: 'available' as const },
|
||||||
});
|
});
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
@@ -420,7 +420,7 @@ export class DashboardService {
|
|||||||
const rentalQb = this.rentalRepo
|
const rentalQb = this.rentalRepo
|
||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.select('COUNT(DISTINCT r.classroomId)', 'cnt')
|
.select('COUNT(DISTINCT r.classroomId)', 'cnt')
|
||||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
.where('r.status = :active', { active: 'active' })
|
||||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
|
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
|
||||||
const rentalResult = await rentalQb.getRawOne();
|
const rentalResult = await rentalQb.getRawOne();
|
||||||
|
|
||||||
@@ -437,7 +437,7 @@ export class DashboardService {
|
|||||||
const combinedRentalQb = this.rentalRepo
|
const combinedRentalQb = this.rentalRepo
|
||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.select('r.classroomId')
|
.select('r.classroomId')
|
||||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
.where('r.status = :active', { active: 'active' })
|
||||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
||||||
.groupBy('r.classroomId');
|
.groupBy('r.classroomId');
|
||||||
const rentalIds = await combinedRentalQb.getRawMany();
|
const rentalIds = await combinedRentalQb.getRawMany();
|
||||||
|
|||||||
@@ -0,0 +1,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
await this.removeUnusedClassroomColumns();
|
await this.removeUnusedClassroomColumns();
|
||||||
await this.cleanupDepositRefundColumns();
|
await this.cleanupDepositRefundColumns();
|
||||||
await this.removeUnusedClassStudentColumns();
|
await this.removeUnusedClassStudentColumns();
|
||||||
|
await this.normalizeClassroomStatuses();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removeUnusedClassroomColumns(): Promise<void> {
|
private async removeUnusedClassroomColumns(): Promise<void> {
|
||||||
@@ -92,6 +93,22 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async normalizeClassroomStatuses(): Promise<void> {
|
||||||
|
const runner = this.dataSource.createQueryRunner();
|
||||||
|
await runner.connect();
|
||||||
|
try {
|
||||||
|
const tables = await runner.getTables(['classrooms']);
|
||||||
|
if (tables.length === 0) return;
|
||||||
|
await runner.query(`
|
||||||
|
UPDATE classrooms
|
||||||
|
SET status = 'available'
|
||||||
|
WHERE status IS NULL OR status NOT IN ('available', 'maintenance', 'archived')
|
||||||
|
`);
|
||||||
|
} finally {
|
||||||
|
await runner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureAiConfigTable(): Promise<void> {
|
private async ensureAiConfigTable(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
const runner = this.dataSource.createQueryRunner();
|
||||||
await runner.connect();
|
await runner.connect();
|
||||||
@@ -410,14 +427,17 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
// Drop any existing FK constraint on schedule_id or class_id
|
// Drop any existing FK constraint on schedule_id or class_id
|
||||||
const fkColumns = ['schedule_id', 'class_id'];
|
const fkColumns = ['schedule_id', 'class_id'];
|
||||||
for (const col of fkColumns) {
|
for (const col of fkColumns) {
|
||||||
const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(`
|
const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(
|
||||||
|
`
|
||||||
SELECT CONSTRAINT_NAME
|
SELECT CONSTRAINT_NAME
|
||||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||||
WHERE TABLE_SCHEMA = DATABASE()
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
AND TABLE_NAME = 'attendance_sessions'
|
AND TABLE_NAME = 'attendance_sessions'
|
||||||
AND COLUMN_NAME = ?
|
AND COLUMN_NAME = ?
|
||||||
AND REFERENCED_TABLE_NAME IS NOT NULL
|
AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||||
`, [col]);
|
`,
|
||||||
|
[col],
|
||||||
|
);
|
||||||
|
|
||||||
for (const row of fkRows) {
|
for (const row of fkRows) {
|
||||||
try {
|
try {
|
||||||
@@ -437,13 +457,16 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
];
|
];
|
||||||
for (const c of constraints) {
|
for (const c of constraints) {
|
||||||
// Only skip if RESTRICT constraint is already confirmed via information_schema
|
// Only skip if RESTRICT constraint is already confirmed via information_schema
|
||||||
const existing: Array<{ DELETE_RULE: string }> = await runner.query(`
|
const existing: Array<{ DELETE_RULE: string }> = await runner.query(
|
||||||
|
`
|
||||||
SELECT DELETE_RULE
|
SELECT DELETE_RULE
|
||||||
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
|
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
|
||||||
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
||||||
AND TABLE_NAME = 'attendance_sessions'
|
AND TABLE_NAME = 'attendance_sessions'
|
||||||
AND CONSTRAINT_NAME = ?
|
AND CONSTRAINT_NAME = ?
|
||||||
`, [c.name]);
|
`,
|
||||||
|
[c.name],
|
||||||
|
);
|
||||||
|
|
||||||
if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') {
|
if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') {
|
||||||
this.logger.log(`考勤场次删除保护约束已存在: ${c.name}`);
|
this.logger.log(`考勤场次删除保护约束已存在: ${c.name}`);
|
||||||
@@ -505,17 +528,13 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
FROM attendance_sessions
|
FROM attendance_sessions
|
||||||
`);
|
`);
|
||||||
await runner.query('DROP TABLE attendance_sessions');
|
await runner.query('DROP TABLE attendance_sessions');
|
||||||
await runner.query(
|
await runner.query('ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions');
|
||||||
'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions',
|
|
||||||
);
|
|
||||||
await runner.query(
|
await runner.query(
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
|
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Rebuild attendance_records to add/protect FK on attendance_session_id
|
// Rebuild attendance_records to add/protect FK on attendance_session_id
|
||||||
const recordsFk = await runner.query(
|
const recordsFk = await runner.query("PRAGMA foreign_key_list('attendance_records')");
|
||||||
"PRAGMA foreign_key_list('attendance_records')",
|
|
||||||
);
|
|
||||||
const hasSessionFk = recordsFk.some(
|
const hasSessionFk = recordsFk.some(
|
||||||
(r: { from: string }) => r.from === 'attendance_session_id',
|
(r: { from: string }) => r.from === 'attendance_session_id',
|
||||||
);
|
);
|
||||||
@@ -548,9 +567,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
FROM attendance_records
|
FROM attendance_records
|
||||||
`);
|
`);
|
||||||
await runner.query('DROP TABLE attendance_records');
|
await runner.query('DROP TABLE attendance_records');
|
||||||
await runner.query(
|
await runner.query('ALTER TABLE attendance_records_new RENAME TO attendance_records');
|
||||||
'ALTER TABLE attendance_records_new RENAME TO attendance_records',
|
|
||||||
);
|
|
||||||
await runner.query(
|
await runner.query(
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
|
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
|
||||||
);
|
);
|
||||||
@@ -560,9 +577,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
// If violations exist, the transaction rolls back and old tables are preserved.
|
// If violations exist, the transaction rolls back and old tables are preserved.
|
||||||
const checkRows = await runner.query('PRAGMA foreign_key_check');
|
const checkRows = await runner.query('PRAGMA foreign_key_check');
|
||||||
if (checkRows.length > 0) {
|
if (checkRows.length > 0) {
|
||||||
throw new Error(
|
throw new Error(`外键一致性检查失败: ${checkRows.length} 行违反外键约束`);
|
||||||
`外键一致性检查失败: ${checkRows.length} 行违反外键约束`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await runner.query('COMMIT');
|
await runner.query('COMMIT');
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import {
|
|||||||
import { Classroom } from './classroom.entity';
|
import { Classroom } from './classroom.entity';
|
||||||
import { Organization } from './organization.entity';
|
import { Organization } from './organization.entity';
|
||||||
|
|
||||||
|
export enum ClassroomRentalStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
ENDED = 'ended',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
@Entity('classroom_rentals')
|
@Entity('classroom_rentals')
|
||||||
@Index(['classroomId', 'startDate', 'endDate'])
|
@Index(['classroomId', 'startDate', 'endDate'])
|
||||||
export class ClassroomRental {
|
export class ClassroomRental {
|
||||||
@@ -57,8 +63,8 @@ export class ClassroomRental {
|
|||||||
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 20, default: 'active' })
|
@Column({ type: 'varchar', length: 20, default: ClassroomRentalStatus.ACTIVE })
|
||||||
status: string; // active / ended / cancelled
|
status: ClassroomRentalStatus | 'active' | 'ended' | 'cancelled';
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
notes: string;
|
notes: string;
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
export enum ClassroomStatus {
|
||||||
|
AVAILABLE = 'available',
|
||||||
|
MAINTENANCE = 'maintenance',
|
||||||
|
ARCHIVED = 'archived',
|
||||||
|
}
|
||||||
|
|
||||||
@Entity('classrooms')
|
@Entity('classrooms')
|
||||||
export class Classroom {
|
export class Classroom {
|
||||||
@@ -20,14 +26,12 @@ export class Classroom {
|
|||||||
@Column({ name: 'room_type', type: 'varchar', length: 20, default: '大' })
|
@Column({ name: 'room_type', type: 'varchar', length: 20, default: '大' })
|
||||||
roomType: string; // 大 / 次大 / 小
|
roomType: string; // 大 / 次大 / 小
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: ClassroomStatus.AVAILABLE })
|
||||||
@Column({ type: 'varchar', length: 20, default: 'reserved' })
|
status: ClassroomStatus | 'available' | 'maintenance' | 'archived';
|
||||||
status: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
notes: string;
|
notes: string;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ export { User } from './user.entity';
|
|||||||
export { OperationLog } from './operation-log.entity';
|
export { OperationLog } from './operation-log.entity';
|
||||||
export { Deposit } from './deposit.entity';
|
export { Deposit } from './deposit.entity';
|
||||||
export { DepositInstallment } from './deposit-installment.entity';
|
export { DepositInstallment } from './deposit-installment.entity';
|
||||||
export { Classroom } from './classroom.entity';
|
export { Classroom, ClassroomStatus } from './classroom.entity';
|
||||||
export { Organization } from './organization.entity';
|
export { Organization } from './organization.entity';
|
||||||
export { ClassroomRental } from './classroom-rental.entity';
|
export { ClassroomRental, ClassroomRentalStatus } from './classroom-rental.entity';
|
||||||
export { Permission } from './permission.entity';
|
export { Permission } from './permission.entity';
|
||||||
export { Role } from './role.entity';
|
export { Role } from './role.entity';
|
||||||
export { Class, ClassType, ClassStatus } from './class.entity';
|
export { Class, ClassType, ClassStatus } from './class.entity';
|
||||||
|
|||||||
@@ -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 { Occupancy } from './occupancy.entity';
|
||||||
import { RoomExpense } from './room-expense.entity';
|
import { RoomExpense } from './room-expense.entity';
|
||||||
|
|
||||||
@@ -26,7 +34,7 @@ export class Room {
|
|||||||
roomType: string;
|
roomType: string;
|
||||||
|
|
||||||
@Column({ length: 10, nullable: true })
|
@Column({ length: 10, nullable: true })
|
||||||
gender: string;
|
gender: '男' | '女' | null;
|
||||||
|
|
||||||
@Column({ name: 'rental_category', length: 10, default: 'short' })
|
@Column({ name: 'rental_category', length: 10, default: 'short' })
|
||||||
rentalCategory: string;
|
rentalCategory: string;
|
||||||
@@ -42,5 +50,4 @@ export class Room {
|
|||||||
|
|
||||||
@OneToMany(() => RoomExpense, (e) => e.room)
|
@OneToMany(() => RoomExpense, (e) => e.room)
|
||||||
roomExpenses: RoomExpense[];
|
roomExpenses: RoomExpense[];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
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,27 +1,71 @@
|
|||||||
/** 钉钉配置 */
|
import { Type } from 'class-transformer';
|
||||||
export interface DingTalkThirdConfig {
|
import {
|
||||||
agentId: string; // AppKey
|
IsDefined,
|
||||||
appSecret?: string; // AppSecret;更新已有配置时可留空保留旧值
|
IsEnum,
|
||||||
corpId: string; // CorpId
|
IsNotEmpty,
|
||||||
appId?: string; // 内部应用ID,用于消息推送(可选)
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export enum IntegrationType {
|
||||||
|
WECOM = 'WECOM',
|
||||||
|
DINGTALK = 'DINGTALK',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 企微配置 */
|
export class DingTalkThirdConfigDto {
|
||||||
export interface WeComThirdConfig {
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
appSecret?: string;
|
appSecret?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
corpId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
appId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WeComThirdConfigDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
agentId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
appSecret?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
corpId: string;
|
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) */
|
/** 对外返回的配置(脱敏后,不含 appSecret) */
|
||||||
export interface ThirdConfigBaseDTO<T = unknown> {
|
export interface ThirdConfigBaseDTO<T = unknown> {
|
||||||
type: string;
|
type: string;
|
||||||
verify?: boolean;
|
verify?: boolean;
|
||||||
config: T;
|
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 { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||||
import { RequirePermission } from '../../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../../auth/decorators/permission.decorator';
|
||||||
import { IntegrationConfigService } from './integration-config.service';
|
import { IntegrationConfigService } from './integration-config.service';
|
||||||
import type { SaveConfigRequest } from './dto/config.dto';
|
import { SaveIntegrationConfigDto, TestIntegrationConfigDto } from './dto/config.dto';
|
||||||
|
|
||||||
@Controller('integration/config')
|
@Controller('integration/config')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -31,7 +31,7 @@ export class IntegrationConfigController {
|
|||||||
/** 保存配置 */
|
/** 保存配置 */
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission('integration:trigger')
|
@RequirePermission('integration:trigger')
|
||||||
async saveConfig(@Body() body: SaveConfigRequest) {
|
async saveConfig(@Body() body: SaveIntegrationConfigDto) {
|
||||||
await this.service.saveConfig(body);
|
await this.service.saveConfig(body);
|
||||||
return { success: true, message: '配置已保存' };
|
return { success: true, message: '配置已保存' };
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ export class IntegrationConfigController {
|
|||||||
/** 测试连接 */
|
/** 测试连接 */
|
||||||
@Post('test')
|
@Post('test')
|
||||||
@RequirePermission('integration:read')
|
@RequirePermission('integration:read')
|
||||||
async testConnection(@Body() body: SaveConfigRequest) {
|
async testConnection(@Body() body: TestIntegrationConfigDto) {
|
||||||
const success = await this.service.testConnection(body.type, body.config);
|
const success = await this.service.testConnection(body.type, body.config);
|
||||||
return { success, message: success ? '连接成功' : '连接失败,请检查配置信息' };
|
return { success, message: success ? '连接成功' : '连接失败,请检查配置信息' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import {
|
import { IntegrationConfig, IntegrationConfigDetail } from '../entities/integration-config.entity';
|
||||||
IntegrationConfig,
|
|
||||||
IntegrationConfigDetail,
|
|
||||||
} from '../entities/integration-config.entity';
|
|
||||||
import {
|
import {
|
||||||
ThirdConfigBaseDTO,
|
ThirdConfigBaseDTO,
|
||||||
DingTalkThirdConfig,
|
DingTalkThirdConfigDto,
|
||||||
WeComThirdConfig,
|
WeComThirdConfigDto,
|
||||||
SaveConfigRequest,
|
IntegrationType,
|
||||||
|
SaveIntegrationConfigDto,
|
||||||
} from './dto/config.dto';
|
} from './dto/config.dto';
|
||||||
|
|
||||||
@Injectable()
|
@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 config = await this.ensureConfig();
|
||||||
const detailType = this.getDetailType(request.type);
|
const detailType = this.getDetailType(request.type);
|
||||||
|
|
||||||
@@ -121,8 +119,8 @@ export class IntegrationConfigService {
|
|||||||
|
|
||||||
/** 测试连接 */
|
/** 测试连接 */
|
||||||
async testConnection(
|
async testConnection(
|
||||||
type: string,
|
type: IntegrationType,
|
||||||
config: DingTalkThirdConfig | WeComThirdConfig,
|
config: DingTalkThirdConfigDto | WeComThirdConfigDto,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const finalConfig = { ...config } as Record<string, unknown>;
|
const finalConfig = { ...config } as Record<string, unknown>;
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export class OccupanciesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 首位入住者确定房间性别
|
// 首位入住者确定房间性别
|
||||||
if (student.gender && !room.gender) {
|
if ((student.gender === '男' || student.gender === '女') && !room.gender) {
|
||||||
await this.roomRepo.update(room.id, { gender: student.gender });
|
await this.roomRepo.update(room.id, { gender: student.gender });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,14 +140,6 @@ export class OccupanciesService {
|
|||||||
// 更新宿舍状态
|
// 更新宿舍状态
|
||||||
await this.roomRepo.update(occ.roomId, { status: 'available' });
|
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;
|
return occ;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,14 +165,6 @@ export class OccupanciesService {
|
|||||||
await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' });
|
await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' });
|
||||||
}
|
}
|
||||||
await runner.manager.update(Room, oldOcc.roomId, { 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 } });
|
const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } });
|
||||||
if (!newRoom) throw new NotFoundException('目标宿舍不存在');
|
if (!newRoom) throw new NotFoundException('目标宿舍不存在');
|
||||||
@@ -240,7 +224,7 @@ export class OccupanciesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 首位入住者确定新房性别
|
// 首位入住者确定新房性别
|
||||||
if (student?.gender && !newRoom.gender) {
|
if ((student?.gender === '男' || student?.gender === '女') && !newRoom.gender) {
|
||||||
await runner.manager.update(Room, newRoom.id, { gender: student.gender });
|
await runner.manager.update(Room, newRoom.id, { gender: student.gender });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,13 +323,6 @@ export class OccupanciesService {
|
|||||||
await runner.manager.save(occ);
|
await runner.manager.save(occ);
|
||||||
// 更新房间状态
|
// 更新房间状态
|
||||||
await runner.manager.update(Room, occ.roomId, { status: 'available' });
|
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.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' });
|
||||||
if (occ.lockerId)
|
if (occ.lockerId)
|
||||||
@@ -521,7 +498,11 @@ export class OccupanciesService {
|
|||||||
await this.repo.save(this.repo.create(occData));
|
await this.repo.save(this.repo.create(occData));
|
||||||
|
|
||||||
// 7. 首位入住者确定房间性别
|
// 7. 首位入住者确定房间性别
|
||||||
if (student.gender && !room.gender) {
|
if (
|
||||||
|
!row.checkOutDate?.trim() &&
|
||||||
|
(student.gender === '男' || student.gender === '女') &&
|
||||||
|
!room.gender
|
||||||
|
) {
|
||||||
await this.roomRepo.update(room.id, { gender: student.gender });
|
await this.roomRepo.update(room.id, { gender: student.gender });
|
||||||
room.gender = student.gender;
|
room.gender = student.gender;
|
||||||
}
|
}
|
||||||
|
|||||||
21
apps/server/src/rooms/dto/room.dto.spec.ts
Normal file
21
apps/server/src/rooms/dto/room.dto.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { CreateRoomDto, UpdateRoomDto } from './room.dto';
|
||||||
|
|
||||||
|
const pipe = new ValidationPipe({ transform: true, whitelist: true });
|
||||||
|
const transform = <T extends object>(metatype: new () => T, value: unknown) =>
|
||||||
|
pipe.transform(value, { type: 'body', metatype });
|
||||||
|
|
||||||
|
describe('room gender DTO', () => {
|
||||||
|
it.each(['男', '女', null])('accepts %p', async (gender) => {
|
||||||
|
await expect(
|
||||||
|
transform(CreateRoomDto, { roomNumber: '1-101', capacity: 4, gender }),
|
||||||
|
).resolves.toMatchObject({ gender });
|
||||||
|
await expect(transform(UpdateRoomDto, { gender })).resolves.toMatchObject({ gender });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['不限', 'male', '女生宿舍'])('rejects unsupported value %p', async (gender) => {
|
||||||
|
await expect(
|
||||||
|
transform(CreateRoomDto, { roomNumber: '1-101', capacity: 4, gender }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsOptional, IsInt, IsEnum, Min, IsNumber } from 'class-validator';
|
import { IsString, IsOptional, IsInt, IsEnum, IsIn, Min, IsNumber } from 'class-validator';
|
||||||
|
|
||||||
export class CreateRoomDto {
|
export class CreateRoomDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -20,6 +20,10 @@ export class CreateRoomDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
roomType?: string;
|
roomType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['男', '女'])
|
||||||
|
gender?: '男' | '女' | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
rentalCategory?: string;
|
rentalCategory?: string;
|
||||||
@@ -27,7 +31,6 @@ export class CreateRoomDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
monthlyRate?: number;
|
monthlyRate?: number;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateRoomDto {
|
export class UpdateRoomDto {
|
||||||
@@ -53,8 +56,8 @@ export class UpdateRoomDto {
|
|||||||
roomType?: string;
|
roomType?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn(['男', '女'])
|
||||||
gender?: string;
|
gender?: '男' | '女' | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(['available', 'full', 'maintenance'])
|
@IsEnum(['available', 'full', 'maintenance'])
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export class RoomsController {
|
|||||||
{ header: '宿舍类型', key: 'roomType', width: 12 },
|
{ header: '宿舍类型', key: 'roomType', width: 12 },
|
||||||
{ header: '租赁类型(long/short)', key: 'rentalCategory', width: 18 },
|
{ header: '租赁类型(long/short)', key: 'rentalCategory', width: 18 },
|
||||||
{ header: '月租金', key: 'monthlyRate', width: 10 },
|
{ header: '月租金', key: 'monthlyRate', width: 10 },
|
||||||
|
{ header: '宿舍性别(男/女)', key: 'gender', width: 18 },
|
||||||
];
|
];
|
||||||
ws.addRow({
|
ws.addRow({
|
||||||
roomNumber: '4-102',
|
roomNumber: '4-102',
|
||||||
@@ -76,6 +77,7 @@ export class RoomsController {
|
|||||||
roomType: '四人间',
|
roomType: '四人间',
|
||||||
rentalCategory: 'long',
|
rentalCategory: 'long',
|
||||||
monthlyRate: 800,
|
monthlyRate: 800,
|
||||||
|
gender: '男',
|
||||||
});
|
});
|
||||||
ws.addRow({
|
ws.addRow({
|
||||||
roomNumber: '2-201',
|
roomNumber: '2-201',
|
||||||
@@ -85,6 +87,7 @@ export class RoomsController {
|
|||||||
roomType: '单人间',
|
roomType: '单人间',
|
||||||
rentalCategory: 'short',
|
rentalCategory: 'short',
|
||||||
monthlyRate: 0,
|
monthlyRate: 0,
|
||||||
|
gender: '女',
|
||||||
});
|
});
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Type',
|
'Content-Type',
|
||||||
@@ -168,11 +171,7 @@ export class RoomsController {
|
|||||||
|
|
||||||
@Put(':roomId/beds/:id')
|
@Put(':roomId/beds/:id')
|
||||||
@RequirePermission('room:edit')
|
@RequirePermission('room:edit')
|
||||||
updateBed(
|
updateBed(@Param('roomId') roomId: string, @Param('id') id: string, @Body() dto: UpdateBedDto) {
|
||||||
@Param('roomId') roomId: string,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() dto: UpdateBedDto,
|
|
||||||
) {
|
|
||||||
return this.service.updateBed(+roomId, +id, dto);
|
return this.service.updateBed(+roomId, +id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,16 +339,26 @@ export class RoomsController {
|
|||||||
roomType?: string;
|
roomType?: string;
|
||||||
rentalCategory?: string;
|
rentalCategory?: string;
|
||||||
monthlyRate?: number;
|
monthlyRate?: number;
|
||||||
|
gender?: '男' | '女';
|
||||||
}[] = [];
|
}[] = [];
|
||||||
ws.eachRow((row, idx) => {
|
ws.eachRow((row, idx) => {
|
||||||
if (idx === 1) return;
|
if (idx === 1) return;
|
||||||
const rentalCategoryRaw = String(row.getCell(6).value || '').trim().toLowerCase();
|
const rentalCategoryRaw = String(row.getCell(6).value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
const rentalCategory =
|
const rentalCategory =
|
||||||
rentalCategoryRaw === 'long' || rentalCategoryRaw === 'short'
|
rentalCategoryRaw === 'long' || rentalCategoryRaw === 'short'
|
||||||
? rentalCategoryRaw
|
? rentalCategoryRaw
|
||||||
: undefined;
|
: undefined;
|
||||||
const monthlyRateRaw = Number(row.getCell(7).value);
|
const monthlyRateRaw = Number(row.getCell(7).value);
|
||||||
const monthlyRate = isNaN(monthlyRateRaw) ? undefined : monthlyRateRaw;
|
const monthlyRate = isNaN(monthlyRateRaw) ? undefined : monthlyRateRaw;
|
||||||
|
const genderRaw = String(row.getCell(8).value || '').trim();
|
||||||
|
const gender =
|
||||||
|
genderRaw === '男' || genderRaw === '男生'
|
||||||
|
? '男'
|
||||||
|
: genderRaw === '女' || genderRaw === '女生'
|
||||||
|
? '女'
|
||||||
|
: undefined;
|
||||||
rows.push({
|
rows.push({
|
||||||
roomNumber: String(row.getCell(1).value || ''),
|
roomNumber: String(row.getCell(1).value || ''),
|
||||||
building: String(row.getCell(2).value || '') || undefined,
|
building: String(row.getCell(2).value || '') || undefined,
|
||||||
@@ -358,6 +367,7 @@ export class RoomsController {
|
|||||||
roomType: String(row.getCell(5).value || '').trim() || undefined,
|
roomType: String(row.getCell(5).value || '').trim() || undefined,
|
||||||
rentalCategory,
|
rentalCategory,
|
||||||
monthlyRate,
|
monthlyRate,
|
||||||
|
gender,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const result = await this.service.batchImport(rows);
|
const result = await this.service.batchImport(rows);
|
||||||
|
|||||||
43
apps/server/src/rooms/rooms.gender.spec.ts
Normal file
43
apps/server/src/rooms/rooms.gender.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { RoomsService } from './rooms.service';
|
||||||
|
|
||||||
|
function createService(room: { id: number; gender: '男' | '女' | null }, activeCount: number) {
|
||||||
|
const repo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(room),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
const occRepo = { count: jest.fn().mockResolvedValue(activeCount) };
|
||||||
|
const service = new RoomsService(
|
||||||
|
repo as never,
|
||||||
|
occRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
return { service, repo, occRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RoomsService — room gender maintenance', () => {
|
||||||
|
it('allows an empty room gender to be changed or cleared', async () => {
|
||||||
|
const { service, repo } = createService({ id: 1, gender: '男' }, 0);
|
||||||
|
|
||||||
|
await service.update(1, { gender: null });
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith(1, { gender: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects changing gender while students are living in the room', async () => {
|
||||||
|
const { service, repo } = createService({ id: 1, gender: '男' }, 1);
|
||||||
|
|
||||||
|
await expect(service.update(1, { gender: '女' })).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not query occupants when gender is unchanged or omitted', async () => {
|
||||||
|
const { service, occRepo } = createService({ id: 1, gender: '男' }, 1);
|
||||||
|
|
||||||
|
await service.update(1, { roomType: '四人间' });
|
||||||
|
|
||||||
|
expect(occRepo.count).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -114,7 +114,13 @@ export class RoomsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, dto: UpdateRoomDto) {
|
async update(id: number, dto: UpdateRoomDto) {
|
||||||
await this.findOne(id);
|
const room = await this.findOne(id);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(dto, 'gender') && dto.gender !== room.gender) {
|
||||||
|
const activeCount = await this.occRepo.count({
|
||||||
|
where: { roomId: id, checkOutDate: IsNull() },
|
||||||
|
});
|
||||||
|
if (activeCount > 0) throw new BadRequestException('该宿舍有在住人员,无法修改宿舍性别');
|
||||||
|
}
|
||||||
await this.repo.update(id, dto);
|
await this.repo.update(id, dto);
|
||||||
return this.repo.findOne({ where: { id } });
|
return this.repo.findOne({ where: { id } });
|
||||||
}
|
}
|
||||||
@@ -202,10 +208,7 @@ export class RoomsService {
|
|||||||
for (const occ of occupancies) {
|
for (const occ of occupancies) {
|
||||||
if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []);
|
if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []);
|
||||||
const checkIn = new Date(occ.checkInDate);
|
const checkIn = new Date(occ.checkInDate);
|
||||||
const days = Math.max(
|
const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)));
|
||||||
1,
|
|
||||||
Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)),
|
|
||||||
);
|
|
||||||
occMap.get(occ.roomId)!.push({
|
occMap.get(occ.roomId)!.push({
|
||||||
studentId: occ.studentId,
|
studentId: occ.studentId,
|
||||||
studentName: occ.student?.name || '未知',
|
studentName: occ.student?.name || '未知',
|
||||||
@@ -250,8 +253,11 @@ export class RoomsService {
|
|||||||
const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]);
|
const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]);
|
||||||
orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`;
|
orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`;
|
||||||
}
|
}
|
||||||
const organizationColors = [...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean))];
|
const organizationColors = [
|
||||||
const organizationColor: string | null = organizationColors.length === 1 ? organizationColors[0] : null;
|
...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))];
|
const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))];
|
||||||
return {
|
return {
|
||||||
id: room.id,
|
id: room.id,
|
||||||
@@ -274,7 +280,14 @@ export class RoomsService {
|
|||||||
...new Map(
|
...new Map(
|
||||||
occupancies
|
occupancies
|
||||||
.filter((o) => o.responsibleOrganizationId && o.responsibleOrganization)
|
.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(),
|
).values(),
|
||||||
].sort((a, b) => a.name.localeCompare(b.name)),
|
].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
};
|
};
|
||||||
@@ -289,6 +302,7 @@ export class RoomsService {
|
|||||||
roomType?: string;
|
roomType?: string;
|
||||||
rentalCategory?: string;
|
rentalCategory?: string;
|
||||||
monthlyRate?: number;
|
monthlyRate?: number;
|
||||||
|
gender?: '男' | '女';
|
||||||
}[],
|
}[],
|
||||||
) {
|
) {
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
@@ -314,6 +328,7 @@ export class RoomsService {
|
|||||||
roomType: row.roomType || parsed.roomType || undefined,
|
roomType: row.roomType || parsed.roomType || undefined,
|
||||||
rentalCategory: row.rentalCategory || undefined,
|
rentalCategory: row.rentalCategory || undefined,
|
||||||
monthlyRate: row.monthlyRate ?? undefined,
|
monthlyRate: row.monthlyRate ?? undefined,
|
||||||
|
gender: row.gender ?? undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
imported++;
|
imported++;
|
||||||
@@ -336,7 +351,10 @@ export class RoomsService {
|
|||||||
async getRoomAvailableBeds(roomId: number): Promise<Bed[]> {
|
async getRoomAvailableBeds(roomId: number): Promise<Bed[]> {
|
||||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||||
if (!room) throw new NotFoundException('宿舍不存在');
|
if (!room) throw new NotFoundException('宿舍不存在');
|
||||||
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> {
|
async createBed(roomId: number, dto: CreateBedDto): Promise<Bed> {
|
||||||
@@ -377,7 +395,7 @@ export class RoomsService {
|
|||||||
if (!room) throw new NotFoundException('宿舍不存在');
|
if (!room) throw new NotFoundException('宿舍不存在');
|
||||||
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
|
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
|
||||||
const existing = await this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } });
|
const existing = await this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } });
|
||||||
const numbers = existing.map(b => {
|
const numbers = existing.map((b) => {
|
||||||
const match = b.bedNumber.match(/^\d+/);
|
const match = b.bedNumber.match(/^\d+/);
|
||||||
return match ? parseInt(match[0]) : 0;
|
return match ? parseInt(match[0]) : 0;
|
||||||
});
|
});
|
||||||
@@ -400,14 +418,19 @@ export class RoomsService {
|
|||||||
async getRoomAvailableLockers(roomId: number): Promise<Locker[]> {
|
async getRoomAvailableLockers(roomId: number): Promise<Locker[]> {
|
||||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||||
if (!room) throw new NotFoundException('宿舍不存在');
|
if (!room) throw new NotFoundException('宿舍不存在');
|
||||||
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> {
|
async createLocker(roomId: number, dto: CreateLockerDto): Promise<Locker> {
|
||||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||||
if (!room) throw new NotFoundException('宿舍不存在');
|
if (!room) throw new NotFoundException('宿舍不存在');
|
||||||
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
|
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
|
||||||
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('该柜子编号已存在');
|
if (existing) throw new BadRequestException('该柜子编号已存在');
|
||||||
const locker = this.lockerRepo.create({ ...dto, roomId });
|
const locker = this.lockerRepo.create({ ...dto, roomId });
|
||||||
return this.lockerRepo.save(locker);
|
return this.lockerRepo.save(locker);
|
||||||
@@ -420,7 +443,9 @@ export class RoomsService {
|
|||||||
throw new BadRequestException('该柜子有人占用,请先释放');
|
throw new BadRequestException('该柜子有人占用,请先释放');
|
||||||
}
|
}
|
||||||
if (dto.lockerNumber && dto.lockerNumber !== locker.lockerNumber) {
|
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('该柜子编号已存在');
|
if (dup) throw new BadRequestException('该柜子编号已存在');
|
||||||
}
|
}
|
||||||
Object.assign(locker, dto);
|
Object.assign(locker, dto);
|
||||||
@@ -438,8 +463,11 @@ export class RoomsService {
|
|||||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||||
if (!room) throw new NotFoundException('宿舍不存在');
|
if (!room) throw new NotFoundException('宿舍不存在');
|
||||||
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
|
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
|
||||||
const existing = await this.lockerRepo.find({ where: { roomId }, order: { lockerNumber: 'ASC' } });
|
const existing = await this.lockerRepo.find({
|
||||||
const numbers = existing.map(b => {
|
where: { roomId },
|
||||||
|
order: { lockerNumber: 'ASC' },
|
||||||
|
});
|
||||||
|
const numbers = existing.map((b) => {
|
||||||
const match = b.lockerNumber.match(/^\d+/);
|
const match = b.lockerNumber.match(/^\d+/);
|
||||||
return match ? parseInt(match[0]) : 0;
|
return match ? parseInt(match[0]) : 0;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ClassSchedule,
|
ClassSchedule,
|
||||||
Class,
|
Class,
|
||||||
Classroom,
|
Classroom,
|
||||||
|
ClassroomStatus,
|
||||||
ClassroomRental,
|
ClassroomRental,
|
||||||
ClassTeacher,
|
ClassTeacher,
|
||||||
AttendanceSession,
|
AttendanceSession,
|
||||||
@@ -83,7 +84,7 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const classrooms = await this.classroomRepo.find({
|
const classrooms = await this.classroomRepo.find({
|
||||||
where: { status: Not('archived') },
|
where: { status: ClassroomStatus.AVAILABLE },
|
||||||
select: ['id', 'name', 'building', 'floor', 'roomType'],
|
select: ['id', 'name', 'building', 'floor', 'roomType'],
|
||||||
order: { building: 'ASC', name: 'ASC' },
|
order: { building: 'ASC', name: 'ASC' },
|
||||||
});
|
});
|
||||||
@@ -159,7 +160,16 @@ export class SchedulesService {
|
|||||||
return schedule;
|
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) {
|
async create(dto: CreateScheduleDto) {
|
||||||
|
await this.assertClassroomAvailable(dto.classroomId);
|
||||||
await this.normalizeTeacherForSchedule(dto);
|
await this.normalizeTeacherForSchedule(dto);
|
||||||
await this.assertTeacherAssignedToClass(dto.classId, dto.teacherId);
|
await this.assertTeacherAssignedToClass(dto.classId, dto.teacherId);
|
||||||
await this.checkConflict(
|
await this.checkConflict(
|
||||||
@@ -182,6 +192,9 @@ export class SchedulesService {
|
|||||||
|
|
||||||
// If classroom, weekDay, or times are changing, check conflicts excluding self
|
// If classroom, weekDay, or times are changing, check conflicts excluding self
|
||||||
const classroomId = dto.classroomId ?? existing.classroomId;
|
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 weekDay = dto.weekDay ?? existing.weekDay;
|
||||||
const startTime = dto.startTime ?? existing.startTime;
|
const startTime = dto.startTime ?? existing.startTime;
|
||||||
const endTime = dto.endTime ?? existing.endTime;
|
const endTime = dto.endTime ?? existing.endTime;
|
||||||
@@ -253,7 +266,7 @@ export class SchedulesService {
|
|||||||
const rentalConflicts = await this.rentalRepo
|
const rentalConflicts = await this.rentalRepo
|
||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.where('r.classroomId = :classroomId', { classroomId })
|
.where('r.classroomId = :classroomId', { classroomId })
|
||||||
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
|
.andWhere('r.status = :activeRental', { activeRental: 'active' })
|
||||||
.andWhere('r.startDate <= :endDate', { endDate })
|
.andWhere('r.startDate <= :endDate', { endDate })
|
||||||
.andWhere('r.endDate >= :startDate', { startDate })
|
.andWhere('r.endDate >= :startDate', { startDate })
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|||||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -62,6 +62,7 @@
|
|||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/event-emitter": "^3.1.0",
|
"@nestjs/event-emitter": "^3.1.0",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/mapped-types": "^2.1.1",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.1.19",
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
@@ -3101,6 +3102,26 @@
|
|||||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
|
"@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": {
|
"node_modules/@nestjs/passport": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@nestjs/passport/-/passport-11.0.5.tgz",
|
"resolved": "https://registry.npmmirror.com/@nestjs/passport/-/passport-11.0.5.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user