diff --git a/docs/水票配送订单-后端提示词.md b/docs/水票配送订单-后端提示词.md new file mode 100644 index 0000000..c064ea9 --- /dev/null +++ b/docs/水票配送订单-后端提示词.md @@ -0,0 +1,41 @@ +# 水票配送订单:后端提示词(可直接发给后端) + +## 1) 订单查询(配送员端) +请在 `GET /glt/glt-ticket-order/page` 支持以下筛选,并保证权限隔离: +- `riderId`:只返回该配送员的订单(必要) +- `deliveryStatus`:10待配送、20配送中、30待客户确认、40已完成(必要) +- 排序:建议 `sendTime asc` + `createTime desc`(或给前端一个可控排序字段) + +## 2) 配送流程字段(建议后端落库并回传) +订单表建议确保有以下字段(当前前端已按这些字段做流程判断/展示): +- `riderId/riderName/riderPhone`:配送员信息 +- `deliveryStatus`:10/20/30/40 +- `sendStartTime`:配送员点击“开始配送”的时间 +- `sendEndTime`:配送员点击“确认送达”的时间 +- `sendEndImg`:送达拍照留档图片 URL(可选/必填由后端策略决定) +- `receiveConfirmTime`:客户确认收货时间 +- `receiveConfirmType`:10客户手动确认、20配送照片自动确认、30超时自动确认 + +## 3) 状态流转与校验(强烈建议在后端做) +请在更新订单时做状态机校验,避免前端绕过流程: +- `10 -> 20`:仅允许订单属于当前配送员,且未开始/未送达 +- `20 -> 30`:配送员确认送达(可带 `sendEndImg`) +- `20/30 -> 40`:完成;来源可能是 + - 客户手动确认(写 `receiveConfirmTime` + `receiveConfirmType=10`) + - 配送照片直接完成(写 `receiveConfirmTime` + `receiveConfirmType=20`,并要求 `sendEndImg`) + - 超时自动确认(写 `receiveConfirmTime` + `receiveConfirmType=30`,建议由定时任务执行) + +## 4) 建议新增/明确的接口能力 +为了避免并发抢单/越权更新,建议新增更语义化的接口(或在 update 内做等价校验): +- 接单(抢单/派单):`POST /glt/glt-ticket-order/{id}/accept` + - 后端原子校验:仅当 `riderId is null` 才能写入当前 rider 信息 +- 开始配送:`POST /glt/glt-ticket-order/{id}/start`(写 `sendStartTime` + `deliveryStatus=20`) +- 确认送达:`POST /glt/glt-ticket-order/{id}/delivered`(写 `sendEndTime` + `deliveryStatus=30` + 可选 `sendEndImg`) +- 客户确认收货:`POST /glt/glt-ticket-order/{id}/confirm-receive` + - 校验:只能本人 `userId` 操作,且必须已送达 + +## 5) 为了“导航到收货地址/取货点”的字段补充(建议) +当前仅有 `address` 字符串,无法在小程序内 `openLocation` 精准导航;建议补充: +- 收货地址:`receiverName`、`receiverPhone`、`province/city/district/detail`、`latitude/longitude` +- 取货点(门店/仓库):`storeLatitude/storeLongitude` 或 `warehouseLatitude/warehouseLongitude` + diff --git a/src/api/glt/gltTicketOrder/model/index.ts b/src/api/glt/gltTicketOrder/model/index.ts index 03f155c..f64f474 100644 --- a/src/api/glt/gltTicketOrder/model/index.ts +++ b/src/api/glt/gltTicketOrder/model/index.ts @@ -1,4 +1,4 @@ -import type { PageParam } from '@/api/index'; +import type { PageParam } from '@/api'; /** * 送水订单 diff --git a/src/user/ticket/index.tsx b/src/user/ticket/index.tsx index 2656986..8c3ad2b 100644 --- a/src/user/ticket/index.tsx +++ b/src/user/ticket/index.tsx @@ -16,7 +16,7 @@ import { import { View, Text, Image } from '@tarojs/components'; import { pageGltUserTicket } from '@/api/glt/gltUserTicket'; import type { GltUserTicket } from '@/api/glt/gltUserTicket/model'; -import { pageGltTicketOrder } from '@/api/glt/gltTicketOrder'; +import { pageGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder'; import type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model'; import { BaseUrl } from '@/config/app'; import dayjs from "dayjs"; @@ -37,7 +37,10 @@ const UserTicketList = () => { const [orderPage, setOrderPage] = useState(1); const [orderTotal, setOrderTotal] = useState(0); - const [activeTab, setActiveTab] = useState<'ticket' | 'order'>('ticket'); + const [activeTab, setActiveTab] = useState<'ticket' | 'order'>(() => { + const tab = Taro.getCurrentInstance().router?.params?.tab + return tab === 'order' ? 'order' : 'ticket' + }); const [qrVisible, setQrVisible] = useState(false); const [qrTicket, setQrTicket] = useState(null); @@ -253,13 +256,56 @@ const UserTicketList = () => { return d.isValid() ? d.format('YYYY年MM月DD日 HH:mm:ss') : v; }; - const getOrderStatusText = (status?: number) => { - // Backend field meaning may vary; page asks for "是否送达". - if (status === 1) return { text: '已送达', type: 'success' as const }; - if (status === 0) return { text: '未送达', type: 'warning' as const }; - return { text: status == null ? '-' : String(status), type: 'primary' as const }; + const getTicketOrderStatusMeta = (order: GltTicketOrder) => { + if (order.status === 1) return { text: '已冻结', type: 'warning' as const }; + + const ds = order.deliveryStatus + if (ds === 40 || order.receiveConfirmTime) return { text: '已完成', type: 'success' as const }; + if (ds === 30 || order.sendEndTime) return { text: '待确认收货', type: 'primary' as const }; + if (ds === 20 || order.sendStartTime) return { text: '配送中', type: 'primary' as const }; + if (ds === 10 || order.riderId) return { text: '待配送', type: 'warning' as const }; + return { text: '待派单', type: 'primary' as const }; }; + const canUserConfirmReceive = (order: GltTicketOrder) => { + if (!order?.id) return false + if (order.status === 1) return false + if (order.deliveryStatus === 40) return false + if (order.receiveConfirmTime) return false + // 必须是“已送达”后才能确认收货 + return !!order.sendEndTime || order.deliveryStatus === 30 + } + + const handleUserConfirmReceive = async (order: GltTicketOrder) => { + if (!order?.id) return + if (!canUserConfirmReceive(order)) return + + const modal = await Taro.showModal({ + title: '确认收货', + content: '请确认已收到本次送水,确认后将无法撤销。', + confirmText: '确认收货' + }) + if (!modal.confirm) return + + try { + Taro.showLoading({ title: '提交中...' }) + const now = dayjs().format('YYYY-MM-DD HH:mm:ss') + await updateGltTicketOrder({ + id: order.id, + deliveryStatus: 40, + receiveConfirmTime: now, + receiveConfirmType: 10 + }) + Taro.showToast({ title: '已确认收货', icon: 'success' }) + await reloadOrders(true) + } catch (e) { + console.error('确认收货失败:', e) + Taro.showToast({ title: '确认失败,请重试', icon: 'none' }) + } finally { + Taro.hideLoading() + } + } + useDidShow(() => { if (activeTab === 'ticket') { reloadTickets(true).then(); @@ -436,10 +482,52 @@ const UserTicketList = () => { {(() => { - const meta = getOrderStatusText(item.status); + const meta = getTicketOrderStatusMeta(item); return {meta.text}; })()} + + 订单号:{item.id ?? '-'} + + + 收货地址:{item.address || '-'} + + {item.storeName ? ( + + 门店:{item.storeName} + + ) : null} + {item.sendStartTime ? ( + + 开始配送:{formatDateTime(item.sendStartTime)} + + ) : null} + {item.sendEndTime ? ( + + 送达时间:{formatDateTime(item.sendEndTime)} + + ) : null} + {item.receiveConfirmTime ? ( + + 确认收货:{formatDateTime(item.receiveConfirmTime)} + + ) : null} + {item.sendEndImg ? ( + + + + ) : null} + {canUserConfirmReceive(item) ? ( + + + + ) : null} ))} diff --git a/src/user/ticket/orders/index.tsx b/src/user/ticket/orders/index.tsx index 5f0a905..40b61c4 100644 --- a/src/user/ticket/orders/index.tsx +++ b/src/user/ticket/orders/index.tsx @@ -8,6 +8,8 @@ import { Space, Button, Dialog, + Radio, + RadioGroup, Image, Empty, InfiniteLoading, @@ -22,6 +24,8 @@ import { uploadFile } from '@/api/system/file' const PAGE_SIZE = 10 +type DeliverConfirmMode = 'photoComplete' | 'waitCustomerConfirm' + export default function TicketOrdersPage() { const riderId = useMemo(() => { const raw = Taro.getStorageSync('UserId') @@ -42,6 +46,7 @@ export default function TicketOrdersPage() { const [deliverSubmitting, setDeliverSubmitting] = useState(false) const [deliverOrder, setDeliverOrder] = useState(null) const [deliverImg, setDeliverImg] = useState(undefined) + const [deliverConfirmMode, setDeliverConfirmMode] = useState('photoComplete') const riderTabs = useMemo( () => [ @@ -95,7 +100,21 @@ export default function TicketOrdersPage() { if (!riderId || order.riderId !== riderId) return false if (order.receiveConfirmTime) return false if (order.deliveryStatus === 40) return false - return !order.sendEndTime + if (order.sendEndTime) return false + + // 只允许在“配送中”阶段确认送达 + if (typeof order.deliveryStatus === 'number') return order.deliveryStatus === 20 + return !!order.sendStartTime + } + + const canCompleteByPhoto = (order: GltTicketOrder) => { + if (!order.id) return false + if (order.status === 1) return false + if (!riderId || order.riderId !== riderId) return false + if (order.receiveConfirmTime) return false + if (order.deliveryStatus === 40) return false + // 已送达但未完成:允许补传照片并直接完成 + return !!order.sendEndTime } const filterByTab = useCallback( @@ -173,9 +192,10 @@ export default function TicketOrdersPage() { await reload(false) }, [hasMore, loading, reload]) - const openDeliverDialog = (order: GltTicketOrder) => { + const openDeliverDialog = (order: GltTicketOrder, opts?: { mode?: DeliverConfirmMode }) => { setDeliverOrder(order) setDeliverImg(order.sendEndImg) + setDeliverConfirmMode(opts?.mode || (order.sendEndImg ? 'photoComplete' : 'waitCustomerConfirm')) setDeliverDialogVisible(true) } @@ -210,22 +230,41 @@ export default function TicketOrdersPage() { const handleConfirmDelivered = async () => { if (!deliverOrder?.id) return if (deliverSubmitting) return + if (deliverConfirmMode === 'photoComplete' && !deliverImg) { + Taro.showToast({ title: '请先拍照/上传送达照片', icon: 'none' }) + return + } setDeliverSubmitting(true) try { + const now = dayjs().format('YYYY-MM-DD HH:mm:ss') + // 送达时间:首次“确认送达”写入;补传照片时不要覆盖原送达时间 + const deliveredAt = deliverOrder.sendEndTime || now // 说明: - // - sendEndImg:送达照片留档(可选/必填由后端策略决定) - // - sendEndTime:配送员确认送达时间 - // - deliveryStatus:建议后端设置为 30(待客户确认) - await updateGltTicketOrder({ - id: deliverOrder.id, - deliveryStatus: 30, - sendEndTime: dayjs().format('YYYY-MM-DD HH:mm:ss'), - sendEndImg: deliverImg - }) + // - waitCustomerConfirm:只标记“已送达”,进入待客户确认(客户点击确认收货后完成) + // - photoComplete:拍照留档后可直接完成(由后端策略决定是否允许) + const payload: GltTicketOrder = + deliverConfirmMode === 'photoComplete' + ? { + id: deliverOrder.id, + deliveryStatus: 40, + sendEndTime: deliveredAt, + sendEndImg: deliverImg, + receiveConfirmTime: now, + receiveConfirmType: 20 + } + : { + id: deliverOrder.id, + deliveryStatus: 30, + sendEndTime: deliveredAt, + sendEndImg: deliverImg + } + + await updateGltTicketOrder(payload) Taro.showToast({ title: '已确认送达', icon: 'success' }) setDeliverDialogVisible(false) setDeliverOrder(null) setDeliverImg(undefined) + setDeliverConfirmMode('photoComplete') pageRef.current = 1 await reload(true) } catch (e) { @@ -320,13 +359,19 @@ export default function TicketOrdersPage() { 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 || o.comments || '' + const ticketNo = o.userTicketId || '-' const flow1Done = !!o.riderId - const flow2Done = !!o.sendStartTime || (typeof o.deliveryStatus === 'number' && o.deliveryStatus >= 20) - const flow3Done = !!o.sendEndTime + const flow2Done = + !!o.sendStartTime || (typeof o.deliveryStatus === 'number' && o.deliveryStatus >= 20) + const flow3Done = + !!o.sendEndTime || (typeof o.deliveryStatus === 'number' && o.deliveryStatus >= 30) const flow4Done = !!o.receiveConfirmTime || o.deliveryStatus === 40 const phoneToCall = o.phone + const storePhone = o.storePhone + const pickupName = o.warehouseName || o.storeName + const pickupAddr = o.warehouseAddress || o.storeAddress return ( @@ -337,6 +382,7 @@ export default function TicketOrdersPage() { 下单时间:{timeText} + 票号:{ticketNo} @@ -349,6 +395,16 @@ export default function TicketOrdersPage() { {o.nickname || '-'} {o.phone ? `(${o.phone})` : ''} + + 取货点: + {pickupName || '-'} + + {pickupAddr ? ( + + 取货地址: + {pickupAddr} + + ) : null} 预约配送: {o.sendTime ? dayjs(o.sendTime).format('YYYY-MM-DD HH:mm') : '-'} @@ -359,6 +415,12 @@ export default function TicketOrdersPage() { 门店: {o.storeName || '-'} + {o.storePhone ? ( + + 门店电话: + {o.storePhone} + + ) : null} {remark ? ( 备注: @@ -402,7 +464,7 @@ export default function TicketOrdersPage() { {'>'} 3 送达留档 {'>'} - 4 客户确认收货 + 4 完成 @@ -418,6 +480,29 @@ export default function TicketOrdersPage() { 联系客户 )} + {!!addr && addr !== '-' && ( + + )} + {!!storePhone && ( + + )} {canStartDeliver(o) && ( )} + {canCompleteByPhoto(o) && ( + + )} @@ -455,7 +552,13 @@ export default function TicketOrdersPage() { { @@ -463,10 +566,18 @@ export default function TicketOrdersPage() { setDeliverDialogVisible(false) setDeliverOrder(null) setDeliverImg(undefined) + setDeliverConfirmMode('photoComplete') }} > - 到达收货点后,可拍照留档(推荐/可设为必填),再点确认送达。 + 到达收货点后,可选择“拍照留档直接完成”或“等待客户确认收货”。 + + + setDeliverConfirmMode(v as DeliverConfirmMode)}> + 拍照留档(直接完成) + 客户确认收货(可不拍照) + + diff --git a/src/user/ticket/use.tsx b/src/user/ticket/use.tsx index 2a0ea1e..ae3df9d 100644 --- a/src/user/ticket/use.tsx +++ b/src/user/ticket/use.tsx @@ -250,7 +250,7 @@ const OrderConfirm = () => { Taro.showToast({ title: '下单成功', icon: 'success' }) setTimeout(() => { // 跳转到“我的送水订单” - Taro.redirectTo({ url: '/user/ticket/orders/index' }) + Taro.redirectTo({ url: '/user/ticket/index?tab=order' }) }, 800) } catch (e: any) { console.error('水票下单失败:', e)