6 Commits

13 changed files with 92 additions and 561 deletions

View File

@@ -242,16 +242,11 @@ 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
} }
@@ -283,7 +278,7 @@ export const mockApi = {
return { return {
success: true, success: true,
orderId: payload.orderId, orderId: payload.orderId,
orderWriteOffItemId: payload.orderWriteOffItemId, packageName: payload.packageName,
message: '核销成功', message: '核销成功',
operatedAt: formatDateTime(new Date()) operatedAt: formatDateTime(new Date())
} }

View File

@@ -36,7 +36,7 @@ export const writeOffOrder = async (payload: WriteOffPayload): Promise<WriteOffR
return { return {
success: true, success: true,
orderId: payload.orderId, orderId: payload.orderId,
orderWriteOffItemId: payload.orderWriteOffItemId, packageName: payload.packageName,
message: '核销成功', message: '核销成功',
operatedAt: new Date().toISOString() operatedAt: new Date().toISOString()
} }

View File

@@ -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' | '3' export type CommodityTypeCode = '0' | '1' | '2'
export interface CommodityOrder { export interface CommodityOrder {
id: string id: string
@@ -52,7 +52,6 @@ export interface CommodityService {
} }
export interface CommodityPackageConfig { export interface CommodityPackageConfig {
orderWriteOffItemId?: string
packageName?: string packageName?: string
packageContent?: string packageContent?: string
packageDesc?: string packageDesc?: string
@@ -61,10 +60,6 @@ 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
} }
@@ -126,14 +121,12 @@ export interface UserOrderDetail {
export interface WriteOffPayload { export interface WriteOffPayload {
orderId: string orderId: string
orderWriteOffItemId: string packageName?: 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

View File

@@ -1,22 +0,0 @@
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'
})
})

View File

@@ -1,121 +0,0 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
getWriteOffItemAvailability,
getWriteOffItemQuantity,
getWriteOffStatusText,
resolveWriteOffItemSelection,
shouldShowManualSelectionHint
} from '../writeOffItem.ts'
const writeOffItemModule = await import('../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('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)
})
test('locks scanned v2 code to the specified write off item id', () => {
const selection = resolveWriteOffItemSelection(items, {
source: 'scan',
orderWriteOffItemId: 'item-2',
packageName: '双人套餐'
})
assert.equal(selection.locked, true)
assert.deepEqual(selection.options.map((item) => item.orderWriteOffItemId), ['item-2'])
assert.equal(selection.selectedItemId, 'item-2')
})
test('does not auto select when legacy package name matches multiple available items', () => {
const selection = resolveWriteOffItemSelection(items, {
source: 'scan',
packageName: '双人套餐'
})
assert.equal(selection.mismatch, false)
assert.equal(selection.requiresManualSelection, true)
assert.equal(selection.selectedItemId, '')
assert.deepEqual(selection.options.map((item) => item.orderWriteOffItemId), ['item-1', 'item-2'])
})
test('hides manual selection hint after an item is selected', () => {
const selection = resolveWriteOffItemSelection(items, {
source: 'scan',
packageName: '双人套餐'
})
assert.equal(shouldShowManualSelectionHint(selection, ''), true)
assert.equal(shouldShowManualSelectionHint(selection, 'item-1'), false)
})
test('does not export legacy package name finder', () => {
assert.equal('findWriteOffItem' in writeOffItemModule, false)
})
test('auto selects the only available item matched by legacy package name', () => {
const selection = resolveWriteOffItemSelection(
[
{ ...items[0], writeOffCount: 2 },
items[1]
],
{
source: 'scan',
packageName: '双人套餐'
}
)
assert.equal(selection.selectedItemId, 'item-2')
assert.equal(selection.requiresManualSelection, false)
})
test('default selection skips unavailable items', () => {
const selection = resolveWriteOffItemSelection([
{ ...items[0], writeOffCount: 2 },
items[1]
])
assert.equal(selection.selectedItemId, 'item-2')
})
test('infers partial write off status from quantity when status is 0', () => {
assert.equal(getWriteOffStatusText({ ...items[0], writeOffStatus: 0, writeOffCount: 0 }), '待核销')
assert.equal(getWriteOffStatusText({ ...items[0], writeOffStatus: 0, writeOffCount: 1 }), '部分核销')
assert.equal(getWriteOffStatusText({ ...items[0], writeOffStatus: 0, writeOffCount: 2 }), '已核销')
assert.equal(getWriteOffStatusText({ ...items[0], writeOffStatus: 1, writeOffCount: 0 }), '已核销')
})
test('does not override explicit non-zero write off status with quantity inference', () => {
assert.equal(getWriteOffStatusText({ ...items[0], writeOffStatus: 2, writeOffCount: 0 }), '部分核销')
assert.equal(getWriteOffStatusText({ ...items[0], writeOffStatus: 'PART_WRITE_OFF', writeOffCount: 0 }), '部分核销')
})

