refactor(ticket): 优化水票功能实现逻辑

- 移除手动选择水票功能,改为自动按数量少优先消耗
- 新增 ticketLoaded 状态跟踪水票加载完成情况
- 实现 getTicketAvailableQty 函数统一处理不同租户的可用数量字段差异
- 修改水票过滤逻辑,支持多种状态字段格式并改进商品ID匹配
- 更新下单流程,将单个订单拆分为多个水票订单以支持批量消耗
- 优化水票弹窗界面显示可用总数和消耗顺序说明
- 移除选中水票的相关状态管理和UI组件
- 更新下单确认提示显示优先使用数量少的水票策略
This commit is contained in:
2026-02-26 13:23:17 +08:00
parent ef26a207b0
commit e40120138b

View File

@@ -68,9 +68,9 @@ const OrderConfirm = () => {
// 水票:用于“立即送水”下单(用水票抵扣,无需支付) // 水票:用于“立即送水”下单(用水票抵扣,无需支付)
const [tickets, setTickets] = useState<GltUserTicket[]>([]) const [tickets, setTickets] = useState<GltUserTicket[]>([])
const [selectedTicketId, setSelectedTicketId] = useState<number | undefined>(undefined)
const [ticketPopupVisible, setTicketPopupVisible] = useState(false) const [ticketPopupVisible, setTicketPopupVisible] = useState(false)
const [ticketLoading, setTicketLoading] = useState(false) const [ticketLoading, setTicketLoading] = useState(false)
const [ticketLoaded, setTicketLoaded] = useState(false)
const noTicketPromptedRef = useRef(false) const noTicketPromptedRef = useRef(false)
// Delivery range (geofence): block ordering if address/current location is outside. // Delivery range (geofence): block ordering if address/current location is outside.
@@ -95,15 +95,42 @@ const OrderConfirm = () => {
return Number.isFinite(id) && id > 0 ? id : undefined return Number.isFinite(id) && id > 0 ? id : undefined
}, []) }, [])
const getTicketAvailableQty = (t?: Partial<GltUserTicket> | null) => {
if (!t) return 0
const anyT: any = t
const raw =
anyT.availableQty ??
anyT.availableNum ??
anyT.availableCount ??
anyT.remainQty ??
anyT.remainNum ??
anyT.remainCount
const n = Number(raw)
if (Number.isFinite(n)) return n
// Fallback for tenants that don't return `availableQty`.
const total = Number(anyT.totalQty ?? anyT.totalNum ?? anyT.totalCount ?? 0)
const used = Number(anyT.usedQty ?? anyT.usedNum ?? anyT.usedCount ?? 0)
const frozen = Number(anyT.frozenQty ?? anyT.frozenNum ?? anyT.frozenCount ?? 0)
const computed = (Number.isFinite(total) ? total : 0) - (Number.isFinite(used) ? used : 0) - (Number.isFinite(frozen) ? frozen : 0)
return Number.isFinite(computed) ? computed : 0
}
const usableTickets = useMemo(() => { const usableTickets = useMemo(() => {
const list = (tickets || []) const list = (tickets || [])
.filter(t => t?.deleted !== 1) .filter(t => Number(t?.deleted) !== 1)
.filter(t => t?.status !== 1) // 1 = 冻结(兼容 status 为字符串)
.filter(t => Number.isFinite(Number(t?.id)) && Number(t.id) > 0) .filter(t => Number(t?.status) !== 1)
.filter(t => (t.availableQty ?? 0) > 0) .filter(t => Number.isFinite(Number(t?.id)) && Number(t?.id) > 0)
// Some tenants don't fill goodsId on ticket; allow it as a fallback. .filter(t => getTicketAvailableQty(t) > 0)
.filter(t => (numericGoodsId ? (!t.goodsId || t.goodsId === numericGoodsId) : true)) // Some tenants return goodsId as string; coerce before comparison.
// FIFO: use older tickets first (reduce disputes). .filter((t) => {
if (!numericGoodsId) return true
const tg = Number((t as any)?.goodsId)
const hasGoodsId = Number.isFinite(tg) && tg > 0
return !hasGoodsId || tg === numericGoodsId
})
// Default order in list: older first (reduce disputes). Real consumption order is computed separately.
return list.sort((a, b) => { return list.sort((a, b) => {
const ta = new Date(a.createTime || 0).getTime() || 0 const ta = new Date(a.createTime || 0).getTime() || 0
const tb = new Date(b.createTime || 0).getTime() || 0 const tb = new Date(b.createTime || 0).getTime() || 0
@@ -112,19 +139,28 @@ const OrderConfirm = () => {
}) })
}, [tickets, numericGoodsId]) }, [tickets, numericGoodsId])
const selectedTicket = useMemo(() => {
if (!selectedTicketId) return undefined
return usableTickets.find(t => Number(t.id) === Number(selectedTicketId))
}, [usableTickets, selectedTicketId])
const availableTicketTotal = useMemo(() => { const availableTicketTotal = useMemo(() => {
return Number(selectedTicket?.availableQty || 0) return usableTickets.reduce((sum, t) => sum + getTicketAvailableQty(t), 0)
}, [selectedTicket?.availableQty]) }, [usableTickets])
// Consume tickets with smaller available qty first; ties: older first.
const ticketsToConsume = useMemo(() => {
const list = [...usableTickets]
return list.sort((a, b) => {
const qa = getTicketAvailableQty(a)
const qb = getTicketAvailableQty(b)
if (qa !== qb) return qa - qb
const ta = new Date(a.createTime || 0).getTime() || 0
const tb = new Date(b.createTime || 0).getTime() || 0
if (ta !== tb) return ta - tb
return (a.id || 0) - (b.id || 0)
})
}, [usableTickets])
const noUsableTickets = useMemo(() => { const noUsableTickets = useMemo(() => {
// Only show "go buy tickets" guidance after we have finished loading. // Only show "go buy tickets" guidance after we have finished loading.
return !!userId && !ticketLoading && usableTickets.length === 0 return !!userId && ticketLoaded && !ticketLoading && usableTickets.length === 0
}, [ticketLoading, usableTickets.length, userId]) }, [ticketLoaded, ticketLoading, usableTickets.length, userId])
const maxQuantity = useMemo(() => { const maxQuantity = useMemo(() => {
const stockMax = goods?.stock ?? 999 const stockMax = goods?.stock ?? 999
@@ -420,11 +456,14 @@ const OrderConfirm = () => {
if (ticketLoading) return if (ticketLoading) return
if (!userId) { if (!userId) {
setTickets([]) setTickets([])
setTicketLoaded(false)
return return
} }
try { try {
setTicketLoading(true) setTicketLoading(true)
const list = await listGltUserTicket({ userId, status: 0 }) // Do not pass `status` here: some backends use different status semantics;
// we filter out frozen tickets on the client for compatibility.
const list = await listGltUserTicket({ userId })
setTickets(list || []) setTickets(list || [])
} catch (e) { } catch (e) {
console.error('获取水票失败:', e) console.error('获取水票失败:', e)
@@ -432,6 +471,7 @@ const OrderConfirm = () => {
Taro.showToast({ title: '获取水票失败', icon: 'none' }) Taro.showToast({ title: '获取水票失败', icon: 'none' })
} finally { } finally {
setTicketLoading(false) setTicketLoading(false)
setTicketLoaded(true)
} }
} }
@@ -465,15 +505,20 @@ const OrderConfirm = () => {
return return
} }
// Ensure ticket list is loaded.
if (ticketLoading) {
Taro.showToast({ title: '水票加载中,请稍后再试', icon: 'none' })
return
}
if (!ticketLoaded) {
await loadUserTickets()
}
const storeForOrder = await resolveStoreForOrder() const storeForOrder = await resolveStoreForOrder()
if (!storeForOrder?.id) { if (!storeForOrder?.id) {
Taro.showToast({ title: '未找到可配送门店,请先在首页选择门店或联系管理员配置门店坐标', icon: 'none' }) Taro.showToast({ title: '未找到可配送门店,请先在首页选择门店或联系管理员配置门店坐标', icon: 'none' })
return return
} }
if (!selectedTicket?.id) {
Taro.showToast({ title: '请选择水票', icon: 'none' })
return
}
if (availableTicketTotal <= 0) { if (availableTicketTotal <= 0) {
Taro.showToast({ title: '暂无可用水票', icon: 'none' }) Taro.showToast({ title: '暂无可用水票', icon: 'none' })
return return
@@ -507,7 +552,7 @@ const OrderConfirm = () => {
const confirmRes = await Taro.showModal({ const confirmRes = await Taro.showModal({
title: '确认下单', title: '确认下单',
content: `配送时间:${sendTimeText}\n将使用 ${finalQty} 张水票下单,送水 ${finalQty} 桶,是否确认?` content: `配送时间:${sendTimeText}\n将使用 ${finalQty} 张水票下单(优先使用可用数量少的水票),送水 ${finalQty} 桶,是否确认?`
}) })
if (!confirmRes.confirm) return if (!confirmRes.confirm) return
@@ -518,24 +563,41 @@ const OrderConfirm = () => {
// Best-effort auto dispatch rider. If it fails, backend/manual dispatch can still handle it. // Best-effort auto dispatch rider. If it fails, backend/manual dispatch can still handle it.
const autoRider = storeForOrder.id ? await resolveAutoRiderForStore(storeForOrder.id) : null const autoRider = storeForOrder.id ? await resolveAutoRiderForStore(storeForOrder.id) : null
await addGltTicketOrder({ // Split this "delivery" into multiple ticket orders (backend order model binds to one userTicketId).
userTicketId: selectedTicket.id, // Consume tickets with smaller available qty first.
storeId: storeForOrder.id, let remain = finalQty
addressId: address.id, let created = 0
totalNum: finalQty, for (const t of ticketsToConsume) {
buyerRemarks: orderRemark, if (remain <= 0) break
sendTime: dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss'), const avail = getTicketAvailableQty(t)
// Backend may take userId from token; pass-through is harmless if backend ignores it. const useQty = Math.min(remain, avail)
userId, if (useQty <= 0) continue
riderId: Number.isFinite(Number(autoRider?.userId)) ? Number(autoRider?.userId) : undefined, await addGltTicketOrder({
riderName: autoRider?.realName, userTicketId: Number(t.id),
riderPhone: autoRider?.mobile, storeId: storeForOrder.id,
comments: goods.name ? `立即送水:${goods.name}` : '立即送水' addressId: address.id,
}) totalNum: useQty,
buyerRemarks: orderRemark,
sendTime: dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss'),
// Backend may take userId from token; pass-through is harmless if backend ignores it.
userId,
riderId: Number.isFinite(Number(autoRider?.userId)) ? Number(autoRider?.userId) : undefined,
riderName: autoRider?.realName,
riderPhone: autoRider?.mobile,
comments: goods.name ? `立即送水:${goods.name}` : '立即送水'
})
remain -= useQty
created += 1
}
if (remain > 0) {
// Ticket counts might have changed between loading and submission.
throw new Error('水票可用次数不足,请刷新后重试')
}
await loadUserTickets() await loadUserTickets()
Taro.showToast({ title: '下单成功', icon: 'success' }) Taro.showToast({ title: created > 1 ? '下单成功(已拆分多张水票)' : '下单成功', icon: 'success' })
setTimeout(() => { setTimeout(() => {
// 跳转到“我的送水订单” // 跳转到“我的送水订单”
Taro.redirectTo({ url: '/user/ticket/index?tab=order' }) Taro.redirectTo({ url: '/user/ticket/index?tab=order' })
@@ -633,18 +695,6 @@ const OrderConfirm = () => {
}) })
}, [maxQuantity]) }, [maxQuantity])
// Auto-pick a default ticket (first usable) when ticket list changes.
useEffect(() => {
if (!usableTickets.length) {
setSelectedTicketId(undefined)
return
}
const currentValid = selectedTicketId && usableTickets.some(t => Number(t.id) === Number(selectedTicketId))
if (!currentValid) {
setSelectedTicketId(Number(usableTickets[0].id))
}
}, [usableTickets, selectedTicketId])
// If user has no usable tickets, proactively guide them to purchase (only once per page lifecycle). // If user has no usable tickets, proactively guide them to purchase (only once per page lifecycle).
useEffect(() => { useEffect(() => {
if (!noUsableTickets) return if (!noUsableTickets) return
@@ -772,42 +822,50 @@ const OrderConfirm = () => {
/> />
</CellGroup> </CellGroup>
<CellGroup> <CellGroup>
<Cell <Cell
title={( title={(
<View className="flex items-center gap-2"> <View className="flex items-center gap-2">
<Ticket className={'text-gray-500'}/> <Ticket className={'text-gray-500'}/>
<Text></Text> <Text></Text>
</View>
)}
extra={(
<View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>
{ticketLoading
? '加载中...'
: (selectedTicket
? `${selectedTicket.templateName || '水票'}(可用${selectedTicket.availableQty ?? 0}`
: (noUsableTickets ? '暂无可用水票' : '请选择')
)
}
</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View> </View>
)} )}
onClick={async () => { extra={(
if (ticketLoading) return <View className={'flex items-center gap-2'}>
if (noUsableTickets) { <View className={'text-gray-900'}>
const r = await Taro.showModal({ {ticketLoading
title: '暂无可用水票', ? '加载中...'
content: '您还没有可用水票,是否前往购买?', : (ticketLoaded
confirmText: '去购买', ? (noUsableTickets
cancelText: '暂不' ? '暂无可用水票'
}) : `可用合计 ${availableTicketTotal}${usableTickets.length}组)`
if (r.confirm) await goBuyTickets() )
return : '点击查看'
} )
setTicketPopupVisible(true) }
}} </View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
)}
onClick={async () => {
if (ticketLoading) return
if (!ticketLoaded) {
setTicketPopupVisible(true)
await loadUserTickets()
return
}
if (noUsableTickets) {
const r = await Taro.showModal({
title: '暂无可用水票',
content: '您还没有可用水票,是否前往购买?',
confirmText: '去购买',
cancelText: '暂不'
})
if (r.confirm) await goBuyTickets()
return
}
setTicketPopupVisible(true)
}}
/> />
{noUsableTickets && ( {noUsableTickets && (
<Cell <Cell
@@ -838,50 +896,50 @@ const OrderConfirm = () => {
)}/> )}/>
</CellGroup> </CellGroup>
{/* 水票明细弹窗 */} {/* 水票明细弹窗 */}
<Popup <Popup
visible={ticketPopupVisible} visible={ticketPopupVisible}
position="bottom" position="bottom"
style={{ height: '70vh' }} style={{ height: '70vh' }}
onClose={() => setTicketPopupVisible(false)} onClose={() => setTicketPopupVisible(false)}
> >
<View className="p-4"> <View className="p-4">
<View className="flex justify-between items-center mb-3"> <View className="flex justify-between items-center mb-3">
<Text className="text-base font-medium"></Text> <Text className="text-base font-medium"></Text>
<Text <Text
className="text-sm text-gray-500" className="text-sm text-gray-500"
onClick={() => setTicketPopupVisible(false)} onClick={() => setTicketPopupVisible(false)}
> >
</Text> </Text>
</View> </View>
{!!usableTickets.length && !ticketLoading && (
<View className="text-xs text-gray-500 mb-2">
<Text> {availableTicketTotal} 使</Text>
</View>
)}
{ticketLoading ? ( {ticketLoading ? (
<View className="py-10 text-center text-gray-500"> <View className="py-10 text-center text-gray-500">
<Text>...</Text> <Text>...</Text>
</View> </View>
) : ( ) : (
<> <>
{!!usableTickets.length ? ( {!!usableTickets.length ? (
<CellGroup> <CellGroup>
{usableTickets.map((t) => { {ticketsToConsume.map((t) => {
const active = selectedTicket?.id && Number(selectedTicket.id) === Number(t.id) return (
return ( <Cell
<Cell key={t.id}
key={t.id} title={<Text> {t.id}</Text>}
title={<Text className={active ? 'text-green-600' : ''}> {t.id}</Text>} description={t.orderNo ? `来源订单:${t.orderNo}` : ''}
description={t.orderNo ? `来源订单:${t.orderNo}` : ''} extra={<Text className="text-gray-700"> {getTicketAvailableQty(t)}</Text>}
extra={<Text className="text-gray-700"> {t.availableQty ?? 0}</Text>} onClick={() => setTicketPopupVisible(false)}
onClick={() => { />
setSelectedTicketId(Number(t.id)) )
setTicketPopupVisible(false) })}
Taro.showToast({ title: '水票已选择', icon: 'success' }) </CellGroup>
}} ) : (
/>
)
})}
</CellGroup>
) : (
<View className="py-10 text-center"> <View className="py-10 text-center">
<Empty description="暂无可用水票" /> <Empty description="暂无可用水票" />
<View className="mt-4 flex justify-center"> <View className="mt-4 flex justify-center">
@@ -890,11 +948,11 @@ const OrderConfirm = () => {
</Button> </Button>
</View> </View>
</View> </View>
)} )}
</> </>
)} )}
</View> </View>
</Popup> </Popup>
{/* 门店选择弹窗 */} {/* 门店选择弹窗 */}
<Popup <Popup
@@ -973,19 +1031,18 @@ const OrderConfirm = () => {
</Button> </Button>
) : ( ) : (
<Button <Button
type="success" type="success"
size="large" size="large"
loading={submitLoading || deliveryRangeChecking} loading={submitLoading || deliveryRangeChecking}
disabled={ disabled={
deliveryRangeChecking || deliveryRangeChecking ||
inDeliveryRange === false || inDeliveryRange === false ||
!selectedTicket?.id || availableTicketTotal <= 0 ||
availableTicketTotal <= 0 || !canStartOrder
!canStartOrder }
} onClick={onSubmit}
onClick={onSubmit} >
>
{deliveryRangeChecking {deliveryRangeChecking
? '校验配送范围...' ? '校验配送范围...'
: (inDeliveryRange === false ? '不在配送范围' : (submitLoading ? '提交中...' : '立即提交')) : (inDeliveryRange === false ? '不在配送范围' : (submitLoading ? '提交中...' : '立即提交'))