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,