forked from wangziqi/gongxue-base
Merge pull request #20: refine deposit workflows and RBAC permissions
This commit is contained in:
@@ -9,7 +9,7 @@ export function getAttendanceExperience(
|
|||||||
): AttendanceExperience {
|
): AttendanceExperience {
|
||||||
const domains = getRoleDomains(roles, permissions);
|
const domains = getRoleDomains(roles, permissions);
|
||||||
if (
|
if (
|
||||||
permissions.includes('attendance:manage') ||
|
permissions.includes('attendance:edit') ||
|
||||||
domains.has('academic') ||
|
domains.has('academic') ||
|
||||||
domains.has('super')
|
domains.has('super')
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ describe('deposit student option', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes room type when available', () => {
|
||||||
|
expect(buildDepositStudentOption({ id: 23, name: '张三', studentNo: 'S2026001', roomType: '四人间' })).toEqual({
|
||||||
|
value: 23,
|
||||||
|
label: '张三 (S2026001) - 四人间',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ export interface DepositStudentLookup {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
studentNo?: string | null;
|
studentNo?: string | null;
|
||||||
|
roomType?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildDepositStudentOption = (student: DepositStudentLookup) => ({
|
export const buildDepositStudentOption = (student: DepositStudentLookup) => ({
|
||||||
value: student.id,
|
value: student.id,
|
||||||
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
|
label: `${student.name} (${student.studentNo || `#${student.id}`})${student.roomType ? ` - ${student.roomType}` : ''}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const buildDepositStudentOptions = (students: DepositStudentLookup[]) =>
|
export const buildDepositStudentOptions = (students: DepositStudentLookup[]) =>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -14,12 +14,12 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Empty,
|
Empty,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { PlusOutlined, InboxOutlined, DollarOutlined } from '@ant-design/icons';
|
import { PlusOutlined, InboxOutlined, DollarOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { buildDepositStudentOptions } from './deposit-student-option';
|
import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option';
|
||||||
|
|
||||||
const statusMap: Record<string, { text: string; color: string }> = {
|
const statusMap: Record<string, { text: string; color: string }> = {
|
||||||
paid: { text: '有余额', color: 'green' },
|
paid: { text: '有余额', color: 'green' },
|
||||||
@@ -32,49 +32,141 @@ const installmentStatusMap: Record<string, { text: string; color: string }> = {
|
|||||||
paid: { text: '已缴', color: 'green' },
|
paid: { text: '已缴', color: 'green' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const roomTypeOptions = [
|
||||||
|
{ value: '单人间', label: '单人间' },
|
||||||
|
{ value: '四人间', label: '四人间' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const suggestedDepositByRoomType: Record<string, number> = {
|
||||||
|
单人间: 200,
|
||||||
|
四人间: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DepositRecord {
|
||||||
|
id: number;
|
||||||
|
studentId: number;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
paidDate: string;
|
||||||
|
refundDate?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
installments?: Array<{ id: number; amount: number; dueDate: string; paidDate?: string | null; status: string }>;
|
||||||
|
student?: DepositStudentLookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EligibleStudent {
|
||||||
|
studentId: number;
|
||||||
|
studentName: string;
|
||||||
|
studentNo?: string | null;
|
||||||
|
roomId: number;
|
||||||
|
roomNumber: string;
|
||||||
|
building?: string | null;
|
||||||
|
roomType?: string | null;
|
||||||
|
capacity: number;
|
||||||
|
depositAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
const isFormValidationError = (error: unknown) =>
|
const isFormValidationError = (error: unknown) =>
|
||||||
typeof error === 'object'
|
typeof error === 'object'
|
||||||
&& error !== null
|
&& error !== null
|
||||||
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
|
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
|
||||||
|
|
||||||
const DepositsPage: React.FC = () => {
|
const DepositsPage: React.FC = () => {
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<DepositRecord[]>([]);
|
||||||
const [students, setStudents] = useState<any[]>([]);
|
const [students, setStudents] = useState<DepositStudentLookup[]>([]);
|
||||||
|
const [eligibleStudents, setEligibleStudents] = useState<EligibleStudent[]>([]);
|
||||||
|
const [selectedEligibleStudentIds, setSelectedEligibleStudentIds] = useState<number[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [eligibleLoading, setEligibleLoading] = useState(false);
|
||||||
const [createModal, setCreateModal] = useState(false);
|
const [createModal, setCreateModal] = useState(false);
|
||||||
const [refundModal, setRefundModal] = useState<any>(null);
|
const [batchModal, setBatchModal] = useState(false);
|
||||||
const [detailModal, setDetailModal] = useState<any>(null);
|
const [refundModal, setRefundModal] = useState<DepositRecord | null>(null);
|
||||||
|
const [detailModal, setDetailModal] = useState<DepositRecord | null>(null);
|
||||||
const [installmentModal, setInstallmentModal] = useState<number | null>(null);
|
const [installmentModal, setInstallmentModal] = useState<number | null>(null);
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
|
const [batchForm] = Form.useForm();
|
||||||
const [refundForm] = Form.useForm();
|
const [refundForm] = Form.useForm();
|
||||||
const [installmentForm] = Form.useForm();
|
const [installmentForm] = Form.useForm();
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [filterRoomType, setFilterRoomType] = useState<string | undefined>(undefined);
|
||||||
|
const [batchRoomType, setBatchRoomType] = useState<string>('四人间');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [d, s]: any[] = await Promise.all([
|
const [d, s] = await Promise.all([
|
||||||
api.get('/deposits'),
|
api.get<DepositRecord[]>('/deposits'),
|
||||||
api.get('/deposits/student-lookups'),
|
api.get<DepositStudentLookup[]>('/deposits/student-lookups'),
|
||||||
]);
|
]);
|
||||||
setData(d);
|
setData(d);
|
||||||
setStudents(s);
|
setStudents(s);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '加载失败,请稍后重试');
|
message.error(e?.message || '加载失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
const fetchEligibleStudents = useCallback(async (roomType?: string) => {
|
||||||
|
setEligibleLoading(true);
|
||||||
|
try {
|
||||||
|
const params = roomType ? `?roomType=${encodeURIComponent(roomType)}` : '';
|
||||||
|
const rows = await api.get<EligibleStudent[]>(`/deposits/eligible-students${params}`);
|
||||||
|
setEligibleStudents(rows);
|
||||||
|
setSelectedEligibleStudentIds(rows.map((item) => item.studentId));
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '加载在住人员失败');
|
||||||
|
} finally {
|
||||||
|
setEligibleLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEligibleStudents(filterRoomType);
|
||||||
|
}, [fetchEligibleStudents, filterRoomType]);
|
||||||
|
|
||||||
|
const depositByStudentId = useMemo(() => {
|
||||||
|
const map = new Map<number, DepositRecord>();
|
||||||
|
data.forEach((item) => map.set(item.studentId, item));
|
||||||
|
return map;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
return data.filter((d: any) => {
|
if (filterRoomType) {
|
||||||
|
const s = searchText.trim().toLowerCase();
|
||||||
|
return eligibleStudents
|
||||||
|
.filter((item) => !s || item.studentName.toLowerCase().includes(s) || item.studentNo?.toLowerCase().includes(s))
|
||||||
|
.map((item) => {
|
||||||
|
const deposit = depositByStudentId.get(item.studentId);
|
||||||
|
return {
|
||||||
|
id: deposit?.id ?? `eligible-${item.studentId}`,
|
||||||
|
studentId: item.studentId,
|
||||||
|
amount: deposit?.amount ?? item.depositAmount ?? 0,
|
||||||
|
status: deposit?.status ?? 'unpaid',
|
||||||
|
paidDate: deposit?.paidDate ?? '',
|
||||||
|
refundDate: deposit?.refundDate,
|
||||||
|
notes: deposit?.notes,
|
||||||
|
installments: deposit?.installments ?? [],
|
||||||
|
student: {
|
||||||
|
id: item.studentId,
|
||||||
|
name: item.studentName,
|
||||||
|
studentNo: item.studentNo,
|
||||||
|
roomType: item.roomType,
|
||||||
|
},
|
||||||
|
roomNumber: item.roomNumber,
|
||||||
|
building: item.building,
|
||||||
|
roomType: item.roomType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.filter((d) => {
|
||||||
if (searchText) {
|
if (searchText) {
|
||||||
const s = searchText.toLowerCase();
|
const s = searchText.toLowerCase();
|
||||||
if (!d.student?.name?.toLowerCase().includes(s)) return false;
|
if (!d.student?.name?.toLowerCase().includes(s)) return false;
|
||||||
@@ -82,13 +174,30 @@ const DepositsPage: React.FC = () => {
|
|||||||
if (filterStatus && d.status !== filterStatus) return false;
|
if (filterStatus && d.status !== filterStatus) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [data, searchText, filterStatus]);
|
}, [data, depositByStudentId, eligibleStudents, filterRoomType, filterStatus, searchText]);
|
||||||
|
|
||||||
const studentOptions = useMemo(
|
const studentOptions = useMemo(
|
||||||
() => buildDepositStudentOptions(students),
|
() => buildDepositStudentOptions(students),
|
||||||
[students],
|
[students],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const openBatchModal = (roomType = filterRoomType || '四人间') => {
|
||||||
|
const amount = suggestedDepositByRoomType[roomType] ?? 100;
|
||||||
|
setBatchRoomType(roomType);
|
||||||
|
batchForm.resetFields();
|
||||||
|
batchForm.setFieldsValue({ roomType, amount, paidDate: dayjs() });
|
||||||
|
setBatchModal(true);
|
||||||
|
fetchEligibleStudents(roomType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchRoomTypeChange = (roomType: string) => {
|
||||||
|
setBatchRoomType(roomType);
|
||||||
|
batchForm.setFieldsValue({ amount: suggestedDepositByRoomType[roomType] ?? batchForm.getFieldValue('amount') ?? 100 });
|
||||||
|
fetchEligibleStudents(roomType);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -103,6 +212,36 @@ const DepositsPage: React.FC = () => {
|
|||||||
setCreateModal(false);
|
setCreateModal(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
fetchData();
|
fetchData();
|
||||||
|
fetchEligibleStudents(filterRoomType);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!isFormValidationError(e)) {
|
||||||
|
message.error(e?.message || '操作失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchCreate = async () => {
|
||||||
|
if (selectedEligibleStudentIds.length === 0) {
|
||||||
|
message.warning('请选择至少一名学生');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const values = await batchForm.validateFields();
|
||||||
|
await api.post('/deposits/batch', {
|
||||||
|
studentIds: selectedEligibleStudentIds,
|
||||||
|
amount: values.amount,
|
||||||
|
paidDate: values.paidDate.format('YYYY-MM-DD'),
|
||||||
|
notes: values.notes,
|
||||||
|
roomType: values.roomType,
|
||||||
|
});
|
||||||
|
message.success(`已为 ${selectedEligibleStudentIds.length} 人批量收取押金`);
|
||||||
|
setBatchModal(false);
|
||||||
|
batchForm.resetFields();
|
||||||
|
await fetchData();
|
||||||
|
fetchEligibleStudents(filterRoomType);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!isFormValidationError(e)) {
|
if (!isFormValidationError(e)) {
|
||||||
message.error(e?.message || '操作失败');
|
message.error(e?.message || '操作失败');
|
||||||
@@ -113,6 +252,7 @@ const DepositsPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRefund = async () => {
|
const handleRefund = async () => {
|
||||||
|
if (!refundModal) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const values = await refundForm.validateFields();
|
const values = await refundForm.validateFields();
|
||||||
@@ -124,6 +264,7 @@ const DepositsPage: React.FC = () => {
|
|||||||
setRefundModal(null);
|
setRefundModal(null);
|
||||||
refundForm.resetFields();
|
refundForm.resetFields();
|
||||||
fetchData();
|
fetchData();
|
||||||
|
fetchEligibleStudents(filterRoomType);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!isFormValidationError(e)) {
|
if (!isFormValidationError(e)) {
|
||||||
message.error(e?.message || '操作失败');
|
message.error(e?.message || '操作失败');
|
||||||
@@ -176,32 +317,39 @@ const DepositsPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo(() => [
|
const columns = useMemo(() => [
|
||||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
{ title: '学生', width: 120, render: (_: unknown, r: any) => r.student?.name || '-' },
|
||||||
{ title: '当前可用押金', dataIndex: 'amount', width: 130, render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
{ title: '当前可用押金', dataIndex: 'amount', width: 130, render: (v: number) => `¥${Number(v || 0).toFixed(2)}` },
|
||||||
{ title: '最近收取日期', dataIndex: 'paidDate', width: 120 },
|
{ title: '房间', width: 120, render: (_: unknown, r: any) => r.roomNumber ? `${r.building ? `${r.building}-` : ''}${r.roomNumber}` : '-' },
|
||||||
|
{ title: '房型', dataIndex: 'roomType', width: 100, render: (v: string) => v || '-' },
|
||||||
|
{ title: '最近收取日期', dataIndex: 'paidDate', width: 120, render: (v: string) => v || '-' },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
render: (s: string) => s === 'unpaid'
|
||||||
|
? <Tag color="default">未缴</Tag>
|
||||||
|
: <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
||||||
},
|
},
|
||||||
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: any) => v || '-' },
|
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: unknown) => v || '-' },
|
||||||
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: any) => v || '-' },
|
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 240,
|
width: 240,
|
||||||
render: (_: any, record: any) => (
|
render: (_: unknown, record: any) => {
|
||||||
<Space>
|
const hasDeposit = typeof record.id === 'number';
|
||||||
<PermissionButton
|
return (
|
||||||
permission="deposit:view"
|
<Space>
|
||||||
size="small"
|
{hasDeposit && (
|
||||||
onClick={() => {
|
<PermissionButton
|
||||||
setDetailModal(record);
|
permission="deposit:view"
|
||||||
}}
|
size="small"
|
||||||
>
|
onClick={() => {
|
||||||
详情
|
setDetailModal(record);
|
||||||
</PermissionButton>
|
}}
|
||||||
{record.status === 'paid' && (
|
>
|
||||||
<>
|
详情
|
||||||
|
</PermissionButton>
|
||||||
|
)}
|
||||||
|
{record.status === 'paid' && hasDeposit && (
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="deposit:refund"
|
permission="deposit:refund"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -213,97 +361,171 @@ const DepositsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
退还
|
退还
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</>
|
)}
|
||||||
)}
|
{hasDeposit && (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确定归档?"
|
title="确定归档?"
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/deposits/${record.id}`);
|
await api.delete(`/deposits/${record.id}`);
|
||||||
message.success('归档成功');
|
message.success('归档成功');
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (e: any) {
|
fetchEligibleStudents(filterRoomType);
|
||||||
message.error(e?.message || '归档失败');
|
} catch (e: any) {
|
||||||
}
|
message.error(e?.message || '归档失败');
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<PermissionButton
|
>
|
||||||
permission="deposit:delete"
|
<PermissionButton
|
||||||
size="small"
|
permission="deposit:delete"
|
||||||
danger
|
size="small"
|
||||||
icon={<InboxOutlined />}
|
danger
|
||||||
>
|
icon={<InboxOutlined />}
|
||||||
归档
|
>
|
||||||
</PermissionButton>
|
归档
|
||||||
</Popconfirm>
|
</PermissionButton>
|
||||||
</Space>
|
</Popconfirm>
|
||||||
),
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
], [fetchData]);
|
], [fetchData, fetchEligibleStudents, filterRoomType, refundForm]);
|
||||||
|
|
||||||
|
|
||||||
|
const eligibleColumns = [
|
||||||
|
{ title: '学生', render: (_: unknown, r: EligibleStudent) => `${r.studentName} (${r.studentNo || `#${r.studentId}`})` },
|
||||||
|
{ title: '房间', render: (_: unknown, r: EligibleStudent) => `${r.building ? `${r.building}-` : ''}${r.roomNumber}` },
|
||||||
|
{ title: '房型', dataIndex: 'roomType' },
|
||||||
|
{ title: '当前押金', dataIndex: 'depositAmount', render: (v: number) => `¥${Number(v || 0).toFixed(2)}` },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space wrap>
|
|
||||||
<Input.Search
|
|
||||||
placeholder="搜索学生姓名"
|
|
||||||
allowClear
|
|
||||||
style={{ width: 180 }}
|
|
||||||
onSearch={(v) => setSearchText(v)}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (!e.target.value) setSearchText('');
|
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<Space wrap>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="搜索学生姓名/学号"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 180 }}
|
||||||
|
onSearch={(v) => setSearchText(v)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchText(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="房型筛选"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 130 }}
|
||||||
|
value={filterRoomType}
|
||||||
|
onChange={(v) => setFilterRoomType(v)}
|
||||||
|
options={roomTypeOptions}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="状态筛选"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
value={filterStatus}
|
||||||
|
disabled={!!filterRoomType}
|
||||||
|
onChange={(v) => setFilterStatus(v)}
|
||||||
|
options={[
|
||||||
|
{ value: 'paid', label: '有余额' },
|
||||||
|
{ value: 'refunded', label: '已全退' },
|
||||||
|
{ value: 'depleted', label: '已扣完' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Space wrap>
|
||||||
|
<PermissionButton
|
||||||
|
permission="deposit:create"
|
||||||
|
icon={<TeamOutlined />}
|
||||||
|
onClick={() => openBatchModal()}
|
||||||
|
>
|
||||||
|
按房型批量收取
|
||||||
|
</PermissionButton>
|
||||||
|
<PermissionButton
|
||||||
|
permission="deposit:create"
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
createForm.resetFields();
|
||||||
|
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
|
||||||
|
setCreateModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
收取押金
|
||||||
|
</PermissionButton>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filteredData}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading || (!!filterRoomType && eligibleLoading)}
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
|
pagination={{
|
||||||
|
defaultPageSize: 15,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [15, 30, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||||
/>
|
/>
|
||||||
<Select
|
|
||||||
placeholder="状态筛选"
|
{/* Batch Create Modal */}
|
||||||
allowClear
|
<Modal
|
||||||
style={{ width: 120 }}
|
title="按房型批量收取押金"
|
||||||
value={filterStatus}
|
open={batchModal}
|
||||||
onChange={(v) => setFilterStatus(v)}
|
onOk={handleBatchCreate}
|
||||||
options={[
|
onCancel={() => setBatchModal(false)}
|
||||||
{ value: 'paid', label: '有余额' },
|
okText="确认批量收取"
|
||||||
{ value: 'refunded', label: '已全退' },
|
confirmLoading={saving}
|
||||||
{ value: 'depleted', label: '已扣完' },
|
okButtonProps={{ disabled: selectedEligibleStudentIds.length === 0 }}
|
||||||
]}
|
width={760}
|
||||||
/>
|
>
|
||||||
</Space>
|
<Form form={batchForm} layout="vertical">
|
||||||
<PermissionButton
|
<Space style={{ width: '100%' }} align="start" wrap>
|
||||||
permission="deposit:create"
|
<Form.Item name="roomType" label="房型" rules={[{ required: true, message: '请选择房型' }]}>
|
||||||
type="primary"
|
<Select style={{ width: 140 }} options={roomTypeOptions} onChange={handleBatchRoomTypeChange} />
|
||||||
icon={<PlusOutlined />}
|
</Form.Item>
|
||||||
onClick={() => {
|
<Form.Item name="amount" label="每人收取金额(元)" rules={[{ required: true, message: '请输入金额' }]}>
|
||||||
createForm.resetFields();
|
<InputNumber min={0.01} precision={2} style={{ width: 180 }} />
|
||||||
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
|
</Form.Item>
|
||||||
setCreateModal(true);
|
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||||
}}
|
<DatePicker style={{ width: 180 }} placeholder="选择收取日期" format="YYYY-MM-DD" />
|
||||||
>
|
</Form.Item>
|
||||||
收取押金
|
</Space>
|
||||||
</PermissionButton>
|
<Form.Item name="notes" label="备注">
|
||||||
</div>
|
<Input.TextArea rows={2} placeholder={`${batchRoomType}押金`} />
|
||||||
<Table
|
</Form.Item>
|
||||||
columns={columns}
|
</Form>
|
||||||
dataSource={filteredData}
|
<div style={{ marginBottom: 8 }}>
|
||||||
rowKey="id"
|
已选择 <strong>{selectedEligibleStudentIds.length}</strong> / {eligibleStudents.length} 人
|
||||||
loading={loading}
|
{suggestedDepositByRoomType[batchRoomType] && (
|
||||||
scroll={{ x: 1200 }}
|
<span style={{ color: '#999', marginLeft: 8 }}>建议金额:¥{suggestedDepositByRoomType[batchRoomType]}</span>
|
||||||
pagination={{
|
)}
|
||||||
defaultPageSize: 15,
|
</div>
|
||||||
showSizeChanger: true,
|
<Table
|
||||||
pageSizeOptions: [15, 30, 50, 100],
|
size="small"
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
columns={eligibleColumns}
|
||||||
}}
|
dataSource={eligibleStudents}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
rowKey="studentId"
|
||||||
/>
|
loading={eligibleLoading}
|
||||||
|
locale={{ emptyText: <Empty description="暂无符合条件的在住人员" /> }}
|
||||||
|
pagination={{ pageSize: 6, showSizeChanger: false }}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedEligibleStudentIds,
|
||||||
|
onChange: (keys) => setSelectedEligibleStudentIds(keys as number[]),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -327,10 +549,10 @@ const DepositsPage: React.FC = () => {
|
|||||||
options={studentOptions}
|
options={studentOptions}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
|
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
|
||||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true }]}>
|
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true }]}>
|
||||||
<DatePicker style={{ width: '100%' }} placeholder="选择收取日期" format="YYYY-MM-DD" />
|
<DatePicker style={{ width: '100%' }} placeholder="选择收取日期" format="YYYY-MM-DD" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="notes" label="备注">
|
<Form.Item name="notes" label="备注">
|
||||||
@@ -352,7 +574,7 @@ const DepositsPage: React.FC = () => {
|
|||||||
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
||||||
当前可用押金: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
|
当前可用押金: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
|
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
|
||||||
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
|
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="notes" label="备注">
|
<Form.Item name="notes" label="备注">
|
||||||
@@ -399,10 +621,10 @@ const DepositsPage: React.FC = () => {
|
|||||||
添加分期
|
添加分期
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</div>
|
</div>
|
||||||
{detailModal.installments?.length > 0 ? (
|
{detailModal.installments && detailModal.installments.length > 0 ? (
|
||||||
<List
|
<List
|
||||||
dataSource={detailModal.installments}
|
dataSource={detailModal.installments}
|
||||||
renderItem={(item: any) => (
|
renderItem={(item) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
actions={[
|
actions={[
|
||||||
item.status === 'pending' && (
|
item.status === 'pending' && (
|
||||||
@@ -418,13 +640,14 @@ const DepositsPage: React.FC = () => {
|
|||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
),
|
),
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
|
key="archive"
|
||||||
title="确定归档?"
|
title="确定归档?"
|
||||||
onConfirm={() => handleDeleteInstallment(item.id)}
|
onConfirm={() => handleDeleteInstallment(item.id)}
|
||||||
>
|
>
|
||||||
<PermissionButton key="del" permission="deposit:delete" size="small" danger icon={<InboxOutlined />}>
|
<PermissionButton key="del" permission="deposit:delete" size="small" danger icon={<InboxOutlined />}>
|
||||||
归档
|
归档
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</Popconfirm>
|
</Popconfirm>,
|
||||||
].filter(Boolean)}
|
].filter(Boolean)}
|
||||||
>
|
>
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
@@ -453,16 +676,14 @@ const DepositsPage: React.FC = () => {
|
|||||||
okText="确认"
|
okText="确认"
|
||||||
>
|
>
|
||||||
<Form form={installmentForm} layout="vertical">
|
<Form form={installmentForm} layout="vertical">
|
||||||
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
|
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
|
||||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="dueDate" label="到期日期" rules={[{ required: true }]}>
|
<Form.Item name="dueDate" label="到期日期" rules={[{ required: true }]}>
|
||||||
<DatePicker style={{ width: '100%' }} placeholder="选择到期日期" format="YYYY-MM-DD" />
|
<DatePicker style={{ width: '100%' }} placeholder="选择到期日期" format="YYYY-MM-DD" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,13 +37,9 @@ const PermissionsPage: React.FC = () => {
|
|||||||
class: '班级管理',
|
class: '班级管理',
|
||||||
schedule: '排课管理',
|
schedule: '排课管理',
|
||||||
attendance: '考勤管理',
|
attendance: '考勤管理',
|
||||||
learning: '学习记录',
|
|
||||||
exam: '考试管理',
|
|
||||||
sync: '数据同步',
|
sync: '数据同步',
|
||||||
integration: '集成配置',
|
integration: '集成配置',
|
||||||
department: '部门管理',
|
|
||||||
notification: '通知中心',
|
notification: '通知中心',
|
||||||
profile: '个人资料',
|
|
||||||
ai: 'AI 模型配置',
|
ai: 'AI 模型配置',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -118,18 +118,30 @@ const RolesPage: React.FC = () => {
|
|||||||
|
|
||||||
const groupNames: Record<string, string> = {
|
const groupNames: Record<string, string> = {
|
||||||
dashboard: '数据面板',
|
dashboard: '数据面板',
|
||||||
|
notification: '通知中心',
|
||||||
student: '学生管理',
|
student: '学生管理',
|
||||||
|
'student-scope': '学生范围权限',
|
||||||
|
teacher: '教师管理',
|
||||||
|
'teacher-workspace': '教师工作台',
|
||||||
room: '宿舍管理',
|
room: '宿舍管理',
|
||||||
occupancy: '入住管理',
|
occupancy: '入住管理',
|
||||||
expense: '费用管理',
|
expense: '费用管理',
|
||||||
bill: '账单管理',
|
bill: '账单管理',
|
||||||
deposit: '押金管理',
|
deposit: '押金管理',
|
||||||
|
wallet: '学生余额',
|
||||||
classroom: '教室管理',
|
classroom: '教室管理',
|
||||||
organization: '机构管理',
|
organization: '机构管理',
|
||||||
rental: '租赁订单',
|
rental: '租赁订单',
|
||||||
|
class: '班级管理',
|
||||||
|
schedule: '排课管理',
|
||||||
|
attendance: '考勤管理',
|
||||||
|
'attendance-scope': '考勤范围权限',
|
||||||
log: '操作日志',
|
log: '操作日志',
|
||||||
user: '用户管理',
|
user: '用户管理',
|
||||||
role: '角色管理',
|
role: '角色管理',
|
||||||
|
sync: '数据同步',
|
||||||
|
integration: '集成配置',
|
||||||
|
ai: 'AI 配置',
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo(() => [
|
const columns = useMemo(() => [
|
||||||
|
|||||||
@@ -236,8 +236,10 @@ export class AttendanceController {
|
|||||||
{ header: '时段', key: 'session', width: 15 },
|
{ header: '时段', key: 'session', width: 15 },
|
||||||
{ header: '状态', key: 'status', width: 10 },
|
{ header: '状态', key: 'status', width: 10 },
|
||||||
{ header: '来源', key: 'source', width: 10 },
|
{ header: '来源', key: 'source', width: 10 },
|
||||||
|
{ header: '打卡设备', key: 'punchDevice', width: 30 },
|
||||||
|
{ header: '打卡时间', key: 'punchTime', width: 20 },
|
||||||
{ header: '备注', key: 'remark', width: 30 },
|
{ header: '备注', key: 'remark', width: 30 },
|
||||||
{ header: '打卡时间', key: 'createdAt', width: 20 },
|
{ header: '归档时间', key: 'createdAt', width: 20 },
|
||||||
];
|
];
|
||||||
ws.getRow(1).font = { bold: true };
|
ws.getRow(1).font = { bold: true };
|
||||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||||
@@ -250,6 +252,10 @@ export class AttendanceController {
|
|||||||
session: record.session || '',
|
session: record.session || '',
|
||||||
status: record.status || '',
|
status: record.status || '',
|
||||||
source: record.source || '',
|
source: record.source || '',
|
||||||
|
punchDevice: record.punchDeviceName || record.punchDeviceId || '',
|
||||||
|
punchTime: record.punchTime
|
||||||
|
? record.punchTime.toISOString().replace('T', ' ').substring(0, 19)
|
||||||
|
: '',
|
||||||
remark: record.remark || '',
|
remark: record.remark || '',
|
||||||
createdAt: record.createdAt
|
createdAt: record.createdAt
|
||||||
? record.createdAt.toISOString().replace('T', ' ').substring(0, 19)
|
? record.createdAt.toISOString().replace('T', ' ').substring(0, 19)
|
||||||
|
|||||||
@@ -234,6 +234,106 @@ describe('AttendanceService — DingTalk raw query', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('AttendanceService — attendance device display mappings', () => {
|
||||||
|
function createHistoryQueryBuilder(records: AttendanceRecord[]) {
|
||||||
|
return {
|
||||||
|
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
|
skip: jest.fn().mockReturnThis(),
|
||||||
|
take: jest.fn().mockReturnThis(),
|
||||||
|
getManyAndCount: jest.fn().mockResolvedValue([records, records.length]),
|
||||||
|
getMany: jest.fn().mockResolvedValue(records),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServiceWithRecords(records: AttendanceRecord[]) {
|
||||||
|
const qb = createHistoryQueryBuilder(records);
|
||||||
|
const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||||
|
const attendanceDeviceRepo = {
|
||||||
|
find: jest.fn().mockImplementation(async (options: { where?: Record<string, unknown> }) => {
|
||||||
|
if (options.where && 'deviceSn' in options.where) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
deviceSn: 'ATM-01',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
classroomId: 8,
|
||||||
|
classroom: { id: 8, name: '一号教室' },
|
||||||
|
status: 'disabled',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new AttendanceService(
|
||||||
|
attendanceRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
attendanceDeviceRepo as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
return { service, qb, attendanceDeviceRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('maps history list punch device ids to configured attendance device names', async () => {
|
||||||
|
const record = {
|
||||||
|
id: 1,
|
||||||
|
classId: 8,
|
||||||
|
status: 'present',
|
||||||
|
source: 'dingtalk',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
punchDeviceName: '钉钉原始设备名',
|
||||||
|
} as AttendanceRecord;
|
||||||
|
const { service, attendanceDeviceRepo } = createServiceWithRecords([record]);
|
||||||
|
|
||||||
|
await expect(service.findAll({}, [8])).resolves.toMatchObject({
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
punchDeviceName: '东门考勤机 · 一号教室',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
expect(attendanceDeviceRepo.find).toHaveBeenCalledWith({
|
||||||
|
where: { deviceSn: expect.any(Object) },
|
||||||
|
relations: ['classroom'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps exported punch device ids to configured attendance device names', async () => {
|
||||||
|
const record = {
|
||||||
|
id: 2,
|
||||||
|
classId: 8,
|
||||||
|
status: 'present',
|
||||||
|
source: 'dingtalk',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
punchDeviceName: '钉钉原始设备名',
|
||||||
|
} as AttendanceRecord;
|
||||||
|
const { service } = createServiceWithRecords([record]);
|
||||||
|
|
||||||
|
await expect(service.findAllForExport({}, [8])).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
punchDeviceName: '东门考勤机 · 一号教室',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── Session serialization tests ──
|
// ── Session serialization tests ──
|
||||||
function deferred<T>(): {
|
function deferred<T>(): {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export class AttendanceService {
|
|||||||
const devicesBySn = new Map<string, AttendanceDevice>();
|
const devicesBySn = new Map<string, AttendanceDevice>();
|
||||||
if (sns.length > 0) {
|
if (sns.length > 0) {
|
||||||
const devices = await this.attendanceDeviceRepo.find({
|
const devices = await this.attendanceDeviceRepo.find({
|
||||||
where: { deviceSn: In(sns), status: 'active' },
|
where: { deviceSn: In(sns) },
|
||||||
relations: ['classroom'],
|
relations: ['classroom'],
|
||||||
});
|
});
|
||||||
for (const device of devices) devicesBySn.set(device.deviceSn, device);
|
for (const device of devices) devicesBySn.set(device.deviceSn, device);
|
||||||
@@ -907,7 +907,7 @@ export class AttendanceService {
|
|||||||
qb.skip((page - 1) * pageSize).take(pageSize);
|
qb.skip((page - 1) * pageSize).take(pageSize);
|
||||||
|
|
||||||
const [list, total] = await qb.getManyAndCount();
|
const [list, total] = await qb.getManyAndCount();
|
||||||
return { list, total, page, pageSize };
|
return { list: await this.attachAttendanceDeviceMappings(list), total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Get distinct classes with attendance records ──
|
// ── Get distinct classes with attendance records ──
|
||||||
@@ -1053,7 +1053,8 @@ export class AttendanceService {
|
|||||||
|
|
||||||
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
|
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
|
||||||
|
|
||||||
return qb.getMany();
|
const records = await qb.getMany();
|
||||||
|
return this.attachAttendanceDeviceMappings(records);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAttendanceRecord(id: number) {
|
async findAttendanceRecord(id: number) {
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import { Repository } from 'typeorm';
|
|||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
import { DepositsService } from './deposits.service';
|
import { DepositsService } from './deposits.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationType } from '../entities/notification.entity';
|
|
||||||
import {
|
import {
|
||||||
|
BatchCreateDepositDto,
|
||||||
CreateDepositDto,
|
CreateDepositDto,
|
||||||
CreateDepositInstallmentDto,
|
CreateDepositInstallmentDto,
|
||||||
RefundDepositDto,
|
RefundDepositDto,
|
||||||
@@ -44,6 +44,13 @@ export class DepositsController {
|
|||||||
return this.service.getStudentLookups();
|
return this.service.getStudentLookups();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Get('eligible-students')
|
||||||
|
@RequirePermission('deposit:view')
|
||||||
|
getEligibleStudents(@Query('roomType') roomType?: string) {
|
||||||
|
return this.service.getEligibleStudents(roomType || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermission('deposit:view')
|
@RequirePermission('deposit:view')
|
||||||
findAll(
|
findAll(
|
||||||
@@ -99,6 +106,25 @@ export class DepositsController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Post('batch')
|
||||||
|
@RequirePermission('deposit:create')
|
||||||
|
async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: any) {
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
const result = await this.service.batchCreate(dto, req.user?.id);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '押金管理',
|
||||||
|
action: '批量收取押金',
|
||||||
|
targetType: 'deposit',
|
||||||
|
detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/installments')
|
@Post(':id/installments')
|
||||||
@RequirePermission('deposit:edit')
|
@RequirePermission('deposit:edit')
|
||||||
async addInstallment(
|
async addInstallment(
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
import { Deposit } from '../entities/deposit.entity';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||||
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
import { DepositsService } from './deposits.service';
|
import { DepositsService } from './deposits.service';
|
||||||
import { DepositsController } from './deposits.controller';
|
import { DepositsController } from './deposits.controller';
|
||||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule],
|
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student, Occupancy]), OperationLogsModule, NotificationsModule],
|
||||||
controllers: [DepositsController],
|
controllers: [DepositsController],
|
||||||
providers: [DepositsService],
|
providers: [DepositsService],
|
||||||
exports: [DepositsService],
|
exports: [DepositsService],
|
||||||
|
|||||||
98
apps/server/src/deposits/deposits.room-type.spec.ts
Normal file
98
apps/server/src/deposits/deposits.room-type.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { DepositsService } from './deposits.service';
|
||||||
|
|
||||||
|
const createQb = (rows: unknown[] = []) => ({
|
||||||
|
innerJoin: jest.fn().mockReturnThis(),
|
||||||
|
leftJoin: jest.fn().mockReturnThis(),
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
|
getRawMany: jest.fn().mockResolvedValue(rows),
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeService(rows: unknown[] = []) {
|
||||||
|
const qb = createQb(rows);
|
||||||
|
const repo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(async (value) => value),
|
||||||
|
};
|
||||||
|
const studentRepo = { findOne: jest.fn(async ({ where }: any) => ({ id: where.id })) };
|
||||||
|
const occupancyRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||||
|
const service = new DepositsService(repo as never, {} as never, studentRepo as never, occupancyRepo as never);
|
||||||
|
return { service, qb, repo, studentRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DepositsService room-type deposits', () => {
|
||||||
|
it('filters current occupants by room type with capacity fallback', async () => {
|
||||||
|
const { service, qb } = makeService([
|
||||||
|
{
|
||||||
|
studentId: 1,
|
||||||
|
studentName: '张三',
|
||||||
|
studentNo: 'S1',
|
||||||
|
roomId: 8,
|
||||||
|
roomNumber: '401',
|
||||||
|
building: 'A',
|
||||||
|
roomType: null,
|
||||||
|
capacity: 4,
|
||||||
|
depositAmount: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(service.getEligibleStudents('四人间')).resolves.toEqual([
|
||||||
|
{
|
||||||
|
studentId: 1,
|
||||||
|
studentName: '张三',
|
||||||
|
studentNo: 'S1',
|
||||||
|
roomId: 8,
|
||||||
|
roomNumber: '401',
|
||||||
|
building: 'A',
|
||||||
|
roomType: '四人间',
|
||||||
|
capacity: 4,
|
||||||
|
depositAmount: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(qb.where).toHaveBeenCalledWith('o.status = :activeStatus', { activeStatus: 'active' });
|
||||||
|
expect(qb.andWhere).toHaveBeenCalledWith('o.checkOutDate IS NULL');
|
||||||
|
expect(qb.andWhere).toHaveBeenCalledWith(
|
||||||
|
'(room.roomType = :roomType OR ((room.roomType IS NULL OR room.roomType = :emptyRoomType) AND room.capacity = :fallbackCapacity))',
|
||||||
|
{ roomType: '四人间', emptyRoomType: '', fallbackCapacity: 4 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates or accumulates deposits for a batch of selected students', async () => {
|
||||||
|
const { service, repo } = makeService();
|
||||||
|
const existing = { id: 1, studentId: 2, amount: 50, status: 'paid' };
|
||||||
|
repo.findOne.mockImplementation(async ({ where }: any) => {
|
||||||
|
if (where.id) return existing;
|
||||||
|
if (where.studentId === 2) return existing;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.batchCreate({
|
||||||
|
studentIds: [2, 3, 3],
|
||||||
|
amount: 100,
|
||||||
|
paidDate: '2026-07-17',
|
||||||
|
notes: '四人间押金',
|
||||||
|
}, 9);
|
||||||
|
|
||||||
|
expect(result.count).toBe(2);
|
||||||
|
expect(existing.amount).toBe(150);
|
||||||
|
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ studentId: 3, amount: 100 }));
|
||||||
|
expect(repo.save).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid batch amounts', async () => {
|
||||||
|
const { service, repo } = makeService();
|
||||||
|
|
||||||
|
await expect(service.batchCreate({
|
||||||
|
studentIds: [1],
|
||||||
|
amount: 0.004,
|
||||||
|
paidDate: '2026-07-17',
|
||||||
|
})).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,11 +4,38 @@ import { Repository } from 'typeorm';
|
|||||||
import { Deposit } from '../entities/deposit.entity';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||||
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
|
|
||||||
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
import { BatchCreateDepositDto, CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||||
|
|
||||||
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
|
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
|
||||||
|
|
||||||
|
const capacityRoomTypeText: Record<number, string> = {
|
||||||
|
1: '单人间',
|
||||||
|
2: '二人间',
|
||||||
|
3: '三人间',
|
||||||
|
4: '四人间',
|
||||||
|
5: '五人间',
|
||||||
|
6: '六人间',
|
||||||
|
8: '八人间',
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeRoomType = (roomType?: string | null, capacity?: number | string | null) => {
|
||||||
|
const trimmed = roomType?.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
const normalizedCapacity = Number(capacity);
|
||||||
|
return capacityRoomTypeText[normalizedCapacity] || (normalizedCapacity > 0 ? `${normalizedCapacity}人间` : '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const roomTypeCapacity = (roomType?: string) => {
|
||||||
|
const text = roomType?.trim();
|
||||||
|
if (!text) return undefined;
|
||||||
|
const knownCapacity = Object.entries(capacityRoomTypeText).find(([, label]) => label === text);
|
||||||
|
if (knownCapacity) return Number(knownCapacity[0]);
|
||||||
|
const match = text.match(/^(\d+)人间$/);
|
||||||
|
return match ? Number(match[1]) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DepositsService {
|
export class DepositsService {
|
||||||
|
|
||||||
@@ -18,6 +45,8 @@ export class DepositsService {
|
|||||||
private installmentRepo: Repository<DepositInstallment>,
|
private installmentRepo: Repository<DepositInstallment>,
|
||||||
@InjectRepository(Student)
|
@InjectRepository(Student)
|
||||||
private studentRepo: Repository<Student>,
|
private studentRepo: Repository<Student>,
|
||||||
|
@InjectRepository(Occupancy)
|
||||||
|
private occupancyRepo?: Repository<Occupancy>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getStudentLookups() {
|
async getStudentLookups() {
|
||||||
@@ -28,6 +57,78 @@ export class DepositsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getEligibleStudents(roomType?: string) {
|
||||||
|
const trimmedRoomType = roomType?.trim();
|
||||||
|
const fallbackCapacity = roomTypeCapacity(trimmedRoomType);
|
||||||
|
const qb = this.occupancyRepo!
|
||||||
|
.createQueryBuilder('o')
|
||||||
|
.innerJoin('o.student', 'student')
|
||||||
|
.innerJoin('o.room', 'room')
|
||||||
|
.leftJoin(Deposit, 'deposit', 'deposit.student_id = student.id AND deposit.status != :archived', {
|
||||||
|
archived: 'archived',
|
||||||
|
})
|
||||||
|
.select('student.id', 'studentId')
|
||||||
|
.addSelect('student.name', 'studentName')
|
||||||
|
.addSelect('student.studentNo', 'studentNo')
|
||||||
|
.addSelect('room.id', 'roomId')
|
||||||
|
.addSelect('room.roomNumber', 'roomNumber')
|
||||||
|
.addSelect('room.building', 'building')
|
||||||
|
.addSelect('room.roomType', 'roomType')
|
||||||
|
.addSelect('room.capacity', 'capacity')
|
||||||
|
.addSelect('deposit.amount', 'depositAmount')
|
||||||
|
.where('o.status = :activeStatus', { activeStatus: 'active' })
|
||||||
|
.andWhere('o.checkOutDate IS NULL')
|
||||||
|
.andWhere('student.status = :studentStatus', { studentStatus: 'active' })
|
||||||
|
.orderBy('room.building', 'ASC')
|
||||||
|
.addOrderBy('room.roomNumber', 'ASC')
|
||||||
|
.addOrderBy('student.name', 'ASC');
|
||||||
|
|
||||||
|
if (trimmedRoomType) {
|
||||||
|
if (fallbackCapacity) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(room.roomType = :roomType OR ((room.roomType IS NULL OR room.roomType = :emptyRoomType) AND room.capacity = :fallbackCapacity))',
|
||||||
|
{ roomType: trimmedRoomType, emptyRoomType: '', fallbackCapacity },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
qb.andWhere('room.roomType = :roomType', { roomType: trimmedRoomType });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await qb.getRawMany();
|
||||||
|
return rows.map((row) => ({
|
||||||
|
studentId: Number(row.studentId),
|
||||||
|
studentName: row.studentName,
|
||||||
|
studentNo: row.studentNo ?? null,
|
||||||
|
roomId: Number(row.roomId),
|
||||||
|
roomNumber: row.roomNumber,
|
||||||
|
building: row.building ?? null,
|
||||||
|
roomType: normalizeRoomType(row.roomType, row.capacity),
|
||||||
|
capacity: Number(row.capacity),
|
||||||
|
depositAmount: money(row.depositAmount),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchCreate(dto: BatchCreateDepositDto, userId?: number) {
|
||||||
|
const studentIds = [...new Set(dto.studentIds)];
|
||||||
|
if (studentIds.length === 0) throw new BadRequestException('请选择学生');
|
||||||
|
const amount = money(dto.amount);
|
||||||
|
if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) {
|
||||||
|
throw new BadRequestException('收取金额最多保留两位小数');
|
||||||
|
}
|
||||||
|
if (amount <= 0) throw new BadRequestException('收取金额必须大于0');
|
||||||
|
|
||||||
|
const results: Deposit[] = [];
|
||||||
|
for (const studentId of studentIds) {
|
||||||
|
results.push(await this.create({
|
||||||
|
studentId,
|
||||||
|
amount,
|
||||||
|
paidDate: dto.paidDate,
|
||||||
|
notes: dto.notes,
|
||||||
|
}, userId));
|
||||||
|
}
|
||||||
|
return { count: results.length, amount, results };
|
||||||
|
}
|
||||||
|
|
||||||
async findAll(query?: { studentId?: number; status?: string }) {
|
async findAll(query?: { studentId?: number; status?: string }) {
|
||||||
const qb = this.repo
|
const qb = this.repo
|
||||||
.createQueryBuilder('d')
|
.createQueryBuilder('d')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator';
|
import { ArrayNotEmpty, IsArray, IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator';
|
||||||
|
|
||||||
export class CreateDepositDto {
|
export class CreateDepositDto {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -16,6 +16,28 @@ export class CreateDepositDto {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BatchCreateDepositDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
studentIds: number[];
|
||||||
|
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0.01)
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
paidDate: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
roomType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class RefundDepositDto {
|
export class RefundDepositDto {
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
refundDate: string;
|
refundDate: string;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ describe('preset role permissions', () => {
|
|||||||
it('keeps teachers read-only in scheduling while preserving class attendance access', () => {
|
it('keeps teachers read-only in scheduling while preserving class attendance access', () => {
|
||||||
const teacher = permissionsFor('teacher');
|
const teacher = permissionsFor('teacher');
|
||||||
|
|
||||||
expect(teacher.groups).toEqual(['notification', 'profile']);
|
expect(teacher.groups).toEqual(['notification']);
|
||||||
expect(teacher.extras).toEqual(
|
expect(teacher.extras).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'teacher-workspace:view',
|
'teacher-workspace:view',
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ describe('RbacService seedData', () => {
|
|||||||
),
|
),
|
||||||
create: jest.fn((value) => value),
|
create: jest.fn((value) => value),
|
||||||
save: jest.fn(async (value) => value),
|
save: jest.fn(async (value) => value),
|
||||||
find: jest.fn(async () => permissions),
|
find: jest.fn(async (options?: any) => {
|
||||||
|
if (options?.where?.code) {
|
||||||
|
return permissions.filter((permission) => permission.code === 'profile:view');
|
||||||
|
}
|
||||||
|
return permissions;
|
||||||
|
}),
|
||||||
|
remove: jest.fn(async (value) => value),
|
||||||
};
|
};
|
||||||
const roleRepo = {
|
const roleRepo = {
|
||||||
findOne: jest.fn(async ({ where }: any) =>
|
findOne: jest.fn(async ({ where }: any) =>
|
||||||
@@ -58,7 +64,7 @@ describe('RbacService seedData', () => {
|
|||||||
expect(teacherRole.name).toBe('任课老师');
|
expect(teacherRole.name).toBe('任课老师');
|
||||||
expect(teacherRole.permissions.map((permission) => permission.code)).toEqual(
|
expect(teacherRole.permissions.map((permission) => permission.code)).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'profile:view',
|
'notification:view',
|
||||||
'teacher-workspace:view',
|
'teacher-workspace:view',
|
||||||
'schedule:view',
|
'schedule:view',
|
||||||
'attendance:create',
|
'attendance:create',
|
||||||
@@ -67,6 +73,7 @@ describe('RbacService seedData', () => {
|
|||||||
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain(
|
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain(
|
||||||
'schedule:create',
|
'schedule:create',
|
||||||
);
|
);
|
||||||
|
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('profile:view');
|
||||||
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('student:view');
|
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('student:view');
|
||||||
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('class:view');
|
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('class:view');
|
||||||
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('room:view');
|
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('room:view');
|
||||||
@@ -106,7 +113,13 @@ describe('RbacService legacy role consolidation', () => {
|
|||||||
findOne: jest.fn(async ({ where }: any) => permissions.find((item) => item.code === where.code) ?? null),
|
findOne: jest.fn(async ({ where }: any) => permissions.find((item) => item.code === where.code) ?? null),
|
||||||
create: jest.fn((value) => value),
|
create: jest.fn((value) => value),
|
||||||
save: jest.fn(async (value) => value),
|
save: jest.fn(async (value) => value),
|
||||||
find: jest.fn(async () => permissions),
|
find: jest.fn(async (options?: any) => {
|
||||||
|
if (options?.where?.code) {
|
||||||
|
return permissions.filter((permission) => permission.code === 'profile:view');
|
||||||
|
}
|
||||||
|
return permissions;
|
||||||
|
}),
|
||||||
|
remove: jest.fn(async (value) => value),
|
||||||
};
|
};
|
||||||
const roleRepo = {
|
const roleRepo = {
|
||||||
findOne: jest.fn(async () => targetRole),
|
findOne: jest.fn(async () => targetRole),
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
|
|
||||||
const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [
|
const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [
|
||||||
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
|
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
|
||||||
{ code: 'profile:view', name: '查看个人资料', group: 'profile' },
|
|
||||||
{ code: 'notification:view', name: '查看通知', group: 'notification' },
|
{ code: 'notification:view', name: '查看通知', group: 'notification' },
|
||||||
{ code: 'student:view', name: '查看学生管理', group: 'student' },
|
{ code: 'student:view', name: '查看学生管理', group: 'student' },
|
||||||
{ code: 'student:basic-view', name: '查看学生基础信息', group: 'student-scope' },
|
{ code: 'student:basic-view', name: '查看学生基础信息', group: 'student-scope' },
|
||||||
@@ -88,25 +87,62 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
|||||||
{ code: 'attendance:edit', name: '编辑全部考勤', group: 'attendance' },
|
{ code: 'attendance:edit', name: '编辑全部考勤', group: 'attendance' },
|
||||||
{ code: 'attendance:self-edit', name: '编辑任教班级考勤', group: 'attendance-scope' },
|
{ code: 'attendance:self-edit', name: '编辑任教班级考勤', group: 'attendance-scope' },
|
||||||
{ code: 'attendance:export', name: '导出考勤', group: 'attendance' },
|
{ code: 'attendance:export', name: '导出考勤', group: 'attendance' },
|
||||||
{ code: 'attendance:generate', name: '按课表生成考勤', group: 'attendance' },
|
|
||||||
{ code: 'learning:create', name: '创建学习任务', group: 'learning' },
|
|
||||||
{ code: 'learning:edit', name: '编辑学习任务', group: 'learning' },
|
|
||||||
{ code: 'learning:delete', name: '删除学习任务', group: 'learning' },
|
|
||||||
{ code: 'exam:create', name: '创建考试', group: 'exam' },
|
|
||||||
{ code: 'exam:edit', name: '编辑考试', group: 'exam' },
|
|
||||||
{ code: 'exam:delete', name: '删除考试', group: 'exam' },
|
|
||||||
{ code: 'sync:trigger', name: '触发数据同步', group: 'sync' },
|
{ code: 'sync:trigger', name: '触发数据同步', group: 'sync' },
|
||||||
{ code: 'sync:read', name: '查看同步状态', group: 'sync' },
|
{ code: 'sync:read', name: '查看同步状态', group: 'sync' },
|
||||||
{ code: 'integration:trigger', name: '触发集成', group: 'integration' },
|
{ code: 'integration:trigger', name: '触发集成', group: 'integration' },
|
||||||
{ code: 'integration:read', name: '查看集成状态', group: 'integration' },
|
{ code: 'integration:read', name: '查看集成状态', group: 'integration' },
|
||||||
{ code: 'department:view', name: '查看部门', group: 'department' },
|
|
||||||
{ code: 'department:edit', name: '编辑部门', group: 'department' },
|
|
||||||
{ code: 'department:delete', name: '删除部门', group: 'department' },
|
|
||||||
{ code: 'ai:config:read', name: '查看 AI 配置', group: 'ai' },
|
{ code: 'ai:config:read', name: '查看 AI 配置', group: 'ai' },
|
||||||
{ code: 'ai:config:write', name: '修改 AI 配置', group: 'ai' },
|
{ code: 'ai:config:write', name: '修改 AI 配置', group: 'ai' },
|
||||||
{ code: 'ai:config:test', name: '测试 AI 连接', group: 'ai' },
|
{ code: 'ai:config:test', name: '测试 AI 连接', group: 'ai' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DEPRECATED_PERMISSION_CODES = [
|
||||||
|
'profile:view',
|
||||||
|
'attendance:generate',
|
||||||
|
'learning:create',
|
||||||
|
'learning:edit',
|
||||||
|
'learning:delete',
|
||||||
|
'exam:create',
|
||||||
|
'exam:edit',
|
||||||
|
'exam:delete',
|
||||||
|
'department:view',
|
||||||
|
'department:edit',
|
||||||
|
'department:delete',
|
||||||
|
// Legacy permission codes from older admin UI / seed data.
|
||||||
|
'student:add',
|
||||||
|
'student:update',
|
||||||
|
'room:add',
|
||||||
|
'room:update',
|
||||||
|
'occupancy:add',
|
||||||
|
'occupancy:update',
|
||||||
|
'attendance:add',
|
||||||
|
'attendance:update',
|
||||||
|
'attendance:delete',
|
||||||
|
'attendance:batch',
|
||||||
|
'bill:export',
|
||||||
|
'deposit:collect',
|
||||||
|
'expense:add',
|
||||||
|
'expense:update',
|
||||||
|
'class:add',
|
||||||
|
'class:update',
|
||||||
|
'schedule:add',
|
||||||
|
'schedule:update',
|
||||||
|
'classroom:add',
|
||||||
|
'classroom:update',
|
||||||
|
'rental:add',
|
||||||
|
'rental:update',
|
||||||
|
'role:add',
|
||||||
|
'role:update',
|
||||||
|
'user:add',
|
||||||
|
'user:update',
|
||||||
|
'archive:view',
|
||||||
|
'archive:import',
|
||||||
|
'archive:export',
|
||||||
|
'report:generate',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DEPRECATED_PERMISSION_CODE_SET = new Set<string>(DEPRECATED_PERMISSION_CODES);
|
||||||
|
|
||||||
export const PRESET_ROLES: Array<{
|
export const PRESET_ROLES: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -130,7 +166,7 @@ export const PRESET_ROLES: Array<{
|
|||||||
code: 'teacher',
|
code: 'teacher',
|
||||||
description: '查看自己的排课、今日课程和任教班级考勤',
|
description: '查看自己的排课、今日课程和任教班级考勤',
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
permissionGroups: ['notification', 'profile'],
|
permissionGroups: ['notification'],
|
||||||
extraPermissions: [
|
extraPermissions: [
|
||||||
'teacher-workspace:view',
|
'teacher-workspace:view',
|
||||||
'schedule:view',
|
'schedule:view',
|
||||||
@@ -151,11 +187,8 @@ export const PRESET_ROLES: Array<{
|
|||||||
'schedule',
|
'schedule',
|
||||||
'attendance',
|
'attendance',
|
||||||
'classroom',
|
'classroom',
|
||||||
'learning',
|
|
||||||
'exam',
|
|
||||||
'dashboard',
|
'dashboard',
|
||||||
'notification',
|
'notification',
|
||||||
'profile',
|
|
||||||
],
|
],
|
||||||
extraPermissions: [
|
extraPermissions: [
|
||||||
'teacher-workspace:view',
|
'teacher-workspace:view',
|
||||||
@@ -180,7 +213,6 @@ export const PRESET_ROLES: Array<{
|
|||||||
'wallet',
|
'wallet',
|
||||||
'dashboard',
|
'dashboard',
|
||||||
'notification',
|
'notification',
|
||||||
'profile',
|
|
||||||
],
|
],
|
||||||
extraPermissions: ['student:basic-view'],
|
extraPermissions: ['student:basic-view'],
|
||||||
legacyNames: ['宿管老师', '宿管', '财务'],
|
legacyNames: ['宿管老师', '宿管', '财务'],
|
||||||
@@ -191,7 +223,7 @@ export const PRESET_ROLES: Array<{
|
|||||||
code: 'classroom_operations',
|
code: 'classroom_operations',
|
||||||
description: '管理教室、教室排期、外部机构和租赁订单',
|
description: '管理教室、教室排期、外部机构和租赁订单',
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
permissionGroups: ['classroom', 'rental', 'organization', 'notification', 'profile'],
|
permissionGroups: ['classroom', 'rental', 'organization', 'notification'],
|
||||||
legacyNames: ['机构负责人'],
|
legacyNames: ['机构负责人'],
|
||||||
legacyCodes: ['institution_head'],
|
legacyCodes: ['institution_head'],
|
||||||
},
|
},
|
||||||
@@ -207,9 +239,7 @@ export const PRESET_ROLES: Array<{
|
|||||||
'integration',
|
'integration',
|
||||||
'sync',
|
'sync',
|
||||||
'ai',
|
'ai',
|
||||||
'department',
|
|
||||||
'notification',
|
'notification',
|
||||||
'profile',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -253,7 +283,8 @@ export class RbacService {
|
|||||||
where: { code: 'user:delete' },
|
where: { code: 'user:delete' },
|
||||||
});
|
});
|
||||||
const allPerms = (await this.permRepo.find()).filter(
|
const allPerms = (await this.permRepo.find()).filter(
|
||||||
(permission) => permission.code !== 'user:delete',
|
(permission) =>
|
||||||
|
permission.code !== 'user:delete' && !DEPRECATED_PERMISSION_CODE_SET.has(permission.code),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 2: 幂等插入预置角色
|
// Step 2: 幂等插入预置角色
|
||||||
@@ -288,6 +319,22 @@ export class RbacService {
|
|||||||
await this.permRepo.remove(deprecatedUserDeletePermission);
|
await this.permRepo.remove(deprecatedUserDeletePermission);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deprecatedPermissions = await this.permRepo.find({
|
||||||
|
where: { code: In([...DEPRECATED_PERMISSION_CODES]) },
|
||||||
|
});
|
||||||
|
if (deprecatedPermissions.length > 0) {
|
||||||
|
const deprecatedIds = new Set(deprecatedPermissions.map((permission) => permission.id));
|
||||||
|
for (const role of allRoles) {
|
||||||
|
const permissions = role.permissions ?? [];
|
||||||
|
if (permissions.some((permission) => deprecatedIds.has(permission.id))) {
|
||||||
|
role.permissions = permissions.filter((permission) => !deprecatedIds.has(permission.id));
|
||||||
|
await this.roleRepo.save(role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.permRepo.remove(deprecatedPermissions);
|
||||||
|
this.logger.log(`已清理废弃权限点: ${deprecatedPermissions.map((p) => p.code).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Step 3: 合并旧角色并构建新的职责权限矩阵
|
// Step 3: 合并旧角色并构建新的职责权限矩阵
|
||||||
for (const preset of PRESET_ROLES) {
|
for (const preset of PRESET_ROLES) {
|
||||||
const matchesPreset = (role: Role) =>
|
const matchesPreset = (role: Role) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user