refactor(ticket): 优化水票功能实现逻辑
- 移除手动选择水票功能,改为自动按数量少优先消耗 - 新增 ticketLoaded 状态跟踪水票加载完成情况 - 实现 getTicketAvailableQty 函数统一处理不同租户的可用数量字段差异 - 修改水票过滤逻辑,支持多种状态字段格式并改进商品ID匹配 - 更新下单流程,将单个订单拆分为多个水票订单以支持批量消耗 - 优化水票弹窗界面显示可用总数和消耗顺序说明 - 移除选中水票的相关状态管理和UI组件 - 更新下单确认提示显示优先使用数量少的水票策略
This commit is contained in:
@@ -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,24 +563,41 @@ 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
|
||||
|
||||
await addGltTicketOrder({
|
||||
userTicketId: selectedTicket.id,
|
||||
storeId: storeForOrder.id,
|
||||
addressId: address.id,
|
||||
totalNum: finalQty,
|
||||
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}` : '立即送水'
|
||||
})
|
||||
// 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: Number(t.id),
|
||||
storeId: storeForOrder.id,
|
||||
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()
|
||||
|
||||
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
|
||||
@@ -772,42 +822,50 @@ const OrderConfirm = () => {
|
||||
/>
|
||||
</CellGroup>
|
||||
|
||||
<CellGroup>
|
||||
<Cell
|
||||
title={(
|
||||
<View className="flex items-center gap-2">
|
||||
<Ticket className={'text-gray-500'}/>
|
||||
<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}/>
|
||||
<CellGroup>
|
||||
<Cell
|
||||
title={(
|
||||
<View className="flex items-center gap-2">
|
||||
<Ticket className={'text-gray-500'}/>
|
||||
<Text>水票明细</Text>
|
||||
</View>
|
||||
)}
|
||||
onClick={async () => {
|
||||
if (ticketLoading) return
|
||||
if (noUsableTickets) {
|
||||
const r = await Taro.showModal({
|
||||
title: '暂无可用水票',
|
||||
content: '您还没有可用水票,是否前往购买?',
|
||||
confirmText: '去购买',
|
||||
cancelText: '暂不'
|
||||
})
|
||||
if (r.confirm) await goBuyTickets()
|
||||
return
|
||||
}
|
||||
setTicketPopupVisible(true)
|
||||
}}
|
||||
extra={(
|
||||
<View className={'flex items-center gap-2'}>
|
||||
<View className={'text-gray-900'}>
|
||||
{ticketLoading
|
||||
? '加载中...'
|
||||
: (ticketLoaded
|
||||
? (noUsableTickets
|
||||
? '暂无可用水票'
|
||||
: `可用合计 ${availableTicketTotal}(${usableTickets.length}组)`
|
||||
)
|
||||
: '点击查看'
|
||||
)
|
||||
}
|
||||
</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 && (
|
||||
<Cell
|
||||
@@ -838,50 +896,50 @@ const OrderConfirm = () => {
|
||||
)}/>
|
||||
</CellGroup>
|
||||
|
||||
{/* 水票明细弹窗 */}
|
||||
<Popup
|
||||
visible={ticketPopupVisible}
|
||||
position="bottom"
|
||||
style={{ height: '70vh' }}
|
||||
onClose={() => setTicketPopupVisible(false)}
|
||||
>
|
||||
<View className="p-4">
|
||||
<View className="flex justify-between items-center mb-3">
|
||||
<Text className="text-base font-medium">水票明细</Text>
|
||||
{/* 水票明细弹窗 */}
|
||||
<Popup
|
||||
visible={ticketPopupVisible}
|
||||
position="bottom"
|
||||
style={{ height: '70vh' }}
|
||||
onClose={() => setTicketPopupVisible(false)}
|
||||
>
|
||||
<View className="p-4">
|
||||
<View className="flex justify-between items-center mb-3">
|
||||
<Text className="text-base font-medium">水票明细</Text>
|
||||
<Text
|
||||
className="text-sm text-gray-500"
|
||||
onClick={() => setTicketPopupVisible(false)}
|
||||
>
|
||||
关闭
|
||||
</Text>
|
||||
</View>
|
||||
</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">
|
||||
<Text>加载中...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{!!usableTickets.length ? (
|
||||
<CellGroup>
|
||||
{usableTickets.map((t) => {
|
||||
const active = selectedTicket?.id && Number(selectedTicket.id) === Number(t.id)
|
||||
return (
|
||||
<Cell
|
||||
key={t.id}
|
||||
title={<Text className={active ? 'text-green-600' : ''}>票号 {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' })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</CellGroup>
|
||||
) : (
|
||||
{ticketLoading ? (
|
||||
<View className="py-10 text-center text-gray-500">
|
||||
<Text>加载中...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{!!usableTickets.length ? (
|
||||
<CellGroup>
|
||||
{ticketsToConsume.map((t) => {
|
||||
return (
|
||||
<Cell
|
||||
key={t.id}
|
||||
title={<Text>票号 {t.id}</Text>}
|
||||
description={t.orderNo ? `来源订单:${t.orderNo}` : ''}
|
||||
extra={<Text className="text-gray-700">可用 {getTicketAvailableQty(t)}</Text>}
|
||||
onClick={() => setTicketPopupVisible(false)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</CellGroup>
|
||||
) : (
|
||||
<View className="py-10 text-center">
|
||||
<Empty description="暂无可用水票" />
|
||||
<View className="mt-4 flex justify-center">
|
||||
@@ -890,11 +948,11 @@ const OrderConfirm = () => {
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Popup>
|
||||
|
||||
{/* 门店选择弹窗 */}
|
||||
<Popup
|
||||
@@ -973,19 +1031,18 @@ const OrderConfirm = () => {
|
||||
去购买水票
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="success"
|
||||
size="large"
|
||||
loading={submitLoading || deliveryRangeChecking}
|
||||
disabled={
|
||||
deliveryRangeChecking ||
|
||||
inDeliveryRange === false ||
|
||||
!selectedTicket?.id ||
|
||||
availableTicketTotal <= 0 ||
|
||||
!canStartOrder
|
||||
}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
<Button
|
||||
type="success"
|
||||
size="large"
|
||||
loading={submitLoading || deliveryRangeChecking}
|
||||
disabled={
|
||||
deliveryRangeChecking ||
|
||||
inDeliveryRange === false ||
|
||||
availableTicketTotal <= 0 ||
|
||||
!canStartOrder
|
||||
}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{deliveryRangeChecking
|
||||
? '校验配送范围...'
|
||||
: (inDeliveryRange === false ? '不在配送范围' : (submitLoading ? '提交中...' : '立即提交'))
|
||||
|
||||
Reference in New Issue
Block a user