const { Op } = require('sequelize'); const { Bill } = require('../models'); const generateBillNo = () => { const date = new Date(); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const ms = String(date.getTime()).slice(-6); const random = Math.floor(Math.random() * 1000).toString().padStart(3, '0'); return `B${year}${month}${day}${ms}${random}`; }; const toNumber = (value) => { const parsed = parseFloat(value); return Number.isFinite(parsed) ? parsed : 0; }; const billMonthOf = (date) => { if (!date) return null; if (typeof date === 'string') return date.substring(0, 7); return new Date(date).toISOString().substring(0, 7); }; const createRentalBill = async ({ rentalId, roomId, renterId, type = 'income', category, amount, billDate, billMonth, settlementType = 'normal', sourceEventId = null, sourceType = null, sourceId = null, remark, tenantId, createBy, transaction }) => { const receivableAmount = toNumber(amount); if (receivableAmount <= 0) return null; return Bill.create({ billNo: generateBillNo(), rentalId, roomId, renterId, type, category, receivableAmount, receivedAmount: 0, status: 'unpaid', billDate: billDate || new Date(), billMonth: billMonth || billMonthOf(billDate), settlementType, sourceEventId, sourceType, sourceId, remark, tenantId, createBy, updateBy: createBy }, { transaction }); }; const createInitialBills = async ({ rental, renterName = '', settlementType = 'normal', sourceEventId = null, transaction }) => { const bills = []; const label = settlementType === 'renew' ? '续租' : settlementType === 'change_room' ? '换房' : '租约'; const operatorId = rental.createBy || rental.updateBy; const rentBill = await createRentalBill({ rentalId: rental.id, roomId: rental.roomId, renterId: rental.renterId, category: 'rent', amount: rental.rent, billDate: rental.startDate, settlementType, sourceEventId, remark: `${label}首期租金 - ${renterName}`, tenantId: rental.tenantId, createBy: operatorId, transaction }); if (rentBill) bills.push(rentBill); const depositBill = await createRentalBill({ rentalId: rental.id, roomId: rental.roomId, renterId: rental.renterId, category: 'deposit', amount: rental.deposit, billDate: rental.startDate, settlementType, sourceEventId, remark: `${label}押金 - ${renterName}`, tenantId: rental.tenantId, createBy: operatorId, transaction }); if (depositBill) bills.push(depositBill); return bills; }; const createDepositDiffBill = async ({ rental, oldDeposit, newDeposit, renterName = '', settlementType, transaction }) => { const diff = toNumber(newDeposit) - toNumber(oldDeposit); if (diff === 0) return null; return createRentalBill({ rentalId: rental.id, roomId: rental.roomId, renterId: rental.renterId, type: diff > 0 ? 'income' : 'expense', category: 'deposit', amount: Math.abs(diff), billDate: rental.startDate, settlementType, sourceEventId: rental.prevRentalId || null, remark: diff > 0 ? `补交押金 - ${renterName}` : `退还押金差额 - ${renterName}`, tenantId: rental.tenantId, createBy: rental.createBy || rental.updateBy, transaction }); }; const syncEditableInitialBills = async ({ rental, oldRental, renterName = '', operatorId, transaction }) => { const activeInitialBills = await Bill.findAll({ where: { rentalId: rental.id, tenantId: rental.tenantId, settlementType: 'normal', category: { [Op.in]: ['rent', 'deposit'] }, status: 'unpaid', isDeleted: 0 }, transaction }); for (const bill of activeInitialBills) { if (bill.category === 'rent') { await bill.update({ roomId: rental.roomId, renterId: rental.renterId, receivableAmount: rental.rent, billDate: rental.startDate, billMonth: billMonthOf(rental.startDate), remark: `租约首期租金 - ${renterName}`, updateBy: operatorId }, { transaction }); } if (bill.category === 'deposit') { const deposit = toNumber(rental.deposit); if (deposit > 0) { await bill.update({ roomId: rental.roomId, renterId: rental.renterId, receivableAmount: deposit, billDate: rental.startDate, billMonth: billMonthOf(rental.startDate), remark: `租约押金 - ${renterName}`, updateBy: operatorId }, { transaction }); } else { await bill.update({ status: 'cancelled', isDeleted: 1, updateBy: operatorId }, { transaction }); } } } const hasDepositBill = activeInitialBills.some(bill => bill.category === 'deposit'); if (!hasDepositBill && toNumber(oldRental.deposit) <= 0 && toNumber(rental.deposit) > 0) { await createRentalBill({ rentalId: rental.id, roomId: rental.roomId, renterId: rental.renterId, category: 'deposit', amount: rental.deposit, billDate: rental.startDate, settlementType: 'normal', remark: `租约押金 - ${renterName}`, tenantId: rental.tenantId, createBy: operatorId, transaction }); } }; const cancelUnpaidBillsForRental = async ({ rentalId, tenantId, operatorId, transaction }) => { return Bill.update( { status: 'cancelled', isDeleted: 1, updateBy: operatorId }, { where: { rentalId, tenantId, status: 'unpaid', isDeleted: 0 }, transaction } ); }; const assertRentalCanBeDeleted = async ({ rentalId, tenantId, transaction }) => { const bills = await Bill.findAll({ where: { rentalId, tenantId, status: { [Op.ne]: 'cancelled' }, isDeleted: 0 }, attributes: ['id', 'status', 'category', 'receivedAmount'], transaction }); if (bills.length === 0) return; const paidLike = bills.filter(bill => ['paid', 'partial'].includes(bill.status) || toNumber(bill.receivedAmount) > 0); if (paidLike.length === 0) return; const error = new Error( paidLike.length > 0 ? '该租约已有收款记录,不能删除。请办理退租或作废未收账单,并保留历史流水。' : `该租约下有 ${bills.length} 笔有效未收账单,请先作废账单或办理退租后再删除。` ); error.statusCode = 400; throw error; }; const validatePaymentAmount = (bill, amount) => { if (bill.status === 'paid') { const error = new Error('该账单已收清,无法继续收款'); error.statusCode = 400; throw error; } if (bill.status === 'cancelled') { const error = new Error('该账单已取消,无法收款'); error.statusCode = 400; throw error; } const receivableAmount = toNumber(bill.receivableAmount); const currentReceived = toNumber(bill.receivedAmount); const paymentAmount = toNumber(amount); const remainingAmount = receivableAmount - currentReceived; if (paymentAmount <= 0) { const error = new Error('支付金额必须大于0'); error.statusCode = 400; throw error; } if (paymentAmount > remainingAmount) { const error = new Error(`支付金额不能超过剩余应收金额 ¥${remainingAmount.toFixed(2)}`); error.statusCode = 400; throw error; } const newReceivedAmount = currentReceived + paymentAmount; return { paymentAmount, newReceivedAmount, newStatus: newReceivedAmount >= receivableAmount ? 'paid' : 'partial' }; }; module.exports = { generateBillNo, billMonthOf, createRentalBill, createInitialBills, createDepositDiffBill, syncEditableInitialBills, cancelUnpaidBillsForRental, assertRentalCanBeDeleted, validatePaymentAmount };