rentease-backend-new/services/rentalBillingService.js

280 lines
7.7 KiB
JavaScript

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
};