完善考勤排课与接口校验 #11

Merged
wangziqi merged 2 commits from codex/wzq into main 2026-07-14 15:13:13 +00:00
57 changed files with 1831 additions and 821 deletions

View File

@@ -14,6 +14,7 @@ const RoomsPage = lazy(() => import('./pages/Rooms'));
const OccupanciesPage = lazy(() => import('./pages/Occupancies')); const OccupanciesPage = lazy(() => import('./pages/Occupancies'));
const ExpensesPage = lazy(() => import('./pages/Expenses')); const ExpensesPage = lazy(() => import('./pages/Expenses'));
const BillsPage = lazy(() => import('./pages/Bills')); const BillsPage = lazy(() => import('./pages/Bills'));
const WalletsPage = lazy(() => import('./pages/Wallets'));
const RoomVisualPage = lazy(() => import('./pages/RoomVisual')); const RoomVisualPage = lazy(() => import('./pages/RoomVisual'));
const OperationLogsPage = lazy(() => import('./pages/OperationLogs')); const OperationLogsPage = lazy(() => import('./pages/OperationLogs'));
const UsersPage = lazy(() => import('./pages/Users')); const UsersPage = lazy(() => import('./pages/Users'));
@@ -134,6 +135,14 @@ const App: React.FC = () => {
</PermissionRoute> </PermissionRoute>
} }
/> />
<Route
path="wallets"
element={
<PermissionRoute permission="wallet:view">
<WalletsPage />
</PermissionRoute>
}
/>
<Route <Route
path="bills" path="bills"
element={ element={

View File

@@ -73,6 +73,7 @@ const SECTIONS: MenuSection[] = [
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' }, { key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' }, { key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' }, { key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
{ key: '/wallets', label: '学生余额', icon: 'wallet', permission: 'wallet:view' },
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' }, { key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
], ],
}, },
@@ -111,8 +112,9 @@ export function getRoleDomains(roles: readonly string[], permissions: readonly s
normalized.add('academic'); normalized.add('academic');
} }
if ( if (
permissions.includes('room:view') && (permissions.includes('room:view') &&
(permissions.includes('occupancy:view') || permissions.includes('expense:view')) (permissions.includes('occupancy:view') || permissions.includes('expense:view'))) ||
permissions.includes('wallet:view')
) { ) {
normalized.add('accommodation'); normalized.add('accommodation');
} }

View File

@@ -105,6 +105,20 @@ interface AttachmentRecord {
fileSize: number; fileSize: number;
} }
interface AttendanceRecordItem {
id: number;
attendanceDate: string;
session: string;
status: string;
source?: string;
remark?: string | null;
punchTime?: string | null;
punchDeviceName?: string | null;
punchDeviceId?: string | null;
schedule?: { subject?: string } | null;
class?: { name?: string } | null;
}
interface StudentProfileAggregate { interface StudentProfileAggregate {
student: StudentInfo; student: StudentInfo;
profile: ProfileData | null; profile: ProfileData | null;
@@ -113,6 +127,7 @@ interface StudentProfileAggregate {
learningRecords: LearningRecord[]; learningRecords: LearningRecord[];
result: ResultData | null; result: ResultData | null;
attachments: AttachmentRecord[]; attachments: AttachmentRecord[];
attendances: AttendanceRecordItem[];
} }
export interface StudentProfileContentProps { export interface StudentProfileContentProps {
@@ -178,6 +193,58 @@ const formatFileSize = (bytes: number): string => {
// ---- Tab Components ---- // ---- Tab Components ----
const ATTENDANCE_STATUS_MAP: Record<string, { text: string; color: string }> = {
present: { text: '出勤', color: 'green' },
late: { text: '迟到', color: 'orange' },
absent: { text: '缺勤', color: 'red' },
leave: { text: '请假', color: 'blue' },
pending: { text: '待确认', color: 'default' },
};
const SESSION_LABELS: Record<string, string> = {
morning_reading: '早自习',
morning: '上午',
afternoon: '下午',
evening_study: '晚自习',
night_check: '晚寝',
};
const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => {
const columns: ColumnsType<AttendanceRecordItem> = [
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
{ title: '课程', render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤' },
{ title: '时段', dataIndex: 'session', width: 100, render: (value: string) => SESSION_LABELS[value] || value || '-' },
{
title: '结果', dataIndex: 'status', width: 90,
render: (value: string) => {
const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' };
return <Tag color={meta.color}>{meta.text}</Tag>;
},
},
{ title: '打卡时间', dataIndex: 'punchTime', width: 170, render: (value?: string | null) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-' },
{
title: '打卡设备',
render: (_: unknown, record) => {
const name = record.punchDeviceName?.trim();
const id = record.punchDeviceId?.trim();
if (name && id && name !== id) return `${name}${id}`;
return name || id || (record.source === 'manual' ? '老师手动标记' : '-');
},
},
{ title: '备注', dataIndex: 'remark', render: (value?: string | null) => value || '-' },
];
return data.length > 0 ? (
<Table<AttendanceRecordItem>
columns={columns}
dataSource={data}
rowKey="id"
scroll={{ x: 900 }}
pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }}
/>
) : <Empty description="暂无出勤记录" />;
};
interface TabProps { interface TabProps {
studentId: number; studentId: number;
onRefresh: () => void; onRefresh: () => void;
@@ -779,7 +846,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
const tabItems = useMemo(() => { const tabItems = useMemo(() => {
if (!aggregateData) return []; if (!aggregateData) return [];
const { profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData; const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } = aggregateData;
return [ return [
{ {
key: 'profile', key: 'profile',
@@ -807,8 +874,8 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
}, },
{ {
key: 'attendance', key: 'attendance',
label: '出勤记录', label: `出勤记录 (${attendances.length})`,
children: <Empty description="暂无出勤记录" />, children: <AttendanceTab data={attendances} />,
}, },
{ {
key: 'learning', key: 'learning',

View File

@@ -51,6 +51,7 @@ const iconMap: Record<string, React.ReactNode> = {
expense: <DollarOutlined />, expense: <DollarOutlined />,
bill: <FileTextOutlined />, bill: <FileTextOutlined />,
deposit: <WalletOutlined />, deposit: <WalletOutlined />,
wallet: <WalletOutlined />,
classroom: <ReadOutlined />, classroom: <ReadOutlined />,
rental: <FileProtectOutlined />, rental: <FileProtectOutlined />,
organization: <TagsOutlined />, organization: <TagsOutlined />,

View File

@@ -10,7 +10,6 @@ import {
Popconfirm, Popconfirm,
Input, Input,
Select, Select,
Tooltip,
Spin, Spin,
Empty, Empty,
} from 'antd'; } from 'antd';
@@ -26,174 +25,23 @@ import PermissionButton from '../../components/PermissionButton';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
draft: { text: '草稿', color: 'default' }, unpaid: { text: '待支付', color: 'orange' },
partially_paid: { text: '部分支付', color: 'gold' },
paid: { text: '已支付', color: 'green' }, paid: { text: '已支付', color: 'green' },
cancelled: { text: '已取消', color: 'default' },
}; };
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
water: '水费', water: '水费',
electricity: '电费', electricity: '电费',
cleaning: '保洁费', cleaning: '保洁费',
rent: '租金',
damage: '损坏赔偿', damage: '损坏赔偿',
penalty: '罚款', penalty: '罚款',
other: '其他', other: '其他',
}; };
const escapeHtml = (value: unknown) =>
String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`;
const buildBillPrintHtml = (bill: any) => {
const studentName = bill.student?.name || '-';
const status = statusMap[bill.status]?.text || bill.status || '-';
const generatedAt = bill.generatedAt
? dayjs(bill.generatedAt).format('YYYY-MM-DD HH:mm')
: dayjs().format('YYYY-MM-DD HH:mm');
const hasDeposit = Number(bill.availableDeposit || 0) > 0;
const depositDeducted = Number(bill.depositDeductedAmount || 0);
const items = bill.items || [];
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>账单_${escapeHtml(studentName)}_${escapeHtml(bill.id)}</title>
<style>
@page { size: A4; margin: 0; }
* { box-sizing: border-box; }
body {
margin: 0;
color: #000;
background: #f5f5f5;
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", Arial, sans-serif;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.page {
width: 210mm;
min-height: 297mm;
margin: 0 auto;
padding: 50px;
background: #fff;
}
h1 { margin: 0; text-align: center; font-size: 20px; line-height: 1.35; font-weight: 700; }
.generated-time { margin-top: 8px; text-align: center; color: #666; font-size: 10px; }
.basic-info { margin-top: 24px; font-size: 12px; line-height: 1.8; }
.section-title { margin: 16px 0 6px; font-size: 14px; font-weight: 700; text-decoration: underline; }
.amount-summary { font-size: 12px; line-height: 1.75; }
.total { color: #007aff; font-size: 14px; font-weight: 700; }
.deposit { color: #52c41a; font-size: 11px; }
.deposit-deducted { color: #fa8c16; font-size: 11px; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; margin-top: 8px; }
th, td { padding: 5px 6px; border-bottom: 1px solid #ccc; font-size: 9px; line-height: 1.45; text-align: left; vertical-align: top; word-break: break-word; }
th { color: #333; font-weight: 700; }
td.amount, th.amount { text-align: right; white-space: nowrap; }
.footer { margin-top: 34px; text-align: center; color: #999; font-size: 8px; }
.print-actions {
position: fixed;
right: 18px;
top: 18px;
display: flex;
gap: 8px;
}
.print-actions button {
height: 32px;
padding: 0 12px;
border: 1px solid #1f6feb;
border-radius: 4px;
background: #1f6feb;
color: #fff;
cursor: pointer;
}
@media print {
body { background: #fff; }
.page { margin: 0; }
.print-actions { display: none; }
}
</style>
</head>
<body>
<div class="print-actions">
<button onclick="window.print()">打印 / 另存为 PDF</button>
</div>
<main class="page">
<h1>恭学教育基地水电费账单</h1>
<div class="generated-time">生成时间: ${escapeHtml(generatedAt)}</div>
<div class="basic-info">
<div>学生姓名: ${escapeHtml(studentName)}</div>
<div>计费周期: ${escapeHtml(bill.periodStart)} ~ ${escapeHtml(bill.periodEnd)}</div>
<div>账单状态: ${escapeHtml(status)}</div>
</div>
<section>
<div class="section-title">费用汇总</div>
<div class="amount-summary">
<div>分摊费用: ${escapeHtml(money(bill.sharedAmount))}</div>
<div>个人费用: ${escapeHtml(money(bill.personalAmount))}</div>
<div class="total">应付总额: ${escapeHtml(money(bill.totalAmount))}</div>
${
hasDeposit || depositDeducted > 0
? `<div class="deposit">可用押金: ${escapeHtml(money(bill.availableDeposit))}</div>
${depositDeducted > 0 ? `<div class="deposit-deducted">已扣押金: -${escapeHtml(money(depositDeducted))}</div>` : ''}`
: ''
}
</div>
</section>
<section>
<div class="section-title">费用明细</div>
<table>
<thead>
<tr>
<th style="width: 22%;">费用类型</th>
<th>说明</th>
<th style="width: 11%;">天数</th>
<th style="width: 12%;">总人天</th>
<th class="amount" style="width: 16%;">金额(元)</th>
</tr>
</thead>
<tbody>
${
items.length
? items
.map(
(item: any) => `<tr>
<td>${escapeHtml(typeMap[item.expenseType] || item.expenseType || '-')}</td>
<td>${escapeHtml(item.description || '-')}</td>
<td>${escapeHtml(item.days || 0)}</td>
<td>${escapeHtml(item.totalRoomDays || 0)}</td>
<td class="amount">${escapeHtml(Number(item.studentAmount || 0).toFixed(2))}</td>
</tr>`,
)
.join('')
: '<tr><td colspan="5" style="text-align:center; color:#999;">暂无费用明细</td></tr>'
}
</tbody>
</table>
</section>
<div class="footer">
本账单由恭学教育基地管理系统自动生成
</div>
</main>
<script>
window.addEventListener('load', () => {
setTimeout(() => window.print(), 250);
});
</script>
</body>
</html>`;
};
const BillsPage: React.FC = () => { const BillsPage: React.FC = () => {
const [bills, setBills] = useState<any[]>([]); const [bills, setBills] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -239,34 +87,19 @@ const BillsPage: React.FC = () => {
}); });
}, [bills, searchText, filterStatus]); }, [bills, searchText, filterStatus]);
const selectedBillRows = useMemo(
() => bills.filter((bill: any) => selectedRows.includes(bill.id)),
[bills, selectedRows],
);
const canBatchConfirm =
selectedBillRows.length > 0
&& selectedBillRows.every((bill: any) => bill.status === 'draft' && bill.depositSufficient);
const canBatchDelete =
selectedBillRows.length > 0 && selectedBillRows.every((bill: any) => bill.status !== 'paid');
const handleGenerate = async () => { const handleGenerate = async () => {
if (saving) return; setSaving(true);
const values = await generateForm.validateFields();
try { try {
const values = await generateForm.validateFields();
setSaving(true);
const res: any = await api.post('/bills/generate', { const res: any = await api.post('/bills/generate', {
billingMonth: values.billingMonth.format('YYYY-MM'), billingMonth: values.billingMonth.format('YYYY-MM'),
}); });
message.success(res.message || '生成成功'); message.success(res.message || '生成成功');
setGenerateModal(false); setGenerateModal(false);
generateForm.resetFields(); generateForm.resetFields();
void fetchData(); fetchData();
} catch (e: any) { } catch (e: any) {
// Ant Design 的表单校验失败会 reject字段本身已展示错误无需再弹“生成失败”。 message.error(e?.message || '生成失败');
if (!e?.errorFields) {
message.error(e?.message || '生成失败');
}
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -284,43 +117,29 @@ const BillsPage: React.FC = () => {
} }
}; };
const updateStatus = async (id: number, status: string) => {
try {
await api.put(`/bills/${id}/status`, { status });
message.success('账单已确认支付,押金已自动扣除');
fetchData();
if (detailModal?.id === id) {
void showDetail(id);
}
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const batchUpdateStatus = async (status: string) => {
if (selectedRows.length === 0) return message.warning('请先选择账单'); const handleCancel = async (id: number) => {
if (batchLoading) return; let reason = '';
setBatchLoading(true); Modal.confirm({
try { title: '取消账单并退回已扣余额',
await api.put('/bills/batch/status', { ids: selectedRows, status }); content: <Input.TextArea placeholder="请输入取消原因" maxLength={300} onChange={(event) => { reason = event.target.value; }} />,
message.success(`已确认支付 ${selectedRows.length} 条账单,并自动扣除押金`); okText: '确认取消', cancelText: '返回',
setSelectedRows([]); onOk: async () => {
fetchData(); if (!reason.trim()) { message.error('请输入取消原因'); throw new Error('reason required'); }
} catch (e: any) { await api.post(`/bills/${id}/cancel`, { reason: reason.trim() });
message.error(e?.message || '操作失败'); message.success('账单已取消,已扣余额已冲正退回');
} finally { fetchData();
setBatchLoading(false); },
} });
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
await api.delete(`/bills/${id}`); await api.delete(`/bills/${id}`);
message.success('账单已删除'); message.success('删除成功');
fetchData(); fetchData();
} catch (e: any) { } catch (error: any) { message.error(error?.message || '删除失败'); }
message.error(e?.message || '删除失败');
}
}; };
const batchDelete = async () => { const batchDelete = async () => {
@@ -346,24 +165,11 @@ const BillsPage: React.FC = () => {
); );
}; };
const handleExportPdf = useCallback(async (billId: number) => { const handleExportPdf = (billId: number) => {
const printWindow = window.open('', '_blank'); downloadBlob(`/bills/export/pdf/${billId}`, `账单_${billId}.pdf`).catch(() =>
if (!printWindow) { message.error('导出失败'),
message.error('无法打开打印窗口,请允许浏览器弹窗后重试'); );
return; };
}
printWindow.document.write('<!doctype html><title>账单加载中</title><body>账单加载中...</body>');
try {
const bill = await api.get(`/bills/${billId}`);
printWindow.document.open();
printWindow.document.write(buildBillPrintHtml(bill));
printWindow.document.close();
} catch (e: any) {
printWindow.close();
message.error(e?.message || '账单数据加载失败');
}
}, []);
const columns = useMemo(() => [ const columns = useMemo(() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
@@ -390,26 +196,16 @@ const BillsPage: React.FC = () => {
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>, render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
}, },
{ {
title: '可用押金', title: '已扣余额', dataIndex: 'paidAmount', width: 110,
dataIndex: 'availableDeposit', render: (value: number) => <span style={{ color: '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</span>,
width: 120,
render: (v: number) =>
v > 0 ? (
<span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
) : (
<span style={{ color: '#999' }}>-</span>
),
}, },
{ {
title: '已扣押金', title: '待补缴', dataIndex: 'outstandingAmount', width: 110,
dataIndex: 'depositDeductedAmount', render: (value: number) => <strong style={{ color: Number(value) > 0 ? '#cf1322' : '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</strong>,
width: 120, },
render: (v: number) => {
Number(v || 0) > 0 ? ( title: '钱包余额', dataIndex: 'walletBalance', width: 110,
<span style={{ color: '#fa8c16' }}>¥{Number(v).toFixed(2)}</span> render: (value: number) => `¥${Number(value || 0).toFixed(2)}`,
) : (
<span style={{ color: '#999' }}>-</span>
),
}, },
{ {
title: '状态', title: '状态',
@@ -436,25 +232,6 @@ const BillsPage: React.FC = () => {
> >
</PermissionButton> </PermissionButton>
{record.status === 'draft' && (
<Tooltip
title={
record.depositSufficient
? '确认后将自动从该学生押金余额中扣除账单金额'
: '押金不足,请先到押金管理收取押金'
}
>
<PermissionButton
permission="bill:confirm"
size="small"
type="primary"
disabled={!record.depositSufficient}
onClick={() => updateStatus(record.id, 'paid')}
>
</PermissionButton>
</Tooltip>
)}
<PermissionButton <PermissionButton
permission="bill:export-pdf" permission="bill:export-pdf"
size="small" size="small"
@@ -463,26 +240,20 @@ const BillsPage: React.FC = () => {
> >
PDF PDF
</PermissionButton> </PermissionButton>
<Popconfirm {record.status !== 'cancelled' && (
title="确定删除此账单?" <PermissionButton permission="bill:delete" size="small" danger onClick={() => handleCancel(record.id)}>
onConfirm={() => handleDelete(record.id)}
okText="删除"
cancelText="取消"
>
<PermissionButton
permission="bill:delete"
size="small"
danger
disabled={record.status === 'paid'}
icon={<DeleteOutlined />}
>
</PermissionButton> </PermissionButton>
</Popconfirm> )}
{Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && (
<Popconfirm title="确定删除此未支付账单?" onConfirm={() => handleDelete(record.id)} okText="删除" cancelText="取消">
<PermissionButton permission="bill:delete" size="small" danger icon={<DeleteOutlined />}></PermissionButton>
</Popconfirm>
)}
</Space> </Space>
), ),
}, },
], [showDetail, updateStatus, handleDelete, handleExportPdf]); ], [showDetail, handleDelete, handleCancel, handleExportPdf]);
return ( return (
<div> <div>
@@ -512,31 +283,25 @@ const BillsPage: React.FC = () => {
value={filterStatus} value={filterStatus}
onChange={(v) => setFilterStatus(v)} onChange={(v) => setFilterStatus(v)}
options={[ options={[
{ value: 'draft', label: '草稿' }, { value: 'unpaid', label: '待支付' },
{ value: 'partially_paid', label: '部分支付' },
{ value: 'paid', label: '已支付' }, { value: 'paid', label: '已支付' },
{ value: 'cancelled', label: '已取消' },
]} ]}
/> />
<Select placeholder="费用类型" allowClear style={{ width: 120 }} value={filterExpenseType} onChange={setFilterExpenseType} <Select placeholder="费用类型" allowClear style={{ width: 120 }} value={filterExpenseType} onChange={setFilterExpenseType}
options={[{value:'water',label:'水费'},{value:'electricity',label:'电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} /> options={[{value:'water',label:'水费'},{value:'electricity',label:'电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} />
<PermissionButton
permission="bill:confirm"
type="primary"
onClick={() => batchUpdateStatus('paid')}
disabled={!canBatchConfirm}
>
</PermissionButton>
<Popconfirm <Popconfirm
title={`确定删除选中的 ${selectedRows.length} 条账单?`} title={`确定删除选中的 ${selectedRows.length} 条账单?`}
onConfirm={batchDelete} onConfirm={batchDelete}
okText="删除" okText="删除"
cancelText="取消" cancelText="取消"
disabled={!canBatchDelete} disabled={selectedRows.length === 0}
> >
<PermissionButton <PermissionButton
permission="bill:delete" permission="bill:delete"
danger danger
disabled={!canBatchDelete} disabled={selectedRows.length === 0}
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
> >
@@ -570,12 +335,7 @@ const BillsPage: React.FC = () => {
dataSource={filteredBills} dataSource={filteredBills}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
pagination={{ pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }} locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{ rowSelection={{
selectedRowKeys: selectedRows, selectedRowKeys: selectedRows,
@@ -603,9 +363,7 @@ const BillsPage: React.FC = () => {
picker="month" picker="month"
placeholder="选择月份" placeholder="选择月份"
format="YYYY-MM" format="YYYY-MM"
disabledDate={(current) => disabledDate={(current) => !!current && !current.endOf('month').isBefore(dayjs(), 'day')}
!!current && !current.endOf('month').isBefore(dayjs(), 'day')
}
/> />
</Form.Item> </Form.Item>
</Form> </Form>
@@ -645,37 +403,11 @@ const BillsPage: React.FC = () => {
</strong> </strong>
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
{(Number(detailModal.availableDeposit || 0) > 0 <Descriptions bordered size="small" column={3} style={{ marginBottom: 16 }}>
|| Number(detailModal.depositDeductedAmount || 0) > 0 <Descriptions.Item label="已扣余额">¥{Number(detailModal.paidAmount || 0).toFixed(2)}</Descriptions.Item>
|| detailModal.status === 'draft') && ( <Descriptions.Item label="待补缴">¥{Number(detailModal.outstandingAmount || 0).toFixed(2)}</Descriptions.Item>
<div <Descriptions.Item label="当前钱包余额">¥{Number(detailModal.walletBalance || 0).toFixed(2)}</Descriptions.Item>
style={{ </Descriptions>
marginBottom: 16,
padding: 12,
background: detailModal.depositSufficient || detailModal.status === 'paid' ? '#f6ffed' : '#fff2f0',
border: `1px solid ${detailModal.depositSufficient || detailModal.status === 'paid' ? '#b7eb8f' : '#ffccc7'}`,
borderRadius: 8,
}}
>
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>
</div>
<Space size={24} wrap>
<span>
<strong style={{ color: '#52c41a' }}>
¥{Number(detailModal.availableDeposit).toFixed(2)}
</strong>
</span>
<span>
<strong style={{ color: '#fa8c16' }}>
-¥{Number(detailModal.depositDeductedAmount || 0).toFixed(2)}
</strong>
</span>
</Space>
</div>
)}
<h4></h4> <h4></h4>
<Table <Table
scroll={{ x: 700 }} scroll={{ x: 700 }}

View File

@@ -39,6 +39,7 @@ interface ClassScheduleItem {
weekDay: number; weekDay: number;
startTime: string; startTime: string;
endTime: string; endTime: string;
attendanceAdvanceMinutes: number;
startDate: string; startDate: string;
endDate: string; endDate: string;
subject: string; subject: string;
@@ -353,6 +354,7 @@ const ClassDetailPage: React.FC = () => {
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' }, { title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v }, { title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
{ title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` }, { title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` },
{ title: '签到窗口', render: (_: unknown, r: ClassScheduleItem) => `课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课` },
{ title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` }, { title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` },
{ title: '科目', dataIndex: 'subject' }, { title: '科目', dataIndex: 'subject' },
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v }, { title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },

View File

@@ -46,10 +46,12 @@ const ExpensesPage: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [roomModal, setRoomModal] = useState(false); const [roomModal, setRoomModal] = useState(false);
const [personalModal, setPersonalModal] = useState(false); const [personalModal, setPersonalModal] = useState(false);
const [utilityModal, setUtilityModal] = useState(false);
const [editingRoom, setEditingRoom] = useState<any>(null); const [editingRoom, setEditingRoom] = useState<any>(null);
const [editingPersonal, setEditingPersonal] = useState<any>(null); const [editingPersonal, setEditingPersonal] = useState<any>(null);
const [roomForm] = Form.useForm(); const [roomForm] = Form.useForm();
const [personalForm] = Form.useForm(); const [personalForm] = Form.useForm();
const [utilityForm] = Form.useForm();
const [roomSearch, setRoomSearch] = useState(''); const [roomSearch, setRoomSearch] = useState('');
const [roomTypeFilter, setRoomTypeFilter] = useState<string | undefined>(undefined); const [roomTypeFilter, setRoomTypeFilter] = useState<string | undefined>(undefined);
const [personalSearch, setPersonalSearch] = useState(''); const [personalSearch, setPersonalSearch] = useState('');
@@ -192,6 +194,27 @@ const ExpensesPage: React.FC = () => {
} }
}; };
const handleStudentUtility = async () => {
const values = await utilityForm.validateFields();
setSaving(true);
try {
const result: any = await api.post('/expenses/student-utility', {
studentId: values.studentId,
expenseType: values.expenseType,
amount: values.amount,
periodStart: values.period[0].format('YYYY-MM-DD'),
periodEnd: values.period[1].format('YYYY-MM-DD'),
description: values.description,
});
const bill = result.bill;
message.success(`账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`);
setUtilityModal(false);
utilityForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '水电费出账失败'); }
finally { setSaving(false); }
};
const handlePersonalExpense = async () => { const handlePersonalExpense = async () => {
setSaving(true); setSaving(true);
try { try {
@@ -560,6 +583,13 @@ const ExpensesPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</Popconfirm> </Popconfirm>
<PermissionButton
permission="expense:create"
icon={<PlusOutlined />}
onClick={() => { utilityForm.resetFields(); setUtilityModal(true); }}
>
</PermissionButton>
<PermissionButton <PermissionButton
permission="expense:create" permission="expense:create"
type="primary" type="primary"
@@ -639,6 +669,19 @@ const ExpensesPage: React.FC = () => {
</Form> </Form>
</Modal> </Modal>
<Modal title="添加学生水电费并立即出账" open={utilityModal} onOk={handleStudentUtility} onCancel={() => setUtilityModal(false)} okText="生成账单并扣余额" confirmLoading={saving}>
<Form form={utilityForm} layout="vertical">
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="label" options={students.map((student: any) => ({ value: student.id, label: `${student.name} (${student.studentNo || `#${student.id}`})` }))} />
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}><Select options={[{ value: 'water', label: '水费' }, { value: 'electricity', label: '电费' }]} /></Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}><RangePicker style={{ width: '100%' }} format="YYYY-MM-DD" /></Form.Item>
<Form.Item name="description" label="说明"><Input.TextArea rows={2} maxLength={300} /></Form.Item>
</Form>
</Modal>
<Modal <Modal
title={editingPersonal ? '编辑个人费用' : '录入个人附加费'} title={editingPersonal ? '编辑个人费用' : '录入个人附加费'}
open={personalModal} open={personalModal}

View File

@@ -6,6 +6,7 @@ import {
Modal, Modal,
Form, Form,
Input, Input,
InputNumber,
DatePicker, DatePicker,
TimePicker, TimePicker,
Popconfirm, Popconfirm,
@@ -53,6 +54,7 @@ interface ClassScheduleItem {
weekDay: number; weekDay: number;
startTime: string; startTime: string;
endTime: string; endTime: string;
attendanceAdvanceMinutes: number;
startDate: string; startDate: string;
endDate: string; endDate: string;
subject: string; subject: string;
@@ -351,7 +353,7 @@ const SchedulesPage: React.FC = () => {
setEditingSchedule(null); setEditingSchedule(null);
setModalMode('create'); setModalMode('create');
form.resetFields(); form.resetFields();
form.setFieldsValue({ classroomId, weekDay }); form.setFieldsValue({ classroomId, weekDay, attendanceAdvanceMinutes: 30 });
setModalOpen(true); setModalOpen(true);
} }
}; };
@@ -960,9 +962,28 @@ const SchedulesPage: React.FC = () => {
/> />
</Form.Item> </Form.Item>
<Form.Item
name="attendanceAdvanceMinutes"
label="课前签到时间"
tooltip="从上课前指定分钟开始,到下课时间结束;期间任意上班或下班打卡都计为出勤"
initialValue={30}
rules={[{ required: true, message: '请设置课前签到时间' }]}
>
<InputNumber
min={0}
max={1440}
step={5}
addonAfter="分钟"
style={{ width: '100%' }}
placeholder="例如 30"
/>
</Form.Item>
<Form.Item <Form.Item
name="timeRange" name="timeRange"
label="上课时段" label="上课时段"
tooltip="同一教室的前后两节排课必须至少间隔10分钟"
extra="系统按10分钟选择时间并为相邻排课强制预留至少10分钟。"
rules={[{ required: true, message: '请选择时段' }]} rules={[{ required: true, message: '请选择时段' }]}
> >
<TimePicker.RangePicker <TimePicker.RangePicker
@@ -1008,6 +1029,7 @@ const SchedulesPage: React.FC = () => {
weekDay: weekDay:
selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined), selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined),
dateRange: selectedDate ? [selectedDate, selectedDate] : undefined, dateRange: selectedDate ? [selectedDate, selectedDate] : undefined,
attendanceAdvanceMinutes: 30,
}); });
}} }}
> >
@@ -1054,6 +1076,12 @@ const SchedulesPage: React.FC = () => {
<strong></strong> <strong></strong>
{s.startTime} ~ {s.endTime} {s.startTime} ~ {s.endTime}
</div> </div>
{!isMaskedSchedule(s) && (
<div>
<strong></strong>
{s.attendanceAdvanceMinutes ?? 30}
</div>
)}
<div> <div>
<strong></strong> <strong></strong>
{s.startDate} ~ {s.endDate} {s.startDate} ~ {s.endDate}

View File

@@ -16,11 +16,13 @@ describe('schedule edit form mapping', () => {
startDate: '2026-07-01', startDate: '2026-07-01',
endDate: '2026-07-31', endDate: '2026-07-31',
notes: '需要投影设备', notes: '需要投影设备',
attendanceAdvanceMinutes: 45,
}); });
expect(values.classroomId).toBe(1); expect(values.classroomId).toBe(1);
expect(values.weekDay).toBe(5); expect(values.weekDay).toBe(5);
expect(values.notes).toBe('需要投影设备'); expect(values.notes).toBe('需要投影设备');
expect(values.attendanceAdvanceMinutes).toBe(45);
expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']); expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([ expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
'2026-07-01', '2026-07-01',
@@ -39,6 +41,7 @@ describe('schedule edit form mapping', () => {
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')], timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')], dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
notes: ' 临时调整教室 ', notes: ' 临时调整教室 ',
attendanceAdvanceMinutes: 20,
}), }),
).toEqual({ ).toEqual({
classId: 1, classId: 1,
@@ -51,6 +54,7 @@ describe('schedule edit form mapping', () => {
startDate: '2026-08-01', startDate: '2026-08-01',
endDate: '2026-08-31', endDate: '2026-08-31',
notes: '临时调整教室', notes: '临时调整教室',
attendanceAdvanceMinutes: 20,
}); });
}); });
}); });
@@ -67,6 +71,7 @@ describe('schedule notes normalization', () => {
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')], timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')], dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
notes: ' ', notes: ' ',
attendanceAdvanceMinutes: 30,
}).notes, }).notes,
).toBeUndefined(); ).toBeUndefined();
}); });

