feat(trade): WeChat confirm-receive component + admin status log columns
Open weappOrderConfirm in mini-user so users confirm in-app instead of service notice; verify via get_order before completing. Show operator/remark on admin order status timeline. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -19,6 +19,17 @@ export const ORDER_STATUS_LABELS: Record<string, string> = {
|
|||||||
REFUNDED: '已退款',
|
REFUNDED: '已退款',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 订单状态流转操作人(common_event.param3) */
|
||||||
|
export const ORDER_STATUS_OPERATOR_LABELS: Record<string, string> = {
|
||||||
|
USER: '用户',
|
||||||
|
USER_ON_SITE: '用户·现场取货',
|
||||||
|
USER_WECHAT_CONFIRM: '用户·微信确认收货',
|
||||||
|
WECHAT_TRADE_MANAGE: '微信结算同步',
|
||||||
|
MOCK: 'Mock',
|
||||||
|
MOCK_PAY: 'Mock 支付',
|
||||||
|
SYSTEM: '系统',
|
||||||
|
};
|
||||||
|
|
||||||
/** 订单状态 Tag 颜色(Ant Design preset) */
|
/** 订单状态 Tag 颜色(Ant Design preset) */
|
||||||
export const ORDER_STATUS_COLORS: Record<string, string> = {
|
export const ORDER_STATUS_COLORS: Record<string, string> = {
|
||||||
PENDING_PAY: 'red',
|
PENDING_PAY: 'red',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
DELIVERY_PROVIDER_LABELS,
|
DELIVERY_PROVIDER_LABELS,
|
||||||
ORDER_STATUS_COLORS,
|
ORDER_STATUS_COLORS,
|
||||||
ORDER_STATUS_LABELS,
|
ORDER_STATUS_LABELS,
|
||||||
|
ORDER_STATUS_OPERATOR_LABELS,
|
||||||
fmtTime,
|
fmtTime,
|
||||||
} from '../lib/constants';
|
} from '../lib/constants';
|
||||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||||
@@ -102,7 +103,13 @@ type OrderDetail = AdminOrderRow & {
|
|||||||
listUnitPrice?: number;
|
listUnitPrice?: number;
|
||||||
items?: AdminOrderItem[];
|
items?: AdminOrderItem[];
|
||||||
payment?: Record<string, unknown> | null;
|
payment?: Record<string, unknown> | null;
|
||||||
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
|
statusLogs?: Array<{
|
||||||
|
fromStatus: string | null;
|
||||||
|
toStatus: string;
|
||||||
|
operator?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
benefitCoupons?: Array<Record<string, unknown>>;
|
benefitCoupons?: Array<Record<string, unknown>>;
|
||||||
redeemSummary?: OrderRedeemSummary | null;
|
redeemSummary?: OrderRedeemSummary | null;
|
||||||
redeemRecords?: OrderRedeemRecord[];
|
redeemRecords?: OrderRedeemRecord[];
|
||||||
@@ -765,13 +772,40 @@ export default function OrdersPage() {
|
|||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
||||||
<Table
|
<Table
|
||||||
size="small"
|
size="small"
|
||||||
rowKey="createdAt"
|
rowKey={(_, i) => String(i)}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={detail.statusLogs}
|
dataSource={detail.statusLogs}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '从', dataIndex: 'fromStatus', render: (v) => v || '—' },
|
{
|
||||||
{ title: '到', dataIndex: 'toStatus', render: (v) => ORDER_STATUS_LABELS[v] || v },
|
title: '从',
|
||||||
{ title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') },
|
dataIndex: 'fromStatus',
|
||||||
|
width: 100,
|
||||||
|
render: (v) => (v ? ORDER_STATUS_LABELS[v] || v : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '到',
|
||||||
|
dataIndex: 'toStatus',
|
||||||
|
width: 100,
|
||||||
|
render: (v) => ORDER_STATUS_LABELS[v] || v,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作人',
|
||||||
|
dataIndex: 'operator',
|
||||||
|
width: 120,
|
||||||
|
render: (v) => ORDER_STATUS_OPERATOR_LABELS[v] || v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'remark',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 170,
|
||||||
|
render: (v) => new Date(v).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,13 +1,66 @@
|
|||||||
import './lib/text-encoding-polyfill';
|
import './lib/text-encoding-polyfill';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren, useRef } from 'react';
|
||||||
|
import Taro, { useDidShow } from '@tarojs/taro';
|
||||||
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||||
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
|
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
|
||||||
|
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
|
||||||
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
|
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
|
||||||
patchTaroH5Hooks();
|
patchTaroH5Hooks();
|
||||||
|
|
||||||
function App({ children }: PropsWithChildren) {
|
function App({ children }: PropsWithChildren) {
|
||||||
|
const handlingRef = useRef(false);
|
||||||
|
|
||||||
|
useDidShow((options?: {
|
||||||
|
referrerInfo?: {
|
||||||
|
appId?: string;
|
||||||
|
extraData?: { status?: string; errormsg?: string; req_extradata?: Record<string, string> };
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
if (process.env.TARO_ENV !== 'weapp') return;
|
||||||
|
if (handlingRef.current) return;
|
||||||
|
|
||||||
|
const referrerInfo =
|
||||||
|
options?.referrerInfo ||
|
||||||
|
(typeof Taro.getEnterOptionsSync === 'function'
|
||||||
|
? (
|
||||||
|
Taro.getEnterOptionsSync() as {
|
||||||
|
referrerInfo?: {
|
||||||
|
appId?: string;
|
||||||
|
extraData?: {
|
||||||
|
status?: string;
|
||||||
|
errormsg?: string;
|
||||||
|
req_extradata?: Record<string, string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).referrerInfo
|
||||||
|
: undefined);
|
||||||
|
if (!referrerInfo?.appId) return;
|
||||||
|
|
||||||
|
handlingRef.current = true;
|
||||||
|
void handleWechatOrderConfirmShow({ referrerInfo })
|
||||||
|
.then((result) => {
|
||||||
|
if (result.redirectUrl) {
|
||||||
|
Taro.redirectTo({ url: result.redirectUrl }).catch(() => {
|
||||||
|
Taro.reLaunch({ url: result.redirectUrl! });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.handled || !result.orderId) return;
|
||||||
|
const pages = Taro.getCurrentPages();
|
||||||
|
const cur = pages[pages.length - 1] as { route?: string } | undefined;
|
||||||
|
const route = cur?.route || '';
|
||||||
|
if (route.includes('pickup-receive')) {
|
||||||
|
Taro.redirectTo({ url: '/pages/orders/index?tab=done' }).catch(() => {});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
handlingRef.current = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WechatShareBootstrap />
|
<WechatShareBootstrap />
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import { request, toast } from './api';
|
||||||
|
|
||||||
|
/** 微信确认收货组件来源 AppId(官方固定) */
|
||||||
|
export const WECHAT_ORDER_CONFIRM_APPID = 'wx1183b055aeec94d1';
|
||||||
|
|
||||||
|
const PENDING_KEY = 'pending_wechat_order_confirm';
|
||||||
|
|
||||||
|
export type WechatConfirmPayload = {
|
||||||
|
merchantId?: string | null;
|
||||||
|
merchantTradeNo?: string | null;
|
||||||
|
transactionId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingConfirm = {
|
||||||
|
orderId: string;
|
||||||
|
redirectUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenBusinessViewFn = (opts: {
|
||||||
|
businessType: string;
|
||||||
|
extraData: Record<string, string>;
|
||||||
|
success?: () => void;
|
||||||
|
fail?: (err: { errMsg?: string }) => void;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
function getOpenBusinessView(): OpenBusinessViewFn | null {
|
||||||
|
if (process.env.TARO_ENV !== 'weapp') return null;
|
||||||
|
const taroAny = Taro as unknown as { openBusinessView?: OpenBusinessViewFn };
|
||||||
|
if (typeof taroAny.openBusinessView === 'function') return taroAny.openBusinessView.bind(Taro);
|
||||||
|
const wxAny = (globalThis as { wx?: { openBusinessView?: OpenBusinessViewFn } }).wx;
|
||||||
|
if (wxAny && typeof wxAny.openBusinessView === 'function') {
|
||||||
|
return wxAny.openBusinessView.bind(wxAny);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePendingWechatOrderConfirm(pending: PendingConfirm) {
|
||||||
|
Taro.setStorageSync(PENDING_KEY, JSON.stringify(pending));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function takePendingWechatOrderConfirm(): PendingConfirm | null {
|
||||||
|
try {
|
||||||
|
const raw = Taro.getStorageSync(PENDING_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
Taro.removeStorageSync(PENDING_KEY);
|
||||||
|
return typeof raw === 'string' ? (JSON.parse(raw) as PendingConfirm) : (raw as PendingConfirm);
|
||||||
|
} catch {
|
||||||
|
Taro.removeStorageSync(PENDING_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉起微信「确认收货」半屏组件,资金侧确认与自家订单同步。
|
||||||
|
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping-half.html
|
||||||
|
*/
|
||||||
|
export function openWechatOrderConfirm(opts: {
|
||||||
|
orderId: string;
|
||||||
|
payload: WechatConfirmPayload;
|
||||||
|
redirectUrl?: string;
|
||||||
|
}): Promise<'opened' | 'unsupported' | 'missing_pay_ref'> {
|
||||||
|
const open = getOpenBusinessView();
|
||||||
|
if (!open) return Promise.resolve('unsupported');
|
||||||
|
|
||||||
|
const transactionId = opts.payload.transactionId?.trim();
|
||||||
|
const merchantId = opts.payload.merchantId?.trim();
|
||||||
|
const merchantTradeNo = opts.payload.merchantTradeNo?.trim();
|
||||||
|
if (!transactionId && !(merchantId && merchantTradeNo)) {
|
||||||
|
return Promise.resolve('missing_pay_ref');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraData: Record<string, string> = {};
|
||||||
|
if (transactionId) extraData.transaction_id = transactionId;
|
||||||
|
if (merchantId) extraData.merchant_id = merchantId;
|
||||||
|
if (merchantTradeNo) extraData.merchant_trade_no = merchantTradeNo;
|
||||||
|
|
||||||
|
savePendingWechatOrderConfirm({
|
||||||
|
orderId: opts.orderId,
|
||||||
|
redirectUrl: opts.redirectUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
open({
|
||||||
|
businessType: 'weappOrderConfirm',
|
||||||
|
extraData,
|
||||||
|
success: () => resolve('opened'),
|
||||||
|
fail: (err) => {
|
||||||
|
Taro.removeStorageSync(PENDING_KEY);
|
||||||
|
toast(err?.errMsg || '无法打开微信确认收货,请升级微信后重试');
|
||||||
|
resolve('unsupported');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReferrerExtra = {
|
||||||
|
status?: string;
|
||||||
|
errormsg?: string;
|
||||||
|
req_extradata?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理确认收货组件回跳(App/页面 onShow)。
|
||||||
|
* 成功则调用后端同步订单,并返回是否已处理。
|
||||||
|
*/
|
||||||
|
export async function handleWechatOrderConfirmShow(options?: {
|
||||||
|
referrerInfo?: { appId?: string; extraData?: ReferrerExtra };
|
||||||
|
}): Promise<{ handled: boolean; orderId?: string; redirectUrl?: string }> {
|
||||||
|
const info = options?.referrerInfo;
|
||||||
|
if (!info?.appId || info.appId !== WECHAT_ORDER_CONFIRM_APPID) {
|
||||||
|
return { handled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = takePendingWechatOrderConfirm();
|
||||||
|
const status = info.extraData?.status;
|
||||||
|
if (status === 'cancel') {
|
||||||
|
toast('已取消确认收货');
|
||||||
|
return { handled: true, orderId: pending?.orderId };
|
||||||
|
}
|
||||||
|
if (status === 'fail') {
|
||||||
|
toast(info.extraData?.errormsg || '微信确认收货失败');
|
||||||
|
return { handled: true, orderId: pending?.orderId };
|
||||||
|
}
|
||||||
|
if (status !== 'success' || !pending?.orderId) {
|
||||||
|
return { handled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request(`/trade/orders/${pending.orderId}/confirm-receive`, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { source: 'WECHAT_COMPONENT' },
|
||||||
|
});
|
||||||
|
toast('确认收货成功', 'success');
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
orderId: pending.orderId,
|
||||||
|
redirectUrl: pending.redirectUrl,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : '同步订单失败');
|
||||||
|
return { handled: true, orderId: pending.orderId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一入口:小程序走微信组件;H5/无能力时降级为本地确认 */
|
||||||
|
export async function confirmOrderReceive(opts: {
|
||||||
|
orderId: string;
|
||||||
|
wechatConfirm?: WechatConfirmPayload | null;
|
||||||
|
onSitePickup?: boolean;
|
||||||
|
redirectUrl?: string;
|
||||||
|
/** 降级本地确认成功后的回调(不经过微信回跳) */
|
||||||
|
onLocalSuccess?: () => void | Promise<void>;
|
||||||
|
}): Promise<'wechat' | 'local'> {
|
||||||
|
const mode = await openWechatOrderConfirm({
|
||||||
|
orderId: opts.orderId,
|
||||||
|
payload: opts.wechatConfirm || {},
|
||||||
|
redirectUrl: opts.redirectUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === 'opened') return 'wechat';
|
||||||
|
|
||||||
|
// Mock / H5 / 缺支付单号:本地确认(不通知微信资金侧)
|
||||||
|
await request(`/trade/orders/${opts.orderId}/confirm-receive`, {
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
onSitePickup: !!opts.onSitePickup,
|
||||||
|
source: 'USER',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await opts.onLocalSuccess?.();
|
||||||
|
return 'local';
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
import Taro, { useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
import Taro, { useDidShow, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
import ShareNavButton from '../../components/ShareNavButton';
|
import ShareNavButton from '../../components/ShareNavButton';
|
||||||
@@ -9,6 +9,7 @@ import ContactCsButton from '../../components/ContactCsButton';
|
|||||||
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 { maskPhone } from '../../lib/phone';
|
import { maskPhone } from '../../lib/phone';
|
||||||
|
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
|
||||||
import {
|
import {
|
||||||
DEFAULT_SHARE_DESC,
|
DEFAULT_SHARE_DESC,
|
||||||
DEFAULT_SHARE_TITLE,
|
DEFAULT_SHARE_TITLE,
|
||||||
@@ -43,6 +44,7 @@ type OrderDetail = {
|
|||||||
isProxyOrder?: boolean;
|
isProxyOrder?: boolean;
|
||||||
proxyPartnerName?: string | null;
|
proxyPartnerName?: string | null;
|
||||||
items?: OrderItem[];
|
items?: OrderItem[];
|
||||||
|
wechatConfirm?: WechatConfirmPayload | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -82,6 +84,14 @@ export default function OrderDetailPage() {
|
|||||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||||
}, [orderId]);
|
}, [orderId]);
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
if (!orderId) return;
|
||||||
|
// 从微信确认收货组件返回后刷新
|
||||||
|
request<OrderDetail>(`/trade/orders/${orderId}`)
|
||||||
|
.then(setOrder)
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
const isReship = !!order?.originOrderId;
|
const isReship = !!order?.originOrderId;
|
||||||
const isProxy = !!order && (order.isProxyOrder || order.orderType === 'PROXY');
|
const isProxy = !!order && (order.isProxyOrder || order.orderType === 'PROXY');
|
||||||
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
|
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
|
||||||
@@ -127,7 +137,9 @@ export default function OrderDetailPage() {
|
|||||||
|
|
||||||
const { confirm } = await Taro.showModal({
|
const { confirm } = await Taro.showModal({
|
||||||
title: '确认收货?',
|
title: '确认收货?',
|
||||||
content: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
|
content: isWeapp
|
||||||
|
? '将打开微信确认收货,完成后订单即完结,无需再点服务通知。'
|
||||||
|
: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
|
||||||
confirmText: '确认收货',
|
confirmText: '确认收货',
|
||||||
cancelText: '再想想',
|
cancelText: '再想想',
|
||||||
});
|
});
|
||||||
@@ -135,12 +147,19 @@ export default function OrderDetailPage() {
|
|||||||
|
|
||||||
setConfirming(true);
|
setConfirming(true);
|
||||||
try {
|
try {
|
||||||
const updated = await request<OrderDetail>(`/trade/orders/${order.id}/confirm-receive`, {
|
const mode = await confirmOrderReceive({
|
||||||
method: 'POST',
|
orderId: order.id,
|
||||||
data: {},
|
wechatConfirm: order.wechatConfirm,
|
||||||
|
onLocalSuccess: async () => {
|
||||||
|
const updated = await request<OrderDetail>(`/trade/orders/${order.id}`);
|
||||||
|
setOrder(updated);
|
||||||
|
toast('已确认收货');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
setOrder(updated);
|
if (mode === 'wechat') {
|
||||||
toast('已确认收货');
|
// 回跳后由 App.onShow / 本页 useDidShow 处理
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : '确认收货失败');
|
toast(e instanceof Error ? e.message : '确认收货失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import PageShell from '../../components/PageShell';
|
|||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { getProductMainImage } from '../../lib/product-images';
|
import { getProductMainImage } from '../../lib/product-images';
|
||||||
|
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
|
||||||
|
|
||||||
|
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||||
|
|
||||||
type OrderDetail = {
|
type OrderDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,6 +25,7 @@ type OrderDetail = {
|
|||||||
};
|
};
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
mainImageUrl?: string | null;
|
mainImageUrl?: string | null;
|
||||||
|
wechatConfirm?: WechatConfirmPayload | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PickupReceivePage() {
|
export default function PickupReceivePage() {
|
||||||
@@ -45,17 +49,33 @@ export default function PickupReceivePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function confirmReceive() {
|
async function confirmReceive() {
|
||||||
if (!orderId || submitting) return;
|
if (!orderId || !order || submitting) return;
|
||||||
|
|
||||||
|
if (isWeapp) {
|
||||||
|
const { confirm } = await Taro.showModal({
|
||||||
|
title: '确认收货?',
|
||||||
|
content: '将打开微信确认收货,完成后订单即完结,无需再点服务通知。',
|
||||||
|
confirmText: '确认收货',
|
||||||
|
cancelText: '再想想',
|
||||||
|
});
|
||||||
|
if (!confirm) return;
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await request(`/trade/orders/${orderId}/confirm-receive`, {
|
const mode = await confirmOrderReceive({
|
||||||
method: 'POST',
|
orderId,
|
||||||
data: {},
|
wechatConfirm: order.wechatConfirm,
|
||||||
|
onSitePickup: true,
|
||||||
|
redirectUrl: '/pages/orders/index?tab=done',
|
||||||
|
onLocalSuccess: async () => {
|
||||||
|
toast('确认收货成功', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
Taro.redirectTo({ url: '/pages/orders/index?tab=done' });
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
toast('确认收货成功', 'success');
|
if (mode === 'wechat') return;
|
||||||
setTimeout(() => {
|
|
||||||
Taro.redirectTo({ url: '/pages/orders/index?tab=done' });
|
|
||||||
}, 500);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : '确认失败');
|
toast(e instanceof Error ? e.message : '确认失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||||
import { loadAppConfig } from '@dukang/shared-types';
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { WECHAT_PROVIDER } from '../integrations.constants';
|
import { WECHAT_PROVIDER } from '../integrations.constants';
|
||||||
@@ -15,6 +15,8 @@ const MIN_PAID_AGE_MS = 65_000;
|
|||||||
const RETRY_DELAY_MS = 60_000;
|
const RETRY_DELAY_MS = 60_000;
|
||||||
const MAX_ATTEMPTS = 5;
|
const MAX_ATTEMPTS = 5;
|
||||||
const RETRYABLE_ERRCODES = new Set([10060001, -1, 10060012, 10060019]);
|
const RETRYABLE_ERRCODES = new Set([10060001, -1, 10060012, 10060019]);
|
||||||
|
/** 微信 order_state:3 确认收货;4 交易完成 */
|
||||||
|
const WECHAT_CONFIRMED_STATES = new Set([3, 4]);
|
||||||
|
|
||||||
function sleep(ms: number) {
|
function sleep(ms: number) {
|
||||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -33,6 +35,68 @@ export class WechatOrderShippingService {
|
|||||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** 供 C 端拉起微信确认收货组件 */
|
||||||
|
buildConfirmPayload(order: {
|
||||||
|
orderNo: string;
|
||||||
|
payStatus: string;
|
||||||
|
payExternalNo: string | null;
|
||||||
|
}): {
|
||||||
|
merchantId?: string;
|
||||||
|
merchantTradeNo: string;
|
||||||
|
transactionId?: string;
|
||||||
|
} | null {
|
||||||
|
if (order.payStatus !== 'PAID') return null;
|
||||||
|
const mchId = this.wechat.getMchId()?.trim() || undefined;
|
||||||
|
const transactionId = order.payExternalNo?.trim() || undefined;
|
||||||
|
if (!transactionId && !mchId) return null;
|
||||||
|
return {
|
||||||
|
merchantId: mchId,
|
||||||
|
merchantTradeNo: order.orderNo,
|
||||||
|
transactionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验用户已通过微信确认收货组件完成确认(或订单已结算)。
|
||||||
|
* Mock 支付环境跳过。
|
||||||
|
*/
|
||||||
|
async assertWechatUserConfirmed(orderId: bigint): Promise<void> {
|
||||||
|
const cfg = loadAppConfig();
|
||||||
|
if (cfg.mockPay) return;
|
||||||
|
if (!this.wechat.isPayEnabled() && this.wechat.isMock()) return;
|
||||||
|
|
||||||
|
const order = await this.prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { orderNo: true, payExternalNo: true },
|
||||||
|
});
|
||||||
|
if (!order) throw new BadRequestException('订单不存在');
|
||||||
|
|
||||||
|
const transactionId = order.payExternalNo?.trim();
|
||||||
|
const mchId = this.wechat.getMchId()?.trim();
|
||||||
|
if (!transactionId && !(mchId && order.orderNo)) {
|
||||||
|
throw new BadRequestException('缺少微信支付单号,无法校验微信确认收货');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.wechat.getOrderShippingInfo({
|
||||||
|
transactionId: transactionId || undefined,
|
||||||
|
mchId: transactionId ? undefined : mchId,
|
||||||
|
outTradeNo: transactionId ? undefined : order.orderNo,
|
||||||
|
});
|
||||||
|
if (result.errcode && result.errcode !== 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`get_order for confirm failed order=${order.orderNo} ${result.errcode} ${result.errmsg}`,
|
||||||
|
);
|
||||||
|
throw new BadRequestException(
|
||||||
|
result.errmsg || '查询微信订单状态失败,请稍后重试',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.orderState == null || !WECHAT_CONFIRMED_STATES.has(result.orderState)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'请先在微信确认收货组件中完成确认(勿仅点服务通知外的按钮)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 异步安全上报:按 paidAt 等待入库窗口,失败可重试,不阻断主履约流程 */
|
/** 异步安全上报:按 paidAt 等待入库窗口,失败可重试,不阻断主履约流程 */
|
||||||
uploadForOrderSafe(orderId: bigint) {
|
uploadForOrderSafe(orderId: bigint) {
|
||||||
void this.scheduleAndUpload(orderId).catch((err) => {
|
void this.scheduleAndUpload(orderId).catch((err) => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
WechatWxaCodeUnlimitedInput,
|
WechatWxaCodeUnlimitedInput,
|
||||||
WechatUploadShippingInfoInput,
|
WechatUploadShippingInfoInput,
|
||||||
WechatUploadShippingInfoResult,
|
WechatUploadShippingInfoResult,
|
||||||
|
WechatOrderShippingQueryResult,
|
||||||
WechatDeliveryCompany,
|
WechatDeliveryCompany,
|
||||||
} from './wechat.interface';
|
} from './wechat.interface';
|
||||||
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
|
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
|
||||||
@@ -375,6 +376,55 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getOrderShippingInfo(input: {
|
||||||
|
transactionId?: string;
|
||||||
|
mchId?: string;
|
||||||
|
outTradeNo?: string;
|
||||||
|
}): Promise<WechatOrderShippingQueryResult> {
|
||||||
|
const callOnce = async (accessToken: string) => {
|
||||||
|
const body: Record<string, string> = {};
|
||||||
|
if (input.transactionId) {
|
||||||
|
body.transaction_id = input.transactionId;
|
||||||
|
} else {
|
||||||
|
if (!input.mchId || !input.outTradeNo) {
|
||||||
|
throw new BadRequestException('请提供 transaction_id 或 mchid+out_trade_no');
|
||||||
|
}
|
||||||
|
body.merchant_id = input.mchId;
|
||||||
|
body.merchant_trade_no = input.outTradeNo;
|
||||||
|
}
|
||||||
|
const apiUrl = `https://api.weixin.qq.com/wxa/sec/order/get_order?access_token=${accessToken}`;
|
||||||
|
return this.fetchJson<{
|
||||||
|
errcode?: number;
|
||||||
|
errmsg?: string;
|
||||||
|
order?: {
|
||||||
|
transaction_id?: string;
|
||||||
|
merchant_trade_no?: string;
|
||||||
|
order_state?: number;
|
||||||
|
shipping?: { finish_shipping?: boolean };
|
||||||
|
};
|
||||||
|
}>(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let accessToken = await this.getMiniAccessToken();
|
||||||
|
let data = await callOnce(accessToken);
|
||||||
|
if (TOKEN_INVALID_ERRCODES.has(data.errcode ?? -1)) {
|
||||||
|
accessToken = await this.getMiniAccessToken(true);
|
||||||
|
data = await callOnce(accessToken);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
errcode: data.errcode ?? 0,
|
||||||
|
errmsg: data.errmsg ?? 'ok',
|
||||||
|
orderState: data.order?.order_state,
|
||||||
|
transactionId: data.order?.transaction_id,
|
||||||
|
merchantTradeNo: data.order?.merchant_trade_no,
|
||||||
|
finishShipping: data.order?.shipping?.finish_shipping,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getDeliveryList(): Promise<WechatDeliveryCompany[]> {
|
async getDeliveryList(): Promise<WechatDeliveryCompany[]> {
|
||||||
const accessToken = await this.getMiniAccessToken();
|
const accessToken = await this.getMiniAccessToken();
|
||||||
const apiUrl = `https://api.weixin.qq.com/cgi-bin/express/delivery/open_msg/get_delivery_list?access_token=${accessToken}`;
|
const apiUrl = `https://api.weixin.qq.com/cgi-bin/express/delivery/open_msg/get_delivery_list?access_token=${accessToken}`;
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ export class WechatDisabledProvider implements IWechatProvider {
|
|||||||
return this.disabled();
|
return this.disabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOrderShippingInfo() {
|
||||||
|
return this.disabled();
|
||||||
|
}
|
||||||
|
|
||||||
getDeliveryList() {
|
getDeliveryList() {
|
||||||
return this.disabled();
|
return this.disabled();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ export type WechatUploadShippingInfoResult = {
|
|||||||
errmsg: string;
|
errmsg: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WechatOrderShippingQueryResult = {
|
||||||
|
errcode: number;
|
||||||
|
errmsg: string;
|
||||||
|
/** 1待发货 2已发货 3确认收货 4交易完成 5已退款 */
|
||||||
|
orderState?: number;
|
||||||
|
transactionId?: string;
|
||||||
|
merchantTradeNo?: string;
|
||||||
|
finishShipping?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type WechatDeliveryCompany = {
|
export type WechatDeliveryCompany = {
|
||||||
deliveryId: string;
|
deliveryId: string;
|
||||||
deliveryName: string;
|
deliveryName: string;
|
||||||
@@ -137,6 +147,13 @@ export interface IWechatProvider {
|
|||||||
*/
|
*/
|
||||||
uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise<WechatUploadShippingInfoResult>;
|
uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise<WechatUploadShippingInfoResult>;
|
||||||
|
|
||||||
|
/** 查询支付单发货/确认收货状态 */
|
||||||
|
getOrderShippingInfo(input: {
|
||||||
|
transactionId?: string;
|
||||||
|
mchId?: string;
|
||||||
|
outTradeNo?: string;
|
||||||
|
}): Promise<WechatOrderShippingQueryResult>;
|
||||||
|
|
||||||
/** 获取运力公司列表(快递公司 delivery_id) */
|
/** 获取运力公司列表(快递公司 delivery_id) */
|
||||||
getDeliveryList(): Promise<WechatDeliveryCompany[]>;
|
getDeliveryList(): Promise<WechatDeliveryCompany[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ export class WechatMockProvider implements IWechatProvider {
|
|||||||
return { errcode: 0, errmsg: 'ok' };
|
return { errcode: 0, errmsg: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getOrderShippingInfo() {
|
||||||
|
return {
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: 'ok',
|
||||||
|
orderState: 3,
|
||||||
|
finishShipping: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getDeliveryList() {
|
async getDeliveryList() {
|
||||||
return [
|
return [
|
||||||
{ deliveryId: 'SF', deliveryName: '顺丰速运' },
|
{ deliveryId: 'SF', deliveryName: '顺丰速运' },
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ export class WechatRouterProvider implements IWechatProvider {
|
|||||||
return this.resolve().uploadShippingInfo(input);
|
return this.resolve().uploadShippingInfo(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOrderShippingInfo(input: Parameters<IWechatProvider['getOrderShippingInfo']>[0]) {
|
||||||
|
return this.resolve().getOrderShippingInfo(input);
|
||||||
|
}
|
||||||
|
|
||||||
getDeliveryList() {
|
getDeliveryList() {
|
||||||
return this.resolve().getDeliveryList();
|
return this.resolve().getDeliveryList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,10 +67,11 @@ export class TradeController {
|
|||||||
confirmReceive(
|
confirmReceive(
|
||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() body?: { onSitePickup?: boolean },
|
@Body() body?: { onSitePickup?: boolean; source?: 'USER' | 'WECHAT_COMPONENT' },
|
||||||
) {
|
) {
|
||||||
return this.tradeService.confirmReceive(user.actorId, BigInt(id), {
|
return this.tradeService.confirmReceive(user.actorId, BigInt(id), {
|
||||||
onSitePickup: !!body?.onSitePickup,
|
onSitePickup: !!body?.onSitePickup,
|
||||||
|
source: body?.source === 'WECHAT_COMPONENT' ? 'WECHAT_COMPONENT' : 'USER',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -493,7 +493,11 @@ export class TradeService {
|
|||||||
where: orderStatusLogWhere(orderId),
|
where: orderStatusLogWhere(orderId),
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
|
const mapped = mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) });
|
||||||
|
return serializeBigInt({
|
||||||
|
...mapped,
|
||||||
|
wechatConfirm: this.wechatOrderShipping.buildConfirmPayload(order),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOrderTrack(userId: bigint, orderId: bigint) {
|
async getOrderTrack(userId: bigint, orderId: bigint) {
|
||||||
@@ -537,7 +541,7 @@ export class TradeService {
|
|||||||
async confirmReceive(
|
async confirmReceive(
|
||||||
userId: bigint,
|
userId: bigint,
|
||||||
orderId: bigint,
|
orderId: bigint,
|
||||||
_opts?: { onSitePickup?: boolean },
|
opts?: { onSitePickup?: boolean; source?: 'USER' | 'WECHAT_COMPONENT' },
|
||||||
) {
|
) {
|
||||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||||
if (!order) throw new NotFoundException('订单不存在');
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
@@ -546,13 +550,23 @@ export class TradeService {
|
|||||||
throw new BadRequestException('当前状态不可确认收货');
|
throw new BadRequestException('当前状态不可确认收货');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.applyStatusTransition(
|
const viaWechat = opts?.source === 'WECHAT_COMPONENT';
|
||||||
order.id,
|
if (viaWechat) {
|
||||||
order.status,
|
await this.wechatOrderShipping.assertWechatUserConfirmed(orderId);
|
||||||
'COMPLETED',
|
}
|
||||||
order.deliveryType === 'ON_SITE_PICKUP' ? 'USER_ON_SITE' : 'USER',
|
|
||||||
order.deliveryType === 'ON_SITE_PICKUP' ? '用户现场取货确认收货' : undefined,
|
const operator = viaWechat
|
||||||
);
|
? 'USER_WECHAT_CONFIRM'
|
||||||
|
: order.deliveryType === 'ON_SITE_PICKUP' || opts?.onSitePickup
|
||||||
|
? 'USER_ON_SITE'
|
||||||
|
: 'USER';
|
||||||
|
const remark = viaWechat
|
||||||
|
? '用户经微信确认收货组件确认'
|
||||||
|
: order.deliveryType === 'ON_SITE_PICKUP' || opts?.onSitePickup
|
||||||
|
? '用户现场取货确认收货'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await this.applyStatusTransition(order.id, order.status, 'COMPLETED', operator, remark);
|
||||||
return this.getOrder(userId, orderId);
|
return this.getOrder(userId, orderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user