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 @@
-
-
-
-
-
-
{{ stats.total }}
-
总通知数
-
-
-
-
-
-
-
-
{{ stats.unread }}
-
未读
-
-
-
-
-
-
-
-
{{ stats.read }}
-
已读
-
-
-
-
-
-
-
-
-
-
-
@@ -61,7 +22,7 @@
搜索
重置
- 发送通知
+ 发送通知
@@ -102,12 +63,16 @@
-
-
-
-
-
-
+
+
+
+
+ 单发(指定用户)
+ 群发(所有用户)
+
+
+
+
@@ -140,35 +105,41 @@
取消
- {{ sendDialog.isBroadcast ? '群发' : '发送' }}
+ {{ sendForm.sendMode === 'broadcast' ? '群发' : '发送' }}
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;