修改问题.
This commit is contained in:
parent
4b27db42d3
commit
9d7daa281f
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,3 +10,4 @@ server/admin-dist/*
|
||||||
server/data/*
|
server/data/*
|
||||||
server/data/logs/nginx/error.log
|
server/data/logs/nginx/error.log
|
||||||
server/data/logs/nginx/access.log
|
server/data/logs/nginx/access.log
|
||||||
|
/logs/
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/appointments">
|
<el-menu-item index="/appointments">
|
||||||
<el-icon><Document /></el-icon>
|
<el-icon><Document /></el-icon>
|
||||||
<template #title>订单管理</template>
|
<template #title>预约管理</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/withdrawals">
|
<el-menu-item index="/withdrawals">
|
||||||
<el-icon><Wallet /></el-icon>
|
<el-icon><Wallet /></el-icon>
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/payment-orders">
|
<el-menu-item index="/payment-orders">
|
||||||
<el-icon><CreditCard /></el-icon>
|
<el-icon><CreditCard /></el-icon>
|
||||||
<template #title>支付订单</template>
|
<template #title>订单管理</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/commissions">
|
<el-menu-item index="/commissions">
|
||||||
<el-icon><Money /></el-icon>
|
<el-icon><Money /></el-icon>
|
||||||
|
|
|
||||||
|
|
@ -597,14 +597,30 @@
|
||||||
<el-form-item label="服务内容" prop="serviceContent">
|
<el-form-item label="服务内容" prop="serviceContent">
|
||||||
<el-input v-model="paymentForm.serviceContent" placeholder="服务内容描述" />
|
<el-input v-model="paymentForm.serviceContent" placeholder="服务内容描述" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="金额" prop="amount">
|
<el-form-item label="比索金额">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="paymentForm.amount"
|
v-model="paymentForm.amountPeso"
|
||||||
:min="0.01"
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="100"
|
||||||
|
style="width: 180px"
|
||||||
|
/>
|
||||||
|
<span style="margin-left: 10px; color: #909399;">₱ 比索</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="人民币金额">
|
||||||
|
<el-input-number
|
||||||
|
v-model="paymentForm.amountRmb"
|
||||||
|
:min="0"
|
||||||
:precision="2"
|
:precision="2"
|
||||||
:step="10"
|
:step="10"
|
||||||
style="width: 100%"
|
style="width: 180px"
|
||||||
/>
|
/>
|
||||||
|
<span style="margin-left: 10px; color: #909399;">¥ 人民币</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-alert type="info" :closable="false" show-icon>
|
||||||
|
至少填写一种货币金额,两个都填表示同时支付了两种货币
|
||||||
|
</el-alert>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="支付时间" prop="paymentTime">
|
<el-form-item label="支付时间" prop="paymentTime">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
|
|
@ -696,7 +712,8 @@ const paymentForm = reactive({
|
||||||
userId: '',
|
userId: '',
|
||||||
userNickname: '',
|
userNickname: '',
|
||||||
serviceContent: '',
|
serviceContent: '',
|
||||||
amount: 0,
|
amountPeso: null,
|
||||||
|
amountRmb: null,
|
||||||
paymentTime: '',
|
paymentTime: '',
|
||||||
notes: ''
|
notes: ''
|
||||||
})
|
})
|
||||||
|
|
@ -704,10 +721,6 @@ const paymentRules = {
|
||||||
serviceContent: [
|
serviceContent: [
|
||||||
{ required: true, message: '请输入服务内容', trigger: 'blur' }
|
{ required: true, message: '请输入服务内容', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
amount: [
|
|
||||||
{ required: true, message: '请输入金额', trigger: 'blur' },
|
|
||||||
{ type: 'number', min: 0.01, message: '金额必须大于0', trigger: 'blur' }
|
|
||||||
],
|
|
||||||
paymentTime: [
|
paymentTime: [
|
||||||
{ required: true, message: '请选择支付时间', trigger: 'change' }
|
{ required: true, message: '请选择支付时间', trigger: 'change' }
|
||||||
]
|
]
|
||||||
|
|
@ -895,7 +908,8 @@ function handleCreatePaymentOrder(row) {
|
||||||
paymentForm.userId = row.user?.id || row.userId
|
paymentForm.userId = row.user?.id || row.userId
|
||||||
paymentForm.userNickname = `${row.user?.nickname || '-'} (UID: ${row.user?.uid || '-'})`
|
paymentForm.userNickname = `${row.user?.nickname || '-'} (UID: ${row.user?.uid || '-'})`
|
||||||
paymentForm.serviceContent = row.service?.titleZh || row.hotService?.name_zh || row.serviceType || ''
|
paymentForm.serviceContent = row.service?.titleZh || row.hotService?.name_zh || row.serviceType || ''
|
||||||
paymentForm.amount = parseFloat(row.amount) || 0
|
paymentForm.amountPeso = null
|
||||||
|
paymentForm.amountRmb = null
|
||||||
// 默认设置为当前时间
|
// 默认设置为当前时间
|
||||||
paymentForm.paymentTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
paymentForm.paymentTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||||
paymentForm.notes = ''
|
paymentForm.notes = ''
|
||||||
|
|
@ -906,6 +920,15 @@ function handleCreatePaymentOrder(row) {
|
||||||
async function confirmCreatePaymentOrder() {
|
async function confirmCreatePaymentOrder() {
|
||||||
if (!paymentFormRef.value) return
|
if (!paymentFormRef.value) return
|
||||||
|
|
||||||
|
// Custom validation for amounts
|
||||||
|
const hasPeso = paymentForm.amountPeso !== null && paymentForm.amountPeso !== undefined && paymentForm.amountPeso > 0
|
||||||
|
const hasRmb = paymentForm.amountRmb !== null && paymentForm.amountRmb !== undefined && paymentForm.amountRmb > 0
|
||||||
|
|
||||||
|
if (!hasPeso && !hasRmb) {
|
||||||
|
ElMessage.warning('请至少填写一种货币金额(比索或人民币)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await paymentFormRef.value.validate(async (valid) => {
|
await paymentFormRef.value.validate(async (valid) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
|
|
||||||
|
|
@ -914,7 +937,8 @@ async function confirmCreatePaymentOrder() {
|
||||||
const response = await api.post('/api/v1/admin/payment-orders', {
|
const response = await api.post('/api/v1/admin/payment-orders', {
|
||||||
userId: paymentForm.userId,
|
userId: paymentForm.userId,
|
||||||
appointmentId: paymentForm.appointmentNo,
|
appointmentId: paymentForm.appointmentNo,
|
||||||
amount: paymentForm.amount,
|
amountPeso: hasPeso ? paymentForm.amountPeso : undefined,
|
||||||
|
amountRmb: hasRmb ? paymentForm.amountRmb : undefined,
|
||||||
serviceContent: paymentForm.serviceContent,
|
serviceContent: paymentForm.serviceContent,
|
||||||
paymentTime: paymentForm.paymentTime,
|
paymentTime: paymentForm.paymentTime,
|
||||||
notes: paymentForm.notes
|
notes: paymentForm.notes
|
||||||
|
|
@ -926,7 +950,7 @@ async function confirmCreatePaymentOrder() {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create payment order:', error)
|
console.error('Failed to create payment order:', error)
|
||||||
ElMessage.error(error.response?.data?.message || '创建支付订单失败')
|
ElMessage.error(error.response?.data?.error?.message || '创建支付订单失败')
|
||||||
} finally {
|
} finally {
|
||||||
paymentCreating.value = false
|
paymentCreating.value = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="notifications-container">
|
<div class="notifications-container">
|
||||||
<!-- 统计卡片 -->
|
|
||||||
<el-row :gutter="20" class="stats-row">
|
|
||||||
<el-col :span="6">
|
|
||||||
<el-card shadow="hover" class="stat-card">
|
|
||||||
<div class="stat-content">
|
|
||||||
<div class="stat-value">{{ stats.total }}</div>
|
|
||||||
<div class="stat-label">总通知数</div>
|
|
||||||
</div>
|
|
||||||
<el-icon class="stat-icon" color="#409eff"><Bell /></el-icon>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="6">
|
|
||||||
<el-card shadow="hover" class="stat-card">
|
|
||||||
<div class="stat-content">
|
|
||||||
<div class="stat-value">{{ stats.unread }}</div>
|
|
||||||
<div class="stat-label">未读</div>
|
|
||||||
</div>
|
|
||||||
<el-icon class="stat-icon" color="#e6a23c"><Message /></el-icon>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="6">
|
|
||||||
<el-card shadow="hover" class="stat-card">
|
|
||||||
<div class="stat-content">
|
|
||||||
<div class="stat-value">{{ stats.read }}</div>
|
|
||||||
<div class="stat-label">已读</div>
|
|
||||||
</div>
|
|
||||||
<el-icon class="stat-icon" color="#67c23a"><CircleCheck /></el-icon>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="6">
|
|
||||||
<el-card shadow="hover" class="stat-card action-card" @click="showSendDialog('broadcast')">
|
|
||||||
<div class="stat-content">
|
|
||||||
<div class="stat-value"><el-icon><Promotion /></el-icon></div>
|
|
||||||
<div class="stat-label">群发通知</div>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<!-- 筛选和操作栏 -->
|
<!-- 筛选和操作栏 -->
|
||||||
<el-card class="filter-card">
|
<el-card class="filter-card">
|
||||||
<el-form :inline="true" :model="filters">
|
<el-form :inline="true" :model="filters">
|
||||||
|
|
@ -61,7 +22,7 @@
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon>搜索</el-button>
|
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon>搜索</el-button>
|
||||||
<el-button @click="handleReset"><el-icon><Refresh /></el-icon>重置</el-button>
|
<el-button @click="handleReset"><el-icon><Refresh /></el-icon>重置</el-button>
|
||||||
<el-button type="success" @click="showSendDialog('single')"><el-icon><Plus /></el-icon>发送通知</el-button>
|
<el-button type="success" @click="showSendDialog()"><el-icon><Plus /></el-icon>发送通知</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
@ -102,12 +63,16 @@
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 发送通知弹窗 -->
|
<!-- 发送通知弹窗 -->
|
||||||
<el-dialog v-model="sendDialog.visible" :title="sendDialog.isBroadcast ? '群发通知' : '发送通知'" width="600px" destroy-on-close>
|
<el-dialog v-model="sendDialog.visible" title="发送通知" width="600px" destroy-on-close>
|
||||||
<el-form ref="sendFormRef" :model="sendForm" :rules="sendRules" label-width="100px">
|
<el-form ref="sendFormRef" :model="sendForm" :rules="currentRules" label-width="100px">
|
||||||
<el-form-item v-if="!sendDialog.isBroadcast" label="接收用户" prop="userId">
|
<el-form-item label="发送模式" prop="sendMode">
|
||||||
<el-select v-model="sendForm.userId" placeholder="请选择用户" filterable remote :remote-method="searchUsers" :loading="usersLoading" style="width: 100%">
|
<el-radio-group v-model="sendForm.sendMode">
|
||||||
<el-option v-for="user in userOptions" :key="user.id" :label="`${user.nickname || '未设置昵称'} (${user.phone || user.uid})`" :value="user.id" />
|
<el-radio value="single">单发(指定用户)</el-radio>
|
||||||
</el-select>
|
<el-radio value="broadcast">群发(所有用户)</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="sendForm.sendMode === 'single'" label="用户UID" prop="uid">
|
||||||
|
<el-input v-model="sendForm.uid" placeholder="请输入用户UID(6位数字)" maxlength="6" style="width: 100%" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="通知类型" prop="type">
|
<el-form-item label="通知类型" prop="type">
|
||||||
<el-select v-model="sendForm.type" style="width: 100%">
|
<el-select v-model="sendForm.type" style="width: 100%">
|
||||||
|
|
@ -140,35 +105,41 @@
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="sendDialog.visible = false">取消</el-button>
|
<el-button @click="sendDialog.visible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="sending" @click="handleSend">{{ sendDialog.isBroadcast ? '群发' : '发送' }}</el-button>
|
<el-button type="primary" :loading="sending" @click="handleSend">{{ sendForm.sendMode === 'broadcast' ? '群发' : '发送' }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import api from '@/utils/api'
|
import api from '@/utils/api'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
const usersLoading = ref(false)
|
|
||||||
const notifications = ref([])
|
const notifications = ref([])
|
||||||
const userOptions = ref([])
|
|
||||||
const sendFormRef = ref(null)
|
const sendFormRef = ref(null)
|
||||||
|
|
||||||
const stats = reactive({ total: 0, unread: 0, read: 0 })
|
|
||||||
const pagination = reactive({ page: 1, limit: 20, total: 0 })
|
const pagination = reactive({ page: 1, limit: 20, total: 0 })
|
||||||
const filters = reactive({ search: '', type: '', isRead: '' })
|
const filters = reactive({ search: '', type: '', isRead: '' })
|
||||||
const sendDialog = reactive({ visible: false, isBroadcast: false })
|
const sendDialog = reactive({ visible: false })
|
||||||
const sendForm = reactive({ userId: '', type: 'system', titleZh: '', titleEn: '', titlePt: '', contentZh: '', contentEn: '', contentPt: '' })
|
const sendForm = reactive({ sendMode: 'single', uid: '', type: 'system', titleZh: '', titleEn: '', titlePt: '', contentZh: '', contentEn: '', contentPt: '' })
|
||||||
const sendRules = {
|
const sendRules = {
|
||||||
userId: [{ required: true, message: '请选择用户', trigger: 'change' }],
|
uid: [
|
||||||
|
{ required: true, message: '请输入用户UID', trigger: 'blur' },
|
||||||
|
{ pattern: /^\d{6}$/, message: 'UID必须是6位数字', trigger: 'blur' }
|
||||||
|
],
|
||||||
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
|
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
|
||||||
titleZh: [{ required: true, message: '请输入中文标题', trigger: 'blur' }],
|
titleZh: [{ required: true, message: '请输入中文标题', trigger: 'blur' }],
|
||||||
contentZh: [{ required: true, message: '请输入中文内容', trigger: 'blur' }],
|
contentZh: [{ required: true, message: '请输入中文内容', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
const broadcastRules = {
|
||||||
|
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
|
||||||
|
titleZh: [{ required: true, message: '请输入中文标题', trigger: 'blur' }],
|
||||||
|
contentZh: [{ required: true, message: '请输入中文内容', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
const currentRules = computed(() => sendForm.sendMode === 'broadcast' ? broadcastRules : sendRules)
|
||||||
|
|
||||||
const getTypeTag = (type) => ({ system: 'primary', activity: 'success', service: 'warning' }[type] || 'info')
|
const getTypeTag = (type) => ({ system: 'primary', activity: 'success', service: 'warning' }[type] || 'info')
|
||||||
const getTypeLabel = (type) => ({ system: '系统', activity: '活动', service: '服务' }[type] || type)
|
const getTypeLabel = (type) => ({ system: '系统', activity: '活动', service: '服务' }[type] || type)
|
||||||
|
|
@ -188,56 +159,42 @@ async function fetchNotifications() {
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStats() {
|
function showSendDialog() {
|
||||||
try {
|
Object.assign(sendForm, { sendMode: 'single', uid: '', type: 'system', titleZh: '', titleEn: '', titlePt: '', contentZh: '', contentEn: '', contentPt: '' })
|
||||||
const res = await api.get('/api/v1/admin/notifications/statistics')
|
|
||||||
if (res.data.code === 0) Object.assign(stats, res.data.data)
|
|
||||||
} catch (e) { console.error(e) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchUsers(query) {
|
|
||||||
if (!query) return
|
|
||||||
usersLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.get('/api/v1/admin/users', { params: { search: query, limit: 20 } })
|
|
||||||
if (res.data.code === 0) userOptions.value = res.data.data || []
|
|
||||||
} catch (e) { console.error(e) }
|
|
||||||
finally { usersLoading.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSendDialog(mode) {
|
|
||||||
sendDialog.isBroadcast = mode === 'broadcast'
|
|
||||||
Object.assign(sendForm, { userId: '', type: 'system', titleZh: '', titleEn: '', titlePt: '', contentZh: '', contentEn: '', contentPt: '' })
|
|
||||||
sendDialog.visible = true
|
sendDialog.visible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
if (!sendFormRef.value) return
|
if (!sendFormRef.value) return
|
||||||
try {
|
try {
|
||||||
// 群发时不需要验证userId
|
await sendFormRef.value.validate()
|
||||||
if (sendDialog.isBroadcast) {
|
|
||||||
if (!sendForm.titleZh || !sendForm.contentZh) {
|
|
||||||
ElMessage.error('请填写中文标题和内容')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await sendFormRef.value.validate()
|
|
||||||
}
|
|
||||||
} catch { return }
|
} catch { return }
|
||||||
|
|
||||||
if (sendDialog.isBroadcast) {
|
if (sendForm.sendMode === 'broadcast') {
|
||||||
await ElMessageBox.confirm('确定要向所有用户发送此通知吗?', '确认群发', { type: 'warning' })
|
await ElMessageBox.confirm('确定要向所有用户发送此通知吗?', '确认群发', { type: 'warning' })
|
||||||
}
|
}
|
||||||
|
|
||||||
sending.value = true
|
sending.value = true
|
||||||
try {
|
try {
|
||||||
const url = sendDialog.isBroadcast ? '/api/v1/admin/notifications/broadcast' : '/api/v1/admin/notifications/send'
|
const url = sendForm.sendMode === 'broadcast' ? '/api/v1/admin/notifications/broadcast' : '/api/v1/admin/notifications/send'
|
||||||
const res = await api.post(url, sendForm)
|
// 构建请求数据,单发时将uid作为userId发送
|
||||||
|
const data = {
|
||||||
|
type: sendForm.type,
|
||||||
|
titleZh: sendForm.titleZh,
|
||||||
|
titleEn: sendForm.titleEn,
|
||||||
|
titlePt: sendForm.titlePt,
|
||||||
|
contentZh: sendForm.contentZh,
|
||||||
|
contentEn: sendForm.contentEn,
|
||||||
|
contentPt: sendForm.contentPt,
|
||||||
|
}
|
||||||
|
if (sendForm.sendMode === 'single') {
|
||||||
|
data.userId = sendForm.uid
|
||||||
|
}
|
||||||
|
const res = await api.post(url, data)
|
||||||
if (res.data.code === 0) {
|
if (res.data.code === 0) {
|
||||||
ElMessage.success(sendDialog.isBroadcast ? '群发成功' : '发送成功')
|
ElMessage.success(sendForm.sendMode === 'broadcast' ? '群发成功' : '发送成功')
|
||||||
sendDialog.visible = false
|
sendDialog.visible = false
|
||||||
fetchNotifications()
|
fetchNotifications()
|
||||||
fetchStats()
|
|
||||||
}
|
}
|
||||||
} catch (e) { ElMessage.error('发送失败') }
|
} catch (e) { ElMessage.error('发送失败') }
|
||||||
finally { sending.value = false }
|
finally { sending.value = false }
|
||||||
|
|
@ -250,7 +207,6 @@ async function handleDelete(row) {
|
||||||
if (res.data.code === 0) {
|
if (res.data.code === 0) {
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
fetchNotifications()
|
fetchNotifications()
|
||||||
fetchStats()
|
|
||||||
}
|
}
|
||||||
} catch (e) { ElMessage.error('删除失败') }
|
} catch (e) { ElMessage.error('删除失败') }
|
||||||
}
|
}
|
||||||
|
|
@ -260,18 +216,11 @@ function handleReset() { Object.assign(filters, { search: '', type: '', isRead:
|
||||||
function handlePageChange(page) { pagination.page = page; fetchNotifications() }
|
function handlePageChange(page) { pagination.page = page; fetchNotifications() }
|
||||||
function handleSizeChange(size) { pagination.limit = size; pagination.page = 1; fetchNotifications() }
|
function handleSizeChange(size) { pagination.limit = size; pagination.page = 1; fetchNotifications() }
|
||||||
|
|
||||||
onMounted(() => { fetchNotifications(); fetchStats() })
|
onMounted(() => { fetchNotifications() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.notifications-container {
|
.notifications-container {
|
||||||
.stats-row { margin-bottom: 20px; }
|
|
||||||
.stat-card {
|
|
||||||
position: relative;
|
|
||||||
.stat-content { .stat-value { font-size: 28px; font-weight: bold; color: #303133; } .stat-label { font-size: 14px; color: #909399; margin-top: 8px; } }
|
|
||||||
.stat-icon { position: absolute; right: 20px; top: 50%; transform: translateY(-50%); font-size: 48px; opacity: 0.3; }
|
|
||||||
&.action-card { cursor: pointer; transition: all 0.3s; &:hover { transform: translateY(-3px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); } .stat-value { color: #409eff; } }
|
|
||||||
}
|
|
||||||
.filter-card { margin-bottom: 20px; }
|
.filter-card { margin-bottom: 20px; }
|
||||||
.table-card { .pagination-container { margin-top: 20px; display: flex; justify-content: flex-end; } }
|
.table-card { .pagination-container { margin-top: 20px; display: flex; justify-content: flex-end; } }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,13 @@
|
||||||
<el-option label="已取消" value="cancelled" />
|
<el-option label="已取消" value="cancelled" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="货币类型">
|
||||||
|
<el-select v-model="filters.currency" placeholder="全部" clearable style="width: 120px">
|
||||||
|
<el-option label="全部" value="" />
|
||||||
|
<el-option label="人民币" value="rmb" />
|
||||||
|
<el-option label="比索" value="peso" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="支付时间">
|
<el-form-item label="支付时间">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="dateRange"
|
v-model="dateRange"
|
||||||
|
|
@ -31,6 +38,20 @@
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Amount Statistics -->
|
||||||
|
<el-card class="statistics-card">
|
||||||
|
<div class="statistics-wrapper">
|
||||||
|
<div class="statistics-item">
|
||||||
|
<span class="statistics-label">人民币总额</span>
|
||||||
|
<span class="statistics-value rmb">¥{{ statistics.totalRmb }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="statistics-item">
|
||||||
|
<span class="statistics-label">比索总额</span>
|
||||||
|
<span class="statistics-value peso">₱{{ statistics.totalPeso }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<!-- Orders Table -->
|
<!-- Orders Table -->
|
||||||
<el-card class="table-card">
|
<el-card class="table-card">
|
||||||
<el-table v-loading="loading" :data="orders" stripe border>
|
<el-table v-loading="loading" :data="orders" stripe border>
|
||||||
|
|
@ -42,9 +63,13 @@
|
||||||
<small class="text-muted">UID: {{ row.user?.uid || '-' }}</small>
|
<small class="text-muted">UID: {{ row.user?.uid || '-' }}</small>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="amount" label="金额" width="100">
|
<el-table-column label="金额" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="amount">¥{{ row.amount }}</span>
|
<div class="amount-info">
|
||||||
|
<span v-if="row.amountPeso" class="amount peso">₱{{ row.amountPeso }}</span>
|
||||||
|
<span v-if="row.amountRmb" class="amount rmb">¥{{ row.amountRmb }}</span>
|
||||||
|
<span v-if="!row.amountPeso && !row.amountRmb && row.amount" class="amount">¥{{ row.amount }}</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="serviceContent" label="服务内容" min-width="200" show-overflow-tooltip />
|
<el-table-column prop="serviceContent" label="服务内容" min-width="200" show-overflow-tooltip />
|
||||||
|
|
@ -99,8 +124,18 @@
|
||||||
<el-form-item label="用户ID" prop="userId">
|
<el-form-item label="用户ID" prop="userId">
|
||||||
<el-input v-model="createForm.userId" placeholder="输入用户UID(6位数字)" />
|
<el-input v-model="createForm.userId" placeholder="输入用户UID(6位数字)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="支付金额" prop="amount">
|
<el-form-item label="比索金额">
|
||||||
<el-input-number v-model="createForm.amount" :min="0.01" :precision="2" />
|
<el-input-number v-model="createForm.amountPeso" :min="0" :precision="2" placeholder="比索" />
|
||||||
|
<span class="currency-hint">₱ 比索(可选)</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="人民币金额">
|
||||||
|
<el-input-number v-model="createForm.amountRmb" :min="0" :precision="2" placeholder="人民币" />
|
||||||
|
<span class="currency-hint">¥ 人民币(可选)</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-alert type="info" :closable="false" show-icon>
|
||||||
|
比索和人民币至少填写一项,如果两个都填写表示客户同时支付了两种货币
|
||||||
|
</el-alert>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="服务内容" prop="serviceContent">
|
<el-form-item label="服务内容" prop="serviceContent">
|
||||||
<el-input v-model="createForm.serviceContent" type="textarea" :rows="3" placeholder="输入服务内容" />
|
<el-input v-model="createForm.serviceContent" type="textarea" :rows="3" placeholder="输入服务内容" />
|
||||||
|
|
@ -136,13 +171,22 @@
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="用户">{{ currentOrder.user?.nickname }} (UID: {{ currentOrder.user?.uid }})</el-descriptions-item>
|
<el-descriptions-item label="用户">{{ currentOrder.user?.nickname }} (UID: {{ currentOrder.user?.uid }})</el-descriptions-item>
|
||||||
<el-descriptions-item label="支付金额">¥{{ currentOrder.amount }}</el-descriptions-item>
|
<el-descriptions-item label="支付金额">
|
||||||
|
<div class="amount-detail">
|
||||||
|
<span v-if="currentOrder.amountPeso" class="peso">₱{{ currentOrder.amountPeso }} (比索)</span>
|
||||||
|
<span v-if="currentOrder.amountRmb" class="rmb">¥{{ currentOrder.amountRmb }} (人民币)</span>
|
||||||
|
<span v-if="!currentOrder.amountPeso && !currentOrder.amountRmb && currentOrder.amount">¥{{ currentOrder.amount }}</span>
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="服务内容" :span="2">{{ currentOrder.serviceContent }}</el-descriptions-item>
|
<el-descriptions-item label="服务内容" :span="2">{{ currentOrder.serviceContent }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="支付时间">{{ formatDate(currentOrder.paymentTime) }}</el-descriptions-item>
|
<el-descriptions-item label="支付时间">{{ formatDate(currentOrder.paymentTime) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="创建时间">{{ formatDate(currentOrder.createdAt) }}</el-descriptions-item>
|
<el-descriptions-item label="创建时间">{{ formatDate(currentOrder.createdAt) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="备注" :span="2">{{ currentOrder.notes || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="备注" :span="2">{{ currentOrder.notes || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="创建人">{{ currentOrder.creator?.realName || currentOrder.creator?.username }}</el-descriptions-item>
|
<el-descriptions-item label="创建人">{{ currentOrder.creator?.realName || currentOrder.creator?.username }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="关联预约单">{{ currentOrder.appointment?.appointmentNo || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="关联预约单">{{ currentOrder.appointment?.appointmentNo || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="currentOrder.status === 'cancelled'" label="取消原因" :span="2">
|
||||||
|
<span class="cancel-reason">{{ currentOrder.cancelReason || '-' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<el-divider v-if="currentOrder?.commission">佣金信息</el-divider>
|
<el-divider v-if="currentOrder?.commission">佣金信息</el-divider>
|
||||||
|
|
@ -178,6 +222,12 @@ const createFormRef = ref(null)
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
userId: '',
|
userId: '',
|
||||||
status: '',
|
status: '',
|
||||||
|
currency: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const statistics = reactive({
|
||||||
|
totalRmb: '0.00',
|
||||||
|
totalPeso: '0.00',
|
||||||
})
|
})
|
||||||
|
|
||||||
const pagination = reactive({
|
const pagination = reactive({
|
||||||
|
|
@ -188,7 +238,8 @@ const pagination = reactive({
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
userId: '',
|
userId: '',
|
||||||
amount: 0,
|
amountPeso: null,
|
||||||
|
amountRmb: null,
|
||||||
serviceContent: '',
|
serviceContent: '',
|
||||||
paymentTime: '',
|
paymentTime: '',
|
||||||
appointmentId: '',
|
appointmentId: '',
|
||||||
|
|
@ -197,7 +248,6 @@ const createForm = reactive({
|
||||||
|
|
||||||
const createRules = {
|
const createRules = {
|
||||||
userId: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
|
userId: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
|
||||||
amount: [{ required: true, message: '请输入支付金额', trigger: 'blur' }],
|
|
||||||
serviceContent: [{ required: true, message: '请输入服务内容', trigger: 'blur' }],
|
serviceContent: [{ required: true, message: '请输入服务内容', trigger: 'blur' }],
|
||||||
paymentTime: [{ required: true, message: '请选择支付时间', trigger: 'change' }],
|
paymentTime: [{ required: true, message: '请选择支付时间', trigger: 'change' }],
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +262,7 @@ async function fetchOrders() {
|
||||||
}
|
}
|
||||||
if (filters.userId) params.userId = filters.userId
|
if (filters.userId) params.userId = filters.userId
|
||||||
if (filters.status) params.status = filters.status
|
if (filters.status) params.status = filters.status
|
||||||
|
if (filters.currency) params.currency = filters.currency
|
||||||
if (dateRange.value?.length === 2) {
|
if (dateRange.value?.length === 2) {
|
||||||
params.startDate = dateRange.value[0]
|
params.startDate = dateRange.value[0]
|
||||||
params.endDate = dateRange.value[1]
|
params.endDate = dateRange.value[1]
|
||||||
|
|
@ -221,6 +272,15 @@ async function fetchOrders() {
|
||||||
if (response.data.code === 0) {
|
if (response.data.code === 0) {
|
||||||
orders.value = response.data.data.records
|
orders.value = response.data.data.records
|
||||||
pagination.total = response.data.data.pagination.total
|
pagination.total = response.data.data.pagination.total
|
||||||
|
|
||||||
|
// Update statistics from API response
|
||||||
|
if (response.data.data.statistics) {
|
||||||
|
statistics.totalRmb = response.data.data.statistics.totalRmb || '0.00'
|
||||||
|
statistics.totalPeso = response.data.data.statistics.totalPeso || '0.00'
|
||||||
|
} else {
|
||||||
|
statistics.totalRmb = '0.00'
|
||||||
|
statistics.totalPeso = '0.00'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch orders:', error)
|
console.error('Failed to fetch orders:', error)
|
||||||
|
|
@ -238,6 +298,7 @@ function handleSearch() {
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
filters.userId = ''
|
filters.userId = ''
|
||||||
filters.status = ''
|
filters.status = ''
|
||||||
|
filters.currency = ''
|
||||||
dateRange.value = []
|
dateRange.value = []
|
||||||
pagination.page = 1
|
pagination.page = 1
|
||||||
fetchOrders()
|
fetchOrders()
|
||||||
|
|
@ -257,7 +318,8 @@ function handleSizeChange(size) {
|
||||||
function showCreateDialog() {
|
function showCreateDialog() {
|
||||||
Object.assign(createForm, {
|
Object.assign(createForm, {
|
||||||
userId: '',
|
userId: '',
|
||||||
amount: 0,
|
amountPeso: null,
|
||||||
|
amountRmb: null,
|
||||||
serviceContent: '',
|
serviceContent: '',
|
||||||
paymentTime: '',
|
paymentTime: '',
|
||||||
appointmentId: '',
|
appointmentId: '',
|
||||||
|
|
@ -269,6 +331,15 @@ function showCreateDialog() {
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
if (!createFormRef.value) return
|
if (!createFormRef.value) return
|
||||||
|
|
||||||
|
// Custom validation for amounts - check for null/undefined, not just falsy
|
||||||
|
const hasPeso = createForm.amountPeso !== null && createForm.amountPeso !== undefined && createForm.amountPeso > 0
|
||||||
|
const hasRmb = createForm.amountRmb !== null && createForm.amountRmb !== undefined && createForm.amountRmb > 0
|
||||||
|
|
||||||
|
if (!hasPeso && !hasRmb) {
|
||||||
|
ElMessage.warning('请至少填写一种货币金额(比索或人民币)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await createFormRef.value.validate(async (valid) => {
|
await createFormRef.value.validate(async (valid) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
|
|
||||||
|
|
@ -276,7 +347,8 @@ async function handleCreate() {
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/api/v1/admin/payment-orders', {
|
const response = await api.post('/api/v1/admin/payment-orders', {
|
||||||
userId: createForm.userId,
|
userId: createForm.userId,
|
||||||
amount: createForm.amount,
|
amountPeso: hasPeso ? createForm.amountPeso : undefined,
|
||||||
|
amountRmb: hasRmb ? createForm.amountRmb : undefined,
|
||||||
serviceContent: createForm.serviceContent,
|
serviceContent: createForm.serviceContent,
|
||||||
paymentTime: createForm.paymentTime,
|
paymentTime: createForm.paymentTime,
|
||||||
appointmentId: createForm.appointmentId || undefined,
|
appointmentId: createForm.appointmentId || undefined,
|
||||||
|
|
@ -319,11 +391,27 @@ async function showDetails(row) {
|
||||||
|
|
||||||
async function handleCancel(row) {
|
async function handleCancel(row) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('确定要取消此订单吗?相关佣金也将被取消。', '确认取消', {
|
const { value: cancelReason } = await ElMessageBox.prompt(
|
||||||
type: 'warning',
|
'请输入取消原因,相关佣金也将被取消。',
|
||||||
})
|
'取消订单',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确认取消',
|
||||||
|
cancelButtonText: '返回',
|
||||||
|
inputPlaceholder: '请输入取消原因',
|
||||||
|
inputType: 'textarea',
|
||||||
|
inputValidator: (value) => {
|
||||||
|
if (!value || !value.trim()) {
|
||||||
|
return '请输入取消原因'
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
type: 'warning',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const response = await api.put(`/api/v1/admin/payment-orders/${row.id}/cancel`)
|
const response = await api.put(`/api/v1/admin/payment-orders/${row.id}/cancel`, {
|
||||||
|
cancelReason
|
||||||
|
})
|
||||||
if (response.data.code === 0) {
|
if (response.data.code === 0) {
|
||||||
ElMessage.success('订单已取消')
|
ElMessage.success('订单已取消')
|
||||||
fetchOrders()
|
fetchOrders()
|
||||||
|
|
@ -333,7 +421,7 @@ async function handleCancel(row) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error !== 'cancel') {
|
if (error !== 'cancel') {
|
||||||
console.error('Failed to cancel order:', error)
|
console.error('Failed to cancel order:', error)
|
||||||
ElMessage.error('取消失败')
|
ElMessage.error(error.response?.data?.error?.message || '取消失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -355,10 +443,56 @@ onMounted(() => {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-card {
|
.statistics-card {
|
||||||
.amount {
|
margin-bottom: 20px;
|
||||||
color: #e6a23c;
|
|
||||||
|
.statistics-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-label {
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-value {
|
||||||
|
font-size: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|
||||||
|
&.rmb {
|
||||||
|
color: #e6a23c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.peso {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
.amount-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
&.peso {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.rmb {
|
||||||
|
color: #e6a23c;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.commission {
|
.commission {
|
||||||
|
|
@ -381,5 +515,31 @@ onMounted(() => {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.currency-hint {
|
||||||
|
margin-left: 10px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.peso {
|
||||||
|
color: #409eff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rmb {
|
||||||
|
color: #e6a23c;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-reason {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,14 @@ const paymentOrderService = require('../services/paymentOrderService');
|
||||||
const createPaymentOrder = async (req, res) => {
|
const createPaymentOrder = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const adminId = req.adminId || req.admin?.id;
|
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({
|
const result = await paymentOrderService.createPaymentOrder({
|
||||||
userId,
|
userId: userId ? userId.trim() : userId, // Trim whitespace
|
||||||
appointmentId,
|
appointmentId,
|
||||||
amount,
|
amount,
|
||||||
|
amountPeso,
|
||||||
|
amountRmb,
|
||||||
serviceContent,
|
serviceContent,
|
||||||
paymentTime,
|
paymentTime,
|
||||||
notes,
|
notes,
|
||||||
|
|
@ -60,7 +62,7 @@ const createPaymentOrder = async (req, res) => {
|
||||||
*/
|
*/
|
||||||
const getPaymentOrders = async (req, res) => {
|
const getPaymentOrders = async (req, res) => {
|
||||||
try {
|
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({
|
const result = await paymentOrderService.getPaymentOrders({
|
||||||
page,
|
page,
|
||||||
|
|
@ -70,6 +72,7 @@ const getPaymentOrders = async (req, res) => {
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
search,
|
search,
|
||||||
|
currency,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
|
|
@ -137,8 +140,9 @@ const getPaymentOrderById = async (req, res) => {
|
||||||
const cancelPaymentOrder = async (req, res) => {
|
const cancelPaymentOrder = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
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({
|
return res.status(200).json({
|
||||||
code: 0,
|
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({
|
return res.status(500).json({
|
||||||
code: 500,
|
code: 500,
|
||||||
success: false,
|
success: false,
|
||||||
|
|
|
||||||
41
backend/src/migrations/add-payment-order-cancel-reason.js
Normal file
41
backend/src/migrations/add-payment-order-cancel-reason.js
Normal file
|
|
@ -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 };
|
||||||
62
backend/src/migrations/add-payment-order-currencies.js
Normal file
62
backend/src/migrations/add-payment-order-currencies.js
Normal file
|
|
@ -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 };
|
||||||
|
|
@ -40,8 +40,23 @@ const PaymentOrder = sequelize.define('PaymentOrder', {
|
||||||
},
|
},
|
||||||
amount: {
|
amount: {
|
||||||
type: DataTypes.DECIMAL(10, 2),
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
allowNull: false,
|
allowNull: true,
|
||||||
comment: '支付金额',
|
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: {
|
serviceContent: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
@ -76,6 +91,12 @@ const PaymentOrder = sequelize.define('PaymentOrder', {
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
comment: '订单状态',
|
comment: '订单状态',
|
||||||
},
|
},
|
||||||
|
cancelReason: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'cancel_reason',
|
||||||
|
comment: '取消原因',
|
||||||
|
},
|
||||||
}, {
|
}, {
|
||||||
tableName: 'payment_orders',
|
tableName: 'payment_orders',
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ router.get('/statistics', logAdminOperation, adminNotificationController.getStat
|
||||||
/** POST /api/v1/admin/notifications/send - Send to specific user */
|
/** POST /api/v1/admin/notifications/send - Send to specific user */
|
||||||
router.post('/send',
|
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('titleZh').notEmpty().withMessage('Chinese title is required'),
|
||||||
body('contentZh').notEmpty().withMessage('Chinese content is required'),
|
body('contentZh').notEmpty().withMessage('Chinese content is required'),
|
||||||
body('type').optional().isIn(['system', 'activity', 'service']),
|
body('type').optional().isIn(['system', 'activity', 'service']),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
static async sendToUser(userId, notificationData) {
|
||||||
try {
|
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) {
|
if (!user) {
|
||||||
const error = new Error('User not found');
|
const error = new Error('User not found');
|
||||||
error.statusCode = 404;
|
error.statusCode = 404;
|
||||||
|
|
@ -58,7 +65,7 @@ class AdminNotificationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const notification = await Notification.create({
|
const notification = await Notification.create({
|
||||||
userId,
|
userId: user.id,
|
||||||
type: notificationData.type || 'system',
|
type: notificationData.type || 'system',
|
||||||
titleZh: notificationData.titleZh,
|
titleZh: notificationData.titleZh,
|
||||||
titleEn: notificationData.titleEn || notificationData.titleZh,
|
titleEn: notificationData.titleEn || notificationData.titleZh,
|
||||||
|
|
@ -69,7 +76,7 @@ class AdminNotificationService {
|
||||||
isRead: false,
|
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();
|
return notification.toJSON();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error sending notification:', error);
|
logger.error('Error sending notification:', error);
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,20 @@ const generateOrderNo = () => {
|
||||||
* @returns {Object} Created payment order with commission info
|
* @returns {Object} Created payment order with commission info
|
||||||
*/
|
*/
|
||||||
const createPaymentOrder = async (data, adminId) => {
|
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
|
// Validate required fields
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Error('请输入用户ID');
|
throw new Error('请输入用户ID');
|
||||||
}
|
}
|
||||||
if (!amount || parseFloat(amount) <= 0) {
|
// At least one amount must be provided (check for actual positive numbers)
|
||||||
throw new Error('请输入有效的支付金额');
|
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) {
|
if (!serviceContent) {
|
||||||
throw new Error('请输入服务内容');
|
throw new Error('请输入服务内容');
|
||||||
|
|
@ -92,12 +98,17 @@ const createPaymentOrder = async (data, adminId) => {
|
||||||
exists = !!existingOrder;
|
exists = !!existingOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate total amount for commission (use RMB if available)
|
||||||
|
const totalAmountForCommission = rmbAmount > 0 ? rmbAmount : legacyAmount;
|
||||||
|
|
||||||
// Create payment order
|
// Create payment order
|
||||||
const paymentOrder = await PaymentOrder.create({
|
const paymentOrder = await PaymentOrder.create({
|
||||||
orderNo,
|
orderNo,
|
||||||
userId: actualUserId,
|
userId: actualUserId,
|
||||||
appointmentId: actualAppointmentId,
|
appointmentId: actualAppointmentId,
|
||||||
amount: parseFloat(amount),
|
amount: legacyAmount > 0 ? legacyAmount : null, // Legacy field
|
||||||
|
amountPeso: pesoAmount > 0 ? pesoAmount : null,
|
||||||
|
amountRmb: rmbAmount > 0 ? rmbAmount : null,
|
||||||
serviceContent,
|
serviceContent,
|
||||||
paymentTime: new Date(paymentTime),
|
paymentTime: new Date(paymentTime),
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
|
|
@ -105,13 +116,15 @@ const createPaymentOrder = async (data, adminId) => {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate commission (if applicable)
|
// Calculate commission (if applicable, based on RMB amount)
|
||||||
let commission = null;
|
let commission = null;
|
||||||
try {
|
if (totalAmountForCommission > 0) {
|
||||||
commission = await commissionService.calculateCommission(paymentOrder.id);
|
try {
|
||||||
} catch (error) {
|
commission = await commissionService.calculateCommission(paymentOrder.id);
|
||||||
// Log error but don't fail the order creation
|
} catch (error) {
|
||||||
console.error('Commission calculation error:', error.message);
|
// Log error but don't fail the order creation
|
||||||
|
console.error('Commission calculation error:', error.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -120,7 +133,9 @@ const createPaymentOrder = async (data, adminId) => {
|
||||||
orderNo: paymentOrder.orderNo,
|
orderNo: paymentOrder.orderNo,
|
||||||
userId: paymentOrder.userId,
|
userId: paymentOrder.userId,
|
||||||
appointmentId: paymentOrder.appointmentId,
|
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,
|
serviceContent: paymentOrder.serviceContent,
|
||||||
paymentTime: paymentOrder.paymentTime,
|
paymentTime: paymentOrder.paymentTime,
|
||||||
notes: paymentOrder.notes,
|
notes: paymentOrder.notes,
|
||||||
|
|
@ -139,53 +154,83 @@ const createPaymentOrder = async (data, adminId) => {
|
||||||
/**
|
/**
|
||||||
* Get payment orders with filters
|
* Get payment orders with filters
|
||||||
* @param {Object} options - Query options
|
* @param {Object} options - Query options
|
||||||
* @returns {Object} Payment orders with pagination
|
* @returns {Object} Payment orders with pagination and statistics
|
||||||
*/
|
*/
|
||||||
const getPaymentOrders = async (options = {}) => {
|
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 offset = (page - 1) * limit;
|
||||||
|
const Op = require('sequelize').Op;
|
||||||
|
|
||||||
const where = {};
|
const where = {};
|
||||||
if (userId) where.userId = userId;
|
if (userId) where.userId = userId;
|
||||||
if (status) where.status = status;
|
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) {
|
if (startDate || endDate) {
|
||||||
where.paymentTime = {};
|
where.paymentTime = {};
|
||||||
if (startDate) where.paymentTime[require('sequelize').Op.gte] = new Date(startDate);
|
if (startDate) where.paymentTime[Op.gte] = new Date(startDate);
|
||||||
if (endDate) where.paymentTime[require('sequelize').Op.lte] = new Date(endDate);
|
if (endDate) where.paymentTime[Op.lte] = new Date(endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where[require('sequelize').Op.or] = [
|
where[Op.or] = [
|
||||||
{ orderNo: { [require('sequelize').Op.like]: `%${search}%` } },
|
{ orderNo: { [Op.like]: `%${search}%` } },
|
||||||
{ serviceContent: { [require('sequelize').Op.like]: `%${search}%` } },
|
{ serviceContent: { [Op.like]: `%${search}%` } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { count, rows } = await PaymentOrder.findAndCountAll({
|
// Execute both queries in parallel: orders list and statistics
|
||||||
where,
|
const [ordersResult, statisticsResult] = await Promise.all([
|
||||||
include: [
|
// Query for paginated orders
|
||||||
{
|
PaymentOrder.findAndCountAll({
|
||||||
model: User,
|
where,
|
||||||
as: 'user',
|
include: [
|
||||||
attributes: ['id', 'uid', 'nickname', 'phone'],
|
{
|
||||||
},
|
model: User,
|
||||||
{
|
as: 'user',
|
||||||
model: Appointment,
|
attributes: ['id', 'uid', 'nickname', 'phone'],
|
||||||
as: 'appointment',
|
},
|
||||||
attributes: ['id', 'appointmentNo'],
|
{
|
||||||
required: false,
|
model: Appointment,
|
||||||
},
|
as: 'appointment',
|
||||||
{
|
attributes: ['id', 'appointmentNo'],
|
||||||
model: Admin,
|
required: false,
|
||||||
as: 'creator',
|
},
|
||||||
attributes: ['id', 'username', 'email'],
|
{
|
||||||
},
|
model: Admin,
|
||||||
],
|
as: 'creator',
|
||||||
order: [['createdAt', 'DESC']],
|
attributes: ['id', 'username', 'email'],
|
||||||
limit: parseInt(limit),
|
},
|
||||||
offset: parseInt(offset),
|
],
|
||||||
});
|
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
|
// Get commission status for each order
|
||||||
const Commission = require('../models/Commission');
|
const Commission = require('../models/Commission');
|
||||||
|
|
@ -207,7 +252,9 @@ const getPaymentOrders = async (options = {}) => {
|
||||||
orderNo: order.orderNo,
|
orderNo: order.orderNo,
|
||||||
user: order.user,
|
user: order.user,
|
||||||
appointment: order.appointment,
|
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,
|
serviceContent: order.serviceContent,
|
||||||
paymentTime: order.paymentTime,
|
paymentTime: order.paymentTime,
|
||||||
notes: order.notes,
|
notes: order.notes,
|
||||||
|
|
@ -225,6 +272,7 @@ const getPaymentOrders = async (options = {}) => {
|
||||||
total: count,
|
total: count,
|
||||||
totalPages: Math.ceil(count / limit),
|
totalPages: Math.ceil(count / limit),
|
||||||
},
|
},
|
||||||
|
statistics,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -276,11 +324,14 @@ const getPaymentOrderById = async (orderId) => {
|
||||||
orderNo: order.orderNo,
|
orderNo: order.orderNo,
|
||||||
user: order.user,
|
user: order.user,
|
||||||
appointment: order.appointment,
|
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,
|
serviceContent: order.serviceContent,
|
||||||
paymentTime: order.paymentTime,
|
paymentTime: order.paymentTime,
|
||||||
notes: order.notes,
|
notes: order.notes,
|
||||||
status: order.status,
|
status: order.status,
|
||||||
|
cancelReason: order.cancelReason,
|
||||||
creator: order.creator,
|
creator: order.creator,
|
||||||
commission: commission ? {
|
commission: commission ? {
|
||||||
id: commission.id,
|
id: commission.id,
|
||||||
|
|
@ -298,9 +349,10 @@ const getPaymentOrderById = async (orderId) => {
|
||||||
/**
|
/**
|
||||||
* Cancel payment order
|
* Cancel payment order
|
||||||
* @param {string} orderId - Payment order ID
|
* @param {string} orderId - Payment order ID
|
||||||
|
* @param {string} cancelReason - Reason for cancellation
|
||||||
* @returns {Object} Cancelled payment order
|
* @returns {Object} Cancelled payment order
|
||||||
*/
|
*/
|
||||||
const cancelPaymentOrder = async (orderId) => {
|
const cancelPaymentOrder = async (orderId, cancelReason) => {
|
||||||
const transaction = await sequelize.transaction();
|
const transaction = await sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -314,8 +366,13 @@ const cancelPaymentOrder = async (orderId) => {
|
||||||
throw new Error('Payment order is already cancelled');
|
throw new Error('Payment order is already cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!cancelReason || !cancelReason.trim()) {
|
||||||
|
throw new Error('请输入取消原因');
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel the order
|
// Cancel the order
|
||||||
order.status = 'cancelled';
|
order.status = 'cancelled';
|
||||||
|
order.cancelReason = cancelReason.trim();
|
||||||
await order.save({ transaction });
|
await order.save({ transaction });
|
||||||
|
|
||||||
// Cancel related commission and revert balance
|
// Cancel related commission and revert balance
|
||||||
|
|
@ -345,6 +402,7 @@ const cancelPaymentOrder = async (orderId) => {
|
||||||
id: order.id,
|
id: order.id,
|
||||||
orderNo: order.orderNo,
|
orderNo: order.orderNo,
|
||||||
status: order.status,
|
status: order.status,
|
||||||
|
cancelReason: order.cancelReason,
|
||||||
commissionCancelled: !!commission,
|
commissionCancelled: !!commission,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
597
backend/src/tests/paymentOrderAmountStatistics.property.test.js
Normal file
597
backend/src/tests/paymentOrderAmountStatistics.property.test.js
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
365
backend/src/tests/paymentOrderCurrencyFilter.property.test.js
Normal file
365
backend/src/tests/paymentOrderCurrencyFilter.property.test.js
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -90,6 +90,8 @@ export default {
|
||||||
notification: 'Notification',
|
notification: 'Notification',
|
||||||
customerService: 'Customer Service',
|
customerService: 'Customer Service',
|
||||||
contactUs: 'Contact Us',
|
contactUs: 'Contact Us',
|
||||||
|
contactPhone: 'Phone',
|
||||||
|
contactEmail: 'Email',
|
||||||
inviteReward: 'Invite Friends for Rewards',
|
inviteReward: 'Invite Friends for Rewards',
|
||||||
userAgreement: 'User Agreement',
|
userAgreement: 'User Agreement',
|
||||||
privacyPolicy: 'Privacy Policy',
|
privacyPolicy: 'Privacy Policy',
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,8 @@ export default {
|
||||||
notification: 'Notificación',
|
notification: 'Notificación',
|
||||||
customerService: 'Atención al Cliente',
|
customerService: 'Atención al Cliente',
|
||||||
contactUs: 'Contáctenos',
|
contactUs: 'Contáctenos',
|
||||||
|
contactPhone: 'Teléfono',
|
||||||
|
contactEmail: 'Correo Electrónico',
|
||||||
inviteReward: 'Invita Amigos y Gana Recompensas',
|
inviteReward: 'Invita Amigos y Gana Recompensas',
|
||||||
userAgreement: 'Acuerdo de Usuario',
|
userAgreement: 'Acuerdo de Usuario',
|
||||||
privacyPolicy: 'Política de Privacidad',
|
privacyPolicy: 'Política de Privacidad',
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,8 @@ export default {
|
||||||
notification: '通知',
|
notification: '通知',
|
||||||
customerService: '客服',
|
customerService: '客服',
|
||||||
contactUs: '联系我们',
|
contactUs: '联系我们',
|
||||||
|
contactPhone: '联系电话',
|
||||||
|
contactEmail: '联系邮箱',
|
||||||
inviteReward: '邀请新人得奖励',
|
inviteReward: '邀请新人得奖励',
|
||||||
userAgreement: '用户协议',
|
userAgreement: '用户协议',
|
||||||
privacyPolicy: '隐私协议',
|
privacyPolicy: '隐私协议',
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,18 @@
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 联系方式 -->
|
||||||
|
<view class="contact-info" v-if="contactPhone || contactEmail">
|
||||||
|
<view class="contact-item" v-if="contactPhone">
|
||||||
|
<text class="contact-label">{{ $t('me.contactPhone') || '联系电话' }}</text>
|
||||||
|
<text class="contact-value" @click="callPhone">{{ contactPhone }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="contact-item" v-if="contactEmail">
|
||||||
|
<text class="contact-label">{{ $t('me.contactEmail') || '联系邮箱' }}</text>
|
||||||
|
<text class="contact-value" selectable>{{ contactEmail }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -32,28 +44,42 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
qrImageUrl: '',
|
qrImageUrl: '',
|
||||||
|
contactPhone: '',
|
||||||
|
contactEmail: '',
|
||||||
loading: true
|
loading: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLoad() {
|
onLoad() {
|
||||||
this.loadQrImage()
|
this.loadConfig()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
goBack() {
|
goBack() {
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
},
|
},
|
||||||
async loadQrImage() {
|
async loadConfig() {
|
||||||
try {
|
try {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
const config = await Config.getPublicConfig()
|
const config = await Config.getPublicConfig()
|
||||||
if (config.contact_qr_image) {
|
if (config.contact_qr_image) {
|
||||||
this.qrImageUrl = Config.getImageUrl(config.contact_qr_image)
|
this.qrImageUrl = Config.getImageUrl(config.contact_qr_image)
|
||||||
}
|
}
|
||||||
|
this.contactPhone = config.contact_phone || ''
|
||||||
|
this.contactEmail = config.contact_email || ''
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载二维码失败:', error)
|
console.error('加载配置失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
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 {
|
.qr-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 60rpx 30rpx;
|
padding: 60rpx 30rpx 40rpx;
|
||||||
|
|
||||||
.qr-box {
|
.qr-box {
|
||||||
width: 600rpx;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
</view>
|
</view>
|
||||||
<text class="title">{{ $t('me.notification') }}</text>
|
<text class="title">{{ $t('me.notification') }}</text>
|
||||||
<view class="mark-read-btn" @click="markAllRead">
|
<view class="mark-read-btn" @click="markAllRead">
|
||||||
<text class="mark-read-text">{{ $t('notification.markAllRead') }}</text>
|
<!-- <text class="mark-read-text">{{ $t('notification.markAllRead') }}</text> -->
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|
@ -270,11 +270,11 @@
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8rpx;
|
top: -5rpx;
|
||||||
right: 4rpx;
|
right: 2rpx;
|
||||||
min-width: 32rpx;
|
min-width: 32rpx;
|
||||||
height: 32rpx;
|
height: 32rpx;
|
||||||
padding: 0 8rpx;
|
padding: 0 2rpx;
|
||||||
background-color: #FF3B30;
|
background-color: #FF3B30;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
font-size: 20rpx;
|
font-size: 20rpx;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user