17 Commits

Author SHA1 Message Date
eac336a54a fix: include contained room expenses in bill generation 2026-07-14 14:53:07 +08:00
ce5fd1c6cb fix: 修复批量更新账单状态功能 2026-07-14 14:24:19 +08:00
05a936bbc2 Merge pull request 'feat: 优化入住办理与名单导入' (#9) from codex/wzq into main 2026-07-14 04:26:53 +00:00
3adf4933d8 Merge pull request '修复pdf导出' (#8) from xiongyuxing/gongxue-base:main into main 2026-07-14 04:26:34 +00:00
d84f37e98f feat: streamline occupancy check-in and imports 2026-07-14 12:23:53 +08:00
16b56ffcd5 merge upstream 2026-07-14 04:09:42 +00:00
718c58589f Merge PR #7: improve attendance and occupancy workflows 2026-07-14 03:22:19 +00:00
aaf49d5580 fix: guard bill generation submission 2026-07-14 11:20:42 +08:00
5cf6aede1e feat: improve occupancy import template 2026-07-14 11:20:41 +08:00
811e7ce826 feat: show DingTalk punch device details 2026-07-14 11:20:41 +08:00
79fa472b78 merge upstream 2026-07-14 03:20:00 +00:00
029af37f3a fix: render bill pdf from frontend print view 2026-07-14 11:18:49 +08:00
d572e984d2 Merge PR #6: keep room capacity and beds consistent 2026-07-14 02:58:01 +00:00
a93ba657a8 fix: keep room capacity and beds consistent 2026-07-14 10:57:13 +08:00
77714642a5 Merge PR #5: main 2026-07-14 02:56:04 +00:00
e7aa202603 fix: 修改 PDFDocument 导入方式以符合 ES6 模块规范 2026-07-14 10:45:27 +08:00
xyx
013b3f4afe fix: 押金页添加批量构建学生选项的功能以简化学生数据处理 2026-07-13 17:18:35 +08:00
33 changed files with 1600 additions and 253 deletions

View File

@@ -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,
});
});
});

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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> },
]} ]}
/> />

View File

@@ -38,11 +38,166 @@ const typeMap: Record<string, string> = {
water: '水费', water: '水费',
electricity: '电费', electricity: '电费',
cleaning: '保洁费', cleaning: '保洁费',
rent: '租金',
damage: '损坏赔偿', damage: '损坏赔偿',
penalty: '罚款', penalty: '罚款',
other: '其他', other: '其他',
}; };
const escapeHtml = (value: unknown) =>
String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`;
const buildBillPrintHtml = (bill: any) => {
const studentName = bill.student?.name || '-';
const status = statusMap[bill.status]?.text || bill.status || '-';
const generatedAt = bill.generatedAt
? dayjs(bill.generatedAt).format('YYYY-MM-DD HH:mm')
: dayjs().format('YYYY-MM-DD HH:mm');
const hasDeposit = Number(bill.availableDeposit || 0) > 0;
const items = bill.items || [];
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>账单_${escapeHtml(studentName)}_${escapeHtml(bill.id)}</title>
<style>
@page { size: A4; margin: 0; }
* { box-sizing: border-box; }
body {
margin: 0;
color: #000;
background: #f5f5f5;
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", Arial, sans-serif;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.page {
width: 210mm;
min-height: 297mm;
margin: 0 auto;
padding: 50px;
background: #fff;
}
h1 { margin: 0; text-align: center; font-size: 20px; line-height: 1.35; font-weight: 700; }
.generated-time { margin-top: 8px; text-align: center; color: #666; font-size: 10px; }
.basic-info { margin-top: 24px; font-size: 12px; line-height: 1.8; }
.section-title { margin: 16px 0 6px; font-size: 14px; font-weight: 700; text-decoration: underline; }
.amount-summary { font-size: 12px; line-height: 1.75; }
.total { color: #007aff; font-size: 14px; font-weight: 700; }
.deposit { color: #52c41a; font-size: 11px; }
.deposit-applied { color: #fa8c16; font-size: 11px; }
.after-deposit { color: #ff3b30; font-size: 14px; 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>个人费用: ${escapeHtml(money(bill.personalAmount))}</div>
<div class="total">应付总额: ${escapeHtml(money(bill.totalAmount))}</div>
${
hasDeposit
? `<div class="deposit">可用押金: ${escapeHtml(money(bill.availableDeposit))}</div>
<div class="deposit-applied">押金抵扣: -${escapeHtml(money(bill.depositApplied))}</div>
<div class="after-deposit">抵扣后应付: ${escapeHtml(money(bill.amountAfterDeposit ?? bill.totalAmount))}</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 +244,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 +256,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 +341,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 || '-' },

View File

@@ -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)',
},
]);
});
}); });

View File

@@ -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);

View File

@@ -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' },
@@ -86,10 +86,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],
); );

View File

@@ -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>

View File

@@ -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>

View File

@@ -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([
{ {

View File

@@ -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) {

View File

@@ -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' }),

View File

@@ -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;

View File

@@ -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',
}),
);
}); });
}); });

View File

@@ -5,7 +5,7 @@ import { Bill } from '../entities/bill.entity';
import { BillItem } from '../entities/bill-item.entity'; import { BillItem } from '../entities/bill-item.entity';
import { Deposit } from '../entities/deposit.entity'; import { Deposit } from '../entities/deposit.entity';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import * as PDFDocument from 'pdfkit'; import PDFDocument from 'pdfkit';
import { Response } from 'express'; import { Response } from 'express';
@Injectable() @Injectable()

View File

@@ -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);
} }
} }

View File

@@ -166,6 +166,43 @@ describe('BillsService — generateBills', () => {
).toBeCloseTo(300, 0); ).toBeCloseTo(300, 0);
}); });
it('includes room expenses whose periods are inside the generated bill period', async () => {
const qb = mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'water',
amount: '300' as unknown as number, periodStart: '2026-07-01', periodEnd: '2026-07-31',
} as RoomExpense,
]);
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-07-01', billingEndDate: null as unknown as string,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills({
periodStart: '2026-06-29',
periodEnd: '2026-07-31',
});
expect(result.count).toBe(1);
expect(qb.where).toHaveBeenCalledWith(
'e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd',
{ periodStart: '2026-06-29', periodEnd: '2026-07-31' },
);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
expect(Number(savedCalls[0][0].sharedAmount)).toBeCloseTo(300, 0);
});
it('mixed → long-term get individual bills, short-term share expenses', async () => { it('mixed → long-term get individual bills, short-term share expenses', async () => {
// Room 1: two expenses // Room 1: two expenses
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(

View File

@@ -50,10 +50,10 @@ 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', {
periodStart, periodStart,
periodEnd, periodEnd,
}) })

View File

@@ -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[];
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
})); }));
} }

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 }));
});
});

View File

@@ -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. 自动收取押金(仅对新入住且非历史记录的学生)

View File

@@ -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',
});
});
});

View 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;
}

View File

@@ -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', () => {

View 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();
});
});

View File

@@ -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}`,
);
} }
} }