修改问题.

This commit is contained in:
18631081161 2025-12-23 17:57:19 +08:00
parent 4b27db42d3
commit 9d7daa281f
19 changed files with 1555 additions and 192 deletions

1
.gitignore vendored
View File

@ -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/

View File

@ -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>

View File

@ -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
} }

View File

@ -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="请输入用户UID6位数字" 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) // uiduserId
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; } }
} }

View File

@ -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="输入用户UID6位数字" /> <el-input v-model="createForm.userId" placeholder="输入用户UID6位数字" />
</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>

View File

@ -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,

View 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 };

View 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 };

View File

@ -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,

View File

@ -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']),

View File

@ -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);

View File

@ -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) {

View 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 }
);
});
});
});

View 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 }
);
});
});
});

View File

@ -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',

View File

@ -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',

View File

@ -90,6 +90,8 @@ export default {
notification: '通知', notification: '通知',
customerService: '客服', customerService: '客服',
contactUs: '联系我们', contactUs: '联系我们',
contactPhone: '联系电话',
contactEmail: '联系邮箱',
inviteReward: '邀请新人得奖励', inviteReward: '邀请新人得奖励',
userAgreement: '用户协议', userAgreement: '用户协议',
privacyPolicy: '隐私协议', privacyPolicy: '隐私协议',

View File

@ -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>

View File

@ -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;