View File

@@ -7,6 +7,7 @@ export interface ScheduleFormValues {
subject: string; subject: string;
teacherId?: number; teacherId?: number;
notes?: string; notes?: string;
attendanceAdvanceMinutes: number;
timeRange: [Dayjs, Dayjs]; timeRange: [Dayjs, Dayjs];
dateRange: [Dayjs, Dayjs]; dateRange: [Dayjs, Dayjs];
} }
@@ -19,6 +20,7 @@ export interface EditableSchedule {
subject: string; subject: string;
teacherId: number | null; teacherId: number | null;
notes?: string | null; notes?: string | null;
attendanceAdvanceMinutes?: number | null;
startTime: string; startTime: string;
endTime: string; endTime: string;
startDate: string; startDate: string;
@@ -32,6 +34,7 @@ export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormVa
subject: schedule.subject, subject: schedule.subject,
teacherId: schedule.teacherId ?? undefined, teacherId: schedule.teacherId ?? undefined,
notes: schedule.notes ?? undefined, notes: schedule.notes ?? undefined,
attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes ?? 30,
timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)], timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)], dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
}); });
@@ -43,6 +46,7 @@ export const buildSchedulePayload = (values: ScheduleFormValues) => ({
subject: values.subject, subject: values.subject,
teacherId: values.teacherId, teacherId: values.teacherId,
notes: values.notes?.trim() || undefined, notes: values.notes?.trim() || undefined,
attendanceAdvanceMinutes: values.attendanceAdvanceMinutes,
startTime: values.timeRange[0].format('HH:mm'), startTime: values.timeRange[0].format('HH:mm'),
endTime: values.timeRange[1].format('HH:mm'), endTime: values.timeRange[1].format('HH:mm'),
startDate: values.dateRange[0].format('YYYY-MM-DD'), startDate: values.dateRange[0].format('YYYY-MM-DD'),

View File

@@ -0,0 +1,102 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Drawer, Form, Input, InputNumber, Modal, Radio, Space, Switch, Table, Tag } from 'antd';
import { HistoryOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
interface WalletRow {
studentId: number;
studentName: string;
studentNo?: string;
balance: number;
outstandingAmount: number;
}
const transactionNames: Record<string, string> = {
recharge: '充值', adjustment: '调账', bill_payment: '账单扣款', bill_refund: '账单冲正',
};
const WalletsPage: React.FC = () => {
const [rows, setRows] = useState<WalletRow[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [debtOnly, setDebtOnly] = useState(false);
const [selected, setSelected] = useState<WalletRow | null>(null);
const [transactions, setTransactions] = useState<any[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const fetchRows = useCallback(async () => {
setLoading(true);
try {
const data = await api.get('/wallets', { params: { keyword: keyword || undefined, debtOnly } });
setRows(data as WalletRow[]);
} catch (error: any) {
message.error(error?.message || '加载学生余额失败');
} finally { setLoading(false); }
}, [keyword, debtOnly]);
useEffect(() => { void fetchRows(); }, [fetchRows]);
const openChange = (row: WalletRow) => {
setSelected(row);
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
};
const submitChange = async () => {
if (!selected) return;
const values = await form.validateFields();
setSaving(true);
try {
const result: any = await api.post('/wallets/change-balance', { studentId: selected.studentId, ...values });
const paid = (result.payments || []).reduce((sum: number, bill: any) => sum + Number(bill.paidAmount || 0), 0);
message.success(paid > 0 ? `余额已更新,并自动补扣历史账单` : '余额已更新');
setSelected(null);
await fetchRows();
} catch (error: any) { message.error(error?.message || '余额操作失败'); }
finally { setSaving(false); }
};
const showTransactions = async (row: WalletRow) => {
setSelected(row); setDrawerOpen(true);
try { setTransactions(await api.get('/wallets/transactions', { params: { studentId: row.studentId } }) as any[]); }
catch (error: any) { message.error(error?.message || '加载流水失败'); }
};
const columns = useMemo(() => [
{ title: '学生', render: (_: unknown, row: WalletRow) => <><strong>{row.studentName}</strong><div style={{ color: '#999' }}>{row.studentNo || `#${row.studentId}`}</div></> },
{ title: '可用余额', dataIndex: 'balance', render: (value: number) => <strong style={{ color: Number(value) > 0 ? '#1677ff' : undefined }}>¥{Number(value).toFixed(2)}</strong> },
{ title: '未付账单', dataIndex: 'outstandingAmount', render: (value: number) => Number(value) > 0 ? <Tag color="red">¥{Number(value).toFixed(2)}</Tag> : <Tag color="green"></Tag> },
{ title: '操作', render: (_: unknown, row: WalletRow) => <Space><PermissionButton permission="wallet:edit" type="primary" size="small" icon={<PlusOutlined />} onClick={() => openChange(row)}>/</PermissionButton><Button size="small" icon={<HistoryOutlined />} onClick={() => showTransactions(row)}></Button></Space> },
], []);
return <div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Space wrap><Input.Search allowClear placeholder="搜索姓名或学号" style={{ width: 240 }} onSearch={setKeyword} onChange={(event) => !event.target.value && setKeyword('')} /><span></span><Switch checked={debtOnly} onChange={setDebtOnly} /></Space>
<Button icon={<ReloadOutlined />} onClick={fetchRows}></Button>
</div>
<Table rowKey="studentId" loading={loading} dataSource={rows} columns={columns} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} />
<Modal title={`${selected?.studentName || ''} - 余额操作`} open={!!selected && !drawerOpen} onCancel={() => setSelected(null)} onOk={submitChange} confirmLoading={saving} okText="确认">
<Form form={form} layout="vertical">
<Form.Item name="type" label="操作类型" rules={[{ required: true }]}><Radio.Group options={[{ label: '充值', value: 'recharge' }, { label: '调账', value: 'adjustment' }]} /></Form.Item>
<Form.Item name="amount" label="变动金额" extra="充值填正数;调减余额时填写负数。充值后会按最早账单优先自动补扣。" rules={[{ required: true, message: '请输入金额' }]}><InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" /></Form.Item>
<Form.Item name="description" label="备注"><Input.TextArea maxLength={300} /></Form.Item>
</Form>
</Modal>
<Drawer title={`${selected?.studentName || ''} - 余额流水`} width={680} open={drawerOpen} onClose={() => { setDrawerOpen(false); setSelected(null); }}>
<Table rowKey="id" dataSource={transactions} pagination={{ pageSize: 10 }} columns={[
{ title: '时间', dataIndex: 'createdAt', render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm') },
{ title: '类型', dataIndex: 'type', render: (value: string) => transactionNames[value] || value },
{ title: '金额', dataIndex: 'amount', render: (value: number) => <span style={{ color: Number(value) >= 0 ? '#389e0d' : '#cf1322' }}>{Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)}</span> },
{ title: '变动后余额', dataIndex: 'balanceAfter', render: (value: number) => `¥${Number(value).toFixed(2)}` },
{ title: '关联账单', dataIndex: 'billId', render: (value: number) => value ? `#${value}` : '-' },
{ title: '说明', dataIndex: 'description' },
]} />
</Drawer>
</div>;
};
export default WalletsPage;

View File

@@ -43,6 +43,8 @@ import {
ArchiveAttachment, ArchiveAttachment,
StudentDingMapping, StudentDingMapping,
AiConfig, AiConfig,
StudentWallet,
WalletTransaction,
} from './entities'; } from './entities';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { AuthorizationModule } from './authorization'; import { AuthorizationModule } from './authorization';
@@ -71,6 +73,7 @@ import { ExpenseTypesModule } from './expense-types/expense-types.module';
import { DatabaseMigrationsModule } from './database/database-migrations.module'; import { DatabaseMigrationsModule } from './database/database-migrations.module';
import { AgentToolsModule } from './agent-tools'; import { AgentToolsModule } from './agent-tools';
import { AiConfigModule } from './ai-config/ai-config.module'; import { AiConfigModule } from './ai-config/ai-config.module';
import { WalletsModule } from './wallets/wallets.module';
import { import {
IntegrationConfig, IntegrationConfig,
@@ -135,6 +138,8 @@ import { IntegrationConfigModule } from './integration/config/config.module';
IntegrationConfig, IntegrationConfig,
IntegrationConfigDetail, IntegrationConfigDetail,
AiConfig, AiConfig,
StudentWallet,
WalletTransaction,
]; ];
if (dbType === 'mysql') { if (dbType === 'mysql') {
return { return {
@@ -168,6 +173,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
DashboardModule, DashboardModule,
OperationLogsModule, OperationLogsModule,
DepositsModule, DepositsModule,
WalletsModule,
ClassroomsModule, ClassroomsModule,
AttendanceModule, AttendanceModule,
ClassesModule, ClassesModule,

View File

@@ -11,6 +11,7 @@ import {
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
Res, Res,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import type { Request as ExpressRequest, Response } from 'express'; import type { Request as ExpressRequest, Response } from 'express';
@@ -47,15 +48,15 @@ export class ArchiveController {
@Get(':studentId') @Get(':studentId')
@RequirePermission('student:view') @RequirePermission('student:view')
async getProfile(@Param('studentId') studentId: string, @Request() req: AuthenticatedRequest) { async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.getProfile(+studentId); const result = await this.archiveService.getProfile(studentId);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '查看档案', action: '查看档案',
targetId: +studentId, targetId: studentId,
targetType: 'archive', targetType: 'archive',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -66,18 +67,18 @@ export class ArchiveController {
@Put(':studentId/profile') @Put(':studentId/profile')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async upsertProfile( async upsertProfile(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Body() dto: UpsertProfileDto, @Body() dto: UpsertProfileDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.upsertProfile(+studentId, dto); const result = await this.archiveService.upsertProfile(studentId, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '更新档案信息', action: '更新档案信息',
targetId: +studentId, targetId: studentId,
targetType: 'student_profile', targetType: 'student_profile',
detail: JSON.stringify(dto), detail: JSON.stringify(dto),
ipAddress, ipAddress,
@@ -89,12 +90,12 @@ export class ArchiveController {
@Post(':studentId/enrollments') @Post(':studentId/enrollments')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async addEnrollment( async addEnrollment(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Body() dto: CreateEnrollmentDto, @Body() dto: CreateEnrollmentDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.addEnrollment(+studentId, dto); const result = await this.archiveService.addEnrollment(studentId, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
@@ -112,18 +113,18 @@ export class ArchiveController {
@Put('enrollments/:id') @Put('enrollments/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async updateEnrollment( async updateEnrollment(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateEnrollmentDto, @Body() dto: UpdateEnrollmentDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.updateEnrollment(+id, dto); const result = await this.archiveService.updateEnrollment(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '编辑报名记录', action: '编辑报名记录',
targetId: +id, targetId: id,
targetType: 'student_enrollment', targetType: 'student_enrollment',
detail: JSON.stringify(dto), detail: JSON.stringify(dto),
ipAddress, ipAddress,
@@ -134,15 +135,15 @@ export class ArchiveController {
@Delete('enrollments/:id') @Delete('enrollments/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async deleteEnrollment(@Param('id') id: string, @Request() req: AuthenticatedRequest) { async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.deleteEnrollment(+id); const result = await this.archiveService.deleteEnrollment(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '删除报名记录', action: '删除报名记录',
targetId: +id, targetId: id,
targetType: 'student_enrollment', targetType: 'student_enrollment',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -153,12 +154,12 @@ export class ArchiveController {
@Post(':studentId/exam-scores') @Post(':studentId/exam-scores')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async addExamScore( async addExamScore(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Body() dto: CreateExamScoreDto, @Body() dto: CreateExamScoreDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.addExamScore(+studentId, dto); const result = await this.archiveService.addExamScore(studentId, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
@@ -176,18 +177,18 @@ export class ArchiveController {
@Put('exam-scores/:id') @Put('exam-scores/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async updateExamScore( async updateExamScore(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateExamScoreDto, @Body() dto: UpdateExamScoreDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.updateExamScore(+id, dto); const result = await this.archiveService.updateExamScore(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '编辑考试成绩', action: '编辑考试成绩',
targetId: +id, targetId: id,
targetType: 'exam_score', targetType: 'exam_score',
detail: JSON.stringify(dto), detail: JSON.stringify(dto),
ipAddress, ipAddress,
@@ -198,15 +199,15 @@ export class ArchiveController {
@Delete('exam-scores/:id') @Delete('exam-scores/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async deleteExamScore(@Param('id') id: string, @Request() req: AuthenticatedRequest) { async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.deleteExamScore(+id); const result = await this.archiveService.deleteExamScore(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '删除考试成绩', action: '删除考试成绩',
targetId: +id, targetId: id,
targetType: 'exam_score', targetType: 'exam_score',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -217,12 +218,12 @@ export class ArchiveController {
@Post(':studentId/learning-records') @Post(':studentId/learning-records')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async addLearningRecord( async addLearningRecord(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Body() dto: CreateLearningRecordDto, @Body() dto: CreateLearningRecordDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.addLearningRecord(+studentId, dto); const result = await this.archiveService.addLearningRecord(studentId, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
@@ -240,18 +241,18 @@ export class ArchiveController {
@Put('learning-records/:id') @Put('learning-records/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async updateLearningRecord( async updateLearningRecord(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateLearningRecordDto, @Body() dto: UpdateLearningRecordDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.updateLearningRecord(+id, dto); const result = await this.archiveService.updateLearningRecord(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '编辑学习记录', action: '编辑学习记录',
targetId: +id, targetId: id,
targetType: 'learning_record', targetType: 'learning_record',
detail: JSON.stringify(dto), detail: JSON.stringify(dto),
ipAddress, ipAddress,
@@ -262,15 +263,15 @@ export class ArchiveController {
@Delete('learning-records/:id') @Delete('learning-records/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async deleteLearningRecord(@Param('id') id: string, @Request() req: AuthenticatedRequest) { async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.deleteLearningRecord(+id); const result = await this.archiveService.deleteLearningRecord(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '删除学习记录', action: '删除学习记录',
targetId: +id, targetId: id,
targetType: 'learning_record', targetType: 'learning_record',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -281,18 +282,18 @@ export class ArchiveController {
@Put(':studentId/result') @Put(':studentId/result')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async upsertResult( async upsertResult(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Body() dto: UpsertResultDto, @Body() dto: UpsertResultDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.upsertResult(+studentId, dto); const result = await this.archiveService.upsertResult(studentId, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '更新录取结果', action: '更新录取结果',
targetId: +studentId, targetId: studentId,
targetType: 'result_archive', targetType: 'result_archive',
detail: JSON.stringify(dto), detail: JSON.stringify(dto),
ipAddress, ipAddress,
@@ -305,13 +306,13 @@ export class ArchiveController {
@RequirePermission('student:edit') @RequirePermission('student:edit')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
async uploadAttachment( async uploadAttachment(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@Body('category') category: string, @Body('category') category: string,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.addAttachment(+studentId, file, category || 'other'); const result = await this.archiveService.addAttachment(studentId, file, category || 'other');
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
@@ -329,13 +330,13 @@ export class ArchiveController {
@Get(':studentId/attachments/:id') @Get(':studentId/attachments/:id')
@RequirePermission('student:view') @RequirePermission('student:view')
async downloadAttachment( async downloadAttachment(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Res() res: Response, @Res() res: Response,
) { ) {
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile( const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(
+studentId, studentId,
+id, id,
); );
res.setHeader('Content-Type', mimeType); res.setHeader('Content-Type', mimeType);
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`); res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`);
@@ -345,15 +346,15 @@ export class ArchiveController {
@Delete('attachments/:id') @Delete('attachments/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async deleteAttachment(@Param('id') id: string, @Request() req: AuthenticatedRequest) { async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.archiveService.deleteAttachment(+id); const result = await this.archiveService.deleteAttachment(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生档案', module: '学生档案',
action: '删除附件', action: '删除附件',
targetId: +id, targetId: id,
targetType: 'archive_attachment', targetType: 'archive_attachment',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -364,7 +365,7 @@ export class ArchiveController {
@Get(':studentId/report-html') @Get(':studentId/report-html')
@RequirePermission('student:view') @RequirePermission('student:view')
async generateReportHtml( async generateReportHtml(
@Param('studentId') studentId: string, @Param('studentId', ParseIntPipe) studentId: number,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
@@ -373,12 +374,12 @@ export class ArchiveController {
username: req.user?.username, username: req.user?.username,
module: 'archive', module: 'archive',
action: 'generate_report_html', action: 'generate_report_html',
targetId: +studentId, targetId: studentId,
targetType: 'student', targetType: 'student',
ipAddress, ipAddress,
userAgent, userAgent,
}); });
const html = await this.reportService.generateReportHtml(+studentId); const html = await this.reportService.generateReportHtml(studentId);
return { html }; return { html };
} }
} }

View File

@@ -17,6 +17,7 @@ describe('ArchiveService.getProfile', () => {
const learningRecordRepo = { find: jest.fn().mockResolvedValue([]) }; const learningRecordRepo = { find: jest.fn().mockResolvedValue([]) };
const resultRepo = { findOne: jest.fn().mockResolvedValue(result) }; const resultRepo = { findOne: jest.fn().mockResolvedValue(result) };
const attachmentRepo = { find: jest.fn().mockResolvedValue([]) }; const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
const attendanceRepo = { find: jest.fn().mockResolvedValue([]) };
const service = new ArchiveService( const service = new ArchiveService(
studentRepo as never, studentRepo as never,
@@ -26,12 +27,13 @@ describe('ArchiveService.getProfile', () => {
learningRecordRepo as never, learningRecordRepo as never,
resultRepo as never, resultRepo as never,
attachmentRepo as never, attachmentRepo as never,
attendanceRepo as never,
{} as never, {} as never,
); );
const response = await service.getProfile(7); const response = await service.getProfile(7);
expect(response).toMatchObject({ student, result }); expect(response).toMatchObject({ student, result, attendances: [] });
expect(response).not.toHaveProperty('resultArchive'); expect(response).not.toHaveProperty('resultArchive');
}); });
}); });

View File

@@ -12,6 +12,7 @@ import { ExamScore } from '../entities/exam-score.entity';
import { LearningRecord } from '../entities/learning-record.entity'; import { LearningRecord } from '../entities/learning-record.entity';
import { ResultArchive } from '../entities/result-archive.entity'; import { ResultArchive } from '../entities/result-archive.entity';
import { ArchiveAttachment } from '../entities/archive-attachment.entity'; import { ArchiveAttachment } from '../entities/archive-attachment.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { import {
UpsertProfileDto, UpsertProfileDto,
CreateEnrollmentDto, CreateEnrollmentDto,
@@ -33,6 +34,7 @@ export class ArchiveService {
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>, @InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>, @InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>, @InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
private readonly notificationsService: NotificationsService, private readonly notificationsService: NotificationsService,
) {} ) {}
@@ -59,7 +61,7 @@ export class ArchiveService {
const student = await this.studentRepo.findOne({ where: { id: studentId } }); const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments] = const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments, attendances] =
await Promise.all([ await Promise.all([
this.profileRepo.findOne({ where: { studentId } }), this.profileRepo.findOne({ where: { studentId } }),
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
@@ -67,6 +69,11 @@ export class ArchiveService {
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }), this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
this.resultRepo.findOne({ where: { studentId } }), this.resultRepo.findOne({ where: { studentId } }),
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
this.attendanceRepo.find({
where: { studentId },
relations: ['schedule', 'class'],
order: { attendanceDate: 'DESC', punchTime: 'DESC' },
}),
]); ]);
return { return {
@@ -77,6 +84,7 @@ export class ArchiveService {
learningRecords, learningRecords,
result: resultArchive, result: resultArchive,
attachments, attachments,
attendances,
}; };
} }

View File

@@ -20,6 +20,14 @@ const createService = () => {
update: jest.fn().mockResolvedValue({ affected: 1 }), update: jest.fn().mockResolvedValue({ affected: 1 }),
}; };
const attendanceService = { const attendanceService = {
getLessonAttendanceImportDateRange: jest.fn().mockImplementation((targetSchedule, lessonDate: string) => {
if (targetSchedule.endTime > targetSchedule.startTime) {
return { startDate: lessonDate, endDate: lessonDate };
}
const next = new Date(`${lessonDate}T00:00:00.000Z`);
next.setUTCDate(next.getUTCDate() + 1);
return { startDate: lessonDate, endDate: next.toISOString().slice(0, 10) };
}),
getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']), getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']),
createLessonAttendanceFromDingTalk: jest.fn().mockImplementation( createLessonAttendanceFromDingTalk: jest.fn().mockImplementation(
async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({ async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({

View File

@@ -110,9 +110,12 @@ export class AttendanceSettlementService {
schedule.teacherId, schedule.teacherId,
schedule.classId, schedule.classId,
); );
const importRange = this.attendanceService.getLessonAttendanceImportDateRange(
schedule,
lessonDate,
);
const imported = await this.importService.importFromDingTalk({ const imported = await this.importService.importFromDingTalk({
startDate: lessonDate, ...importRange,
endDate: this.isOvernight(schedule) ? this.shiftDate(lessonDate, 1) : lessonDate,
userIds, userIds,
autoMatch: true, autoMatch: true,
userId: schedule.teacherId, userId: schedule.teacherId,

View File

@@ -162,6 +162,9 @@ describe('AttendanceController — write data scope', () => {
assertClassAccess: jest.fn(), assertClassAccess: jest.fn(),
getAccessibleClassIds: jest.fn(), getAccessibleClassIds: jest.fn(),
getTeacherClassDingUserIds: jest.fn(), getTeacherClassDingUserIds: jest.fn(),
getLessonAttendanceImportDateRange: jest.fn().mockImplementation(
(_schedule, lessonDate: string) => ({ startDate: lessonDate, endDate: lessonDate }),
),
batchCreate: jest.fn(), batchCreate: jest.fn(),
generateFromSchedules: jest.fn(), generateFromSchedules: jest.fn(),
findAttendanceRecord: jest.fn(), findAttendanceRecord: jest.fn(),

View File

@@ -13,6 +13,7 @@ import {
Res, Res,
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { Observable, filter } from 'rxjs'; import { Observable, filter } from 'rxjs';
import type { Request as ExpressRequest, Response } from 'express'; import type { Request as ExpressRequest, Response } from 'express';
@@ -27,6 +28,7 @@ import {
QueryDingRawDto, QueryDingRawDto,
MatchDingRecordDto, MatchDingRecordDto,
AttendanceReportQueryDto, AttendanceReportQueryDto,
AttendanceAlertsQueryDto,
UpdateAttendanceRecordDto, UpdateAttendanceRecordDto,
GenerateFromSchedulesDto, GenerateFromSchedulesDto,
LessonAttendanceQueryDto, LessonAttendanceQueryDto,
@@ -93,11 +95,11 @@ export class AttendanceController {
@Get('attendance-lessons/schedules/:scheduleId') @Get('attendance-lessons/schedules/:scheduleId')
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
async getLessonAttendance( async getLessonAttendance(
@Param('scheduleId') scheduleId: string, @Param('scheduleId', ParseIntPipe) scheduleId: number,
@Query() query: LessonAttendanceQueryDto, @Query() query: LessonAttendanceQueryDto,
@Request() req: { user: RequestUser }, @Request() req: { user: RequestUser },
) { ) {
const result = await this.service.getLessonAttendance(+scheduleId, query.date); const result = await this.service.getLessonAttendance(scheduleId, query.date);
await this.assertClassAccess(req, result.schedule.classId!); await this.assertClassAccess(req, result.schedule.classId!);
return result; return result;
} }
@@ -105,26 +107,29 @@ export class AttendanceController {
@Post('attendance-lessons/schedules/:scheduleId/pull') @Post('attendance-lessons/schedules/:scheduleId/pull')
@RequirePermission('attendance:create') @RequirePermission('attendance:create')
async pullLessonAttendance( async pullLessonAttendance(
@Param('scheduleId') scheduleId: string, @Param('scheduleId', ParseIntPipe) scheduleId: number,
@Body() dto: StartLessonAttendanceDto, @Body() dto: StartLessonAttendanceDto,
@Request() req: { user: RequestUser }, @Request() req: { user: RequestUser },
) { ) {
const schedule = await this.service.getLessonAttendance(+scheduleId, dto.date); const schedule = await this.service.getLessonAttendance(scheduleId, dto.date);
await this.assertClassAccess(req, schedule.schedule.classId!); await this.assertClassAccess(req, schedule.schedule.classId!);
const importClassIds = await this.service.getTeacherClassDingUserIds( const importClassIds = await this.service.getTeacherClassDingUserIds(
req.user.id, req.user.id,
schedule.schedule.classId!, schedule.schedule.classId!,
this.canManageAllAttendance(req), this.canManageAllAttendance(req),
); );
const importRange = this.service.getLessonAttendanceImportDateRange(
schedule.schedule,
dto.date,
);
const importResult = await this.importService.importFromDingTalk({ const importResult = await this.importService.importFromDingTalk({
startDate: dto.date, ...importRange,
endDate: dto.date,
userIds: importClassIds, userIds: importClassIds,
autoMatch: true, autoMatch: true,
userId: req.user.id, userId: req.user.id,
}); });
const result = await this.service.createLessonAttendanceFromDingTalk( const result = await this.service.createLessonAttendanceFromDingTalk(
+scheduleId, scheduleId,
dto.date, dto.date,
req.user.id, req.user.id,
); );
@@ -143,18 +148,18 @@ export class AttendanceController {
@Post('attendance-lessons/:sessionId/complete') @Post('attendance-lessons/:sessionId/complete')
@RequirePermission('attendance:create') @RequirePermission('attendance:create')
async completeLessonAttendance( async completeLessonAttendance(
@Param('sessionId') sessionId: string, @Param('sessionId', ParseIntPipe) sessionId: number,
@Request() req: { user: RequestUser }, @Request() req: { user: RequestUser },
) { ) {
const session = await this.service.findAttendanceSession(+sessionId); const session = await this.service.findAttendanceSession(sessionId);
await this.assertClassAccess(req, session.classId); await this.assertClassAccess(req, session.classId);
const result = await this.service.completeLessonAttendance(+sessionId, req.user.id); const result = await this.service.completeLessonAttendance(sessionId, req.user.id);
await this.logService.log({ await this.logService.log({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
module: '考勤管理', module: '考勤管理',
action: '完成课程点名', action: '完成课程点名',
targetId: +sessionId, targetId: sessionId,
targetType: 'attendanceSession', targetType: 'attendanceSession',
detail: `班级${session.classId} 日期${session.lessonDate}`, detail: `班级${session.classId} 日期${session.lessonDate}`,
}); });
@@ -278,23 +283,23 @@ export class AttendanceController {
@Put('attendance-records/:id') @Put('attendance-records/:id')
@RequirePermission('attendance:edit', 'attendance:self-edit') @RequirePermission('attendance:edit', 'attendance:self-edit')
async update( async update(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateAttendanceRecordDto, @Body() dto: UpdateAttendanceRecordDto,
@Request() req: any, @Request() req: any,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const existing = await this.service.findAttendanceRecord(+id); const existing = await this.service.findAttendanceRecord(id);
if (existing.classId == null && !this.canManageAllAttendance(req)) { if (existing.classId == null && !this.canManageAllAttendance(req)) {
throw new ForbiddenException('无权修改未关联班级的考勤记录'); throw new ForbiddenException('无权修改未关联班级的考勤记录');
} }
if (existing.classId != null) await this.assertClassAccess(req, existing.classId); if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
const result = await this.service.update(+id, dto); const result = await this.service.update(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '考勤管理', module: '考勤管理',
action: '编辑考勤记录', action: '编辑考勤记录',
targetId: +id, targetId: id,
targetType: 'attendanceRecord', targetType: 'attendanceRecord',
detail: `状态=${result.status}, 备注=${result.remark || ''}`, detail: `状态=${result.status}, 备注=${result.remark || ''}`,
ipAddress, ipAddress,
@@ -306,20 +311,20 @@ export class AttendanceController {
// ── Delete a single attendance record ── // ── Delete a single attendance record ──
@Delete('attendance-records/:id') @Delete('attendance-records/:id')
@RequirePermission('attendance:edit', 'attendance:self-edit') @RequirePermission('attendance:edit', 'attendance:self-edit')
async remove(@Param('id') id: string, @Request() req: any) { async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const existing = await this.service.findAttendanceRecord(+id); const existing = await this.service.findAttendanceRecord(id);
if (existing.classId == null && !this.canManageAllAttendance(req)) { if (existing.classId == null && !this.canManageAllAttendance(req)) {
throw new ForbiddenException('无权删除未关联班级的考勤记录'); throw new ForbiddenException('无权删除未关联班级的考勤记录');
} }
if (existing.classId != null) await this.assertClassAccess(req, existing.classId); if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
const result = await this.service.remove(+id); const result = await this.service.remove(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '考勤管理', module: '考勤管理',
action: '删除考勤记录', action: '删除考勤记录',
targetId: +id, targetId: id,
targetType: 'attendanceRecord', targetType: 'attendanceRecord',
detail: `删除考勤记录 ${id}`, detail: `删除考勤记录 ${id}`,
ipAddress, ipAddress,
@@ -369,18 +374,18 @@ export class AttendanceController {
@Post('ding-attendance-raw/:id/match') @Post('ding-attendance-raw/:id/match')
@RequirePermission('attendance:edit') @RequirePermission('attendance:edit')
async matchDingRecord( async matchDingRecord(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() dto: MatchDingRecordDto, @Body() dto: MatchDingRecordDto,
@Request() req: any, @Request() req: any,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.matchDingRecord(+id, dto); const result = await this.service.matchDingRecord(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '考勤管理', module: '考勤管理',
action: '匹配考勤记录', action: '匹配考勤记录',
targetId: +id, targetId: id,
targetType: 'dingAttendanceRaw', targetType: 'dingAttendanceRaw',
detail: `匹配到学生 ${dto.studentId}`, detail: `匹配到学生 ${dto.studentId}`,
ipAddress, ipAddress,
@@ -458,12 +463,11 @@ export class AttendanceController {
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
async getAlerts( async getAlerts(
@Request() req: { user: RequestUser }, @Request() req: { user: RequestUser },
@Query('days') days?: string, @Query() query: AttendanceAlertsQueryDto,
@Query('threshold') threshold?: string,
) { ) {
return this.service.getAlerts( return this.service.getAlerts(
days ? +days : 14, query.days ?? 14,
threshold ? +threshold : 3, query.threshold ?? 3,
await this.getAccessibleClassIds(req), await this.getAccessibleClassIds(req),
); );
} }

View File

@@ -59,6 +59,7 @@ const endedSchedule = {
subject: '\u6570\u5B66', subject: '\u6570\u5B66',
status: 'active', status: 'active',
scheduleType: 'INTERNAL', scheduleType: 'INTERNAL',
attendanceAdvanceMinutes: 30,
}; };
describe('AttendanceService \u2014 DingTalk course attendance', () => { describe('AttendanceService \u2014 DingTalk course attendance', () => {
@@ -186,6 +187,40 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
]); ]);
}); });
it('counts both OnDuty and OffDuty punches only inside the configured window', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue({ ...endedSchedule, attendanceAdvanceMinutes: 20 });
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
{ studentId: 2, student: { id: 2, name: '李四' } },
{ studentId: 3, student: { id: 3, name: '王五' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T08:40:00+08:00') },
{ matchedStudentId: 2, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:00+08:00') },
{ matchedStudentId: 3, attendanceType: 'OnDuty', checkInTime: new Date('2026-07-11T08:39:59+08:00') },
{ matchedStudentId: 3, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:01+08:00') },
]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present' }),
expect.objectContaining({ studentId: 2, status: 'present' }),
expect.objectContaining({ studentId: 3, status: 'absent' }),
]);
});
it('expands import dates when the pre-class window crosses midnight', () => {
const { service } = createService();
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 },
'2026-07-11',
)).toEqual({ startDate: '2026-07-10', endDate: '2026-07-11' });
});
it('creates local attendance after the lesson starts', async () => { it('creates local attendance after the lesson starts', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } = const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService(); createService();

View File

@@ -175,24 +175,45 @@ export class AttendanceService {
return { schedule, session, records }; return { schedule, session, records };
} }
private getLessonAttendanceWindow(
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): { start: number; end: number; dateFrom: string; dateTo: string } {
const startMinuteOfDay = this.toMinutes(schedule.startTime);
const endMinuteOfDay = this.toMinutes(schedule.endTime);
const advanceMinutes = Math.max(0, schedule.attendanceAdvanceMinutes ?? 30);
const lessonStart = new Date(`${lessonDate}T${schedule.startTime}:00+08:00`).getTime();
let lessonEnd = new Date(`${lessonDate}T${schedule.endTime}:00+08:00`).getTime();
const overnight = endMinuteOfDay <= startMinuteOfDay;
if (overnight) lessonEnd += 24 * 60 * 60 * 1000;
return {
start: lessonStart - advanceMinutes * 60 * 1000,
end: lessonEnd,
dateFrom: advanceMinutes > startMinuteOfDay ? this.shiftDate(lessonDate, -1) : lessonDate,
dateTo: overnight ? this.shiftDate(lessonDate, 1) : lessonDate,
};
}
getLessonAttendanceImportDateRange(
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): { startDate: string; endDate: string } {
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
return { startDate: window.dateFrom, endDate: window.dateTo };
}
private selectDingTalkRecordsForLesson( private selectDingTalkRecordsForLesson(
records: DingAttendanceRaw[], records: DingAttendanceRaw[],
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string, lessonDate: string,
startTime: string,
endTime: string,
): DingAttendanceRaw[] { ): DingAttendanceRaw[] {
const [startHour, startMinute] = startTime.split(':').map(Number); const window = this.getLessonAttendanceWindow(schedule, lessonDate);
const [endHour, endMinute] = endTime.split(':').map(Number); return records.filter((record) => {
const start = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime(); // 上班、下班打卡都有效,按原始记录中实际存在的时间判断。
let end = new Date(`${lessonDate}T${endTime}:00+08:00`).getTime();
if (endHour * 60 + endMinute <= startHour * 60 + startMinute) end += 24 * 60 * 60 * 1000;
const windowStart = start - 3 * 60 * 60 * 1000;
const windowEnd = end + 3 * 60 * 60 * 1000;
const timed = records.filter((record) => {
const time = record.checkInTime ?? record.checkOutTime; const time = record.checkInTime ?? record.checkOutTime;
return time && time.getTime() >= windowStart && time.getTime() <= windowEnd; return time && time.getTime() >= window.start && time.getTime() <= window.end;
}); });
return timed.length > 0 ? timed : records.filter((record) => !record.checkInTime && !record.checkOutTime);
} }
private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string { private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
@@ -291,7 +312,7 @@ export class AttendanceService {
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession); const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord); const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate); const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
const existingRecords = await recordRepo.find({ const existingRecords = await recordRepo.find({
where: { attendanceSessionId: existing.id }, where: { attendanceSessionId: existing.id },
order: { studentId: 'ASC' }, order: { studentId: 'ASC' },
@@ -312,9 +333,8 @@ export class AttendanceService {
const raw = this.selectDingTalkRecordsForLesson( const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(record.studentId) ?? [], rawByStudent.get(record.studentId) ?? [],
schedule,
lessonDate, lessonDate,
schedule.startTime,
schedule.endTime,
); );
record.status = this.mapDingTalkStatus(raw, finalize); record.status = this.mapDingTalkStatus(raw, finalize);
Object.assign(record, this.getLessonPunchMetadata( Object.assign(record, this.getLessonPunchMetadata(
@@ -333,9 +353,8 @@ export class AttendanceService {
if (existingStudentIds.has(classStudent.studentId)) continue; if (existingStudentIds.has(classStudent.studentId)) continue;
const raw = this.selectDingTalkRecordsForLesson( const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [], rawByStudent.get(classStudent.studentId) ?? [],
schedule,
lessonDate, lessonDate,
schedule.startTime,
schedule.endTime,
); );
updatedRecords.push( updatedRecords.push(
recordRepo.create({ recordRepo.create({
@@ -377,7 +396,7 @@ export class AttendanceService {
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession); const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord); const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate); const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
const classStudents = await this.classStudentRepo.find({ const classStudents = await this.classStudentRepo.find({
where: { classId: schedule.classId!, status: 'active' }, where: { classId: schedule.classId!, status: 'active' },
@@ -421,9 +440,8 @@ export class AttendanceService {
const records = classStudents.map((classStudent) => { const records = classStudents.map((classStudent) => {
const raw = this.selectDingTalkRecordsForLesson( const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [], rawByStudent.get(classStudent.studentId) ?? [],
schedule,
lessonDate, lessonDate,
schedule.startTime,
schedule.endTime,
); );
return recordRepo.create({ return recordRepo.create({
studentId: classStudent.studentId, studentId: classStudent.studentId,
@@ -460,6 +478,7 @@ export class AttendanceService {
private async fetchDingTalkRawByStudent( private async fetchDingTalkRawByStudent(
classId: number, classId: number,
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string, lessonDate: string,
): Promise<Map<number, DingAttendanceRaw[]>> { ): Promise<Map<number, DingAttendanceRaw[]>> {
const classStudents = await this.classStudentRepo.find({ const classStudents = await this.classStudentRepo.find({
@@ -467,9 +486,10 @@ export class AttendanceService {
}); });
if (classStudents.length === 0) return new Map(); if (classStudents.length === 0) return new Map();
const studentIds = classStudents.map((cs) => cs.studentId); const studentIds = classStudents.map((cs) => cs.studentId);
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
const rawRecords = await this.dingRawRepo.find({ const rawRecords = await this.dingRawRepo.find({
where: { where: {
attendanceDate: lessonDate, attendanceDate: Between(window.dateFrom, window.dateTo),
matchedStudentId: In(studentIds), matchedStudentId: In(studentIds),
}, },
}); });
@@ -651,6 +671,17 @@ export class AttendanceService {
}); });
} }
private toMinutes(time: string): number {
const [hour, minute] = time.split(':').map(Number);
return hour * 60 + minute;
}
private shiftDate(date: string, days: number): string {
const shifted = new Date(`${date}T00:00:00.000Z`);
shifted.setUTCDate(shifted.getUTCDate() + days);
return shifted.toISOString().slice(0, 10);
}
private mapScheduleTimeToSession(startTime: string): string { private mapScheduleTimeToSession(startTime: string): string {
const hour = parseInt(startTime.slice(0, 2), 10); const hour = parseInt(startTime.slice(0, 2), 10);
if (hour < 8) return 'morning_reading'; if (hour < 8) return 'morning_reading';

View File

@@ -7,6 +7,9 @@ import {
IsIn, IsIn,
ValidateNested, ValidateNested,
IsNotEmpty, IsNotEmpty,
ArrayNotEmpty,
Max,
Min,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
@@ -44,6 +47,7 @@ export class AttendanceRecordItem {
export class BatchCreateAttendanceDto { export class BatchCreateAttendanceDto {
@IsArray() @IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => AttendanceRecordItem) @Type(() => AttendanceRecordItem)
records: AttendanceRecordItem[]; records: AttendanceRecordItem[];
@@ -96,11 +100,14 @@ export class QueryDingRawDto {
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1)
@Type(() => Number) @Type(() => Number)
page?: number; page?: number;
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1)
@Max(200)
@Type(() => Number) @Type(() => Number)
pageSize?: number; pageSize?: number;
} }
@@ -139,11 +146,14 @@ export class QueryAttendanceRecordsDto {
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1)
@Type(() => Number) @Type(() => Number)
page?: number; page?: number;
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1)
@Max(200)
@Type(() => Number) @Type(() => Number)
pageSize?: number; pageSize?: number;
} }
@@ -165,6 +175,22 @@ export class UpdateAttendanceRecordDto {
remark?: string; remark?: string;
} }
export class AttendanceAlertsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
days?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
threshold?: number;
}
export class AttendanceReportQueryDto { export class AttendanceReportQueryDto {
@IsOptional() @IsOptional()
@IsInt() @IsInt()

View File

@@ -5,7 +5,7 @@ import { Bill } from '../entities/bill.entity';
import { BillItem } from '../entities/bill-item.entity'; import { BillItem } from '../entities/bill-item.entity';
import { Deposit } from '../entities/deposit.entity'; import { Deposit } from '../entities/deposit.entity';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit'; import * as PDFDocument from 'pdfkit';
import { Response } from 'express'; import { Response } from 'express';
@Injectable() @Injectable()
@@ -34,20 +34,6 @@ export class BillsExportService {
if (query.status) qb.andWhere('b.status = :status', { status: query.status }); if (query.status) qb.andWhere('b.status = :status', { status: query.status });
const bills = await qb.getMany(); const bills = await qb.getMany();
// 查询涉及学生当前押金余额。账单生成时不冻结押金,导出只展示实时余额和已实际扣款。
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
const depMap = new Map<number, number>();
if (studentIds.length > 0) {
const deposits = await this.depositRepo
.createQueryBuilder('d')
.where('d.studentId IN (:...ids)', { ids: studentIds })
.andWhere('d.status = :status', { status: 'paid' })
.getMany();
for (const d of deposits) {
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0));
}
}
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
workbook.creator = '恭学教育基地管理系统'; workbook.creator = '恭学教育基地管理系统';
@@ -60,8 +46,8 @@ export class BillsExportService {
{ header: '分摊费用', key: 'shared', width: 12 }, { header: '分摊费用', key: 'shared', width: 12 },
{ header: '个人费用', key: 'personal', width: 12 }, { header: '个人费用', key: 'personal', width: 12 },
{ header: '总金额', key: 'total', width: 12 }, { header: '总金额', key: 'total', width: 12 },
{ header: '可用押金', key: 'deposit', width: 12 }, { header: '已扣余额', key: 'paidAmount', width: 12 },
{ header: '已扣押金', key: 'depositDeducted', width: 12 }, { header: '待补缴', key: 'outstandingAmount', width: 12 },
{ header: '状态', key: 'status', width: 10 }, { header: '状态', key: 'status', width: 10 },
{ header: '生成时间', key: 'generatedAt', width: 20 }, { header: '生成时间', key: 'generatedAt', width: 20 },
]; ];
@@ -70,12 +56,13 @@ export class BillsExportService {
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
draft: '草稿', unpaid: '待支付',
partially_paid: '部分支付',
paid: '已结清', paid: '已结清',
cancelled: '已取消',
}; };
for (const bill of bills) { for (const bill of bills) {
const total = Number(bill.totalAmount || 0); const total = Number(bill.totalAmount || 0);
const dep = Number((depMap.get(bill.studentId) || 0).toFixed(2));
ws.addRow({ ws.addRow({
id: bill.id, id: bill.id,
studentName: (bill as any).student?.name || '-', studentName: (bill as any).student?.name || '-',
@@ -83,8 +70,8 @@ export class BillsExportService {
shared: Number(bill.sharedAmount), shared: Number(bill.sharedAmount),
personal: Number(bill.personalAmount), personal: Number(bill.personalAmount),
total, total,
deposit: dep, paidAmount: Number(bill.paidAmount || 0),
depositDeducted: Number(bill.depositDeductedAmount || 0), outstandingAmount: Number(bill.outstandingAmount || 0),
status: statusMap[bill.status] || bill.status, status: statusMap[bill.status] || bill.status,
generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '', generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '',
}); });
@@ -142,15 +129,9 @@ export class BillsExportService {
return; return;
} }
// 查询该学生的当前可用押金。草稿账单只展示余额,不预生成抵扣金额。
const deposits = await this.depositRepo
.createQueryBuilder('d')
.where('d.studentId = :sid', { sid: bill.studentId })
.andWhere('d.status = :status', { status: 'paid' })
.getMany();
const availableDeposit = deposits.reduce((s, d) => s + Number(d.amount || 0), 0);
const totalAmount = Number(bill.totalAmount || 0); const totalAmount = Number(bill.totalAmount || 0);
const depositDeducted = Number(bill.depositDeductedAmount || 0); const paidAmount = Number(bill.paidAmount || 0);
const outstandingAmount = Number(bill.outstandingAmount || 0);
const doc = new PDFDocument({ size: 'A4', margin: 50 }); const doc = new PDFDocument({ size: 'A4', margin: 50 });
res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Type', 'application/pdf');
@@ -185,8 +166,10 @@ export class BillsExportService {
} }
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
draft: '草稿', unpaid: '待支付',
partially_paid: '部分支付',
paid: '已结清', paid: '已结清',
cancelled: '已取消',
}; };
// 标题 // 标题
@@ -216,18 +199,8 @@ export class BillsExportService {
.fillColor('#007AFF') .fillColor('#007AFF')
.text(`应付总额: ¥${totalAmount.toFixed(2)}`); .text(`应付总额: ¥${totalAmount.toFixed(2)}`);
doc.moveDown(0.3); doc.moveDown(0.3);
if (availableDeposit > 0 || depositDeducted > 0) { doc.fontSize(11).fillColor('#389E0D').text(`已扣余额: ¥${paidAmount.toFixed(2)}`);
doc doc.fontSize(14).fillColor(outstandingAmount > 0 ? '#FF3B30' : '#389E0D').text(`待补缴: ¥${outstandingAmount.toFixed(2)}`);
.fontSize(11)
.fillColor('#52C41A')
.text(`可用押金: ¥${availableDeposit.toFixed(2)}`);
if (depositDeducted > 0) {
doc
.fontSize(11)
.fillColor('#FA8C16')
.text(`已扣押金: -¥${depositDeducted.toFixed(2)}`);
}
}
doc.moveDown(1); doc.moveDown(1);
// 明细表格 // 明细表格

View File

@@ -7,11 +7,11 @@ import {
Param, Param,
Body, Body,
Query, Query,
ParseIntPipe,
UseGuards, UseGuards,
Request, Request,
Res, Res,
Req, Req,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm'; import { Repository, In } from 'typeorm';
@@ -21,11 +21,7 @@ import { NotificationType } from '../entities/notification.entity';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { Bill } from '../entities/bill.entity'; import { Bill } from '../entities/bill.entity';
import { BillsExportService } from './bills-export.service'; import { BillsExportService } from './bills-export.service';
import { import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
BatchUpdateBillStatusDto,
GenerateBillsDto,
UpdateBillStatusDto,
} from './dto/bill.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils'; import { extractRequestInfo } from '../common/request-utils';
@@ -80,13 +76,13 @@ export class BillsController {
findAll( findAll(
@Query('periodStart') periodStart?: string, @Query('periodStart') periodStart?: string,
@Query('periodEnd') periodEnd?: string, @Query('periodEnd') periodEnd?: string,
@Query('studentId') studentId?: string, @Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
@Query('status') status?: string, @Query('status') status?: string,
@Query('expenseType') expenseType?: string, @Query('expenseType') expenseType?: string,
) { ) {
return this.service.findAll({ return this.service.findAll({
periodStart, periodEnd, periodStart, periodEnd,
studentId: studentId ? +studentId : undefined, studentId,
status, expenseType, status, expenseType,
}); });
} }
@@ -97,18 +93,50 @@ export class BillsController {
return this.service.findOne(id); return this.service.findOne(id);
} }
// Static routes must be declared before /:id/status, otherwise "batch" is @Put(':id/status')
// treated as an id and converted to NaN by the parameterized route. @RequirePermission('bill:confirm')
async updateStatus(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateBillStatusDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateStatus(id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '确认账单',
targetId: id,
targetType: 'bill',
ipAddress,
userAgent,
});
// Send bill_paid notification
try {
const student = await this.studentRepo.findOne({ where: { id: result.studentId } });
if (student?.userId) {
void this.notificationsService.create({
recipientIds: [student.userId],
type: NotificationType.BILL_PAID,
title: '账单已确认',
content: `账单 #${result.id} 已确认收款,金额: ¥${result.totalAmount}`,
});
}
} catch (_) { /* don't block response */ }
return result;
}
@Put('batch/status') @Put('batch/status')
@RequirePermission('bill:confirm') @RequirePermission('bill:confirm')
async batchUpdateStatus(@Body() body: BatchUpdateBillStatusDto, @Request() req: any) { async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchUpdateStatus(body.ids, body.status); const result = await this.service.batchUpdateStatus(body.ids, body.status);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '账单管理', module: '账单管理',
action: '确认账单并扣押金', action: '确认账单',
detail: `IDs: ${body.ids.join(',')}`, detail: `IDs: ${body.ids.join(',')}`,
ipAddress, ipAddress,
userAgent, userAgent,
@@ -131,37 +159,22 @@ export class BillsController {
return result; return result;
} }
@Put(':id/status') @Post(':id/cancel')
@RequirePermission('bill:confirm') @RequirePermission('bill:delete')
async updateStatus( async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: any) {
@Param('id', ParseIntPipe) id: number, const result = await this.service.cancel(id, dto, req.user?.id);
@Body() dto: UpdateBillStatusDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateStatus(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '账单管理', module: '账单管理',
action: '确认账单并扣押金', action: '取消账单并冲正',
targetId: id, targetId: id,
targetType: 'bill', targetType: 'bill',
detail: dto.reason,
ipAddress, ipAddress,
userAgent, userAgent,
}); });
// Send bill_paid notification
try {
const student = await this.studentRepo.findOne({ where: { id: result.studentId } });
if (student?.userId) {
void this.notificationsService.create({
recipientIds: [student.userId],
type: NotificationType.BILL_PAID,
title: '账单已确认',
content: `账单 #${result.id} 已确认收款,金额: ¥${result.totalAmount}`,
});
}
} catch (_) { /* don't block response */ }
return result; return result;
} }
@@ -205,7 +218,7 @@ export class BillsController {
async exportExcel( async exportExcel(
@Query('periodStart') periodStart?: string, @Query('periodStart') periodStart?: string,
@Query('periodEnd') periodEnd?: string, @Query('periodEnd') periodEnd?: string,
@Query('studentId') studentId?: string, @Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
@Query('status') status?: string, @Query('status') status?: string,
@Res() res?: Response, @Res() res?: Response,
@Req() req?: any, @Req() req?: any,
@@ -224,7 +237,7 @@ export class BillsController {
{ {
periodStart, periodStart,
periodEnd, periodEnd,
studentId: studentId ? +studentId : undefined, studentId,
status, status,
}, },
res!, res!,

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { WalletsModule } from '../wallets/wallets.module';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Bill } from '../entities/bill.entity'; import { Bill } from '../entities/bill.entity';
import { BillItem } from '../entities/bill-item.entity'; import { BillItem } from '../entities/bill-item.entity';
@@ -7,8 +8,8 @@ import { RoomExpense } from '../entities/room-expense.entity';
import { PersonalExpense } from '../entities/personal-expense.entity'; import { PersonalExpense } from '../entities/personal-expense.entity';
import { Occupancy } from '../entities/occupancy.entity'; import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity'; import { Room } from '../entities/room.entity';
import { Deposit } from '../entities/deposit.entity';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { BillsService } from './bills.service'; import { BillsService } from './bills.service';
import { BillsExportService } from './bills-export.service'; import { BillsExportService } from './bills-export.service';
import { BillsController } from './bills.controller'; import { BillsController } from './bills.controller';
@@ -22,10 +23,11 @@ import { BillsController } from './bills.controller';
PersonalExpense, PersonalExpense,
Occupancy, Occupancy,
Room, Room,
Deposit,
Student, Student,
Deposit,
]), ]),
NotificationsModule, NotificationsModule,
WalletsModule,
], ],
controllers: [BillsController], controllers: [BillsController],
providers: [BillsService, BillsExportService], providers: [BillsService, BillsExportService],

View File

@@ -9,6 +9,7 @@ import { PersonalExpense } from '../entities/personal-expense.entity';
import { Occupancy } from '../entities/occupancy.entity'; import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity'; import { Room } from '../entities/room.entity';
import { Deposit } from '../entities/deposit.entity'; import { Deposit } from '../entities/deposit.entity';
import { WalletsService } from '../wallets/wallets.service';
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>; type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
@@ -56,7 +57,23 @@ describe('BillsService — generateBills', () => {
occRepo = mockRepo<Occupancy>(); occRepo = mockRepo<Occupancy>();
roomRepo = mockRepo<Room>(); roomRepo = mockRepo<Room>();
depositRepo = mockRepo<Deposit>(); depositRepo = mockRepo<Deposit>();
dataSource = { transaction: jest.fn(), query: jest.fn().mockResolvedValue([]) }; let nextBillId = 0;
dataSource = {
transaction: jest.fn(async (callback) => callback({
create: (_entity: unknown, value: unknown) => value,
save: jest.fn(async (value: any) => {
if ('totalAmount' in value && 'studentId' in value) {
const saved = { id: ++nextBillId, ...value };
await (billRepo.save as jest.Mock)(saved);
return saved;
}
await (itemRepo.save as jest.Mock)(value);
return { id: value.id || 1, ...value };
}),
createQueryBuilder: jest.fn(() => ({ update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })),
})),
query: jest.fn().mockResolvedValue([]),
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
@@ -69,6 +86,7 @@ describe('BillsService — generateBills', () => {
{ provide: getRepositoryToken(Room), useValue: roomRepo }, { provide: getRepositoryToken(Room), useValue: roomRepo },
{ provide: getRepositoryToken(Deposit), useValue: depositRepo }, { provide: getRepositoryToken(Deposit), useValue: depositRepo },
{ provide: DataSource, useValue: dataSource }, { provide: DataSource, useValue: dataSource },
{ provide: WalletsService, useValue: { debitBill: jest.fn(async (_manager, bill) => bill), refundBill: jest.fn() } },
], ],
}).compile(); }).compile();

View File

@@ -7,8 +7,9 @@ import { RoomExpense } from '../entities/room-expense.entity';
import { PersonalExpense } from '../entities/personal-expense.entity'; import { PersonalExpense } from '../entities/personal-expense.entity';
import { Occupancy } from '../entities/occupancy.entity'; import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity'; import { Room } from '../entities/room.entity';
import { Deposit } from '../entities/deposit.entity'; import { StudentWallet } from '../entities/student-wallet.entity';
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
import { WalletsService } from '../wallets/wallets.service';
@Injectable() @Injectable()
@@ -20,26 +21,47 @@ export class BillsService {
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>, @InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>, @InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
@InjectRepository(Room) private roomRepo: Repository<Room>, @InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
private dataSource: DataSource, private dataSource: DataSource,
private walletsService: WalletsService,
) {} ) {}
/** /**
* 核心计费引擎:按"人天数"加权分摊 * 核心计费引擎:按"人天数"加权分摊
*/ */
async generateBills(dto: GenerateBillsDto) { async generateBills(dto: GenerateBillsDto) {
const { periodStart, periodEnd } = this.resolveBillingPeriod(dto.billingMonth); const { periodStart, periodEnd } = dto.billingMonth
? this.resolveBillingPeriod(dto.billingMonth)
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
const pStart = new Date(periodStart); const pStart = new Date(periodStart);
const pEnd = new Date(periodEnd); const pEnd = new Date(periodEnd);
const existingBills = await this.billRepo.find({ const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
where: { periodStart, periodEnd },
});
if (existingBills.length > 0) { if (existingBills.length > 0) {
throw new BadRequestException(`${dto.billingMonth} 账单已生成,不能重复生成`); throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`);
} }
// 获取账单周期内所有有费用的宿舍 const existingDrafts: Bill[] = [];
if (existingDrafts.length > 0) {
const draftIds = existingDrafts.map((b) => b.id);
await this.personalExpRepo
.createQueryBuilder()
.update()
.set({ billId: null })
.where('billId IN (:...ids)', { ids: draftIds })
.execute();
await this.itemRepo
.createQueryBuilder()
.delete()
.where('billId IN (:...ids)', { ids: draftIds })
.execute();
await this.billRepo
.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids: draftIds })
.execute();
}
// 获取所有有费用的宿舍
const roomExpenses = await this.roomExpRepo const roomExpenses = await this.roomExpRepo
.createQueryBuilder('e') .createQueryBuilder('e')
.where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', {
@@ -147,6 +169,7 @@ export class BillsService {
periodStart, periodStart,
periodEnd, periodEnd,
}) })
.andWhere('pe.billId IS NULL')
.getMany(); .getMany();
const personalMap = new Map<number, number>(); const personalMap = new Map<number, number>();
@@ -175,63 +198,100 @@ export class BillsService {
const personal = personalMap.get(studentId) || 0; const personal = personalMap.get(studentId) || 0;
const total = Number((shared + personal).toFixed(2)); const total = Number((shared + personal).toFixed(2));
const bill = this.billRepo.create({ const savedBill = await this.dataSource.transaction(async (manager) => {
studentId, let bill = await manager.save(
periodStart, manager.create(Bill, {
periodEnd, studentId,
sharedAmount: Number(shared.toFixed(2)), periodStart,
personalAmount: personal, periodEnd,
totalAmount: total, sharedAmount: Number(shared.toFixed(2)),
status: 'draft', personalAmount: personal,
totalAmount: total,
source: 'batch',
paidAmount: 0,
outstandingAmount: total,
status: 'unpaid',
}),
);
const items = [
...(studentBillData.get(studentId)?.items || []),
...(personalItems.get(studentId) || []),
];
for (const item of items) {
await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));
}
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
if (includedPersonal.length) {
await manager
.createQueryBuilder()
.update(PersonalExpense)
.set({ billId: bill.id })
.where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) })
.execute();
}
bill = await this.walletsService.debitBill(manager, bill);
return bill;
}); });
const savedBill = await this.billRepo.save(bill);
// 保存明细
const items = [
...(studentBillData.get(studentId)?.items || []),
...(personalItems.get(studentId) || []),
];
for (const item of items) {
await this.itemRepo.save(this.itemRepo.create({ ...item, billId: savedBill.id }));
}
bills.push(savedBill); bills.push(savedBill);
} }
return { return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd };
message: `成功生成 ${dto.billingMonth}${bills.length} 条账单`,
count: bills.length,
periodStart,
periodEnd,
bills,
};
} }
private resolveBillingPeriod(billingMonth: string) { private resolveBillingPeriod(billingMonth: string) {
const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || ''); const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || '');
if (!matched) { if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
}
const year = Number(matched[1]); const year = Number(matched[1]);
const month = Number(matched[2]); const month = Number(matched[2]);
if (month < 1 || month > 12) { if (month < 1 || month > 12) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
}
const targetMonthStart = new Date(year, month - 1, 1); const targetMonthStart = new Date(year, month - 1, 1);
const currentMonthStart = new Date(); const currentMonthStart = new Date();
currentMonthStart.setDate(1); currentMonthStart.setDate(1);
currentMonthStart.setHours(0, 0, 0, 0); currentMonthStart.setHours(0, 0, 0, 0);
if (targetMonthStart >= currentMonthStart) { if (targetMonthStart >= currentMonthStart) throw new BadRequestException('只能生成已结束月份的账单');
throw new BadRequestException('只能生成已结束月份的账单');
}
const targetMonthEnd = new Date(year, month, 0); const targetMonthEnd = new Date(year, month, 0);
const pad = (value: number) => String(value).padStart(2, '0'); const pad = (value: number) => String(value).padStart(2, '0');
return { return { periodStart: `${year}-${pad(month)}-01`, periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}` };
periodStart: `${year}-${pad(month)}-01`, }
periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}`,
}; async createImmediatePersonalBill(
expense: PersonalExpense,
periodStart: string,
periodEnd: string,
recordedBy?: number,
) {
return this.dataSource.transaction(async (manager) => {
let bill = await manager.save(
manager.create(Bill, {
studentId: expense.studentId,
periodStart,
periodEnd,
sharedAmount: 0,
personalAmount: Number(expense.amount),
totalAmount: Number(expense.amount),
source: 'student_utility',
paidAmount: 0,
outstandingAmount: Number(expense.amount),
status: 'unpaid',
}),
);
await manager.save(
manager.create(BillItem, {
billId: bill.id,
roomId: expense.roomId,
expenseType: expense.expenseType,
description: expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'),
days: 0,
totalRoomDays: 0,
roomTotalAmount: expense.amount,
studentAmount: expense.amount,
}),
);
expense.billId = bill.id;
await manager.save(expense);
bill = await this.walletsService.debitBill(manager, bill, recordedBy);
return bill;
});
} }
async findAll(query?: { async findAll(query?: {
@@ -263,107 +323,80 @@ export class BillsService {
return withDeposit; return withDeposit;
} }
/** /** 查询时附加钱包余额和实际支付数据。 */
* 给账单挂上"押金联动"信息:
* - availableDeposit: 学生当前实时可用押金余额,生成账单时不会冻结
* - depositSufficient: 草稿账单是否已有足够余额可确认
* - depositDeductedAmount: 已确认账单实际扣除的押金金额
*/
private async attachDepositInfo(bills: Bill[]): Promise<any[]> { private async attachDepositInfo(bills: Bill[]): Promise<any[]> {
if (!bills || bills.length === 0) return bills; if (!bills?.length) return bills;
const studentIds = Array.from(new Set(bills.map((b) => b.studentId))); const studentIds = Array.from(new Set(bills.map((bill) => bill.studentId)));
if (studentIds.length === 0) return bills; const wallets = await this.dataSource
const deposits = await this.depositRepo .getRepository(StudentWallet)
.createQueryBuilder('d') .createQueryBuilder('wallet')
.where('d.studentId IN (:...ids)', { ids: studentIds }) .where('wallet.studentId IN (:...ids)', { ids: studentIds })
.getMany(); .getMany();
const depMap = new Map<number, number>(); const balanceMap = new Map(wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]));
for (const d of deposits) { return bills.map((bill) => ({
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0)); ...bill,
} walletBalance: Number((balanceMap.get(bill.studentId) || 0).toFixed(2)),
return bills.map((b) => { paidAmount: Number(bill.paidAmount || 0),
const total = Number(b.totalAmount || 0); outstandingAmount: Number(bill.outstandingAmount || 0),
const available = Number((depMap.get(b.studentId) || 0).toFixed(2)); }));
return Object.assign({}, b, {
availableDeposit: available,
depositSufficient: available >= total,
depositDeductedAmount: Number(b.depositDeductedAmount || 0),
});
});
} }
async updateStatus(id: number, dto: UpdateBillStatusDto) { async updateStatus(id: number, dto: UpdateBillStatusDto) {
if (dto.status !== 'paid') { const bill = await this.billRepo.findOne({ where: { id } });
throw new BadRequestException('账单只能通过确认支付完成扣款'); if (!bill) throw new NotFoundException('账单不存在');
if (dto.status === 'paid' && Number(bill.outstandingAmount) > 0) {
throw new BadRequestException('存在未付金额,不能直接标记为已支付');
} }
return this.dataSource.transaction((manager) => this.payBill(manager, id)); bill.status = dto.status;
return this.billRepo.save(bill);
} }
async batchUpdateStatus(ids: number[], status: string) { async batchUpdateStatus(ids: number[], status: string) {
if (status !== 'paid') { const bills = await this.billRepo.find({ where: { id: In(ids) } });
throw new BadRequestException('账单只能通过确认支付完成扣款'); if (status === 'paid' && bills.some((bill) => Number(bill.outstandingAmount) > 0)) {
throw new BadRequestException('选中账单存在未付金额,不能直接标记为已支付');
} }
const uniqueIds = Array.from(new Set(ids)); await this.billRepo
await this.dataSource.transaction(async (manager) => { .createQueryBuilder()
for (const id of uniqueIds) await this.payBill(manager, id); .update()
}); .set({ status })
return { message: `成功确认 ${uniqueIds.length} 条账单并扣除押金` }; .where('id IN (:...ids)', { ids })
.execute();
return { message: `成功更新 ${ids.length} 条账单状态` };
} }
private async payBill(manager: EntityManager, id: number) { async cancel(id: number, dto: CancelBillDto, recordedBy?: number) {
const billRepo = manager.getRepository(Bill); return this.dataSource.transaction(async (manager) => {
const depositRepo = manager.getRepository(Deposit); const bill = await manager.findOne(Bill, { where: { id } });
const lock = this.supportsPessimisticLocks() if (!bill) throw new NotFoundException('账单不存在');
? ({ mode: 'pessimistic_write' } as const) if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消');
: undefined; await manager.update(PersonalExpense, { billId: id }, { billId: null });
const bill = await billRepo.findOne({ where: { id }, ...(lock ? { lock } : {}) }); return this.walletsService.refundBill(manager, bill, dto.reason, recordedBy);
if (!bill) throw new NotFoundException(`账单 ${id} 不存在`);
if (bill.status === 'paid') return bill;
if (bill.status !== 'draft') throw new BadRequestException(`账单 ${id} 当前状态无法确认支付`);
const deposit = await depositRepo.findOne({
where: { studentId: bill.studentId },
...(lock ? { lock } : {}),
}); });
const available = Number(deposit?.amount || 0);
const required = Number(bill.totalAmount || 0);
if (!deposit || available < required) {
throw new BadRequestException(
`账单 ${id} 押金不足:需 ¥${required.toFixed(2)},当前可用 ¥${available.toFixed(2)},请先到押金管理收取押金`,
);
}
deposit.amount = Number((available - required).toFixed(2));
deposit.status = deposit.amount > 0 ? 'paid' : 'depleted';
bill.depositDeductedAmount = required;
bill.status = 'paid';
await depositRepo.save(deposit);
return billRepo.save(bill);
}
private supportsPessimisticLocks() {
return ['mysql', 'mariadb', 'postgres', 'cockroachdb', 'mssql', 'oracle'].includes(
String(this.dataSource.options.type),
);
} }
async remove(id: number) { async remove(id: number) {
const exists = await this.billRepo.findOne({ where: { id } }); const exists = await this.billRepo.findOne({ where: { id } });
if (!exists) throw new NotFoundException('账单不存在'); if (!exists) throw new NotFoundException('账单不存在');
if (exists.status === 'paid') throw new BadRequestException('已支付账单不能删除'); if (Number(exists.paidAmount) > 0 || exists.status === 'cancelled') {
throw new BadRequestException('已发生资金流水的账单不能删除,请使用取消账单');
}
await this.itemRepo.delete({ billId: id }); await this.itemRepo.delete({ billId: id });
await this.personalExpRepo.update({ billId: id }, { billId: null });
await this.billRepo.delete(id); await this.billRepo.delete(id);
return { message: '账单已删除' }; return { message: '账单已删除' };
} }
async batchRemove(ids: number[]) { async batchRemove(ids: number[]) {
const bills = await this.billRepo.find({ where: { id: In(ids) } }); const bills = await this.billRepo.find({ where: { id: In(ids) } });
if (bills.some((bill) => bill.status === 'paid')) { if (bills.some((bill) => Number(bill.paidAmount) > 0 || bill.status === 'cancelled')) {
throw new BadRequestException('已支付账单不能删除'); throw new BadRequestException('选中账单包含资金流水,不能批量删除');
} }
await this.itemRepo await this.itemRepo.createQueryBuilder().delete().where('billId IN (:...ids)', { ids }).execute();
await this.personalExpRepo
.createQueryBuilder() .createQueryBuilder()
.delete() .update()
.set({ billId: null })
.where('billId IN (:...ids)', { ids }) .where('billId IN (:...ids)', { ids })
.execute(); .execute();
await this.billRepo.createQueryBuilder().delete().where('id IN (:...ids)', { ids }).execute(); await this.billRepo.createQueryBuilder().delete().where('id IN (:...ids)', { ids }).execute();

View File

@@ -1,22 +1,28 @@
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsOptional, IsString, Matches } from 'class-validator'; import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class GenerateBillsDto { export class GenerateBillsDto {
@IsString() @IsString()
@Matches(/^\d{4}-\d{2}$/) @Matches(/^\d{4}-\d{2}$/)
billingMonth: string; // YYYY-MM billingMonth: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
periodStart?: string; // deprecated, derived from billingMonth periodStart?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
periodEnd?: string; // deprecated, derived from billingMonth periodEnd?: string;
} }
export class UpdateBillStatusDto { export class UpdateBillStatusDto {
@IsIn(['paid']) @IsIn(['unpaid', 'partially_paid', 'paid'])
status: 'paid'; status: 'unpaid' | 'partially_paid' | 'paid';
}
export class CancelBillDto {
@IsString()
@MaxLength(300)
reason: string;
} }
export class BatchUpdateBillStatusDto extends UpdateBillStatusDto { export class BatchUpdateBillStatusDto extends UpdateBillStatusDto {

View File

@@ -11,6 +11,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
async onApplicationBootstrap(): Promise<void> { async onApplicationBootstrap(): Promise<void> {
await this.ensureAiConfigTable(); await this.ensureAiConfigTable();
await this.ensureCourseAttendanceSchema(); await this.ensureCourseAttendanceSchema();
await this.ensureStudentWalletSchema();
await this.backfillOrganizations(); await this.backfillOrganizations();
await this.normalizeClassDates(); await this.normalizeClassDates();
await this.protectAttendanceHistory(); await this.protectAttendanceHistory();
@@ -21,6 +22,49 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
await this.normalizeClassroomStatuses(); await this.normalizeClassroomStatuses();
} }
private async ensureStudentWalletSchema(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const isMySQL = this.dataSource.options.type === 'mysql';
const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
await runner.query(`CREATE TABLE IF NOT EXISTS student_wallets (
id ${pk}, student_id INTEGER NOT NULL UNIQUE, balance DECIMAL(12,2) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
await runner.query(`CREATE TABLE IF NOT EXISTS wallet_transactions (
id ${pk}, student_id INTEGER NOT NULL, bill_id INTEGER, type VARCHAR(30) NOT NULL,
amount DECIMAL(12,2) NOT NULL, balance_after DECIMAL(12,2) NOT NULL,
description VARCHAR(300), recorded_by INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
const bills = await runner.getTable('bills');
if (bills) {
const columns = new Set(bills.columns.map((column) => column.name));
const additions = [
['source', "VARCHAR(30) NOT NULL DEFAULT 'batch'"],
['paid_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'],
['outstanding_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'],
['cancelled_at', 'DATETIME'],
['cancel_reason', 'VARCHAR(300)'],
];
for (const [name, definition] of additions) {
if (!columns.has(name)) await runner.query(`ALTER TABLE bills ADD COLUMN ${name} ${definition}`);
}
await runner.query("UPDATE bills SET outstanding_amount = total_amount WHERE outstanding_amount = 0 AND status <> 'paid'");
await runner.query("UPDATE bills SET paid_amount = total_amount, outstanding_amount = 0 WHERE status = 'paid'");
await runner.query("UPDATE bills SET status = 'unpaid' WHERE status IN ('draft', 'confirmed')");
}
const personalExpenses = await runner.getTable('personal_expenses');
if (personalExpenses && !personalExpenses.columns.some((column) => column.name === 'bill_id')) {
await runner.query('ALTER TABLE personal_expenses ADD COLUMN bill_id INTEGER');
}
} finally {
await runner.release();
}
}
private async removeUnusedClassroomColumns(): Promise<void> { private async removeUnusedClassroomColumns(): Promise<void> {
const runner = this.dataSource.createQueryRunner(); const runner = this.dataSource.createQueryRunner();
await runner.connect(); await runner.connect();
@@ -216,7 +260,11 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
const runner = this.dataSource.createQueryRunner(); const runner = this.dataSource.createQueryRunner();
await runner.connect(); await runner.connect();
try { try {
const tables = await runner.getTables(['attendance_records', 'attendance_sessions']); const tables = await runner.getTables([
'class_schedule',
'attendance_records',
'attendance_sessions',
]);
const tableNames = new Set(tables.map((table) => table.name)); const tableNames = new Set(tables.map((table) => table.name));
const isMySQL = this.dataSource.options.type === 'mysql'; const isMySQL = this.dataSource.options.type === 'mysql';
@@ -243,6 +291,17 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
`); `);
} }
if (tableNames.has('class_schedule')) {
const scheduleTable = await runner.getTable('class_schedule');
const scheduleColumns = new Set(scheduleTable?.columns.map((column) => column.name) ?? []);
if (!scheduleColumns.has('attendance_advance_minutes')) {
await runner.query(
'ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes INTEGER NOT NULL DEFAULT 30',
);
this.logger.log('已为排课添加课前签到分钟配置');
}
}
const attendanceTable = await runner.getTable('attendance_records'); const attendanceTable = await runner.getTable('attendance_records');
const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []); const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []);
if (!columnNames.has('schedule_id')) { if (!columnNames.has('schedule_id')) {

View File

@@ -209,6 +209,28 @@ describe('DatabaseMigrationsService — course attendance schema', () => {
expect(runner.release).toHaveBeenCalled(); expect(runner.release).toHaveBeenCalled();
}); });
it('adds the configurable attendance window to existing schedules', async () => {
const runner = mockRunner({
getTables: [
{ name: 'class_schedule', columns: [{ name: 'id' }] },
{ name: 'attendance_records', columns: [{ name: 'id' }] },
{ name: 'attendance_sessions', columns: [{ name: 'id' }] },
],
});
runner.getTable.mockImplementation(async (name: string) =>
name === 'class_schedule'
? { name, columns: [{ name: 'id' }] }
: { name, columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }] },
);
await bootstrapCourseAttendance(runner);
await service.ensureCourseAttendanceSchema();
expect(runner.query).toHaveBeenCalledWith(
expect.stringContaining('ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes'),
);
});
it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => { it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => {
const runner = mockRunner({ const runner = mockRunner({
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }], getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],

View File

@@ -9,6 +9,7 @@ import {
Query, Query,
UseGuards, UseGuards,
Request, Request,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -16,7 +17,12 @@ import { Student } from '../entities/student.entity';
import { DepositsService } from './deposits.service'; import { DepositsService } from './deposits.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity'; import { NotificationType } from '../entities/notification.entity';
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; import {
CreateDepositDto,
CreateDepositInstallmentDto,
RefundDepositDto,
UpdateDepositInstallmentDto,
} from './dto/deposit.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils'; import { extractRequestInfo } from '../common/request-utils';
@@ -40,9 +46,12 @@ export class DepositsController {
@Get() @Get()
@RequirePermission('deposit:view') @RequirePermission('deposit:view')
findAll(@Query('studentId') studentId?: string, @Query('status') status?: string) { findAll(
@Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
@Query('status') status?: string,
) {
return this.service.findAll({ return this.service.findAll({
studentId: studentId ? +studentId : undefined, studentId,
status: status || undefined, status: status || undefined,
}); });
} }
@@ -55,8 +64,8 @@ export class DepositsController {
@Get(':id') @Get(':id')
@RequirePermission('deposit:view') @RequirePermission('deposit:view')
findOne(@Param('id') id: string) { findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(+id); return this.service.findOne(id);
} }
@Post() @Post()
@@ -93,12 +102,12 @@ export class DepositsController {
@Post(':id/installments') @Post(':id/installments')
@RequirePermission('deposit:edit') @RequirePermission('deposit:edit')
async addInstallment( async addInstallment(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() body: { amount: number; dueDate: string }, @Body() body: CreateDepositInstallmentDto,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.addInstallment(+id, body.amount, body.dueDate); const result = await this.service.addInstallment(id, body.amount, body.dueDate);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
@@ -116,18 +125,18 @@ export class DepositsController {
@Put('installments/:installmentId') @Put('installments/:installmentId')
@RequirePermission('deposit:edit') @RequirePermission('deposit:edit')
async updateInstallment( async updateInstallment(
@Param('installmentId') installmentId: string, @Param('installmentId', ParseIntPipe) installmentId: number,
@Body() body: { paidDate?: string; status?: string }, @Body() body: UpdateDepositInstallmentDto,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateInstallment(+installmentId, body); const result = await this.service.updateInstallment(installmentId, body);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '押金管理', module: '押金管理',
action: '更新分期', action: '更新分期',
targetId: +installmentId, targetId: installmentId,
targetType: 'deposit-installment', targetType: 'deposit-installment',
detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`, detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
ipAddress, ipAddress,
@@ -139,17 +148,17 @@ export class DepositsController {
@Delete('installments/:installmentId') @Delete('installments/:installmentId')
@RequirePermission('deposit:delete') @RequirePermission('deposit:delete')
async deleteInstallment( async deleteInstallment(
@Param('installmentId') installmentId: string, @Param('installmentId', ParseIntPipe) installmentId: number,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.deleteInstallment(+installmentId); const result = await this.service.deleteInstallment(installmentId);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '押金管理', module: '押金管理',
action: '删除分期', action: '删除分期',
targetId: +installmentId, targetId: installmentId,
targetType: 'deposit-installment', targetType: 'deposit-installment',
detail: `删除分期${installmentId}`, detail: `删除分期${installmentId}`,
ipAddress, ipAddress,
@@ -160,15 +169,15 @@ export class DepositsController {
@Put(':id/refund') @Put(':id/refund')
@RequirePermission('deposit:refund') @RequirePermission('deposit:refund')
async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) { async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.refund(+id, dto, req.user?.id); const result = await this.service.refund(id, dto, req.user?.id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '押金管理', module: '押金管理',
action: '退还押金', action: '退还押金',
targetId: +id, targetId: id,
targetType: 'deposit', targetType: 'deposit',
detail: `退还全部可用押金 ¥${result.refundAmount}`, detail: `退还全部可用押金 ¥${result.refundAmount}`,
ipAddress, ipAddress,
@@ -191,15 +200,15 @@ export class DepositsController {
@Delete(':id') @Delete(':id')
@RequirePermission('deposit:delete') @RequirePermission('deposit:delete')
async remove(@Param('id') id: string, @Request() req: any) { async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id); const result = await this.service.remove(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '押金管理', module: '押金管理',
action: '删除押金记录', action: '删除押金记录',
targetId: +id, targetId: id,
targetType: 'deposit', targetType: 'deposit',
ipAddress, ipAddress,
userAgent, userAgent,

View File

@@ -2,7 +2,7 @@ import { DepositsService } from './deposits.service';
import { Deposit } from '../entities/deposit.entity'; import { Deposit } from '../entities/deposit.entity';
describe('DepositsService — direct refund', () => { describe('DepositsService — direct refund', () => {
it('stores the refund result on the main status and renamed audit fields', async () => { it('refunds the full available balance and stores audit fields', async () => {
const deposit = { const deposit = {
id: 1, id: 1,
amount: 500, amount: 500,
@@ -16,20 +16,16 @@ describe('DepositsService — direct refund', () => {
const result = await service.refund( const result = await service.refund(
1, 1,
{ { refundDate: '2026-07-13', notes: '退还剩余押金' },
refundDate: '2026-07-13',
deductionAmount: 100,
deductionReason: '物品损坏',
},
42, 42,
); );
expect(result).toMatchObject({ expect(result).toMatchObject({
refundDate: '2026-07-13', refundDate: '2026-07-13',
refundAmount: 400, amount: 0,
deductionAmount: 100, refundAmount: 500,
deductionReason: '物品损坏', notes: '退还剩余押金',
status: 'partial_refund', status: 'refunded',
refundedBy: 42, refundedBy: 42,
}); });
expect(result.refundedAt).toBeInstanceOf(Date); expect(result.refundedAt).toBeInstanceOf(Date);

View File

@@ -1,13 +1,14 @@
import { IsInt, IsNumber, IsString, IsOptional } from 'class-validator'; import { IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator';
export class CreateDepositDto { export class CreateDepositDto {
@IsInt() @IsInt()
studentId: number; studentId: number;
@IsNumber() @IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
amount: number; amount: number;
@IsString() @IsDateString()
paidDate: string; paidDate: string;
@IsOptional() @IsOptional()
@@ -16,10 +17,29 @@ export class CreateDepositDto {
} }
export class RefundDepositDto { export class RefundDepositDto {
@IsString() @IsDateString()
refundDate: string; refundDate: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
notes?: string; notes?: string;
} }
export class CreateDepositInstallmentDto {
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
amount: number;
@IsDateString()
dueDate: string;
}
export class UpdateDepositInstallmentDto {
@IsOptional()
@IsDateString()
paidDate?: string;
@IsOptional()
@IsIn(['pending', 'paid', 'overdue'])
status?: string;
}

View File

@@ -33,12 +33,24 @@ export class Bill {
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) @Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
totalAmount: number; totalAmount: number;
@Column({ name: 'deposit_deducted_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) @Column({ type: 'varchar', length: 30, default: 'batch' })
depositDeductedAmount: number; source: 'batch' | 'student_utility';
@Column({ type: 'varchar', length: 20, default: 'draft' }) @Column({ name: 'paid_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
paidAmount: number;
@Column({ name: 'outstanding_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
outstandingAmount: number;
@Column({ type: 'varchar', length: 20, default: 'unpaid' })
status: string; status: string;
@Column({ name: 'cancelled_at', type: 'datetime', nullable: true })
cancelledAt: Date | null;
@Column({ name: 'cancel_reason', type: 'varchar', length: 300, nullable: true })
cancelReason: string | null;
@CreateDateColumn({ name: 'generated_at' }) @CreateDateColumn({ name: 'generated_at' })
generatedAt: Date; generatedAt: Date;

View File

@@ -45,6 +45,10 @@ export class ClassSchedule {
@Column({ name: 'end_time', length: 5 }) @Column({ name: 'end_time', length: 5 })
endTime: string; endTime: string;
/** 课程开始前允许计入签到的分钟数。 */
@Column({ name: 'attendance_advance_minutes', type: 'integer', default: 30 })
attendanceAdvanceMinutes: number;
@Column({ name: 'start_date', type: 'date' }) @Column({ name: 'start_date', type: 'date' })
startDate: string; startDate: string;

View File

@@ -35,3 +35,6 @@ export { ResultArchive } from './result-archive.entity';
export { ArchiveAttachment } from './archive-attachment.entity'; export { ArchiveAttachment } from './archive-attachment.entity';
export { StudentDingMapping } from './student-ding-mapping.entity'; export { StudentDingMapping } from './student-ding-mapping.entity';
export { AiConfig } from '../ai-config/ai-config.entity'; export { AiConfig } from '../ai-config/ai-config.entity';
export * from './student-wallet.entity';
export * from './wallet-transaction.entity';

View File

@@ -34,6 +34,9 @@ export class PersonalExpense {
@Column({ name: 'recorded_by', nullable: true }) @Column({ name: 'recorded_by', nullable: true })
recordedBy: number; recordedBy: number;
@Column({ name: 'bill_id', type: 'integer', nullable: true })
billId: number | null;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;

View File

@@ -0,0 +1,32 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Student } from './student.entity';
@Entity('student_wallets')
export class StudentWallet {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'student_id', type: 'integer', unique: true })
studentId: number;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
balance: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@OneToOne(() => Student, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'student_id' })
student: Student;
}

View File

@@ -0,0 +1,32 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('wallet_transactions')
@Index(['studentId', 'createdAt'])
export class WalletTransaction {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'student_id', type: 'integer' })
studentId: number;
@Column({ name: 'bill_id', type: 'integer', nullable: true })
billId: number | null;
@Column({ type: 'varchar', length: 30 })
type: 'recharge' | 'adjustment' | 'bill_payment' | 'bill_refund';
@Column({ type: 'decimal', precision: 12, scale: 2 })
amount: number;
@Column({ name: 'balance_after', type: 'decimal', precision: 12, scale: 2 })
balanceAfter: number;
@Column({ type: 'varchar', length: 300, nullable: true })
description: string | null;
@Column({ name: 'recorded_by', type: 'integer', nullable: true })
recordedBy: number | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@@ -1,4 +1,6 @@
import { IsInt, IsString, IsNumber, IsOptional } from 'class-validator'; import { IsDateString, IsIn, IsInt, IsString, IsNumber, IsOptional, Matches, Min } from 'class-validator';
import { PartialType } from '@nestjs/mapped-types';
import { Type } from 'class-transformer';
export class CreateRoomExpenseDto { export class CreateRoomExpenseDto {
@IsInt() @IsInt()
@@ -7,13 +9,14 @@ export class CreateRoomExpenseDto {
@IsString() @IsString()
expenseType: string; expenseType: string;
@IsNumber() @IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
amount: number; amount: number;
@IsString() @IsDateString()
periodStart: string; periodStart: string;
@IsString() @IsDateString()
periodEnd: string; periodEnd: string;
@IsOptional() @IsOptional()
@@ -32,10 +35,11 @@ export class CreatePersonalExpenseDto {
@IsString() @IsString()
expenseType: string; expenseType: string;
@IsNumber() @IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
amount: number; amount: number;
@IsString() @IsDateString()
expenseDate: string; expenseDate: string;
@IsOptional() @IsOptional()
@@ -43,6 +47,33 @@ export class CreatePersonalExpenseDto {
description?: string; description?: string;
} }
export class UpdateRoomExpenseDto extends PartialType(CreateRoomExpenseDto) {}
export class UpdatePersonalExpenseDto extends PartialType(CreatePersonalExpenseDto) {}
export class QueryRoomExpenseDto {
@IsOptional()
@Type(() => Number)
@IsInt()
roomId?: number;
@IsOptional()
@IsDateString()
periodStart?: string;
@IsOptional()
@IsDateString()
periodEnd?: string;
}
export class QueryPersonalExpenseDto {
@IsOptional()
@Type(() => Number)
@IsInt()
studentId?: number;
}
export class BatchRoomExpenseDto { export class BatchRoomExpenseDto {
@IsString() @IsString()
periodStart: string; periodStart: string;
@@ -52,3 +83,28 @@ export class BatchRoomExpenseDto {
expenses: { roomId: number; expenseType: string; amount: number; description?: string }[]; expenses: { roomId: number; expenseType: string; amount: number; description?: string }[];
} }
export class CreateStudentUtilityBillDto {
@IsInt()
studentId: number;
@IsIn(['water', 'electricity'])
expenseType: 'water' | 'electricity';
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
amount: number;
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
periodStart: string;
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
periodEnd: string;
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -12,6 +12,7 @@ import {
Res, Res,
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express'; import type { Response } from 'express';
@@ -20,6 +21,11 @@ import {
CreateRoomExpenseDto, CreateRoomExpenseDto,
CreatePersonalExpenseDto, CreatePersonalExpenseDto,
BatchRoomExpenseDto, BatchRoomExpenseDto,
CreateStudentUtilityBillDto,
QueryPersonalExpenseDto,
QueryRoomExpenseDto,
UpdatePersonalExpenseDto,
UpdateRoomExpenseDto,
} from './dto/expense.dto'; } from './dto/expense.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service';
@@ -78,6 +84,25 @@ export class ExpensesController {
return this.service.getFormLookups(); return this.service.getFormLookups();
} }
@Post('student-utility')
@RequirePermission('expense:create')
async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: any) {
const result = await this.service.createStudentUtilityBill(dto, req.user?.id);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '录入学生水电费并出账',
targetId: result.bill.id,
targetType: 'bill',
detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`,
ipAddress,
userAgent,
});
return result;
}
@Post('room') @Post('room')
@RequirePermission('expense:create') @RequirePermission('expense:create')
async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) { async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) {
@@ -116,29 +141,21 @@ export class ExpensesController {
@Get('room') @Get('room')
@RequirePermission('expense:view') @RequirePermission('expense:view')
findRoomExpenses( findRoomExpenses(@Query() query: QueryRoomExpenseDto) {
@Query('roomId') roomId?: string, return this.service.findRoomExpenses(query);
@Query('periodStart') periodStart?: string,
@Query('periodEnd') periodEnd?: string,
) {
return this.service.findRoomExpenses({
roomId: roomId ? +roomId : undefined,
periodStart,
periodEnd,
});
} }
@Delete('room/:id') @Delete('room/:id')
@RequirePermission('expense:delete') @RequirePermission('expense:delete')
async deleteRoomExpense(@Param('id') id: string, @Request() req: any) { async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.deleteRoomExpense(+id); const result = await this.service.deleteRoomExpense(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '费用管理', module: '费用管理',
action: '删除费用', action: '删除费用',
targetId: +id, targetId: id,
targetType: 'room_expense', targetType: 'room_expense',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -166,18 +183,18 @@ export class ExpensesController {
@Put('room/:id') @Put('room/:id')
@RequirePermission('expense:edit') @RequirePermission('expense:edit')
async updateRoomExpense( async updateRoomExpense(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() dto: CreateRoomExpenseDto, @Body() dto: UpdateRoomExpenseDto,
@Request() req: any, @Request() req: any,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateRoomExpense(+id, dto); const result = await this.service.updateRoomExpense(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '费用管理', module: '费用管理',
action: '编辑费用', action: '编辑费用',
targetId: +id, targetId: id,
targetType: 'room_expense', targetType: 'room_expense',
detail: `¥${dto.amount} ${dto.expenseType}`, detail: `¥${dto.amount} ${dto.expenseType}`,
ipAddress, ipAddress,
@@ -205,21 +222,21 @@ export class ExpensesController {
@Get('personal') @Get('personal')
@RequirePermission('expense:view') @RequirePermission('expense:view')
findPersonalExpenses(@Query('studentId') studentId?: string) { findPersonalExpenses(@Query() query: QueryPersonalExpenseDto) {
return this.service.findPersonalExpenses({ studentId: studentId ? +studentId : undefined }); return this.service.findPersonalExpenses(query);
} }
@Delete('personal/:id') @Delete('personal/:id')
@RequirePermission('expense:delete') @RequirePermission('expense:delete')
async deletePersonalExpense(@Param('id') id: string, @Request() req: any) { async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.deletePersonalExpense(+id); const result = await this.service.deletePersonalExpense(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '费用管理', module: '费用管理',
action: '删除费用', action: '删除费用',
targetId: +id, targetId: id,
ipAddress, ipAddress,
userAgent, userAgent,
}); });
@@ -246,18 +263,18 @@ export class ExpensesController {
@Put('personal/:id') @Put('personal/:id')
@RequirePermission('expense:edit') @RequirePermission('expense:edit')
async updatePersonalExpense( async updatePersonalExpense(
@Param('id') id: string, @Param('id', ParseIntPipe) id: number,
@Body() dto: CreatePersonalExpenseDto, @Body() dto: UpdatePersonalExpenseDto,
@Request() req: any, @Request() req: any,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updatePersonalExpense(+id, dto); const result = await this.service.updatePersonalExpense(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '费用管理', module: '费用管理',
action: '编辑费用', action: '编辑费用',
targetId: +id, targetId: id,
detail: `¥${dto.amount} ${dto.expenseType}`, detail: `¥${dto.amount} ${dto.expenseType}`,
ipAddress, ipAddress,
userAgent, userAgent,

View File

@@ -7,11 +7,13 @@ import { Student } from '../entities/student.entity';
import { ExpensesService } from './expenses.service'; import { ExpensesService } from './expenses.service';
import { ExpensesController } from './expenses.controller'; import { ExpensesController } from './expenses.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { BillsModule } from '../bills/bills.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([RoomExpense, PersonalExpense, Room, Student]), TypeOrmModule.forFeature([RoomExpense, PersonalExpense, Room, Student]),
OperationLogsModule, OperationLogsModule,
BillsModule,
], ],
controllers: [ExpensesController], controllers: [ExpensesController],
providers: [ExpensesService], providers: [ExpensesService],

View File

@@ -9,8 +9,10 @@ import {
CreateRoomExpenseDto, CreateRoomExpenseDto,
CreatePersonalExpenseDto, CreatePersonalExpenseDto,
BatchRoomExpenseDto, BatchRoomExpenseDto,
CreateStudentUtilityBillDto,
} from './dto/expense.dto'; } from './dto/expense.dto';
import { RoomsService } from '../rooms/rooms.service'; import { RoomsService } from '../rooms/rooms.service';
import { BillsService } from '../bills/bills.service';
@Injectable() @Injectable()
@@ -20,6 +22,7 @@ export class ExpensesService {
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>, @InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
@InjectRepository(Room) private roomRepo: Repository<Room>, @InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Student) private studentRepo: Repository<Student>, @InjectRepository(Student) private studentRepo: Repository<Student>,
private billsService: BillsService,
) {} ) {}
async getFormLookups() { async getFormLookups() {
@@ -96,6 +99,30 @@ export class ExpensesService {
return this.roomExpRepo.save(e); return this.roomExpRepo.save(e);
} }
async createStudentUtilityBill(dto: CreateStudentUtilityBillDto, userId?: number) {
if (dto.periodEnd < dto.periodStart) throw new BadRequestException('账期结束日期不能早于开始日期');
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
const expense = await this.personalExpRepo.save(
this.personalExpRepo.create({
studentId: dto.studentId,
expenseType: dto.expenseType,
amount: dto.amount,
expenseDate: dto.periodEnd,
description: dto.description || (dto.expenseType === 'water' ? '学生水费' : '学生电费'),
recordedBy: userId,
billId: null,
}),
);
try {
const bill = await this.billsService.createImmediatePersonalBill(expense, dto.periodStart, dto.periodEnd, userId);
return { expense, bill };
} catch (error) {
await this.personalExpRepo.delete(expense.id);
throw error;
}
}
// 个人附加费 // 个人附加费
async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) { async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });

View File

@@ -51,6 +51,8 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'deposit:edit', 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: '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:view', name: '查看教室', group: 'classroom' },
{ code: 'classroom:create', name: '新增教室', group: 'classroom' }, { code: 'classroom:create', name: '新增教室', group: 'classroom' },
{ code: 'classroom:edit', name: '编辑教室', group: 'classroom' }, { code: 'classroom:edit', name: '编辑教室', group: 'classroom' },
@@ -176,6 +178,7 @@ export const PRESET_ROLES: Array<{
'expense', 'expense',
'bill', 'bill',
'deposit', 'deposit',
'wallet',
'dashboard', 'dashboard',
'notification', 'notification',
'profile', 'profile',

View File

@@ -15,6 +15,21 @@ const createSchedule = (notes: string) =>
notes, notes,
}); });
describe('schedule attendance window validation', () => {
it('accepts a configurable number of minutes before class', async () => {
const dto = createSchedule('');
dto.attendanceAdvanceMinutes = 45;
const errors = await validate(dto);
expect(errors.some((error) => error.property === 'attendanceAdvanceMinutes')).toBe(false);
});
it('rejects values outside 0 to 1440 minutes', async () => {
const dto = Object.assign(new UpdateScheduleDto(), { attendanceAdvanceMinutes: 1441 });
const errors = await validate(dto);
expect(errors.some((error) => error.property === 'attendanceAdvanceMinutes')).toBe(true);
});
});
describe('schedule notes validation', () => { describe('schedule notes validation', () => {
it('rejects notes longer than 500 characters when creating', async () => { it('rejects notes longer than 500 characters when creating', async () => {
const errors = await validate(createSchedule('a'.repeat(501))); const errors = await validate(createSchedule('a'.repeat(501)));

View File

@@ -34,6 +34,12 @@ export class CreateScheduleDto {
@IsNotEmpty() @IsNotEmpty()
endTime: string; endTime: string;
@IsOptional()
@IsInt()
@Min(0)
@Max(1440)
attendanceAdvanceMinutes?: number;
@IsDateString() @IsDateString()
@IsNotEmpty() @IsNotEmpty()
startDate: string; startDate: string;
@@ -88,6 +94,12 @@ export class UpdateScheduleDto {
@Matches(/^\d{2}:\d{2}$/) @Matches(/^\d{2}:\d{2}$/)
endTime?: string; endTime?: string;
@IsOptional()
@IsInt()
@Min(0)
@Max(1440)
attendanceAdvanceMinutes?: number;
@IsOptional() @IsOptional()
@IsDateString() @IsDateString()
startDate?: string; startDate?: string;

View File

@@ -110,15 +110,47 @@ describe('SchedulesService — checkConflict', () => {
).rejects.toThrow(ConflictException); ).rejects.toThrow(ConflictException);
}); });
it('same classroom + same weekday + non-overlapping times → no conflict', async () => { it('rejects schedules separated by less than 10 minutes', async () => {
const qb = mockQueryBuilder<ClassSchedule>([
{ id: 1, subject: '数学', startTime: '08:00', endTime: '10:00' } as ClassSchedule,
]);
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
await expect(
service.checkConflict(1, 3, '10:09', '12:00', '2026-03-01', '2026-06-30'),
).rejects.toThrow('排课之间必须至少间隔 10 分钟');
expect(qb.andWhere).toHaveBeenCalledWith('cs.endTime > :bufferedStartTime', {
bufferedStartTime: '09:59',
});
});
it('allows adjacent schedules when there is exactly a 10-minute gap', async () => {
const qb = mockQueryBuilder<ClassSchedule>([]); const qb = mockQueryBuilder<ClassSchedule>([]);
const rentalQb = mockQueryBuilder<ClassroomRental>([]); const rentalQb = mockQueryBuilder<ClassroomRental>([]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
await expect( await expect(
service.checkConflict(1, 3, '10:00', '12:00', '2026-03-01', '2026-06-30'), service.checkConflict(1, 3, '10:10', '12:00', '2026-03-01', '2026-06-30'),
).resolves.toEqual([]); ).resolves.toEqual([]);
expect(qb.andWhere).toHaveBeenCalledWith('cs.endTime > :bufferedStartTime', {
bufferedStartTime: '10:00',
});
});
it('reserves 10 minutes after the new schedule when checking the next schedule', async () => {
const qb = mockQueryBuilder<ClassSchedule>([]);
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
await service.checkConflict(1, 3, '08:00', '09:50', '2026-03-01', '2026-06-30');
expect(qb.andWhere).toHaveBeenCalledWith('cs.startTime < :bufferedEndTime', {
bufferedEndTime: '10:00',
});
}); });
it('same classroom + same weekday + overlapping times but disjoint date ranges → no conflict', async () => { it('same classroom + same weekday + overlapping times but disjoint date ranges → no conflict', async () => {

View File

@@ -23,6 +23,16 @@ import {
WeeklyViewQueryDto, WeeklyViewQueryDto,
} from './dto/schedule.dto'; } from './dto/schedule.dto';
const SCHEDULE_GAP_MINUTES = 10;
function shiftTime(time: string, minutes: number): string {
const [hours, minutePart] = time.split(':').map(Number);
const shifted = Math.min(24 * 60, Math.max(0, hours * 60 + minutePart + minutes));
const shiftedHours = Math.floor(shifted / 60);
const shiftedMinutes = shifted % 60;
return `${String(shiftedHours).padStart(2, '0')}:${String(shiftedMinutes).padStart(2, '0')}`;
}
@Injectable() @Injectable()
export class SchedulesService { export class SchedulesService {
constructor( constructor(
@@ -58,6 +68,7 @@ export class SchedulesService {
weekDay: schedule.weekDay, weekDay: schedule.weekDay,
startTime: schedule.startTime, startTime: schedule.startTime,
endTime: schedule.endTime, endTime: schedule.endTime,
attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes,
startDate: schedule.startDate, startDate: schedule.startDate,
endDate: schedule.endDate, endDate: schedule.endDate,
subject: '已占用', subject: '已占用',
@@ -243,13 +254,16 @@ export class SchedulesService {
endDate: string, endDate: string,
excludeId?: number, excludeId?: number,
) { ) {
// 为教室换场、整理和人员进出预留时间;恰好间隔 10 分钟允许排课。
const bufferedStartTime = shiftTime(startTime, -SCHEDULE_GAP_MINUTES);
const bufferedEndTime = shiftTime(endTime, SCHEDULE_GAP_MINUTES);
const qb = this.scheduleRepo const qb = this.scheduleRepo
.createQueryBuilder('cs') .createQueryBuilder('cs')
.where('cs.classroomId = :classroomId', { classroomId }) .where('cs.classroomId = :classroomId', { classroomId })
.andWhere('cs.weekDay = :weekDay', { weekDay }) .andWhere('cs.weekDay = :weekDay', { weekDay })
.andWhere('cs.status = :status', { status: 'active' }) .andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.startTime < :endTime', { endTime }) .andWhere('cs.startTime < :bufferedEndTime', { bufferedEndTime })
.andWhere('cs.endTime > :startTime', { startTime }) .andWhere('cs.endTime > :bufferedStartTime', { bufferedStartTime })
.andWhere('cs.startDate <= :endDate', { endDate }) .andWhere('cs.startDate <= :endDate', { endDate })
.andWhere('cs.endDate >= :startDate', { startDate }); .andWhere('cs.endDate >= :startDate', { startDate });
@@ -258,7 +272,7 @@ export class SchedulesService {
const conflicts = await qb.getMany(); const conflicts = await qb.getMany();
if (conflicts.length > 0) { if (conflicts.length > 0) {
throw new ConflictException( throw new ConflictException(
`该时间段与已有排课冲突: ${conflicts.map((c) => `${c.subject}(${c.startTime}-${c.endTime})`).join(', ')}`, `排课之间必须至少间隔 ${SCHEDULE_GAP_MINUTES} 分钟,与以下排课时间过近: ${conflicts.map((c) => `${c.subject}(${c.startTime}-${c.endTime})`).join(', ')}`,
); );
} }

View File

@@ -1,4 +1,5 @@
import { IsString, IsOptional, IsEnum, IsInt } from 'class-validator'; import { IsBoolean, IsIn, IsInt, IsOptional, IsString } from 'class-validator';
import { Transform, Type } from 'class-transformer';
export class CreateStudentDto { export class CreateStudentDto {
@IsString() @IsString()
@@ -82,6 +83,31 @@ export class UpdateStudentDto {
supervisor?: string; supervisor?: string;
@IsOptional() @IsOptional()
@IsEnum(['active', 'graduated', 'withdrawn']) @IsIn(['active', 'graduated', 'withdrawn'])
status?: string; status?: string;
} }
export class QueryStudentDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsIn(['active', 'graduated', 'withdrawn', 'archived'])
status?: string;
@IsOptional()
@Transform(({ value }) => {
if (typeof value === 'boolean') return value;
if (value === 'true' || value === '1') return true;
if (value === 'false' || value === '0') return false;
return value;
})
@IsBoolean()
includeArchived?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
organizationId?: number;
}

View File

@@ -13,6 +13,7 @@ import {
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
Inject, Inject,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -21,7 +22,7 @@ import { ClassTeacher } from '../entities/class-teacher.entity';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express'; import type { Response } from 'express';
import { StudentsService } from './students.service'; import { StudentsService } from './students.service';
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto'; import { CreateStudentDto, QueryStudentDto, UpdateStudentDto } from './dto/student.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils'; import { extractRequestInfo } from '../common/request-utils';
@@ -61,10 +62,7 @@ export class StudentsController {
@Get() @Get()
@RequirePermission('student:view') @RequirePermission('student:view')
async findAll( async findAll(
@Query('name') name: string | undefined, @Query() query: QueryStudentDto,
@Query('status') status: string | undefined,
@Query('includeArchived') includeArchived: string | undefined,
@Query('organizationId') organizationId: string | undefined,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
const classIds = await this.service.getAccessibleClassIds( const classIds = await this.service.getAccessibleClassIds(
@@ -72,12 +70,7 @@ export class StudentsController {
this.canManageAllStudents(req), this.canManageAllStudents(req),
); );
return this.service.findAll( return this.service.findAll(
{ query,
name,
status,
includeArchived: includeArchived === 'true',
organizationId: organizationId ? +organizationId : undefined,
},
classIds, classIds,
); );
} }
@@ -192,8 +185,8 @@ export class StudentsController {
@Get(':id') @Get(':id')
@RequirePermission('student:view') @RequirePermission('student:view')
findOne(@Param('id') id: string) { findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(+id); return this.service.findOne(id);
} }
@Post() @Post()
@@ -217,15 +210,15 @@ export class StudentsController {
@Put(':id') @Put(':id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async update(@Param('id') id: string, @Body() dto: UpdateStudentDto, @Request() req: any) { async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStudentDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto); const result = await this.service.update(id, dto);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生管理', module: '学生管理',
action: '编辑学生', action: '编辑学生',
targetId: +id, targetId: id,
targetType: 'student', targetType: 'student',
detail: JSON.stringify(dto), detail: JSON.stringify(dto),
ipAddress, ipAddress,
@@ -236,15 +229,15 @@ export class StudentsController {
@Delete(':id') @Delete(':id')
@RequirePermission('student:delete') @RequirePermission('student:delete')
async remove(@Param('id') id: string, @Request() req: any) { async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id); const result = await this.service.remove(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生管理', module: '学生管理',
action: '删除学生', action: '删除学生',
targetId: +id, targetId: id,
targetType: 'student', targetType: 'student',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -271,15 +264,15 @@ export class StudentsController {
@Put(':id/restore') @Put(':id/restore')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async restore(@Param('id') id: string, @Request() req: any) { async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.restore(+id); const result = await this.service.restore(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生管理', module: '学生管理',
action: '恢复学生', action: '恢复学生',
targetId: +id, targetId: id,
targetType: 'student', targetType: 'student',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -403,7 +396,7 @@ export class StudentsController {
@Get(':id/compare-classes') @Get(':id/compare-classes')
@RequirePermission('student:view') @RequirePermission('student:view')
compareClasses(@Param('id') id: string) { compareClasses(@Param('id', ParseIntPipe) id: number) {
return this.service.compareClasses(+id); return this.service.compareClasses(id);
} }
} }

View File

@@ -462,3 +462,90 @@ describe('ScheduleSyncService — all shifts fail', () => {
}); });
}); });
describe('ScheduleSyncService — multiple lessons per student per day', () => {
it('combines daily lessons into one DingTalk shift with multiple sections', async () => {
const scheduleRepo = {
find: jest.fn().mockResolvedValue([
{
id: 2,
classId: 10,
classroomId: 1,
weekDay: 1,
startTime: '22:10',
endTime: '23:10',
startDate: '2026-07-13',
endDate: '2026-07-13',
status: 'active',
} as ClassSchedule,
{
id: 1,
classId: 10,
classroomId: 2,
weekDay: 1,
startTime: '20:00',
endTime: '21:00',
startDate: '2026-07-13',
endDate: '2026-07-13',
status: 'active',
} as ClassSchedule,
]),
};
const classStudentRepo = {
find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
};
const mappingRepo = {
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
};
const classRepo = {
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺一班' }]),
};
const scheduleUsers = jest.fn().mockResolvedValue(undefined);
const upsertShift = jest.fn().mockResolvedValue(2022);
const dingTalkService = {
queryShifts: jest.fn().mockResolvedValue([]),
upsertShift,
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 1495610001, group_name: '排课_冲刺一班', type: 'TURN', member_count: 1 },
]),
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
createAttendanceGroup: jest.fn(),
scheduleUsers,
};
const service = new ScheduleSyncService(
scheduleRepo as never,
classStudentRepo as never,
mappingRepo as never,
classRepo as never,
dingTalkService as never,
);
const result = await service.syncAll('2026-07-13', 1);
expect(upsertShift).toHaveBeenCalledTimes(1);
expect(upsertShift).toHaveBeenCalledWith(expect.objectContaining({
name: '冲刺一班_20:00-21:00+22:10-23:10',
sections: [
expect.objectContaining({
times: expect.arrayContaining([
expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 20:00:00' }),
expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 21:00:00' }),
]),
}),
expect.objectContaining({
times: expect.arrayContaining([
expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 22:10:00' }),
expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 23:10:00' }),
]),
}),
],
}));
expect(scheduleUsers).toHaveBeenCalledTimes(1);
expect(scheduleUsers.mock.calls[0][1]).toEqual([
expect.objectContaining({ userid: 'student-1', shift_id: 2022 }),
]);
expect(result.syncedItems).toBe(1);
expect(result.failedBatchCount).toBe(0);
});
});

View File

@@ -1,14 +1,22 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm'; import { Repository, In } from 'typeorm';
import { import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities';
ClassSchedule,
ClassStudent,
StudentDingMapping,
Class,
} from '../entities';
import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service'; import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service';
interface DailySchedulePeriod {
startTime: string;
endTime: string;
scheduleId: number;
}
interface DailySchedulePlan {
classId: number;
date: string;
shiftKey: string;
periods: DailySchedulePeriod[];
}
/** 单次排班同步的结果 */ /** 单次排班同步的结果 */
export interface ScheduleSyncResult { export interface ScheduleSyncResult {
/** 参与同步的排课记录数 */ /** 参与同步的排课记录数 */
@@ -89,9 +97,14 @@ export class ScheduleSyncService {
const endDate = this.addDays(startDate, days); const endDate = this.addDays(startDate, days);
const empty: ScheduleSyncResult = { const empty: ScheduleSyncResult = {
scheduleCount: 0, shiftCount: 0, groupCount: 0, scheduleCount: 0,
syncedItems: 0, skippedNoMapping: 0, shiftCount: 0,
failedBatchCount: 0, failedItems: 0, errors: [], groupCount: 0,
syncedItems: 0,
skippedNoMapping: 0,
failedBatchCount: 0,
failedItems: 0,
errors: [],
groups: [], groups: [],
}; };
@@ -110,63 +123,77 @@ export class ScheduleSyncService {
const classDingUsers = await this.buildClassDingUserMap(classIds); const classDingUsers = await this.buildClassDingUserMap(classIds);
const classNameMap = await this.loadClassNames(classIds); const classNameMap = await this.loadClassNames(classIds);
// ── Step 3: 班次(按时间段去重,班次列表只查一次) ── // ── Step 3: 将每天的多节课合并成一个钉钉班次 ──
const shiftKey = (classId: number, start: string, end: string) => // 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为
`${classId}|${start}-${end}`; // 同一个班次的多个 sections 写入,不能拆成多条 schedule item。
const dailyPlans = this.buildDailySchedulePlans(schedules, startDate, endDate);
const uniqueShifts = new Map< const uniqueShifts = new Map<
string, string,
{ className: string; startTime: string; endTime: string } { className: string; periods: DailySchedulePeriod[] }
>(); >();
const shiftScheduleCount = new Map<string, number>(); const shiftPlanCount = new Map<string, number>();
for (const schedule of schedules) { for (const plan of dailyPlans) {
const classId = schedule.classId as number; if (!uniqueShifts.has(plan.shiftKey)) {
const key = shiftKey(classId, schedule.startTime, schedule.endTime); uniqueShifts.set(plan.shiftKey, {
if (!uniqueShifts.has(key)) { className: classNameMap.get(plan.classId) || `班级${plan.classId}`,
uniqueShifts.set(key, { periods: plan.periods,
className: classNameMap.get(classId) || `班级${classId}`,
startTime: schedule.startTime,
endTime: schedule.endTime,
}); });
} }
shiftScheduleCount.set(key, (shiftScheduleCount.get(key) || 0) + 1); shiftPlanCount.set(plan.shiftKey, (shiftPlanCount.get(plan.shiftKey) || 0) + 1);
} }
const existingShifts = await this.dingTalkService.queryShifts(opUserId); const existingShifts = await this.dingTalkService.queryShifts(opUserId);
const shiftByName = new Map(existingShifts.map((s) => [s.name, s.id])); const shiftByName = new Map(existingShifts.map((shift) => [shift.name, shift.id]));
const timeToShiftId = new Map<string, number>(); const planToShiftId = new Map<string, number>();
const errors: string[] = []; const errors: string[] = [];
let failedBatchCount = 0; let failedBatchCount = 0;
let failedItems = 0; let failedItems = 0;
let shiftCount = 0; let shiftCount = 0;
for (const [key, { className, startTime, endTime }] of uniqueShifts) { for (const [key, { className, periods }] of uniqueShifts) {
const shiftName = `${className}_${startTime}-${endTime}`; const periodLabel = periods
.map((period) => `${period.startTime}-${period.endTime}`)
.join('+');
const shiftName = `${className}_${periodLabel}`;
try { try {
let shiftId = shiftByName.get(shiftName); let shiftId = shiftByName.get(shiftName);
const shiftParams = { const shiftParams = {
...(shiftId === undefined ? {} : { id: shiftId }), ...(shiftId === undefined ? {} : { id: shiftId }),
name: shiftName, name: shiftName,
owner: opUserId, owner: opUserId,
sections: [{ sections: periods.map((period) => ({
times: [ times: [
{ check_type: 'OnDuty' as const, across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false }, {
{ check_type: 'OffDuty' as const, across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false }, check_type: 'OnDuty' as const,
across: 0,
check_time: `1970-01-01 ${period.startTime}:00`,
free_check: false,
},
{
check_type: 'OffDuty' as const,
across: 0,
check_time: `1970-01-01 ${period.endTime}:00`,
free_check: false,
},
], ],
}], })),
setting: { setting: {
is_flexible: false, is_flexible: false,
serious_late_minutes: -1, serious_late_minutes: -1,
absenteeism_late_minutes: this.minutesBetween(startTime, endTime), absenteeism_late_minutes: Math.max(
...periods.map((period) => this.minutesBetween(period.startTime, period.endTime)),
),
}, },
}; };
shiftId = await this.dingTalkService.upsertShift(shiftParams); shiftId = await this.dingTalkService.upsertShift(shiftParams);
shiftByName.set(shiftName, shiftId); shiftByName.set(shiftName, shiftId);
timeToShiftId.set(key, shiftId); planToShiftId.set(key, shiftId);
shiftCount++; shiftCount++;
} catch (e) { } catch (e) {
const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`; const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`;
this.logger.error(msg); this.logger.error(msg);
errors.push(msg); errors.push(msg);
failedBatchCount++; failedBatchCount++;
failedItems += shiftScheduleCount.get(key) || 0; failedItems += shiftPlanCount.get(key) || 0;
} }
} }
@@ -176,10 +203,15 @@ export class ScheduleSyncService {
// ── Step 5: 按班级同步 ── // ── Step 5: 按班级同步 ──
const schedulesByClass = new Map<number, ClassSchedule[]>(); const schedulesByClass = new Map<number, ClassSchedule[]>();
for (const s of schedules) { for (const schedule of schedules) {
const cid = s.classId as number; const classId = schedule.classId as number;
if (!schedulesByClass.has(cid)) schedulesByClass.set(cid, []); if (!schedulesByClass.has(classId)) schedulesByClass.set(classId, []);
schedulesByClass.get(cid)!.push(s); schedulesByClass.get(classId)!.push(schedule);
}
const dailyPlansByClass = new Map<number, DailySchedulePlan[]>();
for (const plan of dailyPlans) {
if (!dailyPlansByClass.has(plan.classId)) dailyPlansByClass.set(plan.classId, []);
dailyPlansByClass.get(plan.classId)!.push(plan);
} }
let syncedItems = 0; let syncedItems = 0;
@@ -196,11 +228,12 @@ export class ScheduleSyncService {
continue; continue;
} }
// 该班级用到的班次 const classDailyPlans = dailyPlansByClass.get(classId) ?? [];
// 该班级在同步日期范围内用到的合并班次
const classShiftIds = new Set<number>(); const classShiftIds = new Set<number>();
for (const s of classSchedules) { for (const plan of classDailyPlans) {
const sid = timeToShiftId.get(shiftKey(classId, s.startTime, s.endTime)); const shiftId = planToShiftId.get(plan.shiftKey);
if (sid) classShiftIds.add(sid); if (shiftId) classShiftIds.add(shiftId);
} }
if (classShiftIds.size === 0) { if (classShiftIds.size === 0) {
this.logger.warn(`班级 ${className} 无可用班次,跳过(班次创建已计入 failure`); this.logger.warn(`班级 ${className} 无可用班次,跳过(班次创建已计入 failure`);
@@ -208,9 +241,7 @@ export class ScheduleSyncService {
} }
// 先展开排班以计算受影响条数 // 先展开排班以计算受影响条数
const items = this.expandSchedules( const items = this.expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId);
classSchedules, dingUserIds, timeToShiftId, startDate, endDate,
);
if (items.length === 0) { if (items.length === 0) {
this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`); this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`);
@@ -227,7 +258,11 @@ export class ScheduleSyncService {
name: groupName, name: groupName,
type: 'TURN' as const, type: 'TURN' as const,
owner: opUserId, owner: opUserId,
members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember' as const, user_id: uid })), members: dingUserIds.map((uid) => ({
role: 'Attendance',
type: 'StaffMember' as const,
user_id: uid,
})),
shift_ids: [...classShiftIds], shift_ids: [...classShiftIds],
enable_emp_select_class: true, enable_emp_select_class: true,
disable_check_without_schedule: false, disable_check_without_schedule: false,
@@ -276,8 +311,8 @@ export class ScheduleSyncService {
this.logger.log( this.logger.log(
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` + `排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` + `${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` +
(failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''), (failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''),
); );
return { return {
@@ -333,53 +368,87 @@ export class ScheduleSyncService {
} }
/** /**
* 将排课记录展开为每个学生的每日排班数组 * 把本地排课转换为“班级 + 日期”的日排班计划
* 每条 ClassSchedule(weekDay, startDate-endDate) × 班级每个学生 → * 同一天相同时间段会去重,多节课按开始时间排序并合并为一个钉钉班次。
* 该日期范围内所有 weekDay 对应日期的排班。
*/ */
private expandSchedules( private buildDailySchedulePlans(
schedules: ClassSchedule[], schedules: ClassSchedule[],
dingUserIds: string[],
timeToShiftId: Map<string, number>,
syncFrom: string, syncFrom: string,
syncTo: string, syncTo: string,
): DingTalkScheduleItem[] { ): DailySchedulePlan[] {
const seen = new Set<string>(); const periodMapByClassDate = new Map<string, Map<string, DailySchedulePeriod>>();
const items: DingTalkScheduleItem[] = [];
const fromDate = new Date(syncFrom); const fromDate = new Date(syncFrom);
const toDate = new Date(syncTo); const toDate = new Date(syncTo);
// 预计算日期范围内每一天是星期几(周日=7 for (let date = new Date(fromDate); date <= toDate; date.setDate(date.getDate() + 1)) {
const dateWeekDays = new Map<string, number>(); const dateStr = date.toISOString().slice(0, 10);
for (let d = new Date(fromDate); d <= toDate; d.setDate(d.getDate() + 1)) { const weekDay = date.getDay() === 0 ? 7 : date.getDay();
const dateStr = d.toISOString().slice(0, 10);
dateWeekDays.set(dateStr, d.getDay() === 0 ? 7 : d.getDay());
}
for (const s of schedules) { for (const schedule of schedules) {
const shiftId = timeToShiftId.get(`${s.classId}|${s.startTime}-${s.endTime}`); if (schedule.classId == null || schedule.weekDay !== weekDay) continue;
if (!shiftId) continue; if (dateStr < schedule.startDate || dateStr > schedule.endDate) continue;
const scheduleStart = s.startDate > syncFrom ? s.startDate : syncFrom; const classDateKey = `${schedule.classId}|${dateStr}`;
const scheduleEnd = s.endDate < syncTo ? s.endDate : syncTo; if (!periodMapByClassDate.has(classDateKey)) {
periodMapByClassDate.set(classDateKey, new Map());
for (const [dateStr, weekDay] of dateWeekDays) { }
if (dateStr < scheduleStart || dateStr > scheduleEnd) continue; const periods = periodMapByClassDate.get(classDateKey)!;
if (weekDay !== s.weekDay) continue; const periodKey = `${schedule.startTime}-${schedule.endTime}`;
const existing = periods.get(periodKey);
const workDate = new Date(dateStr + 'T00:00:00+08:00').getTime(); if (!existing || schedule.id < existing.scheduleId) {
for (const userid of dingUserIds) { periods.set(periodKey, {
const dedupKey = `${userid}|${workDate}|${shiftId}`; startTime: schedule.startTime,
if (seen.has(dedupKey)) continue; endTime: schedule.endTime,
seen.add(dedupKey); scheduleId: schedule.id,
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false }); });
} }
} }
} }
return items; const plans: DailySchedulePlan[] = [];
for (const [classDateKey, periodMap] of periodMapByClassDate) {
const separator = classDateKey.indexOf('|');
const classId = Number(classDateKey.slice(0, separator));
const date = classDateKey.slice(separator + 1);
const periods = [...periodMap.values()].sort(
(left, right) =>
left.startTime.localeCompare(right.startTime) ||
left.endTime.localeCompare(right.endTime) ||
left.scheduleId - right.scheduleId,
);
const periodSignature = periods
.map((period) => `${period.startTime}-${period.endTime}`)
.join('+');
plans.push({
classId,
date,
shiftKey: `${classId}|${periodSignature}`,
periods,
});
}
return plans.sort(
(left, right) => left.date.localeCompare(right.date) || left.classId - right.classId,
);
} }
/** 每个学生每天仅生成一条钉钉排班shift 内可包含多个课程卡段。 */
private expandDailySchedulePlans(
plans: DailySchedulePlan[],
dingUserIds: string[],
planToShiftId: Map<string, number>,
): DingTalkScheduleItem[] {
const items: DingTalkScheduleItem[] = [];
for (const plan of plans) {
const shiftId = planToShiftId.get(plan.shiftKey);
if (!shiftId) continue;
const workDate = new Date(`${plan.date}T00:00:00+08:00`).getTime();
for (const userid of dingUserIds) {
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false });
}
}
return items;
}
private minutesBetween(startTime: string, endTime: string): number { private minutesBetween(startTime: string, endTime: string): number {
const [startHour, startMinute] = startTime.split(':').map(Number); const [startHour, startMinute] = startTime.split(':').map(Number);

View File

@@ -0,0 +1,18 @@
import { IsIn, IsInt, IsNumber, IsOptional, IsString, MaxLength, NotEquals } from 'class-validator';
export class ChangeWalletBalanceDto {
@IsInt()
studentId: number;
@IsNumber({ maxDecimalPlaces: 2 })
@NotEquals(0)
amount: number;
@IsIn(['recharge', 'adjustment'])
type: 'recharge' | 'adjustment';
@IsOptional()
@IsString()
@MaxLength(300)
description?: string;
}

View File

@@ -0,0 +1,44 @@
import { Body, Controller, Get, Post, Query, Request, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { ChangeWalletBalanceDto } from './dto/wallet.dto';
import { WalletsService } from './wallets.service';
@UseGuards(JwtAuthGuard)
@Controller('wallets')
export class WalletsController {
constructor(private service: WalletsService, private logService: OperationLogsService) {}
@Get()
@RequirePermission('wallet:view')
findAll(@Query('keyword') keyword?: string, @Query('debtOnly') debtOnly?: string) {
return this.service.findAll({ keyword, debtOnly: debtOnly === 'true' });
}
@Get('transactions')
@RequirePermission('wallet:view')
findTransactions(@Query('studentId') studentId: string) {
return this.service.findTransactions(Number(studentId));
}
@Post('change-balance')
@RequirePermission('wallet:edit')
async changeBalance(@Body() dto: ChangeWalletBalanceDto, @Request() req: any) {
const result = await this.service.changeBalance(dto, req.user?.id);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生余额',
action: dto.type === 'recharge' ? '余额充值' : '余额调账',
targetId: dto.studentId,
targetType: 'student_wallet',
detail: `金额 ¥${dto.amount}${dto.description ? `${dto.description}` : ''}`,
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Bill } from '../entities/bill.entity';
import { Student } from '../entities/student.entity';
import { StudentWallet } from '../entities/student-wallet.entity';
import { WalletTransaction } from '../entities/wallet-transaction.entity';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { WalletsController } from './wallets.controller';
import { WalletsService } from './wallets.service';
@Module({
imports: [TypeOrmModule.forFeature([StudentWallet, WalletTransaction, Student, Bill]), OperationLogsModule],
controllers: [WalletsController],
providers: [WalletsService],
exports: [WalletsService],
})
export class WalletsModule {}

View File

@@ -0,0 +1,52 @@
import { BadRequestException } from '@nestjs/common';
import { WalletsService } from './wallets.service';
import { Bill } from '../entities/bill.entity';
const manager = (walletBalance: number) => {
const wallet = { id: 1, studentId: 10, balance: walletBalance };
const saved: any[] = [];
return {
wallet,
saved,
value: {
findOne: jest.fn(async () => wallet),
findOneByOrFail: jest.fn(async () => wallet),
save: jest.fn(async (value: any) => { saved.push(value); return value; }),
create: jest.fn((_entity: unknown, value: unknown) => value),
createQueryBuilder: jest.fn(),
},
};
};
describe('WalletsService payment rules', () => {
const service = new WalletsService({} as any, {} as any, {} as any, {} as any);
it('partially pays a bill when balance is insufficient', async () => {
const ctx = manager(40);
const bill = { id: 9, studentId: 10, totalAmount: 100, paidAmount: 0, outstandingAmount: 100, status: 'unpaid' } as Bill;
const result = await service.debitBill(ctx.value as any, bill, 1);
expect(result.status).toBe('partially_paid');
expect(Number(result.paidAmount)).toBe(40);
expect(Number(result.outstandingAmount)).toBe(60);
expect(Number(ctx.wallet.balance)).toBe(0);
expect(ctx.saved.some((row) => row.type === 'bill_payment' && Number(row.amount) === -40)).toBe(true);
});
it('marks a bill paid when balance covers it', async () => {
const ctx = manager(120);
const bill = { id: 9, studentId: 10, totalAmount: 100, paidAmount: 0, outstandingAmount: 100, status: 'unpaid' } as Bill;
const result = await service.debitBill(ctx.value as any, bill);
expect(result.status).toBe('paid');
expect(Number(result.outstandingAmount)).toBe(0);
expect(Number(ctx.wallet.balance)).toBe(20);
});
it('refunds paid amount and cancels the bill', async () => {
const ctx = manager(10);
const bill = { id: 9, studentId: 10, totalAmount: 100, paidAmount: 40, outstandingAmount: 60, status: 'partially_paid' } as Bill;
const result = await service.refundBill(ctx.value as any, bill, '录入错误', 1);
expect(result.status).toBe('cancelled');
expect(Number(ctx.wallet.balance)).toBe(50);
expect(ctx.saved.some((row) => row.type === 'bill_refund' && Number(row.amount) === 40)).toBe(true);
});
});

View File

@@ -0,0 +1,167 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { Bill } from '../entities/bill.entity';
import { Student } from '../entities/student.entity';
import { StudentWallet } from '../entities/student-wallet.entity';
import { WalletTransaction } from '../entities/wallet-transaction.entity';
import { In } from 'typeorm';
import { ChangeWalletBalanceDto } from './dto/wallet.dto';
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
@Injectable()
export class WalletsService {
constructor(
@InjectRepository(StudentWallet) private walletRepo: Repository<StudentWallet>,
@InjectRepository(WalletTransaction) private transactionRepo: Repository<WalletTransaction>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
private dataSource: DataSource,
) {}
async findAll(query?: { keyword?: string; debtOnly?: boolean }) {
const students = await this.studentRepo
.createQueryBuilder('student')
.where('student.status = :status', { status: 'active' })
.andWhere(
query?.keyword
? '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)'
: '1 = 1',
query?.keyword ? { keyword: `%${query.keyword}%` } : {},
)
.orderBy('student.name', 'ASC')
.getMany();
if (!students.length) return [];
const ids = students.map((student) => student.id);
const wallets = await this.walletRepo.find({ where: { studentId: In(ids) } });
const bills = await this.dataSource.getRepository(Bill)
.createQueryBuilder('bill')
.select('bill.studentId', 'studentId')
.addSelect('SUM(bill.outstandingAmount)', 'outstandingAmount')
.where('bill.studentId IN (:...ids)', { ids })
.andWhere('bill.status IN (:...statuses)', { statuses: ['unpaid', 'partially_paid'] })
.groupBy('bill.studentId')
.getRawMany<{ studentId: number; outstandingAmount: string }>();
const walletMap = new Map(wallets.map((wallet) => [wallet.studentId, wallet]));
const debtMap = new Map(bills.map((bill) => [Number(bill.studentId), money(bill.outstandingAmount)]));
return students
.map((student) => ({
studentId: student.id,
studentName: student.name,
studentNo: student.studentNo,
balance: money(walletMap.get(student.id)?.balance),
outstandingAmount: debtMap.get(student.id) || 0,
}))
.filter((row) => !query?.debtOnly || row.outstandingAmount > 0);
}
async findTransactions(studentId: number) {
return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } });
}
async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) {
if (dto.type === 'recharge' && dto.amount <= 0) throw new BadRequestException('充值金额必须大于 0');
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
return this.dataSource.transaction(async (manager) => {
const wallet = await this.getOrCreateWallet(manager, dto.studentId);
const nextBalance = money(Number(wallet.balance) + dto.amount);
if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0');
wallet.balance = nextBalance;
await manager.save(wallet);
await manager.save(
manager.create(WalletTransaction, {
studentId: dto.studentId,
billId: null,
type: dto.type,
amount: money(dto.amount),
balanceAfter: nextBalance,
description: dto.description || (dto.type === 'recharge' ? '财务充值' : '余额调账'),
recordedBy: recordedBy || null,
}),
);
const payments = dto.amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : [];
const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId });
return { wallet: finalWallet, payments };
});
}
async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) {
if (bill.status === 'cancelled' || money(bill.outstandingAmount) <= 0) return bill;
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
const amount = money(Math.min(Number(wallet.balance), Number(bill.outstandingAmount)));
if (amount <= 0) {
bill.status = money(bill.paidAmount) > 0 ? 'partially_paid' : 'unpaid';
return manager.save(bill);
}
wallet.balance = money(Number(wallet.balance) - amount);
bill.paidAmount = money(Number(bill.paidAmount) + amount);
bill.outstandingAmount = money(Number(bill.totalAmount) - Number(bill.paidAmount));
bill.status = bill.outstandingAmount <= 0 ? 'paid' : 'partially_paid';
await manager.save(wallet);
await manager.save(bill);
await manager.save(
manager.create(WalletTransaction, {
studentId: bill.studentId,
billId: bill.id,
type: 'bill_payment',
amount: -amount,
balanceAfter: wallet.balance,
description: `账单 #${bill.id} 自动扣款`,
recordedBy: recordedBy || null,
}),
);
return bill;
}
async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) {
const paid = money(bill.paidAmount);
if (paid > 0) {
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
wallet.balance = money(Number(wallet.balance) + paid);
await manager.save(wallet);
await manager.save(
manager.create(WalletTransaction, {
studentId: bill.studentId,
billId: bill.id,
type: 'bill_refund',
amount: paid,
balanceAfter: wallet.balance,
description: `取消账单 #${bill.id} 冲正:${reason}`,
recordedBy: recordedBy || null,
}),
);
}
bill.status = 'cancelled';
bill.paidAmount = 0;
bill.outstandingAmount = 0;
bill.cancelledAt = new Date();
bill.cancelReason = reason;
return manager.save(bill);
}
private async settleOutstandingBills(manager: EntityManager, studentId: number, recordedBy?: number) {
const bills = await manager
.createQueryBuilder(Bill, 'bill')
.where('bill.studentId = :studentId', { studentId })
.andWhere('bill.status IN (:...statuses)', { statuses: ['unpaid', 'partially_paid'] })
.andWhere('bill.outstandingAmount > 0')
.orderBy('bill.periodStart', 'ASC')
.addOrderBy('bill.id', 'ASC')
.getMany();
const settled: Bill[] = [];
for (const bill of bills) {
const wallet = await manager.findOne(StudentWallet, { where: { studentId } });
if (!wallet || money(wallet.balance) <= 0) break;
settled.push(await this.debitBill(manager, bill, recordedBy));
}
return settled;
}
private async getOrCreateWallet(manager: EntityManager, studentId: number) {
let wallet = await manager.findOne(StudentWallet, { where: { studentId } });
if (!wallet) wallet = await manager.save(manager.create(StudentWallet, { studentId, balance: 0 }));
return wallet;
}
}