feat(ticket): 添加送水订单功能和页面

- 新增 ticket/orders/index 页面用于展示送水订单
- 添加 GltTicketOrder 相关数据模型定义
- 实现送水订单的增删改查 API 接口
- 在水票使用页面集成订单功能
- 添加水票选择逻辑优化
- 实现送水订单列表分页加载
- 集成下拉刷新和上拉加载更多功能
This commit is contained in:
2026-02-05 19:17:40 +08:00
parent a1e5bf1c05
commit 96d1bb959e
6 changed files with 345 additions and 126 deletions

View File

@@ -23,13 +23,8 @@ import type {ShopStore} from "@/api/shop/shopStore/model";
import {getShopStore, listShopStore} from "@/api/shop/shopStore";
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model'
import { listGltUserTicket, updateGltUserTicket } from '@/api/glt/gltUserTicket'
import { addGltUserTicketLog } from '@/api/glt/gltUserTicketLog'
import { createOrder, listShopOrder } from '@/api/shop/shopOrder'
import type { OrderCreateRequest } from '@/api/shop/shopOrder/model'
// payType=12 in this project is "free order" (no payment). Used for water-ticket orders.
const PAY_TYPE_FREE = 12
import { listGltUserTicket } from '@/api/glt/gltUserTicket'
import { addGltTicketOrder } from '@/api/glt/gltTicketOrder'
const OrderConfirm = () => {
const [goods, setGoods] = useState<ShopGoods | null>(null);
@@ -58,6 +53,7 @@ const OrderConfirm = () => {
// 水票:用于“立即送水”下单(用水票抵扣,无需支付)
const [tickets, setTickets] = useState<GltUserTicket[]>([])
const [selectedTicketId, setSelectedTicketId] = useState<number | undefined>(undefined)
const [ticketPopupVisible, setTicketPopupVisible] = useState(false)
const [ticketLoading, setTicketLoading] = useState(false)
@@ -80,7 +76,8 @@ const OrderConfirm = () => {
.filter(t => t?.status !== 1)
.filter(t => Number.isFinite(Number(t?.id)) && Number(t.id) > 0)
.filter(t => (t.availableQty ?? 0) > 0)
.filter(t => (numericGoodsId ? t.goodsId === numericGoodsId : true))
// 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).
return list.sort((a, b) => {
const ta = new Date(a.createTime || 0).getTime() || 0
@@ -90,9 +87,14 @@ 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 usableTickets.reduce((sum, t) => sum + Number(t.availableQty || 0), 0)
}, [usableTickets])
return Number(selectedTicket?.availableQty || 0)
}, [selectedTicket?.availableQty])
const maxQuantity = useMemo(() => {
const stockMax = goods?.stock ?? 999
@@ -104,34 +106,6 @@ const OrderConfirm = () => {
return Math.max(1, Math.min(quantity, maxQuantity))
}, [quantity, maxQuantity])
type ConsumePlanItem = {
ticket: GltUserTicket
qty: number
availableAfter: number
usedAfter: number
}
const buildConsumePlan = (needQty: number): ConsumePlanItem[] => {
let remaining = Math.max(0, needQty)
const plan: ConsumePlanItem[] = []
for (const t of usableTickets) {
if (!remaining) break
const available = Number(t.availableQty || 0)
const used = Number(t.usedQty || 0)
if (available <= 0) continue
const take = Math.min(available, remaining)
remaining -= take
plan.push({
ticket: t,
qty: take,
availableAfter: available - take,
usedAfter: used + take
})
}
if (remaining > 0) return []
return plan
}
const loadStores = async () => {
if (storeLoading) return
try {
@@ -185,53 +159,6 @@ const OrderConfirm = () => {
}
}
const findOrderIdByOrderNo = async (orderNo: string): Promise<number | undefined> => {
try {
const list = await listShopOrder({ orderNo, userId } as any)
const first = (list || []).find(o => o?.orderNo === orderNo)
return first?.orderId
} catch (_e) {
return undefined
}
}
const consumeTicketsForOrder = async (
needQty: number,
orderNo: string,
orderId?: number
) => {
const plan = buildConsumePlan(needQty)
if (!plan.length) throw new Error('水票可用次数不足')
// NOTE: This is a client-side best-effort implementation.
// For strict consistency (order + ticket deduction + log in one transaction),
// please implement a backend API to do these steps atomically.
for (const item of plan) {
const t = item.ticket
const availableBefore = Number(t.availableQty || 0)
await updateGltUserTicket({
...t,
availableQty: item.availableAfter,
usedQty: item.usedAfter
})
// Write-off log (核销记录)
await addGltUserTicketLog({
userTicketId: t.id,
changeType: 2, // 约定2=消费/核销(若后端有枚举,请按后端约定调整)
changeAvailable: -item.qty,
changeUsed: item.qty,
availableAfter: item.availableAfter,
usedAfter: item.usedAfter,
orderId,
orderNo,
userId: userId || t.userId,
comments: `水票下单核销:${item.qty} 张(${availableBefore}${item.availableAfter}`
})
}
}
const onSubmit = async () => {
if (submitLoading) return
if (!goods?.goodsId) return
@@ -249,6 +176,10 @@ const OrderConfirm = () => {
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
return
}
if (!selectedTicket?.id) {
Taro.showToast({ title: '请选择水票', icon: 'none' })
return
}
if (availableTicketTotal <= 0) {
Taro.showToast({ title: '暂无可用水票', icon: 'none' })
return
@@ -278,41 +209,23 @@ const OrderConfirm = () => {
setSubmitLoading(true)
Taro.showLoading({ title: '提交中...' })
const orderData: OrderCreateRequest = {
goodsItems: [{ goodsId: goods.goodsId, quantity: finalQty }],
addressId: address.id,
await addGltTicketOrder({
userTicketId: selectedTicket.id,
storeId: selectedStore.id,
storeName: selectedStore.name,
payType: PAY_TYPE_FREE,
deliveryType: 0,
comments: orderRemark || '水票下单'
}
const res = await createOrder(orderData)
const orderNo = res?.orderNo
if (!orderNo) throw new Error('下单失败,请稍后重试')
const orderId = await findOrderIdByOrderNo(orderNo)
try {
await consumeTicketsForOrder(finalQty, orderNo, orderId)
} catch (consumeErr: any) {
console.error('订单已创建,但水票核销失败:', { orderNo, consumeErr })
await Taro.showModal({
title: '下单已成功',
content: `订单已创建(${orderNo}),但水票扣除/核销记录写入失败,请联系管理员处理。`,
showCancel: false
})
// 避免用户重复下单:直接跳转到订单列表查看处理结果
Taro.redirectTo({ url: '/user/order/order' })
return
}
addressId: address.id,
totalNum: finalQty,
buyerRemarks: orderRemark,
// Backend may take userId from token; pass-through is harmless if backend ignores it.
userId,
comments: goods.name ? `立即送水:${goods.name}` : '立即送水'
})
await loadUserTickets()
Taro.showToast({ title: '下单成功', icon: 'success' })
setTimeout(() => {
// 跳转到“我的送水订单”(当前项目使用“我的订单”页承载)
Taro.redirectTo({ url: '/user/order/order' })
// 跳转到“我的送水订单”
Taro.redirectTo({ url: '/user/ticket/orders/index' })
}, 800)
} catch (e: any) {
console.error('水票下单失败:', e)
@@ -348,12 +261,6 @@ const OrderConfirm = () => {
setAddress(addressRes[0])
}
await loadUserTickets()
// Clamp quantity after loading tickets/stock.
setQuantity(prev => {
const upper = maxQuantity > 0 ? maxQuantity : 1
return Math.max(1, Math.min(prev, upper))
})
} catch (err) {
console.error('加载数据失败:', err)
setError('加载数据失败,请重试')
@@ -381,6 +288,18 @@ 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])
// 重新加载数据
const handleRetry = () => {
loadAllData()
@@ -477,12 +396,14 @@ const OrderConfirm = () => {
title={(
<View className="flex items-center gap-2">
<Ticket className={'text-gray-500'}/>
<Text></Text>
<Text></Text>
</View>
)}
extra={(
<View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>{availableTicketTotal} </View>
<View className={'text-gray-900'}>
{selectedTicket ? `${selectedTicket.templateName || '水票'}(可用${selectedTicket.availableQty ?? 0}` : '请选择'}
</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
)}
@@ -530,14 +451,21 @@ const OrderConfirm = () => {
</View>
) : (
<CellGroup>
{usableTickets.map((t) => (
{usableTickets.map((t) => {
const active = selectedTicket?.id && Number(selectedTicket.id) === Number(t.id)
return (
<Cell
key={t.id}
title={<Text>{t.templateName || '水票'}</Text>}
title={<Text className={active ? 'text-green-600' : ''}>{t.templateName || '水票'}</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' })
}}
/>
))}
)})}
{!usableTickets.length && (
<Cell title={<Text className="text-gray-500"></Text>} />
)}
@@ -621,7 +549,7 @@ const OrderConfirm = () => {
type="success"
size="large"
loading={submitLoading}
disabled={availableTicketTotal <= 0 || maxQuantity <= 0}
disabled={!selectedTicket?.id || availableTicketTotal <= 0 || maxQuantity <= 0}
onClick={onSubmit}
>
{submitLoading ? '提交中...' : '立即提交'}