forked from gxwebsoft/mp-10550
feat(ticket): 添加送水订单功能和页面
- 新增 ticket/orders/index 页面用于展示送水订单 - 添加 GltTicketOrder 相关数据模型定义 - 实现送水订单的增删改查 API 接口 - 在水票使用页面集成订单功能 - 添加水票选择逻辑优化 - 实现送水订单列表分页加载 - 集成下拉刷新和上拉加载更多功能
This commit is contained in:
@@ -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 ? '提交中...' : '立即提交'}
|
||||
|
||||
Reference in New Issue
Block a user