From cc0c0a6ef878617382722b64c6ff96e7a591cd0c Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Tue, 25 Aug 2026 21:12:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(fulfillment):=20=E5=90=8C=E5=9F=8E?= =?UTF-8?q?=E8=BF=90=E8=B4=B9=E4=B8=8E=E8=B7=AF=E7=94=B1=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=8C=E9=85=8D=E9=80=81=E5=8D=95=E5=B1=95=E7=A4=BA=E5=95=86?= =?UTF-8?q?=E5=93=81=E7=94=A8=E6=88=B7=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同城 MANUAL/ZZXFX 按小飞侠价规计费,路由查询回退仓配凭证;HQ 配送单补商品、用户和收货地址。门店核销回跳与小程序核销码一并带上。 Co-authored-by: Cursor --- .../components/AdminStorePackagesSection.tsx | 72 +++--- .../src/components/OrderTrackDrawer.tsx | 4 +- apps/admin-web/src/layouts/AdminLayout.tsx | 14 +- apps/admin-web/src/lib/api.ts | 2 + apps/admin-web/src/pages/DeliveriesPage.tsx | 238 +++++++++++++++--- apps/admin-web/src/pages/OrdersPage.tsx | 12 + apps/h5-shop/src/components/AuthGate.tsx | 6 +- apps/h5-shop/src/lib/redeem-scan.ts | 23 +- apps/h5-shop/src/lib/shop-nav.ts | 14 ++ apps/h5-shop/src/lib/shop-redeem-return.ts | 45 ++++ apps/h5-shop/src/pages/RedeemConfirmPage.tsx | 2 + apps/h5-shop/src/pages/RedeemSuccessPage.tsx | 9 +- apps/h5-shop/src/pages/SelectStorePage.tsx | 25 +- .../mini-user/src/components/RedeemQrCode.tsx | 17 +- apps/mini-user/src/lib/brand-assets.ts | 8 + apps/mini-user/src/lib/redeem-qr.ts | 12 +- .../mini-user/src/pages/redeem-code/index.tsx | 6 +- apps/mini-user/src/pages/redeem/index.tsx | 7 +- packages/shared-types/package.json | 4 +- .../src/fulfillment-provider.test.ts | 18 ++ .../shared-types/src/fulfillment-provider.ts | 7 +- packages/shared-types/src/redeem.test.ts | 39 +++ packages/shared-types/src/redeem.ts | 42 ++++ packages/shared-types/src/trade.ts | 2 + packages/shared-types/tsconfig.json | 3 +- packages/shared-types/vitest.config.ts | 7 + pnpm-lock.yaml | 3 + server/dukang-api/.env.example | 3 + server/dukang-api/.env.production.example | 2 + server/dukang-api/.env.staging.example | 2 + .../scripts/gen-staging-env-from-prod.cjs | 1 + .../fulfillment/delivery-freight.util.test.ts | 82 ++++++ .../fulfillment/delivery-freight.util.ts | 31 +++ .../fulfillment-provider.service.ts | 23 +- .../fulfillment/fulfillment.service.ts | 89 +++++-- .../fulfillment/xfx-goods.util.test.ts | 87 +++++++ .../src/modules/fulfillment/xfx-goods.util.ts | 58 +++++ .../modules/ops/admin-dashboard.service.ts | 7 +- .../src/modules/ops/admin-orders.service.ts | 71 +++++- .../src/modules/ops/admin-redeem.service.ts | 89 +++++-- .../src/modules/redeem/redeem.service.ts | 11 +- .../modules/store/store-package.service.ts | 3 +- .../src/modules/trade/trade.service.ts | 44 ++-- server/dukang-api/tsconfig.json | 3 +- 44 files changed, 1029 insertions(+), 218 deletions(-) create mode 100644 apps/h5-shop/src/lib/shop-nav.ts create mode 100644 apps/h5-shop/src/lib/shop-redeem-return.ts create mode 100644 packages/shared-types/src/fulfillment-provider.test.ts create mode 100644 packages/shared-types/src/redeem.test.ts create mode 100644 packages/shared-types/vitest.config.ts create mode 100644 server/dukang-api/src/modules/fulfillment/delivery-freight.util.test.ts create mode 100644 server/dukang-api/src/modules/fulfillment/delivery-freight.util.ts create mode 100644 server/dukang-api/src/modules/fulfillment/xfx-goods.util.test.ts create mode 100644 server/dukang-api/src/modules/fulfillment/xfx-goods.util.ts diff --git a/apps/admin-web/src/components/AdminStorePackagesSection.tsx b/apps/admin-web/src/components/AdminStorePackagesSection.tsx index ee7a59d..cec0e3c 100644 --- a/apps/admin-web/src/components/AdminStorePackagesSection.tsx +++ b/apps/admin-web/src/components/AdminStorePackagesSection.tsx @@ -11,10 +11,23 @@ import PackageImagesUpload from './PackageImagesUpload'; type PackageRow = StorePackageItemDto; export type AdminStorePackagesHandle = { - /** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */ + /** 仅在用户改过套餐时写入;加载中或未改动则跳过,避免空表单覆盖刚审核通过的线上套餐 */ saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>; }; +function mapLiveRows(live: StorePackagesResponse['live']): PackageRow[] { + return (live ?? []).map((p, i) => { + const imageUrls = normalizeStorePackageImageUrls(p); + return { + ...p, + price: String(p.price), + imageUrl: imageUrls[0] ?? '', + imageUrls, + sortOrder: i, + }; + }); +} + function emptyRow(index = 0): PackageRow { return { name: '', @@ -37,6 +50,7 @@ const AdminStorePackagesSection = forwardRef(null); const itemsRef = useRef(items); const loadingRef = useRef(loading); + const dirtyRef = useRef(false); const navigate = useNavigate(); useEffect(() => { @@ -47,36 +61,33 @@ const AdminStorePackagesSection = forwardRef { setLoading(true); setPendingRequest(null); + dirtyRef.current = false; request(`/admin/stores/${storeId}/packages`) - .then((data) => { - setPendingRequest(data.pendingRequest ?? null); - setItems( - data.live?.length - ? data.live.map((p, i) => { - const imageUrls = normalizeStorePackageImageUrls(p); - return { - ...p, - price: String(p.price), - imageUrl: imageUrls[0] ?? '', - imageUrls, - sortOrder: i, - }; - }) - : [], - ); - }) + .then((data) => applyServerPackages(data)) .catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败')) .finally(() => setLoading(false)); }, [storeId]); - // 在审核页完成审核后,自动刷新本页「有待审核套餐」提醒 + // 审核通过/驳回后刷新提醒;用户未改套餐时同步线上结果,避免抽屉里仍显示空套餐 useEffect(() => { const onChanged = () => { request(`/admin/stores/${storeId}/packages`) - .then((data) => setPendingRequest(data.pendingRequest ?? null)) + .then((data) => { + setPendingRequest(data.pendingRequest ?? null); + if (!dirtyRef.current) { + dirtyRef.current = false; + setItems(mapLiveRows(data.live)); + } + }) .catch(() => undefined); }; window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged); @@ -108,16 +119,19 @@ const AdminStorePackagesSection = forwardRef) { + dirtyRef.current = true; setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); } function addRow() { if (items.length >= STORE_PACKAGE_MAX_COUNT) return; + dirtyRef.current = true; setItems((prev) => [...prev, emptyRow(prev.length)]); } function removeAt(index: number) { const run = () => { + dirtyRef.current = true; setItems((prev) => { const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })); return next.length ? next : []; @@ -203,20 +217,8 @@ const AdminStorePackagesSection = forwardRef { - const imageUrls = normalizeStorePackageImageUrls(p); - return { - ...p, - price: String(p.price), - imageUrl: imageUrls[0] ?? '', - imageUrls, - sortOrder: i, - }; - }) - : [], - ); + dirtyRef.current = false; + setItems(mapLiveRows(data.live)); } catch (e) { if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败'); throw e; @@ -227,7 +229,7 @@ const AdminStorePackagesSection = forwardRef ({ saveIfLoaded: async (opts) => { - if (loadingRef.current) return { skipped: true }; + if (loadingRef.current || !dirtyRef.current) return { skipped: true }; await save(opts); return { skipped: false }; }, diff --git a/apps/admin-web/src/components/OrderTrackDrawer.tsx b/apps/admin-web/src/components/OrderTrackDrawer.tsx index af37671..143d837 100644 --- a/apps/admin-web/src/components/OrderTrackDrawer.tsx +++ b/apps/admin-web/src/components/OrderTrackDrawer.tsx @@ -173,7 +173,9 @@ export default function OrderTrackDrawer({ = Object.fromEntries( + HQ_ADMIN_ROLES.map((r) => [r.value, r.label]), +); + type MenuItem = NonNullable[number]; const MENU_ITEMS: MenuProps['items'] = [ @@ -405,7 +413,9 @@ export default function AdminLayout() { {profile?.name || '—'} - {profile?.adminRole} + + {HQ_ROLE_LABELS[profile?.adminRole ?? ''] || profile?.adminRole || ''} + diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 96c661d..d96b449 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -236,5 +236,7 @@ export type AdminOrderRow = { providerOrderNo: string | null; logisticsCompany?: string | null; manualQueryUrl?: string | null; + /** 当次应付物流费(按瓶当量 × 承运商计价) */ + logisticsFee?: number | null; }; }; diff --git a/apps/admin-web/src/pages/DeliveriesPage.tsx b/apps/admin-web/src/pages/DeliveriesPage.tsx index 96d9892..76e4474 100644 --- a/apps/admin-web/src/pages/DeliveriesPage.tsx +++ b/apps/admin-web/src/pages/DeliveriesPage.tsx @@ -1,16 +1,43 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import OrderTrackDrawer from '../components/OrderTrackDrawer'; import { request } from '../lib/api'; -import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { + DELIVERY_PROVIDER_LABELS, + DELIVERY_TYPE_LABELS, + ORDER_STATUS_LABELS, + fmtTime, +} from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; import { AdminListHeader } from '../components/AdminListHeader'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; +type DeliveryOrder = { + id: string; + orderNo: string; + status: string; + deliveryType: string; + productName: string; + productSpec?: string | null; + barcode69?: string | null; + quantity: number; + saleUnit?: string; + bottlesPerUnit?: number; + payAmount?: number; + receiverName: string; + receiverPhone: string; + receiverAddress?: string; + receiverProvince?: string; + receiverCity?: string; + receiverDistrict?: string; + user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; + imageResource?: { url: string } | null; +}; type Row = { id: string; @@ -19,19 +46,34 @@ type Row = { trackingNo: string | null; providerOrderNo: string | null; updatedAt: string; - /** 当次应付物流费 */ logisticsFee?: number | null; - order?: { - id: string; - orderNo: string; - status: string; - receiverName: string; - receiverPhone: string; - deliveryType: string; - }; + order?: DeliveryOrder; }; +function formatQty(order?: DeliveryOrder | null) { + if (!order || order.quantity == null) return '—'; + const unit = order.saleUnit === 'BOX' ? '箱' : '瓶'; + const bottles = + order.saleUnit === 'BOX' && order.bottlesPerUnit && order.bottlesPerUnit > 1 + ? `(${order.quantity * order.bottlesPerUnit}瓶)` + : ''; + return `${order.quantity}${unit}${bottles}`; +} + +function formatAddress(order?: DeliveryOrder | null) { + if (!order) return '—'; + if (order.deliveryType === 'ON_SITE_PICKUP') return '现场取货'; + const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict] + .filter(Boolean) + .join(''); + const detail = (order.receiverAddress || '').trim(); + if (!region) return detail || '—'; + if (!detail || detail.startsWith(region)) return detail || region; + return `${region}${detail}`; +} + export default function DeliveriesPage() { + const navigate = useNavigate(); const [form] = Form.useForm(); const [editForm] = Form.useForm(); const [filters, setFilters] = useState>({}); @@ -65,25 +107,91 @@ export default function DeliveriesPage() { setTrackOpen(true); } + async function openDetail(row: Row) { + const d = await request(`/admin/deliveries/${row.id}`); + setDetail(d); + editForm.setFieldsValue({ + provider: d.provider, + trackingNo: d.trackingNo, + providerOrderNo: d.providerOrderNo, + }); + setDrawerOpen(true); + } + const baseColumns: ColumnsType = [ { title: '订单号', dataIndex: ['order', 'orderNo'], width: 170, render: (v, row) => ( - { - const d = await request(`/admin/deliveries/${row.id}`); - setDetail(d); - editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo }); - setDrawerOpen(true); - }} - > - {v} - + void openDetail(row)}>{v} ), }, - { title: 'provider', dataIndex: 'provider', width: 90 }, + { + title: '用户', + key: 'user', + width: 140, + render: (_, row) => { + const user = row.order?.user; + if (!user) return '—'; + const label = user.userNo || user.phone || '—'; + return ( +
+ {user.id ? ( + navigate('/users', { state: { openUserId: String(user.id) } })} + > + {label} + + ) : ( + label + )} + {user.phone && user.userNo ? ( + + {user.phone} + + ) : null} +
+ ); + }, + }, + { + title: '商品', + key: 'product', + width: 200, + render: (_, row) => { + const order = row.order; + if (!order?.productName) return '—'; + return ( +
+ {order.productName} + {order.productSpec ? ( + + {order.productSpec} + + ) : null} +
+ ); + }, + }, + { + title: '数量', + key: 'quantity', + width: 90, + render: (_, row) => formatQty(row.order), + }, + { + title: '配送方式', + dataIndex: ['order', 'deliveryType'], + width: 90, + render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—', + }, + { + title: '承运商', + dataIndex: 'provider', + width: 90, + render: (v: string) => DELIVERY_PROVIDER_LABELS[v] || v || '—', + }, { title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' }, { title: '运费', @@ -92,20 +200,33 @@ export default function DeliveriesPage() { render: (v: number | null | undefined) => (v == null ? '—' : `¥${Number(v).toFixed(2)}`), }, { title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' }, - { title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s }, + { + title: '订单状态', + dataIndex: ['order', 'status'], + width: 100, + render: (s) => ORDER_STATUS_LABELS[s] || s, + }, { title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 }, + { + title: '收货电话', + dataIndex: ['order', 'receiverPhone'], + width: 120, + render: (v) => v || '—', + }, + { + title: '配送地址', + key: 'address', + width: 280, + ellipsis: true, + render: (_, row) => formatAddress(row.order), + }, { title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime }, { title: '操作', width: 140, render: (_, row) => ( - + ), }, @@ -113,19 +234,21 @@ export default function DeliveriesPage() { const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize }); + const order = detail?.order; + return (
{settingsModal}
{ setFilters(v); setPage(1); }}> - +
{ setPage(p); setPageSize(ps); } }} /> - setDrawerOpen(false)} + setDrawerOpen(false)} extra={ @@ -142,14 +265,63 @@ export default function DeliveriesPage() { {detail && ( <> - {detail.order?.orderNo} - {detail.order?.receiverName} {detail.order?.receiverPhone} + {order?.orderNo || '—'} + + {order?.user ? ( + + {order.user.id ? ( + navigate('/users', { state: { openUserId: String(order.user!.id) } })} + > + {order.user.userNo} + + ) : ( + order.user.userNo || '—' + )} + + {[order.user.nickname, order.user.phone].filter(Boolean).join(' / ') || ''} + + + ) : '—'} + + + + {order?.imageResource?.url ? ( + + ) : null} + + {order?.productName || '—'} + {order?.productSpec ? ( + + {order.productSpec} + + ) : null} + {order?.barcode69 ? ( + + {order.barcode69} + + ) : null} + + + + {formatQty(order)} + + {DELIVERY_TYPE_LABELS[order?.deliveryType ?? ''] || order?.deliveryType || '—'} + + + {[order?.receiverName, order?.receiverPhone].filter(Boolean).join(' ') || '—'} + + {formatAddress(order)} {detail.logisticsFee == null ? '—' : `¥${Number(detail.logisticsFee).toFixed(2)}`}
- + diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 025560c..8949c01 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -674,6 +674,13 @@ export default function OrdersPage() { width: 90, render: (v: number) => `¥${v}`, }, + { + title: '运费', + key: 'logisticsFee', + width: 90, + render: (_, row) => + row.delivery?.logisticsFee == null ? '—' : `¥${Number(row.delivery.logisticsFee).toFixed(2)}`, + }, { title: '好客权益', width: 200, @@ -1158,6 +1165,11 @@ export default function OrdersPage() { {detail.delivery.trackingNo || '—'} {detail.delivery.providerOrderNo || '—'} + + {detail.delivery.logisticsFee == null + ? '—' + : `¥${Number(detail.delivery.logisticsFee).toFixed(2)}`} + {detail.delivery.manualQueryUrl && ( diff --git a/apps/h5-shop/src/components/AuthGate.tsx b/apps/h5-shop/src/components/AuthGate.tsx index 46fee16..b097d97 100644 --- a/apps/h5-shop/src/components/AuthGate.tsx +++ b/apps/h5-shop/src/components/AuthGate.tsx @@ -1,6 +1,7 @@ import { Navigate, useLocation } from 'react-router-dom'; import { getStoreProfile, hasShopWxSession } from '../lib/api'; import { useStoreSession } from '../contexts/StoreSessionContext'; +import { peekShopReturnTo, rememberShopReturnPath } from '../lib/shop-redeem-return'; const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']); const SELECT_STORE_PATH = '/select-store'; @@ -18,14 +19,17 @@ export default function AuthGate({ children }: { children: React.ReactNode }) { } if (authenticated && location.pathname === '/login') { - return ; + const next = needsSelectStore ? SELECT_STORE_PATH : (peekShopReturnTo() || '/'); + return ; } if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) { + rememberShopReturnPath(location); return ; } if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { + rememberShopReturnPath(location); const profile = getStoreProfile(); if (profile && hasShopWxSession() && location.pathname !== '/login') { return ; diff --git a/apps/h5-shop/src/lib/redeem-scan.ts b/apps/h5-shop/src/lib/redeem-scan.ts index ff640d6..511ed5b 100644 --- a/apps/h5-shop/src/lib/redeem-scan.ts +++ b/apps/h5-shop/src/lib/redeem-scan.ts @@ -1,22 +1 @@ -/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */ -export function parseRedeemTokenFromScan(raw: string): string | null { - const trimmed = raw.trim(); - if (!trimmed) return null; - - if (/^[a-f0-9]{32}$/i.test(trimmed)) { - return trimmed.toLowerCase(); - } - - try { - const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid'); - const fromQuery = url.searchParams.get('token'); - if (fromQuery && /^[a-f0-9]{32}$/i.test(fromQuery)) { - return fromQuery.toLowerCase(); - } - } catch { - /* not a URL */ - } - - const hexMatch = trimmed.match(/[a-f0-9]{32}/i); - return hexMatch ? hexMatch[0].toLowerCase() : null; -} +export { parseRedeemTokenFromScan } from '@dukang/shared-types'; diff --git a/apps/h5-shop/src/lib/shop-nav.ts b/apps/h5-shop/src/lib/shop-nav.ts new file mode 100644 index 0000000..37512f0 --- /dev/null +++ b/apps/h5-shop/src/lib/shop-nav.ts @@ -0,0 +1,14 @@ +import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk'; + +/** iOS 微信内用整页跳转,避免 JSSDK 入场 URL 与 SPA 路径不一致 */ +export function goShopPath( + path: string, + navigate: (path: string, opts?: { replace?: boolean }) => void, + opts?: { replace?: boolean }, +): void { + if (shouldHardNavigateForJssdk()) { + hardNavigateInWechat(path); + return; + } + navigate(path, { replace: opts?.replace ?? true }); +} diff --git a/apps/h5-shop/src/lib/shop-redeem-return.ts b/apps/h5-shop/src/lib/shop-redeem-return.ts new file mode 100644 index 0000000..7d4a7b7 --- /dev/null +++ b/apps/h5-shop/src/lib/shop-redeem-return.ts @@ -0,0 +1,45 @@ +/** 扫核销码落地 /redeem?token= 后未登录或需选店,记下回跳路径(OAuth 整页跳转会丢掉 location.state) */ +export const SHOP_REDEEM_RETURN_KEY = 'shop_redeem_return'; + +function isRedeemReturnPath(path: string): boolean { + if (!path.startsWith('/redeem')) return false; + if (path.startsWith('/redeem/')) return false; + try { + const url = new URL(path, 'https://local.invalid'); + return !!url.searchParams.get('token')?.trim(); + } catch { + return false; + } +} + +export function rememberShopReturnTo(path: string): void { + if (!isRedeemReturnPath(path)) return; + try { + sessionStorage.setItem(SHOP_REDEEM_RETURN_KEY, path); + } catch { + /* ignore */ + } +} + +export function rememberShopReturnPath(location: { pathname: string; search: string }): void { + rememberShopReturnTo(`${location.pathname}${location.search}`); +} + +export function peekShopReturnTo(): string | null { + try { + const path = sessionStorage.getItem(SHOP_REDEEM_RETURN_KEY); + return path && isRedeemReturnPath(path) ? path : null; + } catch { + return null; + } +} + +export function consumeShopReturnTo(): string | null { + const path = peekShopReturnTo(); + try { + sessionStorage.removeItem(SHOP_REDEEM_RETURN_KEY); + } catch { + /* ignore */ + } + return path; +} diff --git a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx index cba4d80..fd6b564 100644 --- a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx +++ b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel'; import { request } from '../lib/api'; import { reportRedeemFailure } from '../lib/redeem-failure'; +import { consumeShopReturnTo } from '../lib/shop-redeem-return'; import { toastError } from '../lib/toast'; import { useStorePageView } from '../lib/usePageView'; @@ -47,6 +48,7 @@ export default function RedeemConfirmPage() { navigate('/', { replace: true }); return; } + consumeShopReturnTo(); setToken(scanned); }, [searchParams, navigate]); diff --git a/apps/h5-shop/src/pages/RedeemSuccessPage.tsx b/apps/h5-shop/src/pages/RedeemSuccessPage.tsx index 9ffca2e..5e4a196 100644 --- a/apps/h5-shop/src/pages/RedeemSuccessPage.tsx +++ b/apps/h5-shop/src/pages/RedeemSuccessPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { goShopPath } from '../lib/shop-nav'; import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus'; import { formatMoney, toMoneyNumber } from '../lib/money'; @@ -35,10 +36,12 @@ export default function RedeemSuccessPage() { }); }, [redeemNo, amount]); + const goHome = () => goShopPath('/', navigate, { replace: true }); + return (
-

杜康好客

@@ -85,11 +88,11 @@ export default function RedeemSuccessPage() {
- - diff --git a/apps/h5-shop/src/pages/SelectStorePage.tsx b/apps/h5-shop/src/pages/SelectStorePage.tsx index e1677a2..d28bf97 100644 --- a/apps/h5-shop/src/pages/SelectStorePage.tsx +++ b/apps/h5-shop/src/pages/SelectStorePage.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; -import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk'; import { useStoreSession } from '../contexts/StoreSessionContext'; import { needsStoreSelection, @@ -10,13 +9,11 @@ import { type ShopSessionPayload, type ShopStoreOption, } from '../lib/api'; +import { goShopPath } from '../lib/shop-nav'; +import { consumeShopReturnTo, peekShopReturnTo } from '../lib/shop-redeem-return'; -function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) { - if (shouldHardNavigateForJssdk()) { - hardNavigateInWechat('/'); - return; - } - navigate('/', { replace: true }); +function goAfterSelectStore(navigate: (path: string, opts?: { replace?: boolean }) => void) { + goShopPath(consumeShopReturnTo() || '/', navigate); } export default function SelectStorePage() { @@ -46,7 +43,7 @@ export default function SelectStorePage() { async function onSelect(storeId: string) { if (loadingId) return; if (storeId === currentStoreId) { - goShopHome(navigate); + goAfterSelectStore(navigate); return; } setLoadingId(storeId); @@ -54,7 +51,7 @@ export default function SelectStorePage() { try { const session = await selectStore(storeId); applySession(session); - goShopHome(navigate); + goAfterSelectStore(navigate); } catch (e) { setMsg(e instanceof Error ? e.message : '选店失败'); } finally { @@ -170,16 +167,14 @@ export default function SelectStorePage() { ); } -/** After login/wechat: route to select-store or home */ +/** After login/wechat: route to select-store, redeem return, or home */ export function routeAfterShopLogin( session: ShopSessionPayload, navigate: (path: string, opts?: { replace?: boolean }) => void, ) { - const path = needsStoreSelection(session) ? '/select-store' : '/'; - // iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂 - if (shouldHardNavigateForJssdk()) { - hardNavigateInWechat(path); + if (needsStoreSelection(session)) { + goShopPath('/select-store', navigate); return; } - navigate(path, { replace: true }); + goShopPath(peekShopReturnTo() || '/', navigate); } diff --git a/apps/mini-user/src/components/RedeemQrCode.tsx b/apps/mini-user/src/components/RedeemQrCode.tsx index 6de502a..e08e496 100644 --- a/apps/mini-user/src/components/RedeemQrCode.tsx +++ b/apps/mini-user/src/components/RedeemQrCode.tsx @@ -10,10 +10,11 @@ import { const CANVAS_ID = 'redeem-qr-canvas'; type RedeemQrCodeProps = { - token: string; + /** 二维码内容:门店 H5 落地 URL,缺省回退 token */ + payload: string; }; -function drawOnWeappCanvas(token: string) { +function drawOnWeappCanvas(payload: string) { const page = Taro.getCurrentInstance().page; const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery(); query @@ -33,33 +34,33 @@ function drawOnWeappCanvas(token: string) { canvas.width = layoutW * dpr; canvas.height = layoutH * dpr; ctx.scale(dpr, dpr); - drawRedeemQrOnCanvas(ctx, token, drawSize); + drawRedeemQrOnCanvas(ctx, payload, drawSize); }); } -export default function RedeemQrCode({ token }: RedeemQrCodeProps) { +export default function RedeemQrCode({ payload }: RedeemQrCodeProps) { const [imgSrc, setImgSrc] = useState(''); const isWeapp = process.env.TARO_ENV === 'weapp'; useEffect(() => { - if (!token) { + if (!payload) { setImgSrc(''); return; } if (isWeapp) { - const timer = setTimeout(() => drawOnWeappCanvas(token), 120); + const timer = setTimeout(() => drawOnWeappCanvas(payload), 120); return () => clearTimeout(timer); } let cancelled = false; - void buildRedeemQrDataUrl(token).then((url) => { + void buildRedeemQrDataUrl(payload).then((url) => { if (!cancelled) setImgSrc(url); }); return () => { cancelled = true; }; - }, [token, isWeapp]); + }, [payload, isWeapp]); return ( diff --git a/apps/mini-user/src/lib/brand-assets.ts b/apps/mini-user/src/lib/brand-assets.ts index 8aef9e0..a2faee8 100644 --- a/apps/mini-user/src/lib/brand-assets.ts +++ b/apps/mini-user/src/lib/brand-assets.ts @@ -3,6 +3,7 @@ import { BRAND_LOGO_URL, BRAND_LOGO_WIDE_URL, CUSTOMER_SERVICE_PHONE, + CUSTOMER_SERVICE_WECOM_URL, QUALIFICATION_DISCLOSURE_URL, type ClientRuntimeConfig, } from '@dukang/shared-types'; @@ -14,6 +15,8 @@ export type BrandAssets = { brandLogoMarkUrl: string; qualificationDisclosureUrl: string; customerServicePhone: string; + customerServiceWecomUrl: string; + wecomCorpId: string; }; const FALLBACK: BrandAssets = { @@ -22,6 +25,8 @@ const FALLBACK: BrandAssets = { brandLogoMarkUrl: BRAND_LOGO_MARK_URL, qualificationDisclosureUrl: QUALIFICATION_DISCLOSURE_URL, customerServicePhone: CUSTOMER_SERVICE_PHONE, + customerServiceWecomUrl: CUSTOMER_SERVICE_WECOM_URL, + wecomCorpId: '', }; let cached: BrandAssets | null = null; @@ -35,6 +40,9 @@ function fromConfig(config: ClientRuntimeConfig | null | undefined): BrandAssets qualificationDisclosureUrl: config?.qualificationDisclosureUrl?.trim() || FALLBACK.qualificationDisclosureUrl, customerServicePhone: config?.customerServicePhone?.trim() || FALLBACK.customerServicePhone, + customerServiceWecomUrl: + config?.customerServiceWecomUrl?.trim() || FALLBACK.customerServiceWecomUrl, + wecomCorpId: config?.wecomCorpId?.trim() || FALLBACK.wecomCorpId, }; } diff --git a/apps/mini-user/src/lib/redeem-qr.ts b/apps/mini-user/src/lib/redeem-qr.ts index decc453..33554b5 100644 --- a/apps/mini-user/src/lib/redeem-qr.ts +++ b/apps/mini-user/src/lib/redeem-qr.ts @@ -8,13 +8,13 @@ const QR_OPTIONS = { color: { dark: '#1f1a17', light: '#ffffff' }, } as const; -/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用) */ +/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用;payload 为落地 URL 或纯 token) */ export function drawRedeemQrOnCanvas( ctx: CanvasRenderingContext2D, - token: string, + payload: string, sizePx = QR_SIZE, ) { - const qr = QRCode.create(token, { errorCorrectionLevel: 'M' }); + const qr = QRCode.create(payload, { errorCorrectionLevel: 'M' }); const count = qr.modules.size; const cell = sizePx / count; @@ -31,11 +31,11 @@ export function drawRedeemQrOnCanvas( } /** H5:Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */ -export async function buildRedeemQrDataUrl(token: string): Promise { +export async function buildRedeemQrDataUrl(payload: string): Promise { try { - return await QRCode.toDataURL(token, QR_OPTIONS); + return await QRCode.toDataURL(payload, QR_OPTIONS); } catch { - return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(token)}`; + return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(payload)}`; } } diff --git a/apps/mini-user/src/pages/redeem-code/index.tsx b/apps/mini-user/src/pages/redeem-code/index.tsx index fd98ec8..16a5761 100644 --- a/apps/mini-user/src/pages/redeem-code/index.tsx +++ b/apps/mini-user/src/pages/redeem-code/index.tsx @@ -39,6 +39,10 @@ export default function RedeemCodePage() { const router = useRouter(); const token = decodeURIComponent(router.params.token ?? ''); const amount = Number(router.params.amount ?? 0); + const landingUrl = router.params.landingUrl + ? decodeURIComponent(router.params.landingUrl) + : ''; + const qrPayload = landingUrl || token; const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS); const timerRef = useRef | null>(null); @@ -148,7 +152,7 @@ export default function RedeemCodePage() { 请向收银员出示此码 - + 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}> {formatTimer(timerSec)} diff --git a/apps/mini-user/src/pages/redeem/index.tsx b/apps/mini-user/src/pages/redeem/index.tsx index 7b8e0ec..08a7dfc 100644 --- a/apps/mini-user/src/pages/redeem/index.tsx +++ b/apps/mini-user/src/pages/redeem/index.tsx @@ -113,12 +113,15 @@ export default function RedeemPage() { const body: { amount: number; couponId?: string } = { amount: value }; if (couponId) body.couponId = couponId; - const data = await request<{ token: string; amount: number }>('/redeem/tokens', { + const data = await request<{ token: string; amount: number; landingUrl?: string }>('/redeem/tokens', { method: 'POST', data: body, }); + const landingQs = data.landingUrl + ? `&landingUrl=${encodeURIComponent(data.landingUrl)}` + : ''; Taro.navigateTo({ - url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}`, + url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}${landingQs}`, }); } catch (e) { toast(e instanceof Error ? e.message : '生成失败'); diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json index e1092d5..641ea24 100644 --- a/packages/shared-types/package.json +++ b/packages/shared-types/package.json @@ -19,9 +19,11 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", + "test": "vitest run", "lint": "eslint src" }, "devDependencies": { - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "vitest": "^1.6.1" } } diff --git a/packages/shared-types/src/fulfillment-provider.test.ts b/packages/shared-types/src/fulfillment-provider.test.ts new file mode 100644 index 0000000..144afef --- /dev/null +++ b/packages/shared-types/src/fulfillment-provider.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { isXfxProviderCode } from './fulfillment-provider'; + +describe('isXfxProviderCode', () => { + it('匹配标准编码与城市前缀', () => { + expect(isXfxProviderCode('XFX')).toBe(true); + expect(isXfxProviderCode('xiaofeixia')).toBe(true); + expect(isXfxProviderCode('ZZXFX')).toBe(true); + expect(isXfxProviderCode('XFX_ZZ')).toBe(true); + }); + + it('不匹配普通承运商', () => { + expect(isXfxProviderCode('')).toBe(false); + expect(isXfxProviderCode('LOGISTICS')).toBe(false); + expect(isXfxProviderCode('MANUAL')).toBe(false); + expect(isXfxProviderCode('SF')).toBe(false); + }); +}); diff --git a/packages/shared-types/src/fulfillment-provider.ts b/packages/shared-types/src/fulfillment-provider.ts index 23a6dd6..e95d3df 100644 --- a/packages/shared-types/src/fulfillment-provider.ts +++ b/packages/shared-types/src/fulfillment-provider.ts @@ -114,8 +114,13 @@ export interface WarehouseFulfillmentConfig { export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const; +/** 小飞侠承运商编码:精确 XFX/XIAOFEIXIA,以及城市前缀如 ZZXFX */ export function isXfxProviderCode(code: string): boolean { - return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase()); + const c = code.trim().toUpperCase(); + if (!c) return false; + if ((XFX_PROVIDER_CODES as readonly string[]).includes(c)) return true; + if (c.includes('XIAOFEIXIA')) return true; + return c.endsWith('XFX') || c.startsWith('XFX'); } /** 承运商未配置提示时 C 端回退文案 */ diff --git a/packages/shared-types/src/redeem.test.ts b/packages/shared-types/src/redeem.test.ts new file mode 100644 index 0000000..a57038d --- /dev/null +++ b/packages/shared-types/src/redeem.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { buildShopRedeemLandingUrl, parseRedeemTokenFromScan } from './redeem'; + +const TOKEN = 'a1b2c3d4e5f6789012345678901234ab'; + +describe('buildShopRedeemLandingUrl', () => { + it('joins shop H5 origin with /redeem?token=', () => { + expect(buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN)).toBe( + `https://shop.dukanghaoke.com/redeem?token=${TOKEN}`, + ); + }); + + it('strips trailing slash on the base', () => { + expect(buildShopRedeemLandingUrl('https://shop-test.dukanghaoke.com/', TOKEN)).toBe( + `https://shop-test.dukanghaoke.com/redeem?token=${TOKEN}`, + ); + }); +}); + +describe('parseRedeemTokenFromScan', () => { + it('accepts a raw 32-hex token', () => { + expect(parseRedeemTokenFromScan(TOKEN.toUpperCase())).toBe(TOKEN); + }); + + it('extracts token from shop landing URL', () => { + const url = buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN); + expect(parseRedeemTokenFromScan(url)).toBe(TOKEN); + }); + + it('strips WeChat QR_CODE prefix before parsing URL', () => { + const url = buildShopRedeemLandingUrl('https://shop.dukanghaoke.com', TOKEN); + expect(parseRedeemTokenFromScan(`QR_CODE,${url}`)).toBe(TOKEN); + }); + + it('returns null for empty or unrelated content', () => { + expect(parseRedeemTokenFromScan('')).toBeNull(); + expect(parseRedeemTokenFromScan('https://shop.dukanghaoke.com/records')).toBeNull(); + }); +}); diff --git a/packages/shared-types/src/redeem.ts b/packages/shared-types/src/redeem.ts index 39c34eb..49e83b8 100644 --- a/packages/shared-types/src/redeem.ts +++ b/packages/shared-types/src/redeem.ts @@ -9,6 +9,48 @@ export interface RedeemTokenResult { expireAt: string; amount: number; boundStoreId?: string | null; + /** 门店 H5 核销确认页落地 URL(写入二维码,微信扫一扫可直达) */ + landingUrl?: string; +} + +const REDEEM_TOKEN_HEX = /^[a-f0-9]{32}$/i; + +/** 门店核销确认页落地 URL,供用户端核销码使用 */ +export function buildShopRedeemLandingUrl(shopH5Url: string, token: string): string { + const base = shopH5Url.replace(/\/$/, ''); + return `${base}/redeem?token=${encodeURIComponent(token)}`; +} + +/** 微信扫码偶发 `QR_CODE,payload` 前缀 */ +function stripScanTypePrefix(raw: string): string { + const comma = raw.indexOf(','); + if (comma > 0 && comma < 24 && /^[A-Z0-9_]+$/.test(raw.slice(0, comma))) { + return raw.slice(comma + 1).trim(); + } + return raw; +} + +/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */ +export function parseRedeemTokenFromScan(raw: string): string | null { + const trimmed = stripScanTypePrefix(raw.trim()); + if (!trimmed) return null; + + if (REDEEM_TOKEN_HEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + try { + const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid'); + const fromQuery = url.searchParams.get('token'); + if (fromQuery && REDEEM_TOKEN_HEX.test(fromQuery)) { + return fromQuery.toLowerCase(); + } + } catch { + /* not a URL */ + } + + const hexMatch = trimmed.match(/[a-f0-9]{32}/i); + return hexMatch ? hexMatch[0].toLowerCase() : null; } export interface RedeemPreviewDto { diff --git a/packages/shared-types/src/trade.ts b/packages/shared-types/src/trade.ts index 69d5bac..2ab41fb 100644 --- a/packages/shared-types/src/trade.ts +++ b/packages/shared-types/src/trade.ts @@ -95,6 +95,8 @@ export interface OrderTrackDto { provider?: string; trackingNo?: string | null; logisticsCompany?: string | null; + /** 承运商查询失败原因(有则 HQ/C 端应展示,避免空白「暂无路由」) */ + queryError?: string | null; } /** 大单拦截原因:≥10 箱不自动推小飞侠 */ diff --git a/packages/shared-types/tsconfig.json b/packages/shared-types/tsconfig.json index 8a9f0e5..45b144e 100644 --- a/packages/shared-types/tsconfig.json +++ b/packages/shared-types/tsconfig.json @@ -9,5 +9,6 @@ "esModuleInterop": true, "skipLibCheck": true }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"] } diff --git a/packages/shared-types/vitest.config.ts b/packages/shared-types/vitest.config.ts new file mode 100644 index 0000000..438103a --- /dev/null +++ b/packages/shared-types/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e43cad..aa2aa7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,6 +279,9 @@ importers: typescript: specifier: ^5.4.5 version: 5.9.3 + vitest: + specifier: ^1.6.1 + version: 1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0) packages/shared-ui: devDependencies: diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index 7274204..b06f78c 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -31,6 +31,9 @@ MOCK_WECHAT=true # C 端 H5 落地页(推广码二维码链接前缀,USER_H5_URL) # 未配置时默认 https://user.runxian.top/user;本地开发可设为 http://localhost:5173/user # USER_H5_URL=https://user.runxian.top/user +# 门店 H5 落地页(用户核销码二维码链接前缀,SHOP_H5_URL) +# 未配置时默认 https://shop.dukanghaoke.com;本地开发可设为 http://localhost:5174 +# SHOP_H5_URL=https://shop.dukanghaoke.com # 反向代理后提取真实客户端 IP(下单 IP 定位) TRUST_PROXY=true diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example index e3b0746..fb13bb8 100644 --- a/server/dukang-api/.env.production.example +++ b/server/dukang-api/.env.production.example @@ -30,6 +30,8 @@ TRUST_PROXY=true # C 端 H5 落地页(推广码二维码;生产统一入口) USER_H5_URL=https://user.runxian.top/user +# 门店 H5 落地页(用户核销码二维码) +SHOP_H5_URL=https://shop.dukanghaoke.com MOCK_WECHAT=false WX_APP_ID= diff --git a/server/dukang-api/.env.staging.example b/server/dukang-api/.env.staging.example index 3585f61..92c4fba 100644 --- a/server/dukang-api/.env.staging.example +++ b/server/dukang-api/.env.staging.example @@ -32,6 +32,8 @@ TRUST_PROXY=true # C 端 H5(测试域) USER_H5_URL=https://user-test.dukanghaoke.com/user +# 门店 H5(测试域,核销码落地页) +SHOP_H5_URL=https://shop-test.dukanghaoke.com # 正式号配置可与生产相同,但 Mock 打开后不走真实支付 WX_APP_ID= diff --git a/server/dukang-api/scripts/gen-staging-env-from-prod.cjs b/server/dukang-api/scripts/gen-staging-env-from-prod.cjs index 2d83289..32d361d 100644 --- a/server/dukang-api/scripts/gen-staging-env-from-prod.cjs +++ b/server/dukang-api/scripts/gen-staging-env-from-prod.cjs @@ -47,6 +47,7 @@ const fixed = { AUTO_APPROVE_STORE: 'true', TRUST_PROXY: 'true', USER_H5_URL: 'https://user-test.dukanghaoke.com/user', + SHOP_H5_URL: 'https://shop-test.dukanghaoke.com', WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay', OSS_UPLOAD_PREFIX: 'staging/uploads', WECOM_AIBOT_ENABLED: 'false', diff --git a/server/dukang-api/src/modules/fulfillment/delivery-freight.util.test.ts b/server/dukang-api/src/modules/fulfillment/delivery-freight.util.test.ts new file mode 100644 index 0000000..7b345a0 --- /dev/null +++ b/server/dukang-api/src/modules/fulfillment/delivery-freight.util.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_XFX_LOGISTICS_PRICING } from '@dukang/shared-types'; +import { calcDeliveryFreightAmount } from './delivery-freight.util'; + +describe('calcDeliveryFreightAmount', () => { + it('瓶装按小飞侠默认计价:2瓶6元', () => { + expect( + calcDeliveryFreightAmount({ + quantity: 2, + bottlesPerUnit: 1, + deliveryType: 'LOCAL', + provider: 'XFX', + }), + ).toBe(6); + }); + + it('箱装先换算瓶数:1箱6瓶=14元', () => { + expect( + calcDeliveryFreightAmount({ + quantity: 1, + bottlesPerUnit: 6, + deliveryType: 'LOCAL', + provider: 'XFX', + }), + ).toBe(14); + }); + + it('现场提货不计运费', () => { + expect( + calcDeliveryFreightAmount({ + quantity: 2, + bottlesPerUnit: 1, + deliveryType: 'ON_SITE_PICKUP', + provider: 'XFX', + }), + ).toBeNull(); + }); + + it('无承运商计价时返回 null;有自定义规则则用之', () => { + expect( + calcDeliveryFreightAmount({ + quantity: 2, + bottlesPerUnit: 1, + deliveryType: 'CROSS_CITY', + provider: 'LOGISTICS', + }), + ).toBeNull(); + + expect( + calcDeliveryFreightAmount({ + quantity: 2, + bottlesPerUnit: 1, + deliveryType: 'CROSS_CITY', + provider: 'LOGISTICS', + pricing: { ...DEFAULT_XFX_LOGISTICS_PRICING }, + }), + ).toBe(6); + }); + + it('同城即使 provider=MANUAL 也按小飞侠默认计价', () => { + expect( + calcDeliveryFreightAmount({ + quantity: 2, + bottlesPerUnit: 1, + deliveryType: 'LOCAL', + provider: 'MANUAL', + }), + ).toBe(6); + }); + + it('城市小飞侠编码 ZZXFX 使用默认计价', () => { + expect( + calcDeliveryFreightAmount({ + quantity: 4, + bottlesPerUnit: 1, + deliveryType: 'LOCAL', + provider: 'MANUAL', + providerCode: 'ZZXFX', + }), + ).toBe(10); + }); +}); diff --git a/server/dukang-api/src/modules/fulfillment/delivery-freight.util.ts b/server/dukang-api/src/modules/fulfillment/delivery-freight.util.ts new file mode 100644 index 0000000..6291e70 --- /dev/null +++ b/server/dukang-api/src/modules/fulfillment/delivery-freight.util.ts @@ -0,0 +1,31 @@ +import { calcLogisticsFeeByBottles, toBottleQuantity, type LogisticsPricingRule } from '@dukang/domain'; +import { DEFAULT_XFX_LOGISTICS_PRICING, isXfxProviderCode } from '@dukang/shared-types'; + +export type DeliveryFreightInput = { + quantity: number; + bottlesPerUnit?: number | null; + deliveryType?: string | null; + provider?: string | null; + providerCode?: string | null; + pricing?: LogisticsPricingRule | null; +}; + +/** 当次应付物流费:按瓶当量 + 承运商计价;现场提货 / 无规则返回 null */ +export function calcDeliveryFreightAmount(input: DeliveryFreightInput): number | null { + if (input.deliveryType === 'ON_SITE_PICKUP') return null; + const bottles = toBottleQuantity( + input.quantity, + input.bottlesPerUnit && input.bottlesPerUnit > 0 ? input.bottlesPerUnit : 1, + ); + if (bottles <= 0) return null; + const code = (input.providerCode || input.provider || '').trim(); + const useDefaultXfx = + isXfxProviderCode(code) || (input.deliveryType === 'LOCAL' && code.toUpperCase() !== 'LOGISTICS'); + const rule = input.pricing ?? (useDefaultXfx ? { ...DEFAULT_XFX_LOGISTICS_PRICING } : null); + if (!rule) return null; + try { + return calcLogisticsFeeByBottles(bottles, rule); + } catch { + return null; + } +} diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts index b2f3fc3..6538dc9 100644 --- a/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts +++ b/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts @@ -90,22 +90,21 @@ export class FulfillmentProviderService { }; } - /** 取第一个启用的小飞侠承运商配置(联调/兼容) */ + /** 取第一个启用且凭证完整的小飞侠承运商配置(含 ZZXFX 等城市编码) */ async resolveDefaultXiaofeixiaConfig(): Promise { - const row = await this.prisma.fulfillmentProvider.findFirst({ - where: { - status: 'ACTIVE', - type: 'API', - code: { in: ['XFX', 'XIAOFEIXIA'] }, - }, + const rows = await this.prisma.fulfillmentProvider.findMany({ + where: { status: 'ACTIVE', type: 'API' }, orderBy: { updatedAt: 'desc' }, }); - if (!row?.configJson) return null; - try { - return await this.resolveXiaofeixiaConfig(row.id); - } catch { - return null; + for (const row of rows) { + if (!isXfxProviderCode(row.code) || !row.configJson) continue; + try { + return await this.resolveXiaofeixiaConfig(row.id); + } catch { + continue; + } } + return null; } async create(input: CreateFulfillmentProviderInput) { diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts index dde846a..601838e 100644 --- a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts +++ b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts @@ -6,6 +6,7 @@ import { XFX_AUTO_DISPATCH_MAX_BOXES, calcOrderBoxCount, shouldHoldAutoCourierDispatch, + toBottleQuantity, } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { CourierService } from '../../integrations/courier/courier.service'; @@ -15,6 +16,9 @@ import { OSS_PROVIDER } from '../../integrations/integrations.constants'; import type { IOssProvider } from '../../integrations/oss/oss.interface'; import { TradeService } from '../trade/trade.service'; import { FulfillmentProviderService } from './fulfillment-provider.service'; +import { buildXfxGoodsPayload } from './xfx-goods.util'; + +type OrderForXfxDispatch = Order & { product?: { spec: string } | null }; export type ManualShipInput = { logisticsCompany: string; @@ -42,7 +46,7 @@ export class FulfillmentService { async dispatchAfterPay(orderId: bigint) { const order = await this.prisma.order.findUnique({ where: { id: orderId }, - include: { delivery: true }, + include: { delivery: true, product: { select: { spec: true } } }, }); if (!order || order.payStatus !== 'PAID') return; @@ -77,7 +81,10 @@ export class FulfillmentService { } // 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送(按瓶当量) - const bottleQty = order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1); + const bottleQty = toBottleQuantity( + order.quantity, + order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1, + ); if (shouldHoldAutoCourierDispatch(bottleQty)) { const boxes = calcOrderBoxCount(bottleQty); this.logger.warn( @@ -118,7 +125,7 @@ export class FulfillmentService { }); } - async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) { + async dispatchApiAuto(order: OrderForXfxDispatch, warehouse: CityWarehouse, provider: FulfillmentProvider) { if (!isXfxProviderCode(provider.code)) { this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`); await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id); @@ -137,6 +144,13 @@ export class FulfillmentService { const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665; const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757; + const { goodsName, goodsNum } = buildXfxGoodsPayload({ + productName: order.productName, + productSpec: order.productSpec, + physicalSpec: order.product?.spec, + quantity: order.quantity, + bottlesPerUnit: order.bottlesPerUnit, + }); try { const result = await this.courier.createShipment( @@ -155,8 +169,8 @@ export class FulfillmentService { address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`, addressDetail: order.receiverAddress, }, - goodsName: order.productName, - goodsNum: order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1), + goodsName, + goodsNum, weight: 2, payMode: CourierPayMode.SENDER, remark: `仓配自动发货 ${order.orderNo}`, @@ -252,8 +266,12 @@ export class FulfillmentService { where: { id: orderId }, include: { delivery: { - include: { signPhotoResource: true }, + include: { + signPhotoResource: true, + fulfillmentProvider: { select: { id: true, code: true } }, + }, }, + fulfillmentWarehouse: { select: { fulfillmentProviderId: true } }, }, }); const base = { @@ -264,35 +282,44 @@ export class FulfillmentService { provider: order?.delivery?.provider, trackingNo: order?.delivery?.trackingNo ?? null, logisticsCompany: order?.delivery?.logisticsCompany ?? null, + queryError: null as string | null, }; if (!order?.delivery) { return base; } + const providerCode = order.delivery.fulfillmentProvider?.code || String(order.delivery.provider || ''); const isXfx = - order.delivery.provider === 'XFX' || isXfxProviderCode(String(order.delivery.provider || '')); - const canQueryCourier = isXfx && (order.delivery.trackingNo || order.orderNo); + order.delivery.provider === 'XFX' || + isXfxProviderCode(providerCode) || + order.deliveryType === 'LOCAL'; + const canQueryCourier = isXfx && !!(order.delivery.trackingNo || order.orderNo); if (canQueryCourier) { try { - const options = order.delivery.fulfillmentProviderId - ? { - xiaofeixia: await this.fulfillmentProviderService.resolveXiaofeixiaConfig( - order.delivery.fulfillmentProviderId, - ), - } - : undefined; + const xiaofeixia = await this.resolveTrackXiaofeixiaConfig({ + delivery: order.delivery, + fulfillmentWarehouse: order.fulfillmentWarehouse, + }); + const options = xiaofeixia ? { xiaofeixia } : undefined; const shipmentQuery = { trackingNumber: order.delivery.trackingNo ?? undefined, outNumber: order.orderNo, }; - const [nodes, signPhotoDataUris] = await Promise.all([ - this.courier.getTrack(shipmentQuery, options).catch(() => [] as TrackNode[]), + const [trackResult, signPhotoDataUris] = await Promise.all([ + this.courier + .getTrack(shipmentQuery, options) + .then((nodes) => ({ nodes: Array.isArray(nodes) ? nodes : [], error: null as string | null })) + .catch((err: unknown) => ({ + nodes: [] as TrackNode[], + error: err instanceof Error ? err.message : '查询路由失败', + })), this.courier.getSignPhotos(shipmentQuery, options).catch(() => [] as string[]), ]); - base.nodes = this.sortTrackNodesOldestFirst(nodes); + base.nodes = this.sortTrackNodesOldestFirst(trackResult.nodes); + base.queryError = trackResult.error; base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoDataUris); if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) { @@ -309,8 +336,8 @@ export class FulfillmentService { } } } - } catch { - // fall through + } catch (err) { + base.queryError = err instanceof Error ? err.message : '查询路由失败'; } } @@ -327,6 +354,28 @@ export class FulfillmentService { }; } + private async resolveTrackXiaofeixiaConfig(order: { + delivery: { fulfillmentProviderId: bigint | null } | null; + fulfillmentWarehouse?: { fulfillmentProviderId: bigint | null } | null; + }): Promise { + const ids = [ + order.delivery?.fulfillmentProviderId, + order.fulfillmentWarehouse?.fulfillmentProviderId, + ].filter((id): id is bigint => id != null); + const seen = new Set(); + for (const id of ids) { + const key = String(id); + if (seen.has(key)) continue; + seen.add(key); + try { + return await this.fulfillmentProviderService.resolveXiaofeixiaConfig(id); + } catch { + continue; + } + } + return this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig(); + } + private sortTrackNodesOldestFirst(nodes: TrackNode[]): TrackNode[] { return [...nodes].sort((a, b) => { const ta = new Date(a.createTime).getTime(); diff --git a/server/dukang-api/src/modules/fulfillment/xfx-goods.util.test.ts b/server/dukang-api/src/modules/fulfillment/xfx-goods.util.test.ts new file mode 100644 index 0000000..e1aaf94 --- /dev/null +++ b/server/dukang-api/src/modules/fulfillment/xfx-goods.util.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { buildXfxGoodsPayload } from './xfx-goods.util'; + +describe('buildXfxGoodsPayload', () => { + it('瓶装:品名 + 酒精度,件数等于购买瓶数', () => { + expect( + buildXfxGoodsPayload({ + productName: '杜康老窖', + productSpec: '单瓶', + physicalSpec: '500ml | 53度', + quantity: 2, + bottlesPerUnit: 1, + }), + ).toEqual({ + goodsName: '杜康老窖 500ml | 53度 单瓶', + goodsNum: 2, + }); + }); + + it('箱装:追加包装规格,件数换算为瓶当量', () => { + expect( + buildXfxGoodsPayload({ + productName: '杜康老窖', + productSpec: '整箱', + physicalSpec: '500ml | 53度', + quantity: 2, + bottlesPerUnit: 6, + }), + ).toEqual({ + goodsName: '杜康老窖 500ml | 53度 整箱', + goodsNum: 12, + }); + }); + + it('无度数:回落 SKU 规格;再缺失则只用品名', () => { + expect( + buildXfxGoodsPayload({ + productName: '杜康老窖', + productSpec: '单瓶', + physicalSpec: null, + quantity: 3, + bottlesPerUnit: 1, + }), + ).toEqual({ + goodsName: '杜康老窖 单瓶', + goodsNum: 3, + }); + + expect( + buildXfxGoodsPayload({ + productName: '杜康老窖', + productSpec: ' ', + physicalSpec: undefined, + quantity: 1, + bottlesPerUnit: 1, + }), + ).toEqual({ + goodsName: '杜康老窖', + goodsNum: 1, + }); + }); + + it('规格重复:不把相同文案拼两次', () => { + expect( + buildXfxGoodsPayload({ + productName: '杜康老窖', + productSpec: '500ml | 53度', + physicalSpec: '500ml | 53度', + quantity: 1, + bottlesPerUnit: 1, + }), + ).toEqual({ + goodsName: '杜康老窖 500ml | 53度', + goodsNum: 1, + }); + + expect( + buildXfxGoodsPayload({ + productName: '杜康老窖', + productSpec: '53度', + physicalSpec: '500ml | 53度', + quantity: 1, + bottlesPerUnit: 1, + }).goodsName, + ).toBe('杜康老窖 500ml | 53度'); + }); +}); diff --git a/server/dukang-api/src/modules/fulfillment/xfx-goods.util.ts b/server/dukang-api/src/modules/fulfillment/xfx-goods.util.ts new file mode 100644 index 0000000..b5045b0 --- /dev/null +++ b/server/dukang-api/src/modules/fulfillment/xfx-goods.util.ts @@ -0,0 +1,58 @@ +import { toBottleQuantity } from '@dukang/domain'; + +const GOODS_NAME_MAX_LEN = 128; + +export type XfxGoodsInput = { + productName: string; + /** SKU 规格快照,如「单瓶 / 整箱」 */ + productSpec?: string | null; + /** SPU 物理规格,如「500ml | 53度」 */ + physicalSpec?: string | null; + quantity: number; + bottlesPerUnit: number; +}; + +export type XfxGoodsPayload = { + goodsName: string; + goodsNum: number; +}; + +/** 小飞侠创建运单货品:品名+酒精度规格,件数用瓶当量 */ +export function buildXfxGoodsPayload(input: XfxGoodsInput): XfxGoodsPayload { + const perUnit = input.bottlesPerUnit > 0 ? input.bottlesPerUnit : 1; + return { + goodsName: buildXfxGoodsName(input), + goodsNum: toBottleQuantity(input.quantity, perUnit), + }; +} + +function buildXfxGoodsName(input: XfxGoodsInput): string { + const name = trimSpec(input.productName); + const physical = trimSpec(input.physicalSpec); + const skuSpec = trimSpec(input.productSpec); + + const parts: string[] = []; + if (name) parts.push(name); + + if (physical) { + parts.push(physical); + if (skuSpec && !isRedundantSpec(physical, skuSpec)) { + parts.push(skuSpec); + } + } else if (skuSpec) { + parts.push(skuSpec); + } + + return parts.join(' ').replace(/\s+/g, ' ').trim().slice(0, GOODS_NAME_MAX_LEN); +} + +function trimSpec(raw?: string | null): string { + return (raw ?? '').trim(); +} + +function isRedundantSpec(physical: string, skuSpec: string): boolean { + const a = physical.replace(/\s+/g, ''); + const b = skuSpec.replace(/\s+/g, ''); + if (!b || a === b) return true; + return a.includes(b) || b.includes(a); +} diff --git a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts index a24598a..1a2ffbc 100644 --- a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts +++ b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts @@ -181,7 +181,12 @@ export class AdminDashboardService { : Promise.resolve(0), can('deliveries') ? this.prisma.orderDelivery.count({ - where: cityFilter ? { order: { cityId: cityFilter } } : undefined, + where: { + order: { + deliveryType: { not: 'ON_SITE_PICKUP' }, + ...(cityFilter ? { cityId: cityFilter } : {}), + }, + }, }) : Promise.resolve(0), can('finance') diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts index 1658208..840e4e0 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -12,6 +12,8 @@ import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.d import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto'; import { FulfillmentService } from '../fulfillment/fulfillment.service'; import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; +import { buildXfxGoodsPayload } from '../fulfillment/xfx-goods.util'; +import { calcDeliveryFreightAmount } from '../fulfillment/delivery-freight.util'; import { AdminRedeemService } from './admin-redeem.service'; import { buildExportFilename, @@ -93,7 +95,16 @@ export class AdminOrdersService { take: pageSize, include: { user: { select: { id: true, userNo: true, phone: true, nickname: true } }, - delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } }, + delivery: { + select: { + provider: true, + trackingNo: true, + providerOrderNo: true, + logisticsCompany: true, + manualQueryUrl: true, + fulfillmentProvider: { select: { code: true, pricingRulesJson: true } }, + }, + }, city: { select: { id: true, name: true, code: true } }, fulfillmentWarehouse: { select: { id: true, name: true } }, benefitCoupon: { @@ -104,7 +115,12 @@ export class AdminOrdersService { this.prisma.order.count({ where }), ]); - return serializeBigInt({ items, total, page, pageSize }); + return serializeBigInt({ + items: items.map((row) => this.withDeliveryLogisticsFee(row)), + total, + page, + pageSize, + }); } async previewExport(dto: AdminOrdersExportDto) { @@ -240,7 +256,11 @@ export class AdminOrdersService { phoneVerifiedAt: true, }, }, - delivery: true, + delivery: { + include: { + fulfillmentProvider: { select: { code: true, pricingRulesJson: true } }, + }, + }, benefitCoupon: { select: { id: true, @@ -279,7 +299,8 @@ export class AdminOrdersService { ? await this.adminRedeemService.buildCouponRedeemTrace(coupon) : { redeemSummary: null, redeemRecords: [] }; - const { benefitCoupon: _coupon, ...orderRest } = order; + const withFee = this.withDeliveryLogisticsFee(order); + const { benefitCoupon: _coupon, ...orderRest } = withFee; return serializeBigInt( mapOrderCompat({ @@ -320,6 +341,7 @@ export class AdminOrdersService { include: { delivery: true, fulfillmentWarehouse: true, + product: { select: { spec: true } }, }, }); if (!order) throw new NotFoundException('订单不存在'); @@ -346,7 +368,7 @@ export class AdminOrdersService { }); order = await this.prisma.order.findUniqueOrThrow({ where: { id }, - include: { delivery: true, fulfillmentWarehouse: true }, + include: { delivery: true, fulfillmentWarehouse: true, product: { select: { spec: true } } }, }); } } @@ -371,6 +393,13 @@ export class AdminOrdersService { } const defaults = this.getShipDefaults(warehouse); + const { goodsName, goodsNum } = buildXfxGoodsPayload({ + productName: order.productName, + productSpec: order.productSpec, + physicalSpec: order.product?.spec, + quantity: order.quantity, + bottlesPerUnit: order.bottlesPerUnit, + }); const shipmentDto: XiaofeixiaCreateShipmentDto = { outNumber: order.orderNo, fromName: dto.fromName || defaults.fromName, @@ -383,8 +412,8 @@ export class AdminOrdersService { toMobile: order.receiverPhone, toAddress: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`, toAddressDetail: order.receiverAddress, - goodsName: order.productName, - goodsNum: order.quantity, + goodsName, + goodsNum, weight: dto.weight ?? defaults.weight, payMode: dto.payMode || defaults.payMode, remark: dto.remark || `HQ发货 ${order.orderNo}`, @@ -433,6 +462,34 @@ export class AdminOrdersService { return this.detail(id); } + private withDeliveryLogisticsFee< + T extends { + quantity: number; + bottlesPerUnit: number; + deliveryType: string; + delivery?: { + provider: string; + fulfillmentProvider?: { code: string; pricingRulesJson: string | null } | null; + } | null; + }, + >(order: T): T { + if (!order.delivery) return order; + const fp = order.delivery.fulfillmentProvider; + const logisticsFee = calcDeliveryFreightAmount({ + quantity: order.quantity, + bottlesPerUnit: order.bottlesPerUnit, + deliveryType: order.deliveryType, + provider: order.delivery.provider, + providerCode: fp?.code, + pricing: this.fulfillmentProviderService.parsePricingRules(fp?.pricingRulesJson ?? null), + }); + const { fulfillmentProvider: _fp, ...deliveryRest } = order.delivery; + return { + ...order, + delivery: { ...deliveryRest, logisticsFee }, + }; + } + getShipDefaults(warehouse?: { contactName: string; contactPhone: string; diff --git a/server/dukang-api/src/modules/ops/admin-redeem.service.ts b/server/dukang-api/src/modules/ops/admin-redeem.service.ts index a5d40cb..30e275e 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem.service.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem.service.ts @@ -4,6 +4,8 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { loadStorePrimaryBank } from '../../common/store/store-bank.util'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; +import { calcDeliveryFreightAmount } from '../fulfillment/delivery-freight.util'; import type { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto'; import type { UpdateDeliveryDto } from './dto/admin-mutate.dto'; @@ -334,18 +336,45 @@ export class AdminRedeemService { } } +const deliveryOrderSelect = { + id: true, + orderNo: true, + status: true, + deliveryType: true, + productName: true, + productSpec: true, + barcode69: true, + quantity: true, + saleUnit: true, + bottlesPerUnit: true, + payAmount: true, + receiverName: true, + receiverPhone: true, + receiverAddress: true, + receiverProvince: true, + receiverCity: true, + receiverDistrict: true, + user: { select: { id: true, userNo: true, phone: true, nickname: true } }, + imageResource: { select: { url: true } }, +} satisfies Prisma.OrderSelect; + @Injectable() export class AdminDeliveriesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly fulfillmentProviderService: FulfillmentProviderService, + ) {} async list(query: AdminDeliveriesQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; - const where: Prisma.OrderDeliveryWhereInput = {}; + const where: Prisma.OrderDeliveryWhereInput = { + order: { deliveryType: { not: 'ON_SITE_PICKUP' } }, + }; if (query.provider) where.provider = query.provider as DeliveryProvider; if (query.trackingNo) where.trackingNo = { contains: query.trackingNo }; if (query.orderNo) { - where.order = { orderNo: { contains: query.orderNo } }; + where.order = { deliveryType: { not: 'ON_SITE_PICKUP' }, orderNo: { contains: query.orderNo } }; } const [items, total] = await Promise.all([ @@ -355,39 +384,53 @@ export class AdminDeliveriesService { skip: (page - 1) * pageSize, take: pageSize, include: { - order: { - select: { - id: true, - orderNo: true, - status: true, - receiverName: true, - receiverPhone: true, - deliveryType: true, - productName: true, - quantity: true, - }, - }, + order: { select: deliveryOrderSelect }, + fulfillmentProvider: { select: { code: true, pricingRulesJson: true } }, }, }), this.prisma.orderDelivery.count({ where }), ]); - return serializeBigInt({ items, total, page, pageSize }); + return serializeBigInt({ + items: items.map((row) => this.withLogisticsFee(row)), + total, + page, + pageSize, + }); } async detail(id: bigint) { const delivery = await this.prisma.orderDelivery.findUnique({ where: { id }, include: { - order: { - include: { - user: { select: { id: true, userNo: true, phone: true } }, - imageResource: { select: { url: true } }, - }, - }, + order: { select: deliveryOrderSelect }, + fulfillmentProvider: { select: { code: true, pricingRulesJson: true } }, }, }); if (!delivery) throw new NotFoundException('配送单不存在'); - return serializeBigInt(delivery); + return serializeBigInt(this.withLogisticsFee(delivery)); + } + + private withLogisticsFee< + T extends { + provider: string; + order: { quantity: number; bottlesPerUnit: number; deliveryType: string }; + fulfillmentProvider?: { code: string; pricingRulesJson: string | null } | null; + }, + >(row: T) { + const { fulfillmentProvider, ...rest } = row; + return { + ...rest, + logisticsFee: calcDeliveryFreightAmount({ + quantity: row.order.quantity, + bottlesPerUnit: row.order.bottlesPerUnit, + deliveryType: row.order.deliveryType, + provider: row.provider, + providerCode: fulfillmentProvider?.code, + pricing: this.fulfillmentProviderService.parsePricingRules( + fulfillmentProvider?.pricingRulesJson ?? null, + ), + }), + }; } async update(id: bigint, dto: UpdateDeliveryDto) { diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index a290b2d..1fbb23c 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -14,6 +14,7 @@ import { allocateBenefitCoupons, } from '@dukang/domain'; import { + buildShopRedeemLandingUrl, ClientApp, REDEEM_PENDING_SNAPSHOT_TTL_SECONDS, REDEEM_PHONE_SESSION_TTL_SECONDS, @@ -24,6 +25,7 @@ import { import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { RedisService } from '../../common/redis/redis.service'; +import { SystemConfigService } from '../../common/system-config/system-config.service'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { AnalyticsService } from '../analytics/analytics.service'; import { SettlementService } from '../settlement/settlement.service'; @@ -84,6 +86,7 @@ export class RedeemService { private readonly authService: AuthService, private readonly payRedeemAnomaly: PayRedeemAnomalyService, private readonly wecomPush: WecomMessagePushService, + private readonly systemConfig: SystemConfigService, ) {} private maskPhoneForStore(phone: string) { @@ -576,7 +579,13 @@ export class RedeemService { }, }); - return { token, expireAt, amount: body.amount, boundStoreId: body.storeId ?? null }; + return { + token, + expireAt, + amount: body.amount, + boundStoreId: body.storeId ?? null, + landingUrl: buildShopRedeemLandingUrl(this.systemConfig.getAppConfig().shopH5Url, token), + }; } async getToken(token: string) { diff --git a/server/dukang-api/src/modules/store/store-package.service.ts b/server/dukang-api/src/modules/store/store-package.service.ts index e00a77a..d107ae6 100644 --- a/server/dukang-api/src/modules/store/store-package.service.ts +++ b/server/dukang-api/src/modules/store/store-package.service.ts @@ -243,8 +243,7 @@ export class StorePackageService { await this.hqPermissions.assertStoreIdInScope(actorId, storeId); const store = await this.prisma.store.findUnique({ where: { id: storeId } }); if (!store) throw new NotFoundException('门店不存在'); - const live = await this.listLivePackages(storeId); - return serializeBigInt({ live }); + return this.getPackagesWithPending(storeId); } async adminDirectSave(storeId: bigint, packages: StorePackageItemDto[], actorId: bigint) { diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index e91ee31..f868c32 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -465,9 +465,11 @@ export class TradeService { operator: 'MOCK_PAY', }), }); - await tx.orderDelivery.create({ - data: { orderId: order.id, provider: 'MANUAL' }, - }); + if (order.deliveryType !== 'ON_SITE_PICKUP') { + await tx.orderDelivery.create({ + data: { orderId: order.id, provider: 'MANUAL' }, + }); + } }); await this.afterOrderPaid(order.id); @@ -516,6 +518,12 @@ export class TradeService { ); } + if (order.deliveryType === 'ON_SITE_PICKUP') { + // 现场取货:支付即完成,不建配送单、不推仓配 + this.wechatOrderShipping.uploadForOrderSafe(orderId); + return; + } + const delivery = await this.prisma.orderDelivery.findUnique({ where: { orderId } }); if (!delivery) { await this.prisma.orderDelivery.create({ @@ -523,12 +531,6 @@ export class TradeService { }); } - if (order.deliveryType === 'ON_SITE_PICKUP') { - // 现场取货:支付后即向微信录入「用户自提」发货信息 - this.wechatOrderShipping.uploadForOrderSafe(orderId); - return; - } - await this.fulfillmentService.dispatchAfterPay(orderId); const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } }); if (refreshed?.status === 'PENDING_SHIP') { @@ -619,11 +621,13 @@ export class TradeService { operator: 'WECHAT_PAY', }), }); - const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } }); - if (!delivery) { - await tx.orderDelivery.create({ - data: { orderId: order.id, provider: 'MANUAL' }, - }); + if (order.deliveryType !== 'ON_SITE_PICKUP') { + const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } }); + if (!delivery) { + await tx.orderDelivery.create({ + data: { orderId: order.id, provider: 'MANUAL' }, + }); + } } }); @@ -2625,11 +2629,13 @@ export class TradeService { operator, }), }); - const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } }); - if (!delivery) { - await tx.orderDelivery.create({ - data: { orderId: order.id, provider: 'MANUAL' }, - }); + if (order.deliveryType !== 'ON_SITE_PICKUP') { + const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } }); + if (!delivery) { + await tx.orderDelivery.create({ + data: { orderId: order.id, provider: 'MANUAL' }, + }); + } } }); diff --git a/server/dukang-api/tsconfig.json b/server/dukang-api/tsconfig.json index 2107a7d..05e0b73 100644 --- a/server/dukang-api/tsconfig.json +++ b/server/dukang-api/tsconfig.json @@ -19,5 +19,6 @@ "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"] }