From ce5fd1c6cbd48b04350ca454e2e53b2c48795542 Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 14 Jul 2026 14:24:19 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=B4=A6=E5=8D=95=E7=8A=B6=E6=80=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/bills/bills.controller.ts | 95 ++++++++++++----------- apps/server/src/bills/dto/bill.dto.ts | 11 ++- 2 files changed, 60 insertions(+), 46 deletions(-) diff --git a/apps/server/src/bills/bills.controller.ts b/apps/server/src/bills/bills.controller.ts index fe69d24..d057a65 100644 --- a/apps/server/src/bills/bills.controller.ts +++ b/apps/server/src/bills/bills.controller.ts @@ -7,6 +7,7 @@ import { Param, Body, Query, + ParseIntPipe, UseGuards, Request, Res, @@ -20,7 +21,11 @@ import { NotificationType } from '../entities/notification.entity'; import { Student } from '../entities/student.entity'; import { Bill } from '../entities/bill.entity'; 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 { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; @@ -88,47 +93,15 @@ export class BillsController { @Get(':id') @RequirePermission('bill:view') - findOne(@Param('id') id: string) { - 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; + findOne(@Param('id', ParseIntPipe) id: number) { + return this.service.findOne(id); } + // 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') @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 result = await this.service.batchUpdateStatus(body.ids, body.status); await this.logService.log({ @@ -158,17 +131,51 @@ export class BillsController { 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') @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 result = await this.service.remove(+id); + const result = await this.service.remove(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账单管理', action: '删除账单', - targetId: +id, + targetId: id, targetType: 'bill', ipAddress, userAgent, @@ -226,18 +233,18 @@ export class BillsController { @Get('export/pdf/:id') @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); await this.logService.log({ userId: req?.user?.id, username: req?.user?.username, module: '账单管理', action: '导出账单', - targetId: +id, + targetId: id, targetType: 'bill', ipAddress, userAgent, }); - return this.exportService.exportStudentPdf(+id, res); + return this.exportService.exportStudentPdf(id, res); } } diff --git a/apps/server/src/bills/dto/bill.dto.ts b/apps/server/src/bills/dto/bill.dto.ts index b25f783..20016b5 100644 --- a/apps/server/src/bills/dto/bill.dto.ts +++ b/apps/server/src/bills/dto/bill.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional } from 'class-validator'; +import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsString } from 'class-validator'; export class GenerateBillsDto { @IsString() @@ -9,6 +9,13 @@ export class GenerateBillsDto { } export class UpdateBillStatusDto { - @IsString() + @IsIn(['draft', 'confirmed', 'paid']) status: 'draft' | 'confirmed' | 'paid'; } + +export class BatchUpdateBillStatusDto extends UpdateBillStatusDto { + @IsArray() + @ArrayNotEmpty() + @IsInt({ each: true }) + ids: number[]; +} From eac336a54a56071dc62c60faa95fc3f0cd76894f Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 14 Jul 2026 14:53:07 +0800 Subject: [PATCH 2/3] fix: include contained room expenses in bill generation --- apps/server/src/bills/bills.service.spec.ts | 37 +++++++++++++++++++++ apps/server/src/bills/bills.service.ts | 4 +-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/server/src/bills/bills.service.spec.ts b/apps/server/src/bills/bills.service.spec.ts index 7b926df..fea9a6b 100644 --- a/apps/server/src/bills/bills.service.spec.ts +++ b/apps/server/src/bills/bills.service.spec.ts @@ -166,6 +166,43 @@ describe('BillsService — generateBills', () => { ).toBeCloseTo(300, 0); }); + it('includes room expenses whose periods are inside the generated bill period', async () => { + const qb = mockQueryBuilder([ + { + 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([ + { + 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([]), + ); + + 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]>; + expect(Number(savedCalls[0][0].sharedAmount)).toBeCloseTo(300, 0); + }); + it('mixed → long-term get individual bills, short-term share expenses', async () => { // Room 1: two expenses (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts index 908c6a9..8a977b5 100644 --- a/apps/server/src/bills/bills.service.ts +++ b/apps/server/src/bills/bills.service.ts @@ -50,10 +50,10 @@ export class BillsService { .execute(); } - // 获取所有有费用的宿舍 + // 获取账单周期内所有有费用的宿舍 const roomExpenses = await this.roomExpRepo .createQueryBuilder('e') - .where('e.periodStart = :periodStart AND e.periodEnd = :periodEnd', { + .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { periodStart, periodEnd, }) From 598b4e8acd699ec39753b91596173b893835c68c Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 14 Jul 2026 16:38:38 +0800 Subject: [PATCH 3/3] feat: link deposits to bill payment --- apps/admin/src/pages/Bills/index.tsx | 149 +++++++++--------- apps/admin/src/pages/Deposits/index.tsx | 54 ++----- apps/server/src/bills/bills-export.service.ts | 33 ++-- apps/server/src/bills/bills.controller.ts | 8 +- apps/server/src/bills/bills.service.ts | 139 +++++++++++----- apps/server/src/bills/dto/bill.dto.ts | 16 +- .../src/deposits/deposits.controller.ts | 4 +- apps/server/src/deposits/deposits.service.ts | 44 ++++-- apps/server/src/deposits/dto/deposit.dto.ts | 8 - apps/server/src/entities/bill.entity.ts | 3 + apps/server/src/entities/deposit.entity.ts | 6 +- .../src/occupancies/occupancies.service.ts | 26 ++- 12 files changed, 272 insertions(+), 218 deletions(-) diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index bccffea..0a45510 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -26,11 +26,8 @@ import PermissionButton from '../../components/PermissionButton'; import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; -const { RangePicker } = DatePicker; - const statusMap: Record = { draft: { text: '草稿', color: 'default' }, - confirmed: { text: '已确认', color: 'blue' }, paid: { text: '已支付', color: 'green' }, }; @@ -61,6 +58,7 @@ const buildBillPrintHtml = (bill: any) => { ? dayjs(bill.generatedAt).format('YYYY-MM-DD HH:mm') : dayjs().format('YYYY-MM-DD HH:mm'); const hasDeposit = Number(bill.availableDeposit || 0) > 0; + const depositDeducted = Number(bill.depositDeductedAmount || 0); const items = bill.items || []; return ` @@ -93,8 +91,7 @@ const buildBillPrintHtml = (bill: any) => { .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; } + .deposit-deducted { color: #fa8c16; font-size: 11px; } table { width: 100%; border-collapse: collapse; table-layout: fixed; margin-top: 8px; } th, td { padding: 5px 6px; border-bottom: 1px solid #ccc; font-size: 9px; line-height: 1.45; text-align: left; vertical-align: top; word-break: break-word; } th { color: #333; font-weight: 700; } @@ -144,10 +141,9 @@ const buildBillPrintHtml = (bill: any) => {
个人费用: ${escapeHtml(money(bill.personalAmount))}
应付总额: ${escapeHtml(money(bill.totalAmount))}
${ - hasDeposit + hasDeposit || depositDeducted > 0 ? `
可用押金: ${escapeHtml(money(bill.availableDeposit))}
-
押金抵扣: -${escapeHtml(money(bill.depositApplied))}
-
抵扣后应付: ${escapeHtml(money(bill.amountAfterDeposit ?? bill.totalAmount))}
` + ${depositDeducted > 0 ? `
已扣押金: -${escapeHtml(money(depositDeducted))}
` : ''}` : '' } @@ -243,6 +239,16 @@ const BillsPage: React.FC = () => { }); }, [bills, searchText, filterStatus]); + const selectedBillRows = useMemo( + () => bills.filter((bill: any) => selectedRows.includes(bill.id)), + [bills, selectedRows], + ); + const canBatchConfirm = + selectedBillRows.length > 0 + && selectedBillRows.every((bill: any) => bill.status === 'draft' && bill.depositSufficient); + const canBatchDelete = + selectedBillRows.length > 0 && selectedBillRows.every((bill: any) => bill.status !== 'paid'); + const handleGenerate = async () => { if (saving) return; @@ -250,8 +256,7 @@ const BillsPage: React.FC = () => { const values = await generateForm.validateFields(); setSaving(true); const res: any = await api.post('/bills/generate', { - periodStart: values.period[0].format('YYYY-MM-DD'), - periodEnd: values.period[1].format('YYYY-MM-DD'), + billingMonth: values.billingMonth.format('YYYY-MM'), }); message.success(res.message || '生成成功'); setGenerateModal(false); @@ -282,10 +287,10 @@ const BillsPage: React.FC = () => { const updateStatus = async (id: number, status: string) => { try { await api.put(`/bills/${id}/status`, { status }); - message.success('状态更新成功'); + message.success('账单已确认支付,押金已自动扣除'); fetchData(); if (detailModal?.id === id) { - setDetailModal({ ...detailModal, status }); + void showDetail(id); } } catch (e: any) { message.error(e?.message || '操作失败'); @@ -298,7 +303,7 @@ const BillsPage: React.FC = () => { setBatchLoading(true); try { await api.put('/bills/batch/status', { ids: selectedRows, status }); - message.success(`已批量更新 ${selectedRows.length} 条账单`); + message.success(`已确认支付 ${selectedRows.length} 条账单,并自动扣除押金`); setSelectedRows([]); fetchData(); } catch (e: any) { @@ -396,20 +401,15 @@ const BillsPage: React.FC = () => { ), }, { - title: '抵扣后应付', - dataIndex: 'amountAfterDeposit', - width: 130, - render: (v: number, r: any) => { - const has = Number(r.availableDeposit || 0) > 0; - if (!has) return -; - const after = Number(v ?? r.totalAmount).toFixed(2); - const applied = Number(r.depositApplied || 0).toFixed(2); - return ( - - ¥{after} - - ); - }, + title: '已扣押金', + dataIndex: 'depositDeductedAmount', + width: 120, + render: (v: number) => + Number(v || 0) > 0 ? ( + ¥{Number(v).toFixed(2)} + ) : ( + - + ), }, { title: '状态', @@ -437,23 +437,23 @@ const BillsPage: React.FC = () => { 详情 {record.status === 'draft' && ( - updateStatus(record.id, 'confirmed')} + - 确认 - - )} - {record.status === 'confirmed' && ( - updateStatus(record.id, 'paid')} - > - 标记已付 - + updateStatus(record.id, 'paid')} + > + 确认支付 + + )} { okText="删除" cancelText="取消" > - }> + } + > 删除 @@ -507,38 +513,30 @@ const BillsPage: React.FC = () => { onChange={(v) => setFilterStatus(v)} options={[ { value: 'draft', label: '草稿' }, - { value: 'confirmed', label: '已确认' }, { value: 'paid', label: '已支付' }, ]} /> - @@ -398,8 +372,8 @@ const DepositsPage: React.FC = () => { {detailModal && (
-

押金金额: ¥{Number(detailModal.amount).toFixed(2)}

-

缴纳日期: {detailModal.paidDate}

+

当前可用押金: ¥{Number(detailModal.amount).toFixed(2)}

+

最近收取日期: {detailModal.paidDate}

状态:{' '} diff --git a/apps/server/src/bills/bills-export.service.ts b/apps/server/src/bills/bills-export.service.ts index 7038f16..ba42661 100644 --- a/apps/server/src/bills/bills-export.service.ts +++ b/apps/server/src/bills/bills-export.service.ts @@ -34,7 +34,7 @@ export class BillsExportService { if (query.status) qb.andWhere('b.status = :status', { status: query.status }); const bills = await qb.getMany(); - // 查询涉及学生的"已缴未退"押金,用于导出押金抵扣字段 + // 查询涉及学生当前押金余额。账单生成时不冻结押金,导出只展示实时余额和已实际扣款。 const studentIds = Array.from(new Set(bills.map((b) => b.studentId))); const depMap = new Map(); if (studentIds.length > 0) { @@ -61,8 +61,7 @@ export class BillsExportService { { header: '个人费用', key: 'personal', width: 12 }, { header: '总金额', key: 'total', width: 12 }, { header: '可用押金', key: 'deposit', width: 12 }, - { header: '押金抵扣', key: 'depositApplied', width: 12 }, - { header: '抵扣后应付', key: 'afterDeposit', width: 14 }, + { header: '已扣押金', key: 'depositDeducted', width: 12 }, { header: '状态', key: 'status', width: 10 }, { header: '生成时间', key: 'generatedAt', width: 20 }, ]; @@ -72,14 +71,11 @@ export class BillsExportService { const statusMap: Record = { draft: '草稿', - confirmed: '已确认', paid: '已结清', }; for (const bill of bills) { const total = Number(bill.totalAmount || 0); const dep = Number((depMap.get(bill.studentId) || 0).toFixed(2)); - const applied = Number(Math.min(dep, total).toFixed(2)); - const after = Number(Math.max(0, total - applied).toFixed(2)); ws.addRow({ id: bill.id, studentName: (bill as any).student?.name || '-', @@ -88,8 +84,7 @@ export class BillsExportService { personal: Number(bill.personalAmount), total, deposit: dep, - depositApplied: applied, - afterDeposit: after, + depositDeducted: Number(bill.depositDeductedAmount || 0), status: statusMap[bill.status] || bill.status, generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '', }); @@ -147,7 +142,7 @@ export class BillsExportService { return; } - // 查询该学生的可用押金(已缴未退) + // 查询该学生的当前可用押金。草稿账单只展示余额,不预生成抵扣金额。 const deposits = await this.depositRepo .createQueryBuilder('d') .where('d.studentId = :sid', { sid: bill.studentId }) @@ -155,8 +150,7 @@ export class BillsExportService { .getMany(); const availableDeposit = deposits.reduce((s, d) => s + Number(d.amount || 0), 0); const totalAmount = Number(bill.totalAmount || 0); - const depositApplied = Math.min(availableDeposit, totalAmount); - const amountAfterDeposit = Math.max(0, totalAmount - depositApplied); + const depositDeducted = Number(bill.depositDeductedAmount || 0); const doc = new PDFDocument({ size: 'A4', margin: 50 }); res.setHeader('Content-Type', 'application/pdf'); @@ -192,7 +186,6 @@ export class BillsExportService { const statusMap: Record = { draft: '草稿', - confirmed: '已确认', paid: '已结清', }; @@ -223,19 +216,17 @@ export class BillsExportService { .fillColor('#007AFF') .text(`应付总额: ¥${totalAmount.toFixed(2)}`); doc.moveDown(0.3); - if (availableDeposit > 0) { + if (availableDeposit > 0 || depositDeducted > 0) { doc .fontSize(11) .fillColor('#52C41A') .text(`可用押金: ¥${availableDeposit.toFixed(2)}`); - doc - .fontSize(11) - .fillColor('#FA8C16') - .text(`押金抵扣: -¥${depositApplied.toFixed(2)}`); - doc - .fontSize(14) - .fillColor('#FF3B30') - .text(`抵扣后应付: ¥${amountAfterDeposit.toFixed(2)}`); + if (depositDeducted > 0) { + doc + .fontSize(11) + .fillColor('#FA8C16') + .text(`已扣押金: -¥${depositDeducted.toFixed(2)}`); + } } doc.moveDown(1); diff --git a/apps/server/src/bills/bills.controller.ts b/apps/server/src/bills/bills.controller.ts index d057a65..b80db02 100644 --- a/apps/server/src/bills/bills.controller.ts +++ b/apps/server/src/bills/bills.controller.ts @@ -54,7 +54,7 @@ export class BillsController { username: req.user?.username, module: '账单管理', action: '生成账单', - detail: `周期 ${dto.periodStart}~${dto.periodEnd}, 生成 ${result.count} 条`, + detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count} 条`, ipAddress, userAgent, }); @@ -67,7 +67,7 @@ export class BillsController { recipientIds: [student.userId], type: NotificationType.BILL_GENERATED, title: '新账单', - content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${dto.periodStart}~${dto.periodEnd}`, + content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${result.periodStart}~${result.periodEnd}`, }); } } @@ -108,7 +108,7 @@ export class BillsController { userId: req.user?.id, username: req.user?.username, module: '账单管理', - action: '确认账单', + action: '确认账单并扣押金', detail: `IDs: ${body.ids.join(',')}`, ipAddress, userAgent, @@ -144,7 +144,7 @@ export class BillsController { userId: req.user?.id, username: req.user?.username, module: '账单管理', - action: '确认账单', + action: '确认账单并扣押金', targetId: id, targetType: 'bill', ipAddress, diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts index 8a977b5..977224b 100644 --- a/apps/server/src/bills/bills.service.ts +++ b/apps/server/src/bills/bills.service.ts @@ -1,6 +1,6 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In, DataSource } from 'typeorm'; +import { Repository, In, DataSource, EntityManager } from 'typeorm'; import { Bill } from '../entities/bill.entity'; import { BillItem } from '../entities/bill-item.entity'; import { RoomExpense } from '../entities/room-expense.entity'; @@ -28,26 +28,15 @@ export class BillsService { * 核心计费引擎:按"人天数"加权分摊 */ async generateBills(dto: GenerateBillsDto) { - const { periodStart, periodEnd } = dto; + const { periodStart, periodEnd } = this.resolveBillingPeriod(dto.billingMonth); const pStart = new Date(periodStart); const pEnd = new Date(periodEnd); - // 删除该周期已有的草稿账单 - const existingDrafts = await this.billRepo.find({ - where: { periodStart, periodEnd, status: 'draft' }, + const existingBills = await this.billRepo.find({ + where: { periodStart, periodEnd }, }); - if (existingDrafts.length > 0) { - const draftIds = existingDrafts.map((b) => b.id); - await this.itemRepo - .createQueryBuilder() - .delete() - .where('billId IN (:...ids)', { ids: draftIds }) - .execute(); - await this.billRepo - .createQueryBuilder() - .delete() - .where('id IN (:...ids)', { ids: draftIds }) - .execute(); + if (existingBills.length > 0) { + throw new BadRequestException(`${dto.billingMonth} 月账单已生成,不能重复生成`); } // 获取账单周期内所有有费用的宿舍 @@ -208,7 +197,41 @@ export class BillsService { bills.push(savedBill); } - return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills }; + return { + message: `成功生成 ${dto.billingMonth} 月 ${bills.length} 条账单`, + count: bills.length, + periodStart, + periodEnd, + bills, + }; + } + + private resolveBillingPeriod(billingMonth: string) { + const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || ''); + if (!matched) { + throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM'); + } + + const year = Number(matched[1]); + const month = Number(matched[2]); + if (month < 1 || month > 12) { + throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM'); + } + + const targetMonthStart = new Date(year, month - 1, 1); + const currentMonthStart = new Date(); + currentMonthStart.setDate(1); + currentMonthStart.setHours(0, 0, 0, 0); + if (targetMonthStart >= currentMonthStart) { + throw new BadRequestException('只能生成已结束月份的账单'); + } + + const targetMonthEnd = new Date(year, month, 0); + const pad = (value: number) => String(value).padStart(2, '0'); + return { + periodStart: `${year}-${pad(month)}-01`, + periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}`, + }; } async findAll(query?: { @@ -242,9 +265,9 @@ export class BillsService { /** * 给账单挂上"押金联动"信息: - * - availableDeposit: 当前学生处于已缴未退状态(paid)的押金总额 - * - depositApplied: 本张账单可从押金抵扣的金额(min(押金, 应付总额)) - * - amountAfterDeposit: 抵扣押金后学生需另外支付的金额 + * - availableDeposit: 学生当前实时可用押金余额,生成账单时不会冻结 + * - depositSufficient: 草稿账单是否已有足够余额可确认 + * - depositDeductedAmount: 已确认账单实际扣除的押金金额 */ private async attachDepositInfo(bills: Bill[]): Promise { if (!bills || bills.length === 0) return bills; @@ -253,7 +276,6 @@ export class BillsService { const deposits = await this.depositRepo .createQueryBuilder('d') .where('d.studentId IN (:...ids)', { ids: studentIds }) - .andWhere('d.status = :status', { status: 'paid' }) .getMany(); const depMap = new Map(); for (const d of deposits) { @@ -262,42 +284,83 @@ export class BillsService { return bills.map((b) => { const total = Number(b.totalAmount || 0); const available = Number((depMap.get(b.studentId) || 0).toFixed(2)); - const applied = Number(Math.min(available, total).toFixed(2)); - const afterDeposit = Number(Math.max(0, total - applied).toFixed(2)); return Object.assign({}, b, { availableDeposit: available, - depositApplied: applied, - amountAfterDeposit: afterDeposit, + depositSufficient: available >= total, + depositDeductedAmount: Number(b.depositDeductedAmount || 0), }); }); } async updateStatus(id: number, dto: UpdateBillStatusDto) { - const bill = await this.billRepo.findOne({ where: { id } }); - if (!bill) throw new NotFoundException('账单不存在'); - bill.status = dto.status; - return this.billRepo.save(bill); + if (dto.status !== 'paid') { + throw new BadRequestException('账单只能通过确认支付完成扣款'); + } + return this.dataSource.transaction((manager) => this.payBill(manager, id)); } async batchUpdateStatus(ids: number[], status: string) { - await this.billRepo - .createQueryBuilder() - .update() - .set({ status }) - .where('id IN (:...ids)', { ids }) - .execute(); - return { message: `成功更新 ${ids.length} 条账单状态` }; + if (status !== 'paid') { + throw new BadRequestException('账单只能通过确认支付完成扣款'); + } + const uniqueIds = Array.from(new Set(ids)); + await this.dataSource.transaction(async (manager) => { + for (const id of uniqueIds) await this.payBill(manager, id); + }); + return { message: `成功确认 ${uniqueIds.length} 条账单并扣除押金` }; + } + + private async payBill(manager: EntityManager, id: number) { + const billRepo = manager.getRepository(Bill); + const depositRepo = manager.getRepository(Deposit); + const lock = this.supportsPessimisticLocks() + ? ({ mode: 'pessimistic_write' } as const) + : undefined; + const bill = await billRepo.findOne({ where: { id }, ...(lock ? { lock } : {}) }); + if (!bill) throw new NotFoundException(`账单 ${id} 不存在`); + if (bill.status === 'paid') return bill; + if (bill.status !== 'draft') throw new BadRequestException(`账单 ${id} 当前状态无法确认支付`); + + const deposit = await depositRepo.findOne({ + where: { studentId: bill.studentId }, + ...(lock ? { lock } : {}), + }); + const available = Number(deposit?.amount || 0); + const required = Number(bill.totalAmount || 0); + if (!deposit || available < required) { + throw new BadRequestException( + `账单 ${id} 押金不足:需 ¥${required.toFixed(2)},当前可用 ¥${available.toFixed(2)},请先到押金管理收取押金`, + ); + } + + deposit.amount = Number((available - required).toFixed(2)); + deposit.status = deposit.amount > 0 ? 'paid' : 'depleted'; + bill.depositDeductedAmount = required; + bill.status = 'paid'; + await depositRepo.save(deposit); + return billRepo.save(bill); + } + + private supportsPessimisticLocks() { + return ['mysql', 'mariadb', 'postgres', 'cockroachdb', 'mssql', 'oracle'].includes( + String(this.dataSource.options.type), + ); } async remove(id: number) { const exists = await this.billRepo.findOne({ where: { id } }); if (!exists) throw new NotFoundException('账单不存在'); + if (exists.status === 'paid') throw new BadRequestException('已支付账单不能删除'); await this.itemRepo.delete({ billId: id }); await this.billRepo.delete(id); return { message: '账单已删除' }; } async batchRemove(ids: number[]) { + const bills = await this.billRepo.find({ where: { id: In(ids) } }); + if (bills.some((bill) => bill.status === 'paid')) { + throw new BadRequestException('已支付账单不能删除'); + } await this.itemRepo .createQueryBuilder() .delete() diff --git a/apps/server/src/bills/dto/bill.dto.ts b/apps/server/src/bills/dto/bill.dto.ts index 20016b5..21a1755 100644 --- a/apps/server/src/bills/dto/bill.dto.ts +++ b/apps/server/src/bills/dto/bill.dto.ts @@ -1,16 +1,22 @@ -import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsString } from 'class-validator'; +import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsOptional, IsString, Matches } from 'class-validator'; export class GenerateBillsDto { @IsString() - periodStart: string; // YYYY-MM-DD + @Matches(/^\d{4}-\d{2}$/) + billingMonth: string; // YYYY-MM + @IsOptional() @IsString() - periodEnd: string; // YYYY-MM-DD + periodStart?: string; // deprecated, derived from billingMonth + + @IsOptional() + @IsString() + periodEnd?: string; // deprecated, derived from billingMonth } export class UpdateBillStatusDto { - @IsIn(['draft', 'confirmed', 'paid']) - status: 'draft' | 'confirmed' | 'paid'; + @IsIn(['paid']) + status: 'paid'; } export class BatchUpdateBillStatusDto extends UpdateBillStatusDto { diff --git a/apps/server/src/deposits/deposits.controller.ts b/apps/server/src/deposits/deposits.controller.ts index 78657bd..bc347dd 100644 --- a/apps/server/src/deposits/deposits.controller.ts +++ b/apps/server/src/deposits/deposits.controller.ts @@ -170,7 +170,7 @@ export class DepositsController { action: '退还押金', targetId: +id, targetType: 'deposit', - detail: `退还¥${result.refundAmount}, 扣除¥${result.deductionAmount}`, + detail: `退还全部可用押金 ¥${result.refundAmount}`, ipAddress, userAgent, }); @@ -182,7 +182,7 @@ export class DepositsController { recipientIds: [student.userId], type: 'deposit_refunded', title: '押金已退还', - content: `您的押金已退还,退还¥${result.refundAmount},扣除¥${result.deductionAmount}`, + content: `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`, }); } } catch (_) { /* don't block response */ } diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index b6597f9..6b33eba 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -46,16 +46,28 @@ export class DepositsService { async create(dto: CreateDepositDto, userId?: number) { const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); if (!student) throw new NotFoundException('学生不存在'); - const deposit = this.repo.create({ - studentId: dto.studentId, - amount: dto.amount, - paidDate: dto.paidDate, - notes: dto.notes, - status: 'paid', - recordedBy: userId, - }); + if (Number(dto.amount) <= 0) throw new BadRequestException('收取金额必须大于0'); - return this.repo.save(deposit); + const existing = await this.repo.findOne({ where: { studentId: dto.studentId } }); + if (existing) { + existing.amount = Number((Number(existing.amount || 0) + Number(dto.amount)).toFixed(2)); + existing.paidDate = dto.paidDate; + existing.status = 'paid'; + existing.recordedBy = userId ?? null; + if (dto.notes) existing.notes = dto.notes; + return this.repo.save(existing); + } + + return this.repo.save( + this.repo.create({ + studentId: dto.studentId, + amount: dto.amount, + paidDate: dto.paidDate, + notes: dto.notes, + status: 'paid', + recordedBy: userId, + }), + ); } async addInstallment(depositId: number, amount: number, dueDate: string) { @@ -90,18 +102,16 @@ export class DepositsService { async refund(id: number, dto: RefundDepositDto, userId?: number) { const deposit = await this.repo.findOne({ where: { id } }); if (!deposit) throw new NotFoundException('押金记录不存在'); - if (deposit.status !== 'paid') throw new BadRequestException('该押金已处理'); + if (deposit.status !== 'paid' || Number(deposit.amount) <= 0) { + throw new BadRequestException('该学生当前没有可退押金'); + } - const deduction = dto.deductionAmount || 0; - const refundAmount = Number(deposit.amount) - deduction; - if (refundAmount < 0) throw new BadRequestException('扣除金额不能大于押金金额'); + const refundAmount = Number(deposit.amount); deposit.refundDate = dto.refundDate; - deposit.deductionAmount = deduction; - deposit.deductionReason = dto.deductionReason || ''; deposit.refundAmount = refundAmount; - deposit.status = - deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded'; + deposit.amount = 0; + deposit.status = 'refunded'; if (dto.notes) deposit.notes = dto.notes; deposit.refundedBy = userId ?? null; deposit.refundedAt = new Date(); diff --git a/apps/server/src/deposits/dto/deposit.dto.ts b/apps/server/src/deposits/dto/deposit.dto.ts index 74ea313..80b449c 100644 --- a/apps/server/src/deposits/dto/deposit.dto.ts +++ b/apps/server/src/deposits/dto/deposit.dto.ts @@ -19,14 +19,6 @@ export class RefundDepositDto { @IsString() refundDate: string; - @IsOptional() - @IsNumber() - deductionAmount?: number; - - @IsOptional() - @IsString() - deductionReason?: string; - @IsOptional() @IsString() notes?: string; diff --git a/apps/server/src/entities/bill.entity.ts b/apps/server/src/entities/bill.entity.ts index 761e765..56df3b5 100644 --- a/apps/server/src/entities/bill.entity.ts +++ b/apps/server/src/entities/bill.entity.ts @@ -33,6 +33,9 @@ export class Bill { @Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) totalAmount: number; + @Column({ name: 'deposit_deducted_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + depositDeductedAmount: number; + @Column({ type: 'varchar', length: 20, default: 'draft' }) status: string; diff --git a/apps/server/src/entities/deposit.entity.ts b/apps/server/src/entities/deposit.entity.ts index f657010..4e3d203 100644 --- a/apps/server/src/entities/deposit.entity.ts +++ b/apps/server/src/entities/deposit.entity.ts @@ -21,7 +21,7 @@ export class Deposit { @Column({ type: 'decimal', precision: 10, scale: 2, default: 500 }) amount: number; - // paid: 已缴 | refunded: 已退 | deducted: 已扣除(部分或全部) + // paid: 有可用余额 | refunded: 余额已全部退还 | depleted: 余额已被账单扣完 @Column({ type: 'varchar', length: 20, default: 'paid' }) status: string; @@ -43,8 +43,8 @@ export class Deposit { @Column({ type: 'text', nullable: true }) notes: string; - @Column({ name: 'recorded_by', nullable: true }) - recordedBy: number; + @Column({ name: 'recorded_by', type: 'integer', nullable: true }) + recordedBy: number | null; @Column({ name: 'refunded_by', type: 'integer', nullable: true }) refundedBy: number | null; diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index d9b5697..856d9b4 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -106,9 +106,18 @@ export class OccupanciesService { if (dto.collectDeposit) { const existingDeposit = await this.depositRepo.findOne({ - where: { studentId: dto.studentId, status: 'paid' }, + where: { studentId: dto.studentId }, }); - if (!existingDeposit) { + if (existingDeposit) { + existingDeposit.amount = Number( + (Number(existingDeposit.amount || 0) + Number(dto.depositAmount ?? 500)).toFixed(2), + ); + existingDeposit.status = 'paid'; + existingDeposit.paidDate = dto.checkInDate; + existingDeposit.recordedBy = userId ?? null; + existingDeposit.notes = '入住登记自动收取'; + await this.depositRepo.save(existingDeposit); + } else { await this.depositRepo.save( this.depositRepo.create({ studentId: dto.studentId, @@ -529,9 +538,18 @@ export class OccupanciesService { // 9. 自动收取押金(仅对新入住且非历史记录的学生) if (options?.autoDeposit && !row.checkOutDate?.trim()) { const existingDeposit = await this.depositRepo.findOne({ - where: { studentId: student.id, status: 'paid' }, + where: { studentId: student.id }, }); - if (!existingDeposit) { + if (existingDeposit) { + existingDeposit.amount = Number( + (Number(existingDeposit.amount || 0) + Number(options.depositAmount || 500)).toFixed(2), + ); + existingDeposit.status = 'paid'; + existingDeposit.paidDate = checkInDate; + existingDeposit.notes = '入住导入自动收取'; + await this.depositRepo.save(existingDeposit); + depositsCreated++; + } else { await this.depositRepo.save( this.depositRepo.create({ studentId: student.id,