From b3d0bafc22c14bfa94935b253b0f8618699d1454 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 17 Jul 2026 17:36:04 +0800 Subject: [PATCH] feat: refine deposit room type workflows --- ...deposit-student-option.integration.test.ts | 7 + .../pages/Deposits/deposit-student-option.ts | 3 +- apps/admin/src/pages/Deposits/index.tsx | 483 +++++++++++++----- .../src/attendance/attendance.controller.ts | 8 +- .../src/attendance/attendance.service.spec.ts | 100 ++++ .../src/attendance/attendance.service.ts | 7 +- .../src/deposits/deposits.controller.ts | 28 +- apps/server/src/deposits/deposits.module.ts | 3 +- .../src/deposits/deposits.room-type.spec.ts | 98 ++++ apps/server/src/deposits/deposits.service.ts | 103 +++- apps/server/src/deposits/dto/deposit.dto.ts | 24 +- 11 files changed, 724 insertions(+), 140 deletions(-) create mode 100644 apps/server/src/deposits/deposits.room-type.spec.ts diff --git a/apps/admin/src/pages/Deposits/deposit-student-option.integration.test.ts b/apps/admin/src/pages/Deposits/deposit-student-option.integration.test.ts index 92041e8..38f4667 100644 --- a/apps/admin/src/pages/Deposits/deposit-student-option.integration.test.ts +++ b/apps/admin/src/pages/Deposits/deposit-student-option.integration.test.ts @@ -26,4 +26,11 @@ describe('deposit student option', () => { }, ]); }); + + it('includes room type when available', () => { + expect(buildDepositStudentOption({ id: 23, name: '张三', studentNo: 'S2026001', roomType: '四人间' })).toEqual({ + value: 23, + label: '张三 (S2026001) - 四人间', + }); + }); }); diff --git a/apps/admin/src/pages/Deposits/deposit-student-option.ts b/apps/admin/src/pages/Deposits/deposit-student-option.ts index 3346453..bb44336 100644 --- a/apps/admin/src/pages/Deposits/deposit-student-option.ts +++ b/apps/admin/src/pages/Deposits/deposit-student-option.ts @@ -2,11 +2,12 @@ export interface DepositStudentLookup { id: number; name: string; studentNo?: string | null; + roomType?: string | null; } export const buildDepositStudentOption = (student: DepositStudentLookup) => ({ value: student.id, - label: `${student.name} (${student.studentNo || `#${student.id}`})`, + label: `${student.name} (${student.studentNo || `#${student.id}`})${student.roomType ? ` - ${student.roomType}` : ''}`, }); export const buildDepositStudentOptions = (students: DepositStudentLookup[]) => diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 55c3c84..798ce3c 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Table, Modal, @@ -14,12 +14,12 @@ import { Card, Empty, } from 'antd'; -import { PlusOutlined, InboxOutlined, DollarOutlined } from '@ant-design/icons'; +import { PlusOutlined, InboxOutlined, DollarOutlined, TeamOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; -import { buildDepositStudentOptions } from './deposit-student-option'; +import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option'; const statusMap: Record = { paid: { text: '有余额', color: 'green' }, @@ -32,49 +32,141 @@ const installmentStatusMap: Record = { paid: { text: '已缴', color: 'green' }, }; +const roomTypeOptions = [ + { value: '单人间', label: '单人间' }, + { value: '四人间', label: '四人间' }, +]; + +const suggestedDepositByRoomType: Record = { + 单人间: 200, + 四人间: 100, +}; + +interface DepositRecord { + id: number; + studentId: number; + amount: number; + status: string; + paidDate: string; + refundDate?: string | null; + notes?: string | null; + installments?: Array<{ id: number; amount: number; dueDate: string; paidDate?: string | null; status: string }>; + student?: DepositStudentLookup; +} + +interface EligibleStudent { + studentId: number; + studentName: string; + studentNo?: string | null; + roomId: number; + roomNumber: string; + building?: string | null; + roomType?: string | null; + capacity: number; + depositAmount: number; +} + const isFormValidationError = (error: unknown) => typeof error === 'object' && error !== null && Array.isArray((error as { errorFields?: unknown }).errorFields); const DepositsPage: React.FC = () => { - const [data, setData] = useState([]); - const [students, setStudents] = useState([]); + const [data, setData] = useState([]); + const [students, setStudents] = useState([]); + const [eligibleStudents, setEligibleStudents] = useState([]); + const [selectedEligibleStudentIds, setSelectedEligibleStudentIds] = useState([]); const [loading, setLoading] = useState(false); + const [eligibleLoading, setEligibleLoading] = useState(false); const [createModal, setCreateModal] = useState(false); - const [refundModal, setRefundModal] = useState(null); - const [detailModal, setDetailModal] = useState(null); + const [batchModal, setBatchModal] = useState(false); + const [refundModal, setRefundModal] = useState(null); + const [detailModal, setDetailModal] = useState(null); const [installmentModal, setInstallmentModal] = useState(null); const [createForm] = Form.useForm(); + const [batchForm] = Form.useForm(); const [refundForm] = Form.useForm(); const [installmentForm] = Form.useForm(); const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(undefined); + const [filterRoomType, setFilterRoomType] = useState(undefined); + const [batchRoomType, setBatchRoomType] = useState('四人间'); const [saving, setSaving] = useState(false); - const fetchData = async () => { + const fetchData = useCallback(async () => { setLoading(true); try { - const [d, s]: any[] = await Promise.all([ - api.get('/deposits'), - api.get('/deposits/student-lookups'), + const [d, s] = await Promise.all([ + api.get('/deposits'), + api.get('/deposits/student-lookups'), ]); setData(d); setStudents(s); } catch (e: any) { message.error(e?.message || '加载失败,请稍后重试'); + } finally { + setLoading(false); } - setLoading(false); - }; - + }, []); + const fetchEligibleStudents = useCallback(async (roomType?: string) => { + setEligibleLoading(true); + try { + const params = roomType ? `?roomType=${encodeURIComponent(roomType)}` : ''; + const rows = await api.get(`/deposits/eligible-students${params}`); + setEligibleStudents(rows); + setSelectedEligibleStudentIds(rows.map((item) => item.studentId)); + } catch (e: any) { + message.error(e?.message || '加载在住人员失败'); + } finally { + setEligibleLoading(false); + } + }, []); useEffect(() => { fetchData(); - }, []); + }, [fetchData]); + + useEffect(() => { + fetchEligibleStudents(filterRoomType); + }, [fetchEligibleStudents, filterRoomType]); + + const depositByStudentId = useMemo(() => { + const map = new Map(); + data.forEach((item) => map.set(item.studentId, item)); + return map; + }, [data]); const filteredData = useMemo(() => { - return data.filter((d: any) => { + if (filterRoomType) { + const s = searchText.trim().toLowerCase(); + return eligibleStudents + .filter((item) => !s || item.studentName.toLowerCase().includes(s) || item.studentNo?.toLowerCase().includes(s)) + .map((item) => { + const deposit = depositByStudentId.get(item.studentId); + return { + id: deposit?.id ?? `eligible-${item.studentId}`, + studentId: item.studentId, + amount: deposit?.amount ?? item.depositAmount ?? 0, + status: deposit?.status ?? 'unpaid', + paidDate: deposit?.paidDate ?? '', + refundDate: deposit?.refundDate, + notes: deposit?.notes, + installments: deposit?.installments ?? [], + student: { + id: item.studentId, + name: item.studentName, + studentNo: item.studentNo, + roomType: item.roomType, + }, + roomNumber: item.roomNumber, + building: item.building, + roomType: item.roomType, + }; + }); + } + + return data.filter((d) => { if (searchText) { const s = searchText.toLowerCase(); if (!d.student?.name?.toLowerCase().includes(s)) return false; @@ -82,13 +174,30 @@ const DepositsPage: React.FC = () => { if (filterStatus && d.status !== filterStatus) return false; return true; }); - }, [data, searchText, filterStatus]); + }, [data, depositByStudentId, eligibleStudents, filterRoomType, filterStatus, searchText]); const studentOptions = useMemo( () => buildDepositStudentOptions(students), [students], ); + + + const openBatchModal = (roomType = filterRoomType || '四人间') => { + const amount = suggestedDepositByRoomType[roomType] ?? 100; + setBatchRoomType(roomType); + batchForm.resetFields(); + batchForm.setFieldsValue({ roomType, amount, paidDate: dayjs() }); + setBatchModal(true); + fetchEligibleStudents(roomType); + }; + + const handleBatchRoomTypeChange = (roomType: string) => { + setBatchRoomType(roomType); + batchForm.setFieldsValue({ amount: suggestedDepositByRoomType[roomType] ?? batchForm.getFieldValue('amount') ?? 100 }); + fetchEligibleStudents(roomType); + }; + const handleCreate = async () => { setSaving(true); try { @@ -103,6 +212,36 @@ const DepositsPage: React.FC = () => { setCreateModal(false); createForm.resetFields(); fetchData(); + fetchEligibleStudents(filterRoomType); + } catch (e: any) { + if (!isFormValidationError(e)) { + message.error(e?.message || '操作失败'); + } + } finally { + setSaving(false); + } + }; + + const handleBatchCreate = async () => { + if (selectedEligibleStudentIds.length === 0) { + message.warning('请选择至少一名学生'); + return; + } + setSaving(true); + try { + const values = await batchForm.validateFields(); + await api.post('/deposits/batch', { + studentIds: selectedEligibleStudentIds, + amount: values.amount, + paidDate: values.paidDate.format('YYYY-MM-DD'), + notes: values.notes, + roomType: values.roomType, + }); + message.success(`已为 ${selectedEligibleStudentIds.length} 人批量收取押金`); + setBatchModal(false); + batchForm.resetFields(); + await fetchData(); + fetchEligibleStudents(filterRoomType); } catch (e: any) { if (!isFormValidationError(e)) { message.error(e?.message || '操作失败'); @@ -113,6 +252,7 @@ const DepositsPage: React.FC = () => { }; const handleRefund = async () => { + if (!refundModal) return; setSaving(true); try { const values = await refundForm.validateFields(); @@ -124,6 +264,7 @@ const DepositsPage: React.FC = () => { setRefundModal(null); refundForm.resetFields(); fetchData(); + fetchEligibleStudents(filterRoomType); } catch (e: any) { if (!isFormValidationError(e)) { message.error(e?.message || '操作失败'); @@ -176,32 +317,39 @@ const DepositsPage: React.FC = () => { }; const columns = useMemo(() => [ - { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, - { title: '当前可用押金', dataIndex: 'amount', width: 130, render: (v: number) => `¥${Number(v).toFixed(2)}` }, - { title: '最近收取日期', dataIndex: 'paidDate', width: 120 }, + { title: '学生', width: 120, render: (_: unknown, r: any) => r.student?.name || '-' }, + { title: '当前可用押金', dataIndex: 'amount', width: 130, render: (v: number) => `¥${Number(v || 0).toFixed(2)}` }, + { title: '房间', width: 120, render: (_: unknown, r: any) => r.roomNumber ? `${r.building ? `${r.building}-` : ''}${r.roomNumber}` : '-' }, + { title: '房型', dataIndex: 'roomType', width: 100, render: (v: string) => v || '-' }, + { title: '最近收取日期', dataIndex: 'paidDate', width: 120, render: (v: string) => v || '-' }, { title: '状态', dataIndex: 'status', - render: (s: string) => {statusMap[s]?.text || s}, + render: (s: string) => s === 'unpaid' + ? 未缴 + : {statusMap[s]?.text || s}, }, - { title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: any) => v || '-' }, - { title: '备注', dataIndex: 'notes', width: 120, render: (v: any) => v || '-' }, + { title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: unknown) => v || '-' }, + { title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' }, { title: '操作', width: 240, - render: (_: any, record: any) => ( - - { - setDetailModal(record); - }} - > - 详情 - - {record.status === 'paid' && ( - <> + render: (_: unknown, record: any) => { + const hasDeposit = typeof record.id === 'number'; + return ( + + {hasDeposit && ( + { + setDetailModal(record); + }} + > + 详情 + + )} + {record.status === 'paid' && hasDeposit && ( { > 退还 - - )} - { - try { - await api.delete(`/deposits/${record.id}`); - message.success('归档成功'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '归档失败'); - } - }} - > - } - > - 归档 - - - - ), + )} + {hasDeposit && ( + { + try { + await api.delete(`/deposits/${record.id}`); + message.success('归档成功'); + fetchData(); + fetchEligibleStudents(filterRoomType); + } catch (e: any) { + message.error(e?.message || '归档失败'); + } + }} + > + } + > + 归档 + + + )} + + ); + }, }, - ], [fetchData]); - + ], [fetchData, fetchEligibleStudents, filterRoomType, refundForm]); + const eligibleColumns = [ + { title: '学生', render: (_: unknown, r: EligibleStudent) => `${r.studentName} (${r.studentNo || `#${r.studentId}`})` }, + { title: '房间', render: (_: unknown, r: EligibleStudent) => `${r.building ? `${r.building}-` : ''}${r.roomNumber}` }, + { title: '房型', dataIndex: 'roomType' }, + { title: '当前押金', dataIndex: 'depositAmount', render: (v: number) => `¥${Number(v || 0).toFixed(2)}` }, + ]; return (
- - setSearchText(v)} - onChange={(e) => { - if (!e.target.value) setSearchText(''); + style={{ + marginBottom: 16, + display: 'flex', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: 8, }} + > + + setSearchText(v)} + onChange={(e) => { + setSearchText(e.target.value); + }} + /> + setFilterStatus(v)} + options={[ + { value: 'paid', label: '有余额' }, + { value: 'refunded', label: '已全退' }, + { value: 'depleted', label: '已扣完' }, + ]} + /> + + + } + onClick={() => openBatchModal()} + > + 按房型批量收取 + + } + onClick={() => { + createForm.resetFields(); + createForm.setFieldsValue({ amount: 500, paidDate: dayjs() }); + setCreateModal(true); + }} + > + 收取押金 + + +
+ `共 ${total} 条`, + }} + locale={{ emptyText: }} /> -
`共 ${total} 条`, - }} - locale={{ emptyText: }} - /> + + {/* Batch Create Modal */} + setBatchModal(false)} + okText="确认批量收取" + confirmLoading={saving} + okButtonProps={{ disabled: selectedEligibleStudentIds.length === 0 }} + width={760} + > +
+ + +
}} + pagination={{ pageSize: 6, showSizeChanger: false }} + rowSelection={{ + selectedRowKeys: selectedEligibleStudentIds, + onChange: (keys) => setSelectedEligibleStudentIds(keys as number[]), + }} + /> + {/* Create Modal */} { options={studentOptions} /> - + - + @@ -352,7 +574,7 @@ const DepositsPage: React.FC = () => {
当前可用押金: ¥{Number(refundModal?.amount || 0).toFixed(2)}
- + @@ -399,10 +621,10 @@ const DepositsPage: React.FC = () => { 添加分期 - {detailModal.installments?.length > 0 ? ( + {detailModal.installments && detailModal.installments.length > 0 ? ( ( + renderItem={(item) => ( { ), handleDeleteInstallment(item.id)} > }> - 归档 - - + 归档 + + , ].filter(Boolean)} > { okText="确认" > - + - +
- - ); }; diff --git a/apps/server/src/attendance/attendance.controller.ts b/apps/server/src/attendance/attendance.controller.ts index 339ac09..6f04a1e 100644 --- a/apps/server/src/attendance/attendance.controller.ts +++ b/apps/server/src/attendance/attendance.controller.ts @@ -236,8 +236,10 @@ export class AttendanceController { { header: '时段', key: 'session', width: 15 }, { header: '状态', key: 'status', width: 10 }, { header: '来源', key: 'source', width: 10 }, + { header: '打卡设备', key: 'punchDevice', width: 30 }, + { header: '打卡时间', key: 'punchTime', width: 20 }, { header: '备注', key: 'remark', width: 30 }, - { header: '打卡时间', key: 'createdAt', width: 20 }, + { header: '归档时间', key: 'createdAt', width: 20 }, ]; ws.getRow(1).font = { bold: true }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; @@ -250,6 +252,10 @@ export class AttendanceController { session: record.session || '', status: record.status || '', source: record.source || '', + punchDevice: record.punchDeviceName || record.punchDeviceId || '', + punchTime: record.punchTime + ? record.punchTime.toISOString().replace('T', ' ').substring(0, 19) + : '', remark: record.remark || '', createdAt: record.createdAt ? record.createdAt.toISOString().replace('T', ' ').substring(0, 19) diff --git a/apps/server/src/attendance/attendance.service.spec.ts b/apps/server/src/attendance/attendance.service.spec.ts index c0f9a58..2de9889 100644 --- a/apps/server/src/attendance/attendance.service.spec.ts +++ b/apps/server/src/attendance/attendance.service.spec.ts @@ -234,6 +234,106 @@ describe('AttendanceService — DingTalk raw query', () => { }); +describe('AttendanceService — attendance device display mappings', () => { + function createHistoryQueryBuilder(records: AttendanceRecord[]) { + return { + leftJoinAndSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([records, records.length]), + getMany: jest.fn().mockResolvedValue(records), + }; + } + + function createServiceWithRecords(records: AttendanceRecord[]) { + const qb = createHistoryQueryBuilder(records); + const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) }; + const attendanceDeviceRepo = { + find: jest.fn().mockImplementation(async (options: { where?: Record }) => { + if (options.where && 'deviceSn' in options.where) { + return [ + { + id: 1, + deviceSn: 'ATM-01', + deviceName: '东门考勤机', + classroomId: 8, + classroom: { id: 8, name: '一号教室' }, + status: 'disabled', + }, + ]; + } + return []; + }), + }; + const service = new AttendanceService( + attendanceRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + attendanceDeviceRepo as never, + {} as never, + ); + return { service, qb, attendanceDeviceRepo }; + } + + it('maps history list punch device ids to configured attendance device names', async () => { + const record = { + id: 1, + classId: 8, + status: 'present', + source: 'dingtalk', + punchSource: 'ATM', + punchDeviceId: 'ATM-01', + punchDeviceName: '钉钉原始设备名', + } as AttendanceRecord; + const { service, attendanceDeviceRepo } = createServiceWithRecords([record]); + + await expect(service.findAll({}, [8])).resolves.toMatchObject({ + list: [ + { + punchDeviceId: 'ATM-01', + punchDeviceName: '东门考勤机 · 一号教室', + }, + ], + total: 1, + }); + expect(attendanceDeviceRepo.find).toHaveBeenCalledWith({ + where: { deviceSn: expect.any(Object) }, + relations: ['classroom'], + }); + }); + + it('maps exported punch device ids to configured attendance device names', async () => { + const record = { + id: 2, + classId: 8, + status: 'present', + source: 'dingtalk', + punchSource: 'ATM', + punchDeviceId: 'ATM-01', + punchDeviceName: '钉钉原始设备名', + } as AttendanceRecord; + const { service } = createServiceWithRecords([record]); + + await expect(service.findAllForExport({}, [8])).resolves.toEqual([ + expect.objectContaining({ + punchDeviceId: 'ATM-01', + punchDeviceName: '东门考勤机 · 一号教室', + }), + ]); + }); +}); + + + // ── Session serialization tests ── function deferred(): { promise: Promise; diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index 98e5a00..f0c24bc 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -88,7 +88,7 @@ export class AttendanceService { const devicesBySn = new Map(); if (sns.length > 0) { const devices = await this.attendanceDeviceRepo.find({ - where: { deviceSn: In(sns), status: 'active' }, + where: { deviceSn: In(sns) }, relations: ['classroom'], }); for (const device of devices) devicesBySn.set(device.deviceSn, device); @@ -907,7 +907,7 @@ export class AttendanceService { qb.skip((page - 1) * pageSize).take(pageSize); const [list, total] = await qb.getManyAndCount(); - return { list, total, page, pageSize }; + return { list: await this.attachAttendanceDeviceMappings(list), total, page, pageSize }; } // ── Get distinct classes with attendance records ── @@ -1053,7 +1053,8 @@ export class AttendanceService { qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC'); - return qb.getMany(); + const records = await qb.getMany(); + return this.attachAttendanceDeviceMappings(records); } async findAttendanceRecord(id: number) { diff --git a/apps/server/src/deposits/deposits.controller.ts b/apps/server/src/deposits/deposits.controller.ts index 5944f11..71fa817 100644 --- a/apps/server/src/deposits/deposits.controller.ts +++ b/apps/server/src/deposits/deposits.controller.ts @@ -16,8 +16,8 @@ import { Repository } from 'typeorm'; import { Student } from '../entities/student.entity'; import { DepositsService } from './deposits.service'; import { NotificationsService } from '../notifications/notifications.service'; -import { NotificationType } from '../entities/notification.entity'; import { + BatchCreateDepositDto, CreateDepositDto, CreateDepositInstallmentDto, RefundDepositDto, @@ -44,6 +44,13 @@ export class DepositsController { return this.service.getStudentLookups(); } + + @Get('eligible-students') + @RequirePermission('deposit:view') + getEligibleStudents(@Query('roomType') roomType?: string) { + return this.service.getEligibleStudents(roomType || undefined); + } + @Get() @RequirePermission('deposit:view') findAll( @@ -99,6 +106,25 @@ export class DepositsController { return result; } + + @Post('batch') + @RequirePermission('deposit:create') + async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.batchCreate(dto, req.user?.id); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '押金管理', + action: '批量收取押金', + targetType: 'deposit', + detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`, + ipAddress, + userAgent, + }); + return result; + } + @Post(':id/installments') @RequirePermission('deposit:edit') async addInstallment( diff --git a/apps/server/src/deposits/deposits.module.ts b/apps/server/src/deposits/deposits.module.ts index 5c49211..161d950 100644 --- a/apps/server/src/deposits/deposits.module.ts +++ b/apps/server/src/deposits/deposits.module.ts @@ -3,13 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Student } from '../entities/student.entity'; import { Deposit } from '../entities/deposit.entity'; import { DepositInstallment } from '../entities/deposit-installment.entity'; +import { Occupancy } from '../entities/occupancy.entity'; import { DepositsService } from './deposits.service'; import { DepositsController } from './deposits.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule], + imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student, Occupancy]), OperationLogsModule, NotificationsModule], controllers: [DepositsController], providers: [DepositsService], exports: [DepositsService], diff --git a/apps/server/src/deposits/deposits.room-type.spec.ts b/apps/server/src/deposits/deposits.room-type.spec.ts new file mode 100644 index 0000000..bc13e57 --- /dev/null +++ b/apps/server/src/deposits/deposits.room-type.spec.ts @@ -0,0 +1,98 @@ +import { BadRequestException } from '@nestjs/common'; +import { DepositsService } from './deposits.service'; + +const createQb = (rows: unknown[] = []) => ({ + innerJoin: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue(rows), +}); + +function makeService(rows: unknown[] = []) { + const qb = createQb(rows); + const repo = { + findOne: jest.fn(), + create: jest.fn((value) => value), + save: jest.fn(async (value) => value), + }; + const studentRepo = { findOne: jest.fn(async ({ where }: any) => ({ id: where.id })) }; + const occupancyRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) }; + const service = new DepositsService(repo as never, {} as never, studentRepo as never, occupancyRepo as never); + return { service, qb, repo, studentRepo }; +} + +describe('DepositsService room-type deposits', () => { + it('filters current occupants by room type with capacity fallback', async () => { + const { service, qb } = makeService([ + { + studentId: 1, + studentName: '张三', + studentNo: 'S1', + roomId: 8, + roomNumber: '401', + building: 'A', + roomType: null, + capacity: 4, + depositAmount: null, + }, + ]); + + await expect(service.getEligibleStudents('四人间')).resolves.toEqual([ + { + studentId: 1, + studentName: '张三', + studentNo: 'S1', + roomId: 8, + roomNumber: '401', + building: 'A', + roomType: '四人间', + capacity: 4, + depositAmount: 0, + }, + ]); + expect(qb.where).toHaveBeenCalledWith('o.status = :activeStatus', { activeStatus: 'active' }); + expect(qb.andWhere).toHaveBeenCalledWith('o.checkOutDate IS NULL'); + expect(qb.andWhere).toHaveBeenCalledWith( + '(room.roomType = :roomType OR ((room.roomType IS NULL OR room.roomType = :emptyRoomType) AND room.capacity = :fallbackCapacity))', + { roomType: '四人间', emptyRoomType: '', fallbackCapacity: 4 }, + ); + }); + + it('creates or accumulates deposits for a batch of selected students', async () => { + const { service, repo } = makeService(); + const existing = { id: 1, studentId: 2, amount: 50, status: 'paid' }; + repo.findOne.mockImplementation(async ({ where }: any) => { + if (where.id) return existing; + if (where.studentId === 2) return existing; + return null; + }); + + const result = await service.batchCreate({ + studentIds: [2, 3, 3], + amount: 100, + paidDate: '2026-07-17', + notes: '四人间押金', + }, 9); + + expect(result.count).toBe(2); + expect(existing.amount).toBe(150); + expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ studentId: 3, amount: 100 })); + expect(repo.save).toHaveBeenCalledTimes(2); + }); + + it('rejects invalid batch amounts', async () => { + const { service, repo } = makeService(); + + await expect(service.batchCreate({ + studentIds: [1], + amount: 0.004, + paidDate: '2026-07-17', + })).rejects.toBeInstanceOf(BadRequestException); + expect(repo.save).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index 8139901..2636ee4 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -4,11 +4,38 @@ import { Repository } from 'typeorm'; import { Deposit } from '../entities/deposit.entity'; import { Student } from '../entities/student.entity'; import { DepositInstallment } from '../entities/deposit-installment.entity'; +import { Occupancy } from '../entities/occupancy.entity'; -import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; +import { BatchCreateDepositDto, CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2)); +const capacityRoomTypeText: Record = { + 1: '单人间', + 2: '二人间', + 3: '三人间', + 4: '四人间', + 5: '五人间', + 6: '六人间', + 8: '八人间', +}; + +const normalizeRoomType = (roomType?: string | null, capacity?: number | string | null) => { + const trimmed = roomType?.trim(); + if (trimmed) return trimmed; + const normalizedCapacity = Number(capacity); + return capacityRoomTypeText[normalizedCapacity] || (normalizedCapacity > 0 ? `${normalizedCapacity}人间` : ''); +}; + +const roomTypeCapacity = (roomType?: string) => { + const text = roomType?.trim(); + if (!text) return undefined; + const knownCapacity = Object.entries(capacityRoomTypeText).find(([, label]) => label === text); + if (knownCapacity) return Number(knownCapacity[0]); + const match = text.match(/^(\d+)人间$/); + return match ? Number(match[1]) : undefined; +}; + @Injectable() export class DepositsService { @@ -18,6 +45,8 @@ export class DepositsService { private installmentRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, + @InjectRepository(Occupancy) + private occupancyRepo?: Repository, ) {} async getStudentLookups() { @@ -28,6 +57,78 @@ export class DepositsService { }); } + async getEligibleStudents(roomType?: string) { + const trimmedRoomType = roomType?.trim(); + const fallbackCapacity = roomTypeCapacity(trimmedRoomType); + const qb = this.occupancyRepo! + .createQueryBuilder('o') + .innerJoin('o.student', 'student') + .innerJoin('o.room', 'room') + .leftJoin(Deposit, 'deposit', 'deposit.student_id = student.id AND deposit.status != :archived', { + archived: 'archived', + }) + .select('student.id', 'studentId') + .addSelect('student.name', 'studentName') + .addSelect('student.studentNo', 'studentNo') + .addSelect('room.id', 'roomId') + .addSelect('room.roomNumber', 'roomNumber') + .addSelect('room.building', 'building') + .addSelect('room.roomType', 'roomType') + .addSelect('room.capacity', 'capacity') + .addSelect('deposit.amount', 'depositAmount') + .where('o.status = :activeStatus', { activeStatus: 'active' }) + .andWhere('o.checkOutDate IS NULL') + .andWhere('student.status = :studentStatus', { studentStatus: 'active' }) + .orderBy('room.building', 'ASC') + .addOrderBy('room.roomNumber', 'ASC') + .addOrderBy('student.name', 'ASC'); + + if (trimmedRoomType) { + if (fallbackCapacity) { + qb.andWhere( + '(room.roomType = :roomType OR ((room.roomType IS NULL OR room.roomType = :emptyRoomType) AND room.capacity = :fallbackCapacity))', + { roomType: trimmedRoomType, emptyRoomType: '', fallbackCapacity }, + ); + } else { + qb.andWhere('room.roomType = :roomType', { roomType: trimmedRoomType }); + } + } + + const rows = await qb.getRawMany(); + return rows.map((row) => ({ + studentId: Number(row.studentId), + studentName: row.studentName, + studentNo: row.studentNo ?? null, + roomId: Number(row.roomId), + roomNumber: row.roomNumber, + building: row.building ?? null, + roomType: normalizeRoomType(row.roomType, row.capacity), + capacity: Number(row.capacity), + depositAmount: money(row.depositAmount), + })); + } + + async batchCreate(dto: BatchCreateDepositDto, userId?: number) { + const studentIds = [...new Set(dto.studentIds)]; + if (studentIds.length === 0) throw new BadRequestException('请选择学生'); + const amount = money(dto.amount); + if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) { + throw new BadRequestException('收取金额最多保留两位小数'); + } + if (amount <= 0) throw new BadRequestException('收取金额必须大于0'); + + const results: Deposit[] = []; + for (const studentId of studentIds) { + results.push(await this.create({ + studentId, + amount, + paidDate: dto.paidDate, + notes: dto.notes, + }, userId)); + } + return { count: results.length, amount, results }; + } + async findAll(query?: { studentId?: number; status?: string }) { const qb = this.repo .createQueryBuilder('d') diff --git a/apps/server/src/deposits/dto/deposit.dto.ts b/apps/server/src/deposits/dto/deposit.dto.ts index 0728ab2..02a8ab0 100644 --- a/apps/server/src/deposits/dto/deposit.dto.ts +++ b/apps/server/src/deposits/dto/deposit.dto.ts @@ -1,4 +1,4 @@ -import { IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator'; +import { ArrayNotEmpty, IsArray, IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator'; export class CreateDepositDto { @IsInt() @@ -16,6 +16,28 @@ export class CreateDepositDto { notes?: string; } +export class BatchCreateDepositDto { + @IsArray() + @ArrayNotEmpty() + @IsInt({ each: true }) + studentIds: number[]; + + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) + amount: number; + + @IsDateString() + paidDate: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + roomType?: string; +} + export class RefundDepositDto { @IsDateString() refundDate: string;