From 2cd4e2568237eaa2e2f2123beef78f547315e999 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Fri, 31 Jul 2026 09:18:19 +0800 Subject: [PATCH] feat(ops): add HQ admin proxy order and mini-user store session fixes Align HQ orders page with partner dual-SMS offline proxy flow; improve mini-user stores session and WeChat confirm-receive handling. Co-authored-by: Cursor --- .../src/components/ProxyOrderModal.tsx | 428 ++++++++++++++++++ apps/admin-web/src/lib/api.ts | 4 + apps/admin-web/src/pages/OrdersPage.tsx | 54 ++- apps/mini-user/src/app.tsx | 2 +- apps/mini-user/src/lib/api.ts | 3 + apps/mini-user/src/lib/stores-session.ts | 135 ++++++ .../mini-user/src/lib/wechat-order-confirm.ts | 202 +++++++-- apps/mini-user/src/pages/benefit/index.tsx | 5 +- apps/mini-user/src/pages/home/index.tsx | 22 +- apps/mini-user/src/pages/orders/index.tsx | 23 +- .../src/pages/pickup-receive/index.tsx | 18 +- apps/mini-user/src/pages/stores/index.tsx | 217 ++++++--- apps/mini-user/src/styles/benefit.css | 7 + apps/mini-user/src/styles/stores.css | 20 +- apps/mini-user/types/global.d.ts | 8 + packages/shared-types/src/trade.ts | 17 + .../hq-operation/hq-operation.constants.ts | 2 + .../ops/admin-proxy-orders.controller.ts | 59 +++ .../src/modules/ops/dto/hq-proxy-order.dto.ts | 102 +++++ .../dukang-api/src/modules/ops/ops.module.ts | 2 + .../src/modules/trade/trade.service.ts | 251 ++++++++++ 21 files changed, 1438 insertions(+), 143 deletions(-) create mode 100644 apps/admin-web/src/components/ProxyOrderModal.tsx create mode 100644 apps/mini-user/src/lib/stores-session.ts create mode 100644 server/dukang-api/src/modules/ops/admin-proxy-orders.controller.ts create mode 100644 server/dukang-api/src/modules/ops/dto/hq-proxy-order.dto.ts diff --git a/apps/admin-web/src/components/ProxyOrderModal.tsx b/apps/admin-web/src/components/ProxyOrderModal.tsx new file mode 100644 index 0000000..96b151b --- /dev/null +++ b/apps/admin-web/src/components/ProxyOrderModal.tsx @@ -0,0 +1,428 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Button, + Checkbox, + Form, + Input, + InputNumber, + Modal, + Radio, + Select, + Space, + Typography, + message, +} from 'antd'; +import type { + HqProxyOrderCreateRequest, + PartnerProxyDeliveryMode, + PartnerProxyOrderOptions, + PartnerProxyOrderPreviewResult, +} from '@dukang/shared-types'; +import ChinaRegionCascader from './ChinaRegionCascader'; +import { parseRegionCodes } from '../lib/china-region'; +import { request } from '../lib/api'; + +type ProxyOrderModalProps = { + open: boolean; + onClose: () => void; + onSuccess: (order: { id: string; orderNo: string }) => void; +}; + +function fmtMoney(n: number) { + return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrderModalProps) { + const [options, setOptions] = useState(null); + const [loadingOptions, setLoadingOptions] = useState(false); + const [phone, setPhone] = useState(''); + const [customerSmsCode, setCustomerSmsCode] = useState(''); + const [operatorSmsCode, setOperatorSmsCode] = useState(''); + const [receiverName, setReceiverName] = useState(''); + const [regionCodes, setRegionCodes] = useState([]); + const [addressDetail, setAddressDetail] = useState(''); + const [productId, setProductId] = useState(); + const [quantity, setQuantity] = useState(2); + const [promoCodeId, setPromoCodeId] = useState(); + const [deliveryMode, setDeliveryMode] = useState('ADDRESS'); + const [autoReceive, setAutoReceive] = useState(false); + const [confirmStep, setConfirmStep] = useState(false); + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [customerCooldown, setCustomerCooldown] = useState(0); + const [operatorCooldown, setOperatorCooldown] = useState(0); + const [submitting, setSubmitting] = useState(false); + + const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]); + + useEffect(() => { + if (!open) return; + setLoadingOptions(true); + request('/admin/proxy-orders/options') + .then((data) => { + setOptions(data); + if (data.products[0]) setProductId(data.products[0].id); + }) + .catch((e) => message.error(e instanceof Error ? e.message : '加载失败')) + .finally(() => setLoadingOptions(false)); + }, [open]); + + useEffect(() => { + if (!open || !productId || quantity < 1) { + setPreview(null); + return; + } + const timer = window.setTimeout(() => { + setPreviewLoading(true); + request('/admin/proxy-orders/preview', { + method: 'POST', + body: JSON.stringify({ + productId, + quantity, + deliveryMode, + receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined, + receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined, + }), + }) + .then(setPreview) + .catch(() => setPreview(null)) + .finally(() => setPreviewLoading(false)); + }, 300); + return () => window.clearTimeout(timer); + }, [open, productId, quantity, deliveryMode, region?.city, region?.district]); + + function resetForm() { + setPhone(''); + setCustomerSmsCode(''); + setOperatorSmsCode(''); + setReceiverName(''); + setRegionCodes([]); + setAddressDetail(''); + setQuantity(2); + setPromoCodeId(undefined); + setDeliveryMode('ADDRESS'); + setAutoReceive(false); + setConfirmStep(false); + setPreview(null); + setCustomerCooldown(0); + setOperatorCooldown(0); + setProductId(options?.products[0]?.id); + } + + function handleClose() { + resetForm(); + onClose(); + } + + function startCooldown(setter: (n: number | ((s: number) => number)) => void) { + setter(60); + const timer = window.setInterval(() => { + setter((s) => { + if (s <= 1) { + window.clearInterval(timer); + return 0; + } + return s - 1; + }); + }, 1000); + } + + async function sendCustomerSms() { + if (!/^1\d{10}$/.test(phone.trim())) { + message.warning('请输入有效手机号'); + return; + } + try { + const res = await request<{ maskedPhone: string }>('/admin/proxy-orders/send-customer-sms', { + method: 'POST', + body: JSON.stringify({ phone: phone.trim() }), + }); + message.success(`客户验证码已发送至 ${res.maskedPhone}`); + startCooldown(setCustomerCooldown); + } catch (e) { + message.error(e instanceof Error ? e.message : '发送失败'); + } + } + + async function sendOperatorSms() { + try { + const res = await request<{ maskedPhone: string }>('/admin/proxy-orders/send-operator-sms', { + method: 'POST', + body: '{}', + }); + message.success(`确认验证码已发送至总部手机 ${res.maskedPhone}`); + startCooldown(setOperatorCooldown); + } catch (e) { + message.error(e instanceof Error ? e.message : '发送失败'); + } + } + + function validateForm(): string | null { + if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号'; + if (!customerSmsCode.trim()) return '请输入客户验证码'; + if (!productId) return '请选择商品'; + if (deliveryMode === 'ADDRESS') { + if (!region?.province || !region?.city || !region?.district) return '请选择省市区'; + if (!addressDetail.trim()) return '请填写详细地址'; + if (!autoReceive) return '配送到址须勾选同意自动收货'; + } else { + const product = options?.products.find((p) => p.id === productId); + if (product && product.allowOnSitePickup === false) return '该商品不支持现场提货'; + } + if (!preview) return '请等待费用计算完成'; + return null; + } + + async function openConfirmStep() { + const err = validateForm(); + if (err) { + message.warning(err); + return; + } + setConfirmStep(true); + setOperatorSmsCode(''); + await sendOperatorSms(); + } + + async function submit() { + const err = validateForm(); + if (err) { + message.warning(err); + return; + } + if (!operatorSmsCode.trim()) { + message.warning('请输入总部确认验证码'); + return; + } + + const payload: HqProxyOrderCreateRequest = { + phone: phone.trim(), + customerSmsCode: customerSmsCode.trim(), + operatorSmsCode: operatorSmsCode.trim(), + deliveryMode, + autoReceive: deliveryMode === 'ADDRESS' ? true : undefined, + receiverName: receiverName.trim() || undefined, + province: deliveryMode === 'ADDRESS' ? region?.province : undefined, + city: deliveryMode === 'ADDRESS' ? region?.city : undefined, + district: deliveryMode === 'ADDRESS' ? region?.district : undefined, + addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined, + productId: productId!, + quantity, + promoCodeId: promoCodeId || undefined, + }; + + setSubmitting(true); + try { + const order = await request<{ id: string; orderNo: string }>('/admin/proxy-orders', { + method: 'POST', + body: JSON.stringify(payload), + }); + message.success(`代下单成功:${order.orderNo}`); + resetForm(); + onSuccess(order); + } catch (e) { + message.error(e instanceof Error ? e.message : '下单失败'); + } finally { + setSubmitting(false); + } + } + + const deliveryLabel = + preview?.deliveryType === 'ON_SITE_PICKUP' + ? '现场提货' + : preview?.deliveryType === 'CROSS_CITY' + ? '跨城配送' + : '同城配送'; + + return ( + + + + + ) : ( + + + + + ) + } + > + + + {!confirmStep ? ( +
+ + + setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))} + /> + + + + + + setCustomerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + /> + + + + ({ + value: p.id, + label: `${p.code} · ${p.name}`, + }))} + /> + + ) : null} + + + { + setDeliveryMode(e.target.value); + if (e.target.value !== 'ADDRESS') setAutoReceive(false); + }} + options={[ + { value: 'ADDRESS', label: '配送到址' }, + { value: 'ON_SITE_PICKUP', label: '现场提货' }, + ]} + /> + + + {deliveryMode === 'ADDRESS' ? ( + <> + + setReceiverName(e.target.value)} + /> + + + + + + setAddressDetail(e.target.value)} + /> + + + setAutoReceive(e.target.checked)}> + 同意自动收货(配送到址必选) + + + + ) : null} + +
+ 费用预览 + {previewLoading ? ( +
计算中…
+ ) : preview ? ( + +
履约:{deliveryLabel}
+
商品金额:¥{fmtMoney(preview.productAmount)}
+
实付:¥{fmtMoney(preview.payAmount)}
+
好客权益:¥{fmtMoney(preview.benefitAmount)}
+
+ ) : ( +
+ 请完善商品与数量 +
+ )} +
+
+ ) : ( +
+ + + + setOperatorSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + /> + + + + {preview ? ( + + {deliveryLabel} · 实付 ¥{fmtMoney(preview.payAmount)} · 权益 ¥{fmtMoney(preview.benefitAmount)} + + ) : null} + + )} +
+ ); +} diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 829be92..f44955d 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -148,6 +148,10 @@ export type AdminOrderRow = { fulfillmentWarehouse?: { id: string; name: string } | null; fulfillmentHold?: boolean; fulfillmentHoldReason?: string | null; + orderType?: string; + isProxyOrder?: boolean; + proxyPartnerName?: string | null; + proxyPartnerPhone?: string | null; user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; delivery?: { provider: string; diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 853cb46..dfdce64 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -28,6 +28,7 @@ import { fmtTime, } from '../lib/constants'; import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types'; +import ProxyOrderModal from '../components/ProxyOrderModal'; type ShipDefaults = { provider: string; @@ -182,7 +183,9 @@ export default function OrdersPage() { const [shipTarget, setShipTarget] = useState(null); const [shipMode, setShipMode] = useState('EXPRESS'); const [warehouses, setWarehouses] = useState([]); + const [proxyOpen, setProxyOpen] = useState(false); const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete'); + const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders'); const selectedOrders = useMemo( () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), @@ -415,6 +418,11 @@ export default function OrdersPage() { {ORDER_STATUS_LABELS[s] || s} {row.fulfillmentHold ? 大单待确认 : null} + {row.orderType === 'PROXY' || row.isProxyOrder ? ( + + 代下单 + + ) : null} ), }, @@ -422,7 +430,8 @@ export default function OrdersPage() { title: '配送', dataIndex: 'deliveryType', width: 90, - render: (v) => (v === 'LOCAL' ? '同城' : '跨城'), + render: (v) => + v === 'ON_SITE_PICKUP' ? '现场提货' : v === 'LOCAL' ? '同城' : '跨城', }, { title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` }, { title: '收货人', dataIndex: 'receiverName', width: 90 }, @@ -467,15 +476,22 @@ export default function OrdersPage() {
订单监控 - {canDeleteOrders ? ( - - ) : null} + + {canProxyOrder ? ( + + ) : null} + {canDeleteOrders ? ( + + ) : null} +
{ setPage(1); void load(); }}> @@ -588,8 +604,16 @@ export default function OrdersPage() { '大单待确认'} ) : null} + {detail.orderType === 'PROXY' || detail.isProxyOrder ? ( + 代下单 + ) : null} + {detail.orderType === 'PROXY' || detail.isProxyOrder || detail.proxyPartnerName ? ( + + {[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'} + + ) : null} {detail.user?.userNo} / {detail.user?.nickname} ¥{detail.productAmount} ¥{detail.payAmount} @@ -1112,6 +1136,16 @@ export default function OrdersPage() { ]} /> + + setProxyOpen(false)} + onSuccess={(order) => { + setProxyOpen(false); + void load(); + void openDetail(order.id); + }} + />
); } diff --git a/apps/mini-user/src/app.tsx b/apps/mini-user/src/app.tsx index d649774..13b40d0 100644 --- a/apps/mini-user/src/app.tsx +++ b/apps/mini-user/src/app.tsx @@ -53,7 +53,7 @@ function App({ children }: PropsWithChildren) { 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(() => {}); + Taro.redirectTo({ url: '/pages/orders/index?tab=all' }).catch(() => {}); } }) .finally(() => { diff --git a/apps/mini-user/src/lib/api.ts b/apps/mini-user/src/lib/api.ts index a740911..211d905 100644 --- a/apps/mini-user/src/lib/api.ts +++ b/apps/mini-user/src/lib/api.ts @@ -1,6 +1,7 @@ import Taro from '@tarojs/taro'; import { ClientApp } from '@dukang/shared-types'; import { forceReloadAfterAccountMerge } from './auth-nav'; +import { resetStoresSessionBootstrap } from './stores-session'; function resolveApiBase(): string { const origin = @@ -56,6 +57,8 @@ export function isLoggedIn(): boolean { export function logout() { clearAuth(); + // 主动退出才重置门店「当次登录」会话;401 清 token 不要打断门店筛选 + resetStoresSessionBootstrap(); Taro.reLaunch({ url: '/pages/home/index' }); } diff --git a/apps/mini-user/src/lib/stores-session.ts b/apps/mini-user/src/lib/stores-session.ts new file mode 100644 index 0000000..fb4c13c --- /dev/null +++ b/apps/mini-user/src/lib/stores-session.ts @@ -0,0 +1,135 @@ +/** + * 门店列表「当次登录」会话 —— 用 Taro Storage 持久化, + * 避免模块多实例 / globalThis 不可靠导致切 tab 后当成首次进入。 + * 仅 logout 时 clear。 + */ + +import Taro from '@tarojs/taro'; + +export type StoresSessionRegion = { + province: string; + city: string; + district: string; +}; + +export type StoresSessionCategory = { + parentId: string; + parentName: string; + childId: string; + childName: string; +}; + +export type StoresListCache = { + cityKey: string; + cityCode: string; + listRegion: StoresSessionRegion; + items: unknown[]; + filterRegion: StoresSessionRegion; + keyword: string; + keywordInput: string; + category: StoresSessionCategory; +}; + +type StoresSession = { + bootstrapped: boolean; + cache: StoresListCache | null; +}; + +const STORAGE_KEY = 'dukang_stores_session_v1'; + +let memory: StoresSession | null = null; + +function emptySession(): StoresSession { + return { bootstrapped: false, cache: null }; +} + +function readSession(): StoresSession { + if (memory) return memory; + try { + const raw = Taro.getStorageSync(STORAGE_KEY); + if (!raw) { + memory = emptySession(); + return memory; + } + const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Partial; + memory = { + bootstrapped: !!parsed.bootstrapped, + cache: (parsed.cache as StoresListCache | null) ?? null, + }; + return memory; + } catch { + memory = emptySession(); + return memory; + } +} + +function writeSession(next: StoresSession) { + memory = next; + try { + Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* ignore quota */ + } +} + +export function isStoresSessionBootstrapped(): boolean { + return readSession().bootstrapped; +} + +export function markStoresSessionBootstrapped(): void { + const cur = readSession(); + writeSession({ ...cur, bootstrapped: true }); +} + +export function getStoresListCache(): StoresListCache | null { + return readSession().cache; +} + +export function setStoresListCache(cache: StoresListCache | null): void { + const cur = readSession(); + writeSession({ ...cur, bootstrapped: true, cache }); +} + +export function patchStoresFilterCache( + patch: Partial< + Pick + >, +): void { + const cur = readSession(); + if (!cur.cache) { + // 列表尚未写入时也要记下用户筛选,避免切回丢失 + writeSession({ + bootstrapped: true, + cache: { + cityKey: '', + cityCode: '', + listRegion: patch.filterRegion ?? { province: '', city: '', district: '' }, + items: [], + filterRegion: patch.filterRegion ?? { province: '', city: '', district: '' }, + keyword: patch.keyword ?? '', + keywordInput: patch.keywordInput ?? '', + category: patch.category ?? { + parentId: '', + parentName: '', + childId: '', + childName: '', + }, + }, + }); + return; + } + writeSession({ + ...cur, + bootstrapped: true, + cache: { ...cur.cache, ...patch }, + }); +} + +export function resetStoresSessionBootstrap(): void { + memory = emptySession(); + try { + Taro.removeStorageSync(STORAGE_KEY); + } catch { + /* ignore */ + } +} diff --git a/apps/mini-user/src/lib/wechat-order-confirm.ts b/apps/mini-user/src/lib/wechat-order-confirm.ts index be87030..1fc22f2 100644 --- a/apps/mini-user/src/lib/wechat-order-confirm.ts +++ b/apps/mini-user/src/lib/wechat-order-confirm.ts @@ -1,5 +1,6 @@ import Taro from '@tarojs/taro'; import { request, toast } from './api'; +import { fetchClientConfig } from './pay-wechat'; /** 微信确认收货组件来源 AppId(官方固定) */ export const WECHAT_ORDER_CONFIRM_APPID = 'wx1183b055aeec94d1'; @@ -17,21 +18,54 @@ type PendingConfirm = { redirectUrl?: string; }; -type OpenBusinessViewFn = (opts: { +type OpenBusinessViewOptions = { businessType: string; extraData: Record; success?: () => void; fail?: (err: { errMsg?: string }) => void; -}) => void; + complete?: () => void; +}; -function getOpenBusinessView(): OpenBusinessViewFn | null { +type MiniWx = { + openBusinessView?: (opts: OpenBusinessViewOptions) => void; +}; + +/** + * 取小程序原生 wx.openBusinessView。 + * 官方兼容写法:`if (wx.openBusinessView) { ... }`(不要用 canIUse 挡业务组件)。 + * Taro 未封装该 API;模块作用域下可能读不到全局 wx,需多重回退。 + */ +function getOpenBusinessView(): ((opts: OpenBusinessViewOptions) => void) | 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); + + const candidates: Array = []; + + try { + // eslint-disable-next-line no-undef + if (typeof wx !== 'undefined') candidates.push(wx as MiniWx); + } catch { + /* ignore */ } + + const g = globalThis as typeof globalThis & { wx?: MiniWx }; + candidates.push(g.wx); + + try { + // 跳出 bundler 模块作用域,读微信运行时全局 + const fromRuntime = new Function( + 'return typeof wx !== "undefined" ? wx : null', + )() as MiniWx | null; + candidates.push(fromRuntime); + } catch { + /* ignore */ + } + + for (const api of candidates) { + if (api && typeof api.openBusinessView === 'function') { + return api.openBusinessView.bind(api); + } + } + return null; } @@ -51,8 +85,46 @@ export function takePendingWechatOrderConfirm(): PendingConfirm | null { } } +function normalizePayload(payload?: WechatConfirmPayload | null): WechatConfirmPayload { + return { + merchantId: payload?.merchantId?.trim() || undefined, + merchantTradeNo: payload?.merchantTradeNo?.trim() || undefined, + transactionId: payload?.transactionId?.trim() || undefined, + }; +} + +async function resolveConfirmPayload( + orderId: string, + hint?: WechatConfirmPayload | null, +): Promise { + const fromHint = normalizePayload(hint); + if (fromHint.transactionId || (fromHint.merchantId && fromHint.merchantTradeNo)) { + return fromHint; + } + + const order = await request<{ + orderNo?: string; + payExternalNo?: string | null; + payment?: { externalNo?: string | null } | null; + wechatConfirm?: WechatConfirmPayload | null; + }>(`/trade/orders/${orderId}`); + + const fromApi = normalizePayload(order.wechatConfirm); + if (fromApi.transactionId || (fromApi.merchantId && fromApi.merchantTradeNo)) { + return fromApi; + } + + const transactionId = + order.payExternalNo?.trim() || order.payment?.externalNo?.trim() || undefined; + return normalizePayload({ + transactionId, + merchantTradeNo: order.orderNo, + merchantId: fromApi.merchantId, + }); +} + /** - * 拉起微信「确认收货」半屏组件,资金侧确认与自家订单同步。 + * 拉起微信「确认收货」半屏组件。 * @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping-half.html */ export function openWechatOrderConfirm(opts: { @@ -61,12 +133,19 @@ export function openWechatOrderConfirm(opts: { redirectUrl?: string; }): Promise<'opened' | 'unsupported' | 'missing_pay_ref'> { const open = getOpenBusinessView(); - if (!open) return Promise.resolve('unsupported'); + if (!open) { + console.warn('[wechat-order-confirm] openBusinessView unavailable', { + taroEnv: process.env.TARO_ENV, + }); + return Promise.resolve('unsupported'); + } - const transactionId = opts.payload.transactionId?.trim(); - const merchantId = opts.payload.merchantId?.trim(); - const merchantTradeNo = opts.payload.merchantTradeNo?.trim(); + const payload = normalizePayload(opts.payload); + const transactionId = payload.transactionId; + const merchantId = payload.merchantId; + const merchantTradeNo = payload.merchantTradeNo; if (!transactionId && !(merchantId && merchantTradeNo)) { + console.warn('[wechat-order-confirm] missing pay ref', payload); return Promise.resolve('missing_pay_ref'); } @@ -81,16 +160,31 @@ export function openWechatOrderConfirm(opts: { }); return new Promise((resolve) => { - open({ - businessType: 'weappOrderConfirm', - extraData, - success: () => resolve('opened'), - fail: (err) => { - Taro.removeStorageSync(PENDING_KEY); - toast(err?.errMsg || '无法打开微信确认收货,请升级微信后重试'); - resolve('unsupported'); - }, - }); + let settled = false; + const done = (mode: 'opened' | 'unsupported' | 'missing_pay_ref') => { + if (settled) return; + settled = true; + resolve(mode); + }; + + try { + open({ + businessType: 'weappOrderConfirm', + extraData, + success: () => done('opened'), + fail: (err) => { + Taro.removeStorageSync(PENDING_KEY); + console.error('[wechat-order-confirm] openBusinessView fail', err, extraData); + toast(err?.errMsg || '无法打开微信确认收货,请稍后重试'); + done('unsupported'); + }, + }); + } catch (err) { + Taro.removeStorageSync(PENDING_KEY); + console.error('[wechat-order-confirm] openBusinessView throw', err); + toast('无法打开微信确认收货组件'); + done('unsupported'); + } }); } @@ -143,24 +237,11 @@ export async function handleWechatOrderConfirmShow(options?: { } } -/** 统一入口:小程序走微信组件;H5/无能力时降级为本地确认 */ -export async function confirmOrderReceive(opts: { +async function confirmLocally(opts: { orderId: string; - wechatConfirm?: WechatConfirmPayload | null; onSitePickup?: boolean; - redirectUrl?: string; - /** 降级本地确认成功后的回调(不经过微信回跳) */ onLocalSuccess?: () => void | Promise; -}): Promise<'wechat' | 'local'> { - const mode = await openWechatOrderConfirm({ - orderId: opts.orderId, - payload: opts.wechatConfirm || {}, - redirectUrl: opts.redirectUrl, - }); - - if (mode === 'opened') return 'wechat'; - - // Mock / H5 / 缺支付单号:本地确认(不通知微信资金侧) +}): Promise<'local'> { await request(`/trade/orders/${opts.orderId}/confirm-receive`, { method: 'POST', data: { @@ -171,3 +252,48 @@ export async function confirmOrderReceive(opts: { await opts.onLocalSuccess?.(); return 'local'; } + +/** + * 统一入口: + * - 小程序 + 真实支付:必须拉起 weappOrderConfirm,禁止静默降级 + * - Mock / H5:本地确认 + */ +export async function confirmOrderReceive(opts: { + orderId: string; + wechatConfirm?: WechatConfirmPayload | null; + onSitePickup?: boolean; + redirectUrl?: string; + onLocalSuccess?: () => void | Promise; +}): Promise<'wechat' | 'local'> { + const isWeapp = process.env.TARO_ENV === 'weapp'; + + if (!isWeapp) { + return confirmLocally(opts); + } + + let mockPay = false; + try { + const cfg = await fetchClientConfig(); + mockPay = !!cfg.mockPay; + } catch { + mockPay = false; + } + + if (mockPay) { + return confirmLocally(opts); + } + + const payload = await resolveConfirmPayload(opts.orderId, opts.wechatConfirm); + const mode = await openWechatOrderConfirm({ + orderId: opts.orderId, + payload, + redirectUrl: opts.redirectUrl, + }); + + if (mode === 'opened') return 'wechat'; + + if (mode === 'missing_pay_ref') { + throw new Error('缺少微信支付单号,无法打开确认收货组件'); + } + throw new Error('当前环境无法打开微信确认收货组件,请用微信最新版打开小程序后重试'); +} diff --git a/apps/mini-user/src/pages/benefit/index.tsx b/apps/mini-user/src/pages/benefit/index.tsx index bbe957f..df102b1 100644 --- a/apps/mini-user/src/pages/benefit/index.tsx +++ b/apps/mini-user/src/pages/benefit/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from 'react'; -import { View, Text } from '@tarojs/components'; +import { View, Text, Image } from '@tarojs/components'; import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import ShareNavButton from '../../components/ShareNavButton'; @@ -13,6 +13,7 @@ import { DEFAULT_SHARE_TITLE, toWeappShareMessage, } from '../../lib/wechat-share'; +import iconBenefit from '../../assets/tabbar/benefit-active.png'; type BenefitSummary = { totalBalance: number; @@ -153,7 +154,7 @@ export default function BenefitPage() { - + - AROMA_TABS.filter((item) => - products.some((product) => product.aromaType === item.key), - ), - [products], - ); - - useEffect(() => { - if (loading || availableAromas.length === 0) return; - if (!availableAromas.some((item) => item.key === tab)) { - setTab(availableAromas[0].key); - } - }, [availableAromas, loading, tab]); - function openProductDetail(id: string) { Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` }); } @@ -187,7 +172,7 @@ export default function HomePage() { - {availableAromas.map((t) => ( + {AROMA_TABS.map((t) => ( 当前城市暂无在售商品 ) : null} + {!loading && products.length > 0 && filtered.length === 0 ? ( + 该香型暂未上线 + ) : null} {!loading && filtered.map((p) => { const thumb = getProductMainImage(p); diff --git a/apps/mini-user/src/pages/orders/index.tsx b/apps/mini-user/src/pages/orders/index.tsx index 5fdc712..b1d8928 100644 --- a/apps/mini-user/src/pages/orders/index.tsx +++ b/apps/mini-user/src/pages/orders/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { View, Text, Image } from '@tarojs/components'; -import Taro, { usePullDownRefresh, useRouter } from '@tarojs/taro'; +import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; import { request, toast } from '../../lib/api'; @@ -15,10 +15,16 @@ const TABS = [ { key: 'completed', label: '已完成' }, ] as const; +/** 兼容历史链接 tab=done */ +function normalizeOrdersTab(raw?: string): string { + if (!raw) return 'all'; + if (raw === 'done') return 'completed'; + return TABS.some((t) => t.key === raw) ? raw : 'all'; +} + function orderStatusLabel(tab: string, status?: string): string { - if (tab !== 'all') { - return TABS.find((t) => t.key === tab)?.label || status || ''; - } + const tabLabel = TABS.find((t) => t.key === tab)?.label; + if (tab !== 'all' && tabLabel) return tabLabel; if (!status) return ''; return ORDER_STATUS_LABELS[status] || status; } @@ -47,8 +53,7 @@ type OrderRow = { export default function OrdersPage() { const router = useRouter(); - const initialTab = (router.params.tab as string) || 'all'; - const [tab, setTab] = useState(initialTab); + const [tab, setTab] = useState(() => normalizeOrdersTab(router.params.tab as string)); const [orders, setOrders] = useState([]); const [loading, setLoading] = useState(true); @@ -72,6 +77,12 @@ export default function OrdersPage() { void loadOrders(); }, [loadOrders]); + useDidShow(() => { + const next = normalizeOrdersTab(router.params.tab as string); + if (next !== tab) setTab(next); + else void loadOrders(); + }); + usePullDownRefresh(() => { void loadOrders().finally(() => Taro.stopPullDownRefresh()); }); diff --git a/apps/mini-user/src/pages/pickup-receive/index.tsx b/apps/mini-user/src/pages/pickup-receive/index.tsx index d709e4c..6fcdc15 100644 --- a/apps/mini-user/src/pages/pickup-receive/index.tsx +++ b/apps/mini-user/src/pages/pickup-receive/index.tsx @@ -23,11 +23,19 @@ type OrderDetail = { mainImageUrl?: string | null; carouselUrls?: string[] | null; }; + items?: Array<{ + productName?: string; + productSpec?: string; + productImage?: string; + quantity?: number; + }>; imageUrl?: string | null; mainImageUrl?: string | null; wechatConfirm?: WechatConfirmPayload | null; }; +const ORDERS_ALL_URL = '/pages/orders/index?tab=all'; + export default function PickupReceivePage() { const router = useRouter(); const orderId = router.params.id ?? router.params.orderId ?? ''; @@ -67,11 +75,11 @@ export default function PickupReceivePage() { orderId, wechatConfirm: order.wechatConfirm, onSitePickup: true, - redirectUrl: '/pages/orders/index?tab=done', + redirectUrl: ORDERS_ALL_URL, onLocalSuccess: async () => { toast('确认收货成功', 'success'); setTimeout(() => { - Taro.redirectTo({ url: '/pages/orders/index?tab=done' }); + Taro.redirectTo({ url: ORDERS_ALL_URL }); }, 500); }, }); @@ -83,9 +91,11 @@ export default function PickupReceivePage() { } } - const name = order?.productName || order?.product?.name || '商品'; - const spec = order?.productSpec || order?.product?.spec; + const item = order?.items?.[0]; + const name = item?.productName || order?.productName || order?.product?.name || '商品'; + const spec = item?.productSpec || order?.productSpec || order?.product?.spec; const image = + (item?.productImage || '').trim() || order?.mainImageUrl || order?.imageUrl || (order?.product ? getProductMainImage(order.product) : '') || diff --git a/apps/mini-user/src/pages/stores/index.tsx b/apps/mini-user/src/pages/stores/index.tsx index 207cacb..20cd50a 100644 --- a/apps/mini-user/src/pages/stores/index.tsx +++ b/apps/mini-user/src/pages/stores/index.tsx @@ -25,8 +25,16 @@ import { toCityWideRegion, type UserCoords, } from '../../lib/user-location'; +import { FALLBACK_CITY_CODE } from '../../lib/product-images'; import { formatDistanceMeters } from '../../lib/geo'; import { request, toast } from '../../lib/api'; +import { + getStoresListCache, + isStoresSessionBootstrapped, + markStoresSessionBootstrapped, + patchStoresFilterCache, + setStoresListCache, +} from '../../lib/stores-session'; import { DEFAULT_SHARE_DESC, DEFAULT_SHARE_TITLE, @@ -54,42 +62,48 @@ type Store = { distanceMeters?: number | null; }; -/** 筛选城市键(省+市);同城不重复请求 */ function makeCityKey(region: Pick): string { return `${region.province}|${region.city}`; } -function sameFilterCity(a: RegionSelection, b: RegionSelection): boolean { - return a.province === b.province && a.city === b.city; +/** + * 定位城市若未开城,接口会 fallback 到郑州 cityCode; + * 筛选器必须与真实拉取城市一致,否则列表被客户端滤空。 + */ +function regionForCatalogFetch(resolved: { + openCity: boolean; + cityCode?: string; + region: RegionSelection; +}): { cityCode: string; region: RegionSelection } { + const cityCode = getCityCodeForCatalog(resolved); + if (resolved.openCity && resolved.cityCode) { + return { cityCode, region: toCityWideRegion(resolved.region) }; + } + return { cityCode: FALLBACK_CITY_CODE, region: toCityWideRegion(DEFAULT_REGION) }; } -/** 跨 tab 切换 / 页面重建仍复用,避免同城反复打 /stores */ -type StoresListCache = { - cityKey: string; - cityCode: string; - region: RegionSelection; - items: Store[]; -}; -let storesListCache: StoresListCache | null = null; - export default function StoresPage() { - const [stores, setStores] = useState(() => storesListCache?.items ?? []); - const [loading, setLoading] = useState(() => !storesListCache); - const [keywordInput, setKeywordInput] = useState(''); - const [keyword, setKeyword] = useState(''); - // 必须与缓存城市对齐,否则 remount 时用默认「郑州」筛掉缓存列表会闪「暂无」 + const cached = getStoresListCache(); + const [stores, setStores] = useState(() => (cached?.items as Store[] | undefined) ?? []); + const [loading, setLoading] = useState(() => !cached && !isStoresSessionBootstrapped()); + const [keywordInput, setKeywordInput] = useState(() => cached?.keywordInput ?? ''); + const [keyword, setKeyword] = useState(() => cached?.keyword ?? ''); const [region, setRegion] = useState( - () => storesListCache?.region ?? DEFAULT_REGION, + () => cached?.filterRegion ?? cached?.listRegion ?? DEFAULT_REGION, ); const [regionOpen, setRegionOpen] = useState(false); - const [category, setCategory] = useState(EMPTY_CATEGORY); + const [category, setCategory] = useState( + () => cached?.category ?? EMPTY_CATEGORY, + ); const [categoryOpen, setCategoryOpen] = useState(false); const [categoryTree, setCategoryTree] = useState([]); - const fetchCityKeyRef = useRef(storesListCache?.cityKey ?? null); + const [locating, setLocating] = useState(false); + const fetchCityKeyRef = useRef(cached?.cityKey ?? null); const fetchSeqRef = useRef(0); + const regionRef = useRef(region); + regionRef.current = region; const regionLabel = formatRegionLabel(region); const categoryLabel = formatCategoryLabel(category); - /** 有数据时只显示列表,不用「加载中」盖住(避免闪烁) */ const showBootLoading = loading && stores.length === 0; const childIdsByParent = useMemo(() => { @@ -107,7 +121,9 @@ export default function StoresPage() { nextCode: string, coords: UserCoords | null, cityKey: string, - nextRegion: RegionSelection, + listRegion: RegionSelection, + /** 写入会话的筛选器;默认保留用户当前选择 */ + filterRegion: RegionSelection = regionRef.current, ) { const seq = ++fetchSeqRef.current; const qs = new URLSearchParams(); @@ -123,12 +139,17 @@ export default function StoresPage() { const items = Array.isArray(list) ? list : []; setStores(items); fetchCityKeyRef.current = cityKey; - storesListCache = { + const prev = getStoresListCache(); + setStoresListCache({ cityKey, cityCode: nextCode, - region: toCityWideRegion(nextRegion), + listRegion: toCityWideRegion(listRegion), items, - }; + filterRegion, + keyword: prev?.keyword ?? keyword, + keywordInput: prev?.keywordInput ?? keywordInput, + category: prev?.category ?? category, + }); } catch (e) { if (seq !== fetchSeqRef.current) return; toast(e instanceof Error ? e.message : '加载失败'); @@ -138,46 +159,48 @@ export default function StoresPage() { } /** - * 先稳住缓存画面 → 再定位;同城不请求;换城再拉。 + * 首次进入:弹窗 + 定位 + 拉列表。 + * 同次再切回:只同步 tab 选中态,不改筛选、不拉接口、不 setState。 */ useDidShow(() => { syncTabBarSelected(1); - // 同步恢复缓存,避免 await 定位期间 region 不对导致列表被滤空 - if (storesListCache) { - fetchCityKeyRef.current = storesListCache.cityKey; - setStores((prev) => (prev.length > 0 ? prev : storesListCache!.items)); - setRegion((prev) => - sameFilterCity(prev, storesListCache!.region) ? prev : storesListCache!.region, - ); - setLoading(false); + if (isStoresSessionBootstrapped()) { + return; } + markStoresSessionBootstrapped(); void (async () => { - const resolved = await resolveUserCity(); - const nextCode = getCityCodeForCatalog(resolved); - const nextRegion = toCityWideRegion(resolved.region); - const nextCityKey = makeCityKey(nextRegion); + const { confirm } = await Taro.showModal({ + title: '获取当前位置', + content: '是否允许获取当前位置来搜索附近门店?拒绝后将按默认城市展示,可下拉刷新重新定位。', + confirmText: '允许', + cancelText: '暂不', + }).catch(() => ({ confirm: false, cancel: true })); - if ( - fetchCityKeyRef.current === nextCityKey || - storesListCache?.cityKey === nextCityKey - ) { - if (storesListCache?.cityKey === nextCityKey) { - fetchCityKeyRef.current = nextCityKey; - setStores((prev) => (prev.length > 0 ? prev : storesListCache!.items)); - // 同城不覆盖用户已选区县 - setRegion((prev) => (sameFilterCity(prev, nextRegion) ? prev : nextRegion)); - } - setLoading(false); + if (confirm) { + setLoading(true); + const resolved = await resolveUserCity(true); + const { cityCode, region: nextRegion } = regionForCatalogFetch(resolved); + const nextCityKey = makeCityKey(nextRegion); + setRegion(nextRegion); + regionRef.current = nextRegion; + await fetchStores( + cityCode, + readCachedUserCoords(), + nextCityKey, + nextRegion, + nextRegion, + ); return; } - // 换城:先清空再 loading,避免旧城数据 + 新城筛选交叉闪一下 - setStores([]); + const nextRegion = toCityWideRegion(DEFAULT_REGION); + const nextCityKey = makeCityKey(nextRegion); setRegion(nextRegion); + regionRef.current = nextRegion; setLoading(true); - await fetchStores(nextCode, readCachedUserCoords(), nextCityKey, nextRegion); + await fetchStores(FALLBACK_CITY_CODE, null, nextCityKey, nextRegion, nextRegion); })(); }); @@ -191,15 +214,21 @@ export default function StoresPage() { void (async () => { try { const resolved = await resolveUserCity(true); - const nextCode = getCityCodeForCatalog(resolved); - const nextRegion = toCityWideRegion(resolved.region); + const { cityCode, region: nextRegion } = regionForCatalogFetch(resolved); const nextCityKey = makeCityKey(nextRegion); setRegion(nextRegion); + regionRef.current = nextRegion; if (fetchCityKeyRef.current !== nextCityKey) { setStores([]); setLoading(true); } - await fetchStores(nextCode, readCachedUserCoords(), nextCityKey, nextRegion); + await fetchStores( + cityCode, + readCachedUserCoords(), + nextCityKey, + nextRegion, + nextRegion, + ); } catch (e) { toast(e instanceof Error ? e.message : '加载失败'); setLoading(false); @@ -230,7 +259,9 @@ export default function StoresPage() { }); function applySearch() { - setKeyword(keywordInput.trim()); + const next = keywordInput.trim(); + setKeyword(next); + patchStoresFilterCache({ keyword: next, keywordInput }); } function resetFilters() { @@ -238,6 +269,49 @@ export default function StoresPage() { setKeyword(''); setCategory(EMPTY_CATEGORY); setRegion(DEFAULT_REGION); + regionRef.current = DEFAULT_REGION; + patchStoresFilterCache({ + keyword: '', + keywordInput: '', + category: EMPTY_CATEGORY, + filterRegion: DEFAULT_REGION, + }); + } + + async function locateToUserRegion() { + if (locating) return; + const { confirm } = await Taro.showModal({ + title: '获取当前位置', + content: '是否允许获取当前位置,并将筛选定位到您所在的城市与区县?', + confirmText: '允许', + cancelText: '暂不', + }).catch(() => ({ confirm: false, cancel: true })); + if (!confirm) return; + + setLocating(true); + setLoading(true); + try { + const resolved = await resolveUserCity(true); + // 筛选器用真实省市+区县;拉数仍按开城 cityCode(未开城则郑州) + const filterRegion = resolved.region; + const { cityCode, region: listRegion } = regionForCatalogFetch(resolved); + const nextCityKey = makeCityKey(listRegion); + setRegion(filterRegion); + regionRef.current = filterRegion; + await fetchStores( + cityCode, + readCachedUserCoords(), + nextCityKey, + listRegion, + filterRegion, + ); + toast(`已定位到${formatRegionLabel(filterRegion)}`, 'success'); + } catch (e) { + toast(e instanceof Error ? e.message : '定位失败'); + setLoading(false); + } finally { + setLocating(false); + } } function formatHours(store: Store) { @@ -291,9 +365,23 @@ export default function StoresPage() { {categoryLabel} - - 重置 - + + {/* 小程序 View 伪元素不稳定,用 Text 保证真机可见 */} + + + { + void locateToUserRegion(); + }} + aria-label="获取当前位置" + > + + @@ -349,14 +437,21 @@ export default function StoresPage() { value={region} levels={3} onClose={() => setRegionOpen(false)} - onConfirm={(next) => setRegion(next)} + onConfirm={(next) => { + setRegion(next); + regionRef.current = next; + patchStoresFilterCache({ filterRegion: next }); + }} /> setCategoryOpen(false)} - onConfirm={(next) => setCategory(next)} + onConfirm={(next) => { + setCategory(next); + patchStoresFilterCache({ category: next }); + }} /> ); diff --git a/apps/mini-user/src/styles/benefit.css b/apps/mini-user/src/styles/benefit.css index 72605ae..6f93bae 100644 --- a/apps/mini-user/src/styles/benefit.css +++ b/apps/mini-user/src/styles/benefit.css @@ -126,6 +126,13 @@ color: var(--color-heritage-red); font-weight: 700; font-size: 18px; + overflow: hidden; +} + +.benefit-hero-logo-img { + width: 28px; + height: 28px; + display: block; } .benefit-hero-cta { diff --git a/apps/mini-user/src/styles/stores.css b/apps/mini-user/src/styles/stores.css index eae8193..7fa2279 100644 --- a/apps/mini-user/src/styles/stores.css +++ b/apps/mini-user/src/styles/stores.css @@ -116,15 +116,27 @@ color: var(--color-subtle-gray); } -.store-filter-reset { +.store-filter-icon-btn { flex-shrink: 0; + width: 36px; height: 36px; - padding: 0 10px; + border-radius: var(--radius-md); + background: var(--color-surface-container-low); display: flex; align-items: center; justify-content: center; - font-size: 13px; - font-weight: 600; + color: var(--color-heritage-red); + box-sizing: border-box; +} + +.store-filter-icon-btn--busy { + opacity: 0.5; +} + +.store-filter-icon-glyph { + font-size: 18px; + line-height: 1; + font-weight: 700; color: var(--color-heritage-red); } diff --git a/apps/mini-user/types/global.d.ts b/apps/mini-user/types/global.d.ts index 0c14519..49d6fdd 100644 --- a/apps/mini-user/types/global.d.ts +++ b/apps/mini-user/types/global.d.ts @@ -14,6 +14,14 @@ declare const definePageConfig: (config: Record) => Record) => void; + canIUse?: (schema: string) => boolean; + requestPayment?: (opts: Record) => void; + [key: string]: unknown; +}; + declare namespace NodeJS { interface ProcessEnv { TARO_ENV: 'weapp' | 'h5' | string; diff --git a/packages/shared-types/src/trade.ts b/packages/shared-types/src/trade.ts index be09c16..f0dcbcd 100644 --- a/packages/shared-types/src/trade.ts +++ b/packages/shared-types/src/trade.ts @@ -111,3 +111,20 @@ export type PartnerProxyOrderCreateRequest = { quantity: number; promoCodeId?: string; }; + +/** 总部代下单(对齐合伙人双短信流程;确认码发至 HQ 登录手机) */ +export type HqProxyOrderCreateRequest = { + phone: string; + customerSmsCode: string; + operatorSmsCode: string; + deliveryMode: PartnerProxyDeliveryMode; + autoReceive?: boolean; + receiverName?: string; + province?: string; + city?: string; + district?: string; + addressDetail?: string; + productId: string; + quantity: number; + promoCodeId?: string; +}; diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index cd84429..c59cdce 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -22,6 +22,7 @@ export const HqOperationAction = { ORDER_SHIP: 'ORDER_SHIP', ORDER_STATUS_DEBUG: 'ORDER_STATUS_DEBUG', ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE', + ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE', STORE_CREATE: 'STORE_CREATE', STORE_UPDATE: 'STORE_UPDATE', STORE_STATUS: 'STORE_STATUS', @@ -122,6 +123,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.ORDER_SHIP]: '订单发货', [HqOperationAction.ORDER_STATUS_DEBUG]: '订单状态调试', [HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单', + [HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单', [HqOperationAction.STORE_CREATE]: '新增门店', [HqOperationAction.STORE_UPDATE]: '编辑门店', [HqOperationAction.STORE_STATUS]: '变更门店状态', diff --git a/server/dukang-api/src/modules/ops/admin-proxy-orders.controller.ts b/server/dukang-api/src/modules/ops/admin-proxy-orders.controller.ts new file mode 100644 index 0000000..585e98f --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-proxy-orders.controller.ts @@ -0,0 +1,59 @@ +import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { + HqPermissionGuard, + RequireHqPermissions, +} from '../../common/guards/hq-permission.guard'; +import type { AuthUser } from '../../common/guards/jwt-auth.guard'; +import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; +import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; +import { TradeService } from '../trade/trade.service'; +import { + HqProxyOrderCreateDto, + HqProxyOrderPreviewDto, + HqProxyOrderSendCustomerSmsDto, +} from './dto/hq-proxy-order.dto'; + +@Controller('admin/proxy-orders') +@UseGuards(HqAuthGuard, HqPermissionGuard) +@RequireHqPermissions('orders') +export class AdminProxyOrdersController { + constructor(private readonly tradeService: TradeService) {} + + @Get('options') + options() { + return this.tradeService.getHqProxyOrderOptions(); + } + + @Post('preview') + preview(@Body() dto: HqProxyOrderPreviewDto) { + return this.tradeService.previewPartnerProxyOrder(dto); + } + + @Post('send-customer-sms') + sendCustomerSms(@Body() dto: HqProxyOrderSendCustomerSmsDto) { + return this.tradeService.sendHqProxyCustomerSms(dto.phone); + } + + @Post('send-operator-sms') + sendOperatorSms(@CurrentUser() user: AuthUser) { + return this.tradeService.sendHqProxyOperatorSms(user.actorId); + } + + @Post() + @HqOperation({ + action: HqOperationAction.ORDER_PROXY_CREATE, + refType: 'ORDER', + batch: true, + includeBody: true, + }) + create( + @CurrentUser() user: AuthUser, + @Body() dto: HqProxyOrderCreateDto, + @Req() req: Request, + ) { + return this.tradeService.createHqProxyOrder(user.actorId, dto, req); + } +} diff --git a/server/dukang-api/src/modules/ops/dto/hq-proxy-order.dto.ts b/server/dukang-api/src/modules/ops/dto/hq-proxy-order.dto.ts new file mode 100644 index 0000000..b21d75b --- /dev/null +++ b/server/dukang-api/src/modules/ops/dto/hq-proxy-order.dto.ts @@ -0,0 +1,102 @@ +import { Type } from 'class-transformer'; +import { + IsBoolean, + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, + Min, + ValidateIf, +} from 'class-validator'; + +export class HqProxyOrderPreviewDto { + @IsString() + @IsNotEmpty() + productId: string; + + @Type(() => Number) + @IsInt() + @Min(1) + quantity: number; + + @IsOptional() + @IsIn(['ADDRESS', 'ON_SITE_PICKUP']) + deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP'; + + @IsOptional() + @IsString() + receiverCity?: string; + + @IsOptional() + @IsString() + receiverDistrict?: string; +} + +export class HqProxyOrderSendCustomerSmsDto { + @IsString() + @Matches(/^1\d{10}$/, { message: '请输入有效手机号' }) + phone: string; +} + +export class HqProxyOrderCreateDto { + @IsString() + @Matches(/^1\d{10}$/, { message: '请输入有效手机号' }) + phone: string; + + @IsString() + @IsNotEmpty() + customerSmsCode: string; + + /** 发至当前 HQ 登录手机号的确认验证码 */ + @IsString() + @IsNotEmpty() + operatorSmsCode: string; + + @IsIn(['ADDRESS', 'ON_SITE_PICKUP']) + deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP'; + + @ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS') + @IsBoolean() + autoReceive?: boolean; + + @IsOptional() + @IsString() + @MaxLength(32) + receiverName?: string; + + @ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS') + @IsString() + @MaxLength(32) + province?: string; + + @ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS') + @IsString() + @MaxLength(32) + city?: string; + + @ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS') + @IsString() + @MaxLength(32) + district?: string; + + @ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS') + @IsString() + @MaxLength(256) + addressDetail?: string; + + @IsString() + @IsNotEmpty() + productId: string; + + @Type(() => Number) + @IsInt() + @Min(1) + quantity: number; + + @IsOptional() + @IsString() + promoCodeId?: string; +} diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index a5a7ee5..2b78686 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -10,6 +10,7 @@ import { AdminUsersController } from './admin-users.controller'; import { AdminUsersService } from './admin-users.service'; import { AdminOrdersController } from './admin-orders.controller'; import { AdminOrdersService } from './admin-orders.service'; +import { AdminProxyOrdersController } from './admin-proxy-orders.controller'; import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller'; import { AdminStoreCategoriesController } from './admin-store-categories.controller'; import { AdminStoresService } from './admin-stores.service'; @@ -76,6 +77,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide AdminDeployController, AdminUsersController, AdminOrdersController, + AdminProxyOrdersController, AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController, diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 7ee9543..7647ee4 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -1383,4 +1383,255 @@ export class TradeService { return this.getPartnerOrder(partnerAccountId, order.id); } + + /** HQ 代下单:商品/推广码选项(不绑定合伙人门店) */ + async getHqProxyOrderOptions() { + const [products, promoCodes] = await Promise.all([ + this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }), + this.promoCodeService.listActiveOptions(), + ]); + return serializeBigInt({ + products: products.map((p) => ({ + id: p.id, + name: p.name, + spec: p.spec, + price: Number(p.price), + benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null, + coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null, + allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup, + })), + promoCodes, + stores: [], + }); + } + + async sendHqProxyCustomerSms(phone: string) { + const normalizedPhone = phone.trim(); + await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_CUSTOMER, { + clientApp: ClientApp.HQ_WEB, + }); + const masked = + normalizedPhone.length >= 7 + ? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}` + : normalizedPhone; + return { ok: true, maskedPhone: masked }; + } + + async sendHqProxyOperatorSms(hqAccountId: bigint) { + const hq = await this.prisma.hqAccount.findUnique({ where: { id: hqAccountId } }); + if (!hq || hq.status !== 'ACTIVE') { + throw new BadRequestException('总部账号无效'); + } + const operatorPhone = hq.phone?.trim(); + if (!operatorPhone || !/^1\d{10}$/.test(operatorPhone)) { + throw new BadRequestException('总部账号手机号无效,无法发送确认验证码'); + } + await this.authService.sendSms(operatorPhone, SmsScene.PARTNER_PROXY_ORDER, { + clientApp: ClientApp.HQ_WEB, + }); + const masked = + operatorPhone.length >= 7 + ? `${operatorPhone.slice(0, 3)}****${operatorPhone.slice(-4)}` + : operatorPhone; + return { ok: true, maskedPhone: masked }; + } + + async createHqProxyOrder( + hqAccountId: bigint, + body: { + phone: string; + customerSmsCode: string; + operatorSmsCode: string; + deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP'; + autoReceive?: boolean; + receiverName?: string; + province?: string; + city?: string; + district?: string; + addressDetail?: string; + productId: string; + quantity: number; + promoCodeId?: string; + }, + req: Request, + ) { + const normalizedPhone = body.phone.trim(); + const hq = await this.prisma.hqAccount.findUnique({ where: { id: hqAccountId } }); + if (!hq || hq.status !== 'ACTIVE') { + throw new BadRequestException('总部账号无效'); + } + const operatorPhone = hq.phone?.trim(); + if (!operatorPhone || !/^1\d{10}$/.test(operatorPhone)) { + throw new BadRequestException('总部账号手机号无效'); + } + + await this.authService.verifySmsCode( + normalizedPhone, + body.customerSmsCode.trim(), + SmsScene.PARTNER_PROXY_CUSTOMER, + ); + await this.authService.verifySmsCode( + operatorPhone, + body.operatorSmsCode.trim(), + SmsScene.PARTNER_PROXY_ORDER, + ); + + if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) { + throw new BadRequestException('配送到址须勾选同意自动收货'); + } + + const maskedOperatorPhone = + operatorPhone.length >= 7 + ? `${operatorPhone.slice(0, 3)}****${operatorPhone.slice(-4)}` + : operatorPhone; + const proxyDisplayName = `总部·${hq.name}`; + + const user = await this.authService.findOrCreateUserByPhone(normalizedPhone, { + sourceType: 'PARTNER_PROXY', + sourceRefId: hq.id, + sourceLabel: `总部代下单·${maskedOperatorPhone}`, + }); + + const preview = await this.previewPartnerProxyOrder({ + productId: body.productId, + quantity: body.quantity, + deliveryMode: body.deliveryMode, + receiverCity: body.city, + receiverDistrict: body.district, + }); + + const product = await this.prisma.commonProductItem.findUniqueOrThrow({ + where: { id: BigInt(body.productId) }, + }); + const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } }); + + let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`; + let receiverProvince = body.province?.trim() || ''; + let receiverCity = body.city?.trim() || ''; + let receiverDistrict = body.district?.trim() || ''; + let receiverAddress = ''; + let commissionDistrict = receiverDistrict; + + if (body.deliveryMode === 'ON_SITE_PICKUP') { + receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`; + receiverProvince = '现场'; + receiverCity = '现场'; + receiverDistrict = '取货'; + receiverAddress = '现场提货'; + commissionDistrict = ''; + } else { + if (!receiverProvince || !receiverCity || !receiverDistrict) { + throw new BadRequestException('请选择省市区'); + } + if (!body.addressDetail?.trim()) { + throw new BadRequestException('请填写详细地址'); + } + receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`; + } + + const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict); + + let promoCodeId: bigint | undefined; + if (body.promoCodeId?.trim()) { + promoCodeId = BigInt(body.promoCodeId.trim()); + await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId); + } + + const orderNo = generateOrderNo(); + const now = new Date(); + const location = buildOrderClientLocationSnapshot( + req, + this.ipGeoService.resolve(extractClientIp(req)), + undefined, + ); + + const order = await this.prisma.$transaction(async (tx) => { + const created = await tx.order.create({ + data: { + orderNo, + orderType: 'PROXY', + userId: user.id, + cityId: city.id, + status: 'COMPLETED', + payStatus: 'PAID', + deliveryType: preview.deliveryType, + channelSource: 'OFFLINE_PROXY', + promoCodeId, + productId: product.id, + barcode69: product.barcode69, + productName: product.name, + productSpec: product.spec, + imageResourceId: product.coverResourceId, + quantity: body.quantity, + listUnitPrice: product.price, + listAmount: preview.productAmount, + productAmount: preview.productAmount, + payAmount: preview.payAmount, + benefitAmount: preview.benefitAmount, + freightAmount: 0, + freightPayType: preview.deliveryType === 'CROSS_CITY' ? 'COD' : null, + receiverName, + receiverPhone: normalizedPhone, + receiverAddress, + receiverProvince, + receiverCity, + receiverDistrict, + clientIp: location.clientIp, + ipProvince: location.ipProvince, + ipCity: location.ipCity, + ipDistrict: location.ipDistrict, + paidAt: now, + shippedAt: now, + completedAt: now, + partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null, + orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null, + proxyPartnerAccountId: null, + proxyPartnerName: proxyDisplayName, + proxyPartnerPhone: operatorPhone, + remark: `总部代下单 hqAccountId=${hq.id} deliveryMode=${body.deliveryMode} customer=${normalizedPhone}`, + }, + include: { product: true, imageResource: true }, + }); + + await tx.orderDelivery.create({ + data: { + orderId: created.id, + provider: 'MANUAL', + outWarehouseAt: now, + shippingAt: now, + deliveredAt: now, + }, + }); + + await tx.commonEvent.create({ + data: buildOrderStatusEvent({ + orderId: created.id, + fromStatus: 'PENDING_PAY', + toStatus: 'COMPLETED', + operator: 'HQ_PROXY', + remark: `总部线下代下单 mode=${body.deliveryMode} customer=${normalizedPhone} hq=${hq.id}`, + }), + }); + + if (promoCodeId) { + await tx.commonPromoCode.update({ + where: { id: promoCodeId }, + data: { orderCount: { increment: 1 } }, + }); + } + + return created; + }); + + await this.benefitService.grantOnOrderPaid(order.id); + + return serializeBigInt({ + id: order.id, + orderNo: order.orderNo, + status: order.status, + payAmount: Number(order.payAmount), + benefitAmount: Number(order.benefitAmount), + proxyPartnerName: proxyDisplayName, + }); + } }