View File

@@ -1,29 +0,0 @@
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)
})

View File

@@ -29,8 +29,7 @@ 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> = {

View File

@@ -1,8 +1,6 @@
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) => {
@@ -19,30 +17,6 @@ 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() : ''

View File

@@ -1,164 +0,0 @@
import type { CommodityPackageConfig } from '@/types/order'
export interface WriteOffItemQuantity {
total: number
writtenOff: number
writable: number
unit: string
}
export interface WriteOffItemAvailability {
available: boolean
reason: string
}
export interface WriteOffItemSelectionInput {
source?: string
orderWriteOffItemId?: string
packageName?: string
fallbackName?: string
}
export interface WriteOffItemSelection {
options: CommodityPackageConfig[]
selectedItemId: string
locked: boolean
mismatch: boolean
requiresManualSelection: boolean
}
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 getFirstAvailableWriteOffItem = (items: CommodityPackageConfig[]) => {
return items.find((item) => getWriteOffItemAvailability(item).available)
}
const matchesWriteOffItemName = (item: CommodityPackageConfig, packageName: string, fallbackName = '') => {
return getWriteOffItemName(item, fallbackName) === packageName || item.packageName === packageName
}
export const resolveWriteOffItemSelection = (
items: CommodityPackageConfig[],
input: WriteOffItemSelectionInput = {}
): WriteOffItemSelection => {
const source = String(input.source || '')
const itemId = String(input.orderWriteOffItemId || '').trim()
const packageName = String(input.packageName || '').trim()
const isScan = source === 'scan'
const locked = isScan && Boolean(itemId)
let options = items
if (locked) {
options = items.filter((item) => getWriteOffItemId(item) === itemId)
} else if (isScan && packageName) {
options = items.filter((item) => matchesWriteOffItemName(item, packageName, input.fallbackName))
}
const mismatch = isScan && Boolean(itemId || packageName) && options.length === 0
const availableOptions = options.filter((item) => getWriteOffItemAvailability(item).available)
if (locked) {
return {
options,
selectedItemId: getWriteOffItemId(options[0]),
locked,
mismatch,
requiresManualSelection: false
}
}
if (isScan && packageName && availableOptions.length > 1) {
return {
options,
selectedItemId: '',
locked,
mismatch,
requiresManualSelection: true
}
}
const matchedById = itemId ? options.find((item) => getWriteOffItemId(item) === itemId) : undefined
const selectedItem = (
matchedById && getWriteOffItemAvailability(matchedById).available
? matchedById
: availableOptions[0] || matchedById || options[0]
)
return {
options,
selectedItemId: getWriteOffItemId(selectedItem),
locked,
mismatch,
requiresManualSelection: false
}
}
export const shouldShowManualSelectionHint = (selection: WriteOffItemSelection, selectedItemId: string) => {
return selection.requiresManualSelection && !String(selectedItemId || '').trim()
}
export const getWriteOffStatusText = (item?: CommodityPackageConfig) => {
const status = item?.writeOffStatus ?? item?.packageStatus
const key = String(status)
const writtenOff = toNumber(item?.writeOffCount)
const total = toNumber(item?.count)
if (key === '1') return '已核销'
if ((status === undefined || status === null || status === '' || key === '0') && (writtenOff !== undefined || total !== undefined)) {
const writtenOffCount = Math.max(writtenOff ?? 0, 0)
const totalCount = Math.max(total ?? 0, 0)
if (writtenOffCount <= 0) return '待核销'
if (totalCount > 0 && writtenOffCount >= totalCount) return '已核销'
return '部分核销'
}
if (status === undefined || status === null || status === '') return ''
return statusTextMap[key] || key
}

View File

@@ -1,21 +0,0 @@
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
}

View File

