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 [selectedTicketId, setSelectedTicketId] = useState<number | undefined>(undefined)
const [ticketPopupVisible, setTicketPopupVisible] = useState(false)
const [ticketLoading, setTicketLoading] = useState(false)
const [ticketLoaded, setTicketLoaded] = useState(false)
const noTicketPromptedRef = useRef(false)
// 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
}, [])
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 list = (tickets || [])
.filter(t => t?.deleted !== 1)
.filter(t => t?.status !== 1)
.filter(t => Number.isFinite(Number(t?.id)) && Number(t.id) > 0)
.filter(t => (t.availableQty ?? 0) > 0)
// Some tenants don't fill goodsId on ticket; allow it as a fallback.
.filter(t => (numericGoodsId ? (!t.goodsId || t.goodsId === numericGoodsId) : true))
// FIFO: use older tickets first (reduce disputes).
.filter(t => Number(t?.deleted) !== 1)
// 1 = 冻结(兼容 status 为字符串)
.filter(t => Number(t?.status) !== 1)
.filter(t => Number.isFinite(Number(t?.id)) && Number(t?.id) > 0)
.filter(t => getTicketAvailableQty(t) > 0)
// Some tenants return goodsId as string; coerce before comparison.
.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) => {
const ta = new Date(a.createTime || 0).getTime() || 0
const tb = new Date(b.createTime || 0).getTime() || 0
@@ -112,19 +139,28 @@ const OrderConfirm = () => {
})
}, [tickets, numericGoodsId])
const selectedTicket = useMemo(() => {
if (!selectedTicketId) return undefined
return usableTickets.find(t => Number(t.id) === Number(selectedTicketId))
}, [usableTickets, selectedTicketId])
const availableTicketTotal = useMemo(() => {
return Number(selectedTicket?.availableQty || 0)
}, [selectedTicket?.availableQty])
return usableTickets.reduce((sum, t) => sum + getTicketAvailableQty(t), 0)
}, [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(() => {
// Only show "go buy tickets" guidance after we have finished loading.
return !!userId && !ticketLoading && usableTickets.length === 0
}, [ticketLoading, usableTickets.length, userId])
return !!userId && ticketLoaded && !ticketLoading && usableTickets.length === 0
}, [ticketLoaded, ticketLoading, usableTickets.length, userId])
const maxQuantity = useMemo(() => {
const stockMax = goods?.stock ?? 999
@@ -420,11 +456,14 @@ const OrderConfirm = () => {
if (ticketLoading) return
if (!userId) {
setTickets([])
setTicketLoaded(false)
return
}
try {
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 || [])
} catch (e) {
console.error('获取水票失败:', e)
@@ -432,6 +471,7 @@ const OrderConfirm = () => {
Taro.showToast({ title: '获取水票失败', icon: 'none' })
} finally {
setTicketLoading(false)
setTicketLoaded(true)
}
}
@@ -465,15 +505,20 @@ const OrderConfirm = () => {
return
}
// Ensure ticket list is loaded.
if (ticketLoading) {
Taro.showToast({ title: '水票加载中,请稍后再试', icon: 'none' })
return
}
if (!ticketLoaded) {
await loadUserTickets()
}
const storeForOrder = await resolveStoreForOrder()
if (!storeForOrder?.id) {
Taro.showToast({ title: '未找到可配送门店,请先在首页选择门店或联系管理员配置门店坐标', icon: 'none' })
return
}
if (!selectedTicket?.id) {
Taro.showToast({ title: '请选择水票', icon: 'none' })
return
}
if (availableTicketTotal <= 0) {
Taro.showToast({ title: '暂无可用水票', icon: 'none' })
return
@@ -507,7 +552,7 @@ const OrderConfirm = () => {
const confirmRes = await Taro.showModal({
title: '确认下单',
content: `配送时间:${sendTimeText}\n将使用 ${finalQty} 张水票下单,送水 ${finalQty} 桶,是否确认?`
content: `配送时间:${sendTimeText}\n将使用 ${finalQty} 张水票下单(优先使用可用数量少的水票),送水 ${finalQty} 桶,是否确认?`
})
if (!confirmRes.confirm) return
@@ -518,11 +563,20 @@ const OrderConfirm = () => {
// Best-effort auto dispatch rider. If it fails, backend/manual dispatch can still handle it.
const autoRider = storeForOrder.id ? await resolveAutoRiderForStore(storeForOrder.id) : null
// Split this "delivery" into multiple ticket orders (backend order model binds to one userTicketId).
// Consume tickets with smaller available qty first.
let remain = finalQty
let created = 0
for (const t of ticketsToConsume) {
if (remain <= 0) break
const avail = getTicketAvailableQty(t)
const useQty = Math.min(remain, avail)
if (useQty <= 0) continue
await addGltTicketOrder({
userTicketId: selectedTicket.id,
userTicketId: Number(t.id),
storeId: storeForOrder.id,
addressId: address.id,
totalNum: finalQty,
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.
@@ -532,10 +586,18 @@ const OrderConfirm = () => {
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()
Taro.showToast({ title: '下单成功', icon: 'success' })
Taro.showToast({ title: created > 1 ? '下单成功(已拆分多张水票)' : '下单成功', icon: 'success' })
setTimeout(() => {
// 跳转到“我的送水订单”
Taro.redirectTo({ url: '/user/ticket/index?tab=order' })
@@ -633,18 +695,6 @@ const OrderConfirm = () => {
})
}, [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).
useEffect(() => {
if (!noUsableTickets) return
@@ -777,7 +827,7 @@ const OrderConfirm = () => {
title={(
<View className="flex items-center gap-2">
<Ticket className={'text-gray-500'}/>
<Text></Text>
<Text></Text>
</View>
)}
extra={(
@@ -785,9 +835,12 @@ const OrderConfirm = () => {
<View className={'text-gray-900'}>
{ticketLoading
? '加载中...'
: (selectedTicket
? `${selectedTicket.templateName || '水票'}(可用${selectedTicket.availableQty ?? 0}`
: (noUsableTickets ? '暂无可用水票' : '请选择')
: (ticketLoaded
? (noUsableTickets
? '暂无可用水票'
: `可用合计 ${availableTicketTotal}${usableTickets.length}组)`
)
: '点击查看'
)
}
</View>
@@ -796,6 +849,11 @@ const OrderConfirm = () => {
)}
onClick={async () => {
if (ticketLoading) return
if (!ticketLoaded) {
setTicketPopupVisible(true)
await loadUserTickets()
return
}
if (noUsableTickets) {
const r = await Taro.showModal({
title: '暂无可用水票',
@@ -855,6 +913,11 @@ const OrderConfirm = () => {
</Text>
</View>
{!!usableTickets.length && !ticketLoading && (
<View className="text-xs text-gray-500 mb-2">
<Text> {availableTicketTotal} 使</Text>
</View>
)}
{ticketLoading ? (
<View className="py-10 text-center text-gray-500">
@@ -864,19 +927,14 @@ const OrderConfirm = () => {
<>
{!!usableTickets.length ? (
<CellGroup>
{usableTickets.map((t) => {
const active = selectedTicket?.id && Number(selectedTicket.id) === Number(t.id)
{ticketsToConsume.map((t) => {
return (
<Cell
key={t.id}
title={<Text className={active ? 'text-green-600' : ''}> {t.id}</Text>}
title={<Text> {t.id}</Text>}
description={t.orderNo ? `来源订单:${t.orderNo}` : ''}
extra={<Text className="text-gray-700"> {t.availableQty ?? 0}</Text>}
onClick={() => {
setSelectedTicketId(Number(t.id))
setTicketPopupVisible(false)
Taro.showToast({ title: '水票已选择', icon: 'success' })
}}
extra={<Text className="text-gray-700"> {getTicketAvailableQty(t)}</Text>}
onClick={() => setTicketPopupVisible(false)}
/>
)
})}
@@ -980,7 +1038,6 @@ const OrderConfirm = () => {
disabled={
deliveryRangeChecking ||
inDeliveryRange === false ||
!selectedTicket?.id ||
availableTicketTotal <= 0 ||
!canStartOrder
}