From e40120138bc1a2d9f9b013f1e3508c1feac89c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Thu, 26 Feb 2026 13:23:17 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ticket):=20=E4=BC=98=E5=8C=96=E6=B0=B4?= =?UTF-8?q?=E7=A5=A8=E5=8A=9F=E8=83=BD=E5=AE=9E=E7=8E=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除手动选择水票功能,改为自动按数量少优先消耗 - 新增 ticketLoaded 状态跟踪水票加载完成情况 - 实现 getTicketAvailableQty 函数统一处理不同租户的可用数量字段差异 - 修改水票过滤逻辑,支持多种状态字段格式并改进商品ID匹配 - 更新下单流程,将单个订单拆分为多个水票订单以支持批量消耗 - 优化水票弹窗界面显示可用总数和消耗顺序说明 - 移除选中水票的相关状态管理和UI组件 - 更新下单确认提示显示优先使用数量少的水票策略 --- src/user/ticket/use.tsx | 335 +++++++++++++++++++++++----------------- 1 file changed, 196 insertions(+), 139 deletions(-) diff --git a/src/user/ticket/use.tsx b/src/user/ticket/use.tsx index 83adf17..f1d5c22 100644 --- a/src/user/ticket/use.tsx +++ b/src/user/ticket/use.tsx @@ -68,9 +68,9 @@ const OrderConfirm = () => { // 水票:用于“立即送水”下单(用水票抵扣,无需支付) const [tickets, setTickets] = useState([]) - const [selectedTicketId, setSelectedTicketId] = useState(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 | 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 = () => { /> - - - - 选择水票 - - )} - extra={( - - - {ticketLoading - ? '加载中...' - : (selectedTicket - ? `${selectedTicket.templateName || '水票'}(可用${selectedTicket.availableQty ?? 0})` - : (noUsableTickets ? '暂无可用水票' : '请选择') - ) - } - - + + + + 水票明细 )} - 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={( + + + {ticketLoading + ? '加载中...' + : (ticketLoaded + ? (noUsableTickets + ? '暂无可用水票' + : `可用合计 ${availableTicketTotal}(${usableTickets.length}组)` + ) + : '点击查看' + ) + } + + + + )} + 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 && ( { )}/> - {/* 水票明细弹窗 */} - setTicketPopupVisible(false)} - > - - - 水票明细 + {/* 水票明细弹窗 */} + setTicketPopupVisible(false)} + > + + + 水票明细 setTicketPopupVisible(false)} > 关闭 - + + {!!usableTickets.length && !ticketLoading && ( + + 可用合计 {availableTicketTotal} 张;下单时将优先使用可用数量少的水票 + + )} - {ticketLoading ? ( - - 加载中... - - ) : ( - <> - {!!usableTickets.length ? ( - - {usableTickets.map((t) => { - const active = selectedTicket?.id && Number(selectedTicket.id) === Number(t.id) - return ( - 票号 {t.id}} - description={t.orderNo ? `来源订单:${t.orderNo}` : ''} - extra={可用 {t.availableQty ?? 0}} - onClick={() => { - setSelectedTicketId(Number(t.id)) - setTicketPopupVisible(false) - Taro.showToast({ title: '水票已选择', icon: 'success' }) - }} - /> - ) - })} - - ) : ( + {ticketLoading ? ( + + 加载中... + + ) : ( + <> + {!!usableTickets.length ? ( + + {ticketsToConsume.map((t) => { + return ( + 票号 {t.id}} + description={t.orderNo ? `来源订单:${t.orderNo}` : ''} + extra={可用 {getTicketAvailableQty(t)}} + onClick={() => setTicketPopupVisible(false)} + /> + ) + })} + + ) : ( @@ -890,11 +948,11 @@ const OrderConfirm = () => { - )} - - )} - - + )} + + )} + + {/* 门店选择弹窗 */} { 去购买水票 ) : ( -