@@ -7,15 +7,6 @@ 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()
@@ -23,8 +14,10 @@ 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 firstWritableItem = computed(() => getFirstAvailableWriteOffItem(detail.value?.commodityPackageConfig || [])) const packageName = computed(() => {
const canGoWriteOff = computed(() => Boolean(detail.value && canWriteOff(detail.value.orderStatus) && firstWritableItem.value)) const first = detail.value?.commodityPackageConfig?.[0]
return first?.packageName || first?.name || detail.value?.commodityName || ''
})
const loadDetail = async () => { const loadDetail = async () => {
loading.value = true loading.value = true
@@ -36,13 +29,11 @@ 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,
orderWriteOffItemId: getWriteOffItemId(firstWritableItem.value), packageName: packageName.value
packageName: getWriteOffItemName(firstWritableItem.value, detail.value?.commodityName)
} }
}) })
} }
@@ -112,24 +103,11 @@ onMounted(loadDetail)
<section class="panel detail-panel"> <section class="panel detail-panel">
<h2 class="section-title">套餐内容</h2> <h2 class="section-title">套餐内容</h2>
<div <div v-for="item in detail.commodityPackageConfig" :key="item.packageName || item.name" class="package-row">
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>{{ getWriteOffItemName(item, detail.commodityName) }}</strong> <strong>{{ item.packageName || item.name || detail.commodityName }}</strong>
<p v-if="getWriteOffItemContent(item)">{{ getWriteOffItemContent(item) }}</p> <p>{{ item.packageContent || item.packageDesc || item.content }}</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>
@@ -155,9 +133,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" :disabled="!canGoWriteOff" @click="goWriteOff"> <van-button block type="primary" @click="goWriteOff">
<template #icon><CheckCircle2 :size="18" /></template> <template #icon><CheckCircle2 :size="18" /></template>
{{ canGoWriteOff ? '立即核销' : '暂无可核销明细' }} 立即核销
</van-button> </van-button>
</div> </div>
</footer> </footer>
@@ -264,10 +242,6 @@ 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);

View File

