This commit is contained in:
xiaoxian 2026-06-11 21:45:33 +08:00
parent a4ed46d10a
commit da941c600e
23 changed files with 1986 additions and 678 deletions

View File

@ -1,6 +1,5 @@
const { Apartment, Tenant } = require('../models'); const { Apartment, Tenant } = require('../models');
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const { logOperation } = require('../utils/logger');
const response = require('../utils/response'); const response = require('../utils/response');
// 格式化时间(考虑时区,转换为北京时间) // 格式化时间(考虑时区,转换为北京时间)
@ -91,6 +90,14 @@ const createApartment = async (req, res) => {
try { try {
const { name, address, description } = req.body; const { name, address, description } = req.body;
// 输入验证
if (!name || name.trim() === '') {
return response.badRequest(res, '公寓名称不能为空');
}
if (!address || address.trim() === '') {
return response.badRequest(res, '地址不能为空');
}
// 检查租户资源使用情况(仅记录日志,不阻止创建) // 检查租户资源使用情况(仅记录日志,不阻止创建)
const tenant = await Tenant.findByPk(req.user.tenantId); const tenant = await Tenant.findByPk(req.user.tenantId);
let currentApartmentCount = 0; let currentApartmentCount = 0;
@ -102,19 +109,7 @@ const createApartment = async (req, res) => {
// 检查是否超出限制 // 检查是否超出限制
if (currentApartmentCount >= tenant.maxApartments) { if (currentApartmentCount >= tenant.maxApartments) {
// 记录超额使用日志 return response.badRequest(res, `当前公寓数量已达到套餐上限 ${tenant.maxApartments} 栋,请升级套餐后再创建`);
await logOperation({
userId: req.user.id,
username: req.user.username,
tenantId: req.user.tenantId,
module: '公寓管理',
action: '超额创建',
description: `创建公寓"${name}",当前已使用 ${currentApartmentCount + 1}/${tenant.maxApartments} 栋(超出限制)`,
method: req.method,
path: req.path,
ip: req.ip,
status: 'success'
});
} }
} }
@ -128,7 +123,7 @@ const createApartment = async (req, res) => {
}); });
response.created(res, '创建成功', { response.created(res, '创建成功', {
apartment, apartment,
warning: tenant && currentApartmentCount >= tenant.maxApartments ? `当前已超出套餐限制(${tenant.maxApartments}栋),续费时将收取超额费用` : null warning: null
}); });
} catch (error) { } catch (error) {
response.serverError(res, '创建公寓失败', error); response.serverError(res, '创建公寓失败', error);

View File

@ -1,6 +1,7 @@
const { Bill, MeterReading, Room, Renter, Rental, BillPayment } = require('../models'); const { Bill, MeterReading, Room, Renter, Rental, BillPayment } = require('../models');
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const response = require('../utils/response'); const response = require('../utils/response');
const rentalBillingService = require('../services/rentalBillingService');
// 生成账单编号 // 生成账单编号
const generateBillNo = () => { const generateBillNo = () => {
@ -261,8 +262,9 @@ const createBill = async (req, res) => {
} }
}; };
// 更新账单 // 更新账单(支持部分更新)
const updateBill = async (req, res) => { const updateBill = async (req, res) => {
const transaction = await require('../config/db').transaction();
try { try {
const { id } = req.params; const { id } = req.params;
const { const {
@ -274,94 +276,171 @@ const updateBill = async (req, res) => {
receivableAmount, receivableAmount,
billMonth, billMonth,
billDate, billDate,
remark remark,
status
} = req.body; } = req.body;
const bill = await Bill.findOne({ const bill = await Bill.findOne({
where: { id, tenantId: req.user.tenantId, isDeleted: 0 } where: { id, tenantId: req.user.tenantId, isDeleted: 0 },
transaction
}); });
if (!bill) { if (!bill) {
await transaction.rollback();
return response.notFound(res, '账单不存在'); return response.notFound(res, '账单不存在');
} }
await bill.update({ const hasReceived = ['paid', 'partial'].includes(bill.status) || parseFloat(bill.receivedAmount || 0) > 0;
roomId: roomId || null, const financialFieldsChanged = [roomId, renterId, rentalId, type, category, receivableAmount, billMonth, billDate, status]
renterId: renterId || null, .some(value => value !== undefined);
rentalId: rentalId || null,
type, if (hasReceived && financialFieldsChanged) {
category, await transaction.rollback();
receivableAmount, return response.badRequest(res, '该账单已有收款记录,不能修改金额、状态、类型、日期或关联对象');
billMonth: billMonth || null, }
billDate: billDate || bill.billDate,
remark, if (status === 'paid' && parseFloat(bill.receivedAmount || 0) < parseFloat(bill.receivableAmount || 0)) {
updateBy: req.user.id await transaction.rollback();
}); return response.badRequest(res, '未收清的账单不能直接标记为已支付,请通过收款流水入账');
}
const updates = { updateBy: req.user.id };
if (roomId !== undefined) updates.roomId = roomId;
if (renterId !== undefined) updates.renterId = renterId;
if (rentalId !== undefined) updates.rentalId = rentalId;
if (type !== undefined) updates.type = type;
if (category !== undefined) updates.category = category;
if (receivableAmount !== undefined) updates.receivableAmount = receivableAmount;
if (billMonth !== undefined) updates.billMonth = billMonth;
if (billDate !== undefined) updates.billDate = billDate;
if (remark !== undefined) updates.remark = remark;
if (status !== undefined) updates.status = status;
await bill.update(updates, { transaction });
await transaction.commit();
response.success(res, '更新成功', formatBillData(bill)); response.success(res, '更新成功', formatBillData(bill));
} catch (error) { } catch (error) {
await transaction.rollback();
console.error('更新账单失败:', error); console.error('更新账单失败:', error);
response.serverError(res, '更新账单失败', error); response.serverError(res, '更新账单失败', error);
} }
}; };
// 删除账单(软删除) // 删除账单(软删除 + 级联删除支付记录
const deleteBill = async (req, res) => { const deleteBill = async (req, res) => {
const transaction = await require('../config/db').transaction();
try { try {
const { id } = req.params; const { id } = req.params;
const bill = await Bill.findOne({ const bill = await Bill.findOne({
where: { id, tenantId: req.user.tenantId, isDeleted: 0 } where: { id, tenantId: req.user.tenantId, isDeleted: 0 },
transaction
}); });
if (!bill) { if (!bill) {
await transaction.rollback();
return response.notFound(res, '账单不存在'); return response.notFound(res, '账单不存在');
} }
const paymentCount = await BillPayment.count({
where: { billId: id, tenantId: req.user.tenantId, isDeleted: 0 },
transaction
});
if (paymentCount > 0 || ['paid', 'partial'].includes(bill.status) || parseFloat(bill.receivedAmount || 0) > 0) {
await transaction.rollback();
return response.badRequest(res, '该账单已有收款流水,不能删除。请保留流水,可通过冲销/退款方式处理。');
}
await bill.update({ await bill.update({
isDeleted: 1, isDeleted: 1,
status: 'cancelled',
updateBy: req.user.id updateBy: req.user.id
}); }, { transaction });
response.success(res, '账单删除成功'); await transaction.commit();
response.success(res, '账单已作废并删除');
} catch (error) { } catch (error) {
await transaction.rollback();
console.error('删除账单失败:', error); console.error('删除账单失败:', error);
response.serverError(res, '删除账单失败', error); response.serverError(res, '删除账单失败', error);
} }
}; };
// 账单收款 // 账单收款(支持幂等性)
const receivePayment = async (req, res) => { const receivePayment = async (req, res) => {
const transaction = await require('../config/db').transaction();
try { try {
const { id } = req.params; const { id } = req.params;
const { amount, paymentMethod } = req.body; const { amount, paymentMethod, transactionNo, remark } = req.body;
const bill = await Bill.findOne({ const bill = await Bill.findOne({
where: { id, tenantId: req.user.tenantId, isDeleted: 0 } where: { id, tenantId: req.user.tenantId, isDeleted: 0 },
transaction
}); });
if (!bill) { if (!bill) {
await transaction.rollback();
return response.notFound(res, '账单不存在'); return response.notFound(res, '账单不存在');
} }
const newReceivedAmount = parseFloat(bill.receivedAmount) + parseFloat(amount); // 幂等性检查:如果提供了交易流水号,检查是否已存在相同的支付记录
const receivableAmount = parseFloat(bill.receivableAmount); if (transactionNo) {
const existingPayment = await BillPayment.findOne({
where: {
transactionNo,
billId: id,
isDeleted: 0
},
transaction
});
if (existingPayment) {
await transaction.rollback();
return response.badRequest(res, '该交易流水号已存在,请勿重复提交');
}
}
let newStatus = 'unpaid'; let paymentInfo;
if (newReceivedAmount >= receivableAmount) { try {
newStatus = 'paid'; const remainingAmount = parseFloat(bill.receivableAmount) - parseFloat(bill.receivedAmount || 0);
} else if (newReceivedAmount > 0) { paymentInfo = rentalBillingService.validatePaymentAmount(bill, amount !== undefined ? amount : remainingAmount);
newStatus = 'partial'; } catch (validationError) {
await transaction.rollback();
return response.badRequest(res, validationError.message);
} }
await bill.update({ await bill.update({
receivedAmount: newReceivedAmount, receivedAmount: paymentInfo.newReceivedAmount,
status: newStatus, status: paymentInfo.newStatus,
paymentMethod: paymentMethod || bill.paymentMethod, paymentMethod: paymentMethod || bill.paymentMethod,
updateBy: req.user.id updateBy: req.user.id
}); }, { transaction });
response.success(res, '收款成功', formatBillData(bill)); // 创建支付记录
const billPayment = await BillPayment.create({
billId: id,
amount: paymentInfo.paymentAmount,
paymentMethod: paymentMethod || 'cash',
paymentTime: new Date(),
transactionNo: transactionNo || null,
remark: remark || null,
tenantId: req.user.tenantId,
createBy: req.user.id
}, { transaction });
await transaction.commit();
response.success(res, '收款成功', {
bill: formatBillData(bill),
payment: {
id: billPayment.id,
amount: billPayment.amount,
paymentMethod: billPayment.paymentMethod,
paymentTime: formatDateTime(billPayment.paymentTime)
}
});
} catch (error) { } catch (error) {
await transaction.rollback();
console.error('账单收款失败:', error); console.error('账单收款失败:', error);
response.serverError(res, '账单收款失败', error); response.serverError(res, '账单收款失败', error);
} }

View File

@ -1,6 +1,7 @@
const { BillPayment, Bill } = require('../models'); const { BillPayment, Bill } = require('../models');
const { Op } = require('sequelize'); const { Op, fn, col } = require('sequelize');
const response = require('../utils/response'); const response = require('../utils/response');
const rentalBillingService = require('../services/rentalBillingService');
// 获取账单的所有支付流水 // 获取账单的所有支付流水
const getPaymentsByBillId = async (req, res) => { const getPaymentsByBillId = async (req, res) => {
@ -26,48 +27,45 @@ const getPaymentsByBillId = async (req, res) => {
// 创建支付流水(收款) // 创建支付流水(收款)
const createPayment = async (req, res) => { const createPayment = async (req, res) => {
const transaction = await require('../config/db').transaction();
try { try {
const { billId } = req.params; const { billId } = req.params;
const { amount, paymentMethod, paymentTime, transactionNo, remark } = req.body; const { amount, paymentMethod, paymentTime, transactionNo, remark } = req.body;
const tenantId = req.user.tenantId; const tenantId = req.user.tenantId;
const createBy = req.user.id; const createBy = req.user.id;
// 查找账单
const bill = await Bill.findOne({ const bill = await Bill.findOne({
where: { id: billId, tenantId, isDeleted: 0 } where: { id: billId, tenantId, isDeleted: 0 },
transaction
}); });
if (!bill) { if (!bill) {
await transaction.rollback();
return response.notFound(res, '账单不存在'); return response.notFound(res, '账单不存在');
} }
if (bill.status === 'paid') { if (transactionNo) {
return response.badRequest(res, '该账单已收清,无法继续收款'); const existingPayment = await BillPayment.findOne({
where: { transactionNo, billId, tenantId, isDeleted: 0 },
transaction
});
if (existingPayment) {
await transaction.rollback();
return response.badRequest(res, '该交易流水号已存在,请勿重复提交');
}
} }
if (bill.status === 'cancelled') { let paymentInfo;
return response.badRequest(res, '该账单已取消,无法收款'); try {
paymentInfo = rentalBillingService.validatePaymentAmount(bill, amount);
} catch (validationError) {
await transaction.rollback();
return response.badRequest(res, validationError.message);
} }
// 计算剩余应收金额
const receivableAmount = parseFloat(bill.receivableAmount);
const currentReceived = parseFloat(bill.receivedAmount || 0);
const remainingAmount = receivableAmount - currentReceived;
// 验证支付金额
const paymentAmount = parseFloat(amount);
if (paymentAmount <= 0) {
return response.badRequest(res, '支付金额必须大于0');
}
if (paymentAmount > remainingAmount) {
return response.badRequest(res, `支付金额不能超过剩余应收金额 ¥${remainingAmount.toFixed(2)}`);
}
// 创建支付流水
const payment = await BillPayment.create({ const payment = await BillPayment.create({
billId, billId,
amount: paymentAmount, amount: paymentInfo.paymentAmount,
paymentMethod, paymentMethod,
paymentTime: paymentTime || new Date(), paymentTime: paymentTime || new Date(),
transactionNo: transactionNo || null, transactionNo: transactionNo || null,
@ -75,33 +73,26 @@ const createPayment = async (req, res) => {
tenantId, tenantId,
createBy, createBy,
isDeleted: 0 isDeleted: 0
}); }, { transaction });
// 更新账单的已收金额和状态
const newReceivedAmount = currentReceived + paymentAmount;
let newStatus = bill.status;
if (newReceivedAmount >= receivableAmount) {
newStatus = 'paid';
} else if (newReceivedAmount > 0) {
newStatus = 'partial';
}
await bill.update({ await bill.update({
receivedAmount: newReceivedAmount, receivedAmount: paymentInfo.newReceivedAmount,
status: newStatus, status: paymentInfo.newStatus,
updateBy: createBy updateBy: createBy
}); }, { transaction });
await transaction.commit();
response.created(res, '收款成功', { response.created(res, '收款成功', {
payment, payment,
bill: { bill: {
id: bill.id, id: bill.id,
receivedAmount: newReceivedAmount, receivedAmount: paymentInfo.newReceivedAmount,
status: newStatus status: paymentInfo.newStatus
} }
}); });
} catch (error) { } catch (error) {
await transaction.rollback();
console.error('创建支付流水失败:', error); console.error('创建支付流水失败:', error);
response.serverError(res, '创建支付流水失败', error); response.serverError(res, '创建支付流水失败', error);
} }
@ -109,44 +100,40 @@ const createPayment = async (req, res) => {
// 删除支付流水(退款/撤销) // 删除支付流水(退款/撤销)
const deletePayment = async (req, res) => { const deletePayment = async (req, res) => {
const transaction = await require('../config/db').transaction();
try { try {
const { id } = req.params; const { id } = req.params;
const tenantId = req.user.tenantId; const tenantId = req.user.tenantId;
const updateBy = req.user.id; const updateBy = req.user.id;
const payment = await BillPayment.findOne({ const payment = await BillPayment.findOne({
where: { id, tenantId, isDeleted: 0 } where: { id, tenantId, isDeleted: 0 },
transaction
}); });
if (!payment) { if (!payment) {
await transaction.rollback();
return response.notFound(res, '支付流水不存在'); return response.notFound(res, '支付流水不存在');
} }
// 查找关联账单
const bill = await Bill.findOne({ const bill = await Bill.findOne({
where: { id: payment.billId, tenantId, isDeleted: 0 } where: { id: payment.billId, tenantId, isDeleted: 0 },
transaction
}); });
if (!bill) { if (!bill) {
await transaction.rollback();
return response.notFound(res, '关联账单不存在'); return response.notFound(res, '关联账单不存在');
} }
// 软删除支付流水 await payment.update({ isDeleted: 1 }, { transaction });
await payment.update({ isDeleted: 1 });
// 重新计算账单的已收金额和状态
const remainingPayments = await BillPayment.findAll({ const remainingPayments = await BillPayment.findAll({
where: { where: { billId: bill.id, tenantId, isDeleted: 0 },
billId: bill.id, transaction
tenantId,
isDeleted: 0
}
}); });
const newReceivedAmount = remainingPayments.reduce((sum, p) => { const newReceivedAmount = remainingPayments.reduce((sum, p) => sum + parseFloat(p.amount), 0);
return sum + parseFloat(p.amount);
}, 0);
let newStatus = 'unpaid'; let newStatus = 'unpaid';
if (newReceivedAmount >= parseFloat(bill.receivableAmount)) { if (newReceivedAmount >= parseFloat(bill.receivableAmount)) {
newStatus = 'paid'; newStatus = 'paid';
@ -158,18 +145,17 @@ const deletePayment = async (req, res) => {
receivedAmount: newReceivedAmount, receivedAmount: newReceivedAmount,
status: newStatus, status: newStatus,
updateBy updateBy
}); }, { transaction });
response.success(res, '支付流水已删除,账单状态已更新', { await transaction.commit();
bill: {
id: bill.id, response.success(res, '支付流水已撤销,账单状态已更新', {
receivedAmount: newReceivedAmount, bill: { id: bill.id, receivedAmount: newReceivedAmount, status: newStatus }
status: newStatus
}
}); });
} catch (error) { } catch (error) {
console.error('删除支付流水失败:', error); await transaction.rollback();
response.serverError(res, '删除支付流水失败', error); console.error('撤销支付流水失败:', error);
response.serverError(res, '撤销支付流水失败', error);
} }
}; };
@ -192,7 +178,7 @@ const getPaymentStatistics = async (req, res) => {
const payments = await BillPayment.findAll({ const payments = await BillPayment.findAll({
where, where,
attributes: ['paymentMethod', [sequelize.fn('SUM', sequelize.col('amount')), 'totalAmount'], [sequelize.fn('COUNT', sequelize.col('id')), 'count']], attributes: ['paymentMethod', [fn('SUM', col('amount')), 'totalAmount'], [fn('COUNT', col('id')), 'count']],
group: ['paymentMethod'] group: ['paymentMethod']
}); });

View File

@ -2,6 +2,7 @@ const { SubscriptionPlan, PricingConfig, Order, Payment, Tenant, User, Apartment
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const billingService = require('../services/billingService'); const billingService = require('../services/billingService');
const response = require('../utils/response'); const response = require('../utils/response');
const sequelize = require('../config/db');
// 格式化日期时间(年月日时分秒) // 格式化日期时间(年月日时分秒)
const formatDateTime = (date) => { const formatDateTime = (date) => {
@ -40,7 +41,7 @@ const getAllPlans = async (req, res) => {
// 创建套餐 // 创建套餐
const createPlan = async (req, res) => { const createPlan = async (req, res) => {
try { try {
const { name, description, maxApartments, maxRooms, maxUsers, monthlyPrice } = req.body; const { code, name, description, maxApartments, maxRooms, maxUsers, monthlyPrice, yearlyPrice, status, sort } = req.body;
// 验证必填字段 // 验证必填字段
if (!name) { if (!name) {
@ -48,18 +49,31 @@ const createPlan = async (req, res) => {
} }
// 检查是否是第一个套餐 // 检查是否是第一个套餐
if (!code) {
return response.badRequest(res, '套餐编码不能为空');
}
const existingPlan = await SubscriptionPlan.findOne({ where: { code, isDeleted: 0 } });
if (existingPlan) {
return response.badRequest(res, '套餐编码已存在');
}
const existingPlans = await SubscriptionPlan.count({ where: { isDeleted: 0 } }); const existingPlans = await SubscriptionPlan.count({ where: { isDeleted: 0 } });
const isDefault = existingPlans === 0; const isDefault = existingPlans === 0;
// 创建套餐 // 创建套餐
const plan = await SubscriptionPlan.create({ const plan = await SubscriptionPlan.create({
code,
name, name,
description, description,
maxApartments: maxApartments || 10, maxApartments: maxApartments || 10,
maxRooms: maxRooms || 50, maxRooms: maxRooms || 50,
maxUsers: maxUsers || 5, maxUsers: maxUsers || 5,
monthlyPrice: monthlyPrice || 0, monthlyPrice: monthlyPrice || 0,
isDefault yearlyPrice: yearlyPrice || 0,
isDefault,
status: status || 'active',
sort: sort || 0
}); });
response.created(res, '套餐创建成功', plan); response.created(res, '套餐创建成功', plan);
@ -72,7 +86,7 @@ const createPlan = async (req, res) => {
const updatePlan = async (req, res) => { const updatePlan = async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { name, description, maxApartments, maxRooms, maxUsers, monthlyPrice, status, sort } = req.body; const { name, description, maxApartments, maxRooms, maxUsers, monthlyPrice, yearlyPrice, status, sort } = req.body;
// 查找套餐 // 查找套餐
const plan = await SubscriptionPlan.findOne({ const plan = await SubscriptionPlan.findOne({
@ -91,6 +105,7 @@ const updatePlan = async (req, res) => {
maxRooms: maxRooms !== undefined ? maxRooms : plan.maxRooms, maxRooms: maxRooms !== undefined ? maxRooms : plan.maxRooms,
maxUsers: maxUsers !== undefined ? maxUsers : plan.maxUsers, maxUsers: maxUsers !== undefined ? maxUsers : plan.maxUsers,
monthlyPrice: monthlyPrice !== undefined ? monthlyPrice : plan.monthlyPrice, monthlyPrice: monthlyPrice !== undefined ? monthlyPrice : plan.monthlyPrice,
yearlyPrice: yearlyPrice !== undefined ? yearlyPrice : plan.yearlyPrice,
status: status !== undefined ? status : plan.status, status: status !== undefined ? status : plan.status,
sort: sort !== undefined ? sort : plan.sort sort: sort !== undefined ? sort : plan.sort
}); });
@ -262,6 +277,14 @@ const initDefaultPlans = async (req, res) => {
]; ];
// 批量创建套餐 // 批量创建套餐
const planDefaults = [
{ code: 'free', yearlyPrice: 0 },
{ code: 'basic', yearlyPrice: 999 },
{ code: 'professional', yearlyPrice: 2999 },
{ code: 'enterprise', yearlyPrice: 9999 }
];
defaultPlans.forEach((plan, index) => Object.assign(plan, planDefaults[index]));
const plans = await SubscriptionPlan.bulkCreate(defaultPlans); const plans = await SubscriptionPlan.bulkCreate(defaultPlans);
response.success(res, '默认套餐初始化成功', plans); response.success(res, '默认套餐初始化成功', plans);
@ -332,7 +355,7 @@ const getOrders = async (req, res) => {
{ {
model: SubscriptionPlan, model: SubscriptionPlan,
as: 'subscriptionPlan', as: 'subscriptionPlan',
attributes: ['id', 'name', 'monthlyPrice'] attributes: ['id', 'code', 'name', 'monthlyPrice', 'yearlyPrice']
} }
] ]
}); });
@ -349,7 +372,7 @@ const getOrders = async (req, res) => {
} }
}; };
// 计算续费价格(含超额费用) // 计算预付订阅价格
const calculatePrice = async (req, res) => { const calculatePrice = async (req, res) => {
try { try {
const { planId, months } = req.body; const { planId, months } = req.body;
@ -373,7 +396,7 @@ const calculatePrice = async (req, res) => {
return response.badRequest(res, '套餐不存在'); return response.badRequest(res, '套餐不存在');
} }
// 计算订单金额(含超额费用) // 计算预付订阅订单金额
const amountInfo = await billingService.calculateRenewalAmount(tenantId, planId, months); const amountInfo = await billingService.calculateRenewalAmount(tenantId, planId, months);
response.success(res, '计算成功', amountInfo); response.success(res, '计算成功', amountInfo);
@ -424,6 +447,7 @@ const createOrder = async (req, res) => {
planId, planId,
planName: plan.name, planName: plan.name,
months, months,
billingCycle: amountInfo.billingCycle,
amount: amountInfo.totalAmount, amount: amountInfo.totalAmount,
discountAmount: 0, discountAmount: 0,
actualAmount: amountInfo.totalAmount, actualAmount: amountInfo.totalAmount,
@ -444,7 +468,7 @@ const createOrder = async (req, res) => {
{ {
model: SubscriptionPlan, model: SubscriptionPlan,
as: 'subscriptionPlan', as: 'subscriptionPlan',
attributes: ['id', 'name', 'monthlyPrice'] attributes: ['id', 'code', 'name', 'monthlyPrice', 'yearlyPrice']
} }
] ]
}); });
@ -477,7 +501,7 @@ const getOrderDetail = async (req, res) => {
{ {
model: SubscriptionPlan, model: SubscriptionPlan,
as: 'subscriptionPlan', as: 'subscriptionPlan',
attributes: ['id', 'name', 'description', 'monthlyPrice', 'maxApartments', 'maxRooms', 'maxUsers'] attributes: ['id', 'code', 'name', 'description', 'monthlyPrice', 'yearlyPrice', 'maxApartments', 'maxRooms', 'maxUsers']
}, },
{ {
model: Payment, model: Payment,
@ -539,39 +563,51 @@ const cancelOrder = async (req, res) => {
// 支付订单(管理员确认收款) // 支付订单(管理员确认收款)
const payOrder = async (req, res) => { const payOrder = async (req, res) => {
const transaction = await sequelize.transaction();
try { try {
const { id: orderId } = req.params; const { id: orderId } = req.params;
const { paymentMethod = 'other', amount, transactionId: customTransactionId, remark } = req.body || {}; const { paymentMethod = 'other', amount, transactionId: customTransactionId, remark } = req.body || {};
const isSystemAdmin = req.user.userType === 'super_admin'; const isSystemAdmin = req.user.userType === 'super_admin';
if (!orderId) { if (!orderId) {
await transaction.rollback();
return response.badRequest(res, '订单ID不能为空'); return response.badRequest(res, '订单ID不能为空');
} }
// 只有管理员可以确认支付 // 只有管理员可以确认支付
if (!isSystemAdmin) { if (!isSystemAdmin) {
await transaction.rollback();
return response.forbidden(res, '无权操作,请联系管理员'); return response.forbidden(res, '无权操作,请联系管理员');
} }
// 查询订单 // 查询订单
const order = await Order.findOne({ const order = await Order.findOne({
where: { id: orderId, isDeleted: 0 } where: { id: orderId, isDeleted: 0 },
transaction
}); });
if (!order) { if (!order) {
await transaction.rollback();
return response.notFound(res, '订单不存在'); return response.notFound(res, '订单不存在');
} }
// 验证订单状态 // 验证订单状态
if (order.status !== 'pending') { if (order.status !== 'pending') {
await transaction.rollback();
return response.badRequest(res, '订单状态异常,无法支付'); return response.badRequest(res, '订单状态异常,无法支付');
} }
// 验证金额 // 验证金额
const paymentAmount = parseFloat(amount) || order.actualAmount; const payableAmount = parseFloat(order.actualAmount || order.amount || 0);
if (paymentAmount <= 0) { const paymentAmount = amount !== undefined ? parseFloat(amount) : payableAmount;
if (!Number.isFinite(paymentAmount) || paymentAmount <= 0) {
await transaction.rollback();
return response.badRequest(res, '支付金额必须大于0'); return response.badRequest(res, '支付金额必须大于0');
} }
if (paymentAmount < payableAmount) {
await transaction.rollback();
return response.badRequest(res, `支付金额不能小于订单应付金额 ${payableAmount}`);
}
// 生成或使用自定义交易流水号 // 生成或使用自定义交易流水号
const transactionId = customTransactionId || `PAY${Date.now()}${Math.floor(Math.random() * 1000)}`; const transactionId = customTransactionId || `PAY${Date.now()}${Math.floor(Math.random() * 1000)}`;
@ -586,18 +622,25 @@ const payOrder = async (req, res) => {
transactionId, transactionId,
paidAt: new Date(), paidAt: new Date(),
remark: remark || '管理员确认收款' remark: remark || '管理员确认收款'
}); }, { transaction });
// 更新订单状态 // 更新订单状态
const paidTime = new Date(); const paidTime = new Date();
await order.update({ await order.update({
status: 'paid', status: 'paid',
paidTime: paidTime paidTime: paidTime
}); }, { transaction });
// 处理支付成功后的租户状态更新 // 处理支付成功后的租户状态更新
// 使用订单的months字段和planId更新资源配置 // 使用订单的months字段和planId更新资源配置
const paymentResult = await billingService.processPaymentSuccess(order.tenantId, order.months, order.planId); const paymentResult = await billingService.processPaymentSuccess({
order,
payment,
operatorId: req.user.id,
transaction
});
await transaction.commit();
response.success(res, '支付成功', { response.success(res, '支付成功', {
payment, payment,
@ -609,6 +652,7 @@ const payOrder = async (req, res) => {
tenant: paymentResult tenant: paymentResult
}); });
} catch (error) { } catch (error) {
await transaction.rollback();
console.error('支付订单失败:', error); console.error('支付订单失败:', error);
response.serverError(res, '支付订单失败', error); response.serverError(res, '支付订单失败', error);
} }
@ -678,7 +722,7 @@ const getBillingInfo = async (req, res) => {
{ {
model: SubscriptionPlan, model: SubscriptionPlan,
as: 'subscriptionPlan', as: 'subscriptionPlan',
attributes: ['id', 'name', 'description', 'monthlyPrice', 'maxApartments', 'maxRooms', 'maxUsers'] attributes: ['id', 'code', 'name', 'description', 'monthlyPrice', 'yearlyPrice', 'maxApartments', 'maxRooms', 'maxUsers']
} }
] ]
}); });

View File

@ -93,6 +93,12 @@ exports.getLoginLogs = async (req, res) => {
// 清空操作日志 // 清空操作日志
exports.clearOperationLogs = async (req, res) => { exports.clearOperationLogs = async (req, res) => {
try { try {
// 权限检查:仅 super_admin 和 tenant_admin 可以清空日志
const userType = req.user.userType;
if (userType !== 'super_admin' && userType !== 'tenant_admin') {
return response.forbidden(res, '无权限执行此操作');
}
const { startTime, endTime } = req.body; const { startTime, endTime } = req.body;
const where = {}; const where = {};
@ -114,6 +120,12 @@ exports.clearOperationLogs = async (req, res) => {
// 清空登录日志 // 清空登录日志
exports.clearLoginLogs = async (req, res) => { exports.clearLoginLogs = async (req, res) => {
try { try {
// 权限检查:仅 super_admin 和 tenant_admin 可以清空日志
const userType = req.user.userType;
if (userType !== 'super_admin' && userType !== 'tenant_admin') {
return response.forbidden(res, '无权限执行此操作');
}
const { startTime, endTime } = req.body; const { startTime, endTime } = req.body;
const where = {}; const where = {};

View File

@ -306,6 +306,7 @@ exports.getRoleMenus = async (req, res) => {
const { roleId } = req.params; const { roleId } = req.params;
const Role = require('../models/Role'); const Role = require('../models/Role');
const Menu = require('../models/Menu');
// 先检查角色是否存在 // 先检查角色是否存在
const role = await Role.findOne({ const role = await Role.findOne({
@ -316,18 +317,21 @@ exports.getRoleMenus = async (req, res) => {
return response.notFound(res, '角色不存在'); return response.notFound(res, '角色不存在');
} }
// 获取角色的菜单权限 // 获取角色的菜单权限 - 从 role.permissions JSON 字段中读取
const roleData = await Role.findByPk(roleId, { const permissions = role.permissions || [];
include: [{ const menuIds = permissions.map(p => p.menuId).filter(Boolean);
model: Menu,
as: 'menus',
where: { isDeleted: 0 },
required: false,
through: { attributes: [] }
}]
});
const menus = roleData ? roleData.menus : []; if (menuIds.length === 0) {
return response.success(res, '获取成功', []);
}
// 查询这些菜单
const menus = await Menu.findAll({
where: {
id: { [Op.in]: menuIds },
isDeleted: 0
}
});
response.success(res, '获取成功', menus.map(formatMenuData)); response.success(res, '获取成功', menus.map(formatMenuData));
} catch (error) { } catch (error) {

View File

@ -145,8 +145,10 @@ const getMeterReadingById = async (req, res) => {
} }
}; };
// 创建抄表记录(同时创建账单) // 创建抄表记录(事务处理 + 自动创建账单)
const createMeterReading = async (req, res) => { const createMeterReading = async (req, res) => {
const transaction = await require('../config/db').transaction();
try { try {
const { const {
roomId, roomId,
@ -166,6 +168,12 @@ const createMeterReading = async (req, res) => {
const tenantId = req.user.tenantId; const tenantId = req.user.tenantId;
// 参数校验
if (currentReading < previousReading) {
await transaction.rollback();
return response.badRequest(res, '本期读数不能小于上期读数');
}
// 创建抄表记录 // 创建抄表记录
const meterReading = await MeterReading.create({ const meterReading = await MeterReading.create({
roomId, roomId,
@ -184,13 +192,13 @@ const createMeterReading = async (req, res) => {
createBy: req.user.id, createBy: req.user.id,
updateBy: req.user.id, updateBy: req.user.id,
isDeleted: 0 isDeleted: 0
}); }, { transaction });
// 自动创建关联账单 // 自动创建关联账单
const categoryMap = { const categoryMap = {
'water': 'water', 'water': '水费',
'electricity': 'electricity', 'electricity': '电费',
'gas': 'gas' 'gas': '燃气费'
}; };
const bill = await Bill.create({ const bill = await Bill.create({
@ -199,35 +207,40 @@ const createMeterReading = async (req, res) => {
renterId: renterId || null, renterId: renterId || null,
rentalId: rentalId || null, rentalId: rentalId || null,
type: 'income', type: 'income',
category: categoryMap[meterType], category: meterType,
receivableAmount: amount, receivableAmount: amount,
receivedAmount: 0, receivedAmount: 0,
status: 'unpaid', status: 'unpaid',
billMonth, billMonth,
billDate: readingDate || new Date(), billDate: readingDate || new Date(),
settlementType: 'normal',
sourceType: 'meter_reading', sourceType: 'meter_reading',
sourceId: meterReading.id, sourceId: meterReading.id,
remark: `${meterType === 'water' ? '水费' : meterType === 'electricity' ? '电费' : '燃气费'} - ${billMonth}`, remark: `${categoryMap[meterType] || meterType}账单 - ${billMonth}`,
tenantId, tenantId,
createBy: req.user.id, createBy: req.user.id,
updateBy: req.user.id, updateBy: req.user.id,
isDeleted: 0 isDeleted: 0
}); }, { transaction });
// 更新抄表记录的账单ID // 更新抄表记录的账单ID
await meterReading.update({ billId: bill.id }); await meterReading.update({ billId: bill.id }, { transaction });
// 提交事务
await transaction.commit();
response.created(res, '抄表记录创建成功,已自动生成账单', { response.created(res, '抄表记录创建成功,已自动生成账单', {
meterReading: formatMeterReadingData(meterReading), meterReading: formatMeterReadingData(meterReading),
bill: { bill: {
id: bill.id, id: bill.id,
billNo: bill.billNo, billNo: bill.billNo,
receivableAmount: bill.receivableAmount,
status: bill.status status: bill.status
} }
}); });
} catch (error) { } catch (error) {
await transaction.rollback();
console.error('创建抄表记录失败:', error); console.error('创建抄表记录失败:', error);
// 处理唯一约束错误
if (error.name === 'SequelizeUniqueConstraintError') { if (error.name === 'SequelizeUniqueConstraintError') {
const field = error.errors[0]?.path || '未知字段'; const field = error.errors[0]?.path || '未知字段';
if (field === 'uk_room_type_month') { if (field === 'uk_room_type_month') {
@ -235,18 +248,18 @@ const createMeterReading = async (req, res) => {
} }
return response.error(res, `数据重复: ${field} 必须唯一`, 409); return response.error(res, `数据重复: ${field} 必须唯一`, 409);
} }
// 输出详细的验证错误信息
if (error.name === 'SequelizeValidationError') { if (error.name === 'SequelizeValidationError') {
const messages = error.errors.map(e => `${e.path}: ${e.message}`).join(', '); const messages = error.errors.map(e => `${e.path}: ${e.message}`).join(', ');
console.error('验证错误详情:', messages);
return response.badRequest(res, `Validation error: ${messages}`); return response.badRequest(res, `Validation error: ${messages}`);
} }
response.serverError(res, '创建抄表记录失败', error); response.serverError(res, '创建抄表记录失败', error);
} }
}; };
// 更新抄表记录 // 更新抄表记录(事务处理 + 同步更新账单 + 账单状态校验)
const updateMeterReading = async (req, res) => { const updateMeterReading = async (req, res) => {
const transaction = await require('../config/db').transaction();
try { try {
const { id } = req.params; const { id } = req.params;
const { const {
@ -258,13 +271,21 @@ const updateMeterReading = async (req, res) => {
} = req.body; } = req.body;
const meterReading = await MeterReading.findOne({ const meterReading = await MeterReading.findOne({
where: { id, tenantId: req.user.tenantId, isDeleted: 0 } where: { id, tenantId: req.user.tenantId, isDeleted: 0 },
transaction
}); });
if (!meterReading) { if (!meterReading) {
await transaction.rollback();
return response.notFound(res, '抄表记录不存在'); return response.notFound(res, '抄表记录不存在');
} }
// 校验参数
if (currentReading < previousReading) {
await transaction.rollback();
return response.badRequest(res, '本期读数不能小于上期读数');
}
// 重新计算用量和金额 // 重新计算用量和金额
const usage = parseFloat(currentReading) - parseFloat(previousReading); const usage = parseFloat(currentReading) - parseFloat(previousReading);
const amount = usage * parseFloat(unitPrice); const amount = usage * parseFloat(unitPrice);
@ -278,21 +299,49 @@ const updateMeterReading = async (req, res) => {
readingDate: readingDate || meterReading.readingDate, readingDate: readingDate || meterReading.readingDate,
remark, remark,
updateBy: req.user.id updateBy: req.user.id
}); }, { transaction });
// 同步更新关联账单金额 // 同步更新关联账单(需校验账单状态)
if (meterReading.billId) { if (meterReading.billId) {
const bill = await Bill.findByPk(meterReading.billId); const bill = await Bill.findOne({
if (bill && bill.isDeleted === 0) { where: { id: meterReading.billId, isDeleted: 0 },
transaction
});
if (bill) {
// 账单已部分收款或已结清,不允许修改金额
if (bill.status === 'partial' || bill.status === 'paid') {
await transaction.rollback();
return response.badRequest(res, `该抄表记录对应的账单已${bill.status === 'paid' ? '结清' : '部分收款'},不允许修改金额`);
}
const categoryMap = {
'water': '水费',
'electricity': '电费',
'gas': '燃气费'
};
await bill.update({ await bill.update({
receivableAmount: amount, receivableAmount: amount,
remark: `${categoryMap[meterReading.meterType] || meterReading.meterType}账单 - ${bill.billMonth}`,
updateBy: req.user.id updateBy: req.user.id
}); }, { transaction });
} }
} }
response.success(res, '更新成功', formatMeterReadingData(meterReading)); // 提交事务
await transaction.commit();
response.success(res, '更新成功', {
meterReading: formatMeterReadingData(meterReading),
bill: meterReading.billId ? {
id: meterReading.billId,
receivableAmount: amount,
status: bill?.status
} : null
});
} catch (error) { } catch (error) {
await transaction.rollback();
console.error('更新抄表记录失败:', error); console.error('更新抄表记录失败:', error);
response.serverError(res, '更新抄表记录失败', error); response.serverError(res, '更新抄表记录失败', error);
} }
@ -383,9 +432,13 @@ const getRoomMeterReadings = async (req, res) => {
// 获取最新读数(用于创建时自动填充上期读数) // 获取最新读数(用于创建时自动填充上期读数)
const getLatestReading = async (req, res) => { const getLatestReading = async (req, res) => {
try { const { roomId, meterType } = req.query;
const { roomId, meterType } = req.query;
if (!roomId) {
return response.badRequest(res, 'roomId不能为空');
}
try {
const latestReading = await MeterReading.findOne({ const latestReading = await MeterReading.findOne({
where: { where: {
roomId, roomId,
@ -398,7 +451,8 @@ const getLatestReading = async (req, res) => {
response.success(res, '获取成功', latestReading ? { response.success(res, '获取成功', latestReading ? {
previousReading: latestReading.currentReading, previousReading: latestReading.currentReading,
billMonth: latestReading.billMonth billMonth: latestReading.billMonth,
unitPrice: latestReading.unitPrice
} : null); } : null);
} catch (error) { } catch (error) {
console.error('获取最新读数失败:', error); console.error('获取最新读数失败:', error);

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
const { Room, Apartment, Rental, Renter, Tenant } = require('../models'); const { Room, Apartment, Rental, Renter, Tenant } = require('../models');
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const { logOperation } = require('../utils/logger');
const response = require('../utils/response'); const response = require('../utils/response');
// 格式化时间(考虑时区,转换为北京时间) // 格式化时间(考虑时区,转换为北京时间)
@ -263,6 +262,28 @@ const createRoom = async (req, res) => {
try { try {
const { apartmentId, roomNumber, floor, roomType, area, monthlyPrice, yearlyPrice, deposit, sortOrder, status, rentalStatus } = req.body; const { apartmentId, roomNumber, floor, roomType, area, monthlyPrice, yearlyPrice, deposit, sortOrder, status, rentalStatus } = req.body;
// 输入验证
if (!roomNumber || roomNumber.trim() === '') {
return response.badRequest(res, '房间号不能为空');
}
if (!apartmentId) {
return response.badRequest(res, '请选择所属公寓');
}
// 验证公寓存在
const apartment = await Apartment.findByPk(apartmentId);
if (!apartment) {
return response.badRequest(res, '公寓不存在');
}
// 验证房间号唯一(同公寓下不能重复)
const existingRoom = await Room.findOne({
where: { apartmentId, roomNumber, isDeleted: 0 }
});
if (existingRoom) {
return response.badRequest(res, '该房间号已存在');
}
// 检查租户资源使用情况(仅记录日志,不阻止创建) // 检查租户资源使用情况(仅记录日志,不阻止创建)
const tenant = await Tenant.findByPk(req.user.tenantId); const tenant = await Tenant.findByPk(req.user.tenantId);
let currentRoomCount = 0; let currentRoomCount = 0;
@ -274,19 +295,7 @@ const createRoom = async (req, res) => {
// 检查是否超出限制 // 检查是否超出限制
if (currentRoomCount >= tenant.maxRooms) { if (currentRoomCount >= tenant.maxRooms) {
// 记录超额使用日志 return response.badRequest(res, `当前房间数量已达到套餐上限 ${tenant.maxRooms} 间,请升级套餐后再创建`);
await logOperation({
userId: req.user.id,
username: req.user.username,
tenantId: req.user.tenantId,
module: '房间管理',
action: '超额创建',
description: `创建房间"${roomNumber}",当前已使用 ${currentRoomCount + 1}/${tenant.maxRooms} 间(超出限制)`,
method: req.method,
path: req.path,
ip: req.ip,
status: 'success'
});
} }
} }
@ -310,7 +319,7 @@ const createRoom = async (req, res) => {
const room = await Room.create(processedData); const room = await Room.create(processedData);
response.created(res, '创建成功', { response.created(res, '创建成功', {
room, room,
warning: tenant && currentRoomCount >= tenant.maxRooms ? `当前已超出套餐限制(${tenant.maxRooms}间),续费时将收取超额费用` : null warning: null
}); });
} catch (error) { } catch (error) {
response.serverError(res, '创建房间失败', error); response.serverError(res, '创建房间失败', error);

View File

@ -111,6 +111,17 @@ const createCategory = async (req, res) => {
const userId = req.user.id; const userId = req.user.id;
const { name, code, type, sort } = req.body; const { name, code, type, sort } = req.body;
// 必填验证
if (!name || name.trim() === '') {
return response.badRequest(res, '类目名称不能为空');
}
if (!code || code.trim() === '') {
return response.badRequest(res, '类目编码不能为空');
}
if (!type) {
return response.badRequest(res, '请选择类目类型');
}
// 检查编码是否已存在 // 检查编码是否已存在
const existing = await Category.findOne({ const existing = await Category.findOne({
where: { code, tenantId, isDeleted: 0 } where: { code, tenantId, isDeleted: 0 }
@ -147,6 +158,17 @@ const updateCategory = async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { name, code, type, sort, status } = req.body; const { name, code, type, sort, status } = req.body;
// 必填验证(如果提供了字段)
if (name !== undefined && name.trim() === '') {
return response.badRequest(res, '类目名称不能为空');
}
if (code !== undefined && code.trim() === '') {
return response.badRequest(res, '类目编码不能为空');
}
if (type !== undefined && !type) {
return response.badRequest(res, '请选择类目类型');
}
const category = await Category.findOne({ const category = await Category.findOne({
where: { id, tenantId, isDeleted: 0 } where: { id, tenantId, isDeleted: 0 }
}); });

View File

@ -132,7 +132,6 @@ exports.createUser = async (req, res) => {
// 检查租户资源使用情况(仅记录日志,不阻止创建) // 检查租户资源使用情况(仅记录日志,不阻止创建)
const tenant = await Tenant.findByPk(req.user.tenantId); const tenant = await Tenant.findByPk(req.user.tenantId);
let currentUserCount = 0; let currentUserCount = 0;
let isOverage = false;
if (tenant) { if (tenant) {
// 获取当前用户数量 // 获取当前用户数量
currentUserCount = await User.count({ currentUserCount = await User.count({
@ -141,20 +140,7 @@ exports.createUser = async (req, res) => {
// 检查是否超出限制 // 检查是否超出限制
if (currentUserCount >= tenant.maxUsers) { if (currentUserCount >= tenant.maxUsers) {
isOverage = true; return response.badRequest(res, `当前用户数量已达到套餐上限 ${tenant.maxUsers} 人,请升级套餐后再创建`);
// 记录超额使用日志
await logOperation({
userId: req.user.id,
username: req.user.username,
tenantId: req.user.tenantId,
module: '用户管理',
action: '超额创建',
description: `创建用户"${username}",当前已使用 ${currentUserCount + 1}/${tenant.maxUsers} 人(超出限制)`,
method: req.method,
path: req.path,
ip: req.ip,
status: 'success'
});
} }
} }
@ -192,7 +178,7 @@ exports.createUser = async (req, res) => {
nickname: user.nickname, nickname: user.nickname,
roleId: user.roleId, roleId: user.roleId,
createTime: formatDateTime(user.createTime), createTime: formatDateTime(user.createTime),
warning: isOverage && tenant ? `当前已超出套餐限制(${tenant.maxUsers}人),续费时将收取超额费用` : null warning: null
}); });
// 记录操作日志 // 记录操作日志
@ -336,15 +322,15 @@ exports.resetUserPassword = async (req, res) => {
// 加密默认密码 // 加密默认密码
const hashedPassword = await bcrypt.hash(defaultPassword, 10); const hashedPassword = await bcrypt.hash(defaultPassword, 10);
// 更新密码 // 更新密码
await user.update({ await user.update({
password: hashedPassword, password: hashedPassword,
updateBy: req.user.id updateBy: req.user.id
}); });
response.success(res, '密码重置成功', { // 安全修复:不返回明文密码给客户端,只返回成功状态
defaultPassword // 实际场景应该通过邮件/短信发送给用户新密码
}); response.success(res, '密码重置成功,请使用新密码登录');
// 记录操作日志 // 记录操作日志
await logOperation({ await logOperation({

View File

@ -244,6 +244,9 @@ CREATE TABLE `rentals` (
`deposit` decimal(10,2) DEFAULT NULL COMMENT '押金', `deposit` decimal(10,2) DEFAULT NULL COMMENT '押金',
`paymentType` enum('monthly','quarterly','half_year','yearly') NOT NULL DEFAULT 'monthly' COMMENT '付租方式', `paymentType` enum('monthly','quarterly','half_year','yearly') NOT NULL DEFAULT 'monthly' COMMENT '付租方式',
`status` enum('active','expired','terminated') NOT NULL DEFAULT 'active' COMMENT '状态active-有效expired-到期terminated-提前终止', `status` enum('active','expired','terminated') NOT NULL DEFAULT 'active' COMMENT '状态active-有效expired-到期terminated-提前终止',
`prevRentalId` int(11) DEFAULT NULL COMMENT '上一个租约ID续租/换房链路)',
`originRentalId` int(11) DEFAULT NULL COMMENT '租约链路起点ID',
`closeReason` enum('renewed','changed_room','terminated','expired') DEFAULT NULL COMMENT '关闭原因',
`operator` varchar(50) DEFAULT NULL COMMENT '经办人', `operator` varchar(50) DEFAULT NULL COMMENT '经办人',
`remark` text COMMENT '备注', `remark` text COMMENT '备注',
`tenantId` int(11) NOT NULL COMMENT '租户ID', `tenantId` int(11) NOT NULL COMMENT '租户ID',
@ -291,6 +294,8 @@ CREATE TABLE `bills` (
`roomId` int(11) DEFAULT NULL COMMENT '房间ID可选', `roomId` int(11) DEFAULT NULL COMMENT '房间ID可选',
`renterId` int(11) DEFAULT NULL COMMENT '租客ID可选', `renterId` int(11) DEFAULT NULL COMMENT '租客ID可选',
`rentalId` int(11) DEFAULT NULL COMMENT '租赁ID可选', `rentalId` int(11) DEFAULT NULL COMMENT '租赁ID可选',
`sourceEventId` int(11) DEFAULT NULL COMMENT '来源租约事件ID',
`settlementType` enum('normal','checkout','change_room','renew') NOT NULL DEFAULT 'normal' COMMENT '结算类型',
`type` enum('income','expense') NOT NULL COMMENT '账单类型income-收入expense-支出', `type` enum('income','expense') NOT NULL COMMENT '账单类型income-收入expense-支出',
`category` varchar(50) NOT NULL COMMENT '分类rent-租金, water-水费, electricity-电费, gas-燃气费, deposit-押金, maintenance-维修费, property_fee-物业费, agency_fee-中介费, penalty-违约金, other_income-其他收入, other_expense-其他支出', `category` varchar(50) NOT NULL COMMENT '分类rent-租金, water-水费, electricity-电费, gas-燃气费, deposit-押金, maintenance-维修费, property_fee-物业费, agency_fee-中介费, penalty-违约金, other_income-其他收入, other_expense-其他支出',
`receivableAmount` decimal(10,2) NOT NULL COMMENT '应收金额', `receivableAmount` decimal(10,2) NOT NULL COMMENT '应收金额',
@ -376,6 +381,33 @@ CREATE TABLE `meter_readings` (
UNIQUE KEY `uk_room_type_month` (`roomId`, `meterType`, `billMonth`, `tenantId`, `isDeleted`) UNIQUE KEY `uk_room_type_month` (`roomId`, `meterType`, `billMonth`, `tenantId`, `isDeleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抄表记录表(水电气)'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抄表记录表(水电气)';
-- 租约事件表(用于追踪入住/续租/换房/退租)
DROP TABLE IF EXISTS `rental_events`;
CREATE TABLE `rental_events` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '事件ID',
`eventNo` varchar(50) NOT NULL COMMENT '事件编号',
`eventType` enum('checkin','renew','change_room','terminate') NOT NULL COMMENT '事件类型',
`rentalId` int(11) DEFAULT NULL COMMENT '关联租约ID',
`prevRentalId` int(11) DEFAULT NULL COMMENT '旧租约ID',
`newRentalId` int(11) DEFAULT NULL COMMENT '新租约ID',
`roomId` int(11) DEFAULT NULL COMMENT '原房间ID',
`newRoomId` int(11) DEFAULT NULL COMMENT '新房间ID',
`renterId` int(11) DEFAULT NULL COMMENT '租客ID',
`eventDate` date NOT NULL COMMENT '事件日期',
`remark` text COMMENT '备注',
`tenantId` int(11) NOT NULL COMMENT '租户ID',
`createBy` int(11) DEFAULT NULL COMMENT '创建人ID',
`isDeleted` int(11) NOT NULL DEFAULT '0' COMMENT '删除状态0未删除1已删除',
`createTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updateTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_event_no` (`eventNo`),
KEY `idx_tenant` (`tenantId`),
KEY `idx_event_type` (`eventType`),
KEY `idx_rental` (`rentalId`),
KEY `idx_event_date` (`eventDate`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租约事件表';
-- 交易记录表 -- 交易记录表
DROP TABLE IF EXISTS `transactions`; DROP TABLE IF EXISTS `transactions`;
CREATE TABLE `transactions` ( CREATE TABLE `transactions` (
@ -475,6 +507,10 @@ CREATE TABLE `orders` (
`planId` int(11) NOT NULL COMMENT '套餐ID', `planId` int(11) NOT NULL COMMENT '套餐ID',
`planName` varchar(100) NOT NULL COMMENT '套餐名称', `planName` varchar(100) NOT NULL COMMENT '套餐名称',
`months` int(11) NOT NULL DEFAULT '1' COMMENT '购买月数', `months` int(11) NOT NULL DEFAULT '1' COMMENT '购买月数',
`billingCycle` enum('monthly','yearly','custom') NOT NULL DEFAULT 'monthly' COMMENT '计费周期',
`periodStart` datetime DEFAULT NULL COMMENT '订阅周期开始时间',
`periodEnd` datetime DEFAULT NULL COMMENT '订阅周期结束时间',
`subscriptionId` int(11) DEFAULT NULL COMMENT '订阅ID',
`amount` decimal(10,2) NOT NULL COMMENT '订单金额', `amount` decimal(10,2) NOT NULL COMMENT '订单金额',
`discountAmount` decimal(10,2) DEFAULT '0.00' COMMENT '优惠金额', `discountAmount` decimal(10,2) DEFAULT '0.00' COMMENT '优惠金额',
`actualAmount` decimal(10,2) NOT NULL COMMENT '实付金额', `actualAmount` decimal(10,2) NOT NULL COMMENT '实付金额',
@ -490,9 +526,36 @@ CREATE TABLE `orders` (
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `orderNo` (`orderNo`), UNIQUE KEY `orderNo` (`orderNo`),
KEY `idx_tenant` (`tenantId`), KEY `idx_tenant` (`tenantId`),
KEY `idx_subscription` (`subscriptionId`),
KEY `idx_status` (`status`) KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
-- 租户订阅台账表
DROP TABLE IF EXISTS `tenant_subscriptions`;
CREATE TABLE `tenant_subscriptions` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '订阅ID',
`tenantId` int(11) NOT NULL COMMENT '租户ID',
`planId` int(11) NOT NULL COMMENT '套餐ID',
`orderId` int(11) DEFAULT NULL COMMENT '来源订单ID',
`status` enum('active','expired','cancelled') NOT NULL DEFAULT 'active' COMMENT '订阅状态',
`billingCycle` enum('monthly','yearly','custom') NOT NULL DEFAULT 'monthly' COMMENT '计费周期',
`months` int(11) NOT NULL DEFAULT '1' COMMENT '购买月数',
`startDate` datetime NOT NULL COMMENT '订阅开始时间',
`endDate` datetime NOT NULL COMMENT '订阅结束时间',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '实付金额',
`createBy` int(11) DEFAULT NULL COMMENT '创建人ID',
`updateBy` int(11) DEFAULT NULL COMMENT '修改人ID',
`isDeleted` int(11) NOT NULL DEFAULT '0' COMMENT '删除状态0未删除1已删除',
`createTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updateTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_tenant` (`tenantId`),
KEY `idx_plan` (`planId`),
KEY `idx_order` (`orderId`),
KEY `idx_status` (`status`),
KEY `idx_period` (`startDate`,`endDate`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户订阅台账表';
-- 支付记录表 -- 支付记录表
DROP TABLE IF EXISTS `payments`; DROP TABLE IF EXISTS `payments`;
CREATE TABLE `payments` ( CREATE TABLE `payments` (

View File

@ -10,8 +10,8 @@ SET FOREIGN_KEY_CHECKS = 0;
-- 1. 初始化默认租户(系统租户) -- 1. 初始化默认租户(系统租户)
-- 说明:租户只保留编码,不区分类型 -- 说明:租户只保留编码,不区分类型
-- ============================================ -- ============================================
INSERT IGNORE INTO `tenants` (`id`, `code`, `contactName`, `contactPhone`, `status`, `billingStatus`, `maxUsers`, `maxApartments`, `maxRooms`, `createTime`, `updateTime`) VALUES INSERT IGNORE INTO `tenants` (`id`, `code`, `contactName`, `contactPhone`, `status`, `billingStatus`, `planId`, `paidStartDate`, `paidEndDate`, `currentPeriodStart`, `currentPeriodEnd`, `maxUsers`, `maxApartments`, `maxRooms`, `createTime`, `updateTime`) VALUES
(1, 'system', '系统管理员', '13800138000', 'active', 'paid_active', 100, 1000, 10000, NOW(), NOW()); (1, 'system', '系统管理员', '13800138000', 'active', 'paid_active', 4, NOW(), DATE_ADD(NOW(), INTERVAL 10 YEAR), NOW(), DATE_ADD(NOW(), INTERVAL 10 YEAR), 100, 1000, 10000, NOW(), NOW());
-- ============================================ -- ============================================
-- 2. 初始化订阅套餐 -- 2. 初始化订阅套餐
@ -22,6 +22,9 @@ INSERT IGNORE INTO `subscription_plans` (`id`, `code`, `name`, `description`, `m
(3, 'professional', '专业版', '适合中型公寓运营商', 50, 500, 20, 299.00, 2999.00, 0, 'active', 3), (3, 'professional', '专业版', '适合中型公寓运营商', 50, 500, 20, 299.00, 2999.00, 0, 'active', 3),
(4, 'enterprise', '企业版', '适合大型公寓运营企业', 200, 2000, 100, 999.00, 9999.00, 0, 'active', 4); (4, 'enterprise', '企业版', '适合大型公寓运营企业', 200, 2000, 100, 999.00, 9999.00, 0, 'active', 4);
INSERT IGNORE INTO `tenant_subscriptions` (`id`, `tenantId`, `planId`, `orderId`, `status`, `billingCycle`, `months`, `startDate`, `endDate`, `amount`, `createTime`, `updateTime`) VALUES
(1, 1, 4, NULL, 'active', 'custom', 120, NOW(), DATE_ADD(NOW(), INTERVAL 10 YEAR), 0.00, NOW(), NOW());
-- ============================================ -- ============================================
-- 3. 初始化角色(仅租户管理员和普通用户使用) -- 3. 初始化角色(仅租户管理员和普通用户使用)
-- 说明超级管理员不需要角色通过userType判断 -- 说明超级管理员不需要角色通过userType判断
@ -100,7 +103,8 @@ INSERT IGNORE INTO `menus` (`id`, `parentId`, `name`, `code`, `type`, `path`, `c
(41, 7, '编辑租赁', 'rental_edit', 'menu', '/rental/edit/:id', 'views/rental/Edit', NULL, 2, 'hide', 'active', 1, 0, NOW(), NOW()), (41, 7, '编辑租赁', 'rental_edit', 'menu', '/rental/edit/:id', 'views/rental/Edit', NULL, 2, 'hide', 'active', 1, 0, NOW(), NOW()),
(42, 7, '租赁详情', 'rental_detail', 'menu', '/rental/detail/:id', 'views/rental/Detail', NULL, 3, 'hide', 'active', 1, 0, NOW(), NOW()), (42, 7, '租赁详情', 'rental_detail', 'menu', '/rental/detail/:id', 'views/rental/Detail', NULL, 3, 'hide', 'active', 1, 0, NOW(), NOW()),
(43, 7, '续租', 'rental_renew', 'menu', '/rental/renew/:id', 'views/rental/Renew', NULL, 4, 'hide', 'active', 1, 0, NOW(), NOW()), (43, 7, '续租', 'rental_renew', 'menu', '/rental/renew/:id', 'views/rental/Renew', NULL, 4, 'hide', 'active', 1, 0, NOW(), NOW()),
(44, 7, '退租', 'rental_checkout', 'menu', '/rental/checkout/:id', 'views/rental/Checkout', NULL, 5, 'hide', 'active', 1, 0, NOW(), NOW()); (44, 7, '退租', 'rental_checkout', 'menu', '/rental/checkout/:id', 'views/rental/Checkout', NULL, 5, 'hide', 'active', 1, 0, NOW(), NOW()),
(75, 7, '换房', 'rental_change_room', 'menu', '/rental/change-room/:id', 'views/rental/ChangeRoom', NULL, 6, 'hide', 'active', 1, 0, NOW(), NOW());
-- 5. 财务管理(父菜单) -- 5. 财务管理(父菜单)
INSERT IGNORE INTO `menus` (`id`, `name`, `code`, `type`, `path`, `component`, `icon`, `sort`, `visible`, `status`, `isBasic`, `isDeleted`, `createTime`, `updateTime`) VALUES INSERT IGNORE INTO `menus` (`id`, `name`, `code`, `type`, `path`, `component`, `icon`, `sort`, `visible`, `status`, `isBasic`, `isDeleted`, `createTime`, `updateTime`) VALUES
@ -288,6 +292,10 @@ INSERT IGNORE INTO `pricing_configs` (`id`, `overageApartmentPrice`, `overageRoo
INSERT IGNORE INTO `settings` (`id`, `key`, `value`, `description`, `tenantId`, `createTime`, `updateTime`) VALUES INSERT IGNORE INTO `settings` (`id`, `key`, `value`, `description`, `tenantId`, `createTime`, `updateTime`) VALUES
(1, 'expireReminderDays', '30', '房间到期提前提醒天数', 1, NOW(), NOW()); (1, 'expireReminderDays', '30', '房间到期提前提醒天数', 1, NOW(), NOW());
-- 结算策略:换房/退租即时结算
INSERT IGNORE INTO `settings` (`id`, `key`, `value`, `description`, `tenantId`, `createTime`, `updateTime`) VALUES
(2, 'rentalSettlementMode', 'instant', '租约结算模式instant-即时结算', 1, NOW(), NOW());
-- ============================================ -- ============================================
-- 8. 初始化支付设置 -- 8. 初始化支付设置
-- ============================================ -- ============================================

View File

@ -44,6 +44,17 @@ const Bill = sequelize.define('Bill', {
}, },
comment: '租赁ID可选' comment: '租赁ID可选'
}, },
sourceEventId: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Rental event/source id'
},
settlementType: {
type: DataTypes.ENUM('normal', 'checkout', 'change_room', 'renew'),
allowNull: false,
defaultValue: 'normal',
comment: 'Settlement type'
},
type: { type: {
type: DataTypes.ENUM('income', 'expense'), type: DataTypes.ENUM('income', 'expense'),
allowNull: false, allowNull: false,

View File

@ -6,101 +6,122 @@ const Order = sequelize.define('Order', {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
primaryKey: true, primaryKey: true,
autoIncrement: true, autoIncrement: true,
comment: '订单ID' comment: 'Order ID'
}, },
orderNo: { orderNo: {
type: DataTypes.STRING(50), type: DataTypes.STRING(50),
allowNull: false, allowNull: false,
comment: '订单编号' comment: 'Order number'
}, },
tenantId: { tenantId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
comment: '租户ID' comment: 'Tenant ID'
}, },
planId: { planId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
comment: '套餐ID' comment: 'Plan ID'
}, },
planName: { planName: {
type: DataTypes.STRING(100), type: DataTypes.STRING(100),
allowNull: false, allowNull: false,
comment: '套餐名称' comment: 'Plan name snapshot'
}, },
months: { months: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
defaultValue: 1, defaultValue: 1,
comment: '购买月数' comment: 'Purchased months'
},
billingCycle: {
type: DataTypes.ENUM('monthly', 'yearly', 'custom'),
allowNull: false,
defaultValue: 'monthly',
comment: 'Billing cycle'
},
periodStart: {
type: DataTypes.DATE,
allowNull: true,
comment: 'Subscription period start'
},
periodEnd: {
type: DataTypes.DATE,
allowNull: true,
comment: 'Subscription period end'
},
subscriptionId: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Subscription ID'
}, },
amount: { amount: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
allowNull: false, allowNull: false,
comment: '订单金额' comment: 'Order amount'
}, },
discountAmount: { discountAmount: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
defaultValue: 0.00, defaultValue: 0.00,
comment: '优惠金额' comment: 'Discount amount'
}, },
actualAmount: { actualAmount: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
allowNull: false, allowNull: false,
comment: '实付金额' comment: 'Payable amount'
}, },
status: { status: {
type: DataTypes.ENUM('pending', 'paid', 'cancelled'), type: DataTypes.ENUM('pending', 'paid', 'cancelled'),
defaultValue: 'pending', defaultValue: 'pending',
comment: '状态pending-待支付paid-已支付cancelled-已取消' comment: 'Order status'
}, },
paidTime: { paidTime: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: true, allowNull: true,
comment: '支付时间' comment: 'Paid time'
}, },
expireTime: { expireTime: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,
comment: '过期时间' comment: 'Order expiration time'
}, },
remark: { remark: {
type: DataTypes.STRING(255), type: DataTypes.STRING(255),
allowNull: true, allowNull: true,
comment: '备注' comment: 'Remark'
}, },
createBy: { createBy: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: true, allowNull: true,
comment: '创建人ID' comment: 'Created by'
}, },
updateBy: { updateBy: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: true, allowNull: true,
comment: '修改人ID' comment: 'Updated by'
}, },
isDeleted: { isDeleted: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
defaultValue: 0, defaultValue: 0,
comment: '删除状态0未删除1已删除' comment: 'Soft delete flag'
}, },
createTime: { createTime: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,
defaultValue: DataTypes.NOW, defaultValue: DataTypes.NOW,
comment: '创建时间' comment: 'Created time'
}, },
updateTime: { updateTime: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,
defaultValue: DataTypes.NOW, defaultValue: DataTypes.NOW,
comment: '更新时间' comment: 'Updated time'
} }
}, { }, {
tableName: 'orders', tableName: 'orders',
timestamps: false, timestamps: false,
comment: '订单表' comment: 'Orders'
}); });
module.exports = Order; module.exports = Order;

View File

@ -65,6 +65,21 @@ const Rental = sequelize.define('Rental', {
defaultValue: 'active', defaultValue: 'active',
comment: '租约状态active生效中expired已到期terminated提前终止' comment: '租约状态active生效中expired已到期terminated提前终止'
}, },
prevRentalId: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Previous rental in renew/change-room chain'
},
originRentalId: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'First rental in the lifecycle chain'
},
closeReason: {
type: DataTypes.ENUM('renewed', 'changed_room', 'terminated', 'expired'),
allowNull: true,
comment: 'Reason this rental was closed'
},
remark: { remark: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: true, allowNull: true,
@ -112,4 +127,4 @@ const Rental = sequelize.define('Rental', {
Rental.belongsTo(Room, { foreignKey: 'roomId' }); Rental.belongsTo(Room, { foreignKey: 'roomId' });
Rental.belongsTo(Renter, { foreignKey: 'renterId' }); Rental.belongsTo(Renter, { foreignKey: 'renterId' });
module.exports = Rental; module.exports = Rental;

View File

@ -6,75 +6,86 @@ const SubscriptionPlan = sequelize.define('SubscriptionPlan', {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
primaryKey: true, primaryKey: true,
autoIncrement: true, autoIncrement: true,
comment: '套餐ID' comment: 'Plan ID'
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true,
comment: 'Plan code'
}, },
name: { name: {
type: DataTypes.STRING(100), type: DataTypes.STRING(100),
allowNull: false, allowNull: false,
comment: '套餐名称' comment: 'Plan name'
}, },
description: { description: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: true, allowNull: true,
comment: '套餐描述' comment: 'Plan description'
}, },
maxApartments: { maxApartments: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
defaultValue: 10, defaultValue: 10,
comment: '最大公寓数' comment: 'Apartment limit'
}, },
maxRooms: { maxRooms: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
defaultValue: 50, defaultValue: 50,
comment: '最大房间数' comment: 'Room limit'
}, },
maxUsers: { maxUsers: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
defaultValue: 5, defaultValue: 5,
comment: '最大用户数' comment: 'User limit'
}, },
monthlyPrice: { monthlyPrice: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
defaultValue: 0, defaultValue: 0,
comment: '月费价格' comment: 'Monthly price'
},
yearlyPrice: {
type: DataTypes.DECIMAL(10, 2),
defaultValue: 0,
comment: 'Yearly price'
}, },
isDefault: { isDefault: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
defaultValue: false, defaultValue: false,
comment: '是否默认套餐' comment: 'Whether this is the default plan'
}, },
status: { status: {
type: DataTypes.ENUM('active', 'inactive'), type: DataTypes.ENUM('active', 'inactive'),
defaultValue: 'active', defaultValue: 'active',
comment: '套餐状态active-启用inactive-禁用' comment: 'Plan status'
}, },
sort: { sort: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
defaultValue: 0, defaultValue: 0,
comment: '排序' comment: 'Sort order'
}, },
isDeleted: { isDeleted: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
defaultValue: 0, defaultValue: 0,
comment: '删除状态0未删除1已删除' comment: 'Soft delete flag'
}, },
createTime: { createTime: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,
defaultValue: DataTypes.NOW, defaultValue: DataTypes.NOW,
comment: '创建时间' comment: 'Created time'
}, },
updateTime: { updateTime: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,
defaultValue: DataTypes.NOW, defaultValue: DataTypes.NOW,
comment: '更新时间' comment: 'Updated time'
} }
}, { }, {
tableName: 'subscription_plans', tableName: 'subscription_plans',
timestamps: false, timestamps: false,
comment: '订阅套餐表' comment: 'Subscription plans'
}); });
module.exports = SubscriptionPlan; module.exports = SubscriptionPlan;

View File

@ -6,86 +6,79 @@ const TenantBillingDetail = sequelize.define('TenantBillingDetail', {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
primaryKey: true, primaryKey: true,
autoIncrement: true, autoIncrement: true,
comment: '明细ID' comment: 'ID'
}, },
tenantId: { tenantId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
comment: '租户ID' comment: 'Tenant ID'
}, },
billingPeriodStart: { billingPeriod: {
type: DataTypes.DATEONLY, type: DataTypes.STRING(7),
allowNull: false, allowNull: false,
comment: '计费周期开始' comment: 'Billing period, format YYYY-MM'
}, },
billingPeriodEnd: { planId: {
type: DataTypes.DATEONLY, type: DataTypes.INTEGER,
allowNull: false, allowNull: true,
comment: '计费周期结束' comment: 'Subscription plan ID'
}, },
baseAmount: { baseAmount: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0, defaultValue: 0,
comment: '基础费用' comment: 'Base subscription amount'
}, },
overageApartmentCount: { extraAmount: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '超额公寓数'
},
overageApartmentAmount: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0, defaultValue: 0,
comment: '超额公寓费用' comment: 'Overage amount'
}, },
overageRoomCount: { discountAmount: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '超额房间数'
},
overageRoomAmount: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0, defaultValue: 0,
comment: '超额房间费用' comment: 'Discount amount'
},
overageUserCount: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '超额用户数'
},
overageUserAmount: {
type: DataTypes.DECIMAL(10, 2),
defaultValue: 0,
comment: '超额用户费用'
},
totalOverageAmount: {
type: DataTypes.DECIMAL(10, 2),
defaultValue: 0,
comment: '超额费用合计'
}, },
totalAmount: { totalAmount: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0, defaultValue: 0,
comment: '总费用' comment: 'Total amount'
}, },
status: { status: {
type: DataTypes.ENUM('calculated', 'billed', 'paid', 'waived'), type: DataTypes.ENUM('pending', 'paid', 'overdue'),
defaultValue: 'calculated', allowNull: false,
comment: '状态' defaultValue: 'pending',
comment: 'Billing detail status'
}, },
orderId: { paidTime: {
type: DataTypes.INTEGER, type: DataTypes.DATE,
allowNull: true, allowNull: true,
comment: '关联订单ID' comment: 'Paid time'
},
createTime: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
comment: 'Created time'
},
updateTime: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
comment: 'Updated time'
} }
}, { }, {
tableName: 'tenant_billing_details', tableName: 'tenant_billing_details',
timestamps: false, timestamps: false,
indexes: [ indexes: [
{ name: 'idx_tenant_period', fields: ['tenantId', 'billingPeriodStart', 'billingPeriodEnd'] }, { name: 'idx_tenant_period', unique: true, fields: ['tenantId', 'billingPeriod'] },
{ name: 'idx_tenant', fields: ['tenantId'] },
{ name: 'idx_status', fields: ['status'] } { name: 'idx_status', fields: ['status'] }
], ],
comment: '租户计费明细表' comment: 'Tenant billing detail table'
}); });
module.exports = TenantBillingDetail; module.exports = TenantBillingDetail;

View File

@ -0,0 +1,94 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/db');
const TenantSubscription = sequelize.define('TenantSubscription', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
comment: 'Subscription ID'
},
tenantId: {
type: DataTypes.INTEGER,
allowNull: false,
comment: 'Tenant ID'
},
planId: {
type: DataTypes.INTEGER,
allowNull: false,
comment: 'Plan ID'
},
orderId: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Source order ID'
},
status: {
type: DataTypes.ENUM('active', 'expired', 'cancelled'),
allowNull: false,
defaultValue: 'active',
comment: 'Subscription status'
},
billingCycle: {
type: DataTypes.ENUM('monthly', 'yearly', 'custom'),
allowNull: false,
defaultValue: 'monthly',
comment: 'Billing cycle'
},
months: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
comment: 'Purchased months'
},
startDate: {
type: DataTypes.DATE,
allowNull: false,
comment: 'Subscription start'
},
endDate: {
type: DataTypes.DATE,
allowNull: false,
comment: 'Subscription end'
},
amount: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0,
comment: 'Paid amount'
},
createBy: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Created by'
},
updateBy: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Updated by'
},
isDeleted: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Soft delete flag'
},
createTime: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
comment: 'Created time'
},
updateTime: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
comment: 'Updated time'
}
}, {
tableName: 'tenant_subscriptions',
timestamps: false,
comment: 'Tenant subscription ledger'
});
module.exports = TenantSubscription;

View File

@ -18,6 +18,7 @@ const Order = require('./Order');
const Payment = require('./Payment'); const Payment = require('./Payment');
const TenantResourceUsage = require('./TenantResourceUsage'); const TenantResourceUsage = require('./TenantResourceUsage');
const TenantBillingDetail = require('./TenantBillingDetail'); const TenantBillingDetail = require('./TenantBillingDetail');
const TenantSubscription = require('./TenantSubscription');
const Coupon = require('./Coupon'); const Coupon = require('./Coupon');
const TenantCouponUsage = require('./TenantCouponUsage'); const TenantCouponUsage = require('./TenantCouponUsage');
const BillingLog = require('./BillingLog'); const BillingLog = require('./BillingLog');
@ -141,9 +142,12 @@ TenantResourceUsage.belongsTo(Tenant, { foreignKey: 'tenantId', as: 'tenant' });
Tenant.hasMany(TenantBillingDetail, { foreignKey: 'tenantId', as: 'billingDetails' }); Tenant.hasMany(TenantBillingDetail, { foreignKey: 'tenantId', as: 'billingDetails' });
TenantBillingDetail.belongsTo(Tenant, { foreignKey: 'tenantId', as: 'tenant' }); TenantBillingDetail.belongsTo(Tenant, { foreignKey: 'tenantId', as: 'tenant' });
// 计费明细与订单关联 Tenant.hasMany(TenantSubscription, { foreignKey: 'tenantId', as: 'subscriptions' });
TenantBillingDetail.belongsTo(Order, { foreignKey: 'orderId', as: 'order' }); TenantSubscription.belongsTo(Tenant, { foreignKey: 'tenantId', as: 'tenant' });
Order.hasMany(TenantBillingDetail, { foreignKey: 'orderId', as: 'billingDetails' }); TenantSubscription.belongsTo(SubscriptionPlan, { foreignKey: 'planId', as: 'subscriptionPlan' });
TenantSubscription.belongsTo(Order, { foreignKey: 'orderId', as: 'order' });
Order.hasOne(TenantSubscription, { foreignKey: 'orderId', as: 'subscription' });
// 优惠券与订单多对多关联(通过使用记录) // 优惠券与订单多对多关联(通过使用记录)
Coupon.belongsToMany(Tenant, { through: TenantCouponUsage, foreignKey: 'couponId', otherKey: 'tenantId', as: 'tenants' }); Coupon.belongsToMany(Tenant, { through: TenantCouponUsage, foreignKey: 'couponId', otherKey: 'tenantId', as: 'tenants' });
@ -182,6 +186,7 @@ module.exports = {
Payment, Payment,
TenantResourceUsage, TenantResourceUsage,
TenantBillingDetail, TenantBillingDetail,
TenantSubscription,
Coupon, Coupon,
TenantCouponUsage, TenantCouponUsage,
BillingLog, BillingLog,

View File

@ -10,6 +10,14 @@ router.get('/:id', rentalController.getRentalById);
router.post('/', rentalController.createRental); router.post('/', rentalController.createRental);
router.put('/:id', rentalController.updateRental); router.put('/:id', rentalController.updateRental);
router.delete('/:id', rentalController.deleteRental); router.delete('/:id', rentalController.deleteRental);
// 续租接口
router.post('/:id/renew', rentalController.renewRental);
// 换房接口
router.post('/:id/change-room', rentalController.changeRoom);
// 退租接口
router.post('/:id/terminate', rentalController.terminateRental); router.post('/:id/terminate', rentalController.terminateRental);
module.exports = router; module.exports = router;

View File

@ -1,324 +1,267 @@
/** const { Tenant, SubscriptionPlan, TenantSubscription, Apartment, Room, User } = require('../models');
* 计费服务 - 处理租户到期检查超额计算等
*/
const { Tenant, SubscriptionPlan, Order, PricingConfig } = require('../models');
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const { logOperation } = require('../utils/logger'); const { logOperation } = require('../utils/logger');
/** const addCalendarMonths = (date, months) => {
* 检查并更新租户到期状态 const source = new Date(date);
* 每天执行一次 const day = source.getDate();
*/ const target = new Date(source);
target.setDate(1);
target.setMonth(target.getMonth() + Number(months));
const lastDayOfTargetMonth = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
target.setDate(Math.min(day, lastDayOfTargetMonth));
return target;
};
const getBillingCycle = (months) => {
if (Number(months) === 12) return 'yearly';
if (Number(months) === 1) return 'monthly';
return 'custom';
};
const normalizeAmount = (value) => Number.parseFloat(Number(value || 0).toFixed(2));
const checkTenantExpiration = async () => { const checkTenantExpiration = async () => {
try { const now = new Date();
const now = new Date();
console.log(`[${now.toISOString()}] 开始检查租户到期状态...`);
// 1. 检查试用期到期的租户 const trialExpiredTenants = await Tenant.findAll({
const trialExpiredTenants = await Tenant.findAll({ where: {
where: { billingStatus: 'trial_active',
billingStatus: 'trial_active', trialEndDate: { [Op.lt]: now }
trialEndDate: {
[Op.lt]: now
}
}
});
for (const tenant of trialExpiredTenants) {
await tenant.update({ billingStatus: 'trial_expired' });
console.log(`租户 ${tenant.name} (ID: ${tenant.id}) 试用期已过期`);
// 记录日志
await logOperation({
tenantId: tenant.id,
module: '计费管理',
action: '试用期到期',
description: `租户 ${tenant.name} 试用期已过期`,
status: 'success'
});
} }
});
// 2. 检查付费期到期的租户 for (const tenant of trialExpiredTenants) {
const paidExpiredTenants = await Tenant.findAll({ await tenant.update({ billingStatus: 'trial_expired' });
where: {
billingStatus: 'paid_active',
paidEndDate: {
[Op.lt]: now
}
}
});
for (const tenant of paidExpiredTenants) {
await tenant.update({ billingStatus: 'paid_expired' });
console.log(`租户 ${tenant.name} (ID: ${tenant.id}) 付费期已过期`);
// 记录日志
await logOperation({
tenantId: tenant.id,
module: '计费管理',
action: '付费期到期',
description: `租户 ${tenant.name} 付费期已过期`,
status: 'success'
});
}
// 3. 检查数据保留期超过90天的租户可选发送提醒或清理
const retentionLimit = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
const longExpiredTenants = await Tenant.findAll({
where: {
billingStatus: {
[Op.in]: ['trial_expired', 'paid_expired']
},
updateTime: {
[Op.lt]: retentionLimit
}
}
});
if (longExpiredTenants.length > 0) {
console.log(`发现 ${longExpiredTenants.length} 个租户超过90天数据保留期`);
// 这里可以添加数据清理逻辑或发送提醒
}
console.log(`[${new Date().toISOString()}] 租户到期检查完成`);
console.log(`- 试用期过期: ${trialExpiredTenants.length}`);
console.log(`- 付费期过期: ${paidExpiredTenants.length}`);
return {
trialExpired: trialExpiredTenants.length,
paidExpired: paidExpiredTenants.length,
longExpired: longExpiredTenants.length
};
} catch (error) {
console.error('检查租户到期状态失败:', error);
throw error;
}
};
/**
* 获取即将到期的租户用于提醒
* @param {number} days - 提前多少天提醒
*/
const getUpcomingExpiredTenants = async (days = 7) => {
try {
const reminderDate = new Date();
reminderDate.setDate(reminderDate.getDate() + days);
const upcomingTenants = await Tenant.findAll({
where: {
[Op.or]: [
{
billingStatus: 'trial_active',
trialEndDate: {
[Op.lte]: reminderDate,
[Op.gte]: new Date()
}
},
{
billingStatus: 'paid_active',
paidEndDate: {
[Op.lte]: reminderDate,
[Op.gte]: new Date()
}
}
]
},
include: [{
model: SubscriptionPlan,
as: 'subscriptionPlan'
}]
});
return upcomingTenants;
} catch (error) {
console.error('获取即将到期租户失败:', error);
throw error;
}
};
/**
* 计算租户的超额使用量
* @param {number} tenantId - 租户ID
*/
const calculateOverage = async (tenantId) => {
try {
const tenant = await Tenant.findByPk(tenantId, {
include: [{
model: SubscriptionPlan,
as: 'subscriptionPlan'
}]
});
if (!tenant || !tenant.subscriptionPlan) {
throw new Error('租户或套餐不存在');
}
const plan = tenant.subscriptionPlan;
// 获取当前使用量
const { Apartment, Room, User } = require('../models');
const [apartmentCount, roomCount, userCount] = await Promise.all([
Apartment.count({ where: { tenantId, isDeleted: 0 } }),
Room.count({ where: { tenantId, isDeleted: 0 } }),
User.count({ where: { tenantId, isDeleted: 0 } })
]);
// 计算超额
const overageApartments = Math.max(0, apartmentCount - plan.maxApartments);
const overageRooms = Math.max(0, roomCount - plan.maxRooms);
const overageUsers = Math.max(0, userCount - plan.maxUsers);
return {
usage: {
apartments: apartmentCount,
rooms: roomCount,
users: userCount
},
limits: {
apartments: plan.maxApartments,
rooms: plan.maxRooms,
users: plan.maxUsers
},
overage: {
apartments: overageApartments,
rooms: overageRooms,
users: overageUsers
}
};
} catch (error) {
console.error('计算超额使用量失败:', error);
throw error;
}
};
/**
* 计算续费订单金额
* @param {number} tenantId - 租户ID
* @param {number} planId - 套餐ID
* @param {number} months - 购买月数
*/
const calculateRenewalAmount = async (tenantId, planId, months) => {
try {
// 获取套餐信息
const plan = await SubscriptionPlan.findByPk(planId);
if (!plan) {
throw new Error('套餐不存在');
}
// 获取当前价格配置
const pricingConfig = await PricingConfig.findOne({
where: { isActive: true },
order: [['effectiveDate', 'DESC']]
});
if (!pricingConfig) {
throw new Error('价格配置不存在');
}
// 计算超额使用费
const overage = await calculateOverage(tenantId);
// 基础费用 = 套餐月费 × 月数
const baseAmount = plan.monthlyPrice * months;
// 超额费用 = (超额公寓×单价 + 超额房间×单价 + 超额用户×单价) × 月数
const overageAmount = (
overage.overage.apartments * pricingConfig.overageApartmentPrice +
overage.overage.rooms * pricingConfig.overageRoomPrice +
overage.overage.users * pricingConfig.overageUserPrice
) * months;
const totalAmount = baseAmount + overageAmount;
return {
baseAmount: parseFloat(baseAmount.toFixed(2)),
overageAmount: parseFloat(overageAmount.toFixed(2)),
totalAmount: parseFloat(totalAmount.toFixed(2)),
details: {
planName: plan.name,
monthlyPrice: plan.monthlyPrice,
months,
overage
}
};
} catch (error) {
console.error('计算续费金额失败:', error);
throw error;
}
};
/**
* 处理支付成功后的租户状态更新
* @param {number} tenantId - 租户ID
* @param {number} months - 续费月数
* @param {number} planId - 套餐ID可选用于更新资源配置
*/
const processPaymentSuccess = async (tenantId, months, planId = null) => {
try {
const tenant = await Tenant.findByPk(tenantId);
if (!tenant) {
throw new Error('租户不存在');
}
const now = new Date();
let paidStartDate, paidEndDate, billingStatus;
// 如果当前是付费期,则延长
if (tenant.billingStatus === 'paid_active' && tenant.paidEndDate && tenant.paidEndDate > now) {
paidStartDate = tenant.paidStartDate;
paidEndDate = new Date(tenant.paidEndDate.getTime() + months * 30 * 24 * 60 * 60 * 1000);
billingStatus = 'paid_active';
} else {
// 新付费期或从过期状态恢复
paidStartDate = now;
paidEndDate = new Date(now.getTime() + months * 30 * 24 * 60 * 60 * 1000);
billingStatus = 'paid_active';
}
// 构建更新数据
const updateData = {
billingStatus,
paidStartDate,
paidEndDate,
currentPeriodStart: paidStartDate,
currentPeriodEnd: paidEndDate
};
// 如果有套餐ID更新租户套餐和资源配置
if (planId) {
const plan = await SubscriptionPlan.findByPk(planId);
if (plan) {
updateData.planId = planId;
updateData.maxApartments = plan.maxApartments;
updateData.maxRooms = plan.maxRooms;
updateData.maxUsers = plan.maxUsers;
}
}
await tenant.update(updateData);
console.log(`租户 ${tenant.name} (ID: ${tenantId}) 续费成功,有效期至 ${paidEndDate.toISOString()}`);
// 记录日志
await logOperation({ await logOperation({
tenantId: tenant.id, tenantId: tenant.id,
module: '计费管理', module: '计费管理',
action: '续费成功', action: '试用期到期',
description: `租户续费 ${months} 个月,有效期至 ${paidEndDate.toLocaleDateString()}`, description: `租户 ${tenant.code} 试用期已过期`,
status: 'success' status: 'success'
}); });
return {
billingStatus,
paidStartDate,
paidEndDate
};
} catch (error) {
console.error('处理支付成功失败:', error);
throw error;
} }
const paidExpiredTenants = await Tenant.findAll({
where: {
billingStatus: 'paid_active',
paidEndDate: { [Op.lt]: now }
}
});
for (const tenant of paidExpiredTenants) {
await tenant.update({ billingStatus: 'paid_expired' });
await TenantSubscription.update(
{ status: 'expired' },
{
where: {
tenantId: tenant.id,
status: 'active',
endDate: { [Op.lt]: now }
}
}
);
await logOperation({
tenantId: tenant.id,
module: '计费管理',
action: '付费期到期',
description: `租户 ${tenant.code} 付费期已过期`,
status: 'success'
});
}
return {
trialExpired: trialExpiredTenants.length,
paidExpired: paidExpiredTenants.length,
longExpired: 0
};
};
const getUpcomingExpiredTenants = async (days = 7) => {
const reminderDate = new Date();
reminderDate.setDate(reminderDate.getDate() + days);
return Tenant.findAll({
where: {
[Op.or]: [
{
billingStatus: 'trial_active',
trialEndDate: {
[Op.lte]: reminderDate,
[Op.gte]: new Date()
}
},
{
billingStatus: 'paid_active',
paidEndDate: {
[Op.lte]: reminderDate,
[Op.gte]: new Date()
}
}
]
},
include: [{
model: SubscriptionPlan,
as: 'subscriptionPlan'
}]
});
};
const calculateOverage = async (tenantId) => {
const tenant = await Tenant.findByPk(tenantId, {
include: [{
model: SubscriptionPlan,
as: 'subscriptionPlan'
}]
});
if (!tenant) {
throw new Error('租户不存在');
}
const [apartmentCount, roomCount, userCount] = await Promise.all([
Apartment.count({ where: { tenantId, isDeleted: 0 } }),
Room.count({ where: { tenantId, isDeleted: 0 } }),
User.count({ where: { tenantId, isDeleted: 0 } })
]);
return {
usage: {
apartments: apartmentCount,
rooms: roomCount,
users: userCount
},
limits: {
apartments: tenant.maxApartments,
rooms: tenant.maxRooms,
users: tenant.maxUsers
},
overage: {
apartments: Math.max(0, apartmentCount - tenant.maxApartments),
rooms: Math.max(0, roomCount - tenant.maxRooms),
users: Math.max(0, userCount - tenant.maxUsers)
}
};
};
const calculateRenewalAmount = async (tenantId, planId, months) => {
const plan = await SubscriptionPlan.findOne({
where: { id: planId, status: 'active', isDeleted: 0 }
});
if (!plan) {
throw new Error('套餐不存在或已停用');
}
const normalizedMonths = Number.parseInt(months, 10);
if (!Number.isInteger(normalizedMonths) || normalizedMonths < 1 || normalizedMonths > 36) {
throw new Error('购买月数必须在1-36之间');
}
const billingCycle = getBillingCycle(normalizedMonths);
const monthlyPrice = Number(plan.monthlyPrice || 0);
const yearlyPrice = Number(plan.yearlyPrice || 0);
const baseAmount = billingCycle === 'yearly' && yearlyPrice > 0
? yearlyPrice
: monthlyPrice * normalizedMonths;
return {
baseAmount: normalizeAmount(baseAmount),
overageAmount: 0,
discountAmount: 0,
totalAmount: normalizeAmount(baseAmount),
billingCycle,
details: {
planName: plan.name,
monthlyPrice: normalizeAmount(monthlyPrice),
yearlyPrice: normalizeAmount(yearlyPrice),
months: normalizedMonths,
billingCycle
}
};
};
const processPaymentSuccess = async ({ order, payment, operatorId, transaction }) => {
const tenant = await Tenant.findByPk(order.tenantId, { transaction });
if (!tenant) {
throw new Error('租户不存在');
}
const plan = await SubscriptionPlan.findByPk(order.planId, { transaction });
if (!plan) {
throw new Error('套餐不存在');
}
const now = new Date();
const currentEnd = tenant.paidEndDate ? new Date(tenant.paidEndDate) : null;
const startDate = tenant.billingStatus === 'paid_active' && currentEnd && currentEnd > now
? currentEnd
: now;
const endDate = addCalendarMonths(startDate, order.months);
const billingCycle = order.billingCycle || getBillingCycle(order.months);
await TenantSubscription.update(
{ status: 'expired' },
{
where: {
tenantId: tenant.id,
status: 'active',
isDeleted: 0
},
transaction
}
);
const subscription = await TenantSubscription.create({
tenantId: tenant.id,
planId: plan.id,
orderId: order.id,
status: 'active',
billingCycle,
months: order.months,
startDate,
endDate,
amount: order.actualAmount,
createBy: operatorId,
updateBy: operatorId
}, { transaction });
await tenant.update({
planId: plan.id,
billingStatus: 'paid_active',
paidStartDate: startDate,
paidEndDate: endDate,
currentPeriodStart: startDate,
currentPeriodEnd: endDate,
maxApartments: plan.maxApartments,
maxRooms: plan.maxRooms,
maxUsers: plan.maxUsers,
updateBy: operatorId
}, { transaction });
await order.update({
subscriptionId: subscription.id,
periodStart: startDate,
periodEnd: endDate
}, { transaction });
await logOperation({
tenantId: tenant.id,
userId: operatorId,
module: '计费管理',
action: '订阅生效',
description: `租户订阅 ${plan.name} ${order.months} 个月,有效期至 ${endDate.toLocaleDateString()}`,
status: 'success'
});
return {
subscription,
billingStatus: 'paid_active',
paidStartDate: startDate,
paidEndDate: endDate,
paymentId: payment ? payment.id : null
};
}; };
module.exports = { module.exports = {

View File

@ -0,0 +1,279 @@
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
};