feat(M08-C): 补管理员验券与经营统计
This commit is contained in:
@@ -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())}`
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "验券与经营",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user