This commit is contained in:
parent
a4ed46d10a
commit
da941c600e
|
|
@ -1,6 +1,5 @@
|
|||
const { Apartment, Tenant } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
const { logOperation } = require('../utils/logger');
|
||||
const response = require('../utils/response');
|
||||
|
||||
// 格式化时间(考虑时区,转换为北京时间)
|
||||
|
|
@ -91,6 +90,14 @@ const createApartment = async (req, res) => {
|
|||
try {
|
||||
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);
|
||||
let currentApartmentCount = 0;
|
||||
|
|
@ -102,19 +109,7 @@ const createApartment = async (req, res) => {
|
|||
|
||||
// 检查是否超出限制
|
||||
if (currentApartmentCount >= 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'
|
||||
});
|
||||
return response.badRequest(res, `当前公寓数量已达到套餐上限 ${tenant.maxApartments} 栋,请升级套餐后再创建`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +123,7 @@ const createApartment = async (req, res) => {
|
|||
});
|
||||
response.created(res, '创建成功', {
|
||||
apartment,
|
||||
warning: tenant && currentApartmentCount >= tenant.maxApartments ? `当前已超出套餐限制(${tenant.maxApartments}栋),续费时将收取超额费用` : null
|
||||
warning: null
|
||||
});
|
||||
} catch (error) {
|
||||
response.serverError(res, '创建公寓失败', error);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const { Bill, MeterReading, Room, Renter, Rental, BillPayment } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
const response = require('../utils/response');
|
||||
const rentalBillingService = require('../services/rentalBillingService');
|
||||
|
||||
// 生成账单编号
|
||||
const generateBillNo = () => {
|
||||
|
|
@ -261,8 +262,9 @@ const createBill = async (req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
// 更新账单
|
||||
// 更新账单(支持部分更新)
|
||||
const updateBill = async (req, res) => {
|
||||
const transaction = await require('../config/db').transaction();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
|
|
@ -274,94 +276,171 @@ const updateBill = async (req, res) => {
|
|||
receivableAmount,
|
||||
billMonth,
|
||||
billDate,
|
||||
remark
|
||||
remark,
|
||||
status
|
||||
} = req.body;
|
||||
|
||||
const bill = await Bill.findOne({
|
||||
where: { id, tenantId: req.user.tenantId, isDeleted: 0 }
|
||||
where: { id, tenantId: req.user.tenantId, isDeleted: 0 },
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!bill) {
|
||||
await transaction.rollback();
|
||||
return response.notFound(res, '账单不存在');
|
||||
}
|
||||
|
||||
await bill.update({
|
||||
roomId: roomId || null,
|
||||
renterId: renterId || null,
|
||||
rentalId: rentalId || null,
|
||||
type,
|
||||
category,
|
||||
receivableAmount,
|
||||
billMonth: billMonth || null,
|
||||
billDate: billDate || bill.billDate,
|
||||
remark,
|
||||
updateBy: req.user.id
|
||||
});
|
||||
const hasReceived = ['paid', 'partial'].includes(bill.status) || parseFloat(bill.receivedAmount || 0) > 0;
|
||||
const financialFieldsChanged = [roomId, renterId, rentalId, type, category, receivableAmount, billMonth, billDate, status]
|
||||
.some(value => value !== undefined);
|
||||
|
||||
if (hasReceived && financialFieldsChanged) {
|
||||
await transaction.rollback();
|
||||
return response.badRequest(res, '该账单已有收款记录,不能修改金额、状态、类型、日期或关联对象');
|
||||
}
|
||||
|
||||
if (status === 'paid' && parseFloat(bill.receivedAmount || 0) < parseFloat(bill.receivableAmount || 0)) {
|
||||
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));
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('更新账单失败:', error);
|
||||
response.serverError(res, '更新账单失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除账单(软删除)
|
||||
// 删除账单(软删除 + 级联删除支付记录)
|
||||
const deleteBill = async (req, res) => {
|
||||
const transaction = await require('../config/db').transaction();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const bill = await Bill.findOne({
|
||||
where: { id, tenantId: req.user.tenantId, isDeleted: 0 }
|
||||
where: { id, tenantId: req.user.tenantId, isDeleted: 0 },
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!bill) {
|
||||
await transaction.rollback();
|
||||
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({
|
||||
isDeleted: 1,
|
||||
status: 'cancelled',
|
||||
updateBy: req.user.id
|
||||
});
|
||||
}, { transaction });
|
||||
|
||||
response.success(res, '账单删除成功');
|
||||
await transaction.commit();
|
||||
response.success(res, '账单已作废并删除');
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('删除账单失败:', error);
|
||||
response.serverError(res, '删除账单失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 账单收款
|
||||
// 账单收款(支持幂等性)
|
||||
const receivePayment = async (req, res) => {
|
||||
const transaction = await require('../config/db').transaction();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { amount, paymentMethod } = req.body;
|
||||
const { amount, paymentMethod, transactionNo, remark } = req.body;
|
||||
|
||||
const bill = await Bill.findOne({
|
||||
where: { id, tenantId: req.user.tenantId, isDeleted: 0 }
|
||||
where: { id, tenantId: req.user.tenantId, isDeleted: 0 },
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!bill) {
|
||||
await transaction.rollback();
|
||||
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';
|
||||
if (newReceivedAmount >= receivableAmount) {
|
||||
newStatus = 'paid';
|
||||
} else if (newReceivedAmount > 0) {
|
||||
newStatus = 'partial';
|
||||
let paymentInfo;
|
||||
try {
|
||||
const remainingAmount = parseFloat(bill.receivableAmount) - parseFloat(bill.receivedAmount || 0);
|
||||
paymentInfo = rentalBillingService.validatePaymentAmount(bill, amount !== undefined ? amount : remainingAmount);
|
||||
} catch (validationError) {
|
||||
await transaction.rollback();
|
||||
return response.badRequest(res, validationError.message);
|
||||
}
|
||||
|
||||
await bill.update({
|
||||
receivedAmount: newReceivedAmount,
|
||||
status: newStatus,
|
||||
receivedAmount: paymentInfo.newReceivedAmount,
|
||||
status: paymentInfo.newStatus,
|
||||
paymentMethod: paymentMethod || bill.paymentMethod,
|
||||
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) {
|
||||
await transaction.rollback();
|
||||
console.error('账单收款失败:', error);
|
||||
response.serverError(res, '账单收款失败', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const { BillPayment, Bill } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
const { Op, fn, col } = require('sequelize');
|
||||
const response = require('../utils/response');
|
||||
const rentalBillingService = require('../services/rentalBillingService');
|
||||
|
||||
// 获取账单的所有支付流水
|
||||
const getPaymentsByBillId = async (req, res) => {
|
||||
|
|
@ -26,48 +27,45 @@ const getPaymentsByBillId = async (req, res) => {
|
|||
|
||||
// 创建支付流水(收款)
|
||||
const createPayment = async (req, res) => {
|
||||
const transaction = await require('../config/db').transaction();
|
||||
try {
|
||||
const { billId } = req.params;
|
||||
const { amount, paymentMethod, paymentTime, transactionNo, remark } = req.body;
|
||||
const tenantId = req.user.tenantId;
|
||||
const createBy = req.user.id;
|
||||
|
||||
// 查找账单
|
||||
const bill = await Bill.findOne({
|
||||
where: { id: billId, tenantId, isDeleted: 0 }
|
||||
where: { id: billId, tenantId, isDeleted: 0 },
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!bill) {
|
||||
await transaction.rollback();
|
||||
return response.notFound(res, '账单不存在');
|
||||
}
|
||||
|
||||
if (bill.status === 'paid') {
|
||||
return response.badRequest(res, '该账单已收清,无法继续收款');
|
||||
if (transactionNo) {
|
||||
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') {
|
||||
return response.badRequest(res, '该账单已取消,无法收款');
|
||||
let paymentInfo;
|
||||
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({
|
||||
billId,
|
||||
amount: paymentAmount,
|
||||
amount: paymentInfo.paymentAmount,
|
||||
paymentMethod,
|
||||
paymentTime: paymentTime || new Date(),
|
||||
transactionNo: transactionNo || null,
|
||||
|
|
@ -75,33 +73,26 @@ const createPayment = async (req, res) => {
|
|||
tenantId,
|
||||
createBy,
|
||||
isDeleted: 0
|
||||
});
|
||||
|
||||
// 更新账单的已收金额和状态
|
||||
const newReceivedAmount = currentReceived + paymentAmount;
|
||||
let newStatus = bill.status;
|
||||
|
||||
if (newReceivedAmount >= receivableAmount) {
|
||||
newStatus = 'paid';
|
||||
} else if (newReceivedAmount > 0) {
|
||||
newStatus = 'partial';
|
||||
}
|
||||
}, { transaction });
|
||||
|
||||
await bill.update({
|
||||
receivedAmount: newReceivedAmount,
|
||||
status: newStatus,
|
||||
receivedAmount: paymentInfo.newReceivedAmount,
|
||||
status: paymentInfo.newStatus,
|
||||
updateBy: createBy
|
||||
});
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
response.created(res, '收款成功', {
|
||||
payment,
|
||||
bill: {
|
||||
id: bill.id,
|
||||
receivedAmount: newReceivedAmount,
|
||||
status: newStatus
|
||||
receivedAmount: paymentInfo.newReceivedAmount,
|
||||
status: paymentInfo.newStatus
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('创建支付流水失败:', error);
|
||||
response.serverError(res, '创建支付流水失败', error);
|
||||
}
|
||||
|
|
@ -109,44 +100,40 @@ const createPayment = async (req, res) => {
|
|||
|
||||
// 删除支付流水(退款/撤销)
|
||||
const deletePayment = async (req, res) => {
|
||||
const transaction = await require('../config/db').transaction();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.user.tenantId;
|
||||
const updateBy = req.user.id;
|
||||
|
||||
const payment = await BillPayment.findOne({
|
||||
where: { id, tenantId, isDeleted: 0 }
|
||||
where: { id, tenantId, isDeleted: 0 },
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
await transaction.rollback();
|
||||
return response.notFound(res, '支付流水不存在');
|
||||
}
|
||||
|
||||
// 查找关联账单
|
||||
const bill = await Bill.findOne({
|
||||
where: { id: payment.billId, tenantId, isDeleted: 0 }
|
||||
where: { id: payment.billId, tenantId, isDeleted: 0 },
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!bill) {
|
||||
await transaction.rollback();
|
||||
return response.notFound(res, '关联账单不存在');
|
||||
}
|
||||
|
||||
// 软删除支付流水
|
||||
await payment.update({ isDeleted: 1 });
|
||||
await payment.update({ isDeleted: 1 }, { transaction });
|
||||
|
||||
// 重新计算账单的已收金额和状态
|
||||
const remainingPayments = await BillPayment.findAll({
|
||||
where: {
|
||||
billId: bill.id,
|
||||
tenantId,
|
||||
isDeleted: 0
|
||||
}
|
||||
where: { billId: bill.id, tenantId, isDeleted: 0 },
|
||||
transaction
|
||||
});
|
||||
|
||||
const newReceivedAmount = remainingPayments.reduce((sum, p) => {
|
||||
return sum + parseFloat(p.amount);
|
||||
}, 0);
|
||||
|
||||
const newReceivedAmount = remainingPayments.reduce((sum, p) => sum + parseFloat(p.amount), 0);
|
||||
let newStatus = 'unpaid';
|
||||
if (newReceivedAmount >= parseFloat(bill.receivableAmount)) {
|
||||
newStatus = 'paid';
|
||||
|
|
@ -158,18 +145,17 @@ const deletePayment = async (req, res) => {
|
|||
receivedAmount: newReceivedAmount,
|
||||
status: newStatus,
|
||||
updateBy
|
||||
});
|
||||
}, { transaction });
|
||||
|
||||
response.success(res, '支付流水已删除,账单状态已更新', {
|
||||
bill: {
|
||||
id: bill.id,
|
||||
receivedAmount: newReceivedAmount,
|
||||
status: newStatus
|
||||
}
|
||||
await transaction.commit();
|
||||
|
||||
response.success(res, '支付流水已撤销,账单状态已更新', {
|
||||
bill: { id: bill.id, receivedAmount: newReceivedAmount, status: newStatus }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除支付流水失败:', error);
|
||||
response.serverError(res, '删除支付流水失败', error);
|
||||
await transaction.rollback();
|
||||
console.error('撤销支付流水失败:', error);
|
||||
response.serverError(res, '撤销支付流水失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -192,7 +178,7 @@ const getPaymentStatistics = async (req, res) => {
|
|||
|
||||
const payments = await BillPayment.findAll({
|
||||
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']
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const { SubscriptionPlan, PricingConfig, Order, Payment, Tenant, User, Apartment
|
|||
const { Op } = require('sequelize');
|
||||
const billingService = require('../services/billingService');
|
||||
const response = require('../utils/response');
|
||||
const sequelize = require('../config/db');
|
||||
|
||||
// 格式化日期时间(年月日时分秒)
|
||||
const formatDateTime = (date) => {
|
||||
|
|
@ -40,7 +41,7 @@ const getAllPlans = async (req, res) => {
|
|||
// 创建套餐
|
||||
const createPlan = async (req, res) => {
|
||||
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) {
|
||||
|
|
@ -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 isDefault = existingPlans === 0;
|
||||
|
||||
// 创建套餐
|
||||
const plan = await SubscriptionPlan.create({
|
||||
code,
|
||||
name,
|
||||
description,
|
||||
maxApartments: maxApartments || 10,
|
||||
maxRooms: maxRooms || 50,
|
||||
maxUsers: maxUsers || 5,
|
||||
monthlyPrice: monthlyPrice || 0,
|
||||
isDefault
|
||||
yearlyPrice: yearlyPrice || 0,
|
||||
isDefault,
|
||||
status: status || 'active',
|
||||
sort: sort || 0
|
||||
});
|
||||
|
||||
response.created(res, '套餐创建成功', plan);
|
||||
|
|
@ -72,7 +86,7 @@ const createPlan = async (req, res) => {
|
|||
const updatePlan = async (req, res) => {
|
||||
try {
|
||||
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({
|
||||
|
|
@ -91,6 +105,7 @@ const updatePlan = async (req, res) => {
|
|||
maxRooms: maxRooms !== undefined ? maxRooms : plan.maxRooms,
|
||||
maxUsers: maxUsers !== undefined ? maxUsers : plan.maxUsers,
|
||||
monthlyPrice: monthlyPrice !== undefined ? monthlyPrice : plan.monthlyPrice,
|
||||
yearlyPrice: yearlyPrice !== undefined ? yearlyPrice : plan.yearlyPrice,
|
||||
status: status !== undefined ? status : plan.status,
|
||||
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);
|
||||
|
||||
response.success(res, '默认套餐初始化成功', plans);
|
||||
|
|
@ -332,7 +355,7 @@ const getOrders = async (req, res) => {
|
|||
{
|
||||
model: 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) => {
|
||||
try {
|
||||
const { planId, months } = req.body;
|
||||
|
|
@ -373,7 +396,7 @@ const calculatePrice = async (req, res) => {
|
|||
return response.badRequest(res, '套餐不存在');
|
||||
}
|
||||
|
||||
// 计算订单金额(含超额费用)
|
||||
// 计算预付订阅订单金额
|
||||
const amountInfo = await billingService.calculateRenewalAmount(tenantId, planId, months);
|
||||
|
||||
response.success(res, '计算成功', amountInfo);
|
||||
|
|
@ -424,6 +447,7 @@ const createOrder = async (req, res) => {
|
|||
planId,
|
||||
planName: plan.name,
|
||||
months,
|
||||
billingCycle: amountInfo.billingCycle,
|
||||
amount: amountInfo.totalAmount,
|
||||
discountAmount: 0,
|
||||
actualAmount: amountInfo.totalAmount,
|
||||
|
|
@ -444,7 +468,7 @@ const createOrder = async (req, res) => {
|
|||
{
|
||||
model: SubscriptionPlan,
|
||||
as: 'subscriptionPlan',
|
||||
attributes: ['id', 'name', 'monthlyPrice']
|
||||
attributes: ['id', 'code', 'name', 'monthlyPrice', 'yearlyPrice']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
@ -477,7 +501,7 @@ const getOrderDetail = async (req, res) => {
|
|||
{
|
||||
model: SubscriptionPlan,
|
||||
as: 'subscriptionPlan',
|
||||
attributes: ['id', 'name', 'description', 'monthlyPrice', 'maxApartments', 'maxRooms', 'maxUsers']
|
||||
attributes: ['id', 'code', 'name', 'description', 'monthlyPrice', 'yearlyPrice', 'maxApartments', 'maxRooms', 'maxUsers']
|
||||
},
|
||||
{
|
||||
model: Payment,
|
||||
|
|
@ -539,39 +563,51 @@ const cancelOrder = async (req, res) => {
|
|||
|
||||
// 支付订单(管理员确认收款)
|
||||
const payOrder = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const { id: orderId } = req.params;
|
||||
const { paymentMethod = 'other', amount, transactionId: customTransactionId, remark } = req.body || {};
|
||||
const isSystemAdmin = req.user.userType === 'super_admin';
|
||||
|
||||
if (!orderId) {
|
||||
await transaction.rollback();
|
||||
return response.badRequest(res, '订单ID不能为空');
|
||||
}
|
||||
|
||||
// 只有管理员可以确认支付
|
||||
if (!isSystemAdmin) {
|
||||
await transaction.rollback();
|
||||
return response.forbidden(res, '无权操作,请联系管理员');
|
||||
}
|
||||
|
||||
// 查询订单
|
||||
const order = await Order.findOne({
|
||||
where: { id: orderId, isDeleted: 0 }
|
||||
where: { id: orderId, isDeleted: 0 },
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
await transaction.rollback();
|
||||
return response.notFound(res, '订单不存在');
|
||||
}
|
||||
|
||||
// 验证订单状态
|
||||
if (order.status !== 'pending') {
|
||||
await transaction.rollback();
|
||||
return response.badRequest(res, '订单状态异常,无法支付');
|
||||
}
|
||||
|
||||
// 验证金额
|
||||
const paymentAmount = parseFloat(amount) || order.actualAmount;
|
||||
if (paymentAmount <= 0) {
|
||||
const payableAmount = parseFloat(order.actualAmount || order.amount || 0);
|
||||
const paymentAmount = amount !== undefined ? parseFloat(amount) : payableAmount;
|
||||
if (!Number.isFinite(paymentAmount) || paymentAmount <= 0) {
|
||||
await transaction.rollback();
|
||||
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)}`;
|
||||
|
|
@ -586,18 +622,25 @@ const payOrder = async (req, res) => {
|
|||
transactionId,
|
||||
paidAt: new Date(),
|
||||
remark: remark || '管理员确认收款'
|
||||
});
|
||||
}, { transaction });
|
||||
|
||||
// 更新订单状态
|
||||
const paidTime = new Date();
|
||||
await order.update({
|
||||
status: 'paid',
|
||||
paidTime: paidTime
|
||||
});
|
||||
}, { transaction });
|
||||
|
||||
// 处理支付成功后的租户状态更新
|
||||
// 使用订单的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, '支付成功', {
|
||||
payment,
|
||||
|
|
@ -609,6 +652,7 @@ const payOrder = async (req, res) => {
|
|||
tenant: paymentResult
|
||||
});
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('支付订单失败:', error);
|
||||
response.serverError(res, '支付订单失败', error);
|
||||
}
|
||||
|
|
@ -678,7 +722,7 @@ const getBillingInfo = async (req, res) => {
|
|||
{
|
||||
model: SubscriptionPlan,
|
||||
as: 'subscriptionPlan',
|
||||
attributes: ['id', 'name', 'description', 'monthlyPrice', 'maxApartments', 'maxRooms', 'maxUsers']
|
||||
attributes: ['id', 'code', 'name', 'description', 'monthlyPrice', 'yearlyPrice', 'maxApartments', 'maxRooms', 'maxUsers']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@ exports.getLoginLogs = async (req, res) => {
|
|||
// 清空操作日志
|
||||
exports.clearOperationLogs = async (req, res) => {
|
||||
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 where = {};
|
||||
|
||||
|
|
@ -114,6 +120,12 @@ exports.clearOperationLogs = async (req, res) => {
|
|||
// 清空登录日志
|
||||
exports.clearLoginLogs = async (req, res) => {
|
||||
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 where = {};
|
||||
|
||||
|
|
|
|||
|
|
@ -306,6 +306,7 @@ exports.getRoleMenus = async (req, res) => {
|
|||
const { roleId } = req.params;
|
||||
|
||||
const Role = require('../models/Role');
|
||||
const Menu = require('../models/Menu');
|
||||
|
||||
// 先检查角色是否存在
|
||||
const role = await Role.findOne({
|
||||
|
|
@ -316,18 +317,21 @@ exports.getRoleMenus = async (req, res) => {
|
|||
return response.notFound(res, '角色不存在');
|
||||
}
|
||||
|
||||
// 获取角色的菜单权限
|
||||
const roleData = await Role.findByPk(roleId, {
|
||||
include: [{
|
||||
model: Menu,
|
||||
as: 'menus',
|
||||
where: { isDeleted: 0 },
|
||||
required: false,
|
||||
through: { attributes: [] }
|
||||
}]
|
||||
});
|
||||
// 获取角色的菜单权限 - 从 role.permissions JSON 字段中读取
|
||||
const permissions = role.permissions || [];
|
||||
const menuIds = permissions.map(p => p.menuId).filter(Boolean);
|
||||
|
||||
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));
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -145,8 +145,10 @@ const getMeterReadingById = async (req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
// 创建抄表记录(同时创建账单)
|
||||
// 创建抄表记录(事务处理 + 自动创建账单)
|
||||
const createMeterReading = async (req, res) => {
|
||||
const transaction = await require('../config/db').transaction();
|
||||
|
||||
try {
|
||||
const {
|
||||
roomId,
|
||||
|
|
@ -166,6 +168,12 @@ const createMeterReading = async (req, res) => {
|
|||
|
||||
const tenantId = req.user.tenantId;
|
||||
|
||||
// 参数校验
|
||||
if (currentReading < previousReading) {
|
||||
await transaction.rollback();
|
||||
return response.badRequest(res, '本期读数不能小于上期读数');
|
||||
}
|
||||
|
||||
// 创建抄表记录
|
||||
const meterReading = await MeterReading.create({
|
||||
roomId,
|
||||
|
|
@ -184,13 +192,13 @@ const createMeterReading = async (req, res) => {
|
|||
createBy: req.user.id,
|
||||
updateBy: req.user.id,
|
||||
isDeleted: 0
|
||||
});
|
||||
}, { transaction });
|
||||
|
||||
// 自动创建关联账单
|
||||
const categoryMap = {
|
||||
'water': 'water',
|
||||
'electricity': 'electricity',
|
||||
'gas': 'gas'
|
||||
'water': '水费',
|
||||
'electricity': '电费',
|
||||
'gas': '燃气费'
|
||||
};
|
||||
|
||||
const bill = await Bill.create({
|
||||
|
|
@ -199,35 +207,40 @@ const createMeterReading = async (req, res) => {
|
|||
renterId: renterId || null,
|
||||
rentalId: rentalId || null,
|
||||
type: 'income',
|
||||
category: categoryMap[meterType],
|
||||
category: meterType,
|
||||
receivableAmount: amount,
|
||||
receivedAmount: 0,
|
||||
status: 'unpaid',
|
||||
billMonth,
|
||||
billDate: readingDate || new Date(),
|
||||
settlementType: 'normal',
|
||||
sourceType: 'meter_reading',
|
||||
sourceId: meterReading.id,
|
||||
remark: `${meterType === 'water' ? '水费' : meterType === 'electricity' ? '电费' : '燃气费'} - ${billMonth}`,
|
||||
remark: `${categoryMap[meterType] || meterType}账单 - ${billMonth}`,
|
||||
tenantId,
|
||||
createBy: req.user.id,
|
||||
updateBy: req.user.id,
|
||||
isDeleted: 0
|
||||
});
|
||||
}, { transaction });
|
||||
|
||||
// 更新抄表记录的账单ID
|
||||
await meterReading.update({ billId: bill.id });
|
||||
await meterReading.update({ billId: bill.id }, { transaction });
|
||||
|
||||
// 提交事务
|
||||
await transaction.commit();
|
||||
|
||||
response.created(res, '抄表记录创建成功,已自动生成账单', {
|
||||
meterReading: formatMeterReadingData(meterReading),
|
||||
bill: {
|
||||
id: bill.id,
|
||||
billNo: bill.billNo,
|
||||
receivableAmount: bill.receivableAmount,
|
||||
status: bill.status
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('创建抄表记录失败:', error);
|
||||
// 处理唯一约束错误
|
||||
if (error.name === 'SequelizeUniqueConstraintError') {
|
||||
const field = error.errors[0]?.path || '未知字段';
|
||||
if (field === 'uk_room_type_month') {
|
||||
|
|
@ -235,18 +248,18 @@ const createMeterReading = async (req, res) => {
|
|||
}
|
||||
return response.error(res, `数据重复: ${field} 必须唯一`, 409);
|
||||
}
|
||||
// 输出详细的验证错误信息
|
||||
if (error.name === 'SequelizeValidationError') {
|
||||
const messages = error.errors.map(e => `${e.path}: ${e.message}`).join(', ');
|
||||
console.error('验证错误详情:', messages);
|
||||
return response.badRequest(res, `Validation error: ${messages}`);
|
||||
}
|
||||
response.serverError(res, '创建抄表记录失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 更新抄表记录
|
||||
// 更新抄表记录(事务处理 + 同步更新账单 + 账单状态校验)
|
||||
const updateMeterReading = async (req, res) => {
|
||||
const transaction = await require('../config/db').transaction();
|
||||
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
|
|
@ -258,13 +271,21 @@ const updateMeterReading = async (req, res) => {
|
|||
} = req.body;
|
||||
|
||||
const meterReading = await MeterReading.findOne({
|
||||
where: { id, tenantId: req.user.tenantId, isDeleted: 0 }
|
||||
where: { id, tenantId: req.user.tenantId, isDeleted: 0 },
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!meterReading) {
|
||||
await transaction.rollback();
|
||||
return response.notFound(res, '抄表记录不存在');
|
||||
}
|
||||
|
||||
// 校验参数
|
||||
if (currentReading < previousReading) {
|
||||
await transaction.rollback();
|
||||
return response.badRequest(res, '本期读数不能小于上期读数');
|
||||
}
|
||||
|
||||
// 重新计算用量和金额
|
||||
const usage = parseFloat(currentReading) - parseFloat(previousReading);
|
||||
const amount = usage * parseFloat(unitPrice);
|
||||
|
|
@ -278,21 +299,49 @@ const updateMeterReading = async (req, res) => {
|
|||
readingDate: readingDate || meterReading.readingDate,
|
||||
remark,
|
||||
updateBy: req.user.id
|
||||
});
|
||||
}, { transaction });
|
||||
|
||||
// 同步更新关联账单金额
|
||||
// 同步更新关联账单(需校验账单状态)
|
||||
if (meterReading.billId) {
|
||||
const bill = await Bill.findByPk(meterReading.billId);
|
||||
if (bill && bill.isDeleted === 0) {
|
||||
const bill = await Bill.findOne({
|
||||
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({
|
||||
receivableAmount: amount,
|
||||
remark: `${categoryMap[meterReading.meterType] || meterReading.meterType}账单 - ${bill.billMonth}`,
|
||||
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) {
|
||||
await transaction.rollback();
|
||||
console.error('更新抄表记录失败:', error);
|
||||
response.serverError(res, '更新抄表记录失败', error);
|
||||
}
|
||||
|
|
@ -383,9 +432,13 @@ const getRoomMeterReadings = 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({
|
||||
where: {
|
||||
roomId,
|
||||
|
|
@ -398,7 +451,8 @@ const getLatestReading = async (req, res) => {
|
|||
|
||||
response.success(res, '获取成功', latestReading ? {
|
||||
previousReading: latestReading.currentReading,
|
||||
billMonth: latestReading.billMonth
|
||||
billMonth: latestReading.billMonth,
|
||||
unitPrice: latestReading.unitPrice
|
||||
} : null);
|
||||
} catch (error) {
|
||||
console.error('获取最新读数失败:', error);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,5 @@
|
|||
const { Room, Apartment, Rental, Renter, Tenant } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
const { logOperation } = require('../utils/logger');
|
||||
const response = require('../utils/response');
|
||||
|
||||
// 格式化时间(考虑时区,转换为北京时间)
|
||||
|
|
@ -263,6 +262,28 @@ const createRoom = async (req, res) => {
|
|||
try {
|
||||
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);
|
||||
let currentRoomCount = 0;
|
||||
|
|
@ -274,19 +295,7 @@ const createRoom = async (req, res) => {
|
|||
|
||||
// 检查是否超出限制
|
||||
if (currentRoomCount >= 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'
|
||||
});
|
||||
return response.badRequest(res, `当前房间数量已达到套餐上限 ${tenant.maxRooms} 间,请升级套餐后再创建`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -310,7 +319,7 @@ const createRoom = async (req, res) => {
|
|||
const room = await Room.create(processedData);
|
||||
response.created(res, '创建成功', {
|
||||
room,
|
||||
warning: tenant && currentRoomCount >= tenant.maxRooms ? `当前已超出套餐限制(${tenant.maxRooms}间),续费时将收取超额费用` : null
|
||||
warning: null
|
||||
});
|
||||
} catch (error) {
|
||||
response.serverError(res, '创建房间失败', error);
|
||||
|
|
|
|||
|
|
@ -111,6 +111,17 @@ const createCategory = async (req, res) => {
|
|||
const userId = req.user.id;
|
||||
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({
|
||||
where: { code, tenantId, isDeleted: 0 }
|
||||
|
|
@ -147,6 +158,17 @@ const updateCategory = async (req, res) => {
|
|||
const { id } = req.params;
|
||||
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({
|
||||
where: { id, tenantId, isDeleted: 0 }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -132,7 +132,6 @@ exports.createUser = async (req, res) => {
|
|||
// 检查租户资源使用情况(仅记录日志,不阻止创建)
|
||||
const tenant = await Tenant.findByPk(req.user.tenantId);
|
||||
let currentUserCount = 0;
|
||||
let isOverage = false;
|
||||
if (tenant) {
|
||||
// 获取当前用户数量
|
||||
currentUserCount = await User.count({
|
||||
|
|
@ -141,20 +140,7 @@ exports.createUser = async (req, res) => {
|
|||
|
||||
// 检查是否超出限制
|
||||
if (currentUserCount >= tenant.maxUsers) {
|
||||
isOverage = true;
|
||||
// 记录超额使用日志
|
||||
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'
|
||||
});
|
||||
return response.badRequest(res, `当前用户数量已达到套餐上限 ${tenant.maxUsers} 人,请升级套餐后再创建`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -192,7 +178,7 @@ exports.createUser = async (req, res) => {
|
|||
nickname: user.nickname,
|
||||
roleId: user.roleId,
|
||||
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);
|
||||
|
||||
// 更新密码
|
||||
// 更新密码
|
||||
await user.update({
|
||||
password: hashedPassword,
|
||||
updateBy: req.user.id
|
||||
});
|
||||
|
||||
response.success(res, '密码重置成功', {
|
||||
defaultPassword
|
||||
});
|
||||
// 安全修复:不返回明文密码给客户端,只返回成功状态
|
||||
// 实际场景应该通过邮件/短信发送给用户新密码
|
||||
response.success(res, '密码重置成功,请使用新密码登录');
|
||||
|
||||
// 记录操作日志
|
||||
await logOperation({
|
||||
|
|
|
|||
|
|
@ -244,6 +244,9 @@ CREATE TABLE `rentals` (
|
|||
`deposit` decimal(10,2) DEFAULT NULL 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-提前终止',
|
||||
`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 '经办人',
|
||||
`remark` text COMMENT '备注',
|
||||
`tenantId` int(11) NOT NULL COMMENT '租户ID',
|
||||
|
|
@ -291,6 +294,8 @@ CREATE TABLE `bills` (
|
|||
`roomId` int(11) DEFAULT NULL COMMENT '房间ID(可选)',
|
||||
`renterId` 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-支出',
|
||||
`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 '应收金额',
|
||||
|
|
@ -376,6 +381,33 @@ CREATE TABLE `meter_readings` (
|
|||
UNIQUE KEY `uk_room_type_month` (`roomId`, `meterType`, `billMonth`, `tenantId`, `isDeleted`)
|
||||
) 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`;
|
||||
CREATE TABLE `transactions` (
|
||||
|
|
@ -475,6 +507,10 @@ CREATE TABLE `orders` (
|
|||
`planId` int(11) NOT NULL COMMENT '套餐ID',
|
||||
`planName` varchar(100) NOT NULL 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 '订单金额',
|
||||
`discountAmount` decimal(10,2) DEFAULT '0.00' COMMENT '优惠金额',
|
||||
`actualAmount` decimal(10,2) NOT NULL COMMENT '实付金额',
|
||||
|
|
@ -490,9 +526,36 @@ CREATE TABLE `orders` (
|
|||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `orderNo` (`orderNo`),
|
||||
KEY `idx_tenant` (`tenantId`),
|
||||
KEY `idx_subscription` (`subscriptionId`),
|
||||
KEY `idx_status` (`status`)
|
||||
) 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`;
|
||||
CREATE TABLE `payments` (
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ SET FOREIGN_KEY_CHECKS = 0;
|
|||
-- 1. 初始化默认租户(系统租户)
|
||||
-- 说明:租户只保留编码,不区分类型
|
||||
-- ============================================
|
||||
INSERT IGNORE INTO `tenants` (`id`, `code`, `contactName`, `contactPhone`, `status`, `billingStatus`, `maxUsers`, `maxApartments`, `maxRooms`, `createTime`, `updateTime`) VALUES
|
||||
(1, 'system', '系统管理员', '13800138000', 'active', 'paid_active', 100, 1000, 10000, NOW(), NOW());
|
||||
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', 4, NOW(), DATE_ADD(NOW(), INTERVAL 10 YEAR), NOW(), DATE_ADD(NOW(), INTERVAL 10 YEAR), 100, 1000, 10000, NOW(), NOW());
|
||||
|
||||
-- ============================================
|
||||
-- 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),
|
||||
(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. 初始化角色(仅租户管理员和普通用户使用)
|
||||
-- 说明:超级管理员不需要角色,通过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()),
|
||||
(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()),
|
||||
(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. 财务管理(父菜单)
|
||||
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
|
||||
(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. 初始化支付设置
|
||||
-- ============================================
|
||||
|
|
|
|||
|
|
@ -44,6 +44,17 @@ const Bill = sequelize.define('Bill', {
|
|||
},
|
||||
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: DataTypes.ENUM('income', 'expense'),
|
||||
allowNull: false,
|
||||
|
|
|
|||
|
|
@ -6,101 +6,122 @@ const Order = sequelize.define('Order', {
|
|||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
comment: '订单ID'
|
||||
comment: 'Order ID'
|
||||
},
|
||||
orderNo: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
comment: '订单编号'
|
||||
comment: 'Order number'
|
||||
},
|
||||
tenantId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: '租户ID'
|
||||
comment: 'Tenant ID'
|
||||
},
|
||||
planId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: '套餐ID'
|
||||
comment: 'Plan ID'
|
||||
},
|
||||
planName: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
comment: '套餐名称'
|
||||
comment: 'Plan name snapshot'
|
||||
},
|
||||
months: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
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: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
comment: '订单金额'
|
||||
comment: 'Order amount'
|
||||
},
|
||||
discountAmount: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
defaultValue: 0.00,
|
||||
comment: '优惠金额'
|
||||
comment: 'Discount amount'
|
||||
},
|
||||
actualAmount: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
comment: '实付金额'
|
||||
comment: 'Payable amount'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('pending', 'paid', 'cancelled'),
|
||||
defaultValue: 'pending',
|
||||
comment: '状态:pending-待支付,paid-已支付,cancelled-已取消'
|
||||
comment: 'Order status'
|
||||
},
|
||||
paidTime: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: '支付时间'
|
||||
comment: 'Paid time'
|
||||
},
|
||||
expireTime: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
comment: '过期时间'
|
||||
comment: 'Order expiration time'
|
||||
},
|
||||
remark: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
comment: '备注'
|
||||
comment: 'Remark'
|
||||
},
|
||||
createBy: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '创建人ID'
|
||||
comment: 'Created by'
|
||||
},
|
||||
updateBy: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '修改人ID'
|
||||
comment: 'Updated by'
|
||||
},
|
||||
isDeleted: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '删除状态(0:未删除,1:已删除)'
|
||||
comment: 'Soft delete flag'
|
||||
},
|
||||
createTime: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
comment: '创建时间'
|
||||
comment: 'Created time'
|
||||
},
|
||||
updateTime: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
comment: '更新时间'
|
||||
comment: 'Updated time'
|
||||
}
|
||||
}, {
|
||||
tableName: 'orders',
|
||||
timestamps: false,
|
||||
comment: '订单表'
|
||||
comment: 'Orders'
|
||||
});
|
||||
|
||||
module.exports = Order;
|
||||
|
|
|
|||
|
|
@ -65,6 +65,21 @@ const Rental = sequelize.define('Rental', {
|
|||
defaultValue: 'active',
|
||||
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: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
|
|
@ -112,4 +127,4 @@ const Rental = sequelize.define('Rental', {
|
|||
Rental.belongsTo(Room, { foreignKey: 'roomId' });
|
||||
Rental.belongsTo(Renter, { foreignKey: 'renterId' });
|
||||
|
||||
module.exports = Rental;
|
||||
module.exports = Rental;
|
||||
|
|
|
|||
|
|
@ -6,75 +6,86 @@ const SubscriptionPlan = sequelize.define('SubscriptionPlan', {
|
|||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
comment: '套餐ID'
|
||||
comment: 'Plan ID'
|
||||
},
|
||||
code: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: 'Plan code'
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
comment: '套餐名称'
|
||||
comment: 'Plan name'
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: '套餐描述'
|
||||
comment: 'Plan description'
|
||||
},
|
||||
maxApartments: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 10,
|
||||
comment: '最大公寓数'
|
||||
comment: 'Apartment limit'
|
||||
},
|
||||
maxRooms: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 50,
|
||||
comment: '最大房间数'
|
||||
comment: 'Room limit'
|
||||
},
|
||||
maxUsers: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 5,
|
||||
comment: '最大用户数'
|
||||
comment: 'User limit'
|
||||
},
|
||||
monthlyPrice: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
defaultValue: 0,
|
||||
comment: '月费价格'
|
||||
comment: 'Monthly price'
|
||||
},
|
||||
yearlyPrice: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
defaultValue: 0,
|
||||
comment: 'Yearly price'
|
||||
},
|
||||
isDefault: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: '是否默认套餐'
|
||||
comment: 'Whether this is the default plan'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive'),
|
||||
defaultValue: 'active',
|
||||
comment: '套餐状态:active-启用,inactive-禁用'
|
||||
comment: 'Plan status'
|
||||
},
|
||||
sort: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: '排序'
|
||||
comment: 'Sort order'
|
||||
},
|
||||
isDeleted: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '删除状态(0:未删除,1:已删除)'
|
||||
comment: 'Soft delete flag'
|
||||
},
|
||||
createTime: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
comment: '创建时间'
|
||||
comment: 'Created time'
|
||||
},
|
||||
updateTime: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
comment: '更新时间'
|
||||
comment: 'Updated time'
|
||||
}
|
||||
}, {
|
||||
tableName: 'subscription_plans',
|
||||
timestamps: false,
|
||||
comment: '订阅套餐表'
|
||||
comment: 'Subscription plans'
|
||||
});
|
||||
|
||||
module.exports = SubscriptionPlan;
|
||||
|
|
|
|||
|
|
@ -6,86 +6,79 @@ const TenantBillingDetail = sequelize.define('TenantBillingDetail', {
|
|||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
comment: '明细ID'
|
||||
comment: 'ID'
|
||||
},
|
||||
tenantId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: '租户ID'
|
||||
comment: 'Tenant ID'
|
||||
},
|
||||
billingPeriodStart: {
|
||||
type: DataTypes.DATEONLY,
|
||||
billingPeriod: {
|
||||
type: DataTypes.STRING(7),
|
||||
allowNull: false,
|
||||
comment: '计费周期开始'
|
||||
comment: 'Billing period, format YYYY-MM'
|
||||
},
|
||||
billingPeriodEnd: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
comment: '计费周期结束'
|
||||
planId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Subscription plan ID'
|
||||
},
|
||||
baseAmount: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '基础费用'
|
||||
comment: 'Base subscription amount'
|
||||
},
|
||||
overageApartmentCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: '超额公寓数'
|
||||
},
|
||||
overageApartmentAmount: {
|
||||
extraAmount: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '超额公寓费用'
|
||||
comment: 'Overage amount'
|
||||
},
|
||||
overageRoomCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: '超额房间数'
|
||||
},
|
||||
overageRoomAmount: {
|
||||
discountAmount: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '超额房间费用'
|
||||
},
|
||||
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: '超额费用合计'
|
||||
comment: 'Discount amount'
|
||||
},
|
||||
totalAmount: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '总费用'
|
||||
comment: 'Total amount'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('calculated', 'billed', 'paid', 'waived'),
|
||||
defaultValue: 'calculated',
|
||||
comment: '状态'
|
||||
type: DataTypes.ENUM('pending', 'paid', 'overdue'),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
comment: 'Billing detail status'
|
||||
},
|
||||
orderId: {
|
||||
type: DataTypes.INTEGER,
|
||||
paidTime: {
|
||||
type: DataTypes.DATE,
|
||||
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',
|
||||
timestamps: false,
|
||||
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'] }
|
||||
],
|
||||
comment: '租户计费明细表'
|
||||
comment: 'Tenant billing detail table'
|
||||
});
|
||||
|
||||
module.exports = TenantBillingDetail;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -18,6 +18,7 @@ const Order = require('./Order');
|
|||
const Payment = require('./Payment');
|
||||
const TenantResourceUsage = require('./TenantResourceUsage');
|
||||
const TenantBillingDetail = require('./TenantBillingDetail');
|
||||
const TenantSubscription = require('./TenantSubscription');
|
||||
const Coupon = require('./Coupon');
|
||||
const TenantCouponUsage = require('./TenantCouponUsage');
|
||||
const BillingLog = require('./BillingLog');
|
||||
|
|
@ -141,9 +142,12 @@ TenantResourceUsage.belongsTo(Tenant, { foreignKey: 'tenantId', as: 'tenant' });
|
|||
Tenant.hasMany(TenantBillingDetail, { foreignKey: 'tenantId', as: 'billingDetails' });
|
||||
TenantBillingDetail.belongsTo(Tenant, { foreignKey: 'tenantId', as: 'tenant' });
|
||||
|
||||
// 计费明细与订单关联
|
||||
TenantBillingDetail.belongsTo(Order, { foreignKey: 'orderId', as: 'order' });
|
||||
Order.hasMany(TenantBillingDetail, { foreignKey: 'orderId', as: 'billingDetails' });
|
||||
Tenant.hasMany(TenantSubscription, { foreignKey: 'tenantId', as: 'subscriptions' });
|
||||
TenantSubscription.belongsTo(Tenant, { foreignKey: 'tenantId', as: 'tenant' });
|
||||
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' });
|
||||
|
|
@ -182,6 +186,7 @@ module.exports = {
|
|||
Payment,
|
||||
TenantResourceUsage,
|
||||
TenantBillingDetail,
|
||||
TenantSubscription,
|
||||
Coupon,
|
||||
TenantCouponUsage,
|
||||
BillingLog,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ router.get('/:id', rentalController.getRentalById);
|
|||
router.post('/', rentalController.createRental);
|
||||
router.put('/:id', rentalController.updateRental);
|
||||
router.delete('/:id', rentalController.deleteRental);
|
||||
|
||||
// 续租接口
|
||||
router.post('/:id/renew', rentalController.renewRental);
|
||||
|
||||
// 换房接口
|
||||
router.post('/:id/change-room', rentalController.changeRoom);
|
||||
|
||||
// 退租接口
|
||||
router.post('/:id/terminate', rentalController.terminateRental);
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,324 +1,267 @@
|
|||
/**
|
||||
* 计费服务 - 处理租户到期检查、超额计算等
|
||||
*/
|
||||
|
||||
const { Tenant, SubscriptionPlan, Order, PricingConfig } = require('../models');
|
||||
const { Tenant, SubscriptionPlan, TenantSubscription, Apartment, Room, User } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
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 () => {
|
||||
try {
|
||||
const now = new Date();
|
||||
console.log(`[${now.toISOString()}] 开始检查租户到期状态...`);
|
||||
const now = new Date();
|
||||
|
||||
// 1. 检查试用期到期的租户
|
||||
const trialExpiredTenants = await Tenant.findAll({
|
||||
where: {
|
||||
billingStatus: 'trial_active',
|
||||
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'
|
||||
});
|
||||
const trialExpiredTenants = await Tenant.findAll({
|
||||
where: {
|
||||
billingStatus: 'trial_active',
|
||||
trialEndDate: { [Op.lt]: now }
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 检查付费期到期的租户
|
||||
const paidExpiredTenants = await Tenant.findAll({
|
||||
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()}`);
|
||||
|
||||
// 记录日志
|
||||
for (const tenant of trialExpiredTenants) {
|
||||
await tenant.update({ billingStatus: 'trial_expired' });
|
||||
await logOperation({
|
||||
tenantId: tenant.id,
|
||||
module: '计费管理',
|
||||
action: '续费成功',
|
||||
description: `租户续费 ${months} 个月,有效期至 ${paidEndDate.toLocaleDateString()}`,
|
||||
action: '试用期到期',
|
||||
description: `租户 ${tenant.code} 试用期已过期`,
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
Loading…
Reference in New Issue