From 9d7daa281fe0f1db59ad8dffd2b6e2c2104c09d3 Mon Sep 17 00:00:00 2001 From: 18631081161 <2088094923@qq.com> Date: Tue, 23 Dec 2025 17:57:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=97=AE=E9=A2=98.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + admin/src/layouts/MainLayout.vue | 4 +- admin/src/views/appointments/index.vue | 48 +- admin/src/views/notifications/index.vue | 143 ++--- admin/src/views/payment-orders/index.vue | 194 +++++- .../adminPaymentOrderController.js | 23 +- .../add-payment-order-cancel-reason.js | 41 ++ .../add-payment-order-currencies.js | 62 ++ backend/src/models/PaymentOrder.js | 25 +- backend/src/routes/adminNotificationRoutes.js | 2 +- .../src/services/adminNotificationService.js | 15 +- backend/src/services/paymentOrderService.js | 148 +++-- ...mentOrderAmountStatistics.property.test.js | 597 ++++++++++++++++++ ...aymentOrderCurrencyFilter.property.test.js | 365 +++++++++++ miniprogram/src/locale/en.js | 2 + miniprogram/src/locale/es.js | 2 + miniprogram/src/locale/zh.js | 2 + miniprogram/src/pages/me/contact-us-page.vue | 65 +- .../src/pages/me/notification-page.vue | 8 +- 19 files changed, 1555 insertions(+), 192 deletions(-) create mode 100644 backend/src/migrations/add-payment-order-cancel-reason.js create mode 100644 backend/src/migrations/add-payment-order-currencies.js create mode 100644 backend/src/tests/paymentOrderAmountStatistics.property.test.js create mode 100644 backend/src/tests/paymentOrderCurrencyFilter.property.test.js diff --git a/.gitignore b/.gitignore index 277fa2a..46d7b82 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ server/admin-dist/* server/data/* server/data/logs/nginx/error.log server/data/logs/nginx/access.log +/logs/ diff --git a/admin/src/layouts/MainLayout.vue b/admin/src/layouts/MainLayout.vue index d8d1c27..409d788 100644 --- a/admin/src/layouts/MainLayout.vue +++ b/admin/src/layouts/MainLayout.vue @@ -16,7 +16,7 @@ - + @@ -24,7 +24,7 @@ - + diff --git a/admin/src/views/appointments/index.vue b/admin/src/views/appointments/index.vue index 2db18e6..171c845 100644 --- a/admin/src/views/appointments/index.vue +++ b/admin/src/views/appointments/index.vue @@ -597,14 +597,30 @@ - + + ₱ 比索 + + + + ¥ 人民币 + + + + 至少填写一种货币金额,两个都填表示同时支付了两种货币 + 0 + const hasRmb = paymentForm.amountRmb !== null && paymentForm.amountRmb !== undefined && paymentForm.amountRmb > 0 + + if (!hasPeso && !hasRmb) { + ElMessage.warning('请至少填写一种货币金额(比索或人民币)') + return + } + await paymentFormRef.value.validate(async (valid) => { if (!valid) return @@ -914,7 +937,8 @@ async function confirmCreatePaymentOrder() { const response = await api.post('/api/v1/admin/payment-orders', { userId: paymentForm.userId, appointmentId: paymentForm.appointmentNo, - amount: paymentForm.amount, + amountPeso: hasPeso ? paymentForm.amountPeso : undefined, + amountRmb: hasRmb ? paymentForm.amountRmb : undefined, serviceContent: paymentForm.serviceContent, paymentTime: paymentForm.paymentTime, notes: paymentForm.notes @@ -926,7 +950,7 @@ async function confirmCreatePaymentOrder() { } } catch (error) { console.error('Failed to create payment order:', error) - ElMessage.error(error.response?.data?.message || '创建支付订单失败') + ElMessage.error(error.response?.data?.error?.message || '创建支付订单失败') } finally { paymentCreating.value = false } diff --git a/admin/src/views/notifications/index.vue b/admin/src/views/notifications/index.vue index b9d65f1..8cb9aee 100644 --- a/admin/src/views/notifications/index.vue +++ b/admin/src/views/notifications/index.vue @@ -1,44 +1,5 @@ diff --git a/backend/src/controllers/adminPaymentOrderController.js b/backend/src/controllers/adminPaymentOrderController.js index 36fc7cf..c7e6b5a 100644 --- a/backend/src/controllers/adminPaymentOrderController.js +++ b/backend/src/controllers/adminPaymentOrderController.js @@ -12,12 +12,14 @@ const paymentOrderService = require('../services/paymentOrderService'); const createPaymentOrder = async (req, res) => { try { const adminId = req.adminId || req.admin?.id; - const { userId, appointmentId, amount, serviceContent, paymentTime, notes } = req.body; + const { userId, appointmentId, amount, amountPeso, amountRmb, serviceContent, paymentTime, notes } = req.body; const result = await paymentOrderService.createPaymentOrder({ - userId, + userId: userId ? userId.trim() : userId, // Trim whitespace appointmentId, amount, + amountPeso, + amountRmb, serviceContent, paymentTime, notes, @@ -60,7 +62,7 @@ const createPaymentOrder = async (req, res) => { */ const getPaymentOrders = async (req, res) => { try { - const { page, limit, userId, status, startDate, endDate, search } = req.query; + const { page, limit, userId, status, startDate, endDate, search, currency } = req.query; const result = await paymentOrderService.getPaymentOrders({ page, @@ -70,6 +72,7 @@ const getPaymentOrders = async (req, res) => { startDate, endDate, search, + currency, }); return res.status(200).json({ @@ -137,8 +140,9 @@ const getPaymentOrderById = async (req, res) => { const cancelPaymentOrder = async (req, res) => { try { const { id } = req.params; + const { cancelReason } = req.body; - const result = await paymentOrderService.cancelPaymentOrder(id); + const result = await paymentOrderService.cancelPaymentOrder(id, cancelReason); return res.status(200).json({ code: 0, @@ -169,6 +173,17 @@ const cancelPaymentOrder = async (req, res) => { }); } + if (error.message.includes('请')) { + return res.status(400).json({ + code: 400, + success: false, + error: { + code: 'INVALID_INPUT', + message: error.message, + }, + }); + } + return res.status(500).json({ code: 500, success: false, diff --git a/backend/src/migrations/add-payment-order-cancel-reason.js b/backend/src/migrations/add-payment-order-cancel-reason.js new file mode 100644 index 0000000..66815d6 --- /dev/null +++ b/backend/src/migrations/add-payment-order-cancel-reason.js @@ -0,0 +1,41 @@ +/** + * Migration: Add cancel_reason field to payment_orders table + */ + +const { sequelize } = require('../config/database'); + +async function up() { + const queryInterface = sequelize.getQueryInterface(); + + // Check if column already exists + const tableInfo = await queryInterface.describeTable('payment_orders'); + + if (!tableInfo.cancel_reason) { + await queryInterface.addColumn('payment_orders', 'cancel_reason', { + type: 'TEXT', + allowNull: true, + comment: '取消原因' + }); + console.log('Added cancel_reason column'); + } + + console.log('Migration completed successfully'); +} + +async function down() { + const queryInterface = sequelize.getQueryInterface(); + await queryInterface.removeColumn('payment_orders', 'cancel_reason'); + console.log('Rollback completed'); +} + +// Run migration +if (require.main === module) { + up() + .then(() => process.exit(0)) + .catch(err => { + console.error('Migration failed:', err); + process.exit(1); + }); +} + +module.exports = { up, down }; diff --git a/backend/src/migrations/add-payment-order-currencies.js b/backend/src/migrations/add-payment-order-currencies.js new file mode 100644 index 0000000..09b23fa --- /dev/null +++ b/backend/src/migrations/add-payment-order-currencies.js @@ -0,0 +1,62 @@ +/** + * Migration: Add currency fields to payment_orders table + * Adds amount_peso and amount_rmb columns for dual currency support + */ + +const { sequelize } = require('../config/database'); + +async function up() { + const queryInterface = sequelize.getQueryInterface(); + + // Check if columns already exist + const tableInfo = await queryInterface.describeTable('payment_orders'); + + if (!tableInfo.amount_peso) { + await queryInterface.addColumn('payment_orders', 'amount_peso', { + type: 'DECIMAL(10, 2)', + allowNull: true, + defaultValue: null, + comment: '支付金额(比索)' + }); + console.log('Added amount_peso column'); + } + + if (!tableInfo.amount_rmb) { + await queryInterface.addColumn('payment_orders', 'amount_rmb', { + type: 'DECIMAL(10, 2)', + allowNull: true, + defaultValue: null, + comment: '支付金额(人民币)' + }); + console.log('Added amount_rmb column'); + } + + // Make original amount column nullable + if (tableInfo.amount && !tableInfo.amount.allowNull) { + await sequelize.query('ALTER TABLE `payment_orders` MODIFY COLUMN `amount` DECIMAL(10, 2) NULL'); + console.log('Made amount column nullable'); + } + + console.log('Migration completed successfully'); +} + +async function down() { + const queryInterface = sequelize.getQueryInterface(); + + await queryInterface.removeColumn('payment_orders', 'amount_peso'); + await queryInterface.removeColumn('payment_orders', 'amount_rmb'); + + console.log('Rollback completed'); +} + +// Run migration +if (require.main === module) { + up() + .then(() => process.exit(0)) + .catch(err => { + console.error('Migration failed:', err); + process.exit(1); + }); +} + +module.exports = { up, down }; diff --git a/backend/src/models/PaymentOrder.js b/backend/src/models/PaymentOrder.js index e4ddcf3..6231f1d 100644 --- a/backend/src/models/PaymentOrder.js +++ b/backend/src/models/PaymentOrder.js @@ -40,8 +40,23 @@ const PaymentOrder = sequelize.define('PaymentOrder', { }, amount: { type: DataTypes.DECIMAL(10, 2), - allowNull: false, - comment: '支付金额', + allowNull: true, + defaultValue: null, + comment: '支付金额(人民币,保留用于兼容)', + }, + amountPeso: { + type: DataTypes.DECIMAL(10, 2), + allowNull: true, + defaultValue: null, + field: 'amount_peso', + comment: '支付金额(比索)', + }, + amountRmb: { + type: DataTypes.DECIMAL(10, 2), + allowNull: true, + defaultValue: null, + field: 'amount_rmb', + comment: '支付金额(人民币)', }, serviceContent: { type: DataTypes.TEXT, @@ -76,6 +91,12 @@ const PaymentOrder = sequelize.define('PaymentOrder', { allowNull: false, comment: '订单状态', }, + cancelReason: { + type: DataTypes.TEXT, + allowNull: true, + field: 'cancel_reason', + comment: '取消原因', + }, }, { tableName: 'payment_orders', timestamps: true, diff --git a/backend/src/routes/adminNotificationRoutes.js b/backend/src/routes/adminNotificationRoutes.js index d0db3eb..e766fe7 100644 --- a/backend/src/routes/adminNotificationRoutes.js +++ b/backend/src/routes/adminNotificationRoutes.js @@ -29,7 +29,7 @@ router.get('/statistics', logAdminOperation, adminNotificationController.getStat /** POST /api/v1/admin/notifications/send - Send to specific user */ router.post('/send', [ - body('userId').isUUID().withMessage('User ID is required'), + body('userId').notEmpty().withMessage('User ID is required'), body('titleZh').notEmpty().withMessage('Chinese title is required'), body('contentZh').notEmpty().withMessage('Chinese content is required'), body('type').optional().isIn(['system', 'activity', 'service']), diff --git a/backend/src/services/adminNotificationService.js b/backend/src/services/adminNotificationService.js index ba11408..8af5e73 100644 --- a/backend/src/services/adminNotificationService.js +++ b/backend/src/services/adminNotificationService.js @@ -46,11 +46,18 @@ class AdminNotificationService { } /** - * Send notification to specific user + * Send notification to specific user (supports both userId and uid) */ static async sendToUser(userId, notificationData) { try { - const user = await User.findByPk(userId); + let user; + // Support both UUID and UID (6-digit number) + if (/^\d{6}$/.test(userId)) { + user = await User.findOne({ where: { uid: userId } }); + } else { + user = await User.findByPk(userId); + } + if (!user) { const error = new Error('User not found'); error.statusCode = 404; @@ -58,7 +65,7 @@ class AdminNotificationService { } const notification = await Notification.create({ - userId, + userId: user.id, type: notificationData.type || 'system', titleZh: notificationData.titleZh, titleEn: notificationData.titleEn || notificationData.titleZh, @@ -69,7 +76,7 @@ class AdminNotificationService { isRead: false, }); - logger.info(`Admin sent notification to user ${userId}`); + logger.info(`Admin sent notification to user ${user.id} (uid: ${user.uid})`); return notification.toJSON(); } catch (error) { logger.error('Error sending notification:', error); diff --git a/backend/src/services/paymentOrderService.js b/backend/src/services/paymentOrderService.js index 52f4034..2ff36c9 100644 --- a/backend/src/services/paymentOrderService.js +++ b/backend/src/services/paymentOrderService.js @@ -28,14 +28,20 @@ const generateOrderNo = () => { * @returns {Object} Created payment order with commission info */ const createPaymentOrder = async (data, adminId) => { - const { userId, appointmentId, amount, serviceContent, paymentTime, notes } = data; + const { userId, appointmentId, amount, amountPeso, amountRmb, serviceContent, paymentTime, notes } = data; // Validate required fields if (!userId) { throw new Error('请输入用户ID'); } - if (!amount || parseFloat(amount) <= 0) { - throw new Error('请输入有效的支付金额'); + // At least one amount must be provided (check for actual positive numbers) + const pesoAmount = amountPeso !== null && amountPeso !== undefined ? parseFloat(amountPeso) : 0; + const rmbAmount = amountRmb !== null && amountRmb !== undefined ? parseFloat(amountRmb) : 0; + const legacyAmount = amount !== null && amount !== undefined ? parseFloat(amount) : 0; + + const hasAmount = pesoAmount > 0 || rmbAmount > 0 || legacyAmount > 0; + if (!hasAmount) { + throw new Error('请输入有效的支付金额(比索或人民币至少填写一项)'); } if (!serviceContent) { throw new Error('请输入服务内容'); @@ -92,12 +98,17 @@ const createPaymentOrder = async (data, adminId) => { exists = !!existingOrder; } + // Calculate total amount for commission (use RMB if available) + const totalAmountForCommission = rmbAmount > 0 ? rmbAmount : legacyAmount; + // Create payment order const paymentOrder = await PaymentOrder.create({ orderNo, userId: actualUserId, appointmentId: actualAppointmentId, - amount: parseFloat(amount), + amount: legacyAmount > 0 ? legacyAmount : null, // Legacy field + amountPeso: pesoAmount > 0 ? pesoAmount : null, + amountRmb: rmbAmount > 0 ? rmbAmount : null, serviceContent, paymentTime: new Date(paymentTime), notes: notes || null, @@ -105,13 +116,15 @@ const createPaymentOrder = async (data, adminId) => { status: 'active', }); - // Calculate commission (if applicable) + // Calculate commission (if applicable, based on RMB amount) let commission = null; - try { - commission = await commissionService.calculateCommission(paymentOrder.id); - } catch (error) { - // Log error but don't fail the order creation - console.error('Commission calculation error:', error.message); + if (totalAmountForCommission > 0) { + try { + commission = await commissionService.calculateCommission(paymentOrder.id); + } catch (error) { + // Log error but don't fail the order creation + console.error('Commission calculation error:', error.message); + } } return { @@ -120,7 +133,9 @@ const createPaymentOrder = async (data, adminId) => { orderNo: paymentOrder.orderNo, userId: paymentOrder.userId, appointmentId: paymentOrder.appointmentId, - amount: parseFloat(paymentOrder.amount).toFixed(2), + amount: paymentOrder.amount ? parseFloat(paymentOrder.amount).toFixed(2) : null, + amountPeso: paymentOrder.amountPeso ? parseFloat(paymentOrder.amountPeso).toFixed(2) : null, + amountRmb: paymentOrder.amountRmb ? parseFloat(paymentOrder.amountRmb).toFixed(2) : null, serviceContent: paymentOrder.serviceContent, paymentTime: paymentOrder.paymentTime, notes: paymentOrder.notes, @@ -139,53 +154,83 @@ const createPaymentOrder = async (data, adminId) => { /** * Get payment orders with filters * @param {Object} options - Query options - * @returns {Object} Payment orders with pagination + * @returns {Object} Payment orders with pagination and statistics */ const getPaymentOrders = async (options = {}) => { - const { page = 1, limit = 20, userId, status, startDate, endDate, search } = options; + const { page = 1, limit = 20, userId, status, startDate, endDate, search, currency } = options; const offset = (page - 1) * limit; + const Op = require('sequelize').Op; const where = {}; if (userId) where.userId = userId; if (status) where.status = status; + // Currency filter: 'rmb' → amountRmb > 0, 'peso' → amountPeso > 0 + // Invalid currency values are ignored (treated as no filter) + if (currency === 'rmb') { + where.amountRmb = { [Op.gt]: 0 }; + } else if (currency === 'peso') { + where.amountPeso = { [Op.gt]: 0 }; + } + if (startDate || endDate) { where.paymentTime = {}; - if (startDate) where.paymentTime[require('sequelize').Op.gte] = new Date(startDate); - if (endDate) where.paymentTime[require('sequelize').Op.lte] = new Date(endDate); + if (startDate) where.paymentTime[Op.gte] = new Date(startDate); + if (endDate) where.paymentTime[Op.lte] = new Date(endDate); } if (search) { - where[require('sequelize').Op.or] = [ - { orderNo: { [require('sequelize').Op.like]: `%${search}%` } }, - { serviceContent: { [require('sequelize').Op.like]: `%${search}%` } }, + where[Op.or] = [ + { orderNo: { [Op.like]: `%${search}%` } }, + { serviceContent: { [Op.like]: `%${search}%` } }, ]; } - const { count, rows } = await PaymentOrder.findAndCountAll({ - where, - include: [ - { - model: User, - as: 'user', - attributes: ['id', 'uid', 'nickname', 'phone'], - }, - { - model: Appointment, - as: 'appointment', - attributes: ['id', 'appointmentNo'], - required: false, - }, - { - model: Admin, - as: 'creator', - attributes: ['id', 'username', 'email'], - }, - ], - order: [['createdAt', 'DESC']], - limit: parseInt(limit), - offset: parseInt(offset), - }); + // Execute both queries in parallel: orders list and statistics + const [ordersResult, statisticsResult] = await Promise.all([ + // Query for paginated orders + PaymentOrder.findAndCountAll({ + where, + include: [ + { + model: User, + as: 'user', + attributes: ['id', 'uid', 'nickname', 'phone'], + }, + { + model: Appointment, + as: 'appointment', + attributes: ['id', 'appointmentNo'], + required: false, + }, + { + model: Admin, + as: 'creator', + attributes: ['id', 'username', 'email'], + }, + ], + order: [['createdAt', 'DESC']], + limit: parseInt(limit), + offset: parseInt(offset), + }), + // Query for amount statistics (sum of all matching orders, not just current page) + PaymentOrder.findOne({ + where, + attributes: [ + [sequelize.fn('COALESCE', sequelize.fn('SUM', sequelize.col('amount_rmb')), 0), 'totalRmb'], + [sequelize.fn('COALESCE', sequelize.fn('SUM', sequelize.col('amount_peso')), 0), 'totalPeso'], + ], + raw: true, + }), + ]); + + const { count, rows } = ordersResult; + + // Format statistics with two decimal places + const statistics = { + totalRmb: parseFloat(statisticsResult?.totalRmb || 0).toFixed(2), + totalPeso: parseFloat(statisticsResult?.totalPeso || 0).toFixed(2), + }; // Get commission status for each order const Commission = require('../models/Commission'); @@ -207,7 +252,9 @@ const getPaymentOrders = async (options = {}) => { orderNo: order.orderNo, user: order.user, appointment: order.appointment, - amount: parseFloat(order.amount).toFixed(2), + amount: order.amount ? parseFloat(order.amount).toFixed(2) : null, + amountPeso: order.amountPeso ? parseFloat(order.amountPeso).toFixed(2) : null, + amountRmb: order.amountRmb ? parseFloat(order.amountRmb).toFixed(2) : null, serviceContent: order.serviceContent, paymentTime: order.paymentTime, notes: order.notes, @@ -225,6 +272,7 @@ const getPaymentOrders = async (options = {}) => { total: count, totalPages: Math.ceil(count / limit), }, + statistics, }; }; @@ -276,11 +324,14 @@ const getPaymentOrderById = async (orderId) => { orderNo: order.orderNo, user: order.user, appointment: order.appointment, - amount: parseFloat(order.amount).toFixed(2), + amount: order.amount ? parseFloat(order.amount).toFixed(2) : null, + amountPeso: order.amountPeso ? parseFloat(order.amountPeso).toFixed(2) : null, + amountRmb: order.amountRmb ? parseFloat(order.amountRmb).toFixed(2) : null, serviceContent: order.serviceContent, paymentTime: order.paymentTime, notes: order.notes, status: order.status, + cancelReason: order.cancelReason, creator: order.creator, commission: commission ? { id: commission.id, @@ -298,9 +349,10 @@ const getPaymentOrderById = async (orderId) => { /** * Cancel payment order * @param {string} orderId - Payment order ID + * @param {string} cancelReason - Reason for cancellation * @returns {Object} Cancelled payment order */ -const cancelPaymentOrder = async (orderId) => { +const cancelPaymentOrder = async (orderId, cancelReason) => { const transaction = await sequelize.transaction(); try { @@ -314,8 +366,13 @@ const cancelPaymentOrder = async (orderId) => { throw new Error('Payment order is already cancelled'); } + if (!cancelReason || !cancelReason.trim()) { + throw new Error('请输入取消原因'); + } + // Cancel the order order.status = 'cancelled'; + order.cancelReason = cancelReason.trim(); await order.save({ transaction }); // Cancel related commission and revert balance @@ -345,6 +402,7 @@ const cancelPaymentOrder = async (orderId) => { id: order.id, orderNo: order.orderNo, status: order.status, + cancelReason: order.cancelReason, commissionCancelled: !!commission, }; } catch (error) { diff --git a/backend/src/tests/paymentOrderAmountStatistics.property.test.js b/backend/src/tests/paymentOrderAmountStatistics.property.test.js new file mode 100644 index 0000000..d399ad0 --- /dev/null +++ b/backend/src/tests/paymentOrderAmountStatistics.property.test.js @@ -0,0 +1,597 @@ +/** + * Payment Order Amount Statistics Property Tests + * **Feature: payment-order-amount-statistics** + */ + +const fc = require('fast-check'); + +// Mock database first +jest.mock('../config/database', () => ({ + sequelize: { + define: jest.fn(() => ({})), + transaction: jest.fn(() => Promise.resolve({ + commit: jest.fn(), + rollback: jest.fn(), + })), + fn: jest.fn((fnName, ...args) => ({ fn: fnName, args })), + col: jest.fn((colName) => ({ col: colName })), + }, +})); + +// Mock models +jest.mock('../models/User', () => ({ + findByPk: jest.fn(), + findOne: jest.fn(), +})); + +jest.mock('../models/Appointment', () => ({ + findByPk: jest.fn(), + findOne: jest.fn(), +})); + +jest.mock('../models/PaymentOrder', () => ({ + findOne: jest.fn(), + create: jest.fn(), + findByPk: jest.fn(), + findAndCountAll: jest.fn(), +})); + +jest.mock('../models/Admin', () => ({ + findByPk: jest.fn(), +})); + +jest.mock('../models/Commission', () => ({ + findAll: jest.fn(), + findOne: jest.fn(), +})); + +jest.mock('../services/commissionService', () => ({ + calculateCommission: jest.fn(), +})); + +const PaymentOrder = require('../models/PaymentOrder'); +const Commission = require('../models/Commission'); +const paymentOrderService = require('../services/paymentOrderService'); + +// Generator for payment order with various currency amounts +const paymentOrderArbitrary = () => fc.record({ + id: fc.uuid(), + orderNo: fc.string({ minLength: 10, maxLength: 20 }).map(s => `PO${s}`), + userId: fc.uuid(), + amountRmb: fc.oneof( + fc.constant(null), + fc.constant(0), + fc.double({ min: 0.01, max: 100000, noNaN: true }) + ), + amountPeso: fc.oneof( + fc.constant(null), + fc.constant(0), + fc.double({ min: 0.01, max: 100000, noNaN: true }) + ), + amount: fc.option(fc.double({ min: 0.01, max: 100000, noNaN: true }), { nil: null }), + serviceContent: fc.string({ minLength: 1, maxLength: 200 }), + paymentTime: fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') }), + status: fc.constantFrom('active', 'cancelled'), + createdAt: fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') }), +}); + +// Helper to create mock order with associations +const createMockOrder = (order) => ({ + ...order, + user: { id: order.userId, uid: '123456', nickname: 'Test User', phone: '1234567890' }, + appointment: null, + creator: { id: 'admin-uuid', username: 'admin', email: 'admin@test.com' }, +}); + +// Helper to calculate expected RMB sum (treating null/undefined as 0) +const calculateExpectedRmbSum = (orders) => { + return orders.reduce((sum, order) => { + const amount = order.amountRmb !== null && order.amountRmb !== undefined ? parseFloat(order.amountRmb) : 0; + return sum + amount; + }, 0); +}; + +// Helper to calculate expected Peso sum (treating null/undefined as 0) +const calculateExpectedPesoSum = (orders) => { + return orders.reduce((sum, order) => { + const amount = order.amountPeso !== null && order.amountPeso !== undefined ? parseFloat(order.amountPeso) : 0; + return sum + amount; + }, 0); +}; + +describe('Payment Order Amount Statistics - Property Tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + + /** + * **Feature: payment-order-amount-statistics, Property 1: RMB Sum Correctness** + * *For any* set of payment orders matching the filter conditions, the returned totalRmb + * should equal the sum of all amountRmb values (treating null/undefined as 0) from those orders. + * **Validates: Requirements 3.1, 3.4** + */ + describe('Property 1: RMB Sum Correctness', () => { + it('should return totalRmb equal to sum of all amountRmb values', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 0, maxLength: 20 }), + async (orders) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + const mockRows = orders.map(createMockOrder); + const expectedRmbSum = calculateExpectedRmbSum(orders); + const expectedPesoSum = calculateExpectedPesoSum(orders); + + // Setup mocks + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + PaymentOrder.findOne.mockResolvedValue({ + totalRmb: expectedRmbSum, + totalPeso: expectedPesoSum, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({}); + + // Assert: totalRmb should equal the sum of all amountRmb values + expect(result.statistics).toBeDefined(); + expect(parseFloat(result.statistics.totalRmb)).toBeCloseTo(expectedRmbSum, 2); + } + ), + { numRuns: 100 } + ); + }); + + it('should treat null amountRmb values as 0 in sum calculation', async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + id: fc.uuid(), + orderNo: fc.string({ minLength: 10, maxLength: 20 }).map(s => `PO${s}`), + userId: fc.uuid(), + amountRmb: fc.constant(null), // All null values + amountPeso: fc.oneof(fc.constant(null), fc.double({ min: 0.01, max: 100000, noNaN: true })), + amount: fc.constant(null), + serviceContent: fc.string({ minLength: 1, maxLength: 200 }), + paymentTime: fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') }), + status: fc.constantFrom('active', 'cancelled'), + createdAt: fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') }), + }), + { minLength: 1, maxLength: 10 } + ), + async (orders) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + const mockRows = orders.map(createMockOrder); + + // Setup mocks - all null RMB values should sum to 0 + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + PaymentOrder.findOne.mockResolvedValue({ + totalRmb: 0, + totalPeso: calculateExpectedPesoSum(orders), + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({}); + + // Assert: totalRmb should be "0.00" when all values are null + expect(result.statistics.totalRmb).toBe('0.00'); + } + ), + { numRuns: 100 } + ); + }); + }); + + + /** + * **Feature: payment-order-amount-statistics, Property 2: Peso Sum Correctness** + * *For any* set of payment orders matching the filter conditions, the returned totalPeso + * should equal the sum of all amountPeso values (treating null/undefined as 0) from those orders. + * **Validates: Requirements 3.2, 3.4** + */ + describe('Property 2: Peso Sum Correctness', () => { + it('should return totalPeso equal to sum of all amountPeso values', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 0, maxLength: 20 }), + async (orders) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + const mockRows = orders.map(createMockOrder); + const expectedRmbSum = calculateExpectedRmbSum(orders); + const expectedPesoSum = calculateExpectedPesoSum(orders); + + // Setup mocks + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + PaymentOrder.findOne.mockResolvedValue({ + totalRmb: expectedRmbSum, + totalPeso: expectedPesoSum, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({}); + + // Assert: totalPeso should equal the sum of all amountPeso values + expect(result.statistics).toBeDefined(); + expect(parseFloat(result.statistics.totalPeso)).toBeCloseTo(expectedPesoSum, 2); + } + ), + { numRuns: 100 } + ); + }); + + it('should treat null amountPeso values as 0 in sum calculation', async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + id: fc.uuid(), + orderNo: fc.string({ minLength: 10, maxLength: 20 }).map(s => `PO${s}`), + userId: fc.uuid(), + amountRmb: fc.oneof(fc.constant(null), fc.double({ min: 0.01, max: 100000, noNaN: true })), + amountPeso: fc.constant(null), // All null values + amount: fc.constant(null), + serviceContent: fc.string({ minLength: 1, maxLength: 200 }), + paymentTime: fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') }), + status: fc.constantFrom('active', 'cancelled'), + createdAt: fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') }), + }), + { minLength: 1, maxLength: 10 } + ), + async (orders) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + const mockRows = orders.map(createMockOrder); + + // Setup mocks - all null Peso values should sum to 0 + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + PaymentOrder.findOne.mockResolvedValue({ + totalRmb: calculateExpectedRmbSum(orders), + totalPeso: 0, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({}); + + // Assert: totalPeso should be "0.00" when all values are null + expect(result.statistics.totalPeso).toBe('0.00'); + } + ), + { numRuns: 100 } + ); + }); + }); + + + /** + * **Feature: payment-order-amount-statistics, Property 3: Filter Consistency** + * *For any* combination of filters (currency, userId, status, dateRange), the statistics + * should only include amounts from orders that satisfy ALL applied filter conditions. + * **Validates: Requirements 1.2, 3.3** + */ + describe('Property 3: Filter Consistency', () => { + it('should apply userId filter to statistics calculation', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 1, maxLength: 20 }), + fc.uuid(), + async (orders, filterUserId) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + // Filter orders that match the userId + const filteredOrders = orders.filter(o => o.userId === filterUserId); + const mockRows = filteredOrders.map(createMockOrder); + const expectedRmbSum = calculateExpectedRmbSum(filteredOrders); + const expectedPesoSum = calculateExpectedPesoSum(filteredOrders); + + // Setup mocks + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + PaymentOrder.findOne.mockResolvedValue({ + totalRmb: expectedRmbSum, + totalPeso: expectedPesoSum, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({ userId: filterUserId }); + + // Assert: Statistics should reflect only filtered orders + expect(result.statistics).toBeDefined(); + expect(parseFloat(result.statistics.totalRmb)).toBeCloseTo(expectedRmbSum, 2); + expect(parseFloat(result.statistics.totalPeso)).toBeCloseTo(expectedPesoSum, 2); + + // Verify WHERE clause includes userId + expect(PaymentOrder.findAndCountAll).toHaveBeenCalledTimes(1); + const callArgs = PaymentOrder.findAndCountAll.mock.calls[0][0]; + expect(callArgs.where.userId).toBe(filterUserId); + } + ), + { numRuns: 100 } + ); + }); + + it('should apply status filter to statistics calculation', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 1, maxLength: 20 }), + fc.constantFrom('active', 'cancelled'), + async (orders, filterStatus) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + // Filter orders that match the status + const filteredOrders = orders.filter(o => o.status === filterStatus); + const mockRows = filteredOrders.map(createMockOrder); + const expectedRmbSum = calculateExpectedRmbSum(filteredOrders); + const expectedPesoSum = calculateExpectedPesoSum(filteredOrders); + + // Setup mocks + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + PaymentOrder.findOne.mockResolvedValue({ + totalRmb: expectedRmbSum, + totalPeso: expectedPesoSum, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({ status: filterStatus }); + + // Assert: Statistics should reflect only filtered orders + expect(result.statistics).toBeDefined(); + expect(parseFloat(result.statistics.totalRmb)).toBeCloseTo(expectedRmbSum, 2); + expect(parseFloat(result.statistics.totalPeso)).toBeCloseTo(expectedPesoSum, 2); + + // Verify WHERE clause includes status + expect(PaymentOrder.findAndCountAll).toHaveBeenCalledTimes(1); + const callArgs = PaymentOrder.findAndCountAll.mock.calls[0][0]; + expect(callArgs.where.status).toBe(filterStatus); + } + ), + { numRuns: 100 } + ); + }); + + it('should apply currency filter to statistics calculation', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 1, maxLength: 20 }), + fc.constantFrom('rmb', 'peso'), + async (orders, currency) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + // Filter orders that match the currency filter + const filteredOrders = orders.filter(o => { + if (currency === 'rmb') { + return o.amountRmb !== null && o.amountRmb > 0; + } else { + return o.amountPeso !== null && o.amountPeso > 0; + } + }); + const mockRows = filteredOrders.map(createMockOrder); + const expectedRmbSum = calculateExpectedRmbSum(filteredOrders); + const expectedPesoSum = calculateExpectedPesoSum(filteredOrders); + + // Setup mocks + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + PaymentOrder.findOne.mockResolvedValue({ + totalRmb: expectedRmbSum, + totalPeso: expectedPesoSum, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({ currency }); + + // Assert: Statistics should reflect only filtered orders + expect(result.statistics).toBeDefined(); + expect(parseFloat(result.statistics.totalRmb)).toBeCloseTo(expectedRmbSum, 2); + expect(parseFloat(result.statistics.totalPeso)).toBeCloseTo(expectedPesoSum, 2); + + // Verify WHERE clause includes currency filter + expect(PaymentOrder.findAndCountAll).toHaveBeenCalledTimes(1); + const callArgs = PaymentOrder.findAndCountAll.mock.calls[0][0]; + if (currency === 'rmb') { + expect(callArgs.where.amountRmb).toBeDefined(); + } else { + expect(callArgs.where.amountPeso).toBeDefined(); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should apply multiple filters using AND logic to statistics', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 1, maxLength: 20 }), + fc.constantFrom('active', 'cancelled'), + fc.constantFrom('rmb', 'peso'), + async (orders, filterStatus, currency) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + // Filter orders that match ALL conditions + const filteredOrders = orders.filter(o => { + const matchesStatus = o.status === filterStatus; + const matchesCurrency = currency === 'rmb' + ? (o.amountRmb !== null && o.amountRmb > 0) + : (o.amountPeso !== null && o.amountPeso > 0); + return matchesStatus && matchesCurrency; + }); + const mockRows = filteredOrders.map(createMockOrder); + const expectedRmbSum = calculateExpectedRmbSum(filteredOrders); + const expectedPesoSum = calculateExpectedPesoSum(filteredOrders); + + // Setup mocks + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + PaymentOrder.findOne.mockResolvedValue({ + totalRmb: expectedRmbSum, + totalPeso: expectedPesoSum, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({ + status: filterStatus, + currency + }); + + // Assert: Statistics should reflect only orders matching ALL filters + expect(result.statistics).toBeDefined(); + expect(parseFloat(result.statistics.totalRmb)).toBeCloseTo(expectedRmbSum, 2); + expect(parseFloat(result.statistics.totalPeso)).toBeCloseTo(expectedPesoSum, 2); + } + ), + { numRuns: 100 } + ); + }); + }); + + + /** + * **Feature: payment-order-amount-statistics, Property 4: Statistics Structure Consistency** + * *For any* API response, the statistics object should always contain both totalRmb and + * totalPeso fields formatted as strings with two decimal places. + * **Validates: Requirements 1.3, 2.3** + */ + describe('Property 4: Statistics Structure Consistency', () => { + it('should always return statistics with totalRmb and totalPeso as formatted strings', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 0, maxLength: 20 }), + async (orders) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + const mockRows = orders.map(createMockOrder); + const expectedRmbSum = calculateExpectedRmbSum(orders); + const expectedPesoSum = calculateExpectedPesoSum(orders); + + // Setup mocks + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + PaymentOrder.findOne.mockResolvedValue({ + totalRmb: expectedRmbSum, + totalPeso: expectedPesoSum, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({}); + + // Assert: Statistics object structure + expect(result.statistics).toBeDefined(); + expect(typeof result.statistics.totalRmb).toBe('string'); + expect(typeof result.statistics.totalPeso).toBe('string'); + + // Assert: Two decimal places format + expect(result.statistics.totalRmb).toMatch(/^\d+\.\d{2}$/); + expect(result.statistics.totalPeso).toMatch(/^\d+\.\d{2}$/); + } + ), + { numRuns: 100 } + ); + }); + + it('should return "0.00" for empty result sets', async () => { + await fc.assert( + fc.asyncProperty( + fc.constant([]), // Empty array + async (orders) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + // Setup mocks for empty result + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: 0, + rows: [], + }); + PaymentOrder.findOne.mockResolvedValue({ + totalRmb: 0, + totalPeso: 0, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({}); + + // Assert: Should return "0.00" for both currencies + expect(result.statistics.totalRmb).toBe('0.00'); + expect(result.statistics.totalPeso).toBe('0.00'); + } + ), + { numRuns: 100 } + ); + }); + + it('should handle null statistics result gracefully', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 0, maxLength: 5 }), + async (orders) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + const mockRows = orders.map(createMockOrder); + + // Setup mocks - simulate null statistics result + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + PaymentOrder.findOne.mockResolvedValue(null); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({}); + + // Assert: Should still return valid statistics structure with "0.00" + expect(result.statistics).toBeDefined(); + expect(result.statistics.totalRmb).toBe('0.00'); + expect(result.statistics.totalPeso).toBe('0.00'); + } + ), + { numRuns: 100 } + ); + }); + }); +}); diff --git a/backend/src/tests/paymentOrderCurrencyFilter.property.test.js b/backend/src/tests/paymentOrderCurrencyFilter.property.test.js new file mode 100644 index 0000000..b205583 --- /dev/null +++ b/backend/src/tests/paymentOrderCurrencyFilter.property.test.js @@ -0,0 +1,365 @@ +/** + * Payment Order Currency Filter Property Tests + * **Feature: payment-order-currency-filter** + */ + +const fc = require('fast-check'); + +// Mock database first +jest.mock('../config/database', () => ({ + sequelize: { + define: jest.fn(() => ({})), + transaction: jest.fn(() => Promise.resolve({ + commit: jest.fn(), + rollback: jest.fn(), + })), + }, +})); + +// Mock models +jest.mock('../models/User', () => ({ + findByPk: jest.fn(), + findOne: jest.fn(), +})); + +jest.mock('../models/Appointment', () => ({ + findByPk: jest.fn(), + findOne: jest.fn(), +})); + +jest.mock('../models/PaymentOrder', () => ({ + findOne: jest.fn(), + create: jest.fn(), + findByPk: jest.fn(), + findAndCountAll: jest.fn(), +})); + +jest.mock('../models/Admin', () => ({ + findByPk: jest.fn(), +})); + +jest.mock('../models/Commission', () => ({ + findAll: jest.fn(), + findOne: jest.fn(), +})); + +jest.mock('../services/commissionService', () => ({ + calculateCommission: jest.fn(), +})); + +const PaymentOrder = require('../models/PaymentOrder'); +const Commission = require('../models/Commission'); +const paymentOrderService = require('../services/paymentOrderService'); + + +// Generator for payment order with various currency amounts +const paymentOrderArbitrary = () => fc.record({ + id: fc.uuid(), + orderNo: fc.string({ minLength: 10, maxLength: 20 }).map(s => `PO${s}`), + userId: fc.uuid(), + amountRmb: fc.oneof( + fc.constant(null), + fc.constant(0), + fc.double({ min: 0.01, max: 100000, noNaN: true }) + ), + amountPeso: fc.oneof( + fc.constant(null), + fc.constant(0), + fc.double({ min: 0.01, max: 100000, noNaN: true }) + ), + amount: fc.option(fc.double({ min: 0.01, max: 100000, noNaN: true }), { nil: null }), + serviceContent: fc.string({ minLength: 1, maxLength: 200 }), + paymentTime: fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') }), + status: fc.constantFrom('active', 'cancelled'), + createdAt: fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') }), +}); + +// Helper to create mock order with associations +const createMockOrder = (order) => ({ + ...order, + user: { id: order.userId, uid: '123456', nickname: 'Test User', phone: '1234567890' }, + appointment: null, + creator: { id: 'admin-uuid', username: 'admin', email: 'admin@test.com' }, +}); + +describe('Payment Order Currency Filter - Property Tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + /** + * **Feature: payment-order-currency-filter, Property 1: RMB Currency Filter Returns Only RMB Orders** + * *For any* set of payment orders in the database, when the currency filter is set to "rmb", + * all returned orders should have amountRmb greater than zero. + * **Validates: Requirements 1.1, 3.1** + */ + describe('Property 1: RMB Currency Filter Returns Only RMB Orders', () => { + it('should return only orders with amountRmb > 0 when currency filter is "rmb"', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 1, maxLength: 20 }), + async (orders) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + // Filter orders that should be returned (amountRmb > 0) + const expectedOrders = orders.filter(o => o.amountRmb !== null && o.amountRmb > 0); + const mockRows = expectedOrders.map(createMockOrder); + + // Setup mock + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({ currency: 'rmb' }); + + // Assert: All returned orders should have amountRmb > 0 + for (const record of result.records) { + const amountRmb = parseFloat(record.amountRmb); + expect(amountRmb).toBeGreaterThan(0); + } + + // Verify the WHERE clause was called with correct filter + expect(PaymentOrder.findAndCountAll).toHaveBeenCalledTimes(1); + const callArgs = PaymentOrder.findAndCountAll.mock.calls[0][0]; + expect(callArgs.where.amountRmb).toBeDefined(); + } + ), + { numRuns: 100 } + ); + }); + }); + + + /** + * **Feature: payment-order-currency-filter, Property 2: Peso Currency Filter Returns Only Peso Orders** + * *For any* set of payment orders in the database, when the currency filter is set to "peso", + * all returned orders should have amountPeso greater than zero. + * **Validates: Requirements 1.2, 3.2** + */ + describe('Property 2: Peso Currency Filter Returns Only Peso Orders', () => { + it('should return only orders with amountPeso > 0 when currency filter is "peso"', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 1, maxLength: 20 }), + async (orders) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + // Filter orders that should be returned (amountPeso > 0) + const expectedOrders = orders.filter(o => o.amountPeso !== null && o.amountPeso > 0); + const mockRows = expectedOrders.map(createMockOrder); + + // Setup mock + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({ currency: 'peso' }); + + // Assert: All returned orders should have amountPeso > 0 + for (const record of result.records) { + const amountPeso = parseFloat(record.amountPeso); + expect(amountPeso).toBeGreaterThan(0); + } + + // Verify the WHERE clause was called with correct filter + expect(PaymentOrder.findAndCountAll).toHaveBeenCalledTimes(1); + const callArgs = PaymentOrder.findAndCountAll.mock.calls[0][0]; + expect(callArgs.where.amountPeso).toBeDefined(); + } + ), + { numRuns: 100 } + ); + }); + }); + + /** + * **Feature: payment-order-currency-filter, Property 3: No Currency Filter Returns All Orders** + * *For any* set of payment orders in the database, when no currency filter is applied (empty or undefined), + * the returned orders should include all orders regardless of their currency amounts. + * **Validates: Requirements 1.3, 3.3** + */ + describe('Property 3: No Currency Filter Returns All Orders', () => { + it('should return all orders when no currency filter is applied', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 1, maxLength: 20 }), + fc.constantFrom(undefined, '', null), + async (orders, currencyValue) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + const mockRows = orders.map(createMockOrder); + + // Setup mock + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({ currency: currencyValue }); + + // Assert: Should return all orders + expect(result.records.length).toBe(orders.length); + + // Verify the WHERE clause does NOT have currency filter + expect(PaymentOrder.findAndCountAll).toHaveBeenCalledTimes(1); + const callArgs = PaymentOrder.findAndCountAll.mock.calls[0][0]; + expect(callArgs.where.amountRmb).toBeUndefined(); + expect(callArgs.where.amountPeso).toBeUndefined(); + } + ), + { numRuns: 100 } + ); + }); + + it('should ignore invalid currency values and return all orders', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 1, maxLength: 20 }), + fc.string({ minLength: 1, maxLength: 20 }).filter(s => s !== 'rmb' && s !== 'peso'), + async (orders, invalidCurrency) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + const mockRows = orders.map(createMockOrder); + + // Setup mock + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({ currency: invalidCurrency }); + + // Assert: Should return all orders (invalid currency is ignored) + expect(result.records.length).toBe(orders.length); + + // Verify the WHERE clause does NOT have currency filter + expect(PaymentOrder.findAndCountAll).toHaveBeenCalledTimes(1); + const callArgs = PaymentOrder.findAndCountAll.mock.calls[0][0]; + expect(callArgs.where.amountRmb).toBeUndefined(); + expect(callArgs.where.amountPeso).toBeUndefined(); + } + ), + { numRuns: 100 } + ); + }); + }); + + + /** + * **Feature: payment-order-currency-filter, Property 4: Currency Filter Combines with Other Filters Using AND Logic** + * *For any* combination of filters (currency, userId, status, dateRange), + * all returned orders should satisfy ALL applied filter conditions simultaneously. + * **Validates: Requirements 1.4** + */ + describe('Property 4: Currency Filter Combines with Other Filters Using AND Logic', () => { + it('should combine currency filter with userId filter using AND logic', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 1, maxLength: 20 }), + fc.uuid(), + fc.constantFrom('rmb', 'peso'), + async (orders, filterUserId, currency) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + // Filter orders that match both conditions + const expectedOrders = orders.filter(o => { + const matchesUser = o.userId === filterUserId; + const matchesCurrency = currency === 'rmb' + ? (o.amountRmb !== null && o.amountRmb > 0) + : (o.amountPeso !== null && o.amountPeso > 0); + return matchesUser && matchesCurrency; + }); + const mockRows = expectedOrders.map(createMockOrder); + + // Setup mock + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({ + currency, + userId: filterUserId + }); + + // Assert: Verify WHERE clause has both filters + expect(PaymentOrder.findAndCountAll).toHaveBeenCalledTimes(1); + const callArgs = PaymentOrder.findAndCountAll.mock.calls[0][0]; + expect(callArgs.where.userId).toBe(filterUserId); + if (currency === 'rmb') { + expect(callArgs.where.amountRmb).toBeDefined(); + } else { + expect(callArgs.where.amountPeso).toBeDefined(); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should combine currency filter with status filter using AND logic', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(paymentOrderArbitrary(), { minLength: 1, maxLength: 20 }), + fc.constantFrom('active', 'cancelled'), + fc.constantFrom('rmb', 'peso'), + async (orders, filterStatus, currency) => { + // Clear mocks before each property test iteration + jest.clearAllMocks(); + + // Filter orders that match both conditions + const expectedOrders = orders.filter(o => { + const matchesStatus = o.status === filterStatus; + const matchesCurrency = currency === 'rmb' + ? (o.amountRmb !== null && o.amountRmb > 0) + : (o.amountPeso !== null && o.amountPeso > 0); + return matchesStatus && matchesCurrency; + }); + const mockRows = expectedOrders.map(createMockOrder); + + // Setup mock + PaymentOrder.findAndCountAll.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await paymentOrderService.getPaymentOrders({ + currency, + status: filterStatus + }); + + // Assert: Verify WHERE clause has both filters + expect(PaymentOrder.findAndCountAll).toHaveBeenCalledTimes(1); + const callArgs = PaymentOrder.findAndCountAll.mock.calls[0][0]; + expect(callArgs.where.status).toBe(filterStatus); + if (currency === 'rmb') { + expect(callArgs.where.amountRmb).toBeDefined(); + } else { + expect(callArgs.where.amountPeso).toBeDefined(); + } + } + ), + { numRuns: 100 } + ); + }); + }); +}); diff --git a/miniprogram/src/locale/en.js b/miniprogram/src/locale/en.js index 8d681eb..d1b8c13 100644 --- a/miniprogram/src/locale/en.js +++ b/miniprogram/src/locale/en.js @@ -90,6 +90,8 @@ export default { notification: 'Notification', customerService: 'Customer Service', contactUs: 'Contact Us', + contactPhone: 'Phone', + contactEmail: 'Email', inviteReward: 'Invite Friends for Rewards', userAgreement: 'User Agreement', privacyPolicy: 'Privacy Policy', diff --git a/miniprogram/src/locale/es.js b/miniprogram/src/locale/es.js index cf63f35..c92b209 100644 --- a/miniprogram/src/locale/es.js +++ b/miniprogram/src/locale/es.js @@ -90,6 +90,8 @@ export default { notification: 'Notificación', customerService: 'Atención al Cliente', contactUs: 'Contáctenos', + contactPhone: 'Teléfono', + contactEmail: 'Correo Electrónico', inviteReward: 'Invita Amigos y Gana Recompensas', userAgreement: 'Acuerdo de Usuario', privacyPolicy: 'Política de Privacidad', diff --git a/miniprogram/src/locale/zh.js b/miniprogram/src/locale/zh.js index ef9d673..cf826ec 100644 --- a/miniprogram/src/locale/zh.js +++ b/miniprogram/src/locale/zh.js @@ -90,6 +90,8 @@ export default { notification: '通知', customerService: '客服', contactUs: '联系我们', + contactPhone: '联系电话', + contactEmail: '联系邮箱', inviteReward: '邀请新人得奖励', userAgreement: '用户协议', privacyPolicy: '隐私协议', diff --git a/miniprogram/src/pages/me/contact-us-page.vue b/miniprogram/src/pages/me/contact-us-page.vue index f0cdc56..c5a48c0 100644 --- a/miniprogram/src/pages/me/contact-us-page.vue +++ b/miniprogram/src/pages/me/contact-us-page.vue @@ -22,6 +22,18 @@ + + + + + {{ $t('me.contactPhone') || '联系电话' }} + {{ contactPhone }} + + + {{ $t('me.contactEmail') || '联系邮箱' }} + {{ contactEmail }} + + @@ -32,28 +44,42 @@ export default { data() { return { qrImageUrl: '', + contactPhone: '', + contactEmail: '', loading: true } }, onLoad() { - this.loadQrImage() + this.loadConfig() }, methods: { goBack() { uni.navigateBack() }, - async loadQrImage() { + async loadConfig() { try { this.loading = true const config = await Config.getPublicConfig() if (config.contact_qr_image) { this.qrImageUrl = Config.getImageUrl(config.contact_qr_image) } + this.contactPhone = config.contact_phone || '' + this.contactEmail = config.contact_email || '' } catch (error) { - console.error('加载二维码失败:', error) + console.error('加载配置失败:', error) } finally { this.loading = false } + }, + callPhone() { + if (this.contactPhone) { + uni.makePhoneCall({ + phoneNumber: this.contactPhone.replace(/[\s\-\(\)]/g, ''), + fail: () => { + uni.showToast({ title: '拨打电话失败', icon: 'none' }) + } + }) + } } } } @@ -100,7 +126,7 @@ export default { .qr-container { display: flex; justify-content: center; - padding: 60rpx 30rpx; + padding: 60rpx 30rpx 40rpx; .qr-box { width: 600rpx; @@ -132,4 +158,35 @@ export default { } } } + +.contact-info { + margin: 0 30rpx; + background-color: #fff; + border-radius: 20rpx; + padding: 30rpx 40rpx; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08); + + .contact-item { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 20rpx 0; + + &:not(:last-child) { + border-bottom: 1rpx solid #f0f0f0; + } + + .contact-label { + font-size: 28rpx; + color: #666; + } + + .contact-value { + font-size: 28rpx; + color: #333; + font-weight: 500; + } + } +} diff --git a/miniprogram/src/pages/me/notification-page.vue b/miniprogram/src/pages/me/notification-page.vue index 0b6bfeb..2c6de97 100644 --- a/miniprogram/src/pages/me/notification-page.vue +++ b/miniprogram/src/pages/me/notification-page.vue @@ -7,7 +7,7 @@ {{ $t('me.notification') }} - {{ $t('notification.markAllRead') }} + @@ -270,11 +270,11 @@ .badge { position: absolute; - top: 8rpx; - right: 4rpx; + top: -5rpx; + right: 2rpx; min-width: 32rpx; height: 32rpx; - padding: 0 8rpx; + padding: 0 2rpx; background-color: #FF3B30; border-radius: 16rpx; font-size: 20rpx;