Merge pull request 'fix: 归档删除改为软删除' (#19)

fix: 归档删除改为软删除
This commit is contained in:
2026-07-17 06:18:38 +00:00
49 changed files with 520 additions and 354 deletions

View File

@@ -25,7 +25,7 @@ import type { ColumnsType } from 'antd/es/table';
import {
PlusOutlined,
UploadOutlined,
DeleteOutlined,
InboxOutlined,
EyeOutlined,
CloseOutlined,
FileTextOutlined,
@@ -747,11 +747,11 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ dat
const handleDelete = async (attachmentId: number) => {
try {
await api.delete(`/archive/attachments/${attachmentId}`);
message.success('已删除');
message.success('已归档');
onRefresh();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '删除失败');
message.error(err?.message || '归档失败');
}
};
@@ -786,9 +786,9 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ dat
>
</Button>
<Popconfirm title="确定删除该附件?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />}>
<Popconfirm title="确定归档该附件?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<InboxOutlined />}>
</Button>
</Popconfirm>
</Space>

View File

@@ -22,7 +22,6 @@ import {
CheckCircleOutlined,
CloseCircleOutlined,
KeyOutlined,
DeleteOutlined,
WarningOutlined,
} from '@ant-design/icons';
import api from '../../api';
@@ -418,7 +417,7 @@ const AiConfigPage: React.FC = () => {
{config?.hasDatabaseKey && canWrite && (
<div style={{ marginBottom: 8 }}>
<Button danger size="small" icon={<DeleteOutlined />} onClick={handleClearKey}>
<Button danger size="small" onClick={handleClearKey}>
</Button>
</div>

View File

@@ -122,10 +122,10 @@ const AttendanceDevicesPage: React.FC = () => {
const handleDelete = async (id: number) => {
try {
await api.delete(`/attendance-devices/${id}`);
message.success('已删除绑定');
message.success('已停用绑定');
await loadData();
} catch (error: any) {
message.error(error?.message || '删除失败');
message.error(error?.message || '停用失败');
}
};
@@ -144,9 +144,9 @@ const AttendanceDevicesPage: React.FC = () => {
<PermissionButton permission="classroom:edit" size="small" type="link" onClick={() => openEdit(record)}>
</PermissionButton>
<Popconfirm title="确定删除此考勤机绑定?" onConfirm={() => handleDelete(record.id)}>
<Popconfirm title="确定停用此考勤机绑定?" onConfirm={() => handleDelete(record.id)}>
<PermissionButton permission="classroom:edit" size="small" danger>
</PermissionButton>
</Popconfirm>
</Space>

View File

@@ -15,7 +15,7 @@ import {
} from 'antd';
import {
FileTextOutlined,
DeleteOutlined,
InboxOutlined,
DownloadOutlined,
FilePdfOutlined,
} from '@ant-design/icons';
@@ -135,21 +135,21 @@ const BillsPage: React.FC = () => {
});
};
const handleDelete = async (id: number) => {
const handleArchive = async (id: number) => {
try {
await api.delete(`/bills/${id}`);
message.success('删除成功');
message.success('账单已归档');
fetchData();
} catch (error: any) { message.error(error?.message || '删除失败'); }
} catch (error: any) { message.error(error?.message || '归档失败'); }
};
const batchDelete = async () => {
const batchArchive = async () => {
if (selectedRows.length === 0) return message.warning('请先选择账单');
if (batchLoading) return;
setBatchLoading(true);
try {
await api.post('/bills/batch/delete', { ids: selectedRows });
message.success(`删除 ${selectedRows.length} 条账单`);
message.success(`归档 ${selectedRows.length} 条账单`);
setSelectedRows([]);
fetchData();
} catch (e: any) {
@@ -260,14 +260,14 @@ const BillsPage: React.FC = () => {
</PermissionButton>
)}
{Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && (
<Popconfirm title="确定删除此未支付账单?" onConfirm={() => handleDelete(record.id)} okText="删除" cancelText="取消">
<PermissionButton permission="bill:delete" size="small" danger icon={<DeleteOutlined />}></PermissionButton>
<Popconfirm title="确定归档此未支付账单?" onConfirm={() => handleArchive(record.id)} okText="归档" cancelText="取消">
<PermissionButton permission="bill:delete" size="small" danger icon={<InboxOutlined />}></PermissionButton>
</Popconfirm>
)}
</Space>
),
},
], [showDetail, handleDelete, handleCancel, handleExportPdf]);
], [showDetail, handleArchive, handleCancel, handleExportPdf]);
return (
<div>
@@ -298,9 +298,9 @@ const BillsPage: React.FC = () => {
<Select placeholder="费用类型" allowClear style={{ width: 120 }} value={filterExpenseType} onChange={setFilterExpenseType}
options={[{value:'water',label:'水费'},{value:'electricity',label:'电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} />
<Popconfirm
title={`确定删除选中的 ${selectedRows.length} 条账单?`}
onConfirm={batchDelete}
okText="删除"
title={`确定归档选中的 ${selectedRows.length} 条账单?`}
onConfirm={batchArchive}
okText="归档"
cancelText="取消"
disabled={selectedRows.length === 0}
>
@@ -308,9 +308,9 @@ const BillsPage: React.FC = () => {
permission="bill:delete"
danger
disabled={selectedRows.length === 0}
icon={<DeleteOutlined />}
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>

View File

@@ -15,7 +15,7 @@ import {
Tooltip,
Empty,
} from 'antd';
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined, StopOutlined, CheckOutlined } from '@ant-design/icons';
import { PlusOutlined, UploadOutlined, FileTextOutlined, StopOutlined, CheckOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
@@ -211,10 +211,10 @@ const ClassroomRentalsPage: React.FC = () => {
const handleDelete = async (id: number) => {
try {
await api.delete(`/classroom-rentals/${id}`);
message.success('已删除');
message.success('已归档');
fetchData();
} catch (e: any) {
message.error(e?.message || '删除失败');
message.error(e?.message || '归档失败');
}
};
@@ -239,10 +239,10 @@ const ClassroomRentalsPage: React.FC = () => {
const handleDeleteContract = async (id: number) => {
try {
await api.delete(`/classroom-rentals/${id}/contract`);
message.success('合同已除');
message.success('合同已除');
fetchData();
} catch (e: any) {
message.error(e?.message || '除失败');
message.error(e?.message || '除失败');
}
};
@@ -350,8 +350,8 @@ const ClassroomRentalsPage: React.FC = () => {
</Button>
</Tooltip>
<Popconfirm title="除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} aria-label="除合同文件" />
<Popconfirm title="除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
<Button size="small" danger icon={<StopOutlined />} aria-label="除合同文件" />
</Popconfirm>
</Space>
) : (
@@ -410,8 +410,8 @@ const ClassroomRentalsPage: React.FC = () => {
</>
)}
{record.effectiveStatus !== 'active' && (
<Popconfirm title="确定删除该租赁订单?合同文件将一并删除。" onConfirm={() => handleDelete(record.id)}>
<PermissionButton permission="rental:delete" size="small" danger></PermissionButton>
<Popconfirm title="确定归档该租赁订单?合同文件会保留。" onConfirm={() => handleDelete(record.id)}>
<PermissionButton permission="rental:delete" size="small" danger></PermissionButton>
</Popconfirm>
)}
</Space>

View File

@@ -14,7 +14,7 @@ import {
Card,
Empty,
} from 'antd';
import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
import { PlusOutlined, InboxOutlined, DollarOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
@@ -168,7 +168,7 @@ const DepositsPage: React.FC = () => {
const handleDeleteInstallment = async (installmentId: number) => {
try {
await api.delete(`/deposits/installments/${installmentId}`);
message.success('分期已删除');
message.success('分期已归档');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
@@ -216,14 +216,14 @@ const DepositsPage: React.FC = () => {
</>
)}
<Popconfirm
title="确定删除"
title="确定归档"
onConfirm={async () => {
try {
await api.delete(`/deposits/${record.id}`);
message.success('删除成功');
message.success('归档成功');
fetchData();
} catch (e: any) {
message.error(e?.message || '删除失败');
message.error(e?.message || '归档失败');
}
}}
>
@@ -231,9 +231,9 @@ const DepositsPage: React.FC = () => {
permission="deposit:delete"
size="small"
danger
icon={<DeleteOutlined />}
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
@@ -418,11 +418,11 @@ const DepositsPage: React.FC = () => {
</PermissionButton>
),
<Popconfirm
title="确定删除"
title="确定归档"
onConfirm={() => handleDeleteInstallment(item.id)}
>
<PermissionButton key="del" permission="deposit:delete" size="small" danger icon={<DeleteOutlined />}>
<PermissionButton key="del" permission="deposit:delete" size="small" danger icon={<InboxOutlined />}>
</PermissionButton>
</Popconfirm>
].filter(Boolean)}

View File

@@ -17,7 +17,7 @@ import {
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
InboxOutlined,
EditOutlined,
UploadOutlined,
DownloadOutlined,
@@ -91,11 +91,11 @@ const ExpensesPage: React.FC = () => {
setBatchLoading(true);
try {
const res: any = await api.post('/expenses/room/batch-delete', { ids: selectedRoomKeys });
message.success(res?.message || `删除 ${selectedRoomKeys.length}`);
message.success(res?.message || `归档 ${selectedRoomKeys.length}`);
setSelectedRoomKeys([]);
fetchData();
} catch (e: any) {
message.error(e?.message || '批量删除失败');
message.error(e?.message || '批量归档失败');
} finally {
setBatchLoading(false);
}
@@ -108,11 +108,11 @@ const ExpensesPage: React.FC = () => {
const res: any = await api.post('/expenses/personal/batch-delete', {
ids: selectedPersonalKeys,
});
message.success(res?.message || `删除 ${selectedPersonalKeys.length}`);
message.success(res?.message || `归档 ${selectedPersonalKeys.length}`);
setSelectedPersonalKeys([]);
fetchData();
} catch (e: any) {
message.error(e?.message || '批量删除失败');
message.error(e?.message || '批量归档失败');
} finally {
setBatchLoading(false);
}
@@ -286,10 +286,10 @@ const ExpensesPage: React.FC = () => {
</PermissionButton>
<Popconfirm
title="确定删除"
title="确定归档"
onConfirm={async () => {
await api.delete(`/expenses/room/${record.id}`);
message.success('删除成功');
message.success('归档成功');
fetchData();
}}
>
@@ -297,9 +297,9 @@ const ExpensesPage: React.FC = () => {
permission="expense:delete"
size="small"
danger
icon={<DeleteOutlined />}
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
@@ -342,10 +342,10 @@ const ExpensesPage: React.FC = () => {
</PermissionButton>
<Popconfirm
title="确定删除"
title="确定归档"
onConfirm={async () => {
await api.delete(`/expenses/personal/${record.id}`);
message.success('删除成功');
message.success('归档成功');
fetchData();
}}
>
@@ -353,9 +353,9 @@ const ExpensesPage: React.FC = () => {
permission="expense:delete"
size="small"
danger
icon={<DeleteOutlined />}
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
@@ -440,19 +440,19 @@ const ExpensesPage: React.FC = () => {
</Space>
<Space>
<Popconfirm
title={`确定删除选中的 ${selectedRoomKeys.length} 条费用?`}
title={`确定归档选中的 ${selectedRoomKeys.length} 条费用?`}
onConfirm={handleBatchDeleteRoom}
okText="删除"
okText="归档"
cancelText="取消"
disabled={selectedRoomKeys.length === 0}
>
<PermissionButton
permission="expense:delete"
danger
icon={<DeleteOutlined />}
icon={<InboxOutlined />}
disabled={selectedRoomKeys.length === 0}
>
</PermissionButton>
</Popconfirm>
<PermissionButton
@@ -568,19 +568,19 @@ const ExpensesPage: React.FC = () => {
</Space>
<Space>
<Popconfirm
title={`确定删除选中的 ${selectedPersonalKeys.length} 条个人费用?`}
title={`确定归档选中的 ${selectedPersonalKeys.length} 条个人费用?`}
onConfirm={handleBatchDeletePersonal}
okText="删除"
okText="归档"
cancelText="取消"
disabled={selectedPersonalKeys.length === 0}
>
<PermissionButton
permission="expense:delete"
danger
icon={<DeleteOutlined />}
icon={<InboxOutlined />}
disabled={selectedPersonalKeys.length === 0}
>
</PermissionButton>
</Popconfirm>
<PermissionButton

View File

@@ -7,7 +7,7 @@ import {
import {
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
SyncOutlined, BankOutlined, UserOutlined,
DeleteOutlined,
StopOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import type { TreeSelectProps } from 'antd/es/tree-select';
@@ -337,13 +337,13 @@ const IntegrationConfigPage: React.FC = () => {
setAttendanceGroups([]);
if (response.data.failed.length > 0) {
message.warning(
`删除 ${response.data.deleted.length} 个,失败 ${response.data.failed.length}`,
`清空 ${response.data.deleted.length} 个,失败 ${response.data.failed.length}`,
);
} else {
message.success(`删除钉钉全部 ${response.data.deleted.length} 个考勤组`);
message.success(`清空钉钉全部 ${response.data.deleted.length} 个考勤组`);
}
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '删除钉钉考勤组失败');
message.error(error instanceof Error ? error.message : '清空钉钉考勤组失败');
} finally {
setDeletingGroups(false);
}
@@ -380,11 +380,11 @@ const IntegrationConfigPage: React.FC = () => {
<PermissionButton
permission="sync:trigger"
danger
icon={<DeleteOutlined />}
icon={<StopOutlined />}
loading={loadingGroups}
onClick={openDeleteAllGroups}
>
</PermissionButton>
</Space>
@@ -484,9 +484,9 @@ const IntegrationConfigPage: React.FC = () => {
</Drawer>
)}
<Modal
title="确认删除钉钉全部考勤组"
title="确认清空钉钉全部考勤组"
open={deleteGroupsOpen}
okText="确认全部删除"
okText="确认全部清空"
okButtonProps={{ danger: true, disabled: attendanceGroups.length === 0 }}
cancelText="取消"
confirmLoading={deletingGroups}
@@ -496,8 +496,8 @@ const IntegrationConfigPage: React.FC = () => {
<Alert
type="error"
showIcon
message={`将永久删除钉钉上的 ${attendanceGroups.length} 个考勤组`}
description="本地班级和排课不会删除。删除后需在排课管理中重新同步,才能重建考勤组。"
message={`将永久清空钉钉上的 ${attendanceGroups.length} 个考勤组`}
description="本地班级和排课不会清空。清空后需在排课管理中重新同步,才能重建考勤组。"
style={{ marginBottom: 12 }}
/>
<List

View File

@@ -21,7 +21,7 @@ import {
PlusOutlined,
SwapOutlined,
LogoutOutlined,
DeleteOutlined,
InboxOutlined,
UploadOutlined,
DownloadOutlined,
ExportOutlined,
@@ -236,11 +236,11 @@ const OccupanciesPage: React.FC = () => {
setBatchLoading(true);
try {
const res: any = await api.post('/occupancies/batch-delete', { ids: selectedRowKeys });
message.success(res?.message || `删除 ${selectedRowKeys.length}`);
message.success(res?.message || `归档 ${selectedRowKeys.length}`);
setSelectedRowKeys([]);
fetchData();
} catch (e: any) {
message.error(e?.message || '批量删除失败');
message.error(e?.message || '批量归档失败');
} finally {
setBatchLoading(false);
}
@@ -297,19 +297,19 @@ const OccupanciesPage: React.FC = () => {
<Space>
<Tag>退宿</Tag>
<Popconfirm
title="确定删除此记录?"
title="确定归档此记录?"
onConfirm={async () => {
try {
await api.delete(`/occupancies/${record.id}`);
message.success('删除成功');
message.success('归档成功');
fetchData();
} catch (e: any) {
message.error(e?.message || '删除失败');
message.error(e?.message || '归档失败');
}
}}
>
<PermissionButton permission="occupancy:delete" size="small" danger icon={<DeleteOutlined />}>
<PermissionButton permission="occupancy:delete" size="small" danger icon={<InboxOutlined />}>
</PermissionButton>
</Popconfirm>
</Space>
@@ -320,7 +320,7 @@ const OccupanciesPage: React.FC = () => {
const rowSelection = useMemo(() => ({
selectedRowKeys,
onChange: (keys: any[]) => setSelectedRowKeys(keys),
// 「在住记录」Tab禁用已退宿防止误选用于批量退宿「全部记录」Tab均可选用于批量删除
// 「在住记录」Tab禁用已退宿防止误选用于批量退宿「全部记录」Tab均可选用于批量归档
getCheckboxProps: (record: any) => (showActive ? { disabled: !!record.checkOutDate } : {}),
}), [selectedRowKeys, showActive]);
@@ -479,20 +479,20 @@ const OccupanciesPage: React.FC = () => {
</PermissionButton>
) : (
<Popconfirm
title={`确定删除选中的 ${selectedRowKeys.length} 条入住记录?在住记录会自动跳过`}
title={`确定归档选中的 ${selectedRowKeys.length} 条入住记录?在住记录会自动跳过`}
onConfirm={handleBatchDelete}
okText="删除"
okText="归档"
cancelText="取消"
>
<PermissionButton
permission="occupancy:delete"
danger
size="small"
icon={<DeleteOutlined />}
icon={<InboxOutlined />}
style={{ marginLeft: 12 }}
loading={batchLoading}
>
</PermissionButton>
</Popconfirm>
)}

View File

@@ -11,7 +11,7 @@ import {
Checkbox,
Empty,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { PlusOutlined, EditOutlined, StopOutlined } from '@ant-design/icons';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
@@ -106,13 +106,13 @@ const RolesPage: React.FC = () => {
}
};
const handleDelete = async (id: number) => {
const handleDisable = async (id: number) => {
try {
await api.delete(`/rbac/roles/${id}`);
message.success('角色已删除');
message.success('角色已停用');
fetchData();
} catch (e: any) {
message.error(e.message || '删除失败');
message.error(e.message || '停用失败');
}
};
@@ -169,9 +169,9 @@ const RolesPage: React.FC = () => {
</PermissionButton>
{!record.isSystem && (
<Popconfirm title="确认删除该角色?" onConfirm={() => handleDelete(record.id)}>
<PermissionButton permission="role:delete" type="link" size="small" danger icon={<DeleteOutlined />}>
<Popconfirm title="确认停用该角色?" onConfirm={() => handleDisable(record.id)}>
<PermissionButton permission="role:delete" type="link" size="small" icon={<StopOutlined />}>
</PermissionButton>
</Popconfirm>
)}

View File

@@ -25,7 +25,6 @@ import {
InboxOutlined,
SearchOutlined,
ExportOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
@@ -229,9 +228,9 @@ const RoomsPage: React.FC = () => {
const handleDeleteBed = async (id: number) => {
try {
await api.delete(`/rooms/${drawerRoom.id}/beds/${id}`);
message.success('已删除');
message.success('已归档');
fetchBeds(drawerRoom.id);
} catch (e: any) { message.error(e?.message || '删除失败'); }
} catch (e: any) { message.error(e?.message || '归档失败'); }
};
const handleBatchBeds = async (count: number) => {
@@ -263,9 +262,9 @@ const RoomsPage: React.FC = () => {
const handleDeleteLocker = async (id: number) => {
try {
await api.delete(`/rooms/${drawerRoom.id}/lockers/${id}`);
message.success('已删除');
message.success('已归档');
fetchLockers(drawerRoom.id);
} catch (e: any) { message.error(e?.message || '删除失败'); }
} catch (e: any) { message.error(e?.message || '归档失败'); }
};
const handleBatchLockers = async (count: number) => {
@@ -461,7 +460,7 @@ const RoomsPage: React.FC = () => {
<PermissionButton
permission="room:delete"
danger
icon={<DeleteOutlined />}
icon={<InboxOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
@@ -701,7 +700,7 @@ const RoomsPage: React.FC = () => {
</PermissionButton>
{r.status !== 'occupied' && (
<Popconfirm title="确定删除" onConfirm={() => handleDeleteBed(r.id)}>
<Popconfirm title="确定归档" onConfirm={() => handleDeleteBed(r.id)}>
<PermissionButton
permission="room:edit"
size="small"
@@ -709,7 +708,7 @@ const RoomsPage: React.FC = () => {
danger
disabled={drawerRoom?.status === 'archived'}
>
</PermissionButton>
</Popconfirm>
)}
@@ -784,7 +783,7 @@ const RoomsPage: React.FC = () => {
</PermissionButton>
{r.status !== 'occupied' && (
<Popconfirm title="确定删除" onConfirm={() => handleDeleteLocker(r.id)}>
<Popconfirm title="确定归档" onConfirm={() => handleDeleteLocker(r.id)}>
<PermissionButton
permission="room:edit"
size="small"
@@ -792,7 +791,7 @@ const RoomsPage: React.FC = () => {
danger
disabled={drawerRoom?.status === 'archived'}
>
</PermissionButton>
</Popconfirm>
)}

View File

@@ -27,10 +27,10 @@ import {
CalendarOutlined,
LeftOutlined,
RightOutlined,
DeleteOutlined,
CloudSyncOutlined,
PlusOutlined,
EditOutlined,
StopOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
@@ -455,26 +455,30 @@ const SchedulesPage: React.FC = () => {
void loadClassTeachers(editableSchedule.classId);
};
// ---- Delete schedule ----
const removeScheduleFromSelection = (id: number) => {
const remaining = selectedSchedules.filter((s) => s.id !== id);
setSelectedSchedules(remaining);
if (remaining.length === 0) {
setModalOpen(false);
}
};
const handleDelete = async (id: number | null) => {
// ---- Disable / delete schedule ----
const handleDisable = async (id: number | null) => {
if (id === null) return;
try {
await api.delete(`/class-schedules/${id}`);
message.success('排课已删除');
// Refresh the displayed schedules
const remaining = selectedSchedules.filter((s) => s.id !== id);
setSelectedSchedules(remaining);
if (remaining.length === 0) {
setModalOpen(false);
}
await api.put(`/class-schedules/${id}`, { status: 'inactive' });
message.success('排课已停用,历史考勤记录已保留,教室占用已释放');
removeScheduleFromSelection(id);
fetchData();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '删除失败');
message.error(err?.message || '停用失败');
}
};
// ---- Classroom select options ----
const classroomOptions = useMemo(
@@ -1113,21 +1117,23 @@ const SchedulesPage: React.FC = () => {
</PermissionButton>
)}
<Popconfirm
title="确认删除该排课?"
onConfirm={() => handleDelete(s.id)}
okText="删除"
cancelText="取消"
>
<PermissionButton
permission="schedule:delete"
size="small"
danger
icon={<DeleteOutlined />}
{s.scheduleType !== 'RENTAL' && s.status === 'active' && (
<Popconfirm
title="确认停用该排课?"
description="停用后历史考勤记录会保留,但该排课不会再显示或占用教室。"
onConfirm={() => handleDisable(s.id)}
okText="停用"
cancelText="取消"
>
</PermissionButton>
</Popconfirm>
<PermissionButton
permission="schedule:edit"
size="small"
icon={<StopOutlined />}
>
</PermissionButton>
</Popconfirm>
)}
</Space>
)}
</div>

View File

@@ -21,7 +21,6 @@ import {
} from 'antd';
import type { UploadProps } from 'antd';
import {
DeleteOutlined,
DownloadOutlined,
ExportOutlined,
EyeOutlined,
@@ -589,7 +588,7 @@ const StudentsPage: React.FC = () => {
<PermissionButton
permission="student:delete"
danger
icon={<DeleteOutlined />}
icon={<InboxOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>

View File

@@ -142,7 +142,7 @@ export class ArchiveController {
userId: req.user?.id,
username: req.user?.username,
module: '学生档案',
action: '删除报名记录',
action: '归档报名记录',
targetId: id,
targetType: 'student_enrollment',
ipAddress,
@@ -206,7 +206,7 @@ export class ArchiveController {
userId: req.user?.id,
username: req.user?.username,
module: '学生档案',
action: '删除考试成绩',
action: '归档考试成绩',
targetId: id,
targetType: 'exam_score',
ipAddress,
@@ -270,7 +270,7 @@ export class ArchiveController {
userId: req.user?.id,
username: req.user?.username,
module: '学生档案',
action: '删除学习记录',
action: '归档学习记录',
targetId: id,
targetType: 'learning_record',
ipAddress,
@@ -353,7 +353,7 @@ export class ArchiveController {
userId: req.user?.id,
username: req.user?.username,
module: '学生档案',
action: '删除附件',
action: '归档附件',
targetId: id,
targetType: 'archive_attachment',
ipAddress,

View File

@@ -71,11 +71,11 @@ export class ArchiveService {
attendances,
] = await Promise.all([
this.profileRepo.findOne({ where: { studentId } }),
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
this.enrollmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }),
this.examScoreRepo.find({ where: { studentId, status: 'active' }, order: { examDate: 'DESC' } }),
this.learningRecordRepo.find({ where: { studentId, status: 'active' }, order: { recordDate: 'DESC' } }),
this.resultRepo.findOne({ where: { studentId } }),
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
this.attachmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }),
this.attendanceRepo.find({
where: { studentId },
relations: ['schedule', 'class'],
@@ -126,8 +126,9 @@ export class ArchiveService {
async deleteEnrollment(id: number) {
const entity = await this.enrollmentRepo.findOne({ where: { id } });
if (!entity) throw new NotFoundException('报名记录不存在');
await this.enrollmentRepo.remove(entity);
return { message: '已删除' };
if (entity.status === 'archived') throw new BadRequestException('报名记录已归档');
await this.enrollmentRepo.update(id, { status: 'archived' });
return { message: '已归档' };
}
private async assertEnrollmentBelongsToStudent(studentId: number, enrollmentId?: number) {
@@ -158,8 +159,9 @@ export class ArchiveService {
async deleteExamScore(id: number) {
const entity = await this.examScoreRepo.findOne({ where: { id } });
if (!entity) throw new NotFoundException('考试成绩不存在');
await this.examScoreRepo.remove(entity);
return { message: '已删除' };
if (entity.status === 'archived') throw new BadRequestException('考试成绩已归档');
await this.examScoreRepo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async addLearningRecord(studentId: number, dto: CreateLearningRecordDto) {
@@ -180,8 +182,9 @@ export class ArchiveService {
async deleteLearningRecord(id: number) {
const entity = await this.learningRecordRepo.findOne({ where: { id } });
if (!entity) throw new NotFoundException('学习记录不存在');
await this.learningRecordRepo.remove(entity);
return { message: '已删除' };
if (entity.status === 'archived') throw new BadRequestException('学习记录已归档');
await this.learningRecordRepo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async upsertResult(studentId: number, dto: UpsertResultDto) {
@@ -241,13 +244,8 @@ export class ArchiveService {
async deleteAttachment(id: number) {
const entity = await this.attachmentRepo.findOne({ where: { id } });
if (!entity) throw new NotFoundException('附件不存在');
const absPath = this.resolveAttachmentPath(entity.filePath);
if (fs.existsSync(absPath)) {
fs.unlinkSync(absPath);
}
await this.attachmentRepo.remove(entity);
return { message: '已删除' };
if (entity.status === 'archived') throw new BadRequestException('附件已归档');
await this.attachmentRepo.update(id, { status: 'archived' });
return { message: '已归档' };
}
}

View File

@@ -74,7 +74,7 @@ export class AttendanceDevicesController {
userId: req.user?.id,
username: req.user?.username,
module: '考勤机',
action: '删除考勤机绑定',
action: '停用考勤机绑定',
targetId: id,
targetType: 'attendanceDevice',
ipAddress,

View File

@@ -74,8 +74,11 @@ export class AttendanceDevicesService {
async remove(id: number) {
const device = await this.repo.findOne({ where: { id } });
if (!device) throw new NotFoundException('考勤机不存在');
await this.repo.delete(id);
return { message: '已删除' };
if (device.status === AttendanceDeviceStatus.DISABLED) {
throw new BadRequestException('考勤机已停用');
}
await this.repo.update(id, { status: AttendanceDeviceStatus.DISABLED });
return { message: '已停用(绑定数据已保留)' };
}
async findActiveBySn(deviceSns: string[]) {

View File

@@ -323,10 +323,10 @@ export class AttendanceController {
userId: req.user?.id,
username: req.user?.username,
module: '考勤管理',
action: '删除考勤记录',
action: '归档考勤记录',
targetId: id,
targetType: 'attendanceRecord',
detail: `删除考勤记录 ${id}`,
detail: `归档考勤记录 ${id}`,
ipAddress,
userAgent,
});

View File

@@ -16,6 +16,7 @@ function createService(bills: Partial<Bill>[] = []) {
find: jest.fn().mockResolvedValue(bills),
findOne: jest.fn().mockResolvedValue(bills[0] ?? null),
save: jest.fn(async (value) => value),
update: jest.fn(),
createQueryBuilder: jest.fn(() => queryBuilder()),
};
const manager = {
@@ -55,23 +56,26 @@ describe('BillsService state and batch boundaries', () => {
expect(billRepo.save).not.toHaveBeenCalled();
});
it('rejects an empty batch delete', async () => {
it('rejects an empty batch archive', async () => {
const { service, dataSource } = createService();
await expect(service.batchRemove([])).rejects.toBeInstanceOf(BadRequestException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects a batch delete when some ids do not exist', async () => {
it('rejects a batch archive when some ids do not exist', async () => {
const { service, dataSource } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
await expect(service.batchRemove([1, 2])).rejects.toBeInstanceOf(NotFoundException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('deletes a bill and its links in one transaction', async () => {
const { service, dataSource, manager } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
await expect(service.remove(1)).resolves.toEqual({ message: '账单已删除' });
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(manager.delete).toHaveBeenCalledTimes(2);
expect(manager.update).toHaveBeenCalledTimes(1);
it('archives an unpaid bill without deleting rows', async () => {
const { service, billRepo, dataSource, manager } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
await expect(service.remove(1)).resolves.toEqual({ message: '账单已归档' });
expect(billRepo.save).not.toHaveBeenCalled();
expect(billRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(billRepo.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
expect((billRepo as any).update).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'cancelled' }));
expect(dataSource.transaction).not.toHaveBeenCalled();
expect(manager.delete).not.toHaveBeenCalled();
});
});

View File

@@ -187,7 +187,7 @@ export class BillsController {
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '删除账单',
action: '归档账单',
targetId: id,
targetType: 'bill',
ipAddress,
@@ -205,7 +205,7 @@ export class BillsController {
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '批量删除账单',
action: '批量归档账单',
detail: `IDs: ${body.ids.join(',')}`,
ipAddress,
userAgent,

View File

@@ -71,6 +71,7 @@ export class BillsService {
periodStart,
periodEnd,
})
.andWhere('e.status = :status', { status: 'active' })
.getMany();
// 按宿舍分组费用
@@ -177,6 +178,7 @@ export class BillsService {
periodStart,
periodEnd,
})
.andWhere('pe.status = :status', { status: 'active' })
.andWhere('pe.billId IS NULL')
.getMany();
@@ -392,31 +394,42 @@ export class BillsService {
async remove(id: number) {
const exists = await this.billRepo.findOne({ where: { id } });
if (!exists) throw new NotFoundException('账单不存在');
if (Number(exists.paidAmount) > 0 || exists.status === 'cancelled') {
throw new BadRequestException('已发生资金流水的账单不能删除,请使用取消账单');
if (exists.status === 'cancelled') throw new BadRequestException('账单已归档');
if (Number(exists.paidAmount) > 0) {
throw new BadRequestException('已发生资金流水的账单请使用取消账单并冲正');
}
await this.dataSource.transaction(async (manager) => {
await manager.delete(BillItem, { billId: id });
await manager.update(PersonalExpense, { billId: id }, { billId: null });
await manager.delete(Bill, id);
await this.billRepo.update(id, {
status: 'cancelled',
outstandingAmount: 0,
cancelReason: '归档未支付账单',
cancelledAt: new Date(),
});
return { message: '账单已删除' };
return { message: '账单已归档' };
}
async batchRemove(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的账单');
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的账单');
const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
if (bills.some((bill) => Number(bill.paidAmount) > 0 || bill.status === 'cancelled')) {
throw new BadRequestException('选中账单包含资金流水,不能批量删除');
if (bills.some((bill) => Number(bill.paidAmount) > 0)) {
throw new BadRequestException('选中账单包含资金流水,请逐条取消并冲正');
}
await this.dataSource.transaction(async (manager) => {
await manager.delete(BillItem, { billId: In(uniqueIds) });
await manager.update(PersonalExpense, { billId: In(uniqueIds) }, { billId: null });
await manager.delete(Bill, uniqueIds);
});
return { message: `成功删除 ${uniqueIds.length} 条账单` };
const targetIds = bills.filter((bill) => bill.status !== 'cancelled').map((bill) => bill.id);
if (targetIds.length > 0) {
await this.billRepo
.createQueryBuilder()
.update()
.set({
status: 'cancelled',
outstandingAmount: 0,
cancelReason: '批量归档未支付账单',
cancelledAt: new Date(),
})
.where('id IN (:...ids)', { ids: targetIds })
.execute();
}
return { message: `成功归档 ${targetIds.length} 条账单`, archived: targetIds.length };
}
private assertStatusMatchesAmounts(bill: Bill, status: string) {

View File

@@ -170,7 +170,7 @@ export class ClassesController {
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '删除班级',
action: '归档班级',
targetId: +id,
targetType: 'class',
ipAddress,

View File

@@ -300,23 +300,9 @@ export class ClassesService {
return { success: true };
}
/** 物理删除班级(已归档的才能删除) */
/** 归档班级(兼容旧删除入口,不物理删除) */
async remove(id: number) {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
if (!cls.isArchived) throw new BadRequestException('请先归档再删除');
const sessionCount = await this.attendanceSessionRepo.count({
where: { classId: id },
});
if (sessionCount > 0) {
throw new ConflictException(
`无法删除已产生 ${sessionCount} 个考勤场次的班级。请先取消或停用班级以保护历史考勤数据。`,
);
}
await this.classRepo.remove(cls);
return { success: true };
return this.archive(id);
}
async getStudents(classId: number) {

View File

@@ -182,7 +182,7 @@ export class ClassroomRentalsController {
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '删除租赁',
action: '归档租赁',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,
@@ -249,7 +249,7 @@ export class ClassroomRentalsController {
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '除合同',
action: '除合同',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,

View File

@@ -326,7 +326,7 @@ export class ClassroomRentalsService {
throw new BadRequestException('仅有效租赁可以取消');
}
await this.repo.update(id, { status: ClassroomRentalStatus.CANCELLED });
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
await this.scheduleRepo.update({ rentalId: id, scheduleType: 'RENTAL' }, { status: 'inactive' });
return this.findOne(id);
}
@@ -353,24 +353,12 @@ export class ClassroomRentalsService {
async remove(id: number) {
const rental = await this.findOne(id);
if (rental.effectiveStatus === ClassroomRentalStatus.ACTIVE) {
throw new BadRequestException('进行中的租赁请先取消或结束');
if (rental.status === ClassroomRentalStatus.CANCELLED) {
return { message: '租赁订单已归档' };
}
// 同步删除对应排课记录
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
// 同时删除合同文件
if (rental.contractPath) {
const full = path.join(this.uploadDir, rental.contractPath);
if (fs.existsSync(full)) {
try {
fs.unlinkSync(full);
} catch {
/* ignore */
}
}
}
await this.repo.delete(id);
return { message: '删除成功' };
await this.repo.update(id, { status: ClassroomRentalStatus.CANCELLED });
await this.scheduleRepo.update({ rentalId: id, scheduleType: 'RENTAL' }, { status: 'inactive' });
return { message: '租赁订单已归档(合同文件已保留)' };
}
private withEffectiveStatus(rental: ClassroomRental) {
@@ -442,7 +430,7 @@ export class ClassroomRentalsService {
const fullPath = path.join(this.uploadDir, filename);
// 路径遍历防护
if (!fullPath.startsWith(this.uploadDir)) throw new BadRequestException('路径非法');
// 删除旧文件
// 替换旧文件
if (rental.contractPath) {
const oldPath = path.join(this.uploadDir, rental.contractPath);
if (fs.existsSync(oldPath)) {
@@ -473,7 +461,7 @@ export class ClassroomRentalsService {
}
}
await this.repo.update(id, { contractPath: null as any, contractOriginalName: null as any });
return { message: '合同已除' };
return { message: '合同已除' };
}
/**

View File

@@ -157,10 +157,10 @@ export class DepositsController {
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '删除分期',
action: '归档分期',
targetId: installmentId,
targetType: 'deposit-installment',
detail: `删除分期${installmentId}`,
detail: `归档分期${installmentId}`,
ipAddress,
userAgent,
});
@@ -207,7 +207,7 @@ export class DepositsController {
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '删除押金记录',
action: '归档押金记录',
targetId: id,
targetType: 'deposit',
ipAddress,

View File

@@ -36,12 +36,18 @@ export class DepositsService {
.orderBy('d.createdAt', 'DESC');
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
return qb.getMany();
else qb.andWhere('d.status != :archived', { archived: 'archived' });
const deposits = await qb.getMany();
for (const deposit of deposits) {
deposit.installments = deposit.installments?.filter((item) => item.status !== 'archived') ?? [];
}
return deposits;
}
async findOne(id: number) {
const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] });
if (!deposit) throw new NotFoundException('押金记录不存在');
deposit.installments = deposit.installments?.filter((item) => item.status !== 'archived') ?? [];
return deposit;
}
@@ -110,8 +116,9 @@ export class DepositsService {
async deleteInstallment(id: number) {
const installment = await this.installmentRepo.findOne({ where: { id } });
if (!installment) throw new NotFoundException('分期记录不存在');
await this.installmentRepo.delete(id);
return { message: '删除成功' };
if (installment.status === 'archived') throw new BadRequestException('分期记录已归档');
await this.installmentRepo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async refund(id: number, dto: RefundDepositDto, userId?: number) {
@@ -137,8 +144,9 @@ export class DepositsService {
async remove(id: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
await this.repo.delete(id);
return { message: '删除成功' };
if (deposit.status === 'archived') throw new BadRequestException('押金记录已归档');
await this.repo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async getStats() {

View File

@@ -37,6 +37,9 @@ export class ArchiveAttachment {
mimeType: string;
@Column({ type: 'varchar', length: 20, default: 'active' })
status: 'active' | 'archived';
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -51,6 +51,9 @@ export class ExamScore {
examDate: string;
@Column({ type: 'varchar', length: 20, default: 'active' })
status: 'active' | 'archived';
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -37,6 +37,9 @@ export class LearningRecord {
nextStep: string;
@Column({ type: 'varchar', length: 20, default: 'active' })
status: 'active' | 'archived';
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -41,6 +41,9 @@ export class Occupancy {
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ type: 'varchar', length: 20, default: 'active' })
status: 'active' | 'archived';
@Column({ name: 'bed_id', type: 'integer', nullable: true })
bedId: number;

View File

@@ -34,6 +34,9 @@ export class PersonalExpense {
@Column({ name: 'recorded_by', nullable: true })
recordedBy: number;
@Column({ type: 'varchar', length: 20, default: 'active' })
status: 'active' | 'archived';
@Column({ name: 'bill_id', type: 'integer', nullable: true })
billId: number | null;

View File

@@ -34,6 +34,9 @@ export class RoomExpense {
@Column({ name: 'recorded_by', nullable: true })
recordedBy: number;
@Column({ type: 'varchar', length: 20, default: 'active' })
status: 'active' | 'archived';
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -73,12 +73,12 @@ export class ExpenseTypesController {
userId: req.user?.id,
username: req.user?.username,
module: '费用类型',
action: '删除费用类型',
action: '停用费用类型',
targetId: +id,
targetType: 'expense_type',
ipAddress,
userAgent,
});
return { message: '已删除' };
return { message: '已停用' };
}
}

View File

@@ -66,6 +66,7 @@ export class ExpenseTypesService {
async remove(id: number): Promise<void> {
const t = await this.findOne(id);
await this.repo.remove(t);
if (!t.enabled) return;
await this.repo.update(t.id, { enabled: false });
}
}

View File

@@ -154,7 +154,7 @@ export class ExpensesController {
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '删除费用',
action: '归档费用',
targetId: id,
targetType: 'room_expense',
ipAddress,
@@ -172,7 +172,7 @@ export class ExpensesController {
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '批量删除宿舍费用',
action: '批量归档宿舍费用',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
@@ -235,7 +235,7 @@ export class ExpensesController {
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '删除费用',
action: '归档费用',
targetId: id,
ipAddress,
userAgent,
@@ -252,7 +252,7 @@ export class ExpensesController {
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '批量删除个人费用',
action: '批量归档个人费用',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,

View File

@@ -76,6 +76,7 @@ export class ExpensesService {
const qb = this.roomExpRepo
.createQueryBuilder('e')
.leftJoinAndSelect('e.room', 'room')
.where('e.status = :status', { status: 'active' })
.orderBy('e.createdAt', 'DESC');
if (query?.roomId) qb.andWhere('e.roomId = :roomId', { roomId: query.roomId });
if (query?.periodStart) qb.andWhere('e.periodStart >= :ps', { ps: query.periodStart });
@@ -86,21 +87,23 @@ export class ExpensesService {
async deleteRoomExpense(id: number) {
const e = await this.roomExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
await this.roomExpRepo.delete(id);
return { message: '删除成功' };
if (e.status === 'archived') throw new BadRequestException('费用记录已归档');
await this.roomExpRepo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async batchDeleteRoomExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的记录');
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录');
const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) }, select: ['id'] });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const result = await this.roomExpRepo
.createQueryBuilder()
.delete()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: uniqueIds })
.execute();
return { message: '批量删除成功', deleted: result.affected || 0 };
return { message: `已批量归档 ${result.affected || 0}`, archived: result.affected || 0 };
}
async updateRoomExpense(id: number, dto: Partial<CreateRoomExpenseDto>) {
@@ -172,7 +175,7 @@ export class ExpensesService {
}
async findPersonalExpenses(query?: { studentId?: number }) {
const where: Record<string, unknown> = {};
const where: Record<string, unknown> = { status: 'active' };
if (query?.studentId) where.studentId = query.studentId;
return this.personalExpRepo.find({
where,
@@ -184,14 +187,15 @@ export class ExpensesService {
async deletePersonalExpense(id: number) {
const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能删除,请先取消账单');
await this.personalExpRepo.delete(id);
return { message: '删除成功' };
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能归档,请先取消账单');
if (e.status === 'archived') throw new BadRequestException('费用记录已归档');
await this.personalExpRepo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async batchDeletePersonalExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的记录');
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录');
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
if (existing.some((expense) => expense.billId)) {
@@ -199,10 +203,11 @@ export class ExpensesService {
}
const result = await this.personalExpRepo
.createQueryBuilder()
.delete()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: uniqueIds })
.execute();
return { message: '批量删除成功', deleted: result.affected || 0 };
return { message: `已批量归档 ${result.affected || 0}`, archived: result.affected || 0 };
}
async updatePersonalExpense(id: number, dto: Partial<CreatePersonalExpenseDto>) {

View File

@@ -166,7 +166,7 @@ export class OccupanciesController {
userId: req.user?.id,
username: req.user?.username,
module: '入住管理',
action: '删除入住记录',
action: '归档入住记录',
targetId: +id,
targetType: 'occupancy',
ipAddress,
@@ -184,7 +184,7 @@ export class OccupanciesController {
userId: req.user?.id,
username: req.user?.username,
module: '入住管理',
action: '批量删除入住记录',
action: '批量归档入住记录',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,

View File

@@ -39,6 +39,7 @@ export class OccupanciesService {
.leftJoinAndSelect('o.room', 'room')
.leftJoinAndSelect('o.bed', 'bed')
.leftJoinAndSelect('o.locker', 'locker')
.where('o.status = :status', { status: 'active' })
.orderBy('o.checkInDate', 'DESC');
if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId });
if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId });
@@ -287,13 +288,14 @@ export class OccupanciesService {
async remove(id: number) {
const occ = await this.repo.findOne({ where: { id } });
if (!occ) throw new NotFoundException('入住记录不存在');
if (!occ.checkOutDate) throw new BadRequestException('在住记录不能删除,请先办理退宿');
await this.repo.delete(id);
return { message: '删除成功' };
if (!occ.checkOutDate) throw new BadRequestException('在住记录不能归档,请先办理退宿');
if (occ.status === 'archived') throw new BadRequestException('入住记录已归档');
await this.repo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async batchRemove(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录');
if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的记录');
const records = await this.repo.find({ where: { id: In(ids) }, relations: ['student'] });
const skipped: string[] = [];
const deletableIds: number[] = [];
@@ -304,20 +306,21 @@ export class OccupanciesService {
deletableIds.push(occ.id);
}
}
let deleted = 0;
let archived = 0;
if (deletableIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.delete()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: deletableIds })
.execute();
deleted = result.affected || 0;
archived = result.affected || 0;
}
const message =
skipped.length > 0
? `成功删除 ${deleted} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿`
: `批量删除成功,共 ${deleted}`;
return { message, deleted, skipped: skipped.length };
? `成功归档 ${archived} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿`
: `批量归档成功,共 ${archived}`;
return { message, archived, skipped: skipped.length };
}
async batchCheckOut(dto: {

View File

@@ -97,7 +97,7 @@ export class RbacController {
userId: req.user?.id,
username: req.user?.username,
module: 'RBAC',
action: '删除角色',
action: '停用角色',
targetId: +id,
targetType: 'role',
ipAddress,

View File

@@ -24,39 +24,39 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'teacher:edit', name: '编辑教师', group: 'teacher' },
{ code: 'student:create', name: '新增学生', group: 'student' },
{ code: 'student:edit', name: '编辑学生', group: 'student' },
{ code: 'student:delete', name: '删除学生', group: 'student' },
{ code: 'student:delete', name: '归档学生', group: 'student' },
{ code: 'student:import', name: '导入学生', group: 'student' },
{ code: 'student:export', name: '导出学生', group: 'student' },
{ code: 'room:view', name: '查看宿舍', group: 'room' },
{ code: 'room:create', name: '新增宿舍', group: 'room' },
{ code: 'room:edit', name: '编辑宿舍', group: 'room' },
{ code: 'room:delete', name: '删除宿舍', group: 'room' },
{ code: 'room:delete', name: '归档宿舍', group: 'room' },
{ code: 'occupancy:view', name: '查看入住', group: 'occupancy' },
{ code: 'occupancy:checkin', name: '办理入住', group: 'occupancy' },
{ code: 'occupancy:checkout', name: '办理退宿', group: 'occupancy' },
{ code: 'occupancy:transfer', name: '调换宿舍', group: 'occupancy' },
{ code: 'occupancy:delete', name: '删除入住记录', group: 'occupancy' },
{ code: 'occupancy:delete', name: '归档入住记录', group: 'occupancy' },
{ code: 'expense:view', name: '查看费用', group: 'expense' },
{ code: 'expense:create', name: '录入费用', group: 'expense' },
{ code: 'expense:edit', name: '编辑费用', group: 'expense' },
{ code: 'expense:delete', name: '删除费用', group: 'expense' },
{ code: 'expense:delete', name: '归档费用', group: 'expense' },
{ code: 'bill:view', name: '查看账单', group: 'bill' },
{ code: 'bill:generate', name: '生成账单', group: 'bill' },
{ code: 'bill:confirm', name: '确认账单', group: 'bill' },
{ code: 'bill:delete', name: '删除账单', group: 'bill' },
{ code: 'bill:delete', name: '归档账单', group: 'bill' },
{ code: 'bill:export-excel', name: '导出 Excel', group: 'bill' },
{ code: 'bill:export-pdf', name: '导出 PDF', group: 'bill' },
{ code: 'deposit:view', name: '查看押金', group: 'deposit' },
{ code: 'deposit:create', name: '新增押金', group: 'deposit' },
{ code: 'deposit:edit', name: '编辑押金', group: 'deposit' },
{ code: 'deposit:delete', name: '删除押金', group: 'deposit' },
{ code: 'deposit:delete', name: '归档押金', group: 'deposit' },
{ code: 'deposit:refund', name: '直接退还押金', group: 'deposit' },
{ code: 'wallet:view', name: '查看学生余额', group: 'wallet' },
{ code: 'wallet:edit', name: '充值和调账', group: 'wallet' },
{ code: 'classroom:view', name: '查看教室', group: 'classroom' },
{ code: 'classroom:create', name: '新增教室', group: 'classroom' },
{ code: 'classroom:edit', name: '编辑教室', group: 'classroom' },
{ code: 'classroom:delete', name: '删除教室', group: 'classroom' },
{ code: 'classroom:delete', name: '归档教室', group: 'classroom' },
{ code: 'organization:view', name: '查看机构', group: 'organization' },
{ code: 'organization:create', name: '新增机构', group: 'organization' },
{ code: 'organization:edit', name: '编辑机构', group: 'organization' },
@@ -64,7 +64,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'rental:view', name: '查看租赁订单', group: 'rental' },
{ code: 'rental:create', name: '新增租赁订单', group: 'rental' },
{ code: 'rental:edit', name: '编辑租赁订单', group: 'rental' },
{ code: 'rental:delete', name: '删除租赁订单', group: 'rental' },
{ code: 'rental:delete', name: '归档租赁订单', group: 'rental' },
{ code: 'log:view', name: '查看操作日志', group: 'log' },
{ code: 'log:create', name: '写入操作日志', group: 'log' },
{ code: 'user:view', name: '查看用户', group: 'user' },
@@ -74,15 +74,15 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'role:view', name: '查看角色', group: 'role' },
{ code: 'role:create', name: '创建角色', group: 'role' },
{ code: 'role:edit', name: '编辑角色', group: 'role' },
{ code: 'role:delete', name: '删除角色', group: 'role' },
{ code: 'role:delete', name: '停用角色', group: 'role' },
{ code: 'class:view', name: '查看班级', group: 'class' },
{ code: 'class:create', name: '创建班级', group: 'class' },
{ code: 'class:edit', name: '编辑班级', group: 'class' },
{ code: 'class:delete', name: '删除班级', group: 'class' },
{ code: 'class:delete', name: '归档班级', group: 'class' },
{ code: 'schedule:view', name: '查看排课', group: 'schedule' },
{ code: 'schedule:create', name: '创建排课', group: 'schedule' },
{ code: 'schedule:edit', name: '编辑排课', group: 'schedule' },
{ code: 'schedule:delete', name: '删除排课', group: 'schedule' },
{ code: 'schedule:delete', name: '停用排课', group: 'schedule' },
{ code: 'attendance:view', name: '查看考勤', group: 'attendance' },
{ code: 'attendance:create', name: '新增考勤', group: 'attendance' },
{ code: 'attendance:edit', name: '编辑全部考勤', group: 'attendance' },
@@ -442,9 +442,11 @@ export class RbacService {
async deleteRole(id: number): Promise<{ message: string }> {
const role = await this.roleRepo.findOneOrFail({ where: { id } });
if (role.isSystem) throw new Error('系统角色不可删除');
await this.roleRepo.remove(role);
return { message: '角色已删除' };
if (role.isSystem) throw new Error('系统角色不可停用');
if (role.status === 0) return { message: '角色已停用' };
role.status = 0;
await this.roleRepo.save(role);
return { message: '角色已停用' };
}
async findAllPermissions(): Promise<Permission[]> {

View File

@@ -395,7 +395,7 @@ export class RoomsService {
async getRoomBeds(roomId: number): Promise<Bed[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
return this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } });
return this.bedRepo.find({ where: { roomId, status: Not('archived') }, order: { bedNumber: 'ASC' } });
}
async getRoomAvailableBeds(roomId: number): Promise<Bed[]> {
@@ -437,15 +437,16 @@ export class RoomsService {
async deleteBed(roomId: number, id: number): Promise<void> {
const bed = await this.bedRepo.findOne({ where: { id, roomId } });
if (!bed) throw new NotFoundException('床位不存在');
if (bed.status === 'occupied') throw new BadRequestException('该床位有人入住,无法删除');
await this.bedRepo.remove(bed);
if (bed.status === 'occupied') throw new BadRequestException('该床位有人入住,无法归档');
if (bed.status === 'archived') throw new BadRequestException('该床位已归档');
await this.bedRepo.update(id, { status: 'archived' });
}
async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise<Bed[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
const existing = await this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } });
const existing = await this.bedRepo.find({ where: { roomId, status: Not('archived') }, order: { bedNumber: 'ASC' } });
this.assertCanAddBedsFromCount(room, existing.length, dto.count);
const numbers = existing.map((b) => {
const match = b.bedNumber.match(/^\d+/);
@@ -495,7 +496,7 @@ export class RoomsService {
async getRoomLockers(roomId: number): Promise<Locker[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
return this.lockerRepo.find({ where: { roomId }, order: { lockerNumber: 'ASC' } });
return this.lockerRepo.find({ where: { roomId, status: Not('archived') }, order: { lockerNumber: 'ASC' } });
}
async getRoomAvailableLockers(roomId: number): Promise<Locker[]> {
@@ -538,8 +539,9 @@ export class RoomsService {
async deleteLocker(roomId: number, id: number): Promise<void> {
const locker = await this.lockerRepo.findOne({ where: { id, roomId } });
if (!locker) throw new NotFoundException('柜子不存在');
if (locker.status === 'occupied') throw new BadRequestException('该柜子有人占用,无法删除');
await this.lockerRepo.remove(locker);
if (locker.status === 'occupied') throw new BadRequestException('该柜子有人占用,无法归档');
if (locker.status === 'archived') throw new BadRequestException('该柜子已归档');
await this.lockerRepo.update(id, { status: 'archived' });
}
async batchCreateLockers(roomId: number, dto: BatchCreateLockerDto): Promise<Locker[]> {
@@ -547,7 +549,7 @@ export class RoomsService {
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
const existing = await this.lockerRepo.find({
where: { roomId },
where: { roomId, status: Not('archived') },
order: { lockerNumber: 'ASC' },
});
const numbers = existing.map((b) => {

View File

@@ -28,6 +28,16 @@ describe('schedule attendance window validation', () => {
const errors = await validate(dto);
expect(errors.some((error) => error.property === 'attendanceAdvanceMinutes')).toBe(true);
});
it('accepts only supported schedule statuses when updating', async () => {
const inactive = Object.assign(new UpdateScheduleDto(), { status: 'inactive' });
const cancelled = Object.assign(new UpdateScheduleDto(), { status: 'cancelled' });
const paused = Object.assign(new UpdateScheduleDto(), { status: 'paused' });
expect((await validate(inactive)).some((error) => error.property === 'status')).toBe(false);
expect((await validate(cancelled)).some((error) => error.property === 'status')).toBe(false);
expect((await validate(paused)).some((error) => error.property === 'status')).toBe(true);
});
});
describe('schedule notes validation', () => {

View File

@@ -9,6 +9,7 @@ import {
Min,
Max,
MaxLength,
IsIn,
} from 'class-validator';
import { Type } from 'class-transformer';
@@ -133,6 +134,11 @@ export class UpdateScheduleDto {
@IsString()
@MaxLength(500)
notes?: string;
@IsOptional()
@IsString()
@IsIn(['active', 'inactive', 'cancelled'])
status?: string;
}
export class QueryScheduleDto {

View File

@@ -258,7 +258,7 @@ export class SchedulesController {
userId: req.user?.id,
username: req.user?.username,
module: '排课管理',
action: '删除排课',
action: '停用排课',
targetId: +id,
targetType: 'class-schedule',
ipAddress,

View File

@@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConflictException } from '@nestjs/common';
import { BadRequestException, ConflictException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { SchedulesService } from './schedules.service';
import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity';
@@ -274,28 +274,48 @@ describe('SchedulesService — getClassroomOccupancy', () => {
});
});
describe('SchedulesService — remove', () => {
describe('SchedulesService — remove/update status', () => {
let service: SchedulesService;
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'findOne' | 'remove'>>;
let scheduleRepo: jest.Mocked<
Pick<Repository<ClassSchedule>, 'findOne' | 'remove' | 'update' | 'createQueryBuilder'>
>;
let classroomRepo: jest.Mocked<Pick<Repository<Classroom>, 'find' | 'findOne'>>;
let classTeacherRepo: jest.Mocked<Pick<Repository<ClassTeacher>, 'find' | 'findOne'>>;
let rentalRepo: jest.Mocked<Pick<Repository<ClassroomRental>, 'createQueryBuilder'>>;
let attendanceSessionRepo: jest.Mocked<Pick<Repository<AttendanceSession>, 'count'>>;
beforeEach(async () => {
const scheduleRepoMock = {
findOne: jest.fn(),
remove: jest.fn(),
update: jest.fn(),
createQueryBuilder: jest.fn(),
};
const classroomRepoMock = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue({ id: 1, status: 'available' }),
};
const classTeacherRepoMock = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue({ id: 1 }),
};
const rentalRepoMock = { createQueryBuilder: jest.fn() };
const module: TestingModule = await Test.createTestingModule({
providers: [
SchedulesService,
{
provide: getRepositoryToken(ClassSchedule),
useValue: { findOne: jest.fn(), remove: jest.fn() },
useValue: scheduleRepoMock,
},
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(Classroom), useValue: classroomRepoMock },
{
provide: getRepositoryToken(ClassroomRental),
useValue: { createQueryBuilder: jest.fn() },
useValue: rentalRepoMock,
},
{
provide: getRepositoryToken(ClassTeacher),
useValue: { find: jest.fn().mockResolvedValue([]) },
useValue: classTeacherRepoMock,
},
{
provide: getRepositoryToken(AttendanceSession),
@@ -306,29 +326,32 @@ describe('SchedulesService — remove', () => {
service = module.get<SchedulesService>(SchedulesService);
scheduleRepo = module.get(getRepositoryToken(ClassSchedule));
classroomRepo = module.get(getRepositoryToken(Classroom));
classTeacherRepo = module.get(getRepositoryToken(ClassTeacher));
rentalRepo = module.get(getRepositoryToken(ClassroomRental));
attendanceSessionRepo = module.get(getRepositoryToken(AttendanceSession));
});
it('deletes a schedule with no attendance sessions', async () => {
const schedule = { id: 1, subject: '数学' } as ClassSchedule;
it('disables a schedule instead of deleting it', async () => {
const schedule = { id: 1, subject: '数学', status: 'active' } as ClassSchedule;
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule);
(scheduleRepo.remove as jest.Mock).mockResolvedValue(schedule);
(attendanceSessionRepo.count as jest.Mock).mockResolvedValue(0);
(scheduleRepo.update as jest.Mock).mockResolvedValue({ affected: 1 });
const result = await service.remove(1);
expect(result).toEqual({ success: true });
expect(result).toEqual({ success: true, message: '排课已停用(历史考勤记录已保留)' });
expect(scheduleRepo.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
expect(scheduleRepo.remove).toHaveBeenCalledWith(schedule);
expect(scheduleRepo.update).toHaveBeenCalledWith(1, { status: 'inactive' });
expect(scheduleRepo.remove).not.toHaveBeenCalled();
expect(attendanceSessionRepo.count).not.toHaveBeenCalled();
});
it('rejects deletion when attendance sessions exist', async () => {
const schedule = { id: 2, subject: '英语' } as ClassSchedule;
it('rejects disabling an already inactive schedule', async () => {
const schedule = { id: 2, subject: '英语', status: 'inactive' } as ClassSchedule;
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule);
(scheduleRepo.remove as jest.Mock).mockResolvedValue(schedule);
(attendanceSessionRepo.count as jest.Mock).mockResolvedValue(3);
await expect(service.remove(2)).rejects.toThrow(ConflictException);
await expect(service.remove(2)).rejects.toThrow('排课已停用');
expect(scheduleRepo.update).not.toHaveBeenCalled();
expect(scheduleRepo.remove).not.toHaveBeenCalled();
});
@@ -338,6 +361,84 @@ describe('SchedulesService — remove', () => {
await expect(service.remove(999)).rejects.toThrow('排课记录不存在');
expect(scheduleRepo.remove).not.toHaveBeenCalled();
});
it('disables a schedule without checking conflicts or deleting history', async () => {
const schedule = {
id: 3,
classId: 1,
classroomId: 10,
weekDay: 2,
startTime: '09:00',
endTime: '10:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
subject: '英语',
teacherId: 5,
status: 'active',
} as ClassSchedule;
(scheduleRepo.findOne as jest.Mock)
.mockResolvedValueOnce(schedule)
.mockResolvedValueOnce({ ...schedule, status: 'inactive' });
const result = await service.update(3, { status: 'inactive' });
expect(result).toMatchObject({ id: 3, status: 'inactive' });
expect(scheduleRepo.update).toHaveBeenCalledWith(3, { status: 'inactive' });
expect(scheduleRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(rentalRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(classroomRepo.findOne).not.toHaveBeenCalled();
expect(classTeacherRepo.findOne).not.toHaveBeenCalled();
});
it('checks classroom availability and conflicts when reactivating a schedule', async () => {
const schedule = {
id: 4,
classId: 1,
classroomId: 10,
weekDay: 2,
startTime: '09:00',
endTime: '10:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
subject: '英语',
teacherId: 5,
status: 'inactive',
} as ClassSchedule;
(scheduleRepo.findOne as jest.Mock)
.mockResolvedValueOnce(schedule)
.mockResolvedValueOnce({ ...schedule, status: 'active' });
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
await expect(service.update(4, { status: 'active' })).resolves.toMatchObject({
id: 4,
status: 'active',
});
expect(classroomRepo.findOne).toHaveBeenCalledWith({ where: { id: 10 } });
expect(scheduleRepo.createQueryBuilder).toHaveBeenCalled();
expect(scheduleRepo.update).toHaveBeenCalledWith(4, { status: 'active' });
});
it('rejects invalid schedule statuses', async () => {
const schedule = {
id: 5,
classId: 1,
classroomId: 10,
weekDay: 2,
startTime: '09:00',
endTime: '10:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
subject: '英语',
status: 'active',
} as ClassSchedule;
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule);
await expect(service.update(5, { status: 'paused' })).rejects.toThrow(BadRequestException);
expect(scheduleRepo.update).not.toHaveBeenCalled();
});
});
describe('SchedulesService — range boundaries', () => {

View File

@@ -24,6 +24,10 @@ import {
} from './dto/schedule.dto';
const SCHEDULE_GAP_MINUTES = 10;
const ACTIVE_SCHEDULE_STATUS = 'active';
const INACTIVE_SCHEDULE_STATUSES = ['inactive', 'cancelled'] as const;
type ScheduleStatus = typeof ACTIVE_SCHEDULE_STATUS | (typeof INACTIVE_SCHEDULE_STATUSES)[number];
const SCHEDULE_STATUSES: readonly ScheduleStatus[] = [ACTIVE_SCHEDULE_STATUS, ...INACTIVE_SCHEDULE_STATUSES];
function shiftTime(time: string, minutes: number): string {
const [hours, minutePart] = time.split(':').map(Number);
@@ -171,6 +175,12 @@ export class SchedulesService {
return schedule;
}
private assertValidScheduleStatus(status: string): asserts status is ScheduleStatus {
if (!SCHEDULE_STATUSES.includes(status as ScheduleStatus)) {
throw new BadRequestException('排课状态无效');
}
}
private async assertClassroomAvailable(classroomId: number) {
const classroom = await this.classroomRepo.findOne({ where: { id: classroomId } });
if (!classroom) throw new NotFoundException('教室不存在');
@@ -216,11 +226,12 @@ export class SchedulesService {
const existing = await this.scheduleRepo.findOne({ where: { id } });
if (!existing) throw new NotFoundException('排课记录不存在');
// If classroom, weekDay, or times are changing, check conflicts excluding self
const nextStatus = dto.status ?? existing.status;
this.assertValidScheduleStatus(nextStatus);
// If classroom, weekDay, or times are changing, check conflicts excluding self.
// Inactive/cancelled schedules preserve history but no longer occupy classrooms.
const classroomId = dto.classroomId ?? existing.classroomId;
if (dto.classroomId !== undefined && dto.classroomId !== existing.classroomId) {
await this.assertClassroomAvailable(dto.classroomId);
}
const weekDay = dto.weekDay ?? existing.weekDay;
const startTime = dto.startTime ?? existing.startTime;
const endTime = dto.endTime ?? existing.endTime;
@@ -228,17 +239,27 @@ export class SchedulesService {
const endDate = dto.endDate ?? existing.endDate;
this.assertValidScheduleRange(startTime, endTime, startDate, endDate);
const normalized = await this.normalizeTeacherForSchedule({
...dto,
classId: dto.classId ?? existing.classId ?? undefined,
subject: dto.subject ?? existing.subject,
});
if (dto.teacherId === undefined && normalized.teacherId !== undefined) {
dto.teacherId = normalized.teacherId;
if (nextStatus === ACTIVE_SCHEDULE_STATUS) {
if (dto.classroomId !== undefined && dto.classroomId !== existing.classroomId) {
await this.assertClassroomAvailable(dto.classroomId);
} else if (existing.status !== ACTIVE_SCHEDULE_STATUS) {
await this.assertClassroomAvailable(classroomId);
}
}
if (nextStatus === ACTIVE_SCHEDULE_STATUS) {
const normalized = await this.normalizeTeacherForSchedule({
...dto,
classId: dto.classId ?? existing.classId ?? undefined,
subject: dto.subject ?? existing.subject,
});
if (dto.teacherId === undefined && normalized.teacherId !== undefined) {
dto.teacherId = normalized.teacherId;
}
const teacherId = dto.teacherId ?? existing.teacherId;
await this.assertTeacherAssignedToClass(dto.classId ?? existing.classId, teacherId);
await this.checkConflict(classroomId, weekDay, startTime, endTime, startDate, endDate, id);
}
const teacherId = dto.teacherId ?? existing.teacherId;
await this.assertTeacherAssignedToClass(dto.classId ?? existing.classId, teacherId);
await this.checkConflict(classroomId, weekDay, startTime, endTime, startDate, endDate, id);
await this.scheduleRepo.update(id, dto);
return this.findOne(id);
@@ -247,18 +268,12 @@ export class SchedulesService {
async remove(id: number) {
const schedule = await this.scheduleRepo.findOne({ where: { id } });
if (!schedule) throw new NotFoundException('排课记录不存在');
const sessionCount = await this.attendanceSessionRepo.count({
where: { scheduleId: id },
});
if (sessionCount > 0) {
throw new ConflictException(
`无法删除已产生 ${sessionCount} 个考勤场次的排课。请先取消或停用排课以保护历史考勤数据。`,
);
if (schedule.status !== ACTIVE_SCHEDULE_STATUS) {
throw new BadRequestException('排课已停用');
}
await this.scheduleRepo.remove(schedule);
return { success: true };
await this.scheduleRepo.update(id, { status: 'inactive' });
return { success: true, message: '排课已停用(历史考勤记录已保留)' };
}
async checkConflict(
@@ -277,7 +292,7 @@ export class SchedulesService {
.createQueryBuilder('cs')
.where('cs.classroomId = :classroomId', { classroomId })
.andWhere('cs.weekDay = :weekDay', { weekDay })
.andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS })
.andWhere('cs.startTime < :bufferedEndTime', { bufferedEndTime })
.andWhere('cs.endTime > :bufferedStartTime', { bufferedStartTime })
.andWhere('cs.startDate <= :endDate', { endDate })
@@ -323,7 +338,7 @@ export class SchedulesService {
}
const schedules = await qb
.andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS })
.orderBy('cs.weekDay', 'ASC')
.addOrderBy('cs.startTime', 'ASC')
.getMany();
@@ -356,7 +371,7 @@ export class SchedulesService {
const qb = this.scheduleRepo
.createQueryBuilder('cs')
.where('cs.classroomId = :classroomId', { classroomId })
.andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS })
.andWhere('cs.scheduleType IN (:...scheduleTypes)', {
scheduleTypes: ['INTERNAL', 'RENTAL'],
});

View File

@@ -333,7 +333,7 @@ export class StudentsController {
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '删除学生',
action: '归档学生',
targetId: id,
targetType: 'student',
ipAddress,
@@ -351,7 +351,7 @@ export class StudentsController {
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '批量删除学生',
action: '批量归档学生',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,

View File

@@ -56,13 +56,13 @@ DROP TABLE IF EXISTS `integration_config_detail`;
DROP TABLE IF EXISTS `integration_config`;
-- ---------- 表结构 ----------
CREATE TABLE IF NOT EXISTS `room_expenses` (`id` int NOT NULL AUTO_INCREMENT, `room_id` int NOT NULL, `expense_type` varchar(20) NOT NULL, `amount` decimal(10,2) NOT NULL, `period_start` date NOT NULL, `period_end` date NOT NULL, `description` text NULL, `recorded_by` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `room_expenses` (`id` int NOT NULL AUTO_INCREMENT, `room_id` int NOT NULL, `expense_type` varchar(20) NOT NULL, `amount` decimal(10,2) NOT NULL, `period_start` date NOT NULL, `period_end` date NOT NULL, `description` text NULL, `recorded_by` int NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `rooms` (`id` int NOT NULL AUTO_INCREMENT, `room_number` varchar(20) NOT NULL, `building` varchar(50) NULL, `floor` int NULL, `capacity` int NOT NULL, `status` varchar(20) NOT NULL DEFAULT 'available', `room_type` varchar(20) NULL, `rental_category` varchar(10) NOT NULL DEFAULT 'short', `monthly_rate` decimal(10,2) NOT NULL DEFAULT 0.00, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_8f7c6fa4c469bab1a06fe3e49f` (`room_number`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `organizations` (`id` int NOT NULL AUTO_INCREMENT, `public_id` varchar(36) NOT NULL, `code` varchar(50) NOT NULL, `name` varchar(100) NOT NULL, `is_host` tinyint NOT NULL DEFAULT 0, `contact_name` varchar(50) NULL, `phone` varchar(30) NULL, `color` varchar(20) NULL, `notes` text NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_0db5eda192bf60a02bd41931f8` (`public_id`), UNIQUE INDEX `IDX_7e27c3b62c681fbe3e2322535f` (`code`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `beds` (`id` int NOT NULL AUTO_INCREMENT, `room_id` int NOT NULL, `bed_number` varchar(20) NOT NULL, `status` varchar(20) NOT NULL DEFAULT 'available', `notes` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_95e9ba0a907346ef7b0d5ca488` (`room_id`, `bed_number`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `lockers` (`id` int NOT NULL AUTO_INCREMENT, `room_id` int NOT NULL, `locker_number` varchar(20) NOT NULL, `status` varchar(20) NOT NULL DEFAULT 'available', `notes` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_8bc984d80d58c6909f738d8282` (`room_id`, `locker_number`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `occupancies` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `room_id` int NOT NULL, `check_in_date` date NOT NULL, `check_out_date` date NULL, `billing_start_date` date NOT NULL, `billing_end_date` date NULL, `check_out_reason` varchar(100) NULL, `notes` text NULL, `bed_id` int NULL, `locker_id` int NULL, `stay_type` varchar(10) NOT NULL DEFAULT 'short', `responsible_organization_id` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `personal_expenses` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `room_id` int NULL, `expense_type` varchar(20) NOT NULL, `amount` decimal(10,2) NOT NULL, `expense_date` date NOT NULL, `description` text NULL, `recorded_by` int NULL, `bill_id` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `occupancies` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `room_id` int NOT NULL, `check_in_date` date NOT NULL, `check_out_date` date NULL, `billing_start_date` date NOT NULL, `billing_end_date` date NULL, `check_out_reason` varchar(100) NULL, `notes` text NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `bed_id` int NULL, `locker_id` int NULL, `stay_type` varchar(10) NOT NULL DEFAULT 'short', `responsible_organization_id` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `personal_expenses` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `room_id` int NULL, `expense_type` varchar(20) NOT NULL, `amount` decimal(10,2) NOT NULL, `expense_date` date NOT NULL, `description` text NULL, `recorded_by` int NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `bill_id` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `bill_items` (`id` int NOT NULL AUTO_INCREMENT, `bill_id` int NOT NULL, `room_id` int NULL, `expense_type` varchar(20) NULL, `description` varchar(200) NULL, `days` int NULL, `total_room_days` int NULL, `room_total_amount` decimal(10,2) NULL, `student_amount` decimal(10,2) NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `bills` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `period_start` date NOT NULL, `period_end` date NOT NULL, `shared_amount` decimal(10,2) NOT NULL DEFAULT 0.00, `personal_amount` decimal(10,2) NOT NULL DEFAULT 0.00, `total_amount` decimal(10,2) NOT NULL DEFAULT 0.00, `source` varchar(30) NOT NULL DEFAULT 'batch', `paid_amount` decimal(10,2) NOT NULL DEFAULT 0.00, `outstanding_amount` decimal(10,2) NOT NULL DEFAULT 0.00, `status` varchar(20) NOT NULL DEFAULT 'unpaid', `cancelled_at` datetime NULL, `cancel_reason` varchar(300) NULL, `generated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `permissions` (`id` int NOT NULL AUTO_INCREMENT, `code` varchar(50) NOT NULL, `name` varchar(50) NOT NULL, `group` varchar(30) NOT NULL, `description` varchar(200) NULL, UNIQUE INDEX `IDX_8dad765629e83229da6feda1c1` (`code`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
@@ -88,10 +88,10 @@ CREATE TABLE IF NOT EXISTS `expense_types` (`id` int NOT NULL AUTO_INCREMENT, `c
CREATE TABLE IF NOT EXISTS `notifications` (`id` int NOT NULL AUTO_INCREMENT, `recipient_id` int NOT NULL, `type` varchar(30) NOT NULL, `title` varchar(200) NOT NULL, `content` text NULL, `link` varchar(500) NULL, `is_read` tinyint NOT NULL DEFAULT 0, `read_at` datetime NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `student_profiles` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `target_college` varchar(100) NULL, `target_major` varchar(100) NULL, `subject_direction` varchar(50) NULL, `grade` varchar(20) NULL, `profile_date` date NULL, `notes` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_4cedc08d3dc1f2c2da8a12f7a8` (`student_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `student_enrollments` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `course_category` varchar(50) NULL, `class_type` varchar(50) NULL, `class_name` varchar(100) NULL, `head_teacher` varchar(50) NULL, `subject_teacher` varchar(50) NULL, `start_date` date NULL, `end_date` date NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `exam_scores` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `enrollment_id` int NULL, `exam_type` varchar(50) NULL, `exam_name` varchar(100) NULL, `subject` varchar(50) NULL, `score` decimal(5,2) NULL, `class_avg` decimal(5,2) NULL, `rank` int NULL, `exam_date` date NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `learning_records` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `record_date` date NULL, `record_type` varchar(50) NULL, `content` text NULL, `follow_up_method` varchar(50) NULL, `next_step` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `exam_scores` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `enrollment_id` int NULL, `exam_type` varchar(50) NULL, `exam_name` varchar(100) NULL, `subject` varchar(50) NULL, `score` decimal(5,2) NULL, `class_avg` decimal(5,2) NULL, `rank` int NULL, `exam_date` date NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `learning_records` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `record_date` date NULL, `record_type` varchar(50) NULL, `content` text NULL, `follow_up_method` varchar(50) NULL, `next_step` text NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `result_archives` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `culture_final_score` decimal(5,2) NULL, `professional_final_score` decimal(5,2) NULL, `admission_status` varchar(50) NULL, `admitted_college` varchar(100) NULL, `admitted_major` varchar(100) NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_377bba8eb6a027eecd9737d4ed` (`student_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `archive_attachments` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `category` varchar(50) NULL, `file_name` varchar(255) NULL, `file_path` varchar(500) NULL, `file_size` int NULL, `mime_type` varchar(100) NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `archive_attachments` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `category` varchar(50) NULL, `file_name` varchar(255) NULL, `file_path` varchar(500) NULL, `file_size` int NULL, `mime_type` varchar(100) NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `student_ding_mapping` (`id` int NOT NULL AUTO_INCREMENT, `ding_user_id` varchar(100) NOT NULL, `student_id` int NOT NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_0d1ec47e2f901d37e3b6e56331` (`ding_user_id`), UNIQUE INDEX `IDX_f9ba15ff04de8ffbd8679ae9db` (`student_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `ai_config` (`id` int NOT NULL AUTO_INCREMENT, `singleton_key` varchar(20) NOT NULL DEFAULT 'GLOBAL', `provider` varchar(50) NOT NULL DEFAULT 'OPENAI', `base_url` varchar(500) NULL, `encrypted_api_key` text NULL, `api_key_iv` varchar(50) NULL, `api_key_auth_tag` varchar(50) NULL, `key_last4` varchar(4) NULL, `default_model` varchar(100) NULL, `enabled` tinyint NOT NULL DEFAULT 0, `timeout_ms` int NOT NULL DEFAULT '30000', `verified` tinyint NOT NULL DEFAULT 0, `last_tested_at` datetime NULL, `last_test_latency_ms` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `uq_ai_config_singleton` (`singleton_key`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `student_wallets` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `balance` decimal(12,2) NOT NULL DEFAULT 0.00, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_07a434ad1a960d506386754d59` (`student_id`), UNIQUE INDEX `REL_07a434ad1a960d506386754d59` (`student_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
@@ -263,39 +263,39 @@ INSERT INTO `permissions` (`id`, `code`, `name`, `group`) VALUES
('8', 'teacher:edit', '编辑教师', 'teacher'),
('9', 'student:create', '新增学生', 'student'),
('10', 'student:edit', '编辑学生', 'student'),
('11', 'student:delete', '删除学生', 'student'),
('11', 'student:delete', '归档学生', 'student'),
('12', 'student:import', '导入学生', 'student'),
('13', 'student:export', '导出学生', 'student'),
('14', 'room:view', '查看宿舍', 'room'),
('15', 'room:create', '新增宿舍', 'room'),
('16', 'room:edit', '编辑宿舍', 'room'),
('17', 'room:delete', '删除宿舍', 'room'),
('17', 'room:delete', '归档宿舍', 'room'),
('18', 'occupancy:view', '查看入住', 'occupancy'),
('19', 'occupancy:checkin', '办理入住', 'occupancy'),
('20', 'occupancy:checkout', '办理退宿', 'occupancy'),
('21', 'occupancy:transfer', '调换宿舍', 'occupancy'),
('22', 'occupancy:delete', '删除入住记录', 'occupancy'),
('22', 'occupancy:delete', '归档入住记录', 'occupancy'),
('23', 'expense:view', '查看费用', 'expense'),
('24', 'expense:create', '录入费用', 'expense'),
('25', 'expense:edit', '编辑费用', 'expense'),
('26', 'expense:delete', '删除费用', 'expense'),
('26', 'expense:delete', '归档费用', 'expense'),
('27', 'bill:view', '查看账单', 'bill'),
('28', 'bill:generate', '生成账单', 'bill'),
('29', 'bill:confirm', '确认账单', 'bill'),
('30', 'bill:delete', '删除账单', 'bill'),
('30', 'bill:delete', '归档账单', 'bill'),
('31', 'bill:export-excel', '导出 Excel', 'bill'),
('32', 'bill:export-pdf', '导出 PDF', 'bill'),
('33', 'deposit:view', '查看押金', 'deposit'),
('34', 'deposit:create', '新增押金', 'deposit'),
('35', 'deposit:edit', '编辑押金', 'deposit'),
('36', 'deposit:delete', '删除押金', 'deposit'),
('36', 'deposit:delete', '归档押金', 'deposit'),
('37', 'deposit:refund', '直接退还押金', 'deposit'),
('38', 'wallet:view', '查看学生余额', 'wallet'),
('39', 'wallet:edit', '充值和调账', 'wallet'),
('40', 'classroom:view', '查看教室', 'classroom'),
('41', 'classroom:create', '新增教室', 'classroom'),
('42', 'classroom:edit', '编辑教室', 'classroom'),
('43', 'classroom:delete', '删除教室', 'classroom'),
('43', 'classroom:delete', '归档教室', 'classroom'),
('44', 'organization:view', '查看机构', 'organization'),
('45', 'organization:create', '新增机构', 'organization'),
('46', 'organization:edit', '编辑机构', 'organization'),
@@ -303,26 +303,26 @@ INSERT INTO `permissions` (`id`, `code`, `name`, `group`) VALUES
('48', 'rental:view', '查看租赁订单', 'rental'),
('49', 'rental:create', '新增租赁订单', 'rental'),
('50', 'rental:edit', '编辑租赁订单', 'rental'),
('51', 'rental:delete', '删除租赁订单', 'rental'),
('51', 'rental:delete', '归档租赁订单', 'rental'),
('52', 'log:view', '查看操作日志', 'log'),
('53', 'log:create', '写入操作日志', 'log'),
('54', 'user:view', '查看用户', 'user'),
('55', 'user:create', '创建用户', 'user'),
('56', 'user:edit', '编辑用户', 'user'),
('57', 'user:delete', '删除用户', 'user'),
('57', 'user:delete', '归档用户', 'user'),
('58', 'user:reset-password', '重置密码', 'user'),
('59', 'role:view', '查看角色', 'role'),
('60', 'role:create', '创建角色', 'role'),
('61', 'role:edit', '编辑角色', 'role'),
('62', 'role:delete', '删除角色', 'role'),
('62', 'role:delete', '停用角色', 'role'),
('63', 'class:view', '查看班级', 'class'),
('64', 'class:create', '创建班级', 'class'),
('65', 'class:edit', '编辑班级', 'class'),
('66', 'class:delete', '删除班级', 'class'),
('66', 'class:delete', '归档班级', 'class'),
('67', 'schedule:view', '查看排课', 'schedule'),
('68', 'schedule:create', '创建排课', 'schedule'),
('69', 'schedule:edit', '编辑排课', 'schedule'),
('70', 'schedule:delete', '删除排课', 'schedule'),
('70', 'schedule:delete', '停用排课', 'schedule'),
('71', 'attendance:view', '查看考勤', 'attendance'),
('72', 'attendance:create', '新增考勤', 'attendance'),
('73', 'attendance:edit', '编辑全部考勤', 'attendance'),