feat(trade): add on-site pickup order flow
CI / verify (pull_request) Has been cancelled

Product flag, ON_SITE_PICKUP delivery, mini confirm/receive pages; pay skips warehouse auto-advance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-23 01:17:59 +08:00
parent e93e4a7721
commit cebeefdb22
19 changed files with 580 additions and 146 deletions
+14 -2
View File
@@ -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' }) {
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
@@ -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) => <Tag>{PRODUCT_STATUS_LABELS[s] || s}</Tag> },
{
title: '现场取货',
dataIndex: 'allowOnSitePickup',
width: 90,
render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>),
},
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
@@ -357,7 +369,7 @@ export default function ProductsPage() {
void reload();
}} width={720}>
<Form form={createForm} layout="vertical" initialValues={{
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, allowOnSitePickup: false,
carouselUrls: [''], detailImageUrls: [''],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
+2
View File
@@ -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',
+25 -1
View File
@@ -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() {
</View>
</View>
<View className="home-product-actions">
{p.allowOnSitePickup ? (
<Text
className="home-pickup-btn"
onClick={() => {
void goOnSitePickup(p.id);
}}
>
</Text>
) : null}
<Text className="home-buy-btn" onClick={() => openProductDetail(p.id)}>
</Text>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '现场取货确认',
});
@@ -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<OrderPreview | null>(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<OrderPreview>('/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 (
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
<SubPageHeader title="现场取货确认" />
<View className="sub-page-body">
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="u-muted"> · · </Text>
</View>
{preview ? (
<>
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-product-row">
<View className="order-product-thumb">
{productImage ? (
<Image
className="order-product-thumb-img"
src={productImage}
mode="aspectFill"
/>
) : null}
</View>
<View style={{ flex: 1 }}>
<Text className="order-product-name">{preview.product.name}</Text>
{preview.product.spec ? (
<Text className="u-muted">{preview.product.spec}</Text>
) : null}
<Text className="order-product-price">
¥{Number(preview.product.price).toFixed(2)}
</Text>
</View>
</View>
<View className="order-qty-row">
<Text></Text>
<View className="order-qty-controls">
<View
className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`}
onClick={() => updateQuantity(quantity - 1)}
>
<Text></Text>
</View>
<Text className="order-qty-value">{quantity}</Text>
<View className="order-qty-btn" onClick={() => updateQuantity(quantity + 1)}>
<Text></Text>
</View>
</View>
</View>
</View>
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">¥{Number(preview.productAmount).toFixed(2)}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value--price">¥{Number(preview.benefitAmount).toFixed(2)}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value"></Text>
</View>
</View>
</>
) : previewLoading ? (
<View className="u-empty"></View>
) : null}
{msg ? (
<Text className="u-muted" style={{ display: 'block', marginTop: 8 }}>
{msg}
</Text>
) : null}
</View>
<View className="order-confirm-bar">
<View className="order-confirm-total">
<Text className="order-confirm-total-label"></Text>
<Text className="order-confirm-total-value">
¥{preview ? Number(preview.payAmount).toFixed(2) : '—'}
</Text>
</View>
<View
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
onClick={() => {
if (!canSubmit) return;
void submit();
}}
>
<Text>{submitLabel}</Text>
</View>
</View>
</PageShell>
);
}
@@ -56,16 +56,6 @@ const STATUS_LABELS: Record<string, string> = {
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<OrderDetail | null>(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<OrderDetail>(`/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() {
<Text className="u-muted"></Text>
)}
</View>
{canOnSitePickup ? (
<View className="order-card">
<Text className="order-card-title"></Text>
<View
className="order-pickup-option"
onClick={() => setOnSitePickup((v) => !v)}
>
<View
className={`order-pickup-check${onSitePickup ? ' order-pickup-check--on' : ''}`}
>
{onSitePickup ? <Text className="order-pickup-check-mark"></Text> : null}
</View>
<View className="order-pickup-copy">
<Text className="order-pickup-title"></Text>
<Text className="order-pickup-desc">
</Text>
</View>
</View>
</View>
) : null}
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-row">
@@ -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 ? '提交中…' : '确认收货'}
</View>
) : null}
</View>
+13 -3
View File
@@ -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');
}
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);
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '确认收货',
});
@@ -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<OrderDetail | null>(null);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const load = useCallback(() => {
if (!orderId) return;
setLoading(true);
request<OrderDetail>(`/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 (
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
<SubPageHeader title="确认收货" />
<View className="sub-page-body">
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="u-muted"></Text>
</View>
{loading && !order ? (
<View className="order-card">
<Text className="u-muted"></Text>
</View>
) : null}
{order ? (
<>
<View className="order-card">
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">{order.orderNo || '—'}</Text>
</View>
<View className="order-product-row" style={{ marginTop: 12 }}>
<View className="order-product-thumb">
{image ? (
<Image className="order-product-thumb-img" src={image} mode="aspectFill" />
) : null}
</View>
<View style={{ flex: 1 }}>
<Text className="order-product-name">{name}</Text>
{spec ? <Text className="u-muted">{spec}</Text> : null}
<Text className="u-muted">×{order.quantity ?? 1}</Text>
</View>
</View>
</View>
<View className="order-card">
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value order-pay-amount">
¥{Number.isFinite(amount) ? amount.toFixed(2) : '—'}
</Text>
</View>
</View>
</>
) : null}
</View>
<View className="pay-bar">
<View
className="order-confirm-submit"
style={{ flex: 1, opacity: canConfirm && !submitting ? 1 : 0.6 }}
onClick={() => {
if (!canConfirm || submitting) return;
void confirmReceive();
}}
>
<Text>{submitting ? '提交中…' : canConfirm ? '确认收货' : '订单状态不可确认'}</Text>
</View>
</View>
</PageShell>
);
}
+14
View File
@@ -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 {
-52
View File
@@ -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);
+5
View File
@@ -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', () => {
+7 -1
View File
@@ -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 {
+2
View File
@@ -25,6 +25,8 @@ export interface ProductDto {
/** 详情长图(bizType=DETAIL 或 detailContent JSON */
detailImageUrls?: string[];
detailContent?: ProductDetailContentDto | null;
/** 是否允许现场取货下单 */
allowOnSitePickup?: boolean;
}
export interface ProductDetailFeatureDto {
+1
View File
@@ -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 {
+2
View File
@@ -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)
@@ -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 }
: {}),
@@ -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;
@@ -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 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);
}