280 lines
7.7 KiB
JavaScript
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
|
||
|
|
};
|