From c86550f8946124c2b0e53e9603973f0bbf454904 Mon Sep 17 00:00:00 2001 From: xiong Date: Wed, 15 Jul 2026 09:03:20 +0800 Subject: [PATCH 1/2] feat: deduct personal expenses from deposit refunds --- apps/admin/src/pages/Deposits/index.tsx | 35 ++++++++++++- .../src/deposits/deposits.lookups.spec.ts | 2 +- apps/server/src/deposits/deposits.module.ts | 7 ++- .../src/deposits/deposits.refund.spec.ts | 19 +++++-- apps/server/src/deposits/deposits.service.ts | 52 +++++++++++++++++-- 5 files changed, 105 insertions(+), 10 deletions(-) diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 4ac6578..4ae504d 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -37,6 +37,8 @@ const isFormValidationError = (error: unknown) => && error !== null && Array.isArray((error as { errorFields?: unknown }).errorFields); +const moneyNumber = (value: unknown) => Number(Number(value || 0).toFixed(2)); + const DepositsPage: React.FC = () => { const [data, setData] = useState([]); const [students, setStudents] = useState([]); @@ -350,7 +352,38 @@ const DepositsPage: React.FC = () => { >
- 当前可用押金: ¥{Number(refundModal?.amount || 0).toFixed(2)} +
+ 当前可用押金:{' '} + ¥{moneyNumber(refundModal?.amount).toFixed(2)} +
+
+ 未出账个人附加费:{' '} + ¥{moneyNumber(refundModal?.personalExpenseAmount).toFixed(2)} +
+
+ 自动扣除:{' '} + + ¥ + {Math.min( + moneyNumber(refundModal?.amount), + moneyNumber(refundModal?.personalExpenseAmount), + ).toFixed(2)} + +
+
+ 实际退还:{' '} + + ¥ + {Math.max( + 0, + moneyNumber(refundModal?.amount) - + Math.min( + moneyNumber(refundModal?.amount), + moneyNumber(refundModal?.personalExpenseAmount), + ), + ).toFixed(2)} + +
diff --git a/apps/server/src/deposits/deposits.lookups.spec.ts b/apps/server/src/deposits/deposits.lookups.spec.ts index c6f2e7e..d770723 100644 --- a/apps/server/src/deposits/deposits.lookups.spec.ts +++ b/apps/server/src/deposits/deposits.lookups.spec.ts @@ -5,7 +5,7 @@ describe('DepositsService permission-scoped lookups', () => { const studentRepo = { find: jest.fn().mockResolvedValue([{ id: 2, name: '张三', studentNo: 'S2' }]), }; - const service = new DepositsService({} as never, {} as never, studentRepo as never); + const service = new DepositsService({} as never, {} as never, studentRepo as never, {} as never); await expect(service.getStudentLookups()).resolves.toEqual([ { id: 2, name: '张三', studentNo: 'S2' }, diff --git a/apps/server/src/deposits/deposits.module.ts b/apps/server/src/deposits/deposits.module.ts index 5c49211..730fe69 100644 --- a/apps/server/src/deposits/deposits.module.ts +++ b/apps/server/src/deposits/deposits.module.ts @@ -3,13 +3,18 @@ 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 { PersonalExpense } from '../entities/personal-expense.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, PersonalExpense]), + OperationLogsModule, + NotificationsModule, + ], controllers: [DepositsController], providers: [DepositsService], exports: [DepositsService], diff --git a/apps/server/src/deposits/deposits.refund.spec.ts b/apps/server/src/deposits/deposits.refund.spec.ts index 9677350..d8d7d1a 100644 --- a/apps/server/src/deposits/deposits.refund.spec.ts +++ b/apps/server/src/deposits/deposits.refund.spec.ts @@ -2,9 +2,10 @@ import { DepositsService } from './deposits.service'; import { Deposit } from '../entities/deposit.entity'; describe('DepositsService — direct refund', () => { - it('refunds the full available balance and stores audit fields', async () => { + it('deducts unbilled personal expenses before refunding the remaining balance', async () => { const deposit = { id: 1, + studentId: 10, amount: 500, status: 'paid', } as Deposit; @@ -12,7 +13,17 @@ describe('DepositsService — direct refund', () => { findOne: jest.fn().mockResolvedValue(deposit), save: jest.fn().mockImplementation(async (value: Deposit) => value), }; - const service = new DepositsService(repo as never, {} as never, {} as never); + const personalExpenseRepo = { + createQueryBuilder: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([{ studentId: 10, amount: '120' }]), + }), + }; + const service = new DepositsService(repo as never, {} as never, {} as never, personalExpenseRepo as never); const result = await service.refund( 1, @@ -23,7 +34,9 @@ describe('DepositsService — direct refund', () => { expect(result).toMatchObject({ refundDate: '2026-07-13', amount: 0, - refundAmount: 500, + refundAmount: 380, + deductionAmount: 120, + deductionReason: '自动扣除个人附加费用 ¥120.00', notes: '退还剩余押金', status: 'refunded', refundedBy: 42, diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index 6b33eba..0b34f41 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm'; import { Deposit } from '../entities/deposit.entity'; import { Student } from '../entities/student.entity'; import { DepositInstallment } from '../entities/deposit-installment.entity'; +import { PersonalExpense } from '../entities/personal-expense.entity'; import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; @@ -16,6 +17,8 @@ export class DepositsService { private installmentRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, + @InjectRepository(PersonalExpense) + private personalExpenseRepo: Repository, ) {} async getStudentLookups() { @@ -34,13 +37,15 @@ export class DepositsService { .orderBy('d.createdAt', 'DESC'); if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId }); if (query?.status) qb.andWhere('d.status = :status', { status: query.status }); - return qb.getMany(); + const deposits = await qb.getMany(); + return this.attachPersonalExpenseAmount(deposits); } async findOne(id: number) { const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] }); if (!deposit) throw new NotFoundException('押金记录不存在'); - return deposit; + const [withPersonalExpense] = await this.attachPersonalExpenseAmount([deposit]); + return withPersonalExpense; } async create(dto: CreateDepositDto, userId?: number) { @@ -106,12 +111,18 @@ export class DepositsService { throw new BadRequestException('该学生当前没有可退押金'); } - const refundAmount = Number(deposit.amount); + const depositAmount = Number(deposit.amount); + const personalExpenseAmount = await this.getPersonalExpenseAmount(deposit.studentId); + const deductionAmount = Number(Math.min(depositAmount, personalExpenseAmount).toFixed(2)); + const refundAmount = Number((depositAmount - deductionAmount).toFixed(2)); deposit.refundDate = dto.refundDate; deposit.refundAmount = refundAmount; + deposit.deductionAmount = deductionAmount; + deposit.deductionReason = + deductionAmount > 0 ? `自动扣除个人附加费用 ¥${deductionAmount.toFixed(2)}` : ''; deposit.amount = 0; - deposit.status = 'refunded'; + deposit.status = refundAmount > 0 ? 'refunded' : 'depleted'; if (dto.notes) deposit.notes = dto.notes; deposit.refundedBy = userId ?? null; deposit.refundedAt = new Date(); @@ -135,4 +146,37 @@ export class DepositsService { qb.groupBy('d.status'); return qb.getRawMany(); } + + private async attachPersonalExpenseAmount(deposits: Deposit[]) { + if (!deposits.length) return deposits; + const studentIds = Array.from(new Set(deposits.map((deposit) => deposit.studentId))); + const amountMap = await this.getPersonalExpenseAmountMap(studentIds); + return deposits.map((deposit) => + Object.assign({}, deposit, { + personalExpenseAmount: amountMap.get(deposit.studentId) || 0, + }), + ); + } + + private async getPersonalExpenseAmount(studentId: number) { + const amountMap = await this.getPersonalExpenseAmountMap([studentId]); + return amountMap.get(studentId) || 0; + } + + private async getPersonalExpenseAmountMap(studentIds: number[]) { + const amountMap = new Map(); + if (!studentIds.length) return amountMap; + const rows = await this.personalExpenseRepo + .createQueryBuilder('pe') + .select('pe.studentId', 'studentId') + .addSelect('SUM(pe.amount)', 'amount') + .where('pe.studentId IN (:...studentIds)', { studentIds }) + .andWhere('pe.billId IS NULL') + .groupBy('pe.studentId') + .getRawMany<{ studentId: number | string; amount: string | number | null }>(); + for (const row of rows) { + amountMap.set(Number(row.studentId), Number(Number(row.amount || 0).toFixed(2))); + } + return amountMap; + } } -- 2.49.1 From 6f23f8a9f1747d214ca757c329d31a378a38ea86 Mon Sep 17 00:00:00 2001 From: xiong Date: Wed, 15 Jul 2026 09:12:36 +0800 Subject: [PATCH 2/2] feat: allow repeated bill generation and editable deposit deductions --- apps/admin/src/pages/Bills/index.tsx | 3 +- apps/admin/src/pages/Deposits/index.tsx | 44 ++++--- apps/server/src/bills/bills.service.ts | 112 ++++++++++++------ .../src/deposits/deposits.refund.spec.ts | 18 +-- apps/server/src/deposits/deposits.service.ts | 12 +- apps/server/src/deposits/dto/deposit.dto.ts | 4 + 6 files changed, 109 insertions(+), 84 deletions(-) diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index a8ab646..54b6398 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -356,14 +356,13 @@ const BillsPage: React.FC = () => { name="billingMonth" label="账单月份" rules={[{ required: true, message: '请选择账单月份' }]} - extra="只能选择已结束月份,每个月只能生成一次账单" + extra="选择任意账单月份;重复生成时无变化不生成,有变化则生成差额账单" > !!current && !current.endOf('month').isBefore(dayjs(), 'day')} />
diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 4ae504d..8ab82b7 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -120,6 +120,7 @@ const DepositsPage: React.FC = () => { const values = await refundForm.validateFields(); await api.put(`/deposits/${refundModal.id}/refund`, { refundDate: values.refundDate.format('YYYY-MM-DD'), + deductionAmount: values.deductionAmount || 0, notes: values.notes, }); message.success('退还操作完成'); @@ -210,7 +211,13 @@ const DepositsPage: React.FC = () => { type="primary" onClick={() => { setRefundModal(record); - refundForm.setFieldsValue({ refundDate: dayjs() }); + refundForm.setFieldsValue({ + refundDate: dayjs(), + deductionAmount: Math.min( + moneyNumber(record.amount), + moneyNumber(record.personalExpenseAmount), + ), + }); }} > 退还 @@ -361,33 +368,24 @@ const DepositsPage: React.FC = () => { ¥{moneyNumber(refundModal?.personalExpenseAmount).toFixed(2)}
- 自动扣除:{' '} - - ¥ - {Math.min( - moneyNumber(refundModal?.amount), - moneyNumber(refundModal?.personalExpenseAmount), - ).toFixed(2)} - -
-
- 实际退还:{' '} - - ¥ - {Math.max( - 0, - moneyNumber(refundModal?.amount) - - Math.min( - moneyNumber(refundModal?.amount), - moneyNumber(refundModal?.personalExpenseAmount), - ), - ).toFixed(2)} - + 下方扣除金额会自动填入个人附加费,可手动调整
+ + + diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts index d0e1080..6792698 100644 --- a/apps/server/src/bills/bills.service.ts +++ b/apps/server/src/bills/bills.service.ts @@ -36,30 +36,8 @@ export class BillsService { const pEnd = new Date(periodEnd); const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } }); - if (existingBills.length > 0) { - throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`); - } - - const existingDrafts: Bill[] = []; - if (existingDrafts.length > 0) { - const draftIds = existingDrafts.map((b) => b.id); - await this.personalExpRepo - .createQueryBuilder() - .update() - .set({ billId: null }) - .where('billId IN (:...ids)', { ids: draftIds }) - .execute(); - await this.itemRepo - .createQueryBuilder() - .delete() - .where('billId IN (:...ids)', { ids: draftIds }) - .execute(); - await this.billRepo - .createQueryBuilder() - .delete() - .where('id IN (:...ids)', { ids: draftIds }) - .execute(); - } + const existingBatchBills = existingBills.filter((bill) => bill.source === 'batch'); + const existingBatchBillIds = existingBatchBills.map((bill) => bill.id); // 获取所有有费用的宿舍 const roomExpenses = await this.roomExpRepo @@ -169,7 +147,12 @@ export class BillsService { periodStart, periodEnd, }) - .andWhere('pe.billId IS NULL') + .andWhere( + existingBatchBillIds.length > 0 + ? '(pe.billId IS NULL OR pe.billId IN (:...existingBatchBillIds))' + : 'pe.billId IS NULL', + existingBatchBillIds.length > 0 ? { existingBatchBillIds } : {}, + ) .getMany(); const personalMap = new Map(); @@ -198,18 +181,58 @@ export class BillsService { const personal = personalMap.get(studentId) || 0; const total = Number((shared + personal).toFixed(2)); + const existingStudentBills = existingBatchBills.filter( + (bill) => bill.studentId === studentId && bill.status !== 'cancelled', + ); + const existingTotal = this.roundMoney( + existingStudentBills.reduce((sum, bill) => sum + Number(bill.totalAmount || 0), 0), + ); + const paidAmount = this.roundMoney( + existingStudentBills.reduce((sum, bill) => sum + Number(bill.paidAmount || 0), 0), + ); + + if (existingStudentBills.length > 0 && this.moneyEquals(existingTotal, total)) { + continue; + } + + const remainingTotal = this.roundMoney(Math.max(0, total - paidAmount)); + const ratio = total > 0 ? remainingTotal / total : 0; + const remainingShared = this.roundMoney(shared * ratio); + const remainingPersonal = this.roundMoney(remainingTotal - remainingShared); + const savedBill = await this.dataSource.transaction(async (manager) => { + const deletableBills = existingStudentBills.filter((bill) => Number(bill.paidAmount || 0) <= 0); + const fundedBills = existingStudentBills.filter((bill) => Number(bill.paidAmount || 0) > 0); + + if (deletableBills.length) { + const ids = deletableBills.map((bill) => bill.id); + await manager.update(PersonalExpense, { billId: In(ids) }, { billId: null }); + await manager.delete(BillItem, { billId: In(ids) }); + await manager.delete(Bill, { id: In(ids) }); + } + + for (const bill of fundedBills) { + const paid = this.roundMoney(Number(bill.paidAmount || 0)); + await manager.update(Bill, bill.id, { + totalAmount: paid, + outstandingAmount: 0, + status: 'paid', + }); + } + + if (remainingTotal <= 0) return null; + let bill = await manager.save( manager.create(Bill, { studentId, periodStart, periodEnd, - sharedAmount: Number(shared.toFixed(2)), - personalAmount: personal, - totalAmount: total, + sharedAmount: remainingShared, + personalAmount: remainingPersonal, + totalAmount: remainingTotal, source: 'batch', paidAmount: 0, - outstandingAmount: total, + outstandingAmount: remainingTotal, status: 'unpaid', }), ); @@ -218,7 +241,13 @@ export class BillsService { ...(personalItems.get(studentId) || []), ]; for (const item of items) { - await manager.save(manager.create(BillItem, { ...item, billId: bill.id })); + await manager.save( + manager.create(BillItem, { + ...item, + studentAmount: this.roundMoney(Number(item.studentAmount || 0) * ratio), + billId: bill.id, + }), + ); } const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId); if (includedPersonal.length) { @@ -232,10 +261,24 @@ export class BillsService { bill = await this.walletsService.debitBill(manager, bill); return bill; }); - bills.push(savedBill); + if (savedBill) bills.push(savedBill); } - return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd }; + return { + message: bills.length > 0 ? `成功生成 ${bills.length} 条差额账单` : '账单无变化,未生成新账单', + count: bills.length, + bills, + periodStart, + periodEnd, + }; + } + + private roundMoney(value: number) { + return Number(Number(value || 0).toFixed(2)); + } + + private moneyEquals(left: number, right: number) { + return this.roundMoney(left) === this.roundMoney(right); } private resolveBillingPeriod(billingMonth: string) { @@ -244,11 +287,6 @@ export class BillsService { 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())}` }; diff --git a/apps/server/src/deposits/deposits.refund.spec.ts b/apps/server/src/deposits/deposits.refund.spec.ts index d8d7d1a..444bb7d 100644 --- a/apps/server/src/deposits/deposits.refund.spec.ts +++ b/apps/server/src/deposits/deposits.refund.spec.ts @@ -2,7 +2,7 @@ import { DepositsService } from './deposits.service'; import { Deposit } from '../entities/deposit.entity'; describe('DepositsService — direct refund', () => { - it('deducts unbilled personal expenses before refunding the remaining balance', async () => { + it('deducts the submitted amount before refunding the remaining balance', async () => { const deposit = { id: 1, studentId: 10, @@ -13,21 +13,11 @@ describe('DepositsService — direct refund', () => { findOne: jest.fn().mockResolvedValue(deposit), save: jest.fn().mockImplementation(async (value: Deposit) => value), }; - const personalExpenseRepo = { - createQueryBuilder: jest.fn().mockReturnValue({ - select: jest.fn().mockReturnThis(), - addSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - groupBy: jest.fn().mockReturnThis(), - getRawMany: jest.fn().mockResolvedValue([{ studentId: 10, amount: '120' }]), - }), - }; - const service = new DepositsService(repo as never, {} as never, {} as never, personalExpenseRepo as never); + const service = new DepositsService(repo as never, {} as never, {} as never, {} as never); const result = await service.refund( 1, - { refundDate: '2026-07-13', notes: '退还剩余押金' }, + { refundDate: '2026-07-13', deductionAmount: 120, notes: '退还剩余押金' }, 42, ); @@ -36,7 +26,7 @@ describe('DepositsService — direct refund', () => { amount: 0, refundAmount: 380, deductionAmount: 120, - deductionReason: '自动扣除个人附加费用 ¥120.00', + deductionReason: '扣除个人附加费用 ¥120.00', notes: '退还剩余押金', status: 'refunded', refundedBy: 42, diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index 0b34f41..c35d35f 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -112,15 +112,16 @@ export class DepositsService { } const depositAmount = Number(deposit.amount); - const personalExpenseAmount = await this.getPersonalExpenseAmount(deposit.studentId); - const deductionAmount = Number(Math.min(depositAmount, personalExpenseAmount).toFixed(2)); + const deductionAmount = Number(Number(dto.deductionAmount || 0).toFixed(2)); + if (deductionAmount < 0) throw new BadRequestException('扣除金额不能小于0'); + if (deductionAmount > depositAmount) throw new BadRequestException('扣除金额不能大于当前可用押金'); const refundAmount = Number((depositAmount - deductionAmount).toFixed(2)); deposit.refundDate = dto.refundDate; deposit.refundAmount = refundAmount; deposit.deductionAmount = deductionAmount; deposit.deductionReason = - deductionAmount > 0 ? `自动扣除个人附加费用 ¥${deductionAmount.toFixed(2)}` : ''; + deductionAmount > 0 ? `扣除个人附加费用 ¥${deductionAmount.toFixed(2)}` : ''; deposit.amount = 0; deposit.status = refundAmount > 0 ? 'refunded' : 'depleted'; if (dto.notes) deposit.notes = dto.notes; @@ -158,11 +159,6 @@ export class DepositsService { ); } - private async getPersonalExpenseAmount(studentId: number) { - const amountMap = await this.getPersonalExpenseAmountMap([studentId]); - return amountMap.get(studentId) || 0; - } - private async getPersonalExpenseAmountMap(studentIds: number[]) { const amountMap = new Map(); if (!studentIds.length) return amountMap; diff --git a/apps/server/src/deposits/dto/deposit.dto.ts b/apps/server/src/deposits/dto/deposit.dto.ts index 0728ab2..293bdd8 100644 --- a/apps/server/src/deposits/dto/deposit.dto.ts +++ b/apps/server/src/deposits/dto/deposit.dto.ts @@ -20,6 +20,10 @@ export class RefundDepositDto { @IsDateString() refundDate: string; + @IsOptional() + @IsNumber({ maxDecimalPlaces: 2 }) + deductionAmount?: number; + @IsOptional() @IsString() notes?: string; -- 2.49.1