diff --git a/apps/mini-user/src/components/LogisticsRichText.tsx b/apps/mini-user/src/components/LogisticsRichText.tsx index 3854a2b..752a70f 100644 --- a/apps/mini-user/src/components/LogisticsRichText.tsx +++ b/apps/mini-user/src/components/LogisticsRichText.tsx @@ -1,6 +1,6 @@ import { Text } from '@tarojs/components'; import Taro from '@tarojs/taro'; -import { splitLogisticsText } from '../lib/order-logistics'; +import { splitLogisticsText } from '../lib/order-logistics-utils'; import { toast } from '../lib/api'; type LogisticsRichTextProps = { diff --git a/apps/mini-user/src/lib/order-logistics-utils.ts b/apps/mini-user/src/lib/order-logistics-utils.ts new file mode 100644 index 0000000..3771fe1 --- /dev/null +++ b/apps/mini-user/src/lib/order-logistics-utils.ts @@ -0,0 +1,114 @@ +import type { OrderTrackDto } from '@dukang/shared-types'; +import { isXfxProviderCode } from '@dukang/shared-types'; +import { formatShanghaiDateTime } from './datetime'; + +export type OrderDelivery = { + provider?: string; + trackingNo?: string; + logisticsCompany?: string; + manualQueryUrl?: string; +}; + +export type OrderTrackNode = { + trackInfo?: string; + createTime?: string; + createdAt?: string; + statusName?: string; +}; + +export type OrderTrackEstimatedArrival = { + arriveTime: string; + siteName?: string; +}; + +export type OrderTrackResponse = OrderTrackDto; + +/** 订单详情页展示物流摘要的状态(配送中) */ +export const ORDER_LOGISTICS_PREVIEW_STATUSES = ['SHIPPING', 'SHIPPED'] as const; + +const PHONE_PATTERN = /(1[3-9]\d{9}|\d{3,4}-\d{7,8})/g; + +function compareTrackNodeTime(a: OrderTrackNode, b: OrderTrackNode) { + const ta = new Date(a.createTime || a.createdAt || 0).getTime(); + const tb = new Date(b.createTime || b.createdAt || 0).getTime(); + if (Number.isNaN(ta) && Number.isNaN(tb)) return 0; + if (Number.isNaN(ta)) return 1; + if (Number.isNaN(tb)) return -1; + return ta - tb; +} + +export function deliveryProviderLabel(provider?: string, company?: string) { + if (company) return company; + if (!provider || provider === 'MOCK' || isXfxProviderCode(provider)) { + return '小飞侠配送'; + } + if (provider === 'LOGISTICS') return '快递配送'; + return provider; +} + +export function shouldLoadOrderTrack(delivery?: OrderDelivery | null) { + if (!delivery) return false; + if (delivery.trackingNo) return true; + return isXfxProviderCode(delivery.provider || ''); +} + +export function formatTrackNodeTime(node: OrderTrackNode) { + return formatShanghaiDateTime(node.createTime || node.createdAt); +} + +export function sortTrackNodesNewestFirst(nodes: OrderTrackNode[]): OrderTrackNode[] { + return [...nodes].sort((a, b) => -compareTrackNodeTime(a, b)); +} + +export function sortTrackNodesOldestFirst(nodes: OrderTrackNode[]): OrderTrackNode[] { + return [...nodes].sort(compareTrackNodeTime); +} + +export function latestTrackNode(nodes: OrderTrackNode[]) { + if (!nodes.length) return null; + let latest = nodes[0]; + for (let i = 1; i < nodes.length; i += 1) { + if (compareTrackNodeTime(nodes[i], latest) > 0) { + latest = nodes[i]; + } + } + return latest; +} + +export function isLogisticsNotArrived(nodes: OrderTrackNode[], orderStatus?: string) { + if (['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(orderStatus || '')) { + return false; + } + if (nodes.length === 0) return true; + return !nodes.some( + (node) => node.statusName?.includes('签收') || node.trackInfo?.includes('签收'), + ); +} + +export function formatEstimatedArrival(eta?: OrderTrackEstimatedArrival | null) { + if (!eta?.arriveTime) return ''; + const time = formatShanghaiDateTime(eta.arriveTime); + if (eta.siteName) return `预计送达:${time}(${eta.siteName})`; + return `预计送达:${time}`; +} + +export type LogisticsTextSegment = { type: 'text' | 'phone'; value: string }; + +export function splitLogisticsText(text: string): LogisticsTextSegment[] { + if (!text) return []; + const segments: LogisticsTextSegment[] = []; + let lastIndex = 0; + const regex = new RegExp(PHONE_PATTERN.source, 'g'); + let match: RegExpExecArray | null; + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + segments.push({ type: 'text', value: text.slice(lastIndex, match.index) }); + } + segments.push({ type: 'phone', value: match[0] }); + lastIndex = match.index + match[0].length; + } + if (lastIndex < text.length) { + segments.push({ type: 'text', value: text.slice(lastIndex) }); + } + return segments.length ? segments : [{ type: 'text', value: text }]; +} diff --git a/apps/mini-user/src/lib/order-logistics.ts b/apps/mini-user/src/lib/order-logistics.ts index 361fa54..695130a 100644 --- a/apps/mini-user/src/lib/order-logistics.ts +++ b/apps/mini-user/src/lib/order-logistics.ts @@ -1,118 +1,7 @@ -import type { OrderTrackDto } from '@dukang/shared-types'; -import { isXfxProviderCode } from '@dukang/shared-types'; -import { formatShanghaiDateTime } from './datetime'; +export * from './order-logistics-utils'; +import type { OrderTrackResponse } from './order-logistics-utils'; import { request } from './api'; -export type OrderDelivery = { - provider?: string; - trackingNo?: string; - logisticsCompany?: string; - manualQueryUrl?: string; -}; - -export type OrderTrackNode = { - trackInfo?: string; - createTime?: string; - createdAt?: string; - statusName?: string; -}; - -export type OrderTrackEstimatedArrival = { - arriveTime: string; - siteName?: string; -}; - -export type OrderTrackResponse = OrderTrackDto; - -/** 订单详情页展示物流摘要的状态(配送中) */ -export const ORDER_LOGISTICS_PREVIEW_STATUSES = ['SHIPPING', 'SHIPPED'] as const; - -const PHONE_PATTERN = /(1[3-9]\d{9}|\d{3,4}-\d{7,8})/g; - -export function deliveryProviderLabel(provider?: string, company?: string) { - if (company) return company; - if (!provider || provider === 'MOCK' || isXfxProviderCode(provider)) { - return '小飞侠配送'; - } - if (provider === 'LOGISTICS') return '快递配送'; - return provider; -} - -export function shouldLoadOrderTrack(delivery?: OrderDelivery | null) { - if (!delivery) return false; - if (delivery.trackingNo) return true; - return isXfxProviderCode(delivery.provider || ''); -} - -export function formatTrackNodeTime(node: OrderTrackNode) { - return formatShanghaiDateTime(node.createTime || node.createdAt); -} - -export function sortTrackNodesNewestFirst(nodes: OrderTrackNode[]) { - return [...nodes].sort((a, b) => { - const ta = new Date(a.createTime || a.createdAt || 0).getTime(); - const tb = new Date(b.createTime || b.createdAt || 0).getTime(); - if (Number.isNaN(ta) && Number.isNaN(tb)) return 0; - if (Number.isNaN(ta)) return 1; - if (Number.isNaN(tb)) return -1; - return tb - ta; - }); -} - -export function sortTrackNodesOldestFirst(nodes: OrderTrackNode[]) { - return [...nodes].sort((a, b) => { - const ta = new Date(a.createTime || a.createdAt || 0).getTime(); - const tb = new Date(b.createTime || b.createdAt || 0).getTime(); - if (Number.isNaN(ta) && Number.isNaN(tb)) return 0; - if (Number.isNaN(ta)) return 1; - if (Number.isNaN(tb)) return -1; - return ta - tb; - }); -} - -export function latestTrackNode(nodes: OrderTrackNode[]) { - const sorted = sortTrackNodesNewestFirst(nodes); - return sorted[0] ?? null; -} - -export function isLogisticsNotArrived(nodes: OrderTrackNode[], orderStatus?: string) { - if (['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(orderStatus || '')) { - return false; - } - if (nodes.length === 0) return true; - return !nodes.some( - (node) => node.statusName?.includes('签收') || node.trackInfo?.includes('签收'), - ); -} - -export function formatEstimatedArrival(eta?: OrderTrackEstimatedArrival | null) { - if (!eta?.arriveTime) return ''; - const time = formatShanghaiDateTime(eta.arriveTime); - if (eta.siteName) return `预计送达:${time}(${eta.siteName})`; - return `预计送达:${time}`; -} - -export type LogisticsTextSegment = { type: 'text' | 'phone'; value: string }; - -export function splitLogisticsText(text: string): LogisticsTextSegment[] { - if (!text) return []; - const segments: LogisticsTextSegment[] = []; - let lastIndex = 0; - const regex = new RegExp(PHONE_PATTERN.source, 'g'); - let match: RegExpExecArray | null; - while ((match = regex.exec(text)) !== null) { - if (match.index > lastIndex) { - segments.push({ type: 'text', value: text.slice(lastIndex, match.index) }); - } - segments.push({ type: 'phone', value: match[0] }); - lastIndex = match.index + match[0].length; - } - if (lastIndex < text.length) { - segments.push({ type: 'text', value: text.slice(lastIndex) }); - } - return segments.length ? segments : [{ type: 'text', value: text }]; -} - export async function fetchOrderTrack(orderId: string) { return request(`/trade/orders/${orderId}/track`); } diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx index 4e7185d..25c6c22 100644 --- a/apps/mini-user/src/pages/order-detail/index.tsx +++ b/apps/mini-user/src/pages/order-detail/index.tsx @@ -10,7 +10,6 @@ import LogisticsRichText from '../../components/LogisticsRichText'; import { request, toast } from '../../lib/api'; import { buildPayUrl } from '../../lib/checkout-nav'; import { - fetchOrderTrack, formatEstimatedArrival, isLogisticsNotArrived, latestTrackNode, @@ -18,7 +17,8 @@ import { shouldLoadOrderTrack, type OrderTrackEstimatedArrival, type OrderTrackNode, -} from '../../lib/order-logistics'; +} from '../../lib/order-logistics-utils'; +import { fetchOrderTrack } from '../../lib/order-logistics'; import { maskPhone } from '../../lib/phone'; import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm'; import { diff --git a/apps/mini-user/src/pages/order-logistics/index.tsx b/apps/mini-user/src/pages/order-logistics/index.tsx index 1f68298..6cc9ccf 100644 --- a/apps/mini-user/src/pages/order-logistics/index.tsx +++ b/apps/mini-user/src/pages/order-logistics/index.tsx @@ -7,13 +7,13 @@ import LogisticsRichText from '../../components/LogisticsRichText'; import { request, toast } from '../../lib/api'; import { deliveryProviderLabel, - fetchOrderTrack, formatTrackNodeTime, shouldLoadOrderTrack, sortTrackNodesOldestFirst, type OrderDelivery, type OrderTrackNode, -} from '../../lib/order-logistics'; +} from '../../lib/order-logistics-utils'; +import { fetchOrderTrack } from '../../lib/order-logistics'; import { usePageView } from '../../lib/usePageView'; type OrderDetail = { @@ -32,7 +32,10 @@ export default function OrderLogisticsPage() { const [signPhotoUrls, setSignPhotoUrls] = useState([]); const [trackLoading, setTrackLoading] = useState(false); - const sortedTrackNodes = useMemo(() => sortTrackNodesOldestFirst(trackNodes), [trackNodes]); + const sortedTrackNodes = useMemo( + () => (Array.isArray(trackNodes) ? sortTrackNodesOldestFirst(trackNodes) : []), + [trackNodes], + ); const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes( order?.status || '', ); diff --git a/server/dukang-api/src/callbacks/delivery-callback.service.ts b/server/dukang-api/src/callbacks/delivery-callback.service.ts index acfc6a1..fbeda27 100644 --- a/server/dukang-api/src/callbacks/delivery-callback.service.ts +++ b/server/dukang-api/src/callbacks/delivery-callback.service.ts @@ -73,7 +73,10 @@ export class DeliveryCallbackService { return this.courier.buildTrackCallbackResponse(false); } - const targetStatus = this.xiaofeixiaProvider.mapTrackStatus(payload.status); + const targetStatus = this.xiaofeixiaProvider.mapTrackStatus( + payload.status, + payload.statusName, + ); let applied = false; if (targetStatus && targetStatus !== order.status) { await this.tradeService.applyStatusTransition( diff --git a/server/dukang-api/src/integrations/courier/courier.service.ts b/server/dukang-api/src/integrations/courier/courier.service.ts index e5b440e..6714951 100644 --- a/server/dukang-api/src/integrations/courier/courier.service.ts +++ b/server/dukang-api/src/integrations/courier/courier.service.ts @@ -67,7 +67,7 @@ export class CourierService { return this.provider.parseTrackCallback(body); } - mapTrackStatus(status: string): CourierMappedOrderStatus | null { - return this.provider.mapTrackStatus(status); + mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null { + return this.provider.mapTrackStatus(status, statusName); } } diff --git a/server/dukang-api/src/integrations/courier/courier.types.ts b/server/dukang-api/src/integrations/courier/courier.types.ts index 036f378..88416bb 100644 --- a/server/dukang-api/src/integrations/courier/courier.types.ts +++ b/server/dukang-api/src/integrations/courier/courier.types.ts @@ -128,6 +128,6 @@ export interface ICourierProvider { checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise; estimateFreight(weight: number, options?: CourierCallOptions): Promise; parseTrackCallback(body: unknown): TrackCallbackPayload | null; - mapTrackStatus(status: string): CourierMappedOrderStatus | null; + mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null; buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse; } diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts index e46ba70..5ae9c58 100644 --- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts @@ -157,7 +157,11 @@ export class XiaofeixiaProvider implements ICourierProvider { }; } - mapTrackStatus(status: string): CourierMappedOrderStatus | null { + mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null { + const name = (statusName || '').trim(); + if (status === '5' || /签收/.test(name)) { + return 'COMPLETED'; + } switch (String(status).trim()) { case '1': return 'OUT_WAREHOUSE'; @@ -165,8 +169,6 @@ export class XiaofeixiaProvider implements ICourierProvider { case '3': case '4': return 'SHIPPING'; - case '5': - return 'PENDING_RECEIVE'; case '7': return null; default: diff --git a/杜康好客-v3-PRD.md b/杜康好客-v3-PRD.md index 1db0a03..58cec30 100644 --- a/杜康好客-v3-PRD.md +++ b/杜康好客-v3-PRD.md @@ -48,7 +48,7 @@ | 版本 | 日期 | 说明 | |------|------|------| -| **3.4.13** | 2026-08-05 | 推广码归因统计、核销用户信息、技术支持工单优先级、mini-user 门店/商品/提货/版本/**物流增强(签收照/拨号/ETA/回调)**、H5 登录校验、合伙人微信暂停禁登、**OSS 图片超 10MB 客户端压缩**;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) | +| **3.4.13** | 2026-08-05 | 推广码归因统计、核销用户信息、技术支持工单优先级、mini-user 门店/商品/提货/版本/**物流增强(签收照/拨号/ETA/路由回调签收→已完成)**、H5 登录校验、合伙人微信暂停禁登、**OSS 图片超 10MB 客户端压缩**;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) | --- diff --git a/杜康好客-v3-现状对照.md b/杜康好客-v3-现状对照.md index c806696..3a88911 100644 --- a/杜康好客-v3-现状对照.md +++ b/杜康好客-v3-现状对照.md @@ -341,7 +341,7 @@ C2~C7、C14 见 §1.3。 | H5 登录前端校验 | ✅ | partner/shop LoginPage | | mini-user 门店体验 | ✅ | 电话脱敏/埋点、门头 aspectFit、套餐折叠 | | mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 | -| mini-user 版本/物流 | ✅ | 摘要 + 签收照 + 拨号 + 时间线 + ETA + `/callbacks/courier/xfx/track` | +| mini-user 版本/物流 | ✅ | 摘要 + 签收照 + 拨号 + 时间线 + ETA;回调签收→**COMPLETED** | | OSS 大图压缩 | ✅ | shared-ui compressImage;四端 upload + mini 头像 | | 文档 | ✅ | PRD §0.5 + v3.4.13 开发文档 | diff --git a/杜康好客-v3.4.13-体验优化开发文档.md b/杜康好客-v3.4.13-体验优化开发文档.md index c953ad1..f4ee091 100644 --- a/杜康好客-v3.4.13-体验优化开发文档.md +++ b/杜康好客-v3.4.13-体验优化开发文档.md @@ -94,7 +94,17 @@ POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track | 100102 | 路由查询 | `getTrack` | | 100108 | 签收照片 | `getSignPhotos` → OSS → `signPhotoUrls` | | 100301 | 预估送达 | `checkDeliveryCoverage` → `estimatedArrival` | -| 回调 | 路由推送 | `parseTrackCallback` + `mapTrackStatus` | +| 回调 | 路由推送 | `parseTrackCallback` + `mapTrackStatus`(**status=5 / statusName 含「签收」→ 订单 `COMPLETED`**) | + +**路由回调状态映射(小飞侠)** + +| 小飞侠 status / statusName | 系统订单状态 | +|---------------------------|--------------| +| 1 下单 | `OUT_WAREHOUSE` | +| 2 取件 / 3 中转 | `SHIPPING` | +| 4 派件 | `SHIPPING` | +| **5 签收** / statusName **已签收** | **`COMPLETED`(已完成)** | +| 7 取消 | 仅记录日志,不改订单 | ## 5. 数据表 @@ -112,6 +122,6 @@ POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track - [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒 - [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、门头/套餐正常 - [ ] mini-user:配送中物流摘要 + 签收照预览 + 电话拨号 + 时间线旧→新全点亮 + 预估送达 -- [ ] 小飞侠路由回调 `POST /callbacks/courier/xfx/track` 可推进订单状态且幂等 +- [ ] 小飞侠路由回调 `POST /callbacks/courier/xfx/track`:status=5 或 statusName 已签收 → 订单 **COMPLETED**;重复回调幂等 - [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错 - [ ] `pnpm lint` 无新增错误