feat(M08-C): 补管理员验券与经营统计

This commit is contained in:
Codex
2026-08-10 11:08:55 +08:00
parent 3bfccb8513
commit ce52f07588
18 changed files with 1141 additions and 22 deletions
+223
View File
@@ -0,0 +1,223 @@
const { request, cents, clientRequestId, ensureLogin } = require('../../utils/api.js')
const providers = [
{ value: 'MEITUAN', label: '美团' },
{ value: 'DIANPING', label: '大众点评' },
{ value: 'DOUYIN', label: '抖音' },
{ value: 'KUAISHOU', label: '快手' },
]
const providerLabels = Object.fromEntries(providers.map((item) => [item.value, item.label]))
Page({
data: {
storeId: '',
storeName: '',
loading: false,
redeeming: false,
errorMessage: '',
canRedeem: false,
rangeDays: 30,
ranges: [7, 30, 90],
summary: {
orderTotal: 0,
collectedAmountText: '¥0.00',
payingMemberTotal: 0,
voucherSucceeded: 0,
availableRoomTotal: 0,
attentionRoomTotal: 0,
},
paymentChannels: [],
dailyRevenue: [],
providers,
providerIndex: 0,
pendingOrders: [],
orderIndex: 0,
voucherCode: '',
manualNote: '',
redemptionRecords: [],
},
async onLoad(options) {
this.setData({
storeId: options.storeId || '',
storeName: options.storeName || '',
})
await this.loadBusiness()
},
async onPullDownRefresh() {
await this.loadBusiness()
wx.stopPullDownRefresh()
},
async loadBusiness() {
if (!this.data.storeId) {
this.setData({ errorMessage: '缺少门店信息' })
return
}
this.setData({ loading: true, errorMessage: '' })
try {
await ensureLogin()
const me = await request('/auth/me')
const access = me.data?.access || { roles: [], capabilities: [] }
const canRedeem = access.capabilities.includes('store.operation.write')
|| access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN')
this.setData({ canRedeem })
const storeId = encodeURIComponent(this.data.storeId)
const to = new Date()
const from = new Date(to.getTime() - this.data.rangeDays * 86400000)
const [statistics, orders, records] = await Promise.all([
request(`/management/statistics?storeId=${storeId}&from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}`),
canRedeem
? request(`/orders?page=1&pageSize=50&storeId=${storeId}&status=PENDING_PAYMENT`)
: Promise.resolve({ data: { items: [] } }),
request(`/management/third-party/records?storeId=${storeId}`),
])
this.presentStatistics(statistics.data || {})
const pendingOrders = (orders.data?.items || []).map((item) => {
const unpaidAmountCents = Math.max(0, Number(item.totalAmountCents || 0) - Number(item.paidAmountCents || 0))
return {
...item,
unpaidAmountCents,
label: `${item.orderNo} · ${item.roomName || item.roomNo || '房间'} · ${cents(unpaidAmountCents)}`,
}
})
const redemptionRecords = (records.data?.redemptions || []).map((item) => ({
...item,
providerText: providerLabels[item.provider] || item.provider,
statusText: this.redemptionStatus(item.status),
timeText: this.formatTime(item.completedAt || item.createdAt),
}))
this.setData({
pendingOrders,
orderIndex: Math.min(this.data.orderIndex, Math.max(0, pendingOrders.length - 1)),
redemptionRecords,
})
} catch (error) {
this.setData({ errorMessage: error.message || '经营数据加载失败' })
} finally {
this.setData({ loading: false })
}
},
presentStatistics(data) {
const summary = data.summary || {}
this.setData({
summary: {
...summary,
collectedAmountText: cents(summary.collectedAmountCents),
},
paymentChannels: (data.paymentChannels || []).map((item) => ({
...item,
amountText: cents(item.amountCents),
})),
dailyRevenue: (data.dailyRevenue || []).slice(-14).reverse().map((item) => ({
...item,
amountText: cents(item.amountCents),
})),
})
},
selectRange(event) {
const rangeDays = Number(event.currentTarget.dataset.days)
if (!rangeDays || rangeDays === this.data.rangeDays) return
this.setData({ rangeDays })
this.loadBusiness()
},
selectProvider(event) {
this.setData({ providerIndex: Number(event.detail.value) || 0 })
},
selectOrder(event) {
this.setData({ orderIndex: Number(event.detail.value) || 0 })
},
inputVoucherCode(event) {
this.setData({ voucherCode: String(event.detail.value || '').trim() })
},
inputManualNote(event) {
this.setData({ manualNote: String(event.detail.value || '').trim() })
},
scanVoucher() {
if (!this.data.canRedeem) return
wx.scanCode({
scanType: ['barCode', 'qrCode'],
success: ({ result }) => this.setData({ voucherCode: String(result || '').trim() }),
fail: (error) => {
if (!String(error.errMsg || '').includes('cancel')) {
this.setData({ errorMessage: '扫码失败,请手工输入券码' })
}
},
})
},
redeemVoucher(event) {
if (!this.data.canRedeem || this.data.redeeming) return
const manual = event.currentTarget.dataset.mode === 'manual'
const order = this.data.pendingOrders[this.data.orderIndex]
const provider = this.data.providers[this.data.providerIndex]
const voucherCode = this.data.voucherCode.trim()
const manualNote = this.data.manualNote.trim()
if (!order || !provider || voucherCode.length < 4) {
this.setData({ errorMessage: '请选择待支付订单并输入有效券码' })
return
}
if (manual && !manualNote) {
this.setData({ errorMessage: '人工核销必须填写确认说明' })
return
}
const title = manual ? '人工确认核销' : '在线验券'
wx.showModal({
title,
content: `确认将${provider.label}券用于订单 ${order.orderNo},抵扣 ${cents(order.unpaidAmountCents)} 吗?`,
success: ({ confirm }) => {
if (confirm) this.submitRedemption({ manual, order, provider, voucherCode, manualNote })
},
})
},
async submitRedemption({ manual, order, provider, voucherCode, manualNote }) {
this.setData({ redeeming: true, errorMessage: '' })
try {
const payload = {
provider: provider.value,
voucherCode,
orderId: order.id,
clientRequestId: clientRequestId(manual ? 'manager-manual-redeem' : 'manager-redeem'),
...(manual ? { amountCents: order.unpaidAmountCents, note: manualNote } : {}),
}
const endpoint = manual
? '/management/group-vouchers/redeem-manual'
: '/management/group-vouchers/redeem'
const response = await request(endpoint, { method: 'POST', data: payload })
const result = response.data || {}
if (result.status !== 'SUCCEEDED') {
throw new Error(result.failureCode ? `验券未成功:${result.failureCode}` : '验券正在处理中')
}
this.setData({ voucherCode: '', manualNote: '' })
wx.showToast({ title: '核销成功', icon: 'success' })
await this.loadBusiness()
} catch (error) {
this.setData({ errorMessage: error.message || '验券失败' })
} finally {
this.setData({ redeeming: false })
}
},
redemptionStatus(status) {
return { SUCCEEDED: '成功', FAILED: '失败', PENDING: '处理中' }[status] || status
},
formatTime(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
const pad = (part) => String(part).padStart(2, '0')
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "验券与经营",
"enablePullDownRefresh": true
}
+73
View File
@@ -0,0 +1,73 @@
<scroll-view class="scrollarea" scroll-y type="list">
<view class="container business-page">
<view class="page-heading">
<view>
<view class="title">验券与经营</view>
<view class="subtitle">{{storeName || '当前门店'}}</view>
</view>
<view class="permission-tag">{{canRedeem ? '可验券' : '只读'}}</view>
</view>
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<view wx:if="{{loading}}" class="loading">正在加载经营数据...</view>
<view class="range-tabs">
<button wx:for="{{ranges}}" wx:key="*this" size="mini" class="{{item === rangeDays ? 'active' : ''}}" data-days="{{item}}" bindtap="selectRange">近 {{item}} 天</button>
</view>
<view class="summary-grid">
<view class="summary-card primary"><strong>{{summary.collectedAmountText}}</strong><text>实收</text></view>
<view class="summary-card"><strong>{{summary.orderTotal}}</strong><text>订单</text></view>
<view class="summary-card"><strong>{{summary.payingMemberTotal}}</strong><text>消费会员</text></view>
<view class="summary-card success"><strong>{{summary.voucherSucceeded}}</strong><text>验券成功</text></view>
<view class="summary-card"><strong>{{summary.availableRoomTotal}}</strong><text>当前空闲</text></view>
<view class="summary-card warning"><strong>{{summary.attentionRoomTotal}}</strong><text>房态待处理</text></view>
</view>
<view class="section-title">收款渠道</view>
<view wx:if="{{paymentChannels.length === 0}}" class="empty compact">当前区间暂无成功收款</view>
<view wx:for="{{paymentChannels}}" wx:key="channel" class="metric-row">
<text>{{item.channel}} · {{item.total}} 笔</text><strong>{{item.amountText}}</strong>
</view>
<block wx:if="{{canRedeem}}">
<view class="section-title">管理员验券</view>
<view class="form-card">
<view class="field-label">平台</view>
<picker range="{{providers}}" range-key="label" value="{{providerIndex}}" bindchange="selectProvider">
<view class="picker-value">{{providers[providerIndex].label}}</view>
</picker>
<view class="field-label">待支付订单</view>
<picker wx:if="{{pendingOrders.length}}" range="{{pendingOrders}}" range-key="label" value="{{orderIndex}}" bindchange="selectOrder">
<view class="picker-value">{{pendingOrders[orderIndex].label}}</view>
</picker>
<view wx:else class="empty compact">当前门店没有待支付订单</view>
<view class="field-label">团购券码</view>
<view class="voucher-input-row">
<input value="{{voucherCode}}" maxlength="128" placeholder="扫码或输入券码" bindinput="inputVoucherCode" />
<button size="mini" bindtap="scanVoucher">扫码</button>
</view>
<view class="field-label">人工确认说明</view>
<input value="{{manualNote}}" maxlength="512" placeholder="仅人工核销时必填" bindinput="inputManualNote" />
<view class="form-actions">
<button type="primary" loading="{{redeeming}}" disabled="{{!pendingOrders.length}}" data-mode="online" bindtap="redeemVoucher">在线验券</button>
<button loading="{{redeeming}}" disabled="{{!pendingOrders.length}}" data-mode="manual" bindtap="redeemVoucher">人工确认核销</button>
</view>
<view class="form-tip">核销金额取订单服务端未支付金额;券码仅保存哈希与脱敏值。</view>
</view>
</block>
<view class="section-title">近期核销记录</view>
<view wx:if="{{redemptionRecords.length === 0}}" class="empty compact">当前门店暂无核销记录</view>
<view wx:for="{{redemptionRecords}}" wx:key="id" class="record-card">
<view class="metric-row"><strong>{{item.providerText}} · {{item.voucherMasked}}</strong><text class="status-{{item.status}}">{{item.statusText}}</text></view>
<view class="record-meta">订单 {{item.orderId}} · 操作人 {{item.actorId || '系统'}} · {{item.timeText}}</view>
<view wx:if="{{item.failureCode}}" class="failure-code">{{item.failureCode}}</view>
</view>
<view class="section-title">每日实收</view>
<view wx:if="{{dailyRevenue.length === 0}}" class="empty compact">当前区间暂无每日实收</view>
<view wx:for="{{dailyRevenue}}" wx:key="date" class="metric-row">
<text>{{item.date}} · {{item.total}} 笔</text><strong>{{item.amountText}}</strong>
</view>
</view>
</scroll-view>
+136
View File
@@ -0,0 +1,136 @@
.business-page {
padding-bottom: 48rpx;
}
.page-heading,
.metric-row,
.voucher-input-row {
align-items: center;
display: flex;
justify-content: space-between;
}
.subtitle,
.record-meta,
.form-tip {
color: #64748b;
font-size: 23rpx;
margin-top: 8rpx;
}
.permission-tag {
background: #eef2ff;
border-radius: 999rpx;
color: #3730a3;
font-size: 22rpx;
padding: 8rpx 16rpx;
}
.range-tabs {
display: flex;
gap: 12rpx;
margin: 24rpx 0;
}
.range-tabs button {
margin: 0;
}
.range-tabs button.active {
background: #0f172a;
color: #fff;
}
.summary-grid {
display: grid;
gap: 12rpx;
grid-template-columns: repeat(3, 1fr);
}
.summary-card {
background: #f8fafc;
border: 1rpx solid #e2e8f0;
border-radius: 16rpx;
display: flex;
flex-direction: column;
padding: 16rpx;
}
.summary-card strong { font-size: 30rpx; }
.summary-card text { color: #64748b; font-size: 21rpx; }
.summary-card.primary strong { color: #2563eb; }
.summary-card.success strong { color: #15803d; }
.summary-card.warning strong { color: #c2410c; }
.section-title {
font-size: 30rpx;
font-weight: 600;
margin: 30rpx 0 16rpx;
}
.metric-row,
.record-card,
.form-card {
background: #fff;
border: 1rpx solid #e2e8f0;
border-radius: 16rpx;
margin-bottom: 12rpx;
padding: 20rpx;
}
.form-card input,
.picker-value {
background: #f8fafc;
border: 1rpx solid #cbd5e1;
border-radius: 12rpx;
min-height: 52rpx;
padding: 12rpx 16rpx;
}
.field-label {
color: #475569;
font-size: 24rpx;
margin: 20rpx 0 10rpx;
}
.field-label:first-child { margin-top: 0; }
.voucher-input-row {
gap: 12rpx;
}
.voucher-input-row input { flex: 1; }
.voucher-input-row button { margin: 0; }
.form-actions {
display: flex;
gap: 12rpx;
margin-top: 24rpx;
}
.form-actions button {
flex: 1;
font-size: 25rpx;
margin: 0;
}
.status-SUCCEEDED { color: #15803d; }
.status-FAILED,
.failure-code { color: #b91c1c; }
.status-PENDING { color: #c2410c; }
.failure-code {
font-size: 22rpx;
margin-top: 8rpx;
}
.loading,
.empty {
color: #64748b;
padding: 30rpx 0;
text-align: center;
}
.empty.compact {
padding: 14rpx 0;
}
+7
View File
@@ -236,6 +236,13 @@ Page({
})
},
openBusiness() {
if (!this.data.selectedStoreId) return
wx.navigateTo({
url: `/pages/manager/business?storeId=${encodeURIComponent(this.data.selectedStoreId)}&storeName=${encodeURIComponent(this.data.selectedStoreName)}`,
})
},
manageOrder(event) {
if (!this.data.canWrite) return
const orderId = event.currentTarget.dataset.orderId
+8
View File
@@ -40,6 +40,14 @@
<button size="mini" bindtap="openOperations">进入</button>
</view>
<view wx:if="{{selectedStoreId}}" class="management-tools">
<view>
<view class="card-title">验券与经营</view>
<view class="card-meta">团购券扫码/输入核销与门店经营统计</view>
</view>
<button size="mini" bindtap="openBusiness">进入</button>
</view>
<view class="summary-grid">
<view class="summary-card"><strong>{{summary.roomTotal}}</strong><text>房间</text></view>
<view class="summary-card success"><strong>{{summary.available}}</strong><text>空闲</text></view>