调整登录页核销端文案 #5
@@ -242,11 +242,16 @@ export const mockApi = {
|
|||||||
commodityDescription: '面向到店客人的酒店权益商品,需由员工确认后核销。',
|
commodityDescription: '面向到店客人的酒店权益商品,需由员工确认后核销。',
|
||||||
commodityPackageConfig: [
|
commodityPackageConfig: [
|
||||||
{
|
{
|
||||||
|
orderWriteOffItemId: `WOI${order.id}-1`,
|
||||||
packageName: order.commodityName,
|
packageName: order.commodityName,
|
||||||
packageContent: `${order.commodityName} x ${order.commodityAmount}`,
|
packageContent: `${order.commodityName} x ${order.commodityAmount}`,
|
||||||
name: order.commodityName,
|
name: order.commodityName,
|
||||||
count: totalCount,
|
count: totalCount,
|
||||||
unit: '份',
|
unit: '份',
|
||||||
|
writeOffStatus: writeOffCount >= totalCount ? 'WRITTEN_OFF' : 'NOT_WRITE_OFF',
|
||||||
|
legacySnapshot: false,
|
||||||
|
writeOffSupported: true,
|
||||||
|
snapshotOperationSupported: true,
|
||||||
packageStatus: writeOffCount >= totalCount ? 1 : 0,
|
packageStatus: writeOffCount >= totalCount ? 1 : 0,
|
||||||
writeOffCount
|
writeOffCount
|
||||||
}
|
}
|
||||||
@@ -278,7 +283,7 @@ export const mockApi = {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
orderId: payload.orderId,
|
orderId: payload.orderId,
|
||||||
packageName: payload.packageName,
|
orderWriteOffItemId: payload.orderWriteOffItemId,
|
||||||
message: '核销成功',
|
message: '核销成功',
|
||||||
operatedAt: formatDateTime(new Date())
|
operatedAt: formatDateTime(new Date())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const writeOffOrder = async (payload: WriteOffPayload): Promise<WriteOffR
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
orderId: payload.orderId,
|
orderId: payload.orderId,
|
||||||
packageName: payload.packageName,
|
orderWriteOffItemId: payload.orderWriteOffItemId,
|
||||||
message: '核销成功',
|
message: '核销成功',
|
||||||
operatedAt: new Date().toISOString()
|
operatedAt: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { PageQuery } from './api'
|
|||||||
|
|
||||||
export type OrderStatus = '0' | '1' | '2' | '3' | '4' | '5' | '6'
|
export type OrderStatus = '0' | '1' | '2' | '3' | '4' | '5' | '6'
|
||||||
export type PayStatus = '0' | '1'
|
export type PayStatus = '0' | '1'
|
||||||
export type CommodityTypeCode = '0' | '1' | '2'
|
export type CommodityTypeCode = '0' | '1' | '2' | '3'
|
||||||
|
|
||||||
export interface CommodityOrder {
|
export interface CommodityOrder {
|
||||||
id: string
|
id: string
|
||||||
@@ -52,6 +52,7 @@ export interface CommodityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CommodityPackageConfig {
|
export interface CommodityPackageConfig {
|
||||||
|
orderWriteOffItemId?: string
|
||||||
packageName?: string
|
packageName?: string
|
||||||
packageContent?: string
|
packageContent?: string
|
||||||
packageDesc?: string
|
packageDesc?: string
|
||||||
@@ -60,6 +61,10 @@ export interface CommodityPackageConfig {
|
|||||||
count?: number
|
count?: number
|
||||||
color?: string
|
color?: string
|
||||||
unit?: string
|
unit?: string
|
||||||
|
writeOffStatus?: string | number
|
||||||
|
legacySnapshot?: boolean
|
||||||
|
writeOffSupported?: boolean
|
||||||
|
snapshotOperationSupported?: boolean
|
||||||
packageStatus?: number
|
packageStatus?: number
|
||||||
writeOffCount?: number
|
writeOffCount?: number
|
||||||
}
|
}
|
||||||
@@ -121,12 +126,14 @@ export interface UserOrderDetail {
|
|||||||
|
|
||||||
export interface WriteOffPayload {
|
export interface WriteOffPayload {
|
||||||
orderId: string
|
orderId: string
|
||||||
packageName?: string
|
orderWriteOffItemId: string
|
||||||
|
writeOffRequestNo: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WriteOffResult {
|
export interface WriteOffResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
orderId: string
|
orderId: string
|
||||||
|
orderWriteOffItemId?: string
|
||||||
packageName?: string
|
packageName?: string
|
||||||
message: string
|
message: string
|
||||||
operatedAt: string
|
operatedAt: string
|
||||||
|
|||||||
22
src/utils/__tests__/writeOffCode.test.ts
Normal file
22
src/utils/__tests__/writeOffCode.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { parseWriteOffCode } from '../writeOffCode.ts'
|
||||||
|
|
||||||
|
test('parses v2 JSON write off code with item id', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parseWriteOffCode('{"v":2,"orderId":"2067459232180035585","orderWriteOffItemId":"item-2","packageName":"全价门票"}'),
|
||||||
|
{
|
||||||
|
orderId: '2067459232180035585',
|
||||||
|
orderWriteOffItemId: 'item-2',
|
||||||
|
packageName: '全价门票',
|
||||||
|
version: 2
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parses legacy orderId and packageName write off code', () => {
|
||||||
|
assert.deepEqual(parseWriteOffCode('order-1&%E5%A5%97%E9%A4%90A'), {
|
||||||
|
orderId: 'order-1',
|
||||||
|
packageName: '套餐A'
|
||||||
|
})
|
||||||
|
})
|
||||||
53
src/utils/__tests__/writeOffItem.test.ts
Normal file
53
src/utils/__tests__/writeOffItem.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import {
|
||||||
|
findWriteOffItem,
|
||||||
|
getWriteOffItemAvailability,
|
||||||
|
getWriteOffItemQuantity
|
||||||
|
} from '../writeOffItem.ts'
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
orderWriteOffItemId: 'item-1',
|
||||||
|
name: '双人套餐',
|
||||||
|
count: 2,
|
||||||
|
writeOffCount: 0,
|
||||||
|
writeOffSupported: true,
|
||||||
|
snapshotOperationSupported: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
orderWriteOffItemId: 'item-2',
|
||||||
|
name: '双人套餐',
|
||||||
|
count: 2,
|
||||||
|
writeOffCount: 1,
|
||||||
|
writeOffSupported: true,
|
||||||
|
snapshotOperationSupported: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
test('finds write off item by id before matching duplicated package name', () => {
|
||||||
|
assert.equal(
|
||||||
|
findWriteOffItem(items, { orderWriteOffItemId: 'item-2', packageName: '双人套餐' })?.orderWriteOffItemId,
|
||||||
|
'item-2'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('matches legacy package name when item id is absent', () => {
|
||||||
|
assert.equal(findWriteOffItem(items, { packageName: '双人套餐' })?.orderWriteOffItemId, 'item-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calculates total, written off and remaining quantity from item fields', () => {
|
||||||
|
assert.deepEqual(getWriteOffItemQuantity(items[1]), {
|
||||||
|
total: 2,
|
||||||
|
writtenOff: 1,
|
||||||
|
writable: 1,
|
||||||
|
unit: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('marks legacy or unsupported or completed item unavailable', () => {
|
||||||
|
assert.equal(getWriteOffItemAvailability({ ...items[0], legacySnapshot: true }).available, false)
|
||||||
|
assert.equal(getWriteOffItemAvailability({ ...items[0], writeOffSupported: false }).available, false)
|
||||||
|
assert.equal(getWriteOffItemAvailability({ ...items[0], snapshotOperationSupported: false }).available, false)
|
||||||
|
assert.equal(getWriteOffItemAvailability({ ...items[0], writeOffCount: 2 }).available, false)
|
||||||
|
})
|
||||||
29
src/utils/__tests__/writeOffRequestNo.test.ts
Normal file
29
src/utils/__tests__/writeOffRequestNo.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { getReusableWriteOffRequestNo, type WriteOffRequestNoState } from '../writeOffRequestNo.ts'
|
||||||
|
|
||||||
|
test('reuses request number for the same write off target', () => {
|
||||||
|
const state: WriteOffRequestNoState = { targetKey: '', requestNo: '' }
|
||||||
|
let sequence = 0
|
||||||
|
const createRequestNo = () => `request-${++sequence}`
|
||||||
|
|
||||||
|
const first = getReusableWriteOffRequestNo(state, 'order-1:item-1', createRequestNo)
|
||||||
|
const retry = getReusableWriteOffRequestNo(state, 'order-1:item-1', createRequestNo)
|
||||||
|
|
||||||
|
assert.equal(first, 'request-1')
|
||||||
|
assert.equal(retry, 'request-1')
|
||||||
|
assert.equal(sequence, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a new request number when the write off target changes', () => {
|
||||||
|
const state: WriteOffRequestNoState = { targetKey: '', requestNo: '' }
|
||||||
|
let sequence = 0
|
||||||
|
const createRequestNo = () => `request-${++sequence}`
|
||||||
|
|
||||||
|
const first = getReusableWriteOffRequestNo(state, 'order-1:item-1', createRequestNo)
|
||||||
|
const second = getReusableWriteOffRequestNo(state, 'order-1:item-2', createRequestNo)
|
||||||
|
|
||||||
|
assert.equal(first, 'request-1')
|
||||||
|
assert.equal(second, 'request-2')
|
||||||
|
assert.equal(sequence, 2)
|
||||||
|
})
|
||||||
@@ -29,7 +29,8 @@ export const payStatusText: Record<PayStatus, string> = {
|
|||||||
export const commodityTypeText: Record<CommodityTypeCode, string> = {
|
export const commodityTypeText: Record<CommodityTypeCode, string> = {
|
||||||
'0': '酒店',
|
'0': '酒店',
|
||||||
'1': '门票',
|
'1': '门票',
|
||||||
'2': '餐饮'
|
'2': '餐饮',
|
||||||
|
'3': '套餐'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const eventStatusText: Record<EventStatus, string> = {
|
export const eventStatusText: Record<EventStatus, string> = {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export interface WriteOffCodePayload {
|
export interface WriteOffCodePayload {
|
||||||
orderId: string
|
orderId: string
|
||||||
|
orderWriteOffItemId?: string
|
||||||
packageName?: string
|
packageName?: string
|
||||||
|
version?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeDecode = (value: string) => {
|
const safeDecode = (value: string) => {
|
||||||
@@ -17,6 +19,30 @@ export const parseWriteOffCode = (rawValue: string): WriteOffCodePayload => {
|
|||||||
throw new Error('未识别到核销码')
|
throw new Error('未识别到核销码')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (raw.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(raw) as {
|
||||||
|
v?: number
|
||||||
|
orderId?: string
|
||||||
|
orderWriteOffItemId?: string
|
||||||
|
packageName?: string
|
||||||
|
}
|
||||||
|
const orderId = String(payload.orderId || '').trim()
|
||||||
|
if (!orderId) {
|
||||||
|
throw new Error('核销码缺少订单号')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
orderId,
|
||||||
|
orderWriteOffItemId: payload.orderWriteOffItemId ? String(payload.orderWriteOffItemId).trim() : undefined,
|
||||||
|
packageName: payload.packageName ? String(payload.packageName).trim() : undefined,
|
||||||
|
version: payload.v
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === '核销码缺少订单号') throw error
|
||||||
|
throw new Error('核销码格式不正确')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const splitIndex = raw.indexOf('&')
|
const splitIndex = raw.indexOf('&')
|
||||||
const orderId = safeDecode(splitIndex >= 0 ? raw.slice(0, splitIndex) : raw).trim()
|
const orderId = safeDecode(splitIndex >= 0 ? raw.slice(0, splitIndex) : raw).trim()
|
||||||
const packageName = splitIndex >= 0 ? safeDecode(raw.slice(splitIndex + 1)).trim() : ''
|
const packageName = splitIndex >= 0 ? safeDecode(raw.slice(splitIndex + 1)).trim() : ''
|
||||||
|
|||||||
94
src/utils/writeOffItem.ts
Normal file
94
src/utils/writeOffItem.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { CommodityPackageConfig } from '@/types/order'
|
||||||
|
|
||||||
|
export interface WriteOffItemMatch {
|
||||||
|
orderWriteOffItemId?: string
|
||||||
|
packageName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteOffItemQuantity {
|
||||||
|
total: number
|
||||||
|
writtenOff: number
|
||||||
|
writable: number
|
||||||
|
unit: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteOffItemAvailability {
|
||||||
|
available: boolean
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusTextMap: Record<string, string> = {
|
||||||
|
'0': '待核销',
|
||||||
|
'1': '已核销',
|
||||||
|
'2': '部分核销',
|
||||||
|
NOT_WRITE_OFF: '待核销',
|
||||||
|
WAIT_WRITE_OFF: '待核销',
|
||||||
|
PART_WRITE_OFF: '部分核销',
|
||||||
|
PARTIAL_WRITE_OFF: '部分核销',
|
||||||
|
WRITE_OFF: '已核销',
|
||||||
|
WRITTEN_OFF: '已核销',
|
||||||
|
FINISHED: '已核销'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toNumber = (value: unknown) => {
|
||||||
|
const number = Number(value)
|
||||||
|
return Number.isFinite(number) ? number : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWriteOffItemId = (item?: CommodityPackageConfig) => {
|
||||||
|
return String(item?.orderWriteOffItemId || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWriteOffItemName = (item?: CommodityPackageConfig, fallbackName = '') => {
|
||||||
|
return (item?.name || item?.packageName || fallbackName || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWriteOffItemContent = (item?: CommodityPackageConfig) => {
|
||||||
|
return item?.packageContent || item?.packageDesc || item?.content || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWriteOffItemQuantity = (item?: CommodityPackageConfig): WriteOffItemQuantity => {
|
||||||
|
const total = toNumber(item?.count) ?? 0
|
||||||
|
const writtenOff = Math.min(toNumber(item?.writeOffCount) ?? 0, total)
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
writtenOff,
|
||||||
|
writable: Math.max(total - writtenOff, 0),
|
||||||
|
unit: item?.unit || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWriteOffItemAvailability = (item?: CommodityPackageConfig): WriteOffItemAvailability => {
|
||||||
|
if (!item) return { available: false, reason: '缺少可核销明细' }
|
||||||
|
if (!getWriteOffItemId(item)) return { available: false, reason: '缺少核销明细ID' }
|
||||||
|
if (item.legacySnapshot) return { available: false, reason: '历史未迁移明细不可核销' }
|
||||||
|
if (item.writeOffSupported === false) return { available: false, reason: '当前明细不支持核销' }
|
||||||
|
if (item.snapshotOperationSupported === false) return { available: false, reason: '当前明细不支持快照核销' }
|
||||||
|
if (getWriteOffItemQuantity(item).writable <= 0) return { available: false, reason: '当前明细已核销完' }
|
||||||
|
return { available: true, reason: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findWriteOffItem = (items: CommodityPackageConfig[], match: WriteOffItemMatch) => {
|
||||||
|
const itemId = String(match.orderWriteOffItemId || '').trim()
|
||||||
|
if (itemId) {
|
||||||
|
return items.find((item) => getWriteOffItemId(item) === itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageName = String(match.packageName || '').trim()
|
||||||
|
if (packageName) {
|
||||||
|
return items.find((item) => getWriteOffItemName(item) === packageName || item.packageName === packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getFirstAvailableWriteOffItem = (items: CommodityPackageConfig[]) => {
|
||||||
|
return items.find((item) => getWriteOffItemAvailability(item).available)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWriteOffStatusText = (item?: CommodityPackageConfig) => {
|
||||||
|
const status = item?.writeOffStatus ?? item?.packageStatus
|
||||||
|
if (status === undefined || status === null || status === '') return ''
|
||||||
|
const key = String(status)
|
||||||
|
return statusTextMap[key] || key
|
||||||
|
}
|
||||||
21
src/utils/writeOffRequestNo.ts
Normal file
21
src/utils/writeOffRequestNo.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface WriteOffRequestNoState {
|
||||||
|
targetKey: string
|
||||||
|
requestNo: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createWriteOffRequestNo = () => {
|
||||||
|
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
|
||||||
|
return `h5-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getReusableWriteOffRequestNo = (
|
||||||
|
state: WriteOffRequestNoState,
|
||||||
|
targetKey: string,
|
||||||
|
createRequestNo = createWriteOffRequestNo
|
||||||
|
) => {
|
||||||
|
if (!state.requestNo || state.targetKey !== targetKey) {
|
||||||
|
state.targetKey = targetKey
|
||||||
|
state.requestNo = createRequestNo()
|
||||||
|
}
|
||||||
|
return state.requestNo
|
||||||
|
}
|
||||||
@@ -7,6 +7,15 @@ import PageNav from '@/components/PageNav.vue'
|
|||||||
import StatusTag from '@/components/StatusTag.vue'
|
import StatusTag from '@/components/StatusTag.vue'
|
||||||
import type { UserOrderDetail } from '@/types/order'
|
import type { UserOrderDetail } from '@/types/order'
|
||||||
import { canWriteOff, commodityTypeText, payStatusText } from '@/utils/constants'
|
import { canWriteOff, commodityTypeText, payStatusText } from '@/utils/constants'
|
||||||
|
import {
|
||||||
|
getFirstAvailableWriteOffItem,
|
||||||
|
getWriteOffItemAvailability,
|
||||||
|
getWriteOffItemContent,
|
||||||
|
getWriteOffItemId,
|
||||||
|
getWriteOffItemName,
|
||||||
|
getWriteOffItemQuantity,
|
||||||
|
getWriteOffStatusText
|
||||||
|
} from '@/utils/writeOffItem'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -14,10 +23,8 @@ const detail = ref<UserOrderDetail | null>(null)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const orderId = computed(() => String(route.params.id || ''))
|
const orderId = computed(() => String(route.params.id || ''))
|
||||||
const packageName = computed(() => {
|
const firstWritableItem = computed(() => getFirstAvailableWriteOffItem(detail.value?.commodityPackageConfig || []))
|
||||||
const first = detail.value?.commodityPackageConfig?.[0]
|
const canGoWriteOff = computed(() => Boolean(detail.value && canWriteOff(detail.value.orderStatus) && firstWritableItem.value))
|
||||||
return first?.packageName || first?.name || detail.value?.commodityName || ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadDetail = async () => {
|
const loadDetail = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -29,11 +36,13 @@ const loadDetail = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goWriteOff = () => {
|
const goWriteOff = () => {
|
||||||
|
if (!firstWritableItem.value) return
|
||||||
router.push({
|
router.push({
|
||||||
path: '/verify/confirm',
|
path: '/verify/confirm',
|
||||||
query: {
|
query: {
|
||||||
orderId: orderId.value,
|
orderId: orderId.value,
|
||||||
packageName: packageName.value
|
orderWriteOffItemId: getWriteOffItemId(firstWritableItem.value),
|
||||||
|
packageName: getWriteOffItemName(firstWritableItem.value, detail.value?.commodityName)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -103,11 +112,24 @@ onMounted(loadDetail)
|
|||||||
|
|
||||||
<section class="panel detail-panel">
|
<section class="panel detail-panel">
|
||||||
<h2 class="section-title">套餐内容</h2>
|
<h2 class="section-title">套餐内容</h2>
|
||||||
<div v-for="item in detail.commodityPackageConfig" :key="item.packageName || item.name" class="package-row">
|
<div
|
||||||
|
v-for="(item, index) in detail.commodityPackageConfig"
|
||||||
|
:key="getWriteOffItemId(item) || `${getWriteOffItemName(item, detail.commodityName)}-${index}`"
|
||||||
|
class="package-row"
|
||||||
|
>
|
||||||
<ReceiptText :size="17" />
|
<ReceiptText :size="17" />
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ item.packageName || item.name || detail.commodityName }}</strong>
|
<strong>{{ getWriteOffItemName(item, detail.commodityName) }}</strong>
|
||||||
<p>{{ item.packageContent || item.packageDesc || item.content }}</p>
|
<p v-if="getWriteOffItemContent(item)">{{ getWriteOffItemContent(item) }}</p>
|
||||||
|
<p>
|
||||||
|
<span v-if="getWriteOffStatusText(item)">{{ getWriteOffStatusText(item) }} · </span>
|
||||||
|
已核销 {{ getWriteOffItemQuantity(item).writtenOff }}{{ getWriteOffItemQuantity(item).unit }} /
|
||||||
|
剩余 {{ getWriteOffItemQuantity(item).writable }}{{ getWriteOffItemQuantity(item).unit }} /
|
||||||
|
总数 {{ getWriteOffItemQuantity(item).total }}{{ getWriteOffItemQuantity(item).unit }}
|
||||||
|
</p>
|
||||||
|
<p v-if="!getWriteOffItemAvailability(item).available" class="package-row__warning">
|
||||||
|
{{ getWriteOffItemAvailability(item).reason }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="detail.commodityTip" class="tip-text">{{ detail.commodityTip }}</p>
|
<p v-if="detail.commodityTip" class="tip-text">{{ detail.commodityTip }}</p>
|
||||||
@@ -133,9 +155,9 @@ onMounted(loadDetail)
|
|||||||
|
|
||||||
<footer v-if="detail && canWriteOff(detail.orderStatus)" class="fixed-action">
|
<footer v-if="detail && canWriteOff(detail.orderStatus)" class="fixed-action">
|
||||||
<div class="fixed-action__inner">
|
<div class="fixed-action__inner">
|
||||||
<van-button block type="primary" @click="goWriteOff">
|
<van-button block type="primary" :disabled="!canGoWriteOff" @click="goWriteOff">
|
||||||
<template #icon><CheckCircle2 :size="18" /></template>
|
<template #icon><CheckCircle2 :size="18" /></template>
|
||||||
立即核销
|
{{ canGoWriteOff ? '立即核销' : '暂无可核销明细' }}
|
||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -242,6 +264,10 @@ onMounted(loadDetail)
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.package-row__warning {
|
||||||
|
color: var(--rose) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-line {
|
.empty-line {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
|||||||
@@ -8,25 +8,36 @@ import PageNav from '@/components/PageNav.vue'
|
|||||||
import StatusTag from '@/components/StatusTag.vue'
|
import StatusTag from '@/components/StatusTag.vue'
|
||||||
import type { CommodityPackageConfig, UserOrderDetail } from '@/types/order'
|
import type { CommodityPackageConfig, UserOrderDetail } from '@/types/order'
|
||||||
import { canWriteOff } from '@/utils/constants'
|
import { canWriteOff } from '@/utils/constants'
|
||||||
|
import {
|
||||||
|
findWriteOffItem,
|
||||||
|
getFirstAvailableWriteOffItem,
|
||||||
|
getWriteOffItemAvailability,
|
||||||
|
getWriteOffItemContent,
|
||||||
|
getWriteOffItemId,
|
||||||
|
getWriteOffItemName,
|
||||||
|
getWriteOffItemQuantity,
|
||||||
|
getWriteOffStatusText,
|
||||||
|
toNumber
|
||||||
|
} from '@/utils/writeOffItem'
|
||||||
|
import {
|
||||||
|
getReusableWriteOffRequestNo,
|
||||||
|
type WriteOffRequestNoState
|
||||||
|
} from '@/utils/writeOffRequestNo'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const detail = ref<UserOrderDetail | null>(null)
|
const detail = ref<UserOrderDetail | null>(null)
|
||||||
const packageName = ref('')
|
const selectedItemId = ref('')
|
||||||
|
const writeOffRequestNoState = ref<WriteOffRequestNoState>({ targetKey: '', requestNo: '' })
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
|
||||||
const orderId = computed(() => String(route.query.orderId || ''))
|
const orderId = computed(() => String(route.query.orderId || ''))
|
||||||
const routePackageName = computed(() => String(route.query.packageName || '').trim())
|
const routePackageName = computed(() => String(route.query.packageName || '').trim())
|
||||||
const isScanPackageLocked = computed(() => String(route.query.source || '') === 'scan' && Boolean(routePackageName.value))
|
const routeWriteOffItemId = computed(() => String(route.query.orderWriteOffItemId || '').trim())
|
||||||
|
const isScanSource = computed(() => String(route.query.source || '') === 'scan')
|
||||||
const toNumber = (value: unknown) => {
|
const isScanItemLocked = computed(() => isScanSource.value && Boolean(routeWriteOffItemId.value))
|
||||||
const number = Number(value)
|
const hasScanMatchCriteria = computed(() => isScanSource.value && Boolean(routeWriteOffItemId.value || routePackageName.value))
|
||||||
return Number.isFinite(number) ? number : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPackageName = (item?: CommodityPackageConfig) =>
|
|
||||||
(item?.packageName || item?.name || detail.value?.commodityName || '').trim()
|
|
||||||
|
|
||||||
const packageConfigs = computed<CommodityPackageConfig[]>(() => {
|
const packageConfigs = computed<CommodityPackageConfig[]>(() => {
|
||||||
if (!detail.value) return []
|
if (!detail.value) return []
|
||||||
@@ -41,55 +52,62 @@ const packageConfigs = computed<CommodityPackageConfig[]>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const packageOptions = computed(() => {
|
const packageOptions = computed(() => {
|
||||||
if (!isScanPackageLocked.value) return packageConfigs.value
|
if (isScanSource.value && routeWriteOffItemId.value) {
|
||||||
return packageConfigs.value.filter((item) => getPackageName(item) === routePackageName.value)
|
return packageConfigs.value.filter((item) => getWriteOffItemId(item) === routeWriteOffItemId.value)
|
||||||
|
}
|
||||||
|
if (isScanSource.value && routePackageName.value) {
|
||||||
|
return packageConfigs.value.filter(
|
||||||
|
(item) =>
|
||||||
|
getWriteOffItemName(item, detail.value?.commodityName) === routePackageName.value ||
|
||||||
|
item.packageName === routePackageName.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return packageConfigs.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const packageMismatch = computed(() => isScanPackageLocked.value && packageOptions.value.length === 0)
|
const packageMismatch = computed(() => hasScanMatchCriteria.value && packageOptions.value.length === 0)
|
||||||
|
|
||||||
const selectedPackage = computed(() => {
|
const selectedPackage = computed(() => {
|
||||||
return packageOptions.value.find((item) => getPackageName(item) === packageName.value) || packageOptions.value[0]
|
return packageOptions.value.find((item) => getWriteOffItemId(item) === selectedItemId.value) || packageOptions.value[0]
|
||||||
})
|
})
|
||||||
|
|
||||||
const writtenOffByRecord = computed(() => {
|
const quantityInfo = computed(() => getWriteOffItemQuantity(selectedPackage.value))
|
||||||
if (!detail.value) return 0
|
|
||||||
const selectedName = getPackageName(selectedPackage.value)
|
|
||||||
return (detail.value.writeOffRecordList || []).filter((record) => {
|
|
||||||
if (!selectedName) return true
|
|
||||||
return !record.packageName || record.packageName === selectedName
|
|
||||||
}).length
|
|
||||||
})
|
|
||||||
|
|
||||||
const quantityInfo = computed(() => {
|
const selectedItemAvailability = computed(() => getWriteOffItemAvailability(selectedPackage.value))
|
||||||
const total = toNumber(selectedPackage.value?.count) ?? toNumber(detail.value?.commodityAmount) ?? 0
|
const selectedPackageName = computed(() => getWriteOffItemName(selectedPackage.value, detail.value?.commodityName))
|
||||||
const packageWriteOffCount = toNumber(selectedPackage.value?.writeOffCount)
|
|
||||||
const writtenOff =
|
|
||||||
packageWriteOffCount ??
|
|
||||||
(selectedPackage.value?.packageStatus === 1 && total > 0 ? total : writtenOffByRecord.value)
|
|
||||||
|
|
||||||
return {
|
|
||||||
total,
|
|
||||||
writtenOff,
|
|
||||||
writable: Math.max(total - writtenOff, 0),
|
|
||||||
unit: selectedPackage.value?.unit || ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
detail.value &&
|
detail.value &&
|
||||||
canWriteOff(detail.value.orderStatus) &&
|
canWriteOff(detail.value.orderStatus) &&
|
||||||
packageName.value &&
|
selectedItemId.value &&
|
||||||
!packageMismatch.value &&
|
!packageMismatch.value &&
|
||||||
quantityInfo.value.writable > 0
|
selectedItemAvailability.value.available
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatCount = (value: number) => `${value}${quantityInfo.value.unit || ''}`
|
const formatCount = (value: number) => `${value}${quantityInfo.value.unit || ''}`
|
||||||
|
const getErrorMessage = (error: unknown) => (error instanceof Error ? error.message : '核销失败')
|
||||||
|
|
||||||
|
const hasShownToast = (error: unknown) => {
|
||||||
|
return Boolean(error && typeof error === 'object' && (error as { toastShown?: boolean }).toastShown)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getItemLabel = (item: CommodityPackageConfig) => {
|
||||||
|
const quantity = getWriteOffItemQuantity(item)
|
||||||
|
const statusText = getWriteOffStatusText(item)
|
||||||
|
const parts = [`已核销 ${quantity.writtenOff}${quantity.unit}`, `剩余 ${quantity.writable}${quantity.unit}`]
|
||||||
|
if (statusText) parts.unshift(statusText)
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSelectItem = (item: CommodityPackageConfig) => {
|
||||||
|
return !isScanItemLocked.value && getWriteOffItemAvailability(item).available
|
||||||
|
}
|
||||||
|
|
||||||
const selectPackage = (item: CommodityPackageConfig) => {
|
const selectPackage = (item: CommodityPackageConfig) => {
|
||||||
if (isScanPackageLocked.value) return
|
if (!canSelectItem(item)) return
|
||||||
packageName.value = getPackageName(item)
|
selectedItemId.value = getWriteOffItemId(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDetail = async () => {
|
const loadDetail = async () => {
|
||||||
@@ -101,13 +119,13 @@ const loadDetail = async () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
detail.value = await fetchOrderDetail(orderId.value)
|
detail.value = await fetchOrderDetail(orderId.value)
|
||||||
const matchedPackage = routePackageName.value
|
const matchedPackage = findWriteOffItem(packageConfigs.value, {
|
||||||
? packageConfigs.value.find((item) => getPackageName(item) === routePackageName.value)
|
orderWriteOffItemId: routeWriteOffItemId.value,
|
||||||
: undefined
|
packageName: routePackageName.value
|
||||||
packageName.value =
|
})
|
||||||
routePackageName.value && (matchedPackage || isScanPackageLocked.value)
|
const firstAvailablePackage = getFirstAvailableWriteOffItem(packageOptions.value)
|
||||||
? routePackageName.value
|
const fallbackPackage = packageOptions.value[0] || packageConfigs.value[0]
|
||||||
: getPackageName(packageConfigs.value[0])
|
selectedItemId.value = getWriteOffItemId(matchedPackage || firstAvailablePackage || fallbackPackage)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -120,47 +138,52 @@ const submit = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (packageMismatch.value) {
|
if (packageMismatch.value) {
|
||||||
showToast('扫码套餐不属于当前订单')
|
showToast(routeWriteOffItemId.value ? '扫码明细不属于当前订单' : '扫码套餐不属于当前订单')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!packageName.value) {
|
if (!selectedItemId.value || !selectedPackage.value) {
|
||||||
showToast('请选择核销套餐')
|
showToast('请选择核销明细')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (quantityInfo.value.writable <= 0) {
|
if (!selectedItemAvailability.value.available) {
|
||||||
showToast('当前套餐商品已核销完')
|
showToast(selectedItemAvailability.value.reason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await showConfirmDialog({
|
await showConfirmDialog({
|
||||||
title: '确认核销',
|
title: '确认核销',
|
||||||
message: `订单 ${detail.value.orderId} 将核销「${packageName.value}」,核销后不可撤回。`
|
message: `订单 ${detail.value.orderId} 将核销「${selectedPackageName.value}」,核销后不可撤回。`
|
||||||
})
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
|
const requestNo = getReusableWriteOffRequestNo(
|
||||||
|
writeOffRequestNoState.value,
|
||||||
|
`${detail.value.orderId}:${selectedItemId.value}`
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
const result = await writeOffOrder({
|
const result = await writeOffOrder({
|
||||||
orderId: detail.value.orderId,
|
orderId: detail.value.orderId,
|
||||||
packageName: packageName.value
|
orderWriteOffItemId: selectedItemId.value,
|
||||||
|
writeOffRequestNo: requestNo
|
||||||
})
|
})
|
||||||
router.replace({
|
router.replace({
|
||||||
path: '/verify/result',
|
path: '/verify/result',
|
||||||
query: {
|
query: {
|
||||||
success: String(result.success),
|
success: String(result.success),
|
||||||
orderId: result.orderId,
|
orderId: result.orderId,
|
||||||
packageName: result.packageName,
|
orderWriteOffItemId: result.orderWriteOffItemId || selectedItemId.value,
|
||||||
|
packageName: selectedPackageName.value,
|
||||||
message: result.message,
|
message: result.message,
|
||||||
operatedAt: result.operatedAt
|
operatedAt: result.operatedAt
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
router.replace({
|
const message = getErrorMessage(error)
|
||||||
path: '/verify/result',
|
if (!hasShownToast(error)) {
|
||||||
query: {
|
showToast(message)
|
||||||
success: 'false',
|
|
||||||
orderId: detail.value.orderId,
|
|
||||||
packageName: packageName.value,
|
|
||||||
message: error instanceof Error ? error.message : '核销失败'
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -190,25 +213,41 @@ onMounted(loadDetail)
|
|||||||
|
|
||||||
<section class="panel detail-panel">
|
<section class="panel detail-panel">
|
||||||
<h2 class="section-title">核销内容</h2>
|
<h2 class="section-title">核销内容</h2>
|
||||||
<van-radio-group v-model="packageName">
|
<van-radio-group v-model="selectedItemId">
|
||||||
<van-cell-group class="package-group">
|
<van-cell-group class="package-group">
|
||||||
<van-cell v-if="packageMismatch" title="扫码套餐不属于当前订单" label="请返回重新扫码或联系管理员核对订单二维码" />
|
|
||||||
<van-cell
|
<van-cell
|
||||||
v-for="item in packageOptions"
|
v-if="packageMismatch"
|
||||||
:key="getPackageName(item)"
|
:title="routeWriteOffItemId ? '扫码明细不属于当前订单' : '扫码套餐不属于当前订单'"
|
||||||
:clickable="!isScanPackageLocked"
|
label="请返回重新扫码或联系管理员核对订单二维码"
|
||||||
|
/>
|
||||||
|
<van-cell
|
||||||
|
v-for="(item, index) in packageOptions"
|
||||||
|
:key="getWriteOffItemId(item) || `${getWriteOffItemName(item, detail.commodityName)}-${index}`"
|
||||||
|
:clickable="canSelectItem(item)"
|
||||||
@click="selectPackage(item)"
|
@click="selectPackage(item)"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="package-title">
|
<div class="package-title">
|
||||||
<ReceiptText :size="17" />
|
<ReceiptText :size="17" />
|
||||||
<span>{{ getPackageName(item) }}</span>
|
<span>{{ getWriteOffItemName(item, detail.commodityName) }}</span>
|
||||||
<van-tag v-if="isScanPackageLocked" type="success" plain>扫码指定</van-tag>
|
<van-tag v-if="isScanItemLocked" type="success" plain>扫码指定</van-tag>
|
||||||
|
<van-tag v-else-if="isScanSource && routePackageName" type="primary" plain>扫码匹配</van-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #label>
|
||||||
|
<div class="package-label">
|
||||||
|
<span v-if="getWriteOffItemContent(item)">{{ getWriteOffItemContent(item) }}</span>
|
||||||
|
<span>{{ getItemLabel(item) }}</span>
|
||||||
|
<span v-if="!getWriteOffItemAvailability(item).available" class="package-label__warning">
|
||||||
|
{{ getWriteOffItemAvailability(item).reason }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #label>{{ item.packageContent || item.packageDesc || item.content }}</template>
|
|
||||||
<template #right-icon>
|
<template #right-icon>
|
||||||
<van-radio :name="getPackageName(item)" :disabled="isScanPackageLocked" />
|
<van-radio
|
||||||
|
:name="getWriteOffItemId(item)"
|
||||||
|
:disabled="isScanItemLocked || !getWriteOffItemAvailability(item).available"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</van-cell>
|
</van-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
@@ -243,6 +282,10 @@ onMounted(loadDetail)
|
|||||||
<BadgeCheck :size="20" />
|
<BadgeCheck :size="20" />
|
||||||
<span>该订单当前不可核销,请返回订单详情核对状态。</span>
|
<span>该订单当前不可核销,请返回订单详情核对状态。</span>
|
||||||
</section>
|
</section>
|
||||||
|
<section v-else-if="selectedPackage && !selectedItemAvailability.available" class="panel warning-panel">
|
||||||
|
<BadgeCheck :size="20" />
|
||||||
|
<span>{{ selectedItemAvailability.reason }}</span>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -309,6 +352,15 @@ onMounted(loadDetail)
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.package-label {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-label__warning {
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
.warning-panel {
|
.warning-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const goConfirm = (payload: WriteOffCodePayload) => {
|
|||||||
query: {
|
query: {
|
||||||
orderId: payload.orderId,
|
orderId: payload.orderId,
|
||||||
source: 'scan',
|
source: 'scan',
|
||||||
|
...(payload.orderWriteOffItemId ? { orderWriteOffItemId: payload.orderWriteOffItemId } : {}),
|
||||||
...(payload.packageName ? { packageName: payload.packageName } : {})
|
...(payload.packageName ? { packageName: payload.packageName } : {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user