diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx index 00b1c43..98d4d0f 100644 --- a/apps/admin-web/src/pages/ProductsPage.tsx +++ b/apps/admin-web/src/pages/ProductsPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, - Table, Tabs, Tag, Typography, message, + Switch, Table, Tabs, Tag, Typography, message, } from 'antd'; import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; @@ -31,6 +31,7 @@ type Row = { benefitAmount: number; status: string; sortOrder: number; + allowOnSitePickup?: boolean; mainImageUrl?: string | null; carouselUrls?: string[]; detailImageUrls?: string[]; @@ -49,6 +50,7 @@ type ProductFormValues = { benefitAmount?: number; status?: string; sortOrder?: number; + allowOnSitePickup?: boolean; coverUrl?: string; carouselUrls?: string[]; detailImageUrls?: string[]; @@ -100,6 +102,7 @@ function buildProductPayload(v: ProductFormValues) { benefitAmount: v.benefitAmount, status: v.status, sortOrder: v.sortOrder, + allowOnSitePickup: !!v.allowOnSitePickup, coverUrl: v.coverUrl, carouselUrls, detailImageUrls, @@ -217,6 +220,9 @@ function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) { + + + @@ -266,6 +272,12 @@ export default function ProductsPage() { { title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` }, { title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` }, { title: '状态', dataIndex: 'status', width: 80, render: (s) => {PRODUCT_STATUS_LABELS[s] || s} }, + { + title: '现场取货', + dataIndex: 'allowOnSitePickup', + width: 90, + render: (v: boolean) => (v ? 允许 : ), + }, { title: '排序', dataIndex: 'sortOrder', width: 60 }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { @@ -357,7 +369,7 @@ export default function ProductsPage() { void reload(); }} width={720}>
diff --git a/apps/mini-user/src/app.config.ts b/apps/mini-user/src/app.config.ts index 1bb2d0a..4739cb8 100644 --- a/apps/mini-user/src/app.config.ts +++ b/apps/mini-user/src/app.config.ts @@ -7,9 +7,11 @@ export default defineAppConfig({ 'pages/product-detail/index', 'pages/store-detail/index', 'pages/order-confirm/index', + 'pages/order-confirm-pickup/index', 'pages/pay/index', 'pages/orders/index', 'pages/order-detail/index', + 'pages/pickup-receive/index', 'pages/addresses/index', 'pages/address-edit/index', 'pages/customer-service/index', diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx index 6474764..11d54c5 100644 --- a/apps/mini-user/src/pages/home/index.tsx +++ b/apps/mini-user/src/pages/home/index.tsx @@ -6,7 +6,9 @@ import TabMainHeader from '../../components/TabMainHeader'; import CouponBadge from '../../components/CouponBadge'; import ProductCarousel from '../../components/ProductCarousel'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; -import { request, toast } from '../../lib/api'; +import { goLogin } from '../../lib/auth-nav'; +import { isLoggedIn, request, toast } from '../../lib/api'; +import { ensurePayReady } from '../../lib/pay-ready'; import { capturePromoSceneAndTouchScan } from '../../lib/promo'; import { getProductImages } from '../../lib/product-images'; import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location'; @@ -19,6 +21,7 @@ type Product = { mainImageUrl?: string | null; carouselUrls?: string[] | null; aromaType: string; + allowOnSitePickup?: boolean; }; const AROMA_TABS = [ @@ -95,6 +98,17 @@ export default function HomePage() { Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` }); } + async function goOnSitePickup(productId: string) { + const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`; + if (!isLoggedIn()) { + goLogin(returnPath); + return; + } + const ready = await ensurePayReady(returnPath); + if (!ready) return; + Taro.navigateTo({ url: returnPath }); + } + const filtered = products.filter((p) => p.aromaType === tab); return ( @@ -140,6 +154,16 @@ export default function HomePage() { + {p.allowOnSitePickup ? ( + { + void goOnSitePickup(p.id); + }} + > + 现场取货 + + ) : null} openProductDetail(p.id)}> 立即购买 diff --git a/apps/mini-user/src/pages/order-confirm-pickup/index.config.ts b/apps/mini-user/src/pages/order-confirm-pickup/index.config.ts new file mode 100644 index 0000000..9ffd8d7 --- /dev/null +++ b/apps/mini-user/src/pages/order-confirm-pickup/index.config.ts @@ -0,0 +1,4 @@ +export default definePageConfig({ + navigationStyle: 'custom', + navigationBarTitleText: '现场取货确认', +}); diff --git a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx new file mode 100644 index 0000000..23d95b8 --- /dev/null +++ b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx @@ -0,0 +1,246 @@ +import { useEffect, useRef, useState } from 'react'; +import { View, Text, Image } from '@tarojs/components'; +import Taro, { useRouter } from '@tarojs/taro'; +import PageShell from '../../components/PageShell'; +import SubPageHeader from '../../components/SubPageHeader'; +import { goLogin } from '../../lib/auth-nav'; +import { buildPayUrl } from '../../lib/checkout-nav'; +import { ensurePayReady } from '../../lib/pay-ready'; +import { fetchUserProfile } from '../../lib/pay-wechat'; +import { request } from '../../lib/api'; +import { getProductMainImage } from '../../lib/product-images'; + +type PreviewProduct = { + id: string; + name: string; + spec?: string; + subtitle?: string; + price: number; + mainImageUrl?: string | null; + carouselUrls?: string[] | null; +}; + +type OrderPreview = { + product: PreviewProduct; + quantity: number; + deliveryType: string; + productAmount: number; + payAmount: number; + benefitAmount: number; + quantityOk?: boolean; + quantityMessage?: string | null; + minQty?: number; +}; + +export default function OrderConfirmPickupPage() { + const router = useRouter(); + const productId = router.params.productId ?? ''; + const [quantity, setQuantity] = useState(Math.max(1, Number(router.params.qty || 1))); + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [loading, setLoading] = useState(false); + const [msg, setMsg] = useState(''); + const phonePromptSkipped = useRef(false); + + useEffect(() => { + if (!productId) return; + let cancelled = false; + setPreviewLoading(true); + request('/trade/orders/preview', { + method: 'POST', + data: { productId, quantity, onSitePickup: true }, + }) + .then((data) => { + if (!cancelled) { + setPreview(data); + setMsg(data.quantityOk === false ? data.quantityMessage || '' : ''); + } + }) + .catch((e) => { + if (!cancelled) { + setPreview(null); + setMsg(e instanceof Error ? e.message : '加载失败'); + } + }) + .finally(() => { + if (!cancelled) setPreviewLoading(false); + }); + return () => { + cancelled = true; + }; + }, [productId, quantity]); + + const minQty = preview?.minQty ?? 1; + const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false; + const canSubmit = !!preview && quantityOk && !loading && !previewLoading; + + function updateQuantity(next: number) { + if (next < 1) return; + setQuantity(next); + } + + async function doSubmit() { + const order = await request<{ id: string }>('/trade/orders', { + method: 'POST', + data: { productId, quantity, onSitePickup: true }, + }); + Taro.redirectTo({ + url: buildPayUrl({ + orderId: order.id, + productId, + qty: String(quantity), + }), + }); + } + + async function submit() { + if (!canSubmit) { + if (!quantityOk) setMsg(`现场取货至少购买 ${minQty} 瓶`); + return; + } + + const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=${quantity}`; + + if (!phonePromptSkipped.current) { + try { + const profile = await fetchUserProfile(); + const phoneBound = + !!profile.phoneVerified || + (!!profile.phone && /^1[3-9]\d{9}$/.test(String(profile.phone))); + if (!phoneBound) { + const { confirm, cancel } = await Taro.showModal({ + title: '建议绑定手机号', + content: '绑定后便于订单通知与售后联系;也可跳过,不绑定也能继续下单。', + confirmText: '去绑定', + cancelText: '暂不绑定', + }); + if (confirm) { + goLogin(returnPath, { needPhone: '1' }); + return; + } + if (cancel) { + phonePromptSkipped.current = true; + } + } + } catch { + /* 拉取档案失败不阻塞下单 */ + } + } + + const ready = await ensurePayReady(returnPath); + if (!ready) return; + + setLoading(true); + setMsg(''); + try { + await doSubmit(); + } catch (e) { + setMsg(e instanceof Error ? e.message : '下单失败'); + } finally { + setLoading(false); + } + } + + const productImage = preview?.product ? getProductMainImage(preview.product) : ''; + const submitLabel = loading + ? '提交中…' + : !quantityOk + ? `至少购买 ${minQty} 瓶` + : '提交订单'; + + return ( + + + + + 取货方式 + 现场取货 · 无需填写收货地址 · 免运费 + + + {preview ? ( + <> + + 商品信息 + + + {productImage ? ( + + ) : null} + + + {preview.product.name} + {preview.product.spec ? ( + {preview.product.spec} + ) : null} + + ¥{Number(preview.product.price).toFixed(2)} + + + + + 购买数量 + + updateQuantity(quantity - 1)} + > + + + {quantity} + updateQuantity(quantity + 1)}> + + + + + + + + 费用明细 + + 商品金额 + ¥{Number(preview.productAmount).toFixed(2)} + + + 好客权益 + ¥{Number(preview.benefitAmount).toFixed(2)} + + + 运费 + 免运费 + + + + ) : previewLoading ? ( + 加载订单信息… + ) : null} + + {msg ? ( + + {msg} + + ) : null} + + + + 应付合计 + + ¥{preview ? Number(preview.payAmount).toFixed(2) : '—'} + + + { + if (!canSubmit) return; + void submit(); + }} + > + {submitLabel} + + + + ); +} diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx index b64407e..998a036 100644 --- a/apps/mini-user/src/pages/order-detail/index.tsx +++ b/apps/mini-user/src/pages/order-detail/index.tsx @@ -56,16 +56,6 @@ const STATUS_LABELS: Record = { REFUNDED: '已退款', }; -/** 已付款未完成:可选现场取货并确认收货 */ -const ON_SITE_PICKUP_STATUSES = new Set([ - 'PENDING_SHIP', - 'OUT_WAREHOUSE', - 'SHIPPING', - 'SHIPPED', - 'PENDING_RECEIVE', - 'DELIVERED', -]); - function fullReceiverAddress(order: OrderDetail) { const detail = (order.receiverAddress || '').trim(); const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict] @@ -80,7 +70,6 @@ export default function OrderDetailPage() { const router = useRouter(); const orderId = router.params.id ?? ''; const [order, setOrder] = useState(null); - const [onSitePickup, setOnSitePickup] = useState(false); const [confirming, setConfirming] = useState(false); useEffect(() => { @@ -92,13 +81,8 @@ export default function OrderDetailPage() { const isReship = !!order?.originOrderId; const canPay = !!order && order.status === 'PENDING_PAY' && !isReship; - const canOnSitePickup = - !!order && !isReship && ON_SITE_PICKUP_STATUSES.has(order.status || ''); const canConfirmReceive = - !!order && - !isReship && - (['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '') || - (onSitePickup && canOnSitePickup)); + !!order && !isReship && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || ''); const item = order?.items?.[0]; const productName = item?.productName || order?.productName || '杜康商品'; @@ -137,13 +121,9 @@ export default function OrderDetailPage() { async function confirmReceive() { if (!order || !canConfirmReceive || confirming) return; - const useOnSite = - onSitePickup || !['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || ''); const { confirm } = await Taro.showModal({ - title: useOnSite ? '确认现场取货?' : '确认收货?', - content: useOnSite - ? '请确认您已在现场拿到商品。确认后订单将完成,好客权益即时可用,无法再安排配送。若尚未取到酒,请勿确认。' - : '请确认已收到商品。确认后订单将完成,好客权益可正常使用。', + title: '确认收货?', + content: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。', confirmText: '确认收货', cancelText: '再想想', }); @@ -153,11 +133,10 @@ export default function OrderDetailPage() { try { const updated = await request(`/trade/orders/${order.id}/confirm-receive`, { method: 'POST', - data: { onSitePickup: useOnSite }, + data: {}, }); setOrder(updated); - setOnSitePickup(false); - toast(useOnSite ? '现场取货已确认,订单完成' : '已确认收货'); + toast('已确认收货'); } catch (e) { toast(e instanceof Error ? e.message : '确认收货失败'); } finally { @@ -225,27 +204,6 @@ export default function OrderDetailPage() { 地址信息待完善 )} - {canOnSitePickup ? ( - - 取货方式 - setOnSitePickup((v) => !v)} - > - - {onSitePickup ? : null} - - - 现场取货 - - 已在活动现场或门店拿到商品时勾选,确认后订单直接完成 - - - - - ) : null} 订单信息 @@ -303,7 +261,7 @@ export default function OrderDetailPage() { className={`order-confirm-submit${confirming ? ' order-confirm-submit--disabled' : ''}`} onClick={confirming ? undefined : () => void confirmReceive()} > - {confirming ? '提交中…' : onSitePickup ? '确认现场取货' : '确认收货'} + {confirming ? '提交中…' : '确认收货'} ) : null} diff --git a/apps/mini-user/src/pages/pay/index.tsx b/apps/mini-user/src/pages/pay/index.tsx index fa2ff8e..6c1368d 100644 --- a/apps/mini-user/src/pages/pay/index.tsx +++ b/apps/mini-user/src/pages/pay/index.tsx @@ -29,6 +29,7 @@ export default function PayPage() { const [msg, setMsg] = useState(''); const [orderNo, setOrderNo] = useState(''); const [payAmount, setPayAmount] = useState('—'); + const [deliveryType, setDeliveryType] = useState(''); const returnPath = orderId ? `/pages/pay/index?orderId=${orderId}` @@ -62,11 +63,15 @@ export default function PayPage() { setPayAmount('—'); return; } - request<{ orderNo?: string; payAmount?: number | string; totalAmount?: number | string }>( - `/trade/orders/${orderId}`, - ) + request<{ + orderNo?: string; + payAmount?: number | string; + totalAmount?: number | string; + deliveryType?: string; + }>(`/trade/orders/${orderId}`) .then((order) => { setOrderNo(order.orderNo || ''); + setDeliveryType(order.deliveryType || ''); const amount = Number(order.payAmount ?? order.totalAmount ?? 0); if (Number.isFinite(amount) && amount > 0) { setPayAmount(amount.toFixed(2)); @@ -74,6 +79,7 @@ export default function PayPage() { }) .catch((e) => { setOrderNo(''); + setDeliveryType(''); toast(e instanceof Error ? e.message : '加载订单失败'); }); }, [orderId]); @@ -137,7 +143,11 @@ export default function PayPage() { } else { toast('支付成功', 'success'); } - Taro.redirectTo({ url: '/pages/orders/index?tab=paid' }); + if (deliveryType === 'ON_SITE_PICKUP') { + Taro.redirectTo({ url: `/pages/pickup-receive/index?id=${orderId}` }); + } else { + Taro.redirectTo({ url: '/pages/orders/index?tab=paid' }); + } } catch (e) { if (isWechatAuthRequiredError(e)) { setNeedsWechatAuth(true); diff --git a/apps/mini-user/src/pages/pickup-receive/index.config.ts b/apps/mini-user/src/pages/pickup-receive/index.config.ts new file mode 100644 index 0000000..654a424 --- /dev/null +++ b/apps/mini-user/src/pages/pickup-receive/index.config.ts @@ -0,0 +1,4 @@ +export default definePageConfig({ + navigationStyle: 'custom', + navigationBarTitleText: '确认收货', +}); diff --git a/apps/mini-user/src/pages/pickup-receive/index.tsx b/apps/mini-user/src/pages/pickup-receive/index.tsx new file mode 100644 index 0000000..bf8c7e9 --- /dev/null +++ b/apps/mini-user/src/pages/pickup-receive/index.tsx @@ -0,0 +1,137 @@ +import { useCallback, useState } from 'react'; +import { View, Text, Image } from '@tarojs/components'; +import Taro, { useDidShow, useRouter } from '@tarojs/taro'; +import PageShell from '../../components/PageShell'; +import SubPageHeader from '../../components/SubPageHeader'; +import { request, toast } from '../../lib/api'; +import { getProductMainImage } from '../../lib/product-images'; + +type OrderDetail = { + id: string; + orderNo?: string; + status?: string; + payAmount?: number | string; + productName?: string; + productSpec?: string; + quantity?: number; + product?: { + name?: string; + spec?: string; + mainImageUrl?: string | null; + carouselUrls?: string[] | null; + }; + imageUrl?: string | null; + mainImageUrl?: string | null; +}; + +export default function PickupReceivePage() { + const router = useRouter(); + const orderId = router.params.id ?? router.params.orderId ?? ''; + const [order, setOrder] = useState(null); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const load = useCallback(() => { + if (!orderId) return; + setLoading(true); + request(`/trade/orders/${orderId}`) + .then((data) => setOrder(data)) + .catch((e) => toast(e instanceof Error ? e.message : '加载失败')) + .finally(() => setLoading(false)); + }, [orderId]); + + useDidShow(() => { + load(); + }); + + async function confirmReceive() { + if (!orderId || submitting) return; + setSubmitting(true); + try { + await request(`/trade/orders/${orderId}/confirm-receive`, { + method: 'POST', + data: {}, + }); + toast('确认收货成功', 'success'); + setTimeout(() => { + Taro.redirectTo({ url: '/pages/orders/index?tab=done' }); + }, 500); + } catch (e) { + toast(e instanceof Error ? e.message : '确认失败'); + } finally { + setSubmitting(false); + } + } + + const name = order?.productName || order?.product?.name || '商品'; + const spec = order?.productSpec || order?.product?.spec; + const image = + order?.mainImageUrl || + order?.imageUrl || + (order?.product ? getProductMainImage(order.product) : '') || + ''; + const amount = Number(order?.payAmount ?? 0); + const canConfirm = order?.status === 'PENDING_RECEIVE' || order?.status === 'DELIVERED'; + + return ( + + + + + 现场取货 + 请确认已在现场拿到商品后再点击确认收货 + + + {loading && !order ? ( + + 加载中… + + ) : null} + + {order ? ( + <> + + + 订单号 + {order.orderNo || '—'} + + + + {image ? ( + + ) : null} + + + {name} + {spec ? {spec} : null} + ×{order.quantity ?? 1} + + + + + + + 实付金额 + + ¥{Number.isFinite(amount) ? amount.toFixed(2) : '—'} + + + + + ) : null} + + + { + if (!canConfirm || submitting) return; + void confirmReceive(); + }} + > + {submitting ? '提交中…' : canConfirm ? '确认收货' : '订单状态不可确认'} + + + + ); +} diff --git a/apps/mini-user/src/styles/home.css b/apps/mini-user/src/styles/home.css index 5d2eaa4..7d792d7 100644 --- a/apps/mini-user/src/styles/home.css +++ b/apps/mini-user/src/styles/home.css @@ -191,6 +191,20 @@ padding: 0 var(--space-gutter) var(--space-gutter); display: flex; justify-content: flex-end; + gap: 8px; +} + +.home-pickup-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border-radius: var(--radius-full); + background: #2e7d32; + color: #fff; + font-size: 13px; + font-weight: 600; + border: none; } .home-buy-btn { diff --git a/apps/mini-user/src/styles/order.css b/apps/mini-user/src/styles/order.css index 089b4cc..12625e3 100644 --- a/apps/mini-user/src/styles/order.css +++ b/apps/mini-user/src/styles/order.css @@ -73,58 +73,6 @@ pointer-events: none; } -.order-pickup-option { - display: flex; - align-items: flex-start; - gap: 12px; -} - -.order-pickup-check { - width: 20px; - height: 20px; - margin-top: 2px; - flex-shrink: 0; - border: 1.5px solid var(--color-outline, #c8c4be); - border-radius: 4px; - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: center; - background: #fff; -} - -.order-pickup-check--on { - border-color: var(--color-primary, #8b1a1a); - background: var(--color-primary, #8b1a1a); -} - -.order-pickup-check-mark { - color: #fff; - font-size: 12px; - font-weight: 700; - line-height: 1; -} - -.order-pickup-copy { - flex: 1; - min-width: 0; -} - -.order-pickup-title { - display: block; - font-size: 15px; - font-weight: 600; - color: var(--color-on-surface); - margin-bottom: 4px; -} - -.order-pickup-desc { - display: block; - font-size: 12px; - line-height: 1.5; - color: var(--color-on-surface-variant, #78716c); -} - .order-card { background: var(--color-card); border-radius: var(--radius-lg); diff --git a/packages/domain/src/index.test.ts b/packages/domain/src/index.test.ts index d6a4923..50805d7 100644 --- a/packages/domain/src/index.test.ts +++ b/packages/domain/src/index.test.ts @@ -32,6 +32,11 @@ describe('validateMinPurchase', () => { expect(validateMinPurchase('CROSS_CITY', 5, 2, 6).ok).toBe(false); expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true); }); + + it('on-site pickup requires at least 1 bottle', () => { + expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false); + expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(true); + }); }); describe('validateRedeemAmount', () => { diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 5a0280c..f89a0c7 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -8,11 +8,17 @@ export function calcBenefitAmount(product: ProductPricing): number { } export function validateMinPurchase( - deliveryType: 'LOCAL' | 'CROSS_CITY', + deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP', quantity: number, localMinQty: number, crossMinQty: number, ): { ok: boolean; message?: string } { + if (deliveryType === 'ON_SITE_PICKUP') { + if (quantity < 1) { + return { ok: false, message: '现场取货至少购买 1 瓶' }; + } + return { ok: true }; + } const min = deliveryType === 'LOCAL' ? localMinQty : crossMinQty; if (quantity < min) { return { diff --git a/packages/shared-types/src/catalog.ts b/packages/shared-types/src/catalog.ts index 67c4125..b522834 100644 --- a/packages/shared-types/src/catalog.ts +++ b/packages/shared-types/src/catalog.ts @@ -25,6 +25,8 @@ export interface ProductDto { /** 详情长图(bizType=DETAIL 或 detailContent JSON) */ detailImageUrls?: string[]; detailContent?: ProductDetailContentDto | null; + /** 是否允许现场取货下单 */ + allowOnSitePickup?: boolean; } export interface ProductDetailFeatureDto { diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts index 96d0b61..42373d9 100644 --- a/packages/shared-types/src/enums.ts +++ b/packages/shared-types/src/enums.ts @@ -91,6 +91,7 @@ export enum OrderTab { export enum DeliveryType { LOCAL = 'LOCAL', CROSS_CITY = 'CROSS_CITY', + ON_SITE_PICKUP = 'ON_SITE_PICKUP', } export enum AromaType { diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 65a3499..fe0f0f6 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -262,6 +262,7 @@ enum PayStatus { enum DeliveryType { LOCAL CROSS_CITY + ON_SITE_PICKUP } enum FreightPayType { @@ -451,6 +452,7 @@ model CommonProductItem { benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2) status ProductStatus @default(DRAFT) sortOrder Int @default(0) @map("sort_order") + allowOnSitePickup Boolean @default(false) @map("allow_on_site_pickup") coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt detailContent Json? @map("detail_content") createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) diff --git a/server/dukang-api/src/modules/ops/admin-products.service.ts b/server/dukang-api/src/modules/ops/admin-products.service.ts index 7f0502c..34d0f06 100644 --- a/server/dukang-api/src/modules/ops/admin-products.service.ts +++ b/server/dukang-api/src/modules/ops/admin-products.service.ts @@ -89,6 +89,7 @@ export class AdminProductsService { benefitAmount: dto.benefitAmount ?? dto.price, status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE', sortOrder: dto.sortOrder ?? 0, + allowOnSitePickup: dto.allowOnSitePickup ?? false, ...(dto.detailContent !== undefined ? { detailContent: dto.detailContent as Prisma.InputJsonValue } : {}), @@ -118,6 +119,7 @@ export class AdminProductsService { ...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}), ...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}), ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), + ...(dto.allowOnSitePickup !== undefined ? { allowOnSitePickup: dto.allowOnSitePickup } : {}), ...(dto.detailContent !== undefined ? { detailContent: dto.detailContent as Prisma.InputJsonValue } : {}), diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 72dd471..1840897 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -947,6 +947,10 @@ export class CreateProductDto { @IsNumber() sortOrder?: number; + @IsOptional() + @IsBoolean() + allowOnSitePickup?: boolean; + @IsOptional() @IsString() coverUrl?: string; @@ -995,6 +999,10 @@ export class UpdateProductDto { @IsNumber() sortOrder?: number; + @IsOptional() + @IsBoolean() + allowOnSitePickup?: boolean; + @IsOptional() @IsString() coverUrl?: string; diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index bb12099..addf0b7 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -51,7 +51,10 @@ export class TradeService { private readonly fulfillmentService: FulfillmentService, ) {} - async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) { + async preview( + userId: bigint, + body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean }, + ) { const product = await this.catalogService.getProduct(BigInt(body.productId)); if (!product || product.status !== 'ON_SALE') { throw new BadRequestException('商品不可购买'); @@ -59,8 +62,15 @@ export class TradeService { const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } }); if (!city) throw new BadRequestException('暂无开城城市'); - let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL'; - if (body.addressId) { + const onSitePickup = !!body.onSitePickup; + if (onSitePickup && !product.allowOnSitePickup) { + throw new BadRequestException('该商品不支持现场取货'); + } + + let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = onSitePickup + ? 'ON_SITE_PICKUP' + : 'LOCAL'; + if (!onSitePickup && body.addressId) { const address = await this.prisma.userAddress.findFirst({ where: { id: BigInt(body.addressId), userId }, }); @@ -84,13 +94,19 @@ export class TradeService { }); const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null; + const minQty = + deliveryType === 'ON_SITE_PICKUP' + ? 1 + : deliveryType === 'LOCAL' + ? city.localMinQty + : city.crossMinQty; return { product, quantity: body.quantity, deliveryType, productAmount, - freightAmount: deliveryType === 'CROSS_CITY' ? 0 : 0, + freightAmount: 0, freightPayType, payAmount: productAmount, benefitAmount: benefitPerUnit * body.quantity, @@ -98,7 +114,8 @@ export class TradeService { /** 起购未满足时仍返回预览,供确认页改数量;下单接口仍会硬校验 */ quantityOk: check.ok, quantityMessage: check.ok ? null : (check.message ?? null), - minQty: deliveryType === 'LOCAL' ? city.localMinQty : city.crossMinQty, + minQty, + onSitePickup, }; } @@ -107,7 +124,8 @@ export class TradeService { body: { productId: string; quantity: number; - addressId: string; + addressId?: string; + onSitePickup?: boolean; clientLocation?: unknown; }, req: Request, @@ -116,10 +134,35 @@ export class TradeService { if (preview.quantityOk === false) { throw new BadRequestException(preview.quantityMessage || '购买数量不满足起购要求'); } - const address = await this.prisma.userAddress.findFirst({ - where: { id: BigInt(body.addressId), userId }, - }); - if (!address) throw new BadRequestException('请选择收货地址'); + + const onSitePickup = !!body.onSitePickup || preview.deliveryType === 'ON_SITE_PICKUP'; + let receiverName = '现场取货'; + let receiverPhone = '00000000000'; + let receiverAddress = '现场取货'; + let receiverProvince = ''; + let receiverCity = ''; + let receiverDistrict = ''; + + if (onSitePickup) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + receiverPhone = user?.phone || '00000000000'; + receiverName = (user?.nickname?.trim() || '现场取货').slice(0, 32); + receiverProvince = '现场'; + receiverCity = '现场'; + receiverDistrict = '取货'; + } else { + if (!body.addressId) throw new BadRequestException('请选择收货地址'); + const address = await this.prisma.userAddress.findFirst({ + where: { id: BigInt(body.addressId), userId }, + }); + if (!address) throw new BadRequestException('请选择收货地址'); + receiverName = address.receiverName; + receiverPhone = address.phone; + receiverAddress = `${address.province}${address.city}${address.district}${address.detail}`; + receiverProvince = address.province; + receiverCity = address.city; + receiverDistrict = address.district; + } const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: BigInt(body.productId) }, @@ -149,7 +192,7 @@ export class TradeService { cityId: city.id, status: 'PENDING_PAY', payStatus: 'UNPAID', - deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY', + deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP', productId: product.id, barcode69: product.barcode69, productName: product.name, @@ -159,12 +202,12 @@ export class TradeService { listUnitPrice: product.price, listAmount: preview.productAmount, productAmount: preview.productAmount, - receiverName: address.receiverName, - receiverPhone: address.phone, - receiverAddress: `${address.province}${address.city}${address.district}${address.detail}`, - receiverProvince: address.province, - receiverCity: address.city, - receiverDistrict: address.district, + receiverName, + receiverPhone, + receiverAddress, + receiverProvince, + receiverCity, + receiverDistrict, clientIp: location.clientIp, ipProvince: location.ipProvince, ipCity: location.ipCity, @@ -203,6 +246,7 @@ export class TradeService { orderId: order.id.toString(), productId: body.productId, quantity: body.quantity, + onSitePickup, }, }); @@ -241,12 +285,14 @@ export class TradeService { order.cityId, order.receiverDistrict, ); + const toStatus = + order.deliveryType === 'ON_SITE_PICKUP' ? 'PENDING_RECEIVE' : 'PENDING_SHIP'; await this.prisma.$transaction(async (tx) => { await tx.order.update({ where: { id: order.id }, data: { - status: 'PENDING_SHIP', + status: toStatus, payStatus: 'PAID', paidAt: now, payExternalNo: externalNo, @@ -269,7 +315,7 @@ export class TradeService { data: buildOrderStatusEvent({ orderId: order.id, fromStatus: 'PENDING_PAY', - toStatus: 'PENDING_SHIP', + toStatus, operator: 'MOCK_PAY', }), }); @@ -292,6 +338,20 @@ export class TradeService { private async afterOrderPaid(orderId: bigint) { await this.benefitService.grantOnOrderPaid(orderId); + const order = await this.prisma.order.findUnique({ where: { id: orderId } }); + if (!order) return; + + const delivery = await this.prisma.orderDelivery.findUnique({ where: { orderId } }); + if (!delivery) { + await this.prisma.orderDelivery.create({ + data: { orderId, provider: 'MANUAL' }, + }); + } + + if (order.deliveryType === 'ON_SITE_PICKUP') { + return; + } + await this.fulfillmentService.dispatchAfterPay(orderId); const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } }); if (refreshed?.status === 'PENDING_SHIP') { @@ -335,6 +395,8 @@ export class TradeService { order.cityId, order.receiverDistrict, ); + const toStatus = + order.deliveryType === 'ON_SITE_PICKUP' ? 'PENDING_RECEIVE' : 'PENDING_SHIP'; await this.prisma.$transaction(async (tx) => { const current = await tx.order.findUnique({ where: { id: order.id } }); if (!current || current.payStatus === 'PAID') return; @@ -342,7 +404,7 @@ export class TradeService { await tx.order.update({ where: { id: order.id }, data: { - status: 'PENDING_SHIP', + status: toStatus, payStatus: 'PAID', paidAt: now, payExternalNo: params.transactionId, @@ -365,7 +427,7 @@ export class TradeService { data: buildOrderStatusEvent({ orderId: order.id, fromStatus: 'PENDING_PAY', - toStatus: 'PENDING_SHIP', + toStatus, operator: 'WECHAT_PAY', }), }); @@ -470,25 +532,12 @@ export class TradeService { async confirmReceive( userId: bigint, orderId: bigint, - opts?: { onSitePickup?: boolean }, + _opts?: { onSitePickup?: boolean }, ) { const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } }); if (!order) throw new NotFoundException('订单不存在'); - const onSitePickup = !!opts?.onSitePickup; - const onSiteEligible = [ - 'PENDING_SHIP', - 'OUT_WAREHOUSE', - 'SHIPPING', - 'SHIPPED', - 'PENDING_RECEIVE', - 'DELIVERED', - ]; - if (onSitePickup) { - if (!onSiteEligible.includes(order.status)) { - throw new BadRequestException('当前状态不可现场取货'); - } - } else if (!['PENDING_RECEIVE', 'DELIVERED'].includes(order.status)) { + if (!['PENDING_RECEIVE', 'DELIVERED'].includes(order.status)) { throw new BadRequestException('当前状态不可确认收货'); } @@ -496,8 +545,8 @@ export class TradeService { order.id, order.status, 'COMPLETED', - onSitePickup ? 'USER_ON_SITE' : 'USER', - onSitePickup ? '用户现场取货确认收货' : undefined, + order.deliveryType === 'ON_SITE_PICKUP' ? 'USER_ON_SITE' : 'USER', + order.deliveryType === 'ON_SITE_PICKUP' ? '用户现场取货确认收货' : undefined, ); return this.getOrder(userId, orderId); }