merge(dev_jacy): courier signed callback completes order (v3.4.13)

This commit is contained in:
2026-08-05 23:46:44 +08:00
12 changed files with 151 additions and 130 deletions
@@ -1,6 +1,6 @@
import { Text } from '@tarojs/components'; import { Text } from '@tarojs/components';
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { splitLogisticsText } from '../lib/order-logistics'; import { splitLogisticsText } from '../lib/order-logistics-utils';
import { toast } from '../lib/api'; import { toast } from '../lib/api';
type LogisticsRichTextProps = { type LogisticsRichTextProps = {
@@ -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 }];
}
+2 -113
View File
@@ -1,118 +1,7 @@
import type { OrderTrackDto } from '@dukang/shared-types'; export * from './order-logistics-utils';
import { isXfxProviderCode } from '@dukang/shared-types'; import type { OrderTrackResponse } from './order-logistics-utils';
import { formatShanghaiDateTime } from './datetime';
import { request } from './api'; 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) { export async function fetchOrderTrack(orderId: string) {
return request<OrderTrackResponse>(`/trade/orders/${orderId}/track`); return request<OrderTrackResponse>(`/trade/orders/${orderId}/track`);
} }
@@ -10,7 +10,6 @@ import LogisticsRichText from '../../components/LogisticsRichText';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav'; import { buildPayUrl } from '../../lib/checkout-nav';
import { import {
fetchOrderTrack,
formatEstimatedArrival, formatEstimatedArrival,
isLogisticsNotArrived, isLogisticsNotArrived,
latestTrackNode, latestTrackNode,
@@ -18,7 +17,8 @@ import {
shouldLoadOrderTrack, shouldLoadOrderTrack,
type OrderTrackEstimatedArrival, type OrderTrackEstimatedArrival,
type OrderTrackNode, type OrderTrackNode,
} from '../../lib/order-logistics'; } from '../../lib/order-logistics-utils';
import { fetchOrderTrack } from '../../lib/order-logistics';
import { maskPhone } from '../../lib/phone'; import { maskPhone } from '../../lib/phone';
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm'; import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
import { import {
@@ -7,13 +7,13 @@ import LogisticsRichText from '../../components/LogisticsRichText';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { import {
deliveryProviderLabel, deliveryProviderLabel,
fetchOrderTrack,
formatTrackNodeTime, formatTrackNodeTime,
shouldLoadOrderTrack, shouldLoadOrderTrack,
sortTrackNodesOldestFirst, sortTrackNodesOldestFirst,
type OrderDelivery, type OrderDelivery,
type OrderTrackNode, type OrderTrackNode,
} from '../../lib/order-logistics'; } from '../../lib/order-logistics-utils';
import { fetchOrderTrack } from '../../lib/order-logistics';
import { usePageView } from '../../lib/usePageView'; import { usePageView } from '../../lib/usePageView';
type OrderDetail = { type OrderDetail = {
@@ -32,7 +32,10 @@ export default function OrderLogisticsPage() {
const [signPhotoUrls, setSignPhotoUrls] = useState<string[]>([]); const [signPhotoUrls, setSignPhotoUrls] = useState<string[]>([]);
const [trackLoading, setTrackLoading] = useState(false); 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( const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
order?.status || '', order?.status || '',
); );
@@ -73,7 +73,10 @@ export class DeliveryCallbackService {
return this.courier.buildTrackCallbackResponse(false); return this.courier.buildTrackCallbackResponse(false);
} }
const targetStatus = this.xiaofeixiaProvider.mapTrackStatus(payload.status); const targetStatus = this.xiaofeixiaProvider.mapTrackStatus(
payload.status,
payload.statusName,
);
let applied = false; let applied = false;
if (targetStatus && targetStatus !== order.status) { if (targetStatus && targetStatus !== order.status) {
await this.tradeService.applyStatusTransition( await this.tradeService.applyStatusTransition(
@@ -67,7 +67,7 @@ export class CourierService {
return this.provider.parseTrackCallback(body); return this.provider.parseTrackCallback(body);
} }
mapTrackStatus(status: string): CourierMappedOrderStatus | null { mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null {
return this.provider.mapTrackStatus(status); return this.provider.mapTrackStatus(status, statusName);
} }
} }
@@ -128,6 +128,6 @@ export interface ICourierProvider {
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult>; checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult>;
estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult>; estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult>;
parseTrackCallback(body: unknown): TrackCallbackPayload | null; parseTrackCallback(body: unknown): TrackCallbackPayload | null;
mapTrackStatus(status: string): CourierMappedOrderStatus | null; mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null;
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse; buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse;
} }
@@ -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()) { switch (String(status).trim()) {
case '1': case '1':
return 'OUT_WAREHOUSE'; return 'OUT_WAREHOUSE';
@@ -165,8 +169,6 @@ export class XiaofeixiaProvider implements ICourierProvider {
case '3': case '3':
case '4': case '4':
return 'SHIPPING'; return 'SHIPPING';
case '5':
return 'PENDING_RECEIVE';
case '7': case '7':
return null; return null;
default: default:
+1 -1
View File
@@ -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) |
--- ---
+1 -1
View File
@@ -341,7 +341,7 @@ C2~C7、C14 见 §1.3。
| H5 登录前端校验 | ✅ | partner/shop LoginPage | | H5 登录前端校验 | ✅ | partner/shop LoginPage |
| mini-user 门店体验 | ✅ | 电话脱敏/埋点、门头 aspectFit、套餐折叠 | | mini-user 门店体验 | ✅ | 电话脱敏/埋点、门头 aspectFit、套餐折叠 |
| mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 | | mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 |
| mini-user 版本/物流 | ✅ | 摘要 + 签收照 + 拨号 + 时间线 + ETA + `/callbacks/courier/xfx/track` | | mini-user 版本/物流 | ✅ | 摘要 + 签收照 + 拨号 + 时间线 + ETA;回调签收→**COMPLETED** |
| OSS 大图压缩 | ✅ | shared-ui compressImage;四端 upload + mini 头像 | | OSS 大图压缩 | ✅ | shared-ui compressImage;四端 upload + mini 头像 |
| 文档 | ✅ | PRD §0.5 + v3.4.13 开发文档 | | 文档 | ✅ | PRD §0.5 + v3.4.13 开发文档 |
@@ -94,7 +94,17 @@ POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
| 100102 | 路由查询 | `getTrack` | | 100102 | 路由查询 | `getTrack` |
| 100108 | 签收照片 | `getSignPhotos` → OSS → `signPhotoUrls` | | 100108 | 签收照片 | `getSignPhotos` → OSS → `signPhotoUrls` |
| 100301 | 预估送达 | `checkDeliveryCoverage``estimatedArrival` | | 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. 数据表 ## 5. 数据表
@@ -112,6 +122,6 @@ POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
- [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒 - [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒
- [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、门头/套餐正常 - [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、门头/套餐正常
- [ ] mini-user:配送中物流摘要 + 签收照预览 + 电话拨号 + 时间线旧→新全点亮 + 预估送达 - [ ] mini-user:配送中物流摘要 + 签收照预览 + 电话拨号 + 时间线旧→新全点亮 + 预估送达
- [ ] 小飞侠路由回调 `POST /callbacks/courier/xfx/track` 可推进订单状态且幂等 - [ ] 小飞侠路由回调 `POST /callbacks/courier/xfx/track`status=5 或 statusName 已签收 → 订单 **COMPLETED**;重复回调幂等
- [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错 - [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错
- [ ] `pnpm lint` 无新增错误 - [ ] `pnpm lint` 无新增错误