@@ -8,34 +8,25 @@ 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 {
getWriteOffItemAvailability,
getWriteOffItemContent,
getWriteOffItemId,
getWriteOffItemName,
getWriteOffItemQuantity,
getWriteOffStatusText,
resolveWriteOffItemSelection,
shouldShowManualSelectionHint,
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 selectedItemId = ref('') const packageName = 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 routeWriteOffItemId = computed(() => String(route.query.orderWriteOffItemId || '').trim()) const isScanPackageLocked = computed(() => String(route.query.source || '') === 'scan' && Boolean(routePackageName.value))
const isScanSource = computed(() => String(route.query.source || '') === 'scan')
const toNumber = (value: unknown) => {
const number = Number(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 []
@@ -49,61 +40,56 @@ const packageConfigs = computed<CommodityPackageConfig[]>(() => {
] ]
}) })
const itemSelection = computed(() => { const packageOptions = computed(() => {
return resolveWriteOffItemSelection(packageConfigs.value, { if (!isScanPackageLocked.value) return packageConfigs.value
source: String(route.query.source || ''), return packageConfigs.value.filter((item) => getPackageName(item) === routePackageName.value)
orderWriteOffItemId: routeWriteOffItemId.value,
packageName: routePackageName.value,
fallbackName: detail.value?.commodityName
})
}) })
const packageOptions = computed(() => itemSelection.value.options) const packageMismatch = computed(() => isScanPackageLocked.value && packageOptions.value.length === 0)
const isScanItemLocked = computed(() => itemSelection.value.locked)
const packageMismatch = computed(() => itemSelection.value.mismatch)
const showManualSelectionHint = computed(() => shouldShowManualSelectionHint(itemSelection.value, selectedItemId.value))
const selectedPackage = computed(() => { const selectedPackage = computed(() => {
return packageOptions.value.find((item) => getWriteOffItemId(item) === selectedItemId.value) return packageOptions.value.find((item) => getPackageName(item) === packageName.value) || packageOptions.value[0]
}) })
const quantityInfo = computed(() => getWriteOffItemQuantity(selectedPackage.value)) const writtenOffByRecord = computed(() => {
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 selectedItemAvailability = computed(() => getWriteOffItemAvailability(selectedPackage.value)) const quantityInfo = computed(() => {
const selectedPackageName = computed(() => getWriteOffItemName(selectedPackage.value, detail.value?.commodityName)) const total = toNumber(selectedPackage.value?.count) ?? toNumber(detail.value?.commodityAmount) ?? 0
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) &&
selectedItemId.value && packageName.value &&
!packageMismatch.value && !packageMismatch.value &&
selectedItemAvailability.value.available quantityInfo.value.writable > 0
) )
}) })
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 (!canSelectItem(item)) return if (isScanPackageLocked.value) return
selectedItemId.value = getWriteOffItemId(item) packageName.value = getPackageName(item)
} }
const loadDetail = async () => { const loadDetail = async () => {
@@ -115,7 +101,13 @@ const loadDetail = async () => {
loading.value = true loading.value = true
try { try {
detail.value = await fetchOrderDetail(orderId.value) detail.value = await fetchOrderDetail(orderId.value)
selectedItemId.value = itemSelection.value.selectedItemId const matchedPackage = routePackageName.value
? packageConfigs.value.find((item) => getPackageName(item) === routePackageName.value)
: undefined
packageName.value =
routePackageName.value && (matchedPackage || isScanPackageLocked.value)
? routePackageName.value
: getPackageName(packageConfigs.value[0])
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -128,52 +120,47 @@ const submit = async () => {
return return
} }
if (packageMismatch.value) { if (packageMismatch.value) {
showToast(routeWriteOffItemId.value ? '扫码明细不属于当前订单' : '扫码套餐不属于当前订单') showToast('扫码套餐不属于当前订单')
return return
} }
if (!selectedItemId.value || !selectedPackage.value) { if (!packageName.value) {
showToast('请选择核销明细') showToast('请选择核销套餐')
return return
} }
if (!selectedItemAvailability.value.available) { if (quantityInfo.value.writable <= 0) {
showToast(selectedItemAvailability.value.reason) showToast('当前套餐商品已核销完')
return return
} }
try {
await showConfirmDialog({ await showConfirmDialog({
title: '确认核销', title: '确认核销',
message: `订单 ${detail.value.orderId} 将核销「${selectedPackageName.value}」,核销后不可撤回。` message: `订单 ${detail.value.orderId} 将核销「${packageName.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,
orderWriteOffItemId: selectedItemId.value, packageName: packageName.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,
orderWriteOffItemId: result.orderWriteOffItemId || selectedItemId.value, packageName: result.packageName,
packageName: selectedPackageName.value,
message: result.message, message: result.message,
operatedAt: result.operatedAt operatedAt: result.operatedAt
} }
}) })
} catch (error) { } catch (error) {
const message = getErrorMessage(error) router.replace({
if (!hasShownToast(error)) { path: '/verify/result',
showToast(message) query: {
success: 'false',
orderId: detail.value.orderId,
packageName: packageName.value,
message: error instanceof Error ? error.message : '核销失败'
} }
})
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -203,41 +190,25 @@ 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="selectedItemId"> <van-radio-group v-model="packageName">
<van-cell-group class="package-group"> <van-cell-group class="package-group">
<van-cell v-if="packageMismatch" title="扫码套餐不属于当前订单" label="请返回重新扫码或联系管理员核对订单二维码" />
<van-cell <van-cell
v-if="packageMismatch" v-for="item in packageOptions"
:title="routeWriteOffItemId ? '扫码明细不属于当前订单' : '扫码套餐不属于当前订单'" :key="getPackageName(item)"
label="请返回重新扫码或联系管理员核对订单二维码" :clickable="!isScanPackageLocked"
/>
<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>{{ getWriteOffItemName(item, detail.commodityName) }}</span> <span>{{ getPackageName(item) }}</span>
<van-tag v-if="isScanItemLocked" type="success" plain>扫码指定</van-tag> <van-tag v-if="isScanPackageLocked" 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 <van-radio :name="getPackageName(item)" :disabled="isScanPackageLocked" />
:name="getWriteOffItemId(item)"
:disabled="isScanItemLocked || !getWriteOffItemAvailability(item).available"
/>
</template> </template>
</van-cell> </van-cell>
</van-cell-group> </van-cell-group>
@@ -272,14 +243,6 @@ onMounted(loadDetail)
<BadgeCheck :size="20" /> <BadgeCheck :size="20" />
<span>该订单当前不可核销请返回订单详情核对状态</span> <span>该订单当前不可核销请返回订单详情核对状态</span>
</section> </section>
<section v-else-if="showManualSelectionHint" class="panel warning-panel">
<BadgeCheck :size="20" />
<span>扫码套餐匹配到多个可核销明细请选择后再核销</span>
</section>
<section v-else-if="selectedPackage && !selectedItemAvailability.available" class="panel warning-panel">
<BadgeCheck :size="20" />
<span>{{ selectedItemAvailability.reason }}</span>
</section>
</template> </template>
</section> </section>
@@ -346,15 +309,6 @@ 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;

View File

@@ -57,7 +57,6 @@ 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 } : {})
} }
}) })