diff --git a/src/api/glt/gltTicketOrder/index.ts b/src/api/glt/gltTicketOrder/index.ts new file mode 100644 index 0000000..46693fb --- /dev/null +++ b/src/api/glt/gltTicketOrder/index.ts @@ -0,0 +1,101 @@ +import request from '@/utils/request'; +import type { ApiResult, PageResult } from '@/api/index'; +import type { GltTicketOrder, GltTicketOrderParam } from './model'; + +/** + * 分页查询送水订单 + */ +export async function pageGltTicketOrder(params: GltTicketOrderParam) { + const res = await request.get>>( + '/glt/glt-ticket-order/page', + params + ); + if (res.code === 0) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 查询送水订单列表 + */ +export async function listGltTicketOrder(params?: GltTicketOrderParam) { + const res = await request.get>( + '/glt/glt-ticket-order', + params + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 添加送水订单 + */ +export async function addGltTicketOrder(data: GltTicketOrder) { + const res = await request.post>( + '/glt/glt-ticket-order', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 修改送水订单 + */ +export async function updateGltTicketOrder(data: GltTicketOrder) { + const res = await request.put>( + '/glt/glt-ticket-order', + data + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 删除送水订单 + */ +export async function removeGltTicketOrder(id?: number) { + const res = await request.del>( + '/glt/glt-ticket-order/' + id + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 批量删除送水订单 + */ +export async function removeBatchGltTicketOrder(data: (number | undefined)[]) { + const res = await request.del>( + '/glt/glt-ticket-order/batch', + { + data + } + ); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} + +/** + * 根据id查询送水订单 + */ +export async function getGltTicketOrder(id: number) { + const res = await request.get>( + '/glt/glt-ticket-order/' + id + ); + if (res.code === 0 && res.data) { + return res.data; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/glt/gltTicketOrder/model/index.ts b/src/api/glt/gltTicketOrder/model/index.ts new file mode 100644 index 0000000..33e5152 --- /dev/null +++ b/src/api/glt/gltTicketOrder/model/index.ts @@ -0,0 +1,51 @@ +import type { PageParam } from '@/api/index'; + +/** + * 送水订单 + */ +export interface GltTicketOrder { + // + id?: number; + // 用户水票ID + userTicketId?: number; + // 门店ID + storeId?: number; + // 配送员 + riderId?: number; + // 仓库ID + warehouseId?: number; + // 关联收货地址 + addressId?: number; + // 收货地址 + address?: string; + // 买家留言 + buyerRemarks?: string; + // 用于统计 + price?: string; + // 购买数量 + totalNum?: number; + // 用户ID + userId?: number; + // 排序(数字越小越靠前) + sortNumber?: number; + // 备注 + comments?: string; + // 状态, 0正常, 1冻结 + status?: number; + // 是否删除, 0否, 1是 + deleted?: number; + // 租户id + tenantId?: number; + // 创建时间 + createTime?: string; + // 修改时间 + updateTime?: string; +} + +/** + * 送水订单搜索条件 + */ +export interface GltTicketOrderParam extends PageParam { + id?: number; + keywords?: string; +} diff --git a/src/app.config.ts b/src/app.config.ts index 035923d..b077560 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -55,6 +55,7 @@ export default { "points/points", "ticket/index", "ticket/use", + "ticket/orders/index", // "gift/index", // "gift/redeem", // "gift/detail", diff --git a/src/user/ticket/orders/index.config.ts b/src/user/ticket/orders/index.config.ts new file mode 100644 index 0000000..454fe17 --- /dev/null +++ b/src/user/ticket/orders/index.config.ts @@ -0,0 +1,6 @@ +export default definePageConfig({ + navigationBarTitleText: '我的送水订单', + navigationBarTextStyle: 'black', + navigationBarBackgroundColor: '#ffffff' +}) + diff --git a/src/user/ticket/orders/index.tsx b/src/user/ticket/orders/index.tsx new file mode 100644 index 0000000..f1affe2 --- /dev/null +++ b/src/user/ticket/orders/index.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react' +import Taro, { useDidShow } from '@tarojs/taro' +import { View, Text } from '@tarojs/components' +import { NavBar, Cell, CellGroup, InfiniteLoading, PullToRefresh, Empty, Loading } from '@nutui/nutui-react-taro' +import { ArrowLeft } from '@nutui/icons-react-taro' +import dayjs from 'dayjs' + +import { pageGltTicketOrder } from '@/api/glt/gltTicketOrder' +import type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model' + +const PAGE_SIZE = 10 + +export default function TicketOrdersPage() { + const [statusBarHeight, setStatusBarHeight] = useState(0) + const [list, setList] = useState([]) + const [loading, setLoading] = useState(false) + const [hasMore, setHasMore] = useState(true) + const [page, setPage] = useState(1) + + const userId = (() => { + const raw = Taro.getStorageSync('UserId') + const id = Number(raw) + return Number.isFinite(id) && id > 0 ? id : undefined + })() + + const reload = async (isRefresh = true) => { + if (loading) return + if (!userId) { + setList([]) + setHasMore(false) + return + } + + setLoading(true) + try { + const currentPage = isRefresh ? 1 : page + const res = await pageGltTicketOrder({ + page: currentPage, + limit: PAGE_SIZE, + userId + } as any) + + const resList = res?.list || [] + const next = isRefresh ? resList : [...list, ...resList] + setList(next) + + const total = typeof res?.count === 'number' ? res.count : next.length + setHasMore(next.length < total) + setPage(currentPage + 1) + } catch (e) { + console.error('获取送水订单失败:', e) + Taro.showToast({ title: '获取送水订单失败', icon: 'none' }) + setHasMore(false) + } finally { + setLoading(false) + } + } + + useEffect(() => { + Taro.getSystemInfo({ + success: (res) => setStatusBarHeight(res.statusBarHeight ?? 0) + }) + }, []) + + useDidShow(() => { + setPage(1) + setHasMore(true) + reload(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }) + + return ( + + + Taro.navigateBack()} />} + > + 我的送水订单 + + + + reload(true)}> + {list.length === 0 && !loading ? ( + + + + ) : ( + + {list.map((o) => { + const qty = Number(o.totalNum || 0) + const timeText = o.createTime ? dayjs(o.createTime).format('YYYY-MM-DD HH:mm') : '' + const addr = o.address || (o.addressId ? `地址ID:${o.addressId}` : '') + const remark = o.buyerRemarks || '' + return ( + + 送水 {qty || '-'} 桶 + {addr ? {addr} : null} + {remark ? 备注:{remark} : null} + + } + extra={{timeText}} + /> + ) + })} + + )} + + reload(false)} + loadingText={ + + + 加载中... + + } + loadMoreText={ + + {list.length === 0 ? '暂无数据' : '没有更多了'} + + } + /> + + + + ) +} diff --git a/src/user/ticket/use.tsx b/src/user/ticket/use.tsx index 952e9a8..2224340 100644 --- a/src/user/ticket/use.tsx +++ b/src/user/ticket/use.tsx @@ -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(null); @@ -58,6 +53,7 @@ const OrderConfirm = () => { // 水票:用于“立即送水”下单(用水票抵扣,无需支付) const [tickets, setTickets] = useState([]) + const [selectedTicketId, setSelectedTicketId] = useState(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 => { - 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={( - 可用水票 + 选择水票 )} extra={( - {availableTicketTotal} 张 + + {selectedTicket ? `${selectedTicket.templateName || '水票'}(可用${selectedTicket.availableQty ?? 0})` : '请选择'} + )} @@ -530,14 +451,21 @@ const OrderConfirm = () => { ) : ( - {usableTickets.map((t) => ( + {usableTickets.map((t) => { + const active = selectedTicket?.id && Number(selectedTicket.id) === Number(t.id) + return ( {t.templateName || '水票'}} + title={{t.templateName || '水票'}} description={t.orderNo ? `来源订单:${t.orderNo}` : ''} extra={可用 {t.availableQty ?? 0}} + onClick={() => { + setSelectedTicketId(Number(t.id)) + setTicketPopupVisible(false) + Taro.showToast({ title: '水票已选择', icon: 'success' }) + }} /> - ))} + )})} {!usableTickets.length && ( 暂无可用水票} /> )} @@ -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 ? '提交中...' : '立即提交'}