diff --git a/controllers/apartmentController.js b/controllers/apartmentController.js index 8a33926..bdc6a47 100644 --- a/controllers/apartmentController.js +++ b/controllers/apartmentController.js @@ -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); diff --git a/controllers/billController.js b/controllers/billController.js index 1e60dae..fdf2a81 100644 --- a/controllers/billController.js +++ b/controllers/billController.js @@ -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); } diff --git a/controllers/billPaymentController.js b/controllers/billPaymentController.js index c079d53..fb6fecf 100644 --- a/controllers/billPaymentController.js +++ b/controllers/billPaymentController.js @@ -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'] }); diff --git a/controllers/billingController.js b/controllers/billingController.js index 7cac342..59d7442 100644 --- a/controllers/billingController.js +++ b/controllers/billingController.js @@ -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'] } ] }); diff --git a/controllers/logController.js b/controllers/logController.js index 2ed6d02..b56f215 100644 --- a/controllers/logController.js +++ b/controllers/logController.js @@ -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 = {}; diff --git a/controllers/menuController.js b/controllers/menuController.js index 59d21e2..eb84aba 100644 --- a/controllers/menuController.js +++ b/controllers/menuController.js @@ -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) { diff --git a/controllers/meterReadingController.js b/controllers/meterReadingController.js index 083f873..0ecdc90 100644 --- a/controllers/meterReadingController.js +++ b/controllers/meterReadingController.js @@ -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); diff --git a/controllers/rentalController.js b/controllers/rentalController.js index 61e90fd..b849536 100644 --- a/controllers/rentalController.js +++ b/controllers/rentalController.js @@ -1,6 +1,7 @@ const { Rental, Room, Apartment, Bill, Transaction, Renter, MeterReading } = require('../models'); const { Op } = require('sequelize'); const response = require('../utils/response'); +const rentalBillingService = require('../services/rentalBillingService'); // 格式化时间(考虑时区,转换为北京时间) const formatDate = (date) => { @@ -33,6 +34,20 @@ const formatRentalData = (rental) => { return formattedRental; }; +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 random = Math.floor(Math.random() * 10000).toString().padStart(4, '0'); + return `B${year}${month}${day}${random}`; +}; + +const buildBillingMeta = (settlementType, sourceEventId = null) => ({ + settlementType, + sourceEventId +}); + // 检查并更新租房状态 const checkAndUpdateRentalStatus = async (tenantId) => { try { @@ -180,8 +195,7 @@ const getRentalById = async (req, res) => { where: { isDeleted: 0 }, include: [ { - model: Apartment, - where: { isDeleted: 0 } + model: Apartment } ] }, @@ -240,12 +254,31 @@ const createRental = async (req, res) => { return response.badRequest(res, '无效的租客ID'); } + // 检查房间是否存在 + const room = await Room.findByPk(parsedRoomId); + if (!room) { + return response.badRequest(res, '房间不存在'); + } + + // 检查房间是否有未结束的租赁合同 + const activeRental = await Rental.findOne({ + where: { + roomId: parsedRoomId, + status: { [Op.in]: ['active', 'reserved'] }, + endDate: { [Op.gte]: new Date() } + } + }); + if (activeRental) { + return response.badRequest(res, '该房间已有进行中的租赁合同'); + } + // 处理押金,为空时设置为0 const deposit = body.deposit || 0; // 获取租客信息用于账单备注 const renter = await Renter.findByPk(parsedRenterId); const renterName = renter ? renter.name : ''; + var transaction; // 创建租房记录 console.log('创建租房记录:', { @@ -263,6 +296,8 @@ const createRental = async (req, res) => { remark: body.remark }); + transaction = await require('../config/db').transaction(); + const rental = await Rental.create({ roomId: parsedRoomId, renterId: parsedRenterId, @@ -279,12 +314,12 @@ const createRental = async (req, res) => { tenantId: req.user.tenantId, createBy: req.user.id, updateBy: req.user.id - }); + }, { transaction }); console.log('租房记录:', rental); // 更新房间状态为已租 - await Room.update({ status: 'rented', rentalStatus: 'normal' }, { where: { id: parsedRoomId } }); + await Room.update({ status: 'rented', rentalStatus: 'normal' }, { where: { id: parsedRoomId }, transaction }); // 自动生成租金账单 await Bill.create({ @@ -299,11 +334,12 @@ const createRental = async (req, res) => { status: 'unpaid', billDate: body.startDate, billMonth: body.startDate.substring(0, 7), + settlementType: 'normal', remark: `租约租金 - ${renterName}`, tenantId: req.user.tenantId, createBy: req.user.id, updateBy: req.user.id - }); + }, { transaction }); // 自动生成押金账单(如果有押金) if (deposit > 0) { @@ -319,15 +355,21 @@ const createRental = async (req, res) => { status: 'unpaid', billDate: body.startDate, billMonth: body.startDate.substring(0, 7), + settlementType: 'normal', remark: `租约押金 - ${renterName}`, tenantId: req.user.tenantId, createBy: req.user.id, updateBy: req.user.id - }); + }, { transaction }); } + await transaction.commit(); + response.created(res, '创建成功', rental); } catch (error) { + if (typeof transaction !== 'undefined' && transaction) { + await transaction.rollback(); + } console.error('创建租房记录时出错:', error); response.serverError(res, '创建租房失败', error); } @@ -338,12 +380,49 @@ const updateRental = async (req, res) => { try { const { id } = req.params; const { roomId, renterId, startDate, endDate, paymentType, rent, deposit, operator, waterMeterStart, electricityMeterStart, waterMeterEnd, electricityMeterEnd, status, remark } = req.body; + const rental = await Rental.findOne({ - where: { id, tenantId: req.user.tenantId, isDeleted: 0 } + where: { id, tenantId: req.user.tenantId, isDeleted: 0 }, + include: [{ model: Room }] }); + if (!rental) { return response.notFound(res, '租房记录不存在'); } + + // 状态校验:已终止/已到期的租约不能编辑 + if (rental.status === 'terminated' || rental.status === 'expired') { + return response.badRequest(res, '已终止或已到期的租约不能编辑'); + } + + const oldRental = rental.toJSON(); + var transaction = await require('../config/db').transaction(); + + // 如果更换了房间,检查新房间是否有效 + if (roomId && roomId !== rental.roomId) { + const newRoom = await Room.findOne({ + where: { id: roomId, tenantId: req.user.tenantId, isDeleted: 0 } + }); + if (!newRoom) { + await transaction.rollback(); + return response.badRequest(res, '新房间不存在'); + } + if (newRoom.status === 'rented') { + await transaction.rollback(); + return response.badRequest(res, '新房间已在租'); + } + // 更新旧房间状态为空房 + await Room.update( + { status: 'empty', rentalStatus: 'normal' }, + { where: { id: rental.roomId }, transaction } + ); + // 更新新房间状态为已租 + await Room.update( + { status: 'rented', rentalStatus: 'normal' }, + { where: { id: roomId }, transaction } + ); + } + // 处理押金,为空时设置为0 const updateDeposit = deposit || 0; await rental.update({ @@ -362,38 +441,115 @@ const updateRental = async (req, res) => { status, remark, updateBy: req.user.id + }, { transaction }); + + const hasPaidBills = await Bill.count({ + where: { + rentalId: id, + tenantId: req.user.tenantId, + status: { [Op.in]: ['paid', 'partial'] }, + isDeleted: 0 + }, + transaction }); + const financialChanged = String(oldRental.rent) !== String(rental.rent) || String(oldRental.deposit || 0) !== String(rental.deposit || 0) || String(oldRental.startDate) !== String(rental.startDate) || oldRental.roomId !== rental.roomId || oldRental.renterId !== rental.renterId; + if (hasPaidBills > 0 && financialChanged) { + await transaction.rollback(); + return response.badRequest(res, '该租约已有收款记录,不能直接修改租金、押金、日期、房间或租客'); + } + + const renter = await Renter.findByPk(rental.renterId, { transaction }); + await rentalBillingService.syncEditableInitialBills({ + rental, + oldRental, + renterName: renter ? renter.name : '', + operatorId: req.user.id, + transaction + }); + + await transaction.commit(); response.success(res, '更新成功', rental); } catch (error) { - response.serverError(res, '更新租房失败', error); + if (typeof transaction !== 'undefined' && transaction) { + await transaction.rollback(); + } + response.serverError(res, '更新租约失败', error); } }; -// 删除租房(软删除) +// 删除租约(软删除) const deleteRental = async (req, res) => { + const transaction = await require('../config/db').transaction(); try { const { id } = req.params; const rental = await Rental.findOne({ - where: { id, tenantId: req.user.tenantId, isDeleted: 0 } + where: { id, tenantId: req.user.tenantId, isDeleted: 0 }, + transaction }); if (!rental) { - return response.notFound(res, '租房记录不存在'); + await transaction.rollback(); + return response.notFound(res, '租约不存在'); } + + await rentalBillingService.assertRentalCanBeDeleted({ + rentalId: id, + tenantId: req.user.tenantId, + transaction + }); + + await rentalBillingService.cancelUnpaidBillsForRental({ + rentalId: id, + tenantId: req.user.tenantId, + operatorId: req.user.id, + transaction + }); + await rental.update({ isDeleted: 1, updateBy: req.user.id + }, { transaction }); + + const otherActiveRental = await Rental.findOne({ + where: { + id: { [Op.ne]: id }, + roomId: rental.roomId, + tenantId: req.user.tenantId, + status: 'active', + isDeleted: 0 + }, + transaction }); - response.success(res, '租房记录删除成功'); + + if (!otherActiveRental) { + await Room.update( + { status: 'empty', rentalStatus: 'normal' }, + { where: { id: rental.roomId, tenantId: req.user.tenantId }, transaction } + ); + } + + await transaction.commit(); + response.success(res, '租约删除成功'); } catch (error) { + await transaction.rollback(); + if (error.statusCode === 400) { + return response.badRequest(res, error.message); + } response.serverError(res, '删除租房失败', error); } }; -// 退租处理 +// 退租处理(优化版:结算水电费 + 退还押金) const terminateRental = async (req, res) => { + const transaction = await require('../config/db').transaction(); + try { const { id } = req.params; - const { waterMeterEnd, electricityMeterEnd, remark } = req.body; + const { + waterMeterEnd, + electricityMeterEnd, + refundDeposit, + remark + } = req.body; const rental = await Rental.findOne({ where: { id, tenantId: req.user.tenantId, isDeleted: 0 }, @@ -401,48 +557,67 @@ const terminateRental = async (req, res) => { { model: Room, where: { isDeleted: 0 }, - include: [ - { - model: Apartment, - where: { isDeleted: 0 } - } - ] + include: [{ model: Apartment, where: { isDeleted: 0 } }] }, - { - model: Renter - } - ] + { model: Renter } + ], + transaction }); if (!rental) { - return response.notFound(res, '租房记录不存在'); + await transaction.rollback(); + return response.notFound(res, '租约不存在'); } if (rental.status !== 'active') { + await transaction.rollback(); return response.badRequest(res, '只有生效中的租约可以退租'); } + // 检查是否有未支付的租金/水电费账单 + const unpaidBills = await Bill.findAll({ + where: { + rentalId: id, + status: { [Op.in]: ['unpaid', 'partial'] }, + category: { [Op.in]: ['rent', 'water', 'electricity'] }, + isDeleted: 0 + }, + transaction + }); + + if (unpaidBills && unpaidBills.length > 0) { + await transaction.rollback(); + return response.badRequest(res, `该租约有 ${unpaidBills.length} 笔未结清的账单,请先结清后再退租`); + } + const room = rental.Room; const apartment = room.Apartment; const renterName = rental.Renter ? rental.Renter.name : ''; + const createBy = req.user.id; + const tenantId = req.user.tenantId; - // 更新租约信息 + + + const billsGenerated = []; + + // 1. 更新租约信息 await rental.update({ - status: 'expired', + status: 'terminated', + closeReason: 'terminated', waterMeterEnd: waterMeterEnd || null, electricityMeterEnd: electricityMeterEnd || null, remark: remark ? `${rental.remark || ''}\n退租备注:${remark}` : rental.remark, - updateBy: req.user.id - }); + updateBy: createBy + }, { transaction }); - // 生成水电费账单(如果有读数差异) + // 2. 生成水电费账单(如有读数差异) if (waterMeterEnd && rental.waterMeterStart) { const waterUsage = parseFloat(waterMeterEnd) - parseFloat(rental.waterMeterStart); - if (waterUsage > 0 && apartment.waterPrice) { - const waterAmount = waterUsage * parseFloat(apartment.waterPrice); - await Bill.create({ - billNo: 'B' + Date.now(), - rentalId: rental.id, + if (waterUsage > 0) { + const waterAmount = waterUsage * parseFloat(apartment.waterPrice || 0); + const bill = await Bill.create({ + billNo: generateBillNo(), + rentalId: id, roomId: room.id, renterId: rental.renterId, type: 'income', @@ -451,21 +626,24 @@ const terminateRental = async (req, res) => { receivedAmount: 0, status: 'unpaid', billDate: new Date(), + settlementType: 'checkout', + sourceEventId: id, remark: `退租水费 - ${renterName}(用量:${waterUsage.toFixed(2)}吨)`, - tenantId: req.user.tenantId, - createBy: req.user.id, - updateBy: req.user.id - }); + tenantId, + createBy, + updateBy: createBy + }, { transaction }); + billsGenerated.push({ category: 'water', amount: waterAmount }); } } if (electricityMeterEnd && rental.electricityMeterStart) { const electricityUsage = parseFloat(electricityMeterEnd) - parseFloat(rental.electricityMeterStart); - if (electricityUsage > 0 && apartment.electricityPrice) { - const electricityAmount = electricityUsage * parseFloat(apartment.electricityPrice); - await Bill.create({ - billNo: 'B' + Date.now() + '1', - rentalId: rental.id, + if (electricityUsage > 0) { + const electricityAmount = electricityUsage * parseFloat(apartment.electricityPrice || 0); + const bill = await Bill.create({ + billNo: generateBillNo(), + rentalId: id, roomId: room.id, renterId: rental.renterId, type: 'income', @@ -474,27 +652,498 @@ const terminateRental = async (req, res) => { receivedAmount: 0, status: 'unpaid', billDate: new Date(), + settlementType: 'checkout', + sourceEventId: id, remark: `退租电费 - ${renterName}(用量:${electricityUsage.toFixed(2)}度)`, - tenantId: req.user.tenantId, - createBy: req.user.id, - updateBy: req.user.id - }); + tenantId, + createBy, + updateBy: createBy + }, { transaction }); + billsGenerated.push({ category: 'electricity', amount: electricityAmount }); } } - // 更新房间状态为空房 + // 3. 生成押金退还账单(支出类型) + const depositToRefund = refundDeposit !== undefined ? refundDeposit : (rental.deposit || 0); + if (depositToRefund > 0) { + const bill = await Bill.create({ + billNo: generateBillNo(), + rentalId: id, + roomId: room.id, + renterId: rental.renterId, + type: 'expense', + category: 'deposit', + receivableAmount: depositToRefund, + receivedAmount: 0, + status: { [Op.in]: ['unpaid', 'partial'] }, + billDate: new Date(), + settlementType: 'checkout', + sourceEventId: id, + remark: `退租押金退还 - ${renterName}`, + tenantId, + createBy, + updateBy: createBy + }, { transaction }); + billsGenerated.push({ category: 'deposit_refund', amount: depositToRefund }); + } + + // 4. 更新房间状态为空房 await Room.update( { status: 'empty', rentalStatus: 'normal' }, - { where: { id: room.id } } + { where: { id: room.id }, transaction } ); - response.success(res, '退租处理成功', rental); + await transaction.commit(); + + response.success(res, '退租办理成功', { + rentalId: id, + roomId: room.id, + billsGenerated, + totalWaterBill: billsGenerated.filter(b => b.category === 'water').reduce((sum, b) => sum + b.amount, 0), + totalElectricityBill: billsGenerated.filter(b => b.category === 'electricity').reduce((sum, b) => sum + b.amount, 0), + depositRefund: depositToRefund + }); + } catch (error) { + await transaction.rollback(); console.error('退租处理时出错:', error); response.serverError(res, '退租处理失败', error); } }; +// 续租接口 +const renewRental = async (req, res) => { + const transaction = await require('../config/db').transaction(); + + try { + const { id } = req.params; + const { + newEndDate, + newRent, + newDeposit, + paymentType = 'monthly', + startDate, + operator, + remark, + waterStartReading, + electricStartReading + } = req.body; + + const tenantId = req.user.tenantId; + const createBy = req.user.id; + + // 获取原租约信息 + const rental = await Rental.findOne({ + where: { id, tenantId, isDeleted: 0 }, + include: [ + { model: Room, where: { isDeleted: 0 } }, + { model: Renter } + ], + transaction + }); + + if (!rental) { + await transaction.rollback(); + return response.notFound(res, '租约不存在'); + } + + if (rental.status !== 'active') { + await transaction.rollback(); + return response.badRequest(res, '只有生效中的租约可以续租'); + } + + // 检查是否有未支付的租金/水电费账单 + const unpaidBills = await Bill.findAll({ + where: { + rentalId: id, + status: 'unpaid', + category: { [Op.in]: ['rent', 'water', 'electricity'] }, + isDeleted: 0 + }, + transaction + }); + + if (unpaidBills && unpaidBills.length > 0) { + await transaction.rollback(); + return response.badRequest(res, `该租约有 ${unpaidBills.length} 笔未结清的账单,请先结清后再续租`); + } + + const renterName = rental.Renter ? rental.Renter.name : ''; + + // 1. 更新原租约为已到期 + await rental.update({ + status: 'expired', + closeReason: 'renewed', + updateBy: createBy + }, { transaction }); + + // 2. 创建新租约 + const newRental = await Rental.create({ + roomId: rental.roomId, + renterId: rental.renterId, + startDate: startDate || rental.endDate, + endDate: newEndDate, + paymentType, + rent: newRent || rental.rent, + deposit: newDeposit !== undefined ? newDeposit : rental.deposit, + operator: operator || null, + waterMeterStart: waterStartReading || null, + electricityMeterStart: electricStartReading || null, + status: 'active', + prevRentalId: rental.id, + originRentalId: rental.originRentalId || rental.id, + remark: remark || `续租自原租约${rental.id}`, + tenantId, + createBy, + updateBy: createBy + }, { transaction }); + + // 3. 更新房间状态 + await Room.update( + { status: 'rented', rentalStatus: 'normal' }, + { where: { id: rental.roomId }, transaction } + ); + + // 4. 生成首期租金账单 + const billStartDate = startDate || rental.endDate; + const billMonth = billStartDate.substring(0, 7); + + await Bill.create({ + billNo: generateBillNo(), + rentalId: newRental.id, + roomId: rental.roomId, + renterId: rental.renterId, + type: 'income', + category: 'rent', + receivableAmount: newRent || rental.rent, + receivedAmount: 0, + status: 'unpaid', + billDate: billStartDate, + billMonth: billMonth, + ...buildBillingMeta('renew', rental.id), + remark: `续租首期租金 - ${renterName}`, + tenantId, + createBy, + updateBy: createBy + }, { transaction }); + + // 5. 如有押金变更,生成押金调整账单 + const depositDiff = (newDeposit !== undefined ? newDeposit : rental.deposit) - rental.deposit; + if (depositDiff !== 0) { + await Bill.create({ + billNo: generateBillNo(), + rentalId: newRental.id, + roomId: rental.roomId, + renterId: rental.renterId, + type: depositDiff > 0 ? 'income' : 'expense', + category: 'deposit', + receivableAmount: Math.abs(depositDiff), + receivedAmount: 0, + status: 'unpaid', + billDate: billStartDate, + billMonth: billMonth, + ...buildBillingMeta('renew', rental.id), + remark: depositDiff > 0 ? `续租补交押金 - ${renterName}` : `续租退还押金 - ${renterName}`, + tenantId, + createBy, + updateBy: createBy + }, { transaction }); + } + + // 6. 记录新的水电表起始读数(如有) + if (waterStartReading !== null && waterStartReading !== undefined) { + await MeterReading.create({ + roomId: rental.roomId, + renterId: rental.renterId, + rentalId: newRental.id, + meterType: 'water', + previousReading: 0, + currentReading: parseFloat(waterStartReading), + usage: 0, + unitPrice: 0, + amount: 0, + billMonth: billMonth, + readingDate: billStartDate, + remark: '续租时水表读数(起始读数)', + tenantId, + createBy, + updateBy: createBy + }, { transaction }); + } + + if (electricStartReading !== null && electricStartReading !== undefined) { + await MeterReading.create({ + roomId: rental.roomId, + renterId: rental.renterId, + rentalId: newRental.id, + meterType: 'electricity', + previousReading: 0, + currentReading: parseFloat(electricStartReading), + usage: 0, + unitPrice: 0, + amount: 0, + billMonth: billMonth, + readingDate: billStartDate, + remark: '续租时电表读数(起始读数)', + tenantId, + createBy, + updateBy: createBy + }, { transaction }); + } + + await transaction.commit(); + + response.created(res, '续租办理成功', { + oldRentalId: rental.id, + newRentalId: newRental.id, + roomId: rental.roomId, + newEndDate: newEndDate, + newRent: newRent || rental.rent + }); + + } catch (error) { + await transaction.rollback(); + console.error('续租处理时出错:', error); + response.serverError(res, '续租处理失败', error); + } +}; + +// 换房接口 +const changeRoom = async (req, res) => { + const transaction = await require('../config/db').transaction(); + + try { + const { id } = req.params; + const { + newRoomId, + changeDate, + newRent, + newDeposit, + operator, + remark, + waterMeterEnd, + electricityMeterEnd + } = req.body; + + const tenantId = req.user.tenantId; + const createBy = req.user.id; + + // 获取原租约信息 + const rental = await Rental.findOne({ + where: { id, tenantId, isDeleted: 0 }, + include: [ + { model: Room, where: { isDeleted: 0 } }, + { model: Renter } + ], + transaction + }); + + if (!rental) { + await transaction.rollback(); + return response.notFound(res, '租约不存在'); + } + + if (rental.status !== 'active') { + await transaction.rollback(); + return response.badRequest(res, '只有生效中的租约可以换房'); + } + + const unsettledBills = await Bill.findAll({ + where: { + rentalId: id, + tenantId, + status: { [Op.in]: ['unpaid', 'partial'] }, + category: { [Op.in]: ['rent', 'water', 'electricity'] }, + isDeleted: 0 + }, + transaction + }); + + if (unsettledBills && unsettledBills.length > 0) { + await transaction.rollback(); + return response.badRequest(res, `该租约有 ${unsettledBills.length} 笔未结清的账单,请先结清后再换房`); + } + + // 检查新房间 + const newRoom = await Room.findOne({ + where: { id: newRoomId, tenantId, isDeleted: 0 }, + transaction + }); + + if (!newRoom) { + await transaction.rollback(); + return response.notFound(res, '新房间不存在'); + } + + if (newRoom.status === 'rented') { + await transaction.rollback(); + return response.badRequest(res, '新房间已在租,请选择其他房间'); + } + + const renterName = rental.Renter ? rental.Renter.name : ''; + const oldRoomId = rental.roomId; + + // 生成账单编号 + 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 random = Math.floor(Math.random() * 10000).toString().padStart(4, '0'); + return `B${year}${month}${day}${random}`; + }; + + // 1. 结算原房间的水电费 + if ((waterMeterEnd || electricityMeterEnd) && changeDate) { + // 获取原房间最新的抄表记录 + if (waterMeterEnd) { + const waterReading = await MeterReading.findOne({ + where: { roomId: oldRoomId, meterType: 'water', rentalId: id, isDeleted: 0 }, + order: [['createTime', 'DESC']], + transaction + }); + + if (waterReading && waterMeterEnd > waterReading.currentReading) { + const waterUsage = waterMeterEnd - waterReading.currentReading; + const waterAmount = waterUsage * parseFloat(waterReading.unitPrice || 0); + + await Bill.create({ + billNo: generateBillNo(), + rentalId: id, + roomId: oldRoomId, + renterId: rental.renterId, + type: 'income', + category: 'water', + receivableAmount: waterAmount, + receivedAmount: 0, + status: 'unpaid', + billDate: changeDate, + billMonth: changeDate.substring(0, 7), + ...buildBillingMeta('change_room', rental.id), + remark: `换房结算水费(${renterName},用量:${waterUsage.toFixed(2)}吨)`, + tenantId, + createBy, + updateBy: createBy + }, { transaction }); + } + } + + if (electricityMeterEnd) { + const electricityReading = await MeterReading.findOne({ + where: { roomId: oldRoomId, meterType: 'electricity', rentalId: id, isDeleted: 0 }, + order: [['createTime', 'DESC']], + transaction + }); + + if (electricityReading && electricityMeterEnd > electricityReading.currentReading) { + const electricityUsage = electricityMeterEnd - electricityReading.currentReading; + const electricityAmount = electricityUsage * parseFloat(electricityReading.unitPrice || 0); + + await Bill.create({ + billNo: generateBillNo(), + rentalId: id, + roomId: oldRoomId, + renterId: rental.renterId, + type: 'income', + category: 'electricity', + receivableAmount: electricityAmount, + receivedAmount: 0, + status: 'unpaid', + billDate: changeDate, + billMonth: changeDate.substring(0, 7), + ...buildBillingMeta('change_room', rental.id), + remark: `换房结算电费(${renterName},用量:${electricityUsage.toFixed(2)}度)`, + tenantId, + createBy, + updateBy: createBy + }, { transaction }); + } + } + } + + // 2. 更新原房间状态为空房 + await Room.update( + { status: 'empty', rentalStatus: 'normal' }, + { where: { id: oldRoomId }, transaction } + ); + + // 3. 关闭原租约(关旧) + await rental.update({ + status: 'terminated', + closeReason: 'changed_room', + remark: `${rental.remark || ''}${remark ? `\n换房备注:${remark}` : ''}`.trim(), + updateBy: createBy + }, { transaction }); + + // 4. 新建租约(开新) + const newRental = await Rental.create({ + roomId: newRoomId, + renterId: rental.renterId, + startDate: changeDate || new Date(), + endDate: rental.endDate, + paymentType: rental.paymentType, + rent: newRent || rental.rent, + deposit: newDeposit !== undefined ? newDeposit : rental.deposit, + operator: operator || rental.operator, + waterMeterStart: waterMeterEnd || null, + electricityMeterStart: electricityMeterEnd || null, + status: 'active', + prevRentalId: rental.id, + originRentalId: rental.originRentalId || rental.id, + remark: `由租约${rental.id}换房新建`, + tenantId, + createBy, + updateBy: createBy + }, { transaction }); + + // 5. 生成新房间租金账单(如有租金) + const newRoomRent = newRent || rental.rent; + if (newRoomRent > 0) { + const billStartDate = changeDate || new Date(); + const billMonth = rentalBillingService.billMonthOf(billStartDate); + await Bill.create({ + billNo: generateBillNo(), + rentalId: newRental.id, + roomId: newRoomId, + renterId: rental.renterId, + type: 'income', + category: 'rent', + receivableAmount: newRoomRent, + receivedAmount: 0, + status: 'unpaid', + billDate: billStartDate, + billMonth: billMonth, + ...buildBillingMeta('change_room', rental.id), + remark: `换房首期租金 - ${renterName}`, + tenantId, + createBy, + updateBy: createBy + }, { transaction }); + } + + // 5. 更新新房间状态为已租 + await Room.update( + { status: 'rented', rentalStatus: 'normal' }, + { where: { id: newRoomId }, transaction } + ); + + await transaction.commit(); + + response.success(res, '换房办理成功', { + oldRentalId: rental.id, + newRentalId: newRental.id, + oldRoomId: oldRoomId, + newRoomId: newRoomId, + changeDate: changeDate + }); + + } catch (error) { + await transaction.rollback(); + console.error('换房处理时出错:', error); + response.serverError(res, '换房处理失败', error); + } +}; + // 获取所有租房(不分页) const listRentals = async (req, res) => { try { @@ -567,6 +1216,7 @@ const listRentals = async (req, res) => { }; // 创建租房(包含租客、水电表读数)- 整合接口 +// 注意:入住时只记录水电表起始读数,不生成账单,水电费通过后续抄表生成 const createRentalWithRenter = async (req, res) => { const transaction = await require('../config/db').transaction(); @@ -587,7 +1237,7 @@ const createRentalWithRenter = async (req, res) => { deposit = 0, operator, remark, - // 水电表读数 + // 水电表读数(入住时记录,不生成账单) waterStartReading, electricStartReading } = req.body; @@ -613,6 +1263,20 @@ const createRentalWithRenter = async (req, res) => { return response.badRequest(res, '租金不能为空'); } + // 检查房间状态 + const room = await Room.findOne({ + where: { id: roomId, tenantId, isDeleted: 0 }, + transaction + }); + if (!room) { + await transaction.rollback(); + return response.notFound(res, '房间不存在'); + } + if (room.status === 'rented') { + await transaction.rollback(); + return response.badRequest(res, '该房间已在租,请选择其他房间'); + } + const parsedRoomId = parseInt(roomId); if (isNaN(parsedRoomId)) { await transaction.rollback(); @@ -670,7 +1334,11 @@ const createRentalWithRenter = async (req, res) => { return `B${year}${month}${day}${random}` } - // 4. 创建租金账单 + // 4. 创建首期租金账单(根据付款周期生成相应期数) + // 简化处理:默认只生成首期账单,后续通过定时任务或手动生成 + const firstBillDate = startDate; + const firstBillMonth = startDate.substring(0, 7); + await Bill.create({ billNo: generateBillNo(), rentalId: rental.id, @@ -681,9 +1349,10 @@ const createRentalWithRenter = async (req, res) => { receivableAmount: rent, receivedAmount: 0, status: 'unpaid', - billDate: startDate, - billMonth: startDate.substring(0, 7), - remark: `租约租金 - ${renterName}`, + billDate: firstBillDate, + billMonth: firstBillMonth, + settlementType: 'normal', + remark: `租约首期租金 - ${renterName}`, tenantId, createBy, updateBy: createBy @@ -701,8 +1370,9 @@ const createRentalWithRenter = async (req, res) => { receivableAmount: deposit, receivedAmount: 0, status: 'unpaid', - billDate: startDate, - billMonth: startDate.substring(0, 7), + billDate: firstBillDate, + billMonth: firstBillMonth, + settlementType: 'normal', remark: `租约押金 - ${renterName}`, tenantId, createBy, @@ -710,71 +1380,65 @@ const createRentalWithRenter = async (req, res) => { }, { transaction }); } - // 6. 创建水表读数记录(如果有) + // 6. 记录水电表起始读数(仅记录,不生成账单) + // 水电费通过后续抄表管理生成账单 + + // 创建水表起始读数记录 if (waterStartReading !== null && waterStartReading !== '' && waterStartReading !== undefined) { - // 获取公寓水费单价 - const room = await Room.findByPk(parsedRoomId, { - include: [{ model: Apartment, as: 'apartment' }], - transaction - }); - const waterPrice = room?.apartment?.waterPrice || 0; - await MeterReading.create({ roomId: parsedRoomId, renterId: renter.id, rentalId: rental.id, meterType: 'water', previousReading: 0, - currentReading: waterStartReading, + currentReading: parseFloat(waterStartReading), usage: 0, - unitPrice: waterPrice, + unitPrice: 0, amount: 0, billMonth: startDate.substring(0, 7), readingDate: startDate, - remark: '入住时水表读数', + remark: '入住时水表读数(起始读数)', tenantId, createBy, updateBy: createBy }, { transaction }); - console.log('水表读数记录创建成功'); + console.log('水表起始读数记录创建成功'); } - // 7. 创建电表读数记录(如果有) + // 创建电表起始读数记录 if (electricStartReading !== null && electricStartReading !== '' && electricStartReading !== undefined) { - // 获取公寓电费单价 - const room = await Room.findByPk(parsedRoomId, { - include: [{ model: Apartment, as: 'apartment' }], - transaction - }); - const electricityPrice = room?.apartment?.electricityPrice || 0; - await MeterReading.create({ roomId: parsedRoomId, renterId: renter.id, rentalId: rental.id, meterType: 'electricity', previousReading: 0, - currentReading: electricStartReading, + currentReading: parseFloat(electricStartReading), usage: 0, - unitPrice: electricityPrice, + unitPrice: 0, amount: 0, billMonth: startDate.substring(0, 7), readingDate: startDate, - remark: '入住时电表读数', + remark: '入住时电表读数(起始读数)', tenantId, createBy, updateBy: createBy }, { transaction }); - console.log('电表读数记录创建成功'); + console.log('电表起始读数记录创建成功'); } // 提交事务 await transaction.commit(); - response.created(res, '创建成功', { + response.created(res, '入住办理成功(首期租金和押金账单已生成,水电表读数已记录)', { rentalId: rental.id, renterId: renter.id, - roomId: parsedRoomId + roomId: parsedRoomId, + billsGenerated: { + rent: true, + deposit: deposit > 0, + meterReadings: !!(waterStartReading || electricStartReading) + } }); } catch (error) { @@ -793,5 +1457,7 @@ module.exports = { updateRental, deleteRental, terminateRental, - createRentalWithRenter + createRentalWithRenter, + renewRental, + changeRoom }; diff --git a/controllers/roomController.js b/controllers/roomController.js index 396a19c..187e6a9 100644 --- a/controllers/roomController.js +++ b/controllers/roomController.js @@ -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); diff --git a/controllers/settingController.js b/controllers/settingController.js index fe80fb3..87b96b7 100644 --- a/controllers/settingController.js +++ b/controllers/settingController.js @@ -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 } }); diff --git a/controllers/userController.js b/controllers/userController.js index 335833c..0d12638 100644 --- a/controllers/userController.js +++ b/controllers/userController.js @@ -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({ diff --git a/database_schema.sql b/database_schema.sql index 47c79f1..deb645a 100644 --- a/database_schema.sql +++ b/database_schema.sql @@ -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` ( diff --git a/init_base_data.sql b/init_base_data.sql index 6d4a275..1134873 100644 --- a/init_base_data.sql +++ b/init_base_data.sql @@ -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. 初始化支付设置 -- ============================================ diff --git a/models/Bill.js b/models/Bill.js index 4c9fa27..c6f33db 100644 --- a/models/Bill.js +++ b/models/Bill.js @@ -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, diff --git a/models/Order.js b/models/Order.js index b54a820..1f03778 100644 --- a/models/Order.js +++ b/models/Order.js @@ -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; diff --git a/models/Rental.js b/models/Rental.js index 39474a6..e10cfb8 100644 --- a/models/Rental.js +++ b/models/Rental.js @@ -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; \ No newline at end of file +module.exports = Rental; diff --git a/models/SubscriptionPlan.js b/models/SubscriptionPlan.js index 471e070..c71afab 100644 --- a/models/SubscriptionPlan.js +++ b/models/SubscriptionPlan.js @@ -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; diff --git a/models/TenantBillingDetail.js b/models/TenantBillingDetail.js index 27fe43d..db6291c 100644 --- a/models/TenantBillingDetail.js +++ b/models/TenantBillingDetail.js @@ -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; diff --git a/models/TenantSubscription.js b/models/TenantSubscription.js new file mode 100644 index 0000000..a8f3ed1 --- /dev/null +++ b/models/TenantSubscription.js @@ -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; diff --git a/models/index.js b/models/index.js index bf4f770..0b52742 100644 --- a/models/index.js +++ b/models/index.js @@ -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, diff --git a/routes/rental.js b/routes/rental.js index 9223875..aca517a 100644 --- a/routes/rental.js +++ b/routes/rental.js @@ -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; \ No newline at end of file diff --git a/services/billingService.js b/services/billingService.js index 9fc43af..908b18f 100644 --- a/services/billingService.js +++ b/services/billingService.js @@ -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 = { diff --git a/services/rentalBillingService.js b/services/rentalBillingService.js new file mode 100644 index 0000000..3b40367 --- /dev/null +++ b/services/rentalBillingService.js @@ -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 +};