forked from wangziqi/gongxue-base
Compare commits
17 Commits
codex/xyx
...
3c4e3bf162
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c4e3bf162 | |||
| ce5fd1c6cb | |||
| 05a936bbc2 | |||
| 3adf4933d8 | |||
| d84f37e98f | |||
| 16b56ffcd5 | |||
| 718c58589f | |||
| aaf49d5580 | |||
| 5cf6aede1e | |||
| 811e7ce826 | |||
| 79fa472b78 | |||
| 029af37f3a | |||
| d572e984d2 | |||
| a93ba657a8 | |||
| 77714642a5 | |||
| e7aa202603 | |||
| 013b3f4afe |
@@ -13,6 +13,7 @@ const StudentsPage = lazy(() => import('./pages/Students'));
|
|||||||
const RoomsPage = lazy(() => import('./pages/Rooms'));
|
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 UtilityBalancesPage = lazy(() => import('./pages/UtilityBalances'));
|
||||||
const BillsPage = lazy(() => import('./pages/Bills'));
|
const BillsPage = lazy(() => import('./pages/Bills'));
|
||||||
const RoomVisualPage = lazy(() => import('./pages/RoomVisual'));
|
const RoomVisualPage = lazy(() => import('./pages/RoomVisual'));
|
||||||
const OperationLogsPage = lazy(() => import('./pages/OperationLogs'));
|
const OperationLogsPage = lazy(() => import('./pages/OperationLogs'));
|
||||||
@@ -126,6 +127,14 @@ const App: React.FC = () => {
|
|||||||
</PermissionRoute>
|
</PermissionRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="utility-balances"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="expense:view">
|
||||||
|
<UtilityBalancesPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="deposits"
|
path="deposits"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const SECTIONS: MenuSection[] = [
|
|||||||
{ key: '/rooms', label: '房间管理', icon: 'home', permission: 'room:view' },
|
{ key: '/rooms', label: '房间管理', icon: 'home', permission: 'room:view' },
|
||||||
{ 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: '/utility-balances', label: '水电余额', icon: 'utility', permission: 'expense:view' },
|
||||||
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
|
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
|
||||||
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
|
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const iconMap: Record<string, React.ReactNode> = {
|
|||||||
overview: <AppstoreOutlined />,
|
overview: <AppstoreOutlined />,
|
||||||
occupancy: <SwapOutlined />,
|
occupancy: <SwapOutlined />,
|
||||||
expense: <DollarOutlined />,
|
expense: <DollarOutlined />,
|
||||||
|
utility: <WalletOutlined />,
|
||||||
bill: <FileTextOutlined />,
|
bill: <FileTextOutlined />,
|
||||||
deposit: <WalletOutlined />,
|
deposit: <WalletOutlined />,
|
||||||
classroom: <ReadOutlined />,
|
classroom: <ReadOutlined />,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
canPullAttendance,
|
canPullAttendance,
|
||||||
getAttendanceExperience,
|
getAttendanceExperience,
|
||||||
|
getPunchDisplayInfo,
|
||||||
getSchedulePhase,
|
getSchedulePhase,
|
||||||
summarizeAttendance,
|
summarizeAttendance,
|
||||||
summarizeLessonCheckins,
|
summarizeLessonCheckins,
|
||||||
@@ -65,3 +66,34 @@ describe('lesson check-in summary', () => {
|
|||||||
).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 });
|
).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('lesson punch device display', () => {
|
||||||
|
it('labels attendance machine punches with the machine name and id', () => {
|
||||||
|
expect(
|
||||||
|
getPunchDisplayInfo({
|
||||||
|
status: 'present',
|
||||||
|
source: 'dingtalk',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
punchTime: '2026-07-11T00:55:00.000Z',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
label: '考勤机打卡',
|
||||||
|
machine: true,
|
||||||
|
detail: '东门考勤机(ATM-01)',
|
||||||
|
time: '2026-07-11T00:55:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes mobile punches and manual teacher markings', () => {
|
||||||
|
expect(
|
||||||
|
getPunchDisplayInfo({ status: 'present', source: 'dingtalk', punchSource: 'USER' }),
|
||||||
|
).toEqual({ label: '手机打卡', machine: false, detail: undefined, time: undefined });
|
||||||
|
expect(getPunchDisplayInfo({ status: 'present', source: 'manual' })).toEqual({
|
||||||
|
label: '老师手动标记',
|
||||||
|
machine: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -82,3 +82,56 @@ export function summarizeLessonCheckins(
|
|||||||
notCheckedIn: records.length - checkedIn,
|
notCheckedIn: records.length - checkedIn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface PunchDisplayRecord {
|
||||||
|
status: string;
|
||||||
|
source?: string;
|
||||||
|
punchTime?: string | null;
|
||||||
|
punchSource?: string | null;
|
||||||
|
punchDeviceName?: string | null;
|
||||||
|
punchDeviceId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PunchDisplayInfo {
|
||||||
|
label: string;
|
||||||
|
machine: boolean;
|
||||||
|
detail?: string;
|
||||||
|
time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPunchDisplayInfo(record: PunchDisplayRecord): PunchDisplayInfo | null {
|
||||||
|
if (record.status !== 'present' && record.status !== 'late') return null;
|
||||||
|
if (record.source === 'manual') return { label: '老师手动标记', machine: false };
|
||||||
|
|
||||||
|
const source = (record.punchSource || '').trim().toUpperCase();
|
||||||
|
const machine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some(
|
||||||
|
(value) => source === value || source.includes(value),
|
||||||
|
);
|
||||||
|
const label = machine
|
||||||
|
? '考勤机打卡'
|
||||||
|
: source === 'USER'
|
||||||
|
? '手机打卡'
|
||||||
|
: source.includes('BEACON') || source.includes('BLE')
|
||||||
|
? '蓝牙打卡'
|
||||||
|
: source.includes('WIFI')
|
||||||
|
? 'Wi-Fi 打卡'
|
||||||
|
: source.includes('APPROVE')
|
||||||
|
? '审批补卡'
|
||||||
|
: source
|
||||||
|
? `其他打卡(${record.punchSource})`
|
||||||
|
: '打卡来源未知';
|
||||||
|
const device = record.punchDeviceName?.trim();
|
||||||
|
const deviceId = record.punchDeviceId?.trim();
|
||||||
|
const detail = device
|
||||||
|
? deviceId && deviceId !== device
|
||||||
|
? `${device}(${deviceId})`
|
||||||
|
: device
|
||||||
|
: deviceId || undefined;
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
machine,
|
||||||
|
detail,
|
||||||
|
time: record.punchTime || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -502,3 +502,25 @@
|
|||||||
.attendance-marking-actions .ant-btn {
|
.attendance-marking-actions .ant-btn {
|
||||||
min-width: 54px;
|
min-width: 54px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.punch-device-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punch-device-cell .ant-tag {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punch-device-cell strong {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punch-device-cell span {
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { message } from '../../ui/app-message';
|
|||||||
import {
|
import {
|
||||||
canPullAttendance,
|
canPullAttendance,
|
||||||
getAttendanceExperience,
|
getAttendanceExperience,
|
||||||
|
getPunchDisplayInfo,
|
||||||
getSchedulePhase,
|
getSchedulePhase,
|
||||||
summarizeLessonCheckins,
|
summarizeLessonCheckins,
|
||||||
type AttendanceSummary,
|
type AttendanceSummary,
|
||||||
@@ -96,6 +97,10 @@ interface AttendanceRecordItem {
|
|||||||
class: { id: number; name: string } | null;
|
class: { id: number; name: string } | null;
|
||||||
scheduleId?: number | null;
|
scheduleId?: number | null;
|
||||||
attendanceSessionId?: number | null;
|
attendanceSessionId?: number | null;
|
||||||
|
punchTime?: string | null;
|
||||||
|
punchSource?: string | null;
|
||||||
|
punchDeviceName?: string | null;
|
||||||
|
punchDeviceId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssignedClass {
|
interface AssignedClass {
|
||||||
@@ -288,6 +293,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
`/attendance-lessons/schedules/${schedule.id}/pull`,
|
`/attendance-lessons/schedules/${schedule.id}/pull`,
|
||||||
{ date: today },
|
{ date: today },
|
||||||
);
|
);
|
||||||
|
setSelectedSchedule(data.schedule);
|
||||||
setLessonSession(data.session);
|
setLessonSession(data.session);
|
||||||
setLessonRecords(data.records);
|
setLessonRecords(data.records);
|
||||||
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
||||||
@@ -386,7 +392,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
|
|
||||||
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={820} title={null} className="attendance-drawer">
|
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
|
||||||
<div className="lesson-record-header">
|
<div className="lesson-record-header">
|
||||||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||||||
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
||||||
@@ -438,6 +444,21 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
|
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
|
||||||
|
{
|
||||||
|
title: '打卡设备',
|
||||||
|
width: 220,
|
||||||
|
render: (_: unknown, record: AttendanceRecordItem) => {
|
||||||
|
const info = getPunchDisplayInfo(record);
|
||||||
|
if (!info) return <span className="muted-text">—</span>;
|
||||||
|
return (
|
||||||
|
<div className="punch-device-cell">
|
||||||
|
<Tag color={info.machine ? 'green' : 'blue'}>{info.label}</Tag>
|
||||||
|
{info.detail && <strong>{info.detail}</strong>}
|
||||||
|
{info.time && <span>{dayjs(info.time).format('HH:mm:ss')}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">—</span> },
|
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">—</span> },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Popconfirm,
|
Popconfirm,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Tooltip,
|
|
||||||
Spin,
|
Spin,
|
||||||
Empty,
|
Empty,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -35,14 +34,166 @@ const statusMap: Record<string, { text: string; color: string }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
water: '水费',
|
utility: '水电费',
|
||||||
electricity: '电费',
|
|
||||||
cleaning: '保洁费',
|
cleaning: '保洁费',
|
||||||
|
rent: '租金',
|
||||||
damage: '损坏赔偿',
|
damage: '损坏赔偿',
|
||||||
penalty: '罚款',
|
penalty: '罚款',
|
||||||
other: '其他',
|
other: '其他',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (value: unknown) =>
|
||||||
|
String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
|
||||||
|
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 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; }
|
||||||
|
.balance { color: #52c41a; font-size: 11px; }
|
||||||
|
.balance-after { color: #fa541c; font-size: 12px; font-weight: 700; }
|
||||||
|
.shortage { color: #ff4d4f; font-size: 12px; font-weight: 700; }
|
||||||
|
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 class="total">应付总额: ${escapeHtml(money(bill.totalAmount))}</div>
|
||||||
|
<div class="balance">当前水电余额: ${escapeHtml(money(bill.utilityBalance))}</div>
|
||||||
|
<div class="balance-after">扣本账单后余额: ${escapeHtml(money(bill.utilityBalanceAfterBill))}</div>
|
||||||
|
${
|
||||||
|
Number(bill.utilityShortageAmount || 0) > 0
|
||||||
|
? `<div class="shortage">需补缴: ${escapeHtml(money(bill.utilityShortageAmount))}</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);
|
||||||
@@ -89,9 +240,11 @@ const BillsPage: React.FC = () => {
|
|||||||
}, [bills, searchText, filterStatus]);
|
}, [bills, searchText, filterStatus]);
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
setSaving(true);
|
if (saving) return;
|
||||||
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', {
|
||||||
periodStart: values.period[0].format('YYYY-MM-DD'),
|
periodStart: values.period[0].format('YYYY-MM-DD'),
|
||||||
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
||||||
@@ -99,9 +252,12 @@ const BillsPage: React.FC = () => {
|
|||||||
message.success(res.message || '生成成功');
|
message.success(res.message || '生成成功');
|
||||||
setGenerateModal(false);
|
setGenerateModal(false);
|
||||||
generateForm.resetFields();
|
generateForm.resetFields();
|
||||||
fetchData();
|
void fetchData();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '生成失败');
|
// Ant Design 的表单校验失败会 reject;字段本身已展示错误,无需再弹“生成失败”。
|
||||||
|
if (!e?.errorFields) {
|
||||||
|
message.error(e?.message || '生成失败');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -181,11 +337,24 @@ const BillsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportPdf = (billId: number) => {
|
const handleExportPdf = useCallback(async (billId: number) => {
|
||||||
downloadBlob(`/bills/export/pdf/${billId}`, `账单_${billId}.pdf`).catch(() =>
|
const printWindow = window.open('', '_blank');
|
||||||
message.error('导出失败'),
|
if (!printWindow) {
|
||||||
);
|
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 || '-' },
|
||||||
@@ -197,13 +366,6 @@ const BillsPage: React.FC = () => {
|
|||||||
align: 'right' as const,
|
align: 'right' as const,
|
||||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '个人费用',
|
|
||||||
dataIndex: 'personalAmount',
|
|
||||||
width: 120,
|
|
||||||
align: 'right' as const,
|
|
||||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '总计',
|
title: '总计',
|
||||||
dataIndex: 'totalAmount',
|
dataIndex: 'totalAmount',
|
||||||
@@ -212,32 +374,39 @@ 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: 'availableDeposit',
|
dataIndex: 'utilityBalance',
|
||||||
width: 120,
|
width: 130,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => (
|
||||||
|
<span style={{ color: Number(v || 0) < 0 ? '#ff4d4f' : '#52c41a' }}>
|
||||||
|
¥{Number(v || 0).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '扣本账单后余额',
|
||||||
|
dataIndex: 'utilityBalanceAfterBill',
|
||||||
|
width: 150,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => (
|
||||||
|
<strong style={{ color: Number(v || 0) < 0 ? '#ff4d4f' : '#52c41a' }}>
|
||||||
|
¥{Number(v || 0).toFixed(2)}
|
||||||
|
</strong>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '需补缴',
|
||||||
|
dataIndex: 'utilityShortageAmount',
|
||||||
|
width: 110,
|
||||||
|
align: 'right' as const,
|
||||||
render: (v: number) =>
|
render: (v: number) =>
|
||||||
v > 0 ? (
|
Number(v || 0) > 0 ? (
|
||||||
<span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
|
<strong style={{ color: '#ff4d4f' }}>¥{Number(v).toFixed(2)}</strong>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: '#999' }}>-</span>
|
<span style={{ color: '#999' }}>-</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '抵扣后应付',
|
|
||||||
dataIndex: 'amountAfterDeposit',
|
|
||||||
width: 130,
|
|
||||||
render: (v: number, r: any) => {
|
|
||||||
const has = Number(r.availableDeposit || 0) > 0;
|
|
||||||
if (!has) return <span style={{ color: '#999' }}>-</span>;
|
|
||||||
const after = Number(v ?? r.totalAmount).toFixed(2);
|
|
||||||
const applied = Number(r.depositApplied || 0).toFixed(2);
|
|
||||||
return (
|
|
||||||
<Tooltip title={`已抵扣押金 ¥${applied}`}>
|
|
||||||
<strong style={{ color: '#fa541c' }}>¥{after}</strong>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -339,7 +508,7 @@ const BillsPage: React.FC = () => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<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:'utility',label:'水电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} />
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="bill:confirm"
|
permission="bill:confirm"
|
||||||
onClick={() => batchUpdateStatus('confirmed')}
|
onClick={() => batchUpdateStatus('confirmed')}
|
||||||
@@ -461,51 +630,45 @@ const BillsPage: React.FC = () => {
|
|||||||
<Descriptions.Item label="分摊费用">
|
<Descriptions.Item label="分摊费用">
|
||||||
¥{Number(detailModal.sharedAmount).toFixed(2)}
|
¥{Number(detailModal.sharedAmount).toFixed(2)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="个人费用">
|
|
||||||
¥{Number(detailModal.personalAmount).toFixed(2)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="合计" span={2}>
|
<Descriptions.Item label="合计" span={2}>
|
||||||
<strong style={{ fontSize: 18, color: '#007AFF' }}>
|
<strong style={{ fontSize: 18, color: '#007AFF' }}>
|
||||||
¥{Number(detailModal.totalAmount).toFixed(2)}
|
¥{Number(detailModal.totalAmount).toFixed(2)}
|
||||||
</strong>
|
</strong>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
{Number(detailModal.availableDeposit || 0) > 0 && (
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
marginBottom: 16,
|
||||||
marginBottom: 16,
|
padding: 12,
|
||||||
padding: 12,
|
background: '#f6ffed',
|
||||||
background: '#f6ffed',
|
border: '1px solid #b7eb8f',
|
||||||
border: '1px solid #b7eb8f',
|
borderRadius: 8,
|
||||||
borderRadius: 8,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>水电余额</div>
|
||||||
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>
|
<Space size={24} wrap>
|
||||||
押金联动(不影响实际押金状态,仅作收款参考)
|
<span>
|
||||||
</div>
|
当前余额:
|
||||||
<Space size={24} wrap>
|
<strong style={{ color: Number(detailModal.utilityBalance || 0) < 0 ? '#ff4d4f' : '#52c41a' }}>
|
||||||
|
¥{Number(detailModal.utilityBalance || 0).toFixed(2)}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
扣本账单后余额:
|
||||||
|
<strong style={{ color: Number(detailModal.utilityBalanceAfterBill || 0) < 0 ? '#ff4d4f' : '#52c41a', fontSize: 16 }}>
|
||||||
|
¥{Number(detailModal.utilityBalanceAfterBill || 0).toFixed(2)}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
{Number(detailModal.utilityShortageAmount || 0) > 0 && (
|
||||||
<span>
|
<span>
|
||||||
当前可用押金:
|
需补缴:
|
||||||
<strong style={{ color: '#52c41a' }}>
|
<strong style={{ color: '#ff4d4f', fontSize: 16 }}>
|
||||||
¥{Number(detailModal.availableDeposit).toFixed(2)}
|
¥{Number(detailModal.utilityShortageAmount || 0).toFixed(2)}
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
)}
|
||||||
本账单可抵扣:
|
</Space>
|
||||||
<strong style={{ color: '#fa8c16' }}>
|
</div>
|
||||||
-¥{Number(detailModal.depositApplied || 0).toFixed(2)}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
抵扣后实付:
|
|
||||||
<strong style={{ color: '#fa541c', fontSize: 16 }}>
|
|
||||||
¥
|
|
||||||
{Number(detailModal.amountAfterDeposit ?? detailModal.totalAmount).toFixed(2)}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<h4>费用明细</h4>
|
<h4>费用明细</h4>
|
||||||
<Table
|
<Table
|
||||||
scroll={{ x: 700 }}
|
scroll={{ x: 700 }}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildDepositStudentOption } from './deposit-student-option';
|
import { buildDepositStudentOption, buildDepositStudentOptions } from './deposit-student-option';
|
||||||
|
|
||||||
describe('deposit student option', () => {
|
describe('deposit student option', () => {
|
||||||
it('uses the student number as the non-sensitive identifier', () => {
|
it('uses the student number as the non-sensitive identifier', () => {
|
||||||
@@ -17,4 +17,13 @@ describe('deposit student option', () => {
|
|||||||
label: '张三 (#23)',
|
label: '张三 (#23)',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses lookup rows without requiring a status field', () => {
|
||||||
|
expect(buildDepositStudentOptions([{ id: 23, name: '张三', studentNo: 'S2026001' }])).toEqual([
|
||||||
|
{
|
||||||
|
value: 23,
|
||||||
|
label: '张三 (S2026001)',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ export const buildDepositStudentOption = (student: DepositStudentLookup) => ({
|
|||||||
value: student.id,
|
value: student.id,
|
||||||
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
|
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const buildDepositStudentOptions = (students: DepositStudentLookup[]) =>
|
||||||
|
students.map(buildDepositStudentOption);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import dayjs from 'dayjs';
|
|||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { buildDepositStudentOption } from './deposit-student-option';
|
import { buildDepositStudentOptions } from './deposit-student-option';
|
||||||
|
|
||||||
const statusMap: Record<string, { text: string; color: string }> = {
|
const statusMap: Record<string, { text: string; color: string }> = {
|
||||||
paid: { text: '已缴', color: 'green' },
|
paid: { text: '已缴', color: 'green' },
|
||||||
@@ -38,6 +38,8 @@ const isFormValidationError = (error: unknown) =>
|
|||||||
&& error !== null
|
&& error !== null
|
||||||
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
|
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
|
||||||
|
|
||||||
|
const money = (value: unknown) => Number(Number(value || 0).toFixed(2));
|
||||||
|
|
||||||
const DepositsPage: React.FC = () => {
|
const DepositsPage: React.FC = () => {
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const [students, setStudents] = useState<any[]>([]);
|
const [students, setStudents] = useState<any[]>([]);
|
||||||
@@ -86,10 +88,7 @@ const DepositsPage: React.FC = () => {
|
|||||||
}, [data, searchText, filterStatus]);
|
}, [data, searchText, filterStatus]);
|
||||||
|
|
||||||
const studentOptions = useMemo(
|
const studentOptions = useMemo(
|
||||||
() =>
|
() => buildDepositStudentOptions(students),
|
||||||
students
|
|
||||||
.filter((s: any) => s.status === 'active')
|
|
||||||
.map(buildDepositStudentOption),
|
|
||||||
[students],
|
[students],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -224,8 +223,13 @@ const DepositsPage: React.FC = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const personalExpenseAmount = money(record.personalExpenseAmount);
|
||||||
|
const depositAmount = money(record.amount);
|
||||||
setRefundModal(record);
|
setRefundModal(record);
|
||||||
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 });
|
refundForm.setFieldsValue({
|
||||||
|
refundDate: dayjs(),
|
||||||
|
deductionAmount: Math.min(personalExpenseAmount, depositAmount),
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
退还
|
退还
|
||||||
@@ -368,15 +372,25 @@ const DepositsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Form form={refundForm} layout="vertical">
|
<Form form={refundForm} layout="vertical">
|
||||||
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
||||||
押金金额: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
|
<div>
|
||||||
|
押金金额: <strong>¥{money(refundModal?.amount).toFixed(2)}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
个人附加费合计:{' '}
|
||||||
|
<strong>¥{money(refundModal?.personalExpenseAmount).toFixed(2)}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
|
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
|
||||||
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
|
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="deductionAmount" label="扣除金额(元)" extra="如无扣除填0">
|
<Form.Item
|
||||||
|
name="deductionAmount"
|
||||||
|
label="扣除金额(元)"
|
||||||
|
extra="自动填入该学生个人附加费用总和;超过押金金额时按押金金额封顶"
|
||||||
|
>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={0}
|
min={0}
|
||||||
max={Number(refundModal?.amount || 500)}
|
max={money(refundModal?.amount || 500)}
|
||||||
precision={2}
|
precision={2}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider,
|
Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider,
|
||||||
Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
|
Drawer, Tree, Select, TreeSelect, Modal, DatePicker,
|
||||||
Row, Col, List,
|
Row, Col, List,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -59,7 +59,6 @@ interface ClassItem {
|
|||||||
classType?: string;
|
classType?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
maxStudents?: number;
|
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,9 +476,6 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
<Form.Item name="endDate" label="结束日期">
|
<Form.Item name="endDate" label="结束日期">
|
||||||
<DatePicker style={{ width: '100%' }} />
|
<DatePicker style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="maxStudents" label="最大人数">
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="0=不限制" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="notes" label="备注">
|
<Form.Item name="notes" label="备注">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const [students, setStudents] = useState<any[]>([]);
|
const [students, setStudents] = useState<any[]>([]);
|
||||||
const [rooms, setRooms] = useState<any[]>([]);
|
const [rooms, setRooms] = useState<any[]>([]);
|
||||||
const [organizations, setOrganizations] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [checkInModal, setCheckInModal] = useState(false);
|
const [checkInModal, setCheckInModal] = useState(false);
|
||||||
const [checkOutModal, setCheckOutModal] = useState<any>(null);
|
const [checkOutModal, setCheckOutModal] = useState<any>(null);
|
||||||
@@ -66,14 +65,13 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [occRes, stuRes, rmRes, tnRes] = (await Promise.allSettled([
|
const [occRes, stuRes, rmRes] = (await Promise.allSettled([
|
||||||
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
|
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
|
||||||
api.get('/students/basic-lookups'),
|
api.get('/students/basic-lookups'),
|
||||||
api.get('/rooms/overview'),
|
api.get('/rooms/overview'),
|
||||||
api.get('/organizations'),
|
|
||||||
])) as PromiseSettledResult<any>[];
|
])) as PromiseSettledResult<any>[];
|
||||||
const labels = ['入住数据', '学生列表', '房间列表', '机构列表'];
|
const labels = ['入住数据', '学生列表', '房间列表'];
|
||||||
[occRes, stuRes, rmRes, tnRes].forEach((res, i) => {
|
[occRes, stuRes, rmRes].forEach((res, i) => {
|
||||||
if (res.status === 'rejected') {
|
if (res.status === 'rejected') {
|
||||||
message.warning(`${labels[i]}加载失败`);
|
message.warning(`${labels[i]}加载失败`);
|
||||||
}
|
}
|
||||||
@@ -81,7 +79,6 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
setData(occRes.status === 'fulfilled' ? occRes.value : []);
|
setData(occRes.status === 'fulfilled' ? occRes.value : []);
|
||||||
setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []);
|
setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []);
|
||||||
setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []);
|
setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []);
|
||||||
setOrganizations(tnRes.status === 'fulfilled' ? tnRes.value : []);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
message.error('数据加载异常');
|
message.error('数据加载异常');
|
||||||
@@ -157,7 +154,8 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
checkInDate: values.checkInDate.format('YYYY-MM-DD'),
|
checkInDate: values.checkInDate.format('YYYY-MM-DD'),
|
||||||
billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'),
|
billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'),
|
||||||
stayType: values.stayType,
|
stayType: values.stayType,
|
||||||
responsibleOrganizationId: values.responsibleOrganizationId,
|
collectDeposit: values.collectDeposit,
|
||||||
|
depositAmount: values.collectDeposit ? values.depositAmount : undefined,
|
||||||
notes: values.notes,
|
notes: values.notes,
|
||||||
bedId: values.bedId,
|
bedId: values.bedId,
|
||||||
lockerId: values.lockerId || undefined,
|
lockerId: values.lockerId || undefined,
|
||||||
@@ -330,7 +328,7 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<Alert
|
<Alert
|
||||||
title="一站式导入"
|
title="一站式导入"
|
||||||
description="导入入住名单时会自动创建学生和宿舍,无需单独在「学生管理」或「宿舍管理」中手动添加。后续仅需在此页面处理换房/退宿等日常操作即可。"
|
description="导入入住名单时会优先按手机号关联已有学生,所属机构自动取学生档案;未找到学生或宿舍时会自动创建。后续仅需在此页面处理换房/退宿等日常操作即可。"
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
closable
|
closable
|
||||||
@@ -367,7 +365,7 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
checkInForm.resetFields();
|
checkInForm.resetFields();
|
||||||
checkInForm.setFieldsValue({ checkInDate: dayjs() });
|
checkInForm.setFieldsValue({ checkInDate: dayjs(), collectDeposit: true, depositAmount: 500 });
|
||||||
setCheckInModal(true);
|
setCheckInModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -407,7 +405,7 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip title="导入时自动创建学生、宿舍和入住记录">
|
<Tooltip title="按手机号关联学生,并自动创建缺失的学生、宿舍和入住记录">
|
||||||
<Button type="primary" ghost icon={<UploadOutlined />}>
|
<Button type="primary" ghost icon={<UploadOutlined />}>
|
||||||
导入入住名单
|
导入入住名单
|
||||||
</Button>
|
</Button>
|
||||||
@@ -598,18 +596,6 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
placeholder="默认为短租"
|
placeholder="默认为短租"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="responsibleOrganizationId" label="负责机构">
|
|
||||||
<Select
|
|
||||||
showSearch
|
|
||||||
allowClear
|
|
||||||
optionFilterProp="label"
|
|
||||||
placeholder="默认取学生所属机构"
|
|
||||||
options={organizations.map((t: { id: number; name: string }) => ({
|
|
||||||
value: t.id,
|
|
||||||
label: t.name,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="bedId"
|
name="bedId"
|
||||||
label="床位"
|
label="床位"
|
||||||
@@ -630,7 +616,10 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
空闲 {availableBeds.length} 张床位
|
空闲 {availableBeds.length} 张床位
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Form.Item name="lockerId" label="柜子(可选)">
|
<Form.Item
|
||||||
|
name="lockerId"
|
||||||
|
label="柜子(可选)"
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="可选分配柜子"
|
placeholder="可选分配柜子"
|
||||||
@@ -641,6 +630,32 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="collectDeposit"
|
||||||
|
label="押金缴纳"
|
||||||
|
valuePropName="checked"
|
||||||
|
extra="开启后,确认入住时同步生成已缴押金记录;已有已缴押金时不会重复创建"
|
||||||
|
>
|
||||||
|
<Switch checkedChildren="已缴" unCheckedChildren="不缴" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, current) => prev.collectDeposit !== current.collectDeposit}>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('collectDeposit') ? (
|
||||||
|
<Form.Item
|
||||||
|
name="depositAmount"
|
||||||
|
label="押金金额"
|
||||||
|
rules={[{ required: true, message: '请输入押金金额' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0.01}
|
||||||
|
precision={2}
|
||||||
|
addonAfter="元"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="notes" label="备注">
|
<Form.Item name="notes" label="备注">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
190
apps/admin/src/pages/UtilityBalances/index.tsx
Normal file
190
apps/admin/src/pages/UtilityBalances/index.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { DatePicker, Empty, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||||
|
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import api from '../../api';
|
||||||
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import { message } from '../../ui/app-message';
|
||||||
|
|
||||||
|
const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`;
|
||||||
|
|
||||||
|
const UtilityBalancesPage: React.FC = () => {
|
||||||
|
const [balances, setBalances] = useState<any[]>([]);
|
||||||
|
const [recharges, setRecharges] = useState<any[]>([]);
|
||||||
|
const [students, setStudents] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [studentFilter, setStudentFilter] = useState<number | undefined>();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [balanceRows, rechargeRows, studentRows]: any[] = await Promise.all([
|
||||||
|
api.get('/utility-balances/balances'),
|
||||||
|
api.get('/utility-balances/recharges', {
|
||||||
|
params: studentFilter ? { studentId: studentFilter } : undefined,
|
||||||
|
}),
|
||||||
|
api.get('/utility-balances/student-lookups'),
|
||||||
|
]);
|
||||||
|
setBalances(balanceRows);
|
||||||
|
setRecharges(rechargeRows);
|
||||||
|
setStudents(studentRows);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '加载失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [studentFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
await api.post('/utility-balances/recharges', {
|
||||||
|
studentId: values.studentId,
|
||||||
|
amount: values.amount,
|
||||||
|
rechargeDate: values.rechargeDate.format('YYYY-MM-DD'),
|
||||||
|
notes: values.notes,
|
||||||
|
});
|
||||||
|
message.success('充值成功');
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
fetchData();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!e?.errorFields) message.error(e?.message || '充值失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const studentOptions = useMemo(
|
||||||
|
() => students.map((s) => ({ value: s.id, label: `${s.name}${s.studentNo ? `(${s.studentNo})` : ''}` })),
|
||||||
|
[students],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<Space wrap>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ width: 220 }}
|
||||||
|
placeholder="按学生筛选充值记录"
|
||||||
|
value={studentFilter}
|
||||||
|
onChange={setStudentFilter}
|
||||||
|
options={studentOptions}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<PermissionButton
|
||||||
|
permission="expense:create"
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
form.setFieldsValue({ rechargeDate: dayjs() });
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
水电余额充值
|
||||||
|
</PermissionButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
title={() => '水电余额汇总'}
|
||||||
|
loading={loading}
|
||||||
|
dataSource={balances}
|
||||||
|
rowKey="studentId"
|
||||||
|
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
|
||||||
|
locale={{ emptyText: <Empty description="暂无余额数据" /> }}
|
||||||
|
columns={[
|
||||||
|
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
|
||||||
|
{ title: '学号', render: (_: any, r: any) => r.student?.studentNo || '-' },
|
||||||
|
{ title: '累计充值', dataIndex: 'totalRecharged', align: 'right' as const, render: money },
|
||||||
|
{ title: '账单扣款', dataIndex: 'usedAmount', align: 'right' as const, render: money },
|
||||||
|
{
|
||||||
|
title: '剩余水电余额',
|
||||||
|
dataIndex: 'balance',
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => (
|
||||||
|
<strong style={{ color: Number(v) < 0 ? '#ff4d4f' : '#52c41a' }}>{money(v)}</strong>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
style={{ marginTop: 24 }}
|
||||||
|
title={() => '充值记录'}
|
||||||
|
loading={loading}
|
||||||
|
dataSource={recharges}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={{ defaultPageSize: 15, showSizeChanger: true }}
|
||||||
|
locale={{ emptyText: <Empty description="暂无充值记录" /> }}
|
||||||
|
columns={[
|
||||||
|
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
|
||||||
|
{ title: '充值金额', dataIndex: 'amount', align: 'right' as const, render: money },
|
||||||
|
{ title: '充值日期', dataIndex: 'rechargeDate' },
|
||||||
|
{ title: '类型', render: () => <Tag color="green">充值</Tag> },
|
||||||
|
{ title: '备注', dataIndex: 'notes' },
|
||||||
|
{
|
||||||
|
title: '录入时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 100,
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除这条充值记录?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await api.delete(`/utility-balances/recharges/${record.id}`);
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchData();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PermissionButton permission="expense:delete" size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</PermissionButton>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="水电余额充值"
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleCreate}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
confirmLoading={saving}
|
||||||
|
okText="确认充值"
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="studentId" label="学生" rules={[{ required: true, message: '请选择学生' }]}>
|
||||||
|
<Select showSearch optionFilterProp="label" options={studentOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="amount" label="充值金额" rules={[{ required: true, message: '请输入充值金额' }]}>
|
||||||
|
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="rechargeDate" label="充值日期" rules={[{ required: true, message: '请选择充值日期' }]}>
|
||||||
|
<DatePicker style={{ width: '100%' }} format="YYYY-MM-DD" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="notes" label="备注">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UtilityBalancesPage;
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
OperationLog,
|
OperationLog,
|
||||||
Deposit,
|
Deposit,
|
||||||
DepositInstallment,
|
DepositInstallment,
|
||||||
|
UtilityRecharge,
|
||||||
Classroom,
|
Classroom,
|
||||||
Organization,
|
Organization,
|
||||||
ClassroomRental,
|
ClassroomRental,
|
||||||
@@ -71,6 +72,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 { UtilityBalancesModule } from './utility-balances/utility-balances.module';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IntegrationConfig,
|
IntegrationConfig,
|
||||||
@@ -109,6 +111,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
|||||||
OperationLog,
|
OperationLog,
|
||||||
Deposit,
|
Deposit,
|
||||||
DepositInstallment,
|
DepositInstallment,
|
||||||
|
UtilityRecharge,
|
||||||
Classroom,
|
Classroom,
|
||||||
Organization,
|
Organization,
|
||||||
ClassroomRental,
|
ClassroomRental,
|
||||||
@@ -181,6 +184,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
|||||||
AgentToolsModule,
|
AgentToolsModule,
|
||||||
ExpenseTypesModule,
|
ExpenseTypesModule,
|
||||||
AiConfigModule,
|
AiConfigModule,
|
||||||
|
UtilityBalancesModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||||
|
|||||||
@@ -93,6 +93,31 @@ describe('AttendanceImportService', () => {
|
|||||||
expect(entity.userName).toBe('张三');
|
expect(entity.userName).toBe('张三');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores DingTalk punch source and attendance machine metadata', async () => {
|
||||||
|
const entity = await (service as any).mapToEntity({
|
||||||
|
userId: 'ding-1',
|
||||||
|
userName: '张三',
|
||||||
|
workDate: '2026-07-01',
|
||||||
|
timeResult: 'Normal',
|
||||||
|
locationResult: '',
|
||||||
|
planCheckTime: '',
|
||||||
|
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||||
|
checkId: 'check-1',
|
||||||
|
checkType: 'OnDuty',
|
||||||
|
sourceType: 'ATM',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entity).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
|
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
|
||||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -138,9 +163,19 @@ describe('AttendanceImportService', () => {
|
|||||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||||
checkId: 'check-1',
|
checkId: 'check-1',
|
||||||
checkType: 'OnDuty',
|
checkType: 'OnDuty',
|
||||||
|
sourceType: 'ATM',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
dingRawRepo.find.mockResolvedValue([{ dingId: 'check-1' }]);
|
dingRawRepo.find.mockResolvedValue([{
|
||||||
|
dingId: 'check-1',
|
||||||
|
punchSource: null,
|
||||||
|
punchDeviceName: null,
|
||||||
|
punchDeviceId: null,
|
||||||
|
rawData: '',
|
||||||
|
}]);
|
||||||
|
dingRawRepo.save.mockImplementation(async (entities) => entities);
|
||||||
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
|
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
|
||||||
|
|
||||||
const result = await service.importFromDingTalk({
|
const result = await service.importFromDingTalk({
|
||||||
@@ -150,10 +185,57 @@ describe('AttendanceImportService', () => {
|
|||||||
autoMatch: true,
|
autoMatch: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(dingRawRepo.save).toHaveBeenCalledWith(
|
||||||
|
[expect.objectContaining({
|
||||||
|
dingId: 'check-1',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
})],
|
||||||
|
{ chunk: 50 },
|
||||||
|
);
|
||||||
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
|
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
|
||||||
expect(result.matched).toBe(1);
|
expect(result.matched).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves existing device metadata when a duplicate response omits it', async () => {
|
||||||
|
const existing = {
|
||||||
|
dingId: 'check-keep-device',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
rawData: '{}',
|
||||||
|
};
|
||||||
|
dingTalkService.fetchAttendanceResults.mockResolvedValue([{
|
||||||
|
userId: 'ding-1',
|
||||||
|
userName: '张三',
|
||||||
|
workDate: '2026-07-01',
|
||||||
|
timeResult: 'Normal',
|
||||||
|
locationResult: '',
|
||||||
|
planCheckTime: '',
|
||||||
|
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||||
|
checkId: 'check-keep-device',
|
||||||
|
checkType: 'OnDuty',
|
||||||
|
sourceType: '',
|
||||||
|
}]);
|
||||||
|
dingRawRepo.find.mockResolvedValue([existing]);
|
||||||
|
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 0, total: 1 });
|
||||||
|
|
||||||
|
await service.importFromDingTalk({
|
||||||
|
startDate: '2026-07-01',
|
||||||
|
endDate: '2026-07-01',
|
||||||
|
userIds: ['ding-1'],
|
||||||
|
autoMatch: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(existing).toEqual(expect.objectContaining({
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
}));
|
||||||
|
expect(dingRawRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('scopes SSE progress events to the importing user', async () => {
|
it('scopes SSE progress events to the importing user', async () => {
|
||||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -104,8 +104,10 @@ export class AttendanceImportService {
|
|||||||
|
|
||||||
// Stage 2: Parse & deduplicate
|
// Stage 2: Parse & deduplicate
|
||||||
this.emit('parsing', 0, total, `Parsing ${total} records...`);
|
this.emit('parsing', 0, total, `Parsing ${total} records...`);
|
||||||
const existingDingIds = await this.getExistingDingIds(rawResults);
|
const existingByDingId = await this.getExistingRecordsByDingId(rawResults);
|
||||||
const newRecords = rawResults.filter((r) => !existingDingIds.has(r.checkId));
|
const newRecords = rawResults.filter((r) => !existingByDingId.has(r.checkId));
|
||||||
|
const duplicateRecords = rawResults.filter((r) => existingByDingId.has(r.checkId));
|
||||||
|
await this.refreshDuplicatePunchMetadata(duplicateRecords, existingByDingId);
|
||||||
skipped = rawResults.length - newRecords.length;
|
skipped = rawResults.length - newRecords.length;
|
||||||
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
|
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
|
||||||
|
|
||||||
@@ -246,17 +248,45 @@ export class AttendanceImportService {
|
|||||||
/**
|
/**
|
||||||
* Query which dingIds already exist to skip duplicates.
|
* Query which dingIds already exist to skip duplicates.
|
||||||
*/
|
*/
|
||||||
private async getExistingDingIds(
|
private async getExistingRecordsByDingId(
|
||||||
results: DingTalkAttendanceResult[],
|
results: DingTalkAttendanceResult[],
|
||||||
): Promise<Set<string>> {
|
): Promise<Map<string, DingAttendanceRaw>> {
|
||||||
const dingIds = results.map((r) => r.checkId).filter(Boolean);
|
const dingIds = results.map((r) => r.checkId).filter(Boolean);
|
||||||
if (dingIds.length === 0) return new Set();
|
if (dingIds.length === 0) return new Map();
|
||||||
|
|
||||||
const existing = await this.dingRawRepo.find({
|
const existing = await this.dingRawRepo.find({
|
||||||
where: { dingId: In(dingIds) },
|
where: { dingId: In(dingIds) },
|
||||||
select: ['dingId'],
|
|
||||||
});
|
});
|
||||||
return new Set(existing.map((e) => e.dingId));
|
return new Map(existing.map((entity) => [entity.dingId, entity]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshDuplicatePunchMetadata(
|
||||||
|
results: DingTalkAttendanceResult[],
|
||||||
|
existingByDingId: Map<string, DingAttendanceRaw>,
|
||||||
|
): Promise<void> {
|
||||||
|
const changed: DingAttendanceRaw[] = [];
|
||||||
|
for (const result of results) {
|
||||||
|
const entity = existingByDingId.get(result.checkId);
|
||||||
|
if (!entity) continue;
|
||||||
|
const punchSource = result.sourceType || entity.punchSource || null;
|
||||||
|
const punchDeviceName = result.deviceName || entity.punchDeviceName || null;
|
||||||
|
const punchDeviceId = result.deviceId || entity.punchDeviceId || null;
|
||||||
|
if (
|
||||||
|
entity.punchSource === punchSource &&
|
||||||
|
entity.punchDeviceName === punchDeviceName &&
|
||||||
|
entity.punchDeviceId === punchDeviceId
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entity.punchSource = punchSource;
|
||||||
|
entity.punchDeviceName = punchDeviceName;
|
||||||
|
entity.punchDeviceId = punchDeviceId;
|
||||||
|
entity.rawData = JSON.stringify(result);
|
||||||
|
changed.push(entity);
|
||||||
|
}
|
||||||
|
if (changed.length > 0) {
|
||||||
|
await this.dingRawRepo.save(changed, { chunk: 50 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -271,6 +301,9 @@ export class AttendanceImportService {
|
|||||||
entity.attendanceType = r.checkType || 'OnDuty';
|
entity.attendanceType = r.checkType || 'OnDuty';
|
||||||
entity.timeResult = r.timeResult;
|
entity.timeResult = r.timeResult;
|
||||||
entity.locationResult = r.locationResult || '';
|
entity.locationResult = r.locationResult || '';
|
||||||
|
entity.punchSource = r.sourceType || null;
|
||||||
|
entity.punchDeviceName = r.deviceName || null;
|
||||||
|
entity.punchDeviceId = r.deviceId || null;
|
||||||
|
|
||||||
// Parse check-in/out times
|
// Parse check-in/out times
|
||||||
if (r.actualCheckTime) {
|
if (r.actualCheckTime) {
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
attendanceType: 'OnDuty',
|
attendanceType: 'OnDuty',
|
||||||
timeResult: 'Normal',
|
timeResult: 'Normal',
|
||||||
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matchedStudentId: 2,
|
matchedStudentId: 2,
|
||||||
@@ -100,7 +103,15 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||||
expect.objectContaining({ studentId: 1, status: 'present', source: 'dingtalk' }),
|
expect.objectContaining({
|
||||||
|
studentId: 1,
|
||||||
|
status: 'present',
|
||||||
|
source: 'dingtalk',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
punchTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||||
|
}),
|
||||||
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
|
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
|
||||||
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
|
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
|
||||||
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
|
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
|
||||||
|
|||||||
@@ -200,6 +200,53 @@ export class AttendanceService {
|
|||||||
if (hasPunch) return 'present';
|
if (hasPunch) return 'present';
|
||||||
return finalize ? 'absent' : 'pending';
|
return finalize ? 'absent' : 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getLessonPunchMetadata(
|
||||||
|
records: DingAttendanceRaw[],
|
||||||
|
lessonDate: string,
|
||||||
|
startTime: string,
|
||||||
|
): Pick<AttendanceRecord, 'punchTime' | 'punchSource' | 'punchDeviceName' | 'punchDeviceId'> {
|
||||||
|
const punches = records
|
||||||
|
.map((record) => ({ record, time: record.checkInTime ?? record.checkOutTime }))
|
||||||
|
.filter((item): item is { record: DingAttendanceRaw; time: Date } => !!item.time);
|
||||||
|
if (punches.length === 0) {
|
||||||
|
return {
|
||||||
|
punchTime: null,
|
||||||
|
punchSource: null,
|
||||||
|
punchDeviceName: null,
|
||||||
|
punchDeviceId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lessonStart = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
|
||||||
|
punches.sort(
|
||||||
|
(left, right) =>
|
||||||
|
Math.abs(left.time.getTime() - lessonStart) - Math.abs(right.time.getTime() - lessonStart),
|
||||||
|
);
|
||||||
|
const primary = punches[0];
|
||||||
|
const metadataRecord = [...punches]
|
||||||
|
.filter(({ record }) =>
|
||||||
|
!!(record.punchSource || record.punchDeviceName || record.punchDeviceId) ||
|
||||||
|
!['OnDuty', 'OffDuty'].includes(record.attendanceType),
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
Math.abs(left.time.getTime() - primary.time.getTime()) -
|
||||||
|
Math.abs(right.time.getTime() - primary.time.getTime()),
|
||||||
|
)[0]?.record;
|
||||||
|
const source =
|
||||||
|
metadataRecord?.punchSource ||
|
||||||
|
(metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType)
|
||||||
|
? metadataRecord.attendanceType
|
||||||
|
: primary.record.punchSource);
|
||||||
|
|
||||||
|
return {
|
||||||
|
punchTime: primary.time,
|
||||||
|
punchSource: source || null,
|
||||||
|
punchDeviceName: metadataRecord?.punchDeviceName || primary.record.punchDeviceName || null,
|
||||||
|
punchDeviceId: metadataRecord?.punchDeviceId || primary.record.punchDeviceId || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
async createLessonAttendanceFromDingTalk(
|
async createLessonAttendanceFromDingTalk(
|
||||||
scheduleId: number,
|
scheduleId: number,
|
||||||
lessonDate: string,
|
lessonDate: string,
|
||||||
@@ -270,6 +317,11 @@ export class AttendanceService {
|
|||||||
schedule.endTime,
|
schedule.endTime,
|
||||||
);
|
);
|
||||||
record.status = this.mapDingTalkStatus(raw, finalize);
|
record.status = this.mapDingTalkStatus(raw, finalize);
|
||||||
|
Object.assign(record, this.getLessonPunchMetadata(
|
||||||
|
raw,
|
||||||
|
lessonDate,
|
||||||
|
schedule.startTime,
|
||||||
|
));
|
||||||
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||||
? null
|
? null
|
||||||
: finalize
|
: finalize
|
||||||
@@ -296,6 +348,11 @@ export class AttendanceService {
|
|||||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||||
status: this.mapDingTalkStatus(raw, finalize),
|
status: this.mapDingTalkStatus(raw, finalize),
|
||||||
source: 'dingtalk',
|
source: 'dingtalk',
|
||||||
|
...this.getLessonPunchMetadata(
|
||||||
|
raw,
|
||||||
|
lessonDate,
|
||||||
|
schedule.startTime,
|
||||||
|
),
|
||||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||||
? undefined
|
? undefined
|
||||||
: finalize
|
: finalize
|
||||||
@@ -378,6 +435,11 @@ export class AttendanceService {
|
|||||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||||
status: this.mapDingTalkStatus(raw, finalize),
|
status: this.mapDingTalkStatus(raw, finalize),
|
||||||
source: 'dingtalk',
|
source: 'dingtalk',
|
||||||
|
...this.getLessonPunchMetadata(
|
||||||
|
raw,
|
||||||
|
lessonDate,
|
||||||
|
schedule.startTime,
|
||||||
|
),
|
||||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||||
? undefined
|
? undefined
|
||||||
: finalize
|
: finalize
|
||||||
@@ -908,6 +970,10 @@ export class AttendanceService {
|
|||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
record.status = dto.status;
|
record.status = dto.status;
|
||||||
record.source = 'manual';
|
record.source = 'manual';
|
||||||
|
record.punchTime = null;
|
||||||
|
record.punchSource = null;
|
||||||
|
record.punchDeviceName = null;
|
||||||
|
record.punchDeviceId = null;
|
||||||
}
|
}
|
||||||
if (dto.remark !== undefined) {
|
if (dto.remark !== undefined) {
|
||||||
record.remark = dto.remark;
|
record.remark = dto.remark;
|
||||||
@@ -933,6 +999,10 @@ export class AttendanceService {
|
|||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
freshRecord.status = dto.status;
|
freshRecord.status = dto.status;
|
||||||
freshRecord.source = 'manual';
|
freshRecord.source = 'manual';
|
||||||
|
freshRecord.punchTime = null;
|
||||||
|
freshRecord.punchSource = null;
|
||||||
|
freshRecord.punchDeviceName = null;
|
||||||
|
freshRecord.punchDeviceId = null;
|
||||||
}
|
}
|
||||||
if (dto.remark !== undefined) {
|
if (dto.remark !== undefined) {
|
||||||
freshRecord.remark = dto.remark;
|
freshRecord.remark = dto.remark;
|
||||||
|
|||||||
@@ -68,8 +68,10 @@ describe('DingTalkService — attendance records', () => {
|
|||||||
userId: 'ding-1',
|
userId: 'ding-1',
|
||||||
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
|
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
|
||||||
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
|
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
|
||||||
sourceType: 'USER',
|
sourceType: 'ATM',
|
||||||
checkType: 'OnDuty',
|
checkType: 'OnDuty',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
timeResult: 'Normal',
|
timeResult: 'Normal',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -83,6 +85,13 @@ describe('DingTalkService — attendance records', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(record.workDate).toBe('2026-07-12');
|
expect(record.workDate).toBe('2026-07-12');
|
||||||
|
expect(record).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
checkType: 'OnDuty',
|
||||||
|
sourceType: 'ATM',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from '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';
|
||||||
import { Deposit } from '../entities/deposit.entity';
|
import { UtilityRecharge } from '../entities/utility-recharge.entity';
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
import * as PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -13,7 +13,8 @@ export class BillsExportService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Bill) private billRepo: Repository<Bill>,
|
@InjectRepository(Bill) private billRepo: Repository<Bill>,
|
||||||
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
|
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
|
||||||
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
|
@InjectRepository(UtilityRecharge)
|
||||||
|
private utilityRechargeRepo: Repository<UtilityRecharge>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,19 +35,8 @@ 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 studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
||||||
const depMap = new Map<number, number>();
|
const balanceMap = await this.getUtilityBalanceMap(studentIds);
|
||||||
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 = '恭学教育基地管理系统';
|
||||||
@@ -58,11 +48,10 @@ export class BillsExportService {
|
|||||||
{ header: '学生姓名', key: 'studentName', width: 14 },
|
{ header: '学生姓名', key: 'studentName', width: 14 },
|
||||||
{ header: '计费周期', key: 'period', width: 24 },
|
{ header: '计费周期', key: 'period', width: 24 },
|
||||||
{ header: '分摊费用', key: 'shared', width: 12 },
|
{ header: '分摊费用', key: 'shared', width: 12 },
|
||||||
{ header: '个人费用', key: 'personal', width: 12 },
|
|
||||||
{ header: '总金额', key: 'total', width: 12 },
|
{ header: '总金额', key: 'total', width: 12 },
|
||||||
{ header: '可用押金', key: 'deposit', width: 12 },
|
{ header: '当前水电余额', key: 'utilityBalance', width: 14 },
|
||||||
{ header: '押金抵扣', key: 'depositApplied', width: 12 },
|
{ header: '扣本账单后余额', key: 'utilityBalanceAfterBill', width: 16 },
|
||||||
{ header: '抵扣后应付', key: 'afterDeposit', width: 14 },
|
{ header: '需补缴', key: 'utilityShortageAmount', width: 12 },
|
||||||
{ header: '状态', key: 'status', width: 10 },
|
{ header: '状态', key: 'status', width: 10 },
|
||||||
{ header: '生成时间', key: 'generatedAt', width: 20 },
|
{ header: '生成时间', key: 'generatedAt', width: 20 },
|
||||||
];
|
];
|
||||||
@@ -77,19 +66,21 @@ export class BillsExportService {
|
|||||||
};
|
};
|
||||||
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));
|
const balance = Number((balanceMap.get(bill.studentId) || 0).toFixed(2));
|
||||||
const applied = Number(Math.min(dep, total).toFixed(2));
|
const balanceAfterBill =
|
||||||
const after = Number(Math.max(0, total - applied).toFixed(2));
|
bill.status === 'confirmed' || bill.status === 'paid'
|
||||||
|
? balance
|
||||||
|
: Number((balance - total).toFixed(2));
|
||||||
|
const shortage = Math.max(0, -balanceAfterBill);
|
||||||
ws.addRow({
|
ws.addRow({
|
||||||
id: bill.id,
|
id: bill.id,
|
||||||
studentName: (bill as any).student?.name || '-',
|
studentName: (bill as any).student?.name || '-',
|
||||||
period: `${bill.periodStart} ~ ${bill.periodEnd}`,
|
period: `${bill.periodStart} ~ ${bill.periodEnd}`,
|
||||||
shared: Number(bill.sharedAmount),
|
shared: Number(bill.sharedAmount),
|
||||||
personal: Number(bill.personalAmount),
|
|
||||||
total,
|
total,
|
||||||
deposit: dep,
|
utilityBalance: balance,
|
||||||
depositApplied: applied,
|
utilityBalanceAfterBill: balanceAfterBill,
|
||||||
afterDeposit: after,
|
utilityShortageAmount: shortage,
|
||||||
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') : '',
|
||||||
});
|
});
|
||||||
@@ -147,16 +138,13 @@ 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 depositApplied = Math.min(availableDeposit, totalAmount);
|
const balanceMap = await this.getUtilityBalanceMap([bill.studentId]);
|
||||||
const amountAfterDeposit = Math.max(0, totalAmount - depositApplied);
|
const utilityBalance = Number((balanceMap.get(bill.studentId) || 0).toFixed(2));
|
||||||
|
const utilityBalanceAfterBill =
|
||||||
|
bill.status === 'confirmed' || bill.status === 'paid'
|
||||||
|
? utilityBalance
|
||||||
|
: Number((utilityBalance - totalAmount).toFixed(2));
|
||||||
|
|
||||||
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');
|
||||||
@@ -217,26 +205,16 @@ export class BillsExportService {
|
|||||||
doc.moveDown(0.3);
|
doc.moveDown(0.3);
|
||||||
doc.fontSize(12);
|
doc.fontSize(12);
|
||||||
doc.text(`分摊费用: ¥${Number(bill.sharedAmount).toFixed(2)}`);
|
doc.text(`分摊费用: ¥${Number(bill.sharedAmount).toFixed(2)}`);
|
||||||
doc.text(`个人费用: ¥${Number(bill.personalAmount).toFixed(2)}`);
|
|
||||||
doc
|
doc
|
||||||
.fontSize(14)
|
.fontSize(14)
|
||||||
.fillColor('#007AFF')
|
.fillColor('#007AFF')
|
||||||
.text(`应付总额: ¥${totalAmount.toFixed(2)}`);
|
.text(`应付总额: ¥${totalAmount.toFixed(2)}`);
|
||||||
doc.moveDown(0.3);
|
doc.moveDown(0.3);
|
||||||
if (availableDeposit > 0) {
|
doc.fontSize(11).fillColor('#52C41A').text(`当前水电余额: ¥${utilityBalance.toFixed(2)}`);
|
||||||
doc
|
doc
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
.fillColor('#52C41A')
|
.fillColor(utilityBalanceAfterBill < 0 ? '#FF3B30' : '#52C41A')
|
||||||
.text(`可用押金: ¥${availableDeposit.toFixed(2)}`);
|
.text(`扣本账单后余额: ¥${utilityBalanceAfterBill.toFixed(2)}`);
|
||||||
doc
|
|
||||||
.fontSize(11)
|
|
||||||
.fillColor('#FA8C16')
|
|
||||||
.text(`押金抵扣: -¥${depositApplied.toFixed(2)}`);
|
|
||||||
doc
|
|
||||||
.fontSize(14)
|
|
||||||
.fillColor('#FF3B30')
|
|
||||||
.text(`抵扣后应付: ¥${amountAfterDeposit.toFixed(2)}`);
|
|
||||||
}
|
|
||||||
doc.moveDown(1);
|
doc.moveDown(1);
|
||||||
|
|
||||||
// 明细表格
|
// 明细表格
|
||||||
@@ -284,4 +262,44 @@ export class BillsExportService {
|
|||||||
|
|
||||||
doc.end();
|
doc.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getUtilityBalanceMap(studentIds: number[]) {
|
||||||
|
const balanceMap = new Map<number, number>();
|
||||||
|
if (studentIds.length === 0) return balanceMap;
|
||||||
|
|
||||||
|
const [rechargeRows, billRows] = await Promise.all([
|
||||||
|
this.utilityRechargeRepo
|
||||||
|
.createQueryBuilder('r')
|
||||||
|
.select('r.studentId', 'studentId')
|
||||||
|
.addSelect('SUM(r.amount)', 'amount')
|
||||||
|
.where('r.studentId IN (:...studentIds)', { studentIds })
|
||||||
|
.groupBy('r.studentId')
|
||||||
|
.getRawMany<{ studentId: number | string; amount: string | number | null }>(),
|
||||||
|
this.billRepo
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.select('b.studentId', 'studentId')
|
||||||
|
.addSelect('SUM(b.totalAmount)', 'amount')
|
||||||
|
.where('b.studentId IN (:...studentIds)', { studentIds })
|
||||||
|
.andWhere('b.status IN (:...statuses)', { statuses: ['confirmed', 'paid'] })
|
||||||
|
.groupBy('b.studentId')
|
||||||
|
.getRawMany<{ studentId: number | string; amount: string | number | null }>(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rechargeMap = new Map<number, number>();
|
||||||
|
for (const row of rechargeRows) {
|
||||||
|
rechargeMap.set(Number(row.studentId), Number(row.amount || 0));
|
||||||
|
}
|
||||||
|
const paidMap = new Map<number, number>();
|
||||||
|
for (const row of billRows) {
|
||||||
|
paidMap.set(Number(row.studentId), Number(row.amount || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const studentId of studentIds) {
|
||||||
|
balanceMap.set(
|
||||||
|
studentId,
|
||||||
|
Number(((rechargeMap.get(studentId) || 0) - (paidMap.get(studentId) || 0)).toFixed(2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return balanceMap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Body,
|
Body,
|
||||||
Query,
|
Query,
|
||||||
|
ParseIntPipe,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
Res,
|
Res,
|
||||||
@@ -20,7 +21,11 @@ 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 { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
import {
|
||||||
|
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';
|
||||||
@@ -88,47 +93,15 @@ export class BillsController {
|
|||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePermission('bill:view')
|
@RequirePermission('bill:view')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.service.findOne(+id);
|
return this.service.findOne(id);
|
||||||
}
|
|
||||||
|
|
||||||
@Put(':id/status')
|
|
||||||
@RequirePermission('bill:confirm')
|
|
||||||
async updateStatus(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static routes must be declared before /:id/status, otherwise "batch" is
|
||||||
|
// treated as an id and converted to NaN by the parameterized route.
|
||||||
@Put('batch/status')
|
@Put('batch/status')
|
||||||
@RequirePermission('bill:confirm')
|
@RequirePermission('bill:confirm')
|
||||||
async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: any) {
|
async batchUpdateStatus(@Body() body: BatchUpdateBillStatusDto, @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({
|
||||||
@@ -158,17 +131,51 @@ export class BillsController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id/status')
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('bill:delete')
|
@RequirePermission('bill: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: 'bill',
|
targetType: 'bill',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -226,18 +233,18 @@ export class BillsController {
|
|||||||
|
|
||||||
@Get('export/pdf/:id')
|
@Get('export/pdf/:id')
|
||||||
@RequirePermission('bill:export-pdf')
|
@RequirePermission('bill:export-pdf')
|
||||||
async exportPdf(@Param('id') id: string, @Res() res: Response, @Req() req: any) {
|
async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
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',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
});
|
});
|
||||||
return this.exportService.exportStudentPdf(+id, res);
|
return this.exportService.exportStudentPdf(id, res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ 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';
|
||||||
import { RoomExpense } from '../entities/room-expense.entity';
|
import { RoomExpense } from '../entities/room-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 { UtilityRecharge } from '../entities/utility-recharge.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';
|
||||||
@@ -19,11 +18,10 @@ import { BillsController } from './bills.controller';
|
|||||||
Bill,
|
Bill,
|
||||||
BillItem,
|
BillItem,
|
||||||
RoomExpense,
|
RoomExpense,
|
||||||
PersonalExpense,
|
|
||||||
Occupancy,
|
Occupancy,
|
||||||
Room,
|
Room,
|
||||||
Deposit,
|
|
||||||
Student,
|
Student,
|
||||||
|
UtilityRecharge,
|
||||||
]),
|
]),
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import { BillsService } from './bills.service';
|
|||||||
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';
|
||||||
import { RoomExpense } from '../entities/room-expense.entity';
|
import { RoomExpense } from '../entities/room-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 { UtilityRecharge } from '../entities/utility-recharge.entity';
|
||||||
|
|
||||||
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
|
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
|
||||||
|
|
||||||
@@ -42,20 +41,18 @@ describe('BillsService — generateBills', () => {
|
|||||||
let billRepo: MockRepository<Bill>;
|
let billRepo: MockRepository<Bill>;
|
||||||
let itemRepo: MockRepository<BillItem>;
|
let itemRepo: MockRepository<BillItem>;
|
||||||
let roomExpRepo: MockRepository<RoomExpense>;
|
let roomExpRepo: MockRepository<RoomExpense>;
|
||||||
let personalExpRepo: MockRepository<PersonalExpense>;
|
|
||||||
let occRepo: MockRepository<Occupancy>;
|
let occRepo: MockRepository<Occupancy>;
|
||||||
let roomRepo: MockRepository<Room>;
|
let roomRepo: MockRepository<Room>;
|
||||||
let depositRepo: MockRepository<Deposit>;
|
let utilityRechargeRepo: MockRepository<UtilityRecharge>;
|
||||||
let dataSource: { transaction: jest.Mock };
|
let dataSource: { transaction: jest.Mock };
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
billRepo = mockRepo<Bill>();
|
billRepo = mockRepo<Bill>();
|
||||||
itemRepo = mockRepo<BillItem>();
|
itemRepo = mockRepo<BillItem>();
|
||||||
roomExpRepo = mockRepo<RoomExpense>();
|
roomExpRepo = mockRepo<RoomExpense>();
|
||||||
personalExpRepo = mockRepo<PersonalExpense>();
|
|
||||||
occRepo = mockRepo<Occupancy>();
|
occRepo = mockRepo<Occupancy>();
|
||||||
roomRepo = mockRepo<Room>();
|
roomRepo = mockRepo<Room>();
|
||||||
depositRepo = mockRepo<Deposit>();
|
utilityRechargeRepo = mockRepo<UtilityRecharge>();
|
||||||
dataSource = { transaction: jest.fn(), query: jest.fn().mockResolvedValue([]) };
|
dataSource = { transaction: jest.fn(), query: jest.fn().mockResolvedValue([]) };
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -64,10 +61,9 @@ describe('BillsService — generateBills', () => {
|
|||||||
{ provide: getRepositoryToken(Bill), useValue: billRepo },
|
{ provide: getRepositoryToken(Bill), useValue: billRepo },
|
||||||
{ provide: getRepositoryToken(BillItem), useValue: itemRepo },
|
{ provide: getRepositoryToken(BillItem), useValue: itemRepo },
|
||||||
{ provide: getRepositoryToken(RoomExpense), useValue: roomExpRepo },
|
{ provide: getRepositoryToken(RoomExpense), useValue: roomExpRepo },
|
||||||
{ provide: getRepositoryToken(PersonalExpense), useValue: personalExpRepo },
|
|
||||||
{ provide: getRepositoryToken(Occupancy), useValue: occRepo },
|
{ provide: getRepositoryToken(Occupancy), useValue: occRepo },
|
||||||
{ provide: getRepositoryToken(Room), useValue: roomRepo },
|
{ provide: getRepositoryToken(Room), useValue: roomRepo },
|
||||||
{ provide: getRepositoryToken(Deposit), useValue: depositRepo },
|
{ provide: getRepositoryToken(UtilityRecharge), useValue: utilityRechargeRepo },
|
||||||
{ provide: DataSource, useValue: dataSource },
|
{ provide: DataSource, useValue: dataSource },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@@ -100,11 +96,6 @@ describe('BillsService — generateBills', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// No personal expenses
|
|
||||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
|
||||||
mockQueryBuilder<PersonalExpense>([]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.generateBills(PERIOD);
|
const result = await service.generateBills(PERIOD);
|
||||||
|
|
||||||
expect(result.count).toBe(1);
|
expect(result.count).toBe(1);
|
||||||
@@ -145,10 +136,6 @@ describe('BillsService — generateBills', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
|
||||||
mockQueryBuilder<PersonalExpense>([]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.generateBills(PERIOD);
|
const result = await service.generateBills(PERIOD);
|
||||||
|
|
||||||
expect(result.count).toBe(2);
|
expect(result.count).toBe(2);
|
||||||
@@ -203,10 +190,6 @@ describe('BillsService — generateBills', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
|
||||||
mockQueryBuilder<PersonalExpense>([]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.generateBills(PERIOD);
|
const result = await service.generateBills(PERIOD);
|
||||||
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
|
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
|
||||||
|
|
||||||
@@ -253,10 +236,6 @@ describe('BillsService — generateBills', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
|
||||||
mockQueryBuilder<PersonalExpense>([]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.generateBills(THREE_MONTHS);
|
const result = await service.generateBills(THREE_MONTHS);
|
||||||
expect(result.count).toBe(1);
|
expect(result.count).toBe(1);
|
||||||
|
|
||||||
@@ -295,10 +274,6 @@ describe('BillsService — generateBills', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
|
||||||
mockQueryBuilder<PersonalExpense>([]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.generateBills(PERIOD);
|
const result = await service.generateBills(PERIOD);
|
||||||
expect(result.count).toBe(1);
|
expect(result.count).toBe(1);
|
||||||
|
|
||||||
@@ -361,10 +336,6 @@ describe('BillsService — generateBills', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
|
||||||
mockQueryBuilder<PersonalExpense>([]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mock student department query
|
// Mock student department query
|
||||||
(dataSource.query as jest.Mock).mockResolvedValue([
|
(dataSource.query as jest.Mock).mockResolvedValue([
|
||||||
{ id: 10, department_id: null },
|
{ id: 10, department_id: null },
|
||||||
@@ -394,7 +365,7 @@ describe('BillsService — generateBills', () => {
|
|||||||
expect(totalAll).toBeCloseTo(700, 0);
|
expect(totalAll).toBeCloseTo(700, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('personal expenses → added on top of shared allocation', async () => {
|
it('personal expenses → excluded from generated bill totals', async () => {
|
||||||
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||||
mockQueryBuilder<RoomExpense>([
|
mockQueryBuilder<RoomExpense>([
|
||||||
{
|
{
|
||||||
@@ -414,17 +385,6 @@ describe('BillsService — generateBills', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Personal expense: damage fee of 50
|
|
||||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
|
||||||
mockQueryBuilder<PersonalExpense>([
|
|
||||||
{
|
|
||||||
id: 1, studentId: 10, roomId: 1,
|
|
||||||
expenseType: 'damage', amount: '50' as unknown as number,
|
|
||||||
expenseDate: '2026-06-15', description: 'broken chair',
|
|
||||||
} as PersonalExpense,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.generateBills(PERIOD);
|
const result = await service.generateBills(PERIOD);
|
||||||
expect(result.count).toBe(1);
|
expect(result.count).toBe(1);
|
||||||
|
|
||||||
@@ -432,8 +392,8 @@ describe('BillsService — generateBills', () => {
|
|||||||
const billData = savedCalls[0][0];
|
const billData = savedCalls[0][0];
|
||||||
|
|
||||||
expect(Number(billData.sharedAmount)).toBeCloseTo(300, 0);
|
expect(Number(billData.sharedAmount)).toBeCloseTo(300, 0);
|
||||||
expect(Number(billData.personalAmount)).toBe(50);
|
expect(Number(billData.personalAmount)).toBe(0);
|
||||||
expect(Number(billData.totalAmount)).toBeCloseTo(350, 0);
|
expect(Number(billData.totalAmount)).toBeCloseTo(300, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zero overlapping days → no bill generated', async () => {
|
it('zero overlapping days → no bill generated', async () => {
|
||||||
@@ -457,10 +417,6 @@ describe('BillsService — generateBills', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
|
||||||
mockQueryBuilder<PersonalExpense>([]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.generateBills(PERIOD);
|
const result = await service.generateBills(PERIOD);
|
||||||
// Occupancy outside period → no matching student days → no bill
|
// Occupancy outside period → no matching student days → no bill
|
||||||
expect(result.count).toBe(0);
|
expect(result.count).toBe(0);
|
||||||
@@ -481,10 +437,6 @@ describe('BillsService — generateBills', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
|
||||||
mockQueryBuilder<PersonalExpense>([]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.generateBills(PERIOD);
|
const result = await service.generateBills(PERIOD);
|
||||||
expect(result.count).toBe(0);
|
expect(result.count).toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,38 +1,32 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, In, DataSource } from 'typeorm';
|
import { DataSource, Repository } from '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';
|
||||||
import { RoomExpense } from '../entities/room-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 { RoomExpense } from '../entities/room-expense.entity';
|
||||||
|
import { UtilityRecharge } from '../entities/utility-recharge.entity';
|
||||||
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillsService {
|
export class BillsService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Bill) private billRepo: Repository<Bill>,
|
@InjectRepository(Bill) private billRepo: Repository<Bill>,
|
||||||
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
|
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
|
||||||
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
||||||
@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>,
|
@InjectRepository(UtilityRecharge) private utilityRechargeRepo: Repository<UtilityRecharge>,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/** 核心计费引擎:按"人天数"加权分摊 */
|
||||||
* 核心计费引擎:按"人天数"加权分摊
|
|
||||||
*/
|
|
||||||
async generateBills(dto: GenerateBillsDto) {
|
async generateBills(dto: GenerateBillsDto) {
|
||||||
const { periodStart, periodEnd } = dto;
|
const { periodStart, periodEnd } = dto;
|
||||||
const pStart = new Date(periodStart);
|
const pStart = new Date(periodStart);
|
||||||
const pEnd = new Date(periodEnd);
|
const pEnd = new Date(periodEnd);
|
||||||
|
|
||||||
// 删除该周期已有的草稿账单
|
|
||||||
const existingDrafts = await this.billRepo.find({
|
const existingDrafts = await this.billRepo.find({
|
||||||
where: { periodStart, periodEnd, status: 'draft' },
|
where: { periodStart, periodEnd, status: 'draft' },
|
||||||
});
|
});
|
||||||
@@ -50,7 +44,6 @@ export class BillsService {
|
|||||||
.execute();
|
.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', {
|
||||||
@@ -59,18 +52,15 @@ export class BillsService {
|
|||||||
})
|
})
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
// 按宿舍分组费用
|
|
||||||
const roomExpMap = new Map<number, RoomExpense[]>();
|
const roomExpMap = new Map<number, RoomExpense[]>();
|
||||||
for (const exp of roomExpenses) {
|
for (const exp of roomExpenses) {
|
||||||
if (!roomExpMap.has(exp.roomId)) roomExpMap.set(exp.roomId, []);
|
if (!roomExpMap.has(exp.roomId)) roomExpMap.set(exp.roomId, []);
|
||||||
roomExpMap.get(exp.roomId)!.push(exp);
|
roomExpMap.get(exp.roomId)!.push(exp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算每个学生的分摊费用
|
|
||||||
const studentBillData = new Map<number, { shared: number; items: any[] }>();
|
const studentBillData = new Map<number, { shared: number; items: any[] }>();
|
||||||
|
|
||||||
for (const [roomId, expenses] of roomExpMap) {
|
for (const [roomId, expenses] of roomExpMap) {
|
||||||
// 获取该宿舍在此周期内的所有入住记录
|
|
||||||
const occupancies = await this.occRepo
|
const occupancies = await this.occRepo
|
||||||
.createQueryBuilder('o')
|
.createQueryBuilder('o')
|
||||||
.leftJoinAndSelect('o.student', 'student')
|
.leftJoinAndSelect('o.student', 'student')
|
||||||
@@ -80,12 +70,9 @@ export class BillsService {
|
|||||||
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
|
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
|
|
||||||
// 分离长租与短租入住记录
|
|
||||||
const shortTermOccs = occupancies.filter((o) => o.stayType !== 'long');
|
const shortTermOccs = occupancies.filter((o) => o.stayType !== 'long');
|
||||||
const longTermOccs = occupancies.filter((o) => o.stayType === 'long');
|
const longTermOccs = occupancies.filter((o) => o.stayType === 'long');
|
||||||
|
|
||||||
// 长租:按月租费独立计费,不参与人天数分摊
|
|
||||||
for (const occ of longTermOccs) {
|
for (const occ of longTermOccs) {
|
||||||
const monthlyRate = Number(occ.room?.monthlyRate || 0);
|
const monthlyRate = Number(occ.room?.monthlyRate || 0);
|
||||||
if (!studentBillData.has(occ.studentId)) {
|
if (!studentBillData.has(occ.studentId)) {
|
||||||
@@ -104,10 +91,8 @@ export class BillsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 短租:原人天数加权分摊逻辑
|
|
||||||
if (shortTermOccs.length === 0) continue;
|
if (shortTermOccs.length === 0) continue;
|
||||||
|
|
||||||
// 计算每个学生的计费天数
|
|
||||||
const studentDays: { studentId: number; days: number }[] = [];
|
const studentDays: { studentId: number; days: number }[] = [];
|
||||||
let totalDays = 0;
|
let totalDays = 0;
|
||||||
|
|
||||||
@@ -128,7 +113,6 @@ export class BillsService {
|
|||||||
|
|
||||||
if (totalDays === 0) continue;
|
if (totalDays === 0) continue;
|
||||||
|
|
||||||
// 对每项费用进行分摊
|
|
||||||
for (const expense of expenses) {
|
for (const expense of expenses) {
|
||||||
for (const sd of studentDays) {
|
for (const sd of studentDays) {
|
||||||
if (sd.days === 0) continue;
|
if (sd.days === 0) continue;
|
||||||
@@ -151,57 +135,23 @@ export class BillsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取个人附加费
|
|
||||||
const personalExps = await this.personalExpRepo
|
|
||||||
.createQueryBuilder('pe')
|
|
||||||
.where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', {
|
|
||||||
periodStart,
|
|
||||||
periodEnd,
|
|
||||||
})
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
const personalMap = new Map<number, number>();
|
|
||||||
const personalItems = new Map<number, any[]>();
|
|
||||||
for (const pe of personalExps) {
|
|
||||||
personalMap.set(pe.studentId, (personalMap.get(pe.studentId) || 0) + Number(pe.amount));
|
|
||||||
if (!personalItems.has(pe.studentId)) personalItems.set(pe.studentId, []);
|
|
||||||
personalItems.get(pe.studentId)!.push({
|
|
||||||
roomId: pe.roomId,
|
|
||||||
expenseType: pe.expenseType,
|
|
||||||
description: `个人费用: ${pe.description || pe.expenseType}`,
|
|
||||||
days: 0,
|
|
||||||
totalRoomDays: 0,
|
|
||||||
roomTotalAmount: pe.amount,
|
|
||||||
studentAmount: pe.amount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 合并所有涉及的学生
|
|
||||||
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
|
|
||||||
// 生成账单
|
|
||||||
const bills: Bill[] = [];
|
const bills: Bill[] = [];
|
||||||
for (const studentId of allStudentIds) {
|
for (const studentId of studentBillData.keys()) {
|
||||||
const shared = studentBillData.get(studentId)?.shared || 0;
|
const shared = studentBillData.get(studentId)?.shared || 0;
|
||||||
const personal = personalMap.get(studentId) || 0;
|
const total = Number(shared.toFixed(2));
|
||||||
const total = Number((shared + personal).toFixed(2));
|
|
||||||
|
|
||||||
const bill = this.billRepo.create({
|
const bill = this.billRepo.create({
|
||||||
studentId,
|
studentId,
|
||||||
periodStart,
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
sharedAmount: Number(shared.toFixed(2)),
|
sharedAmount: Number(shared.toFixed(2)),
|
||||||
personalAmount: personal,
|
personalAmount: 0,
|
||||||
totalAmount: total,
|
totalAmount: total,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
});
|
});
|
||||||
const savedBill = await this.billRepo.save(bill);
|
const savedBill = await this.billRepo.save(bill);
|
||||||
|
|
||||||
// 保存明细
|
const items = studentBillData.get(studentId)?.items || [];
|
||||||
const items = [
|
|
||||||
...(studentBillData.get(studentId)?.items || []),
|
|
||||||
...(personalItems.get(studentId) || []),
|
|
||||||
];
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
await this.itemRepo.save(this.itemRepo.create({ ...item, billId: savedBill.id }));
|
await this.itemRepo.save(this.itemRepo.create({ ...item, billId: savedBill.id }));
|
||||||
}
|
}
|
||||||
@@ -230,44 +180,57 @@ export class BillsService {
|
|||||||
qb.innerJoin('b.items', 'bi', 'bi.expenseType = :et', { et: query.expenseType });
|
qb.innerJoin('b.items', 'bi', 'bi.expenseType = :et', { et: query.expenseType });
|
||||||
}
|
}
|
||||||
const bills = await qb.getMany();
|
const bills = await qb.getMany();
|
||||||
return this.attachDepositInfo(bills);
|
return this.attachUtilityBalanceInfo(bills);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: number) {
|
async findOne(id: number) {
|
||||||
const bill = await this.billRepo.findOne({ where: { id }, relations: ['student', 'items'] });
|
const bill = await this.billRepo.findOne({ where: { id }, relations: ['student', 'items'] });
|
||||||
if (!bill) throw new NotFoundException('账单不存在');
|
if (!bill) throw new NotFoundException('账单不存在');
|
||||||
const [withDeposit] = await this.attachDepositInfo([bill]);
|
const [withUtilityBalance] = await this.attachUtilityBalanceInfo([bill]);
|
||||||
return withDeposit;
|
return withUtilityBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async attachUtilityBalanceInfo(bills: Bill[]): Promise<any[]> {
|
||||||
* 给账单挂上"押金联动"信息:
|
|
||||||
* - availableDeposit: 当前学生处于已缴未退状态(paid)的押金总额
|
|
||||||
* - depositApplied: 本张账单可从押金抵扣的金额(min(押金, 应付总额))
|
|
||||||
* - amountAfterDeposit: 抵扣押金后学生需另外支付的金额
|
|
||||||
*/
|
|
||||||
private async attachDepositInfo(bills: Bill[]): Promise<any[]> {
|
|
||||||
if (!bills || bills.length === 0) return bills;
|
if (!bills || bills.length === 0) return bills;
|
||||||
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
||||||
if (studentIds.length === 0) return bills;
|
if (studentIds.length === 0) return bills;
|
||||||
const deposits = await this.depositRepo
|
|
||||||
.createQueryBuilder('d')
|
const [rechargeRows, billRows] = await Promise.all([
|
||||||
.where('d.studentId IN (:...ids)', { ids: studentIds })
|
this.utilityRechargeRepo
|
||||||
.andWhere('d.status = :status', { status: 'paid' })
|
.createQueryBuilder('r')
|
||||||
.getMany();
|
.select('r.studentId', 'studentId')
|
||||||
const depMap = new Map<number, number>();
|
.addSelect('SUM(r.amount)', 'amount')
|
||||||
for (const d of deposits) {
|
.where('r.studentId IN (:...ids)', { ids: studentIds })
|
||||||
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0));
|
.groupBy('r.studentId')
|
||||||
}
|
.getRawMany<{ studentId: number | string; amount: string | number | null }>(),
|
||||||
|
this.billRepo
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.select('b.studentId', 'studentId')
|
||||||
|
.addSelect('SUM(b.totalAmount)', 'amount')
|
||||||
|
.where('b.studentId IN (:...ids)', { ids: studentIds })
|
||||||
|
.andWhere('b.status IN (:...statuses)', { statuses: ['confirmed', 'paid'] })
|
||||||
|
.groupBy('b.studentId')
|
||||||
|
.getRawMany<{ studentId: number | string; amount: string | number | null }>(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rechargeMap = new Map<number, number>();
|
||||||
|
for (const row of rechargeRows) rechargeMap.set(Number(row.studentId), Number(row.amount || 0));
|
||||||
|
const usedMap = new Map<number, number>();
|
||||||
|
for (const row of billRows) usedMap.set(Number(row.studentId), Number(row.amount || 0));
|
||||||
|
|
||||||
return bills.map((b) => {
|
return bills.map((b) => {
|
||||||
const total = Number(b.totalAmount || 0);
|
const total = Number(b.totalAmount || 0);
|
||||||
const available = Number((depMap.get(b.studentId) || 0).toFixed(2));
|
const currentBalance = Number(
|
||||||
const applied = Number(Math.min(available, total).toFixed(2));
|
((rechargeMap.get(b.studentId) || 0) - (usedMap.get(b.studentId) || 0)).toFixed(2),
|
||||||
const afterDeposit = Number(Math.max(0, total - applied).toFixed(2));
|
);
|
||||||
|
const balanceAfterBill =
|
||||||
|
b.status === 'confirmed' || b.status === 'paid'
|
||||||
|
? currentBalance
|
||||||
|
: Number((currentBalance - total).toFixed(2));
|
||||||
return Object.assign({}, b, {
|
return Object.assign({}, b, {
|
||||||
availableDeposit: available,
|
utilityBalance: currentBalance,
|
||||||
depositApplied: applied,
|
utilityBalanceAfterBill: balanceAfterBill,
|
||||||
amountAfterDeposit: afterDeposit,
|
utilityShortageAmount: Math.max(0, -balanceAfterBill),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsOptional } from 'class-validator';
|
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class GenerateBillsDto {
|
export class GenerateBillsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -9,6 +9,13 @@ export class GenerateBillsDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateBillStatusDto {
|
export class UpdateBillStatusDto {
|
||||||
@IsString()
|
@IsIn(['draft', 'confirmed', 'paid'])
|
||||||
status: 'draft' | 'confirmed' | 'paid';
|
status: 'draft' | 'confirmed' | 'paid';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BatchUpdateBillStatusDto extends UpdateBillStatusDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
ids: number[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe('DepositsService permission-scoped lookups', () => {
|
|||||||
const studentRepo = {
|
const studentRepo = {
|
||||||
find: jest.fn().mockResolvedValue([{ id: 2, name: '张三', studentNo: 'S2' }]),
|
find: jest.fn().mockResolvedValue([{ id: 2, name: '张三', studentNo: 'S2' }]),
|
||||||
};
|
};
|
||||||
const service = new DepositsService({} as never, {} as never, studentRepo as never);
|
const service = new DepositsService({} as never, {} as never, studentRepo as never, {} as never);
|
||||||
|
|
||||||
await expect(service.getStudentLookups()).resolves.toEqual([
|
await expect(service.getStudentLookups()).resolves.toEqual([
|
||||||
{ id: 2, name: '张三', studentNo: 'S2' },
|
{ id: 2, name: '张三', studentNo: 'S2' },
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
import { Deposit } from '../entities/deposit.entity';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||||
|
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||||
import { DepositsService } from './deposits.service';
|
import { DepositsService } from './deposits.service';
|
||||||
import { DepositsController } from './deposits.controller';
|
import { DepositsController } from './deposits.controller';
|
||||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Deposit, DepositInstallment, Student, PersonalExpense]),
|
||||||
|
OperationLogsModule,
|
||||||
|
NotificationsModule,
|
||||||
|
],
|
||||||
controllers: [DepositsController],
|
controllers: [DepositsController],
|
||||||
providers: [DepositsService],
|
providers: [DepositsService],
|
||||||
exports: [DepositsService],
|
exports: [DepositsService],
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ describe('DepositsService — direct refund', () => {
|
|||||||
findOne: jest.fn().mockResolvedValue(deposit),
|
findOne: jest.fn().mockResolvedValue(deposit),
|
||||||
save: jest.fn().mockImplementation(async (value: Deposit) => value),
|
save: jest.fn().mockImplementation(async (value: Deposit) => value),
|
||||||
};
|
};
|
||||||
const service = new DepositsService(repo as never, {} as never, {} as never);
|
const service = new DepositsService(repo as never, {} as never, {} as never, {} as never);
|
||||||
|
|
||||||
const result = await service.refund(
|
const result = await service.refund(
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Repository } from 'typeorm';
|
|||||||
import { Deposit } from '../entities/deposit.entity';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||||
|
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||||
|
|
||||||
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ export class DepositsService {
|
|||||||
private installmentRepo: Repository<DepositInstallment>,
|
private installmentRepo: Repository<DepositInstallment>,
|
||||||
@InjectRepository(Student)
|
@InjectRepository(Student)
|
||||||
private studentRepo: Repository<Student>,
|
private studentRepo: Repository<Student>,
|
||||||
|
@InjectRepository(PersonalExpense)
|
||||||
|
private personalExpenseRepo: Repository<PersonalExpense>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getStudentLookups() {
|
async getStudentLookups() {
|
||||||
@@ -34,13 +37,15 @@ export class DepositsService {
|
|||||||
.orderBy('d.createdAt', 'DESC');
|
.orderBy('d.createdAt', 'DESC');
|
||||||
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
|
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
|
||||||
if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
|
if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
|
||||||
return qb.getMany();
|
const deposits = await qb.getMany();
|
||||||
|
return this.attachPersonalExpenseAmount(deposits);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: number) {
|
async findOne(id: number) {
|
||||||
const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] });
|
const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] });
|
||||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||||
return deposit;
|
const [withPersonalExpense] = await this.attachPersonalExpenseAmount([deposit]);
|
||||||
|
return withPersonalExpense;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateDepositDto, userId?: number) {
|
async create(dto: CreateDepositDto, userId?: number) {
|
||||||
@@ -125,4 +130,29 @@ export class DepositsService {
|
|||||||
qb.groupBy('d.status');
|
qb.groupBy('d.status');
|
||||||
return qb.getRawMany();
|
return qb.getRawMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async attachPersonalExpenseAmount(deposits: Deposit[]) {
|
||||||
|
if (!deposits.length) return deposits;
|
||||||
|
const studentIds = Array.from(new Set(deposits.map((d) => d.studentId).filter(Boolean)));
|
||||||
|
if (!studentIds.length) return deposits;
|
||||||
|
|
||||||
|
const rows = await this.personalExpenseRepo
|
||||||
|
.createQueryBuilder('pe')
|
||||||
|
.select('pe.studentId', 'studentId')
|
||||||
|
.addSelect('SUM(pe.amount)', 'amount')
|
||||||
|
.where('pe.studentId IN (:...studentIds)', { studentIds })
|
||||||
|
.groupBy('pe.studentId')
|
||||||
|
.getRawMany<{ studentId: number | string; amount: string | number | null }>();
|
||||||
|
|
||||||
|
const amountMap = new Map<number, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
amountMap.set(Number(row.studentId), Number(Number(row.amount || 0).toFixed(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return deposits.map((deposit) =>
|
||||||
|
Object.assign({}, deposit, {
|
||||||
|
personalExpenseAmount: amountMap.get(deposit.studentId) || 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,18 @@ export class AttendanceRecord {
|
|||||||
@Column({ name: 'source', length: 20, default: 'manual' })
|
@Column({ name: 'source', length: 20, default: 'manual' })
|
||||||
source: string;
|
source: string;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_time', type: 'datetime', nullable: true })
|
||||||
|
punchTime: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
|
||||||
|
punchSource: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
punchDeviceName: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
|
||||||
|
punchDeviceId: string | null;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ export class DingAttendanceRaw {
|
|||||||
@Column({ name: 'location_result', length: 20, nullable: true })
|
@Column({ name: 'location_result', length: 20, nullable: true })
|
||||||
locationResult: string;
|
locationResult: string;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
|
||||||
|
punchSource: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
punchDeviceName: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
|
||||||
|
punchDeviceId: string | null;
|
||||||
|
|
||||||
@Column({ name: 'match_status', length: 20, default: 'unmatched' })
|
@Column({ name: 'match_status', length: 20, default: 'unmatched' })
|
||||||
matchStatus: string;
|
matchStatus: string;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export { User } from './user.entity';
|
|||||||
export { OperationLog } from './operation-log.entity';
|
export { OperationLog } from './operation-log.entity';
|
||||||
export { Deposit } from './deposit.entity';
|
export { Deposit } from './deposit.entity';
|
||||||
export { DepositInstallment } from './deposit-installment.entity';
|
export { DepositInstallment } from './deposit-installment.entity';
|
||||||
|
export { UtilityRecharge } from './utility-recharge.entity';
|
||||||
export { Classroom, ClassroomStatus } from './classroom.entity';
|
export { Classroom, ClassroomStatus } from './classroom.entity';
|
||||||
export { Organization } from './organization.entity';
|
export { Organization } from './organization.entity';
|
||||||
export { ClassroomRental, ClassroomRentalStatus } from './classroom-rental.entity';
|
export { ClassroomRental, ClassroomRentalStatus } from './classroom-rental.entity';
|
||||||
|
|||||||
37
apps/server/src/entities/utility-recharge.entity.ts
Normal file
37
apps/server/src/entities/utility-recharge.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Student } from './student.entity';
|
||||||
|
|
||||||
|
@Entity('utility_recharges')
|
||||||
|
export class UtilityRecharge {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ name: 'student_id' })
|
||||||
|
studentId: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'recharge_date', type: 'date' })
|
||||||
|
rechargeDate: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string;
|
||||||
|
|
||||||
|
@Column({ name: 'recorded_by', nullable: true })
|
||||||
|
recordedBy: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => Student, { eager: true })
|
||||||
|
@JoinColumn({ name: 'student_id' })
|
||||||
|
student: Student;
|
||||||
|
}
|
||||||
@@ -5,8 +5,7 @@ import { ExpenseType } from '../entities/expense-type.entity';
|
|||||||
import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto';
|
import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto';
|
||||||
|
|
||||||
const DEFAULT_TYPES = [
|
const DEFAULT_TYPES = [
|
||||||
{ code: 'water', name: '水费', category: 'room', sortOrder: 1 },
|
{ code: 'utility', name: '水电费', category: 'room', sortOrder: 1 },
|
||||||
{ code: 'electricity', name: '电费', category: 'room', sortOrder: 2 },
|
|
||||||
{ code: 'cleaning', name: '保洁费', category: 'room', sortOrder: 3 },
|
{ code: 'cleaning', name: '保洁费', category: 'room', sortOrder: 3 },
|
||||||
{ code: 'damage', name: '损坏赔偿', category: 'both', sortOrder: 4 },
|
{ code: 'damage', name: '损坏赔偿', category: 'both', sortOrder: 4 },
|
||||||
{ code: 'penalty', name: '罚款', category: 'personal', sortOrder: 5 },
|
{ code: 'penalty', name: '罚款', category: 'personal', sortOrder: 5 },
|
||||||
|
|||||||
@@ -208,9 +208,11 @@ export class ExpensesService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const utilityFee = Number(((row.electricityFee || 0) + (row.waterFee || 0)).toFixed(2));
|
||||||
|
|
||||||
// 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失,
|
// 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失,
|
||||||
// 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。
|
// 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。
|
||||||
if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) {
|
if (utilityFee <= 0) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`第${rowNum}行: ${row.roomNumber} 电费和水费均为 0,可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`,
|
`第${rowNum}行: ${row.roomNumber} 电费和水费均为 0,可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`,
|
||||||
);
|
);
|
||||||
@@ -218,53 +220,28 @@ export class ExpensesService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 幂等:先删除该房间在同一周期已有的水/电费用记录,避免重复导入产生脏数据
|
// 幂等:先删除该房间在同一周期已有的水电费用记录,避免重复导入产生脏数据。
|
||||||
|
// 同时兼容清理旧版本拆开的 water/electricity 记录。
|
||||||
await this.roomExpRepo
|
await this.roomExpRepo
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.delete()
|
.delete()
|
||||||
.where('roomId = :roomId', { roomId: room.id })
|
.where('roomId = :roomId', { roomId: room.id })
|
||||||
.andWhere('periodStart = :ps AND periodEnd = :pe', { ps: periodStart, pe: periodEnd })
|
.andWhere('periodStart = :ps AND periodEnd = :pe', { ps: periodStart, pe: periodEnd })
|
||||||
.andWhere('expenseType IN (:...types)', { types: ['water', 'electricity'] })
|
.andWhere('expenseType IN (:...types)', { types: ['utility', 'water', 'electricity'] })
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
let savedAny = false;
|
await this.roomExpRepo.save(
|
||||||
// 导入电费
|
this.roomExpRepo.create({
|
||||||
if (row.electricityFee > 0) {
|
roomId: room.id,
|
||||||
await this.roomExpRepo.save(
|
expenseType: 'utility',
|
||||||
this.roomExpRepo.create({
|
amount: utilityFee,
|
||||||
roomId: room.id,
|
periodStart,
|
||||||
expenseType: 'electricity',
|
periodEnd,
|
||||||
amount: row.electricityFee,
|
description: `电量${row.electricityAmount || 0}kWh,电费¥${Number(row.electricityFee || 0).toFixed(2)};用水${row.waterAmount || 0}吨,水费¥${Number(row.waterFee || 0).toFixed(2)}`,
|
||||||
periodStart,
|
recordedBy: userId,
|
||||||
periodEnd,
|
}),
|
||||||
description: `电量${row.electricityAmount}kWh`,
|
);
|
||||||
recordedBy: userId,
|
imported++;
|
||||||
}),
|
|
||||||
);
|
|
||||||
savedAny = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导入水费
|
|
||||||
if (row.waterFee > 0) {
|
|
||||||
await this.roomExpRepo.save(
|
|
||||||
this.roomExpRepo.create({
|
|
||||||
roomId: room.id,
|
|
||||||
expenseType: 'water',
|
|
||||||
amount: row.waterFee,
|
|
||||||
periodStart,
|
|
||||||
periodEnd,
|
|
||||||
description: `用水${row.waterAmount}吨`,
|
|
||||||
recordedBy: userId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
savedAny = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedAny) imported++;
|
|
||||||
else {
|
|
||||||
skipped++;
|
|
||||||
errors.push(`第${rowNum}行: ${row.roomNumber} 无有效金额`);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
errors.push(`第${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`);
|
errors.push(`第${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`);
|
||||||
skipped++;
|
skipped++;
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ export interface DingTalkAttendanceResult {
|
|||||||
actualCheckTime: string;
|
actualCheckTime: string;
|
||||||
checkId: string;
|
checkId: string;
|
||||||
checkType: string;
|
checkType: string;
|
||||||
|
/** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */
|
||||||
|
sourceType: string;
|
||||||
|
/** 部分钉钉租户会额外返回考勤机名称或编号。 */
|
||||||
|
deviceName?: string;
|
||||||
|
deviceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 组织架构 API 类型 ──
|
// ── 组织架构 API 类型 ──
|
||||||
@@ -505,6 +510,8 @@ export class DingTalkService {
|
|||||||
checkType?: string; timeResult?: string;
|
checkType?: string; timeResult?: string;
|
||||||
locationResult?: string; locationMethod?: string;
|
locationResult?: string; locationMethod?: string;
|
||||||
userAddress?: string; userLongitude?: number; userLatitude?: number;
|
userAddress?: string; userLongitude?: number; userLatitude?: number;
|
||||||
|
deviceName?: string; deviceId?: string | number;
|
||||||
|
attendanceMachineName?: string; attendanceMachineId?: string | number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
|
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
|
||||||
@@ -520,7 +527,10 @@ export class DingTalkService {
|
|||||||
planCheckTime: '',
|
planCheckTime: '',
|
||||||
actualCheckTime: new Date(r.userCheckTime).toISOString(),
|
actualCheckTime: new Date(r.userCheckTime).toISOString(),
|
||||||
checkId: String(r.id),
|
checkId: String(r.id),
|
||||||
checkType: r.checkType ?? r.sourceType ?? '',
|
checkType: r.checkType ?? '',
|
||||||
|
sourceType: r.sourceType ?? '',
|
||||||
|
deviceName: r.deviceName ?? r.attendanceMachineName,
|
||||||
|
deviceId: String(r.deviceId ?? r.attendanceMachineId ?? '') || undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
import { CheckInDto, TransferRoomDto } from './occupancy.dto';
|
import { CheckInDto, TransferRoomDto } from './occupancy.dto';
|
||||||
|
|
||||||
@@ -14,6 +15,50 @@ describe('manual occupancy DTO bed requirements', () => {
|
|||||||
expect(errors.some((error) => error.property === 'bedId')).toBe(true);
|
expect(errors.some((error) => error.property === 'bedId')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts optional deposit collection details for manual check-in', async () => {
|
||||||
|
const dto = Object.assign(new CheckInDto(), {
|
||||||
|
studentId: 1,
|
||||||
|
roomId: 2,
|
||||||
|
checkInDate: '2026-07-13',
|
||||||
|
bedId: 3,
|
||||||
|
collectDeposit: true,
|
||||||
|
depositAmount: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(validate(dto)).resolves.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-positive deposit amount', async () => {
|
||||||
|
const dto = Object.assign(new CheckInDto(), {
|
||||||
|
studentId: 1,
|
||||||
|
roomId: 2,
|
||||||
|
checkInDate: '2026-07-13',
|
||||||
|
bedId: 3,
|
||||||
|
collectDeposit: true,
|
||||||
|
depositAmount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = await validate(dto);
|
||||||
|
|
||||||
|
expect(errors.some((error) => error.property === 'depositAmount')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a manually supplied responsible organization', async () => {
|
||||||
|
const pipe = new ValidationPipe({ transform: true, whitelist: true });
|
||||||
|
const dto = await pipe.transform(
|
||||||
|
{
|
||||||
|
studentId: 1,
|
||||||
|
roomId: 2,
|
||||||
|
checkInDate: '2026-07-13',
|
||||||
|
bedId: 3,
|
||||||
|
responsibleOrganizationId: 99,
|
||||||
|
},
|
||||||
|
{ type: 'body', metatype: CheckInDto },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dto).not.toHaveProperty('responsibleOrganizationId');
|
||||||
|
});
|
||||||
|
|
||||||
it('requires a new bed for a room transfer while keeping the locker optional', async () => {
|
it('requires a new bed for a room transfer while keeping the locker optional', async () => {
|
||||||
const dto = Object.assign(new TransferRoomDto(), {
|
const dto = Object.assign(new TransferRoomDto(), {
|
||||||
newRoomId: 3,
|
newRoomId: 3,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsString, IsOptional, IsArray } from 'class-validator';
|
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
export class CheckInDto {
|
export class CheckInDto {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -23,8 +23,14 @@ export class CheckInDto {
|
|||||||
stayType?: string;
|
stayType?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsBoolean()
|
||||||
responsibleOrganizationId?: number;
|
collectDeposit?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
depositAmount?: number;
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
bedId: number;
|
bedId: number;
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
|||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
|
import {
|
||||||
|
createOccupancyImportTemplateWorkbook,
|
||||||
|
parseOccupancyImportWorksheet,
|
||||||
|
} from './occupancy-import-template';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('occupancies')
|
@Controller('occupancies')
|
||||||
@@ -73,7 +77,7 @@ export class OccupanciesController {
|
|||||||
@RequirePermission('occupancy:checkin')
|
@RequirePermission('occupancy:checkin')
|
||||||
async checkIn(@Body() dto: CheckInDto, @Request() req: any) {
|
async checkIn(@Body() dto: CheckInDto, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.checkIn(dto);
|
const result = await this.service.checkIn(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,
|
||||||
@@ -197,17 +201,20 @@ export class OccupanciesController {
|
|||||||
ws.columns = [
|
ws.columns = [
|
||||||
{ header: '宿舍号', key: 'roomNumber', width: 12 },
|
{ header: '宿舍号', key: 'roomNumber', width: 12 },
|
||||||
{ header: '楼栋', key: 'building', width: 12 },
|
{ header: '楼栋', key: 'building', width: 12 },
|
||||||
|
{ header: '床位号', key: 'bedNumber', width: 10 },
|
||||||
|
{ header: '柜子号', key: 'lockerNumber', width: 10 },
|
||||||
{ header: '学生姓名', key: 'studentName', width: 12 },
|
{ header: '学生姓名', key: 'studentName', width: 12 },
|
||||||
{ header: '性别', key: 'gender', width: 8 },
|
{ header: '性别', key: 'gender', width: 8 },
|
||||||
{ header: '电话', key: 'phone', width: 18 },
|
{ header: '电话', key: 'phone', width: 18 },
|
||||||
{ header: '学号/身份证', key: 'idNumber', width: 22 },
|
{ header: '学号/身份证', key: 'idNumber', width: 22 },
|
||||||
{ header: '所属机构', key: 'organization', width: 18 },
|
|
||||||
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
||||||
{ header: '入住日期', key: 'checkInDate', width: 14 },
|
{ header: '入住日期', key: 'checkInDate', width: 14 },
|
||||||
{ header: '退宿日期', key: 'checkOutDate', width: 14 },
|
{ header: '退宿日期', key: 'checkOutDate', width: 14 },
|
||||||
{ header: '计费起始', key: 'billingStartDate', width: 14 },
|
{ header: '计费起始', key: 'billingStartDate', width: 14 },
|
||||||
{ header: '计费截止', key: 'billingEndDate', width: 14 },
|
{ header: '计费截止', key: 'billingEndDate', width: 14 },
|
||||||
{ header: '退宿原因', key: 'checkOutReason', width: 12 },
|
{ header: '入住类型', key: 'stayType', width: 10 },
|
||||||
|
{ header: '退宿原因', key: 'checkOutReason', width: 16 },
|
||||||
|
{ header: '备注', key: 'notes', width: 24 },
|
||||||
];
|
];
|
||||||
ws.getRow(1).font = { bold: true };
|
ws.getRow(1).font = { bold: true };
|
||||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||||
@@ -215,17 +222,20 @@ export class OccupanciesController {
|
|||||||
ws.addRow({
|
ws.addRow({
|
||||||
roomNumber: r.room?.roomNumber || '',
|
roomNumber: r.room?.roomNumber || '',
|
||||||
building: r.room?.building || '',
|
building: r.room?.building || '',
|
||||||
|
bedNumber: r.bed?.bedNumber || '',
|
||||||
|
lockerNumber: r.locker?.lockerNumber || '',
|
||||||
studentName: r.student?.name || '',
|
studentName: r.student?.name || '',
|
||||||
gender: r.student?.gender || '',
|
gender: r.student?.gender || '',
|
||||||
phone: r.student?.phone || '',
|
phone: r.student?.phone || '',
|
||||||
idNumber: r.student?.idNumber || '',
|
idNumber: r.student?.idNumber || '',
|
||||||
organization: r.student?.organization?.name || '',
|
|
||||||
supervisor: r.student?.supervisor || '',
|
supervisor: r.student?.supervisor || '',
|
||||||
checkInDate: r.checkInDate || '',
|
checkInDate: r.checkInDate || '',
|
||||||
checkOutDate: r.checkOutDate || '',
|
checkOutDate: r.checkOutDate || '',
|
||||||
billingStartDate: r.billingStartDate || '',
|
billingStartDate: r.billingStartDate || '',
|
||||||
billingEndDate: r.billingEndDate || '',
|
billingEndDate: r.billingEndDate || '',
|
||||||
|
stayType: r.stayType === 'long' ? '长租' : '短租',
|
||||||
checkOutReason: r.checkOutReason || '',
|
checkOutReason: r.checkOutReason || '',
|
||||||
|
notes: r.notes || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res!.setHeader(
|
res!.setHeader(
|
||||||
@@ -240,68 +250,7 @@ export class OccupanciesController {
|
|||||||
@Get('template')
|
@Get('template')
|
||||||
@RequirePermission('occupancy:view')
|
@RequirePermission('occupancy:view')
|
||||||
async downloadTemplate(@Res() res: Response) {
|
async downloadTemplate(@Res() res: Response) {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = createOccupancyImportTemplateWorkbook();
|
||||||
const ws = workbook.addWorksheet('入住名单导入模板');
|
|
||||||
ws.columns = [
|
|
||||||
{ header: '宿舍号', key: 'roomNumber', width: 12 },
|
|
||||||
{ header: '床位号', key: 'bedNumber', width: 8 },
|
|
||||||
{ header: '姓名', key: 'name', width: 12 },
|
|
||||||
{ header: '性别', key: 'gender', width: 8 },
|
|
||||||
{ header: '民族', key: 'ethnicity', width: 10 },
|
|
||||||
{ header: '电话', key: 'phone', width: 15 },
|
|
||||||
{ header: '学号/身份证', key: 'idNumber', width: 22 },
|
|
||||||
{ header: '入住时间', key: 'checkInDate', width: 14 },
|
|
||||||
{ header: '离宿时间', key: 'checkOutDate', width: 14 },
|
|
||||||
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
|
||||||
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
|
||||||
{ header: '所属机构', key: 'organization', width: 18 },
|
|
||||||
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
|
||||||
];
|
|
||||||
ws.getRow(1).font = { bold: true };
|
|
||||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
|
||||||
// 添加说明行
|
|
||||||
ws.addRow({
|
|
||||||
roomNumber: '4-102',
|
|
||||||
bedNumber: 1,
|
|
||||||
name: '张三',
|
|
||||||
gender: '男',
|
|
||||||
ethnicity: '汉族',
|
|
||||||
phone: '13800138000',
|
|
||||||
idNumber: '2024001',
|
|
||||||
checkInDate: '2026-04-21',
|
|
||||||
checkOutDate: '',
|
|
||||||
emergencyContact: '张父',
|
|
||||||
emergencyPhone: '13900000000',
|
|
||||||
organization: '',
|
|
||||||
supervisor: '',
|
|
||||||
});
|
|
||||||
ws.addRow({
|
|
||||||
roomNumber: '4-102',
|
|
||||||
bedNumber: 2,
|
|
||||||
name: '李四',
|
|
||||||
gender: '男',
|
|
||||||
ethnicity: '汉族',
|
|
||||||
phone: '13800138001',
|
|
||||||
idNumber: '2024002',
|
|
||||||
checkInDate: '2026-04-21',
|
|
||||||
checkOutDate: '',
|
|
||||||
emergencyContact: '',
|
|
||||||
emergencyPhone: '',
|
|
||||||
organization: 'XXX教育科技',
|
|
||||||
supervisor: '王老师',
|
|
||||||
});
|
|
||||||
// 添加使用说明sheet
|
|
||||||
const helpWs = workbook.addWorksheet('使用说明');
|
|
||||||
helpWs.getColumn(1).width = 60;
|
|
||||||
helpWs.addRow(['【入住名单导入说明】']);
|
|
||||||
helpWs.addRow(['1. 导入入住名单会自动创建不存在的学生和宿舍,无需单独导入学生或宿舍']);
|
|
||||||
helpWs.addRow(['2. 宿舍号会智能解析楼栋、楼层和房间类型(如4-102自动识别为4号楼1层四人间)']);
|
|
||||||
helpWs.addRow(['3. 同一宿舍号的多个学生可合并宿舍号单元格,系统会自动继承上一行的宿舍号']);
|
|
||||||
helpWs.addRow(['4. 已存在的学生(按姓名匹配)会自动补充缺失信息(性别、民族等)']);
|
|
||||||
helpWs.addRow(['5. 已有在住记录的学生会自动跳过,不会重复入住']);
|
|
||||||
helpWs.addRow(['6. 填了离宿时间的记录会直接标记为已退宿(用于导入历史数据)']);
|
|
||||||
helpWs.addRow(['7. 床位号仅做标识参考,不影响入住逻辑']);
|
|
||||||
helpWs.getRow(1).font = { bold: true, size: 14 };
|
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Type',
|
'Content-Type',
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
@@ -324,47 +273,7 @@ export class OccupanciesController {
|
|||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(file.buffer as any);
|
await workbook.xlsx.load(file.buffer as any);
|
||||||
const ws = workbook.worksheets[0];
|
const ws = workbook.worksheets[0];
|
||||||
const rows: any[] = [];
|
const rows = parseOccupancyImportWorksheet(ws);
|
||||||
let lastRoomNumber = '';
|
|
||||||
|
|
||||||
ws.eachRow((row, idx) => {
|
|
||||||
if (idx === 1) return; // 跳过表头
|
|
||||||
|
|
||||||
// 宿舍号可能是合并单元格,需要继承上一行
|
|
||||||
const roomNumberVal = row.getCell(1).value;
|
|
||||||
const roomNumber = roomNumberVal ? String(roomNumberVal).trim() : '';
|
|
||||||
if (roomNumber) lastRoomNumber = roomNumber;
|
|
||||||
|
|
||||||
const name = String(row.getCell(3).value || '').trim();
|
|
||||||
if (!name) return; // 无姓名则跳过空行
|
|
||||||
|
|
||||||
// 解析日期
|
|
||||||
const parseDate = (cell: any): string => {
|
|
||||||
const val = cell.value;
|
|
||||||
if (!val) return '';
|
|
||||||
if (val instanceof Date) return val.toISOString().split('T')[0];
|
|
||||||
const s = String(val).trim();
|
|
||||||
// 处理 "YYYY/MM/DD" 或 "YYYY-MM-DD" 或 "YYYY.MM.DD"
|
|
||||||
const m = s.match(/(\d{4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})/);
|
|
||||||
if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
|
|
||||||
return s;
|
|
||||||
};
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
name,
|
|
||||||
roomNumber: lastRoomNumber,
|
|
||||||
gender: String(row.getCell(4).value || '').trim() || undefined,
|
|
||||||
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
|
|
||||||
phone: String(row.getCell(6).value || '').trim() || undefined,
|
|
||||||
idNumber: String(row.getCell(7).value || '').trim() || undefined,
|
|
||||||
checkInDate: parseDate(row.getCell(8)),
|
|
||||||
checkOutDate: parseDate(row.getCell(9)) || undefined,
|
|
||||||
emergencyContact: String(row.getCell(10).value || '').trim() || undefined,
|
|
||||||
emergencyPhone: String(row.getCell(11).value || '').trim() || undefined,
|
|
||||||
organization: String(row.getCell(12).value || '').trim() || undefined,
|
|
||||||
supervisor: String(row.getCell(13).value || '').trim() || undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const result = await this.service.batchImportCheckIn(rows, {
|
const result = await this.service.batchImportCheckIn(rows, {
|
||||||
autoDeposit: autoDeposit === 'true',
|
autoDeposit: autoDeposit === 'true',
|
||||||
depositAmount: depositAmount ? +depositAmount : undefined,
|
depositAmount: depositAmount ? +depositAmount : undefined,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Bed } from '../entities/bed.entity';
|
|||||||
import { Locker } from '../entities/locker.entity';
|
import { Locker } from '../entities/locker.entity';
|
||||||
|
|
||||||
describe('OccupanciesService — responsible organization', () => {
|
describe('OccupanciesService — responsible organization', () => {
|
||||||
it('defaults the responsible organization to the student organization', async () => {
|
it('always takes the responsible organization from the student', async () => {
|
||||||
const occupancyRepo = {
|
const occupancyRepo = {
|
||||||
findOne: jest.fn().mockResolvedValue(null),
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
count: jest.fn().mockResolvedValue(0),
|
count: jest.fn().mockResolvedValue(0),
|
||||||
@@ -42,10 +42,235 @@ describe('OccupanciesService — responsible organization', () => {
|
|||||||
roomId: 2,
|
roomId: 2,
|
||||||
checkInDate: '2026-07-10',
|
checkInDate: '2026-07-10',
|
||||||
bedId: 4,
|
bedId: 4,
|
||||||
});
|
responsibleOrganizationId: 99,
|
||||||
|
} as any);
|
||||||
|
|
||||||
expect(occupancyRepo.create).toHaveBeenCalledWith(
|
expect(occupancyRepo.create).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ responsibleOrganizationId: 7 }),
|
expect.objectContaining({ responsibleOrganizationId: 7 }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('OccupanciesService — manual check-in deposit', () => {
|
||||||
|
const createService = (existingDeposit: Deposit | null = null) => {
|
||||||
|
const occupancyRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(async (value) => ({ ...value, id: 10 })),
|
||||||
|
} as any as Repository<Occupancy>;
|
||||||
|
const roomRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4 }),
|
||||||
|
update: jest.fn(),
|
||||||
|
} as any as Repository<Room>;
|
||||||
|
const studentRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 3, organizationId: 7 }),
|
||||||
|
} as any as Repository<Student>;
|
||||||
|
const depositRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(existingDeposit),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(async (value) => ({ ...value, id: 20 })),
|
||||||
|
} as any as Repository<Deposit>;
|
||||||
|
const bedRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }),
|
||||||
|
update: jest.fn(),
|
||||||
|
} as any as Repository<Bed>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: new OccupanciesService(
|
||||||
|
occupancyRepo,
|
||||||
|
roomRepo,
|
||||||
|
studentRepo,
|
||||||
|
depositRepo,
|
||||||
|
bedRepo,
|
||||||
|
{} as Repository<Locker>,
|
||||||
|
{} as Repository<any>,
|
||||||
|
{} as DataSource,
|
||||||
|
),
|
||||||
|
depositRepo,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates a paid deposit together with manual check-in', async () => {
|
||||||
|
const { service, depositRepo } = createService();
|
||||||
|
|
||||||
|
await service.checkIn(
|
||||||
|
{
|
||||||
|
studentId: 3,
|
||||||
|
roomId: 2,
|
||||||
|
checkInDate: '2026-07-14',
|
||||||
|
bedId: 4,
|
||||||
|
collectDeposit: true,
|
||||||
|
depositAmount: 800,
|
||||||
|
},
|
||||||
|
11,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(depositRepo.create).toHaveBeenCalledWith({
|
||||||
|
studentId: 3,
|
||||||
|
amount: 800,
|
||||||
|
paidDate: '2026-07-14',
|
||||||
|
status: 'paid',
|
||||||
|
recordedBy: 11,
|
||||||
|
notes: '入住登记自动收取',
|
||||||
|
});
|
||||||
|
expect(depositRepo.save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create another paid deposit when one already exists', async () => {
|
||||||
|
const { service, depositRepo } = createService({ id: 99 } as Deposit);
|
||||||
|
|
||||||
|
await service.checkIn({
|
||||||
|
studentId: 3,
|
||||||
|
roomId: 2,
|
||||||
|
checkInDate: '2026-07-14',
|
||||||
|
bedId: 4,
|
||||||
|
collectDeposit: true,
|
||||||
|
depositAmount: 800,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(depositRepo.create).not.toHaveBeenCalled();
|
||||||
|
expect(depositRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OccupanciesService — import bed capacity', () => {
|
||||||
|
it('rejects creating a new bed when the room already has its capacity in beds', async () => {
|
||||||
|
const occupancyRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(),
|
||||||
|
} as any as Repository<Occupancy>;
|
||||||
|
const roomRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 2, roomNumber: '4-102', capacity: 4 }),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
} as any as Repository<Room>;
|
||||||
|
const studentRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 3, name: '张三', organizationId: 7 }),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
} as any as Repository<Student>;
|
||||||
|
const bedRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
count: jest.fn().mockResolvedValue(4),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
} as any as Repository<Bed>;
|
||||||
|
const organizationRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 7, name: '本机构', isHost: true }),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(),
|
||||||
|
} as any as Repository<any>;
|
||||||
|
|
||||||
|
const service = new OccupanciesService(
|
||||||
|
occupancyRepo,
|
||||||
|
roomRepo,
|
||||||
|
studentRepo,
|
||||||
|
{ findOne: jest.fn(), create: jest.fn(), save: jest.fn() } as any as Repository<Deposit>,
|
||||||
|
bedRepo,
|
||||||
|
{ findOne: jest.fn() } as any as Repository<Locker>,
|
||||||
|
organizationRepo,
|
||||||
|
{} as DataSource,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.batchImportCheckIn([
|
||||||
|
{
|
||||||
|
name: '张三',
|
||||||
|
phone: '13800138000',
|
||||||
|
roomNumber: '4-102',
|
||||||
|
bedNumber: '5号床',
|
||||||
|
checkInDate: '2026-07-14',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
imported: 0,
|
||||||
|
skipped: 1,
|
||||||
|
errors: [expect.stringContaining('不能超过额定人数 4')],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(bedRepo.save).not.toHaveBeenCalled();
|
||||||
|
expect(occupancyRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OccupanciesService — import student matching', () => {
|
||||||
|
it('associates an existing student by phone and keeps the student organization', async () => {
|
||||||
|
const existingStudent = {
|
||||||
|
id: 3,
|
||||||
|
name: '学生档案姓名',
|
||||||
|
phone: '13800138000',
|
||||||
|
organizationId: 7,
|
||||||
|
};
|
||||||
|
const occupancyRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(async (value) => ({ ...value, id: 10 })),
|
||||||
|
} as any as Repository<Occupancy>;
|
||||||
|
const roomRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 2, roomNumber: '4-102', capacity: 4 }),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
} as any as Repository<Room>;
|
||||||
|
const studentRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(existingStudent),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
} as any as Repository<Student>;
|
||||||
|
const bedRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 4,
|
||||||
|
roomId: 2,
|
||||||
|
bedNumber: '1号床',
|
||||||
|
status: 'available',
|
||||||
|
}),
|
||||||
|
count: jest.fn(),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
} as any as Repository<Bed>;
|
||||||
|
const organizationRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(),
|
||||||
|
} as any as Repository<any>;
|
||||||
|
|
||||||
|
const service = new OccupanciesService(
|
||||||
|
occupancyRepo,
|
||||||
|
roomRepo,
|
||||||
|
studentRepo,
|
||||||
|
{ findOne: jest.fn() } as any as Repository<Deposit>,
|
||||||
|
bedRepo,
|
||||||
|
{ findOne: jest.fn() } as any as Repository<Locker>,
|
||||||
|
organizationRepo,
|
||||||
|
{} as DataSource,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.batchImportCheckIn([
|
||||||
|
{
|
||||||
|
name: 'Excel姓名',
|
||||||
|
phone: '13800138000',
|
||||||
|
roomNumber: '4-102',
|
||||||
|
bedNumber: '1号床',
|
||||||
|
checkInDate: '2026-07-14',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(studentRepo.findOne).toHaveBeenCalledWith({ where: { phone: '13800138000' } });
|
||||||
|
expect(studentRepo.save).not.toHaveBeenCalled();
|
||||||
|
expect(organizationRepo.findOne).not.toHaveBeenCalled();
|
||||||
|
expect(occupancyRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ studentId: 3, responsibleOrganizationId: 7 }),
|
||||||
|
);
|
||||||
|
expect(result).toEqual(expect.objectContaining({ imported: 1, skipped: 0 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { Bed } from '../entities/bed.entity';
|
|||||||
import { Locker } from '../entities/locker.entity';
|
import { Locker } from '../entities/locker.entity';
|
||||||
import { Deposit } from '../entities/deposit.entity';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
import { Organization } from '../entities/organization.entity';
|
import { Organization } from '../entities/organization.entity';
|
||||||
import { uuidV7 } from '../common/uuid-v7';
|
|
||||||
import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
|
import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
|
||||||
import { RoomsService } from '../rooms/rooms.service';
|
import { RoomsService } from '../rooms/rooms.service';
|
||||||
|
|
||||||
@@ -40,7 +39,6 @@ export class OccupanciesService {
|
|||||||
.leftJoinAndSelect('o.room', 'room')
|
.leftJoinAndSelect('o.room', 'room')
|
||||||
.leftJoinAndSelect('o.bed', 'bed')
|
.leftJoinAndSelect('o.bed', 'bed')
|
||||||
.leftJoinAndSelect('o.locker', 'locker')
|
.leftJoinAndSelect('o.locker', 'locker')
|
||||||
.leftJoinAndSelect('o.responsibleOrganization', 'responsibleOrganization')
|
|
||||||
.orderBy('o.checkInDate', 'DESC');
|
.orderBy('o.checkInDate', 'DESC');
|
||||||
if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId });
|
if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId });
|
||||||
if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId });
|
if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId });
|
||||||
@@ -48,7 +46,7 @@ export class OccupanciesService {
|
|||||||
return qb.getMany();
|
return qb.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkIn(dto: CheckInDto) {
|
async checkIn(dto: CheckInDto, userId?: number) {
|
||||||
// 检查学生是否已有活跃入住
|
// 检查学生是否已有活跃入住
|
||||||
const existing = await this.repo.findOne({
|
const existing = await this.repo.findOne({
|
||||||
where: { studentId: dto.studentId, checkOutDate: IsNull() },
|
where: { studentId: dto.studentId, checkOutDate: IsNull() },
|
||||||
@@ -86,7 +84,7 @@ export class OccupanciesService {
|
|||||||
checkInDate: dto.checkInDate,
|
checkInDate: dto.checkInDate,
|
||||||
billingStartDate: dto.billingStartDate || dto.checkInDate,
|
billingStartDate: dto.billingStartDate || dto.checkInDate,
|
||||||
stayType: dto.stayType,
|
stayType: dto.stayType,
|
||||||
responsibleOrganizationId: dto.responsibleOrganizationId ?? student.organizationId,
|
responsibleOrganizationId: student.organizationId,
|
||||||
notes: dto.notes,
|
notes: dto.notes,
|
||||||
bedId: dto.bedId,
|
bedId: dto.bedId,
|
||||||
lockerId: dto.lockerId,
|
lockerId: dto.lockerId,
|
||||||
@@ -105,6 +103,25 @@ export class OccupanciesService {
|
|||||||
if (count + 1 >= room.capacity) {
|
if (count + 1 >= room.capacity) {
|
||||||
await this.roomRepo.update(room.id, { status: 'full' });
|
await this.roomRepo.update(room.id, { status: 'full' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.collectDeposit) {
|
||||||
|
const existingDeposit = await this.depositRepo.findOne({
|
||||||
|
where: { studentId: dto.studentId, status: 'paid' },
|
||||||
|
});
|
||||||
|
if (!existingDeposit) {
|
||||||
|
await this.depositRepo.save(
|
||||||
|
this.depositRepo.create({
|
||||||
|
studentId: dto.studentId,
|
||||||
|
amount: dto.depositAmount ?? 500,
|
||||||
|
paidDate: dto.checkInDate,
|
||||||
|
status: 'paid',
|
||||||
|
recordedBy: userId,
|
||||||
|
notes: '入住登记自动收取',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,12 +353,16 @@ export class OccupanciesService {
|
|||||||
ethnicity?: string;
|
ethnicity?: string;
|
||||||
emergencyContact?: string;
|
emergencyContact?: string;
|
||||||
emergencyPhone?: string;
|
emergencyPhone?: string;
|
||||||
organization?: string;
|
|
||||||
supervisor?: string;
|
supervisor?: string;
|
||||||
roomNumber: string;
|
roomNumber: string;
|
||||||
building?: string;
|
building?: string;
|
||||||
checkInDate: string;
|
checkInDate: string;
|
||||||
|
billingStartDate?: string;
|
||||||
checkOutDate?: string;
|
checkOutDate?: string;
|
||||||
|
bedNumber?: string;
|
||||||
|
lockerNumber?: string;
|
||||||
|
stayType?: string;
|
||||||
|
notes?: string;
|
||||||
}[],
|
}[],
|
||||||
options?: { autoDeposit?: boolean; depositAmount?: number },
|
options?: { autoDeposit?: boolean; depositAmount?: number },
|
||||||
) {
|
) {
|
||||||
@@ -360,42 +381,33 @@ export class OccupanciesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 解析所属机构;未填写时默认本机构
|
// 1. 通过手机号关联学生;未找到时创建学生并归入本机构
|
||||||
let organization = row.organization?.trim()
|
const phone = row.phone?.trim();
|
||||||
? await this.organizationRepo.findOne({ where: { name: row.organization.trim() } })
|
if (!phone) throw new BadRequestException('手机号不能为空,无法关联学生');
|
||||||
: await this.organizationRepo.findOne({ where: { isHost: true, status: 'active' } });
|
|
||||||
if (!organization && row.organization?.trim()) {
|
|
||||||
organization = await this.organizationRepo.save(
|
|
||||||
this.organizationRepo.create({
|
|
||||||
publicId: uuidV7(),
|
|
||||||
code: `ORG_${Date.now()}_${i}`,
|
|
||||||
name: row.organization.trim(),
|
|
||||||
isHost: false,
|
|
||||||
status: 'active',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!organization) throw new BadRequestException('尚未配置本机构');
|
|
||||||
|
|
||||||
let student = await this.studentRepo.findOne({ where: { name: row.name.trim() } });
|
let student = await this.studentRepo.findOne({ where: { phone } });
|
||||||
if (!student) {
|
if (!student) {
|
||||||
|
const hostOrganization = await this.organizationRepo.findOne({
|
||||||
|
where: { isHost: true, status: 'active' },
|
||||||
|
});
|
||||||
|
if (!hostOrganization) throw new BadRequestException('尚未配置本机构');
|
||||||
|
|
||||||
student = await this.studentRepo.save(
|
student = await this.studentRepo.save(
|
||||||
this.studentRepo.create({
|
this.studentRepo.create({
|
||||||
name: row.name.trim(),
|
name: row.name.trim(),
|
||||||
phone: row.phone?.trim() || undefined,
|
phone,
|
||||||
idNumber: row.idNumber?.trim() || undefined,
|
idNumber: row.idNumber?.trim() || undefined,
|
||||||
gender: row.gender?.trim() || undefined,
|
gender: row.gender?.trim() || undefined,
|
||||||
ethnicity: row.ethnicity?.trim() || undefined,
|
ethnicity: row.ethnicity?.trim() || undefined,
|
||||||
emergencyContact: row.emergencyContact?.trim() || undefined,
|
emergencyContact: row.emergencyContact?.trim() || undefined,
|
||||||
emergencyPhone: row.emergencyPhone?.trim() || undefined,
|
emergencyPhone: row.emergencyPhone?.trim() || undefined,
|
||||||
organizationId: organization.id,
|
organizationId: hostOrganization.id,
|
||||||
supervisor: row.supervisor?.trim() || undefined,
|
supervisor: row.supervisor?.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 更新已有学生的缺失信息
|
// 更新已有学生的缺失信息
|
||||||
const updates: any = {};
|
const updates: any = {};
|
||||||
if (!student.phone && row.phone?.trim()) updates.phone = row.phone.trim();
|
|
||||||
if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
|
if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
|
||||||
if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim();
|
if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim();
|
||||||
if (!student.ethnicity && row.ethnicity?.trim()) updates.ethnicity = row.ethnicity.trim();
|
if (!student.ethnicity && row.ethnicity?.trim()) updates.ethnicity = row.ethnicity.trim();
|
||||||
@@ -403,7 +415,6 @@ export class OccupanciesService {
|
|||||||
updates.emergencyContact = row.emergencyContact.trim();
|
updates.emergencyContact = row.emergencyContact.trim();
|
||||||
if (!student.emergencyPhone && row.emergencyPhone?.trim())
|
if (!student.emergencyPhone && row.emergencyPhone?.trim())
|
||||||
updates.emergencyPhone = row.emergencyPhone.trim();
|
updates.emergencyPhone = row.emergencyPhone.trim();
|
||||||
if (!student.organizationId) updates.organizationId = organization.id;
|
|
||||||
if (!student.supervisor && row.supervisor?.trim())
|
if (!student.supervisor && row.supervisor?.trim())
|
||||||
updates.supervisor = row.supervisor.trim();
|
updates.supervisor = row.supervisor.trim();
|
||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
@@ -450,14 +461,54 @@ export class OccupanciesService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. 匹配或创建床位、柜子,并校验是否可用
|
||||||
|
const isHistoricalRecord = Boolean(row.checkOutDate?.trim());
|
||||||
|
let bed: Bed | null = null;
|
||||||
|
if (row.bedNumber?.trim()) {
|
||||||
|
const bedNumber = row.bedNumber.trim();
|
||||||
|
bed = await this.bedRepo.findOne({ where: { roomId: room.id, bedNumber } });
|
||||||
|
if (!bed) {
|
||||||
|
const existingBedCount = await this.bedRepo.count({ where: { roomId: room.id } });
|
||||||
|
if (existingBedCount >= room.capacity) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
bed = await this.bedRepo.save(
|
||||||
|
this.bedRepo.create({ roomId: room.id, bedNumber, status: 'available' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isHistoricalRecord && bed.status !== 'available') {
|
||||||
|
throw new BadRequestException(`床位 ${bedNumber} 已被占用或维修中`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let locker: Locker | null = null;
|
||||||
|
if (row.lockerNumber?.trim()) {
|
||||||
|
const lockerNumber = row.lockerNumber.trim();
|
||||||
|
locker = await this.lockerRepo.findOne({ where: { roomId: room.id, lockerNumber } });
|
||||||
|
if (!locker) {
|
||||||
|
locker = await this.lockerRepo.save(
|
||||||
|
this.lockerRepo.create({ roomId: room.id, lockerNumber, status: 'available' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isHistoricalRecord && locker.status !== 'available') {
|
||||||
|
throw new BadRequestException(`柜子 ${lockerNumber} 已被占用或维修中`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6. 创建入住记录
|
// 6. 创建入住记录
|
||||||
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
|
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
|
||||||
const occData: any = {
|
const occData: any = {
|
||||||
studentId: student.id,
|
studentId: student.id,
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
checkInDate,
|
checkInDate,
|
||||||
billingStartDate: checkInDate,
|
billingStartDate: row.billingStartDate?.trim() || checkInDate,
|
||||||
responsibleOrganizationId: student.organizationId || organization.id,
|
stayType: row.stayType || undefined,
|
||||||
|
responsibleOrganizationId: student.organizationId,
|
||||||
|
notes: row.notes || undefined,
|
||||||
|
bedId: bed?.id,
|
||||||
|
lockerId: locker?.id,
|
||||||
};
|
};
|
||||||
// 如果有退宿日期,直接记录
|
// 如果有退宿日期,直接记录
|
||||||
if (row.checkOutDate?.trim()) {
|
if (row.checkOutDate?.trim()) {
|
||||||
@@ -466,9 +517,13 @@ export class OccupanciesService {
|
|||||||
}
|
}
|
||||||
await this.repo.save(this.repo.create(occData));
|
await this.repo.save(this.repo.create(occData));
|
||||||
|
|
||||||
// 8. 更新宿舍状态
|
// 7. 更新床位、柜子和宿舍状态
|
||||||
if (!row.checkOutDate?.trim() && count + 1 >= room.capacity) {
|
if (!isHistoricalRecord) {
|
||||||
await this.roomRepo.update(room.id, { status: 'full' });
|
if (bed) await this.bedRepo.update(bed.id, { status: 'occupied' });
|
||||||
|
if (locker) await this.lockerRepo.update(locker.id, { status: 'occupied' });
|
||||||
|
if (count + 1 >= room.capacity) {
|
||||||
|
await this.roomRepo.update(room.id, { status: 'full' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. 自动收取押金(仅对新入住且非历史记录的学生)
|
// 9. 自动收取押金(仅对新入住且非历史记录的学生)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
createOccupancyImportTemplateWorkbook,
|
||||||
|
OCCUPANCY_IMPORT_COLUMNS,
|
||||||
|
parseOccupancyImportWorksheet,
|
||||||
|
} from './occupancy-import-template';
|
||||||
|
|
||||||
|
describe('occupancy import template', () => {
|
||||||
|
it('includes the current occupancy fields including bed and locker numbers', () => {
|
||||||
|
const workbook = createOccupancyImportTemplateWorkbook();
|
||||||
|
const ws = workbook.getWorksheet('入住名单导入模板')!;
|
||||||
|
const headers = ws.getRow(1).values as unknown[];
|
||||||
|
|
||||||
|
expect(headers).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'宿舍号',
|
||||||
|
'楼栋',
|
||||||
|
'床位号',
|
||||||
|
'柜子号',
|
||||||
|
'计费起始日',
|
||||||
|
'电话',
|
||||||
|
'入住类型',
|
||||||
|
'备注',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(headers).not.toContain('所属机构');
|
||||||
|
expect(ws.columnCount).toBe(OCCUPANCY_IMPORT_COLUMNS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('documents the current phone matching, organization, and deposit behavior', () => {
|
||||||
|
const workbook = createOccupancyImportTemplateWorkbook();
|
||||||
|
const helpWs = workbook.getWorksheet('使用说明')!;
|
||||||
|
const instructions = helpWs.getColumn(1).values.join('\n');
|
||||||
|
|
||||||
|
expect(instructions).toContain('按手机号关联已有学生');
|
||||||
|
expect(instructions).toContain('所属机构自动取学生档案');
|
||||||
|
expect(instructions).toContain('导入时自动收押金');
|
||||||
|
expect(instructions).toContain('历史入住不会自动收取');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Excel Date cells on the same local calendar day', () => {
|
||||||
|
const workbook = createOccupancyImportTemplateWorkbook();
|
||||||
|
const ws = workbook.getWorksheet('入住名单导入模板')!;
|
||||||
|
ws.getCell('J2').value = new Date(2026, 3, 21);
|
||||||
|
ws.getCell('K2').value = new Date(2026, 3, 22);
|
||||||
|
ws.getCell('L2').value = new Date(2026, 3, 30);
|
||||||
|
|
||||||
|
expect(parseOccupancyImportWorksheet(ws)[0]).toMatchObject({
|
||||||
|
checkInDate: '2026-04-21',
|
||||||
|
billingStartDate: '2026-04-22',
|
||||||
|
checkOutDate: '2026-04-30',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses rows by header so new columns do not shift existing fields', () => {
|
||||||
|
const workbook = createOccupancyImportTemplateWorkbook();
|
||||||
|
const ws = workbook.getWorksheet('入住名单导入模板')!;
|
||||||
|
const rows = parseOccupancyImportWorksheet(ws);
|
||||||
|
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
roomNumber: '4-102',
|
||||||
|
building: '4号楼',
|
||||||
|
bedNumber: '1号床',
|
||||||
|
lockerNumber: 'A01',
|
||||||
|
name: '张三',
|
||||||
|
checkInDate: '2026-04-21',
|
||||||
|
billingStartDate: '2026-04-21',
|
||||||
|
stayType: 'short',
|
||||||
|
});
|
||||||
|
expect(rows[1]).toMatchObject({
|
||||||
|
bedNumber: '2号床',
|
||||||
|
lockerNumber: 'A02',
|
||||||
|
stayType: 'long',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
222
apps/server/src/occupancies/occupancy-import-template.ts
Normal file
222
apps/server/src/occupancies/occupancy-import-template.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import * as ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
export interface OccupancyImportRow {
|
||||||
|
roomNumber: string;
|
||||||
|
building?: string;
|
||||||
|
bedNumber?: string;
|
||||||
|
lockerNumber?: string;
|
||||||
|
name: string;
|
||||||
|
gender?: string;
|
||||||
|
ethnicity?: string;
|
||||||
|
phone?: string;
|
||||||
|
idNumber?: string;
|
||||||
|
checkInDate: string;
|
||||||
|
billingStartDate?: string;
|
||||||
|
checkOutDate?: string;
|
||||||
|
stayType?: string;
|
||||||
|
emergencyContact?: string;
|
||||||
|
emergencyPhone?: string;
|
||||||
|
supervisor?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OCCUPANCY_IMPORT_COLUMNS = [
|
||||||
|
{ header: '宿舍号', key: 'roomNumber', width: 12 },
|
||||||
|
{ header: '楼栋', key: 'building', width: 10 },
|
||||||
|
{ header: '床位号', key: 'bedNumber', width: 10 },
|
||||||
|
{ header: '柜子号', key: 'lockerNumber', width: 10 },
|
||||||
|
{ header: '姓名', key: 'name', width: 12 },
|
||||||
|
{ header: '性别', key: 'gender', width: 8 },
|
||||||
|
{ header: '民族', key: 'ethnicity', width: 10 },
|
||||||
|
{ header: '电话', key: 'phone', width: 15 },
|
||||||
|
{ header: '学号/身份证', key: 'idNumber', width: 22 },
|
||||||
|
{ header: '入住时间', key: 'checkInDate', width: 14 },
|
||||||
|
{ header: '计费起始日', key: 'billingStartDate', width: 14 },
|
||||||
|
{ header: '离宿时间', key: 'checkOutDate', width: 14 },
|
||||||
|
{ header: '入住类型', key: 'stayType', width: 10 },
|
||||||
|
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
||||||
|
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
||||||
|
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
||||||
|
{ header: '备注', key: 'notes', width: 20 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const HEADER_ALIASES: Record<keyof OccupancyImportRow, string[]> = {
|
||||||
|
roomNumber: ['宿舍号', '房间号'],
|
||||||
|
building: ['楼栋'],
|
||||||
|
bedNumber: ['床位号'],
|
||||||
|
lockerNumber: ['柜子号'],
|
||||||
|
name: ['姓名', '学生姓名'],
|
||||||
|
gender: ['性别'],
|
||||||
|
ethnicity: ['民族'],
|
||||||
|
phone: ['电话', '手机号'],
|
||||||
|
idNumber: ['学号/身份证', '学号', '身份证号'],
|
||||||
|
checkInDate: ['入住时间', '入住日期'],
|
||||||
|
billingStartDate: ['计费起始日', '计费开始日'],
|
||||||
|
checkOutDate: ['离宿时间', '退宿时间', '退宿日期'],
|
||||||
|
stayType: ['入住类型', '住宿类型'],
|
||||||
|
emergencyContact: ['紧急联系人'],
|
||||||
|
emergencyPhone: ['紧急联系人电话', '紧急联系电话'],
|
||||||
|
supervisor: ['负责人/班主任', '负责人', '班主任'],
|
||||||
|
notes: ['备注'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function cellText(cell: ExcelJS.Cell | undefined): string {
|
||||||
|
if (!cell?.value) return '';
|
||||||
|
if (typeof cell.value === 'object' && 'text' in cell.value) {
|
||||||
|
return String(cell.value.text).trim();
|
||||||
|
}
|
||||||
|
return String(cell.value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(cell: ExcelJS.Cell | undefined): string {
|
||||||
|
const value = cell?.value;
|
||||||
|
if (!value) return '';
|
||||||
|
if (value instanceof Date) {
|
||||||
|
const year = value.getFullYear();
|
||||||
|
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(value.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
const text = cellText(cell);
|
||||||
|
const matched = text.match(/(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})/);
|
||||||
|
if (!matched) return text;
|
||||||
|
return `${matched[1]}-${matched[2].padStart(2, '0')}-${matched[3].padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStayType(value: string): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (value === '长租' || value.toLowerCase() === 'long') return 'long';
|
||||||
|
if (value === '短租' || value.toLowerCase() === 'short') return 'short';
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOccupancyImportWorksheet(ws: ExcelJS.Worksheet): OccupancyImportRow[] {
|
||||||
|
const headerIndexes = new Map<string, number>();
|
||||||
|
ws.getRow(1).eachCell((cell, columnNumber) => {
|
||||||
|
const header = cellText(cell).replace(/\s+/g, '');
|
||||||
|
if (header) headerIndexes.set(header, columnNumber);
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnFor = (key: keyof OccupancyImportRow): number | undefined => {
|
||||||
|
for (const alias of HEADER_ALIASES[key]) {
|
||||||
|
const index = headerIndexes.get(alias.replace(/\s+/g, ''));
|
||||||
|
if (index) return index;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const getCell = (row: ExcelJS.Row, key: keyof OccupancyImportRow) => {
|
||||||
|
const index = columnFor(key);
|
||||||
|
return index ? row.getCell(index) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: OccupancyImportRow[] = [];
|
||||||
|
let lastRoomNumber = '';
|
||||||
|
ws.eachRow((row, rowNumber) => {
|
||||||
|
if (rowNumber === 1) return;
|
||||||
|
const roomNumber = cellText(getCell(row, 'roomNumber'));
|
||||||
|
if (roomNumber) lastRoomNumber = roomNumber;
|
||||||
|
const name = cellText(getCell(row, 'name'));
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
roomNumber: lastRoomNumber,
|
||||||
|
building: cellText(getCell(row, 'building')) || undefined,
|
||||||
|
bedNumber: cellText(getCell(row, 'bedNumber')) || undefined,
|
||||||
|
lockerNumber: cellText(getCell(row, 'lockerNumber')) || undefined,
|
||||||
|
name,
|
||||||
|
gender: cellText(getCell(row, 'gender')) || undefined,
|
||||||
|
ethnicity: cellText(getCell(row, 'ethnicity')) || undefined,
|
||||||
|
phone: cellText(getCell(row, 'phone')) || undefined,
|
||||||
|
idNumber: cellText(getCell(row, 'idNumber')) || undefined,
|
||||||
|
checkInDate: parseDate(getCell(row, 'checkInDate')),
|
||||||
|
billingStartDate: parseDate(getCell(row, 'billingStartDate')) || undefined,
|
||||||
|
checkOutDate: parseDate(getCell(row, 'checkOutDate')) || undefined,
|
||||||
|
stayType: normalizeStayType(cellText(getCell(row, 'stayType'))),
|
||||||
|
emergencyContact: cellText(getCell(row, 'emergencyContact')) || undefined,
|
||||||
|
emergencyPhone: cellText(getCell(row, 'emergencyPhone')) || undefined,
|
||||||
|
supervisor: cellText(getCell(row, 'supervisor')) || undefined,
|
||||||
|
notes: cellText(getCell(row, 'notes')) || undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOccupancyImportTemplateWorkbook(): ExcelJS.Workbook {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const ws = workbook.addWorksheet('入住名单导入模板');
|
||||||
|
ws.columns = [...OCCUPANCY_IMPORT_COLUMNS];
|
||||||
|
ws.getRow(1).font = { bold: true };
|
||||||
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||||
|
ws.views = [{ state: 'frozen', ySplit: 1 }];
|
||||||
|
ws.autoFilter = {
|
||||||
|
from: 'A1',
|
||||||
|
to: `${ws.getColumn(OCCUPANCY_IMPORT_COLUMNS.length).letter}1`,
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.addRow({
|
||||||
|
roomNumber: '4-102',
|
||||||
|
building: '4号楼',
|
||||||
|
bedNumber: '1号床',
|
||||||
|
lockerNumber: 'A01',
|
||||||
|
name: '张三',
|
||||||
|
gender: '男',
|
||||||
|
ethnicity: '汉族',
|
||||||
|
phone: '13800138000',
|
||||||
|
idNumber: '2024001',
|
||||||
|
checkInDate: '2026-04-21',
|
||||||
|
billingStartDate: '2026-04-21',
|
||||||
|
checkOutDate: '',
|
||||||
|
stayType: '短租',
|
||||||
|
emergencyContact: '张父',
|
||||||
|
emergencyPhone: '13900000000',
|
||||||
|
supervisor: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
ws.addRow({
|
||||||
|
roomNumber: '4-102',
|
||||||
|
building: '4号楼',
|
||||||
|
bedNumber: '2号床',
|
||||||
|
lockerNumber: 'A02',
|
||||||
|
name: '李四',
|
||||||
|
gender: '男',
|
||||||
|
ethnicity: '汉族',
|
||||||
|
phone: '13800138001',
|
||||||
|
idNumber: '2024002',
|
||||||
|
checkInDate: '2026-04-21',
|
||||||
|
billingStartDate: '2026-04-22',
|
||||||
|
checkOutDate: '',
|
||||||
|
stayType: '长租',
|
||||||
|
emergencyContact: '',
|
||||||
|
emergencyPhone: '',
|
||||||
|
supervisor: '王老师',
|
||||||
|
notes: '示例数据,导入前请删除',
|
||||||
|
});
|
||||||
|
|
||||||
|
const stayTypeColumnNumber = ws.getColumn('stayType').number;
|
||||||
|
for (let row = 2; row <= 1000; row++) {
|
||||||
|
ws.getCell(row, stayTypeColumnNumber).dataValidation = {
|
||||||
|
type: 'list',
|
||||||
|
allowBlank: true,
|
||||||
|
formulae: ['"短租,长租"'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpWs = workbook.addWorksheet('使用说明');
|
||||||
|
helpWs.getColumn(1).width = 90;
|
||||||
|
const instructions = [
|
||||||
|
'【入住名单导入说明】',
|
||||||
|
'1. 宿舍号、姓名、手机号、入住时间为必填项;床位号建议填写,柜子号可选。',
|
||||||
|
'2. 填写床位号或柜子号后,系统会在对应宿舍中匹配;不存在时自动创建,已被占用时该行导入失败。',
|
||||||
|
'3. 宿舍不存在时会自动创建;宿舍号可智能解析楼栋、楼层和房间类型,楼栋列可用于补充楼栋名称。',
|
||||||
|
'4. 同一宿舍号的连续多行可以合并或留空,系统会继承上一行宿舍号。',
|
||||||
|
'5. 入住类型可填“短租”或“长租”;计费起始日不填时默认等于入住时间。',
|
||||||
|
'6. 系统按手机号关联已有学生,入住记录的所属机构自动取学生档案;未找到时会新建学生。',
|
||||||
|
'7. 已有在住记录的学生会自动跳过,不会重复入住。',
|
||||||
|
'8. 填写离宿时间的记录会作为历史入住导入,床位和柜子不会被标记为占用。',
|
||||||
|
'9. 押金不在表格中逐行填写;请在上传前使用页面上的“导入时自动收押金”和金额设置,历史入住不会自动收取,已有已缴押金不会重复创建。',
|
||||||
|
'10. 模板中的两行示例数据仅用于说明,正式导入前请删除或替换。',
|
||||||
|
];
|
||||||
|
instructions.forEach((instruction) => helpWs.addRow([instruction]));
|
||||||
|
helpWs.getRow(1).font = { bold: true, size: 14 };
|
||||||
|
return workbook;
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ describe('preset role permissions', () => {
|
|||||||
expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'deposit']),
|
expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'deposit']),
|
||||||
);
|
);
|
||||||
expect(accommodation.extras).toContain('student:basic-view');
|
expect(accommodation.extras).toContain('student:basic-view');
|
||||||
|
expect(accommodation.extras).not.toContain('organization:view');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps classroom rental operations separate from accommodation operations', () => {
|
it('keeps classroom rental operations separate from accommodation operations', () => {
|
||||||
|
|||||||
136
apps/server/src/rooms/rooms.service.spec.ts
Normal file
136
apps/server/src/rooms/rooms.service.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||||
|
import { Bed } from '../entities/bed.entity';
|
||||||
|
import { Locker } from '../entities/locker.entity';
|
||||||
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
|
import { Room } from '../entities/room.entity';
|
||||||
|
import { RoomExpense } from '../entities/room-expense.entity';
|
||||||
|
import { RoomsService } from './rooms.service';
|
||||||
|
|
||||||
|
describe('RoomsService — capacity consistency', () => {
|
||||||
|
const createService = (options?: {
|
||||||
|
room?: Partial<Room>;
|
||||||
|
beds?: Partial<Bed>[];
|
||||||
|
activeOccupantCount?: number;
|
||||||
|
}) => {
|
||||||
|
const room = { id: 1, capacity: 2, status: 'full', ...options?.room } as Room;
|
||||||
|
const beds = (options?.beds ?? [
|
||||||
|
{ id: 1, roomId: 1, bedNumber: '1号床' },
|
||||||
|
{ id: 2, roomId: 1, bedNumber: '2号床' },
|
||||||
|
]) as Bed[];
|
||||||
|
|
||||||
|
const roomRepo = {
|
||||||
|
findOne: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(room)
|
||||||
|
.mockResolvedValue({ ...room, capacity: 4 }),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
} as unknown as Repository<Room>;
|
||||||
|
const bedRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue(beds),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(async (value) => value),
|
||||||
|
} as unknown as Repository<Bed>;
|
||||||
|
const occupancyRepo = {
|
||||||
|
count: jest.fn().mockResolvedValue(options?.activeOccupantCount ?? 2),
|
||||||
|
} as unknown as Repository<Occupancy>;
|
||||||
|
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn((entity) => {
|
||||||
|
if (entity === Room) return roomRepo;
|
||||||
|
if (entity === Bed) return bedRepo;
|
||||||
|
if (entity === Occupancy) return occupancyRepo;
|
||||||
|
throw new Error(`Unexpected repository: ${String(entity)}`);
|
||||||
|
}),
|
||||||
|
} as unknown as EntityManager;
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn(async (callback) => callback(manager)),
|
||||||
|
} as unknown as DataSource;
|
||||||
|
|
||||||
|
const service = new RoomsService(
|
||||||
|
roomRepo,
|
||||||
|
occupancyRepo,
|
||||||
|
{} as Repository<RoomExpense>,
|
||||||
|
bedRepo,
|
||||||
|
{} as Repository<Locker>,
|
||||||
|
dataSource,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, roomRepo, bedRepo, occupancyRepo };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('automatically creates missing beds when capacity increases', async () => {
|
||||||
|
const { service, roomRepo, bedRepo } = createService();
|
||||||
|
|
||||||
|
await service.update(1, { capacity: 4 });
|
||||||
|
|
||||||
|
expect(bedRepo.create).toHaveBeenNthCalledWith(1, { roomId: 1, bedNumber: '3号床' });
|
||||||
|
expect(bedRepo.create).toHaveBeenNthCalledWith(2, { roomId: 1, bedNumber: '4号床' });
|
||||||
|
expect(bedRepo.save).toHaveBeenCalledWith([
|
||||||
|
{ roomId: 1, bedNumber: '3号床' },
|
||||||
|
{ roomId: 1, bedNumber: '4号床' },
|
||||||
|
]);
|
||||||
|
expect(roomRepo.update).toHaveBeenCalledWith(1, { capacity: 4, status: 'available' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects capacity lower than the active occupant count', async () => {
|
||||||
|
const { service, roomRepo, bedRepo } = createService({
|
||||||
|
room: { capacity: 4, status: 'available' },
|
||||||
|
beds: [{ id: 1, roomId: 1, bedNumber: '1号床' }],
|
||||||
|
activeOccupantCount: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.update(1, { capacity: 2 })).rejects.toThrow(
|
||||||
|
new BadRequestException('额定人数不能少于当前入住人数,当前有 3 人入住'),
|
||||||
|
);
|
||||||
|
expect(roomRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(bedRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects capacity lower than the existing bed count', async () => {
|
||||||
|
const { service, roomRepo, bedRepo } = createService({
|
||||||
|
room: { capacity: 4, status: 'available' },
|
||||||
|
activeOccupantCount: 1,
|
||||||
|
beds: [
|
||||||
|
{ id: 1, roomId: 1, bedNumber: '1号床' },
|
||||||
|
{ id: 2, roomId: 1, bedNumber: '2号床' },
|
||||||
|
{ id: 3, roomId: 1, bedNumber: '3号床' },
|
||||||
|
{ id: 4, roomId: 1, bedNumber: '4号床' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.update(1, { capacity: 3 })).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'额定人数不能少于现有床位数,当前有 4 张床位,请先删除多余的空闲床位',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(roomRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(bedRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the room full when a valid capacity reduction reaches the occupant count', async () => {
|
||||||
|
const { service, roomRepo, bedRepo } = createService({
|
||||||
|
room: { capacity: 4, status: 'available' },
|
||||||
|
activeOccupantCount: 2,
|
||||||
|
beds: [
|
||||||
|
{ id: 1, roomId: 1, bedNumber: '1号床' },
|
||||||
|
{ id: 2, roomId: 1, bedNumber: '2号床' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.update(1, { capacity: 2 });
|
||||||
|
|
||||||
|
expect(roomRepo.update).toHaveBeenCalledWith(1, { capacity: 2, status: 'full' });
|
||||||
|
expect(bedRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates other room fields without changing beds', async () => {
|
||||||
|
const { service, roomRepo, bedRepo, occupancyRepo } = createService();
|
||||||
|
|
||||||
|
await service.update(1, { building: '2号楼' });
|
||||||
|
|
||||||
|
expect(roomRepo.update).toHaveBeenCalledWith(1, { building: '2号楼' });
|
||||||
|
expect(bedRepo.find).not.toHaveBeenCalled();
|
||||||
|
expect(occupancyRepo.count).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, Like, IsNull, Not, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
import {
|
||||||
|
DataSource,
|
||||||
|
Repository,
|
||||||
|
Like,
|
||||||
|
IsNull,
|
||||||
|
Not,
|
||||||
|
In,
|
||||||
|
LessThanOrEqual,
|
||||||
|
MoreThanOrEqual,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
import { Room } from '../entities/room.entity';
|
import { Room } from '../entities/room.entity';
|
||||||
import { Occupancy } from '../entities/occupancy.entity';
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
@@ -19,6 +28,7 @@ export class RoomsService {
|
|||||||
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
||||||
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
|
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
|
||||||
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
||||||
|
private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,9 +126,54 @@ export class RoomsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, dto: UpdateRoomDto) {
|
async update(id: number, dto: UpdateRoomDto) {
|
||||||
await this.findOne(id);
|
return this.dataSource.transaction(async (manager) => {
|
||||||
await this.repo.update(id, dto);
|
const roomRepo = manager.getRepository(Room);
|
||||||
return this.repo.findOne({ where: { id } });
|
const bedRepo = manager.getRepository(Bed);
|
||||||
|
const occupancyRepo = manager.getRepository(Occupancy);
|
||||||
|
const room = await roomRepo.findOne({ where: { id } });
|
||||||
|
if (!room) throw new NotFoundException('宿舍不存在');
|
||||||
|
|
||||||
|
if (dto.capacity !== undefined) {
|
||||||
|
const [beds, activeOccupantCount] = await Promise.all([
|
||||||
|
bedRepo.find({ where: { roomId: id }, order: { bedNumber: 'ASC' } }),
|
||||||
|
occupancyRepo.count({ where: { roomId: id, checkOutDate: IsNull() } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (dto.capacity < activeOccupantCount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`额定人数不能少于当前入住人数,当前有 ${activeOccupantCount} 人入住`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dto.capacity < beds.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`额定人数不能少于现有床位数,当前有 ${beds.length} 张床位,请先删除多余的空闲床位`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.capacity > beds.length) {
|
||||||
|
const countToCreate = dto.capacity - beds.length;
|
||||||
|
const start = this.getNextBedNumber(beds);
|
||||||
|
const newBeds = Array.from({ length: countToCreate }, (_, index) =>
|
||||||
|
bedRepo.create({ roomId: id, bedNumber: `${start + index}号床` }),
|
||||||
|
);
|
||||||
|
await bedRepo.save(newBeds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
dto.status === undefined &&
|
||||||
|
room.status !== 'maintenance' &&
|
||||||
|
room.status !== 'archived'
|
||||||
|
) {
|
||||||
|
dto = {
|
||||||
|
...dto,
|
||||||
|
status: activeOccupantCount >= dto.capacity ? 'full' : 'available',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await roomRepo.update(id, dto);
|
||||||
|
return roomRepo.findOne({ where: { id } });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number) {
|
async remove(id: number) {
|
||||||
@@ -413,6 +468,14 @@ export class RoomsService {
|
|||||||
await this.bedRepo.save(beds);
|
await this.bedRepo.save(beds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getNextBedNumber(beds: Pick<Bed, 'bedNumber'>[]): number {
|
||||||
|
const numbers = beds.map((bed) => {
|
||||||
|
const match = bed.bedNumber.match(/^\d+/);
|
||||||
|
return match ? parseInt(match[0], 10) : 0;
|
||||||
|
});
|
||||||
|
return numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
private async assertCanAddBeds(room: Room, count: number): Promise<void> {
|
private async assertCanAddBeds(room: Room, count: number): Promise<void> {
|
||||||
const existingCount = await this.bedRepo.count({ where: { roomId: room.id } });
|
const existingCount = await this.bedRepo.count({ where: { roomId: room.id } });
|
||||||
this.assertCanAddBedsFromCount(room, existingCount, count);
|
this.assertCanAddBedsFromCount(room, existingCount, count);
|
||||||
@@ -421,7 +484,9 @@ export class RoomsService {
|
|||||||
private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void {
|
private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void {
|
||||||
const remaining = Math.max((room.capacity ?? 0) - existingCount, 0);
|
const remaining = Math.max((room.capacity ?? 0) - existingCount, 0);
|
||||||
if (count > remaining) {
|
if (count > remaining) {
|
||||||
throw new BadRequestException(`床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining} 张`);
|
throw new BadRequestException(
|
||||||
|
`床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining} 张`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
apps/server/src/utility-balances/dto/utility-recharge.dto.ts
Normal file
17
apps/server/src/utility-balances/dto/utility-recharge.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsInt, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateUtilityRechargeDto {
|
||||||
|
@IsInt()
|
||||||
|
studentId: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
rechargeDate: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, 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 { CreateUtilityRechargeDto } from './dto/utility-recharge.dto';
|
||||||
|
import { UtilityBalancesService } from './utility-balances.service';
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('utility-balances')
|
||||||
|
export class UtilityBalancesController {
|
||||||
|
constructor(
|
||||||
|
private service: UtilityBalancesService,
|
||||||
|
private logService: OperationLogsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('student-lookups')
|
||||||
|
@RequirePermission('expense:view')
|
||||||
|
getStudentLookups() {
|
||||||
|
return this.service.getStudentLookups();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('balances')
|
||||||
|
@RequirePermission('expense:view')
|
||||||
|
getBalances() {
|
||||||
|
return this.service.getBalances();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('recharges')
|
||||||
|
@RequirePermission('expense:view')
|
||||||
|
findAll(@Query('studentId') studentId?: string) {
|
||||||
|
return this.service.findAll({ studentId: studentId ? +studentId : undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('recharges')
|
||||||
|
@RequirePermission('expense:create')
|
||||||
|
async create(@Body() dto: CreateUtilityRechargeDto, @Request() req: any) {
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
const result = await this.service.create(dto, req.user?.id);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '水电余额',
|
||||||
|
action: '充值',
|
||||||
|
targetId: result.id,
|
||||||
|
targetType: 'utility-recharge',
|
||||||
|
detail: `学生${dto.studentId} 充值¥${dto.amount}`,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('recharges/:id')
|
||||||
|
@RequirePermission('expense:delete')
|
||||||
|
async remove(@Param('id') id: string, @Request() req: any) {
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
const result = await this.service.remove(+id);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '水电余额',
|
||||||
|
action: '删除充值',
|
||||||
|
targetId: +id,
|
||||||
|
targetType: 'utility-recharge',
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/server/src/utility-balances/utility-balances.module.ts
Normal file
16
apps/server/src/utility-balances/utility-balances.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Bill } from '../entities/bill.entity';
|
||||||
|
import { Student } from '../entities/student.entity';
|
||||||
|
import { UtilityRecharge } from '../entities/utility-recharge.entity';
|
||||||
|
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||||
|
import { UtilityBalancesController } from './utility-balances.controller';
|
||||||
|
import { UtilityBalancesService } from './utility-balances.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([UtilityRecharge, Student, Bill]), OperationLogsModule],
|
||||||
|
controllers: [UtilityBalancesController],
|
||||||
|
providers: [UtilityBalancesService],
|
||||||
|
exports: [UtilityBalancesService],
|
||||||
|
})
|
||||||
|
export class UtilityBalancesModule {}
|
||||||
102
apps/server/src/utility-balances/utility-balances.service.ts
Normal file
102
apps/server/src/utility-balances/utility-balances.service.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Bill } from '../entities/bill.entity';
|
||||||
|
import { Student } from '../entities/student.entity';
|
||||||
|
import { UtilityRecharge } from '../entities/utility-recharge.entity';
|
||||||
|
import { CreateUtilityRechargeDto } from './dto/utility-recharge.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UtilityBalancesService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(UtilityRecharge)
|
||||||
|
private rechargeRepo: Repository<UtilityRecharge>,
|
||||||
|
@InjectRepository(Student)
|
||||||
|
private studentRepo: Repository<Student>,
|
||||||
|
@InjectRepository(Bill)
|
||||||
|
private billRepo: Repository<Bill>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getStudentLookups() {
|
||||||
|
return this.studentRepo.find({
|
||||||
|
select: ['id', 'name', 'studentNo'],
|
||||||
|
where: { status: 'active' },
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(query?: { studentId?: number }) {
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (query?.studentId) where.studentId = query.studentId;
|
||||||
|
return this.rechargeRepo.find({
|
||||||
|
where,
|
||||||
|
relations: ['student'],
|
||||||
|
order: { rechargeDate: 'DESC', createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateUtilityRechargeDto, userId?: number) {
|
||||||
|
if (dto.amount <= 0) throw new BadRequestException('充值金额必须大于 0');
|
||||||
|
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||||
|
if (!student) throw new NotFoundException('学生不存在');
|
||||||
|
return this.rechargeRepo.save(
|
||||||
|
this.rechargeRepo.create({
|
||||||
|
studentId: dto.studentId,
|
||||||
|
amount: dto.amount,
|
||||||
|
rechargeDate: dto.rechargeDate,
|
||||||
|
notes: dto.notes,
|
||||||
|
recordedBy: userId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number) {
|
||||||
|
const exists = await this.rechargeRepo.findOne({ where: { id } });
|
||||||
|
if (!exists) throw new NotFoundException('充值记录不存在');
|
||||||
|
await this.rechargeRepo.delete(id);
|
||||||
|
return { message: '删除成功' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalances() {
|
||||||
|
const [students, rechargeRows, billRows] = await Promise.all([
|
||||||
|
this.studentRepo.find({
|
||||||
|
select: ['id', 'name', 'studentNo', 'status'],
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
}),
|
||||||
|
this.rechargeRepo
|
||||||
|
.createQueryBuilder('r')
|
||||||
|
.select('r.studentId', 'studentId')
|
||||||
|
.addSelect('SUM(r.amount)', 'amount')
|
||||||
|
.groupBy('r.studentId')
|
||||||
|
.getRawMany<{ studentId: number | string; amount: string | number | null }>(),
|
||||||
|
this.billRepo
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.select('b.studentId', 'studentId')
|
||||||
|
.addSelect('SUM(b.totalAmount)', 'amount')
|
||||||
|
.where('b.status IN (:...statuses)', { statuses: ['confirmed', 'paid'] })
|
||||||
|
.groupBy('b.studentId')
|
||||||
|
.getRawMany<{ studentId: number | string; amount: string | number | null }>(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rechargeMap = new Map<number, number>();
|
||||||
|
for (const row of rechargeRows) {
|
||||||
|
rechargeMap.set(Number(row.studentId), Number(row.amount || 0));
|
||||||
|
}
|
||||||
|
const usedMap = new Map<number, number>();
|
||||||
|
for (const row of billRows) {
|
||||||
|
usedMap.set(Number(row.studentId), Number(row.amount || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return students.map((student) => {
|
||||||
|
const totalRecharged = Number((rechargeMap.get(student.id) || 0).toFixed(2));
|
||||||
|
const usedAmount = Number((usedMap.get(student.id) || 0).toFixed(2));
|
||||||
|
return {
|
||||||
|
student,
|
||||||
|
studentId: student.id,
|
||||||
|
totalRecharged,
|
||||||
|
usedAmount,
|
||||||
|
balance: Number((totalRecharged - usedAmount).toFixed(2)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user