Compare commits

...

3 Commits

Author SHA1 Message Date
jacy cebeefdb22 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>
2026-07-23 01:17:59 +08:00
jacy e93e4a7721 推广码后端页面优化 2026-07-23 00:43:49 +08:00
jacy cd6b82abd8 小程序端核销权益输入框限制
CI / verify (pull_request) Has been cancelled
2026-07-23 00:32:09 +08:00
31 changed files with 1084 additions and 425 deletions
+14 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Table, Tabs, Tag, Typography, message, Switch, Table, Tabs, Tag, Typography, message,
} from 'antd'; } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -31,6 +31,7 @@ type Row = {
benefitAmount: number; benefitAmount: number;
status: string; status: string;
sortOrder: number; sortOrder: number;
allowOnSitePickup?: boolean;
mainImageUrl?: string | null; mainImageUrl?: string | null;
carouselUrls?: string[]; carouselUrls?: string[];
detailImageUrls?: string[]; detailImageUrls?: string[];
@@ -49,6 +50,7 @@ type ProductFormValues = {
benefitAmount?: number; benefitAmount?: number;
status?: string; status?: string;
sortOrder?: number; sortOrder?: number;
allowOnSitePickup?: boolean;
coverUrl?: string; coverUrl?: string;
carouselUrls?: string[]; carouselUrls?: string[];
detailImageUrls?: string[]; detailImageUrls?: string[];
@@ -100,6 +102,7 @@ function buildProductPayload(v: ProductFormValues) {
benefitAmount: v.benefitAmount, benefitAmount: v.benefitAmount,
status: v.status, status: v.status,
sortOrder: v.sortOrder, sortOrder: v.sortOrder,
allowOnSitePickup: !!v.allowOnSitePickup,
coverUrl: v.coverUrl, coverUrl: v.coverUrl,
carouselUrls, carouselUrls,
detailImageUrls, detailImageUrls,
@@ -217,6 +220,9 @@ function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
<Form.Item name="sortOrder" label="排序"> <Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item name="coverUrl" label="封面"> <Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" /> <OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item> </Form.Item>
@@ -266,6 +272,12 @@ export default function ProductsPage() {
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` }, { title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', 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: '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: 'sortOrder', width: 60 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
@@ -357,7 +369,7 @@ export default function ProductsPage() {
void reload(); void reload();
}} width={720}> }} width={720}>
<Form form={createForm} layout="vertical" initialValues={{ <Form form={createForm} layout="vertical" initialValues={{
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, allowOnSitePickup: false,
carouselUrls: [''], detailImageUrls: [''], carouselUrls: [''], detailImageUrls: [''],
features: [{ icon: 'water_drop', title: '', desc: '' }], features: [{ icon: 'water_drop', title: '', desc: '' }],
}}> }}>
+244 -236
View File
@@ -1,236 +1,244 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom'; import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
import { } from 'antd';
import type { ColumnsType } from 'antd/es/table';
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, import {
PROMO_CODE_SCENE_LABELS,
} from 'antd'; PROMO_CODE_STATUS_LABELS,
promoConversion,
import type { ColumnsType } from 'antd/es/table'; type PromoCodeItem,
type PromoCodeScene,
import { } from '@dukang/shared-types';
import { request } from '../lib/api';
PROMO_CODE_SCENE_LABELS, import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
PROMO_CODE_STATUS_LABELS,
type Row = PromoCodeItem;
promoConversion,
type SceneOption = { value: PromoCodeScene; label: string };
type PromoCodeItem,
async function downloadQrcode(url: string, filename: string) {
type PromoCodeScene, try {
const res = await fetch(url);
} from '@dukang/shared-types'; const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
import { request } from '../lib/api'; const a = document.createElement('a');
a.href = objectUrl;
import { fmtTime } from '../lib/constants'; a.download = filename;
a.click();
import { useAdminList } from '../lib/useAdminList'; URL.revokeObjectURL(objectUrl);
} catch {
window.open(url, '_blank');
}
type Row = PromoCodeItem; }
export default function PromoCodesPage() {
const navigate = useNavigate();
type SceneOption = { value: PromoCodeScene; label: string }; const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [scenes, setScenes] = useState<SceneOption[]>(
async function downloadQrcode(url: string, filename: string) { Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
value: value as PromoCodeScene,
try { label,
})),
const res = await fetch(url); );
const [createOpen, setCreateOpen] = useState(false);
const blob = await res.blob(); const [creating, setCreating] = useState(false);
const objectUrl = URL.createObjectURL(blob); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/promo-codes',
const a = document.createElement('a'); () => {
const qs = new URLSearchParams();
a.href = objectUrl; if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
a.download = filename; if (filters.status) qs.set('status', filters.status);
if (filters.scene) qs.set('scene', filters.scene);
a.click(); return qs;
},
URL.revokeObjectURL(objectUrl); [filters],
);
} catch {
async function loadScenes() {
window.open(url, '_blank'); try {
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
} if (list.length) setScenes(list);
} catch {
} /* 使用本地默认场景 */
}
}
export default function PromoCodesPage() { useEffect(() => {
void loadScenes();
const navigate = useNavigate(); }, []);
const [filterForm] = Form.useForm(); const columns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
const [createForm] = Form.useForm(); { title: '码值', dataIndex: 'code', width: 110 },
{
const [filters, setFilters] = useState<Record<string, string>>({}); title: '场景',
dataIndex: 'scene',
const [scenes, setScenes] = useState<SceneOption[]>( width: 110,
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ },
{
value: value as PromoCodeScene, title: '状态',
dataIndex: 'status',
label, width: 90,
render: (s) => (
})), <Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
); </Tag>
),
const [createOpen, setCreateOpen] = useState(false); },
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
const [creating, setCreating] = useState(false); { title: '订单', dataIndex: 'orderCount', width: 70 },
{
title: '转化率',
width: 90,
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( render: (_, row) => promoConversion(row.scanCount, row.orderCount),
},
'/admin/promo-codes', {
title: '渠道负责人',
() => { width: 120,
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
const qs = new URLSearchParams(); },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
if (filters.name) qs.set('name', filters.name); {
title: '操作',
if (filters.code) qs.set('code', filters.code); width: 220,
fixed: 'right',
if (filters.status) qs.set('status', filters.status); render: (_, row) => (
<Space size="small" wrap>
if (filters.scene) qs.set('scene', filters.scene); <Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
详情
return qs; </Button>
<Button
}, type="link"
size="small"
[filters], disabled={!row.qrcodeUrl}
onClick={() => {
); if (!row.qrcodeUrl) return;
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
}}
>
async function loadScenes() { 下载小程序码
</Button>
try { <Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
关联用户
const list = await request<SceneOption[]>('/admin/promo-codes/scenes'); </Button>
</Space>
if (list.length) setScenes(list); ),
},
} catch { ];
/* 使用本地默认场景 */ async function handleCreate(values: Record<string, string>) {
setCreating(true);
} try {
const created = await request<PromoCodeItem>('/admin/promo-codes', {
} method: 'POST',
body: JSON.stringify({
name: values.name,
code: values.code?.trim() || undefined,
useEffect(() => { scene: values.scene,
ownerUserId: values.ownerUserId?.trim() || undefined,
void loadScenes(); remark: values.remark?.trim() || undefined,
page: values.page?.trim() || undefined,
}, []); }),
});
message.success('推广码已创建');
setCreateOpen(false);
const columns: ColumnsType<Row> = [ createForm.resetFields();
void reload();
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true }, navigate(`/promo-codes/${created.id}`);
} catch (e) {
{ title: '码值', dataIndex: 'code', width: 110 }, message.error(e instanceof Error ? e.message : '创建失败');
} finally {
{ setCreating(false);
}
title: '场景', }
dataIndex: 'scene', return (
<div>
width: 110, <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>推广码管理</Typography.Title>
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s, <Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>
</div>
},
<Form
{ form={filterForm}
layout="inline"
title: '状态', style={{ marginBottom: 16 }}
onFinish={(v) => { setFilters(v); setPage(1); }}
dataIndex: 'status', >
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
width: 90, <Form.Item name="code" label="码值"><Input allowClear /></Form.Item>
<Form.Item name="scene" label="场景">
render: (s) => ( <Select allowClear style={{ width: 130 }} options={scenes} />
</Form.Item>
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}> <Form.Item name="status" label="状态">
<Select
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s} allowClear
style={{ width: 100 }}
</Tag> options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
), </Form.Item>
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
}, </Form>
{ title: '扫码', dataIndex: 'scanCount', width: 70 }, <Table
rowKey="id"
{ title: '订单', dataIndex: 'orderCount', width: 70 }, className="admin-table-nowrap"
loading={loading}
{ columns={columns}
dataSource={data?.items ?? []}
title: '转化率', scroll={{ x: 1200 }}
pagination={{
width: 90, current: page,
pageSize,
render: (_, row) => promoConversion(row.scanCount, row.orderCount), total: data?.total ?? 0,
showSizeChanger: true,
}, onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
{ />
title: '渠道负责人', <Modal
title="创建推广码"
width: 120, open={createOpen}
onCancel={() => setCreateOpen(false)}
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—', footer={null}
destroyOnClose
}, >
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ scene: 'ONLINE_LINK' }}>
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, <Form.Item name="name" label="推广码名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:郑州品鉴会、门店地推" />
{ </Form.Item>
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
title: '操作', <Select options={scenes} />
</Form.Item>
width: 220, <Form.Item name="code" label="自定义码值(选填)">
<Input placeholder="留空自动生成,如 DKDEMO1" />
fixed: 'right', </Form.Item>
<Form.Item
render: (_, row) => ( name="page"
label="小程序落地页(选填)"
<Space size="small" wrap> extra="如 pages/home/index;留空则使用服务端环境变量 WX_MINI_PROMO_PAGE"
>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}> <Input placeholder="pages/home/index" />
</Form.Item>
详情 <Form.Item name="ownerUserId" label="关联用户 ID(选填)">
<Input placeholder="渠道负责人,填写用户数据库 ID" />
</Button> </Form.Item>
<Form.Item name="remark" label="备注">
<Button <Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={creating} block>
创建并生成小程序码
</Button>
</Form>
</Modal>
</div>
);
}
@@ -155,9 +155,6 @@ export default function PromoCodeDetailPage() {
<Descriptions.Item label="二维码 ID" span={2}> <Descriptions.Item label="二维码 ID" span={2}>
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text> <Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="H5 落地链接" span={2}>
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="小程序码 OSS" span={2}> <Descriptions.Item label="小程序码 OSS" span={2}>
{detail.qrcodeUrl ? ( {detail.qrcodeUrl ? (
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis> <Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis>
@@ -179,7 +176,15 @@ export default function PromoCodeDetailPage() {
<Row gutter={16} style={{ marginTop: 16 }}> <Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small">
<Statistic title="扫码次数" value={stats?.scanCount ?? detail.scanCount} /> <Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="扫码注册用户数"
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
/>
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
@@ -195,11 +200,6 @@ export default function PromoCodeDetailPage() {
/> />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="来源标记用户" value={stats?.sourceMarkedCount ?? '—'} />
</Card>
</Col>
</Row> </Row>
<Modal <Modal
+2
View File
@@ -7,9 +7,11 @@ export default defineAppConfig({
'pages/product-detail/index', 'pages/product-detail/index',
'pages/store-detail/index', 'pages/store-detail/index',
'pages/order-confirm/index', 'pages/order-confirm/index',
'pages/order-confirm-pickup/index',
'pages/pay/index', 'pages/pay/index',
'pages/orders/index', 'pages/orders/index',
'pages/order-detail/index', 'pages/order-detail/index',
'pages/pickup-receive/index',
'pages/addresses/index', 'pages/addresses/index',
'pages/address-edit/index', 'pages/address-edit/index',
'pages/customer-service/index', 'pages/customer-service/index',
@@ -3,6 +3,7 @@ import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav'; import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
import { toast } from '../lib/api'; import { toast } from '../lib/api';
import { capturePromoSceneAndTouchScan } from '../lib/promo';
import { saveWechatLoginResult } from '../lib/pay-wechat'; import { saveWechatLoginResult } from '../lib/pay-wechat';
import { applyWechatShare } from '../lib/wechat-share'; import { applyWechatShare } from '../lib/wechat-share';
import { handleWechatAuthCallback } from '../lib/wechat-auth'; import { handleWechatAuthCallback } from '../lib/wechat-auth';
@@ -29,13 +30,16 @@ function currentPagePathWithQuery(): string {
} }
/** /**
* H5 App 根节点专用:不可用 useDidShow(App 无页面 Context)。 * H5 App 根节点:iOS 签名 URL + 默认分享 + OAuth code 回调。
* - 首次进页:捕获 iOS 签名 URL + 默认分享 + OAuth code 回调 * 小程序:冷启动时捕获推广码 scene 并回传扫码埋点。
* - 路由/回前台:刷新默认分享卡片
*/ */
export default function WechatShareBootstrap() { export default function WechatShareBootstrap() {
const handlingCode = useRef(false); const handlingCode = useRef(false);
useEffect(() => {
void capturePromoSceneAndTouchScan();
}, []);
useEffect(() => { useEffect(() => {
if (process.env.TARO_ENV !== 'h5') return; if (process.env.TARO_ENV !== 'h5') return;
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
+129
View File
@@ -0,0 +1,129 @@
import Taro from '@tarojs/taro';
import { request } from './api';
const PROMO_ID_KEY = 'dukang_promo_id';
/** 同一次进入只 touch 一次扫码计数,避免首页反复 onShow 刷量 */
let lastScanTouchKey = '';
function safeDecode(raw: string): string {
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
function normalizePromoId(raw: unknown): string | null {
if (raw == null || raw === '') return null;
const s = safeDecode(String(raw)).trim();
// 小程序码 scene 写入的是推广活动数字 ID
if (!/^\d+$/.test(s)) return null;
return s;
}
type EnterOptionsLike = {
scene?: string | number;
query?: Record<string, string | undefined>;
path?: string;
};
/** 从启动/进入参数解析推广活动 ID(优先 query.scene,与 getwxacodeunlimit 一致) */
export function extractPromoIdFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
if (!opts) return null;
const q = opts.query ?? {};
return (
normalizePromoId(q.scene) ||
normalizePromoId(q.promoId) ||
normalizePromoId(q.pid) ||
null
);
}
export function getStoredPromoId(): string | null {
try {
const v = Taro.getStorageSync(PROMO_ID_KEY);
return normalizePromoId(v);
} catch {
return null;
}
}
export function setStoredPromoId(promoId: string) {
const id = normalizePromoId(promoId);
if (!id) return;
try {
Taro.setStorageSync(PROMO_ID_KEY, id);
} catch {
/* ignore */
}
}
function readEnterOptions(): EnterOptionsLike | null {
try {
if (typeof Taro.getEnterOptionsSync === 'function') {
return Taro.getEnterOptionsSync() as EnterOptionsLike;
}
} catch {
/* ignore */
}
try {
if (typeof Taro.getLaunchOptionsSync === 'function') {
return Taro.getLaunchOptionsSync() as EnterOptionsLike;
}
} catch {
/* ignore */
}
// H5:从 URL query 读取
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
return {
query: {
scene: params.get('scene') || undefined,
promoId: params.get('promoId') || undefined,
pid: params.get('pid') || undefined,
},
};
}
return null;
}
/**
* 主页面进入时:取出 scene(活动 ID)本地缓存,并回传 /promo/touch 累加扫码次数。
* 同一进入会话只计一次扫码。
*/
export async function capturePromoSceneAndTouchScan(): Promise<void> {
const opts = readEnterOptions();
const fromEnter = extractPromoIdFromEnterOptions(opts);
if (fromEnter) {
setStoredPromoId(fromEnter);
const touchKey = `${fromEnter}|${opts?.path || ''}|${JSON.stringify(opts?.query || {})}|${String(opts?.scene ?? '')}`;
if (touchKey === lastScanTouchKey) return;
lastScanTouchKey = touchKey;
await touchPromo({ promoId: fromEnter, countScan: true });
return;
}
// 无新 scene 时不重复扫码计数
}
/** 登录成功后:用已缓存的活动 ID 做归因(不重复加扫码次数) */
export async function touchStoredPromoAfterLogin(): Promise<void> {
const promoId = getStoredPromoId();
if (!promoId) return;
await touchPromo({ promoId, countScan: false });
}
async function touchPromo(input: { promoId: string; countScan: boolean }): Promise<void> {
try {
await request('/promo/touch', {
method: 'POST',
data: {
promoId: input.promoId,
countScan: input.countScan,
},
});
} catch {
/* 静默失败,不阻断浏览 */
}
}
+25 -10
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
@@ -35,17 +35,18 @@ function usagePercent(coupon: CouponItem) {
export default function BenefitPage() { export default function BenefitPage() {
const metrics = useNavBarMetrics(); const metrics = useNavBarMetrics();
const loggedIn = isLoggedIn(); const [loggedIn, setLoggedIn] = useState(() => isLoggedIn());
const [summary, setSummary] = useState<BenefitSummary | null>(null); const [summary, setSummary] = useState<BenefitSummary | null>(null);
const [coupons, setCoupons] = useState<CouponItem[]>([]); const [coupons, setCoupons] = useState<CouponItem[]>([]);
const [tab, setTab] = useState<'available' | 'history'>('available'); const [tab, setTab] = useState<'available' | 'history'>('available');
useDidShow(() => { const resetGuestState = useCallback(() => {
syncTabBarSelected(2); setSummary(null);
}); setCoupons([]);
}, []);
const loadBenefit = useCallback(() => { const loadBenefit = useCallback(() => {
if (!loggedIn) return Promise.resolve(); if (!isLoggedIn()) return Promise.resolve();
return Promise.all([ return Promise.all([
request<BenefitSummary>('/benefit/summary'), request<BenefitSummary>('/benefit/summary'),
request<CouponItem[]>('/benefit/coupons'), request<CouponItem[]>('/benefit/coupons'),
@@ -55,13 +56,27 @@ export default function BenefitPage() {
setCoupons(Array.isArray(list) ? list : []); setCoupons(Array.isArray(list) ? list : []);
}) })
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')); .catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [loggedIn]); }, []);
useEffect(() => { useDidShow(() => {
void loadBenefit(); syncTabBarSelected(2);
}, [loadBenefit]); const loggedInNow = isLoggedIn();
setLoggedIn(loggedInNow);
if (loggedInNow) {
void loadBenefit();
} else {
resetGuestState();
}
});
usePullDownRefresh(() => { usePullDownRefresh(() => {
const loggedInNow = isLoggedIn();
setLoggedIn(loggedInNow);
if (!loggedInNow) {
resetGuestState();
Taro.stopPullDownRefresh();
return;
}
void loadBenefit().finally(() => Taro.stopPullDownRefresh()); void loadBenefit().finally(() => Taro.stopPullDownRefresh());
}); });
+27 -1
View File
@@ -6,7 +6,10 @@ import TabMainHeader from '../../components/TabMainHeader';
import CouponBadge from '../../components/CouponBadge'; import CouponBadge from '../../components/CouponBadge';
import ProductCarousel from '../../components/ProductCarousel'; import ProductCarousel from '../../components/ProductCarousel';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; 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 { getProductImages } from '../../lib/product-images';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location'; import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
type Product = { type Product = {
@@ -18,6 +21,7 @@ type Product = {
mainImageUrl?: string | null; mainImageUrl?: string | null;
carouselUrls?: string[] | null; carouselUrls?: string[] | null;
aromaType: string; aromaType: string;
allowOnSitePickup?: boolean;
}; };
const AROMA_TABS = [ const AROMA_TABS = [
@@ -35,6 +39,7 @@ export default function HomePage() {
useDidShow(() => { useDidShow(() => {
syncTabBarSelected(0); syncTabBarSelected(0);
void capturePromoSceneAndTouchScan();
void resolveUserCity().then((resolved) => { void resolveUserCity().then((resolved) => {
setDisplayCity(resolved.displayCity); setDisplayCity(resolved.displayCity);
setCityCode(getCityCodeForCatalog(resolved)); setCityCode(getCityCodeForCatalog(resolved));
@@ -93,6 +98,17 @@ export default function HomePage() {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` }); 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); const filtered = products.filter((p) => p.aromaType === tab);
return ( return (
@@ -138,6 +154,16 @@ export default function HomePage() {
</View> </View>
</View> </View>
<View className="home-product-actions"> <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 className="home-buy-btn" onClick={() => openProductDetail(p.id)}>
立即购买 立即购买
</Text> </Text>
+2
View File
@@ -24,6 +24,7 @@ import {
type MiniWechatProfile, type MiniWechatProfile,
} from '../../lib/mini-wechat-profile'; } from '../../lib/mini-wechat-profile';
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api'; import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
import { touchStoredPromoAfterLogin } from '../../lib/promo';
const IS_WEAPP = process.env.TARO_ENV === 'weapp'; const IS_WEAPP = process.env.TARO_ENV === 'weapp';
@@ -176,6 +177,7 @@ export default function LoginPage() {
refreshToken: data.refreshToken, refreshToken: data.refreshToken,
}); });
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile()); void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
void touchStoredPromoAfterLogin();
if (!phoneValue) { if (!phoneValue) {
void fetchUserProfile() void fetchUserProfile()
.then((me) => resolveDefaultUserPhone(me)) .then((me) => resolveDefaultUserPhone(me))
@@ -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: '已退款', REFUNDED: '已退款',
}; };
/** 已付款未完成:可选现场取货并确认收货 */
const ON_SITE_PICKUP_STATUSES = new Set([
'PENDING_SHIP',
'OUT_WAREHOUSE',
'SHIPPING',
'SHIPPED',
'PENDING_RECEIVE',
'DELIVERED',
]);
function fullReceiverAddress(order: OrderDetail) { function fullReceiverAddress(order: OrderDetail) {
const detail = (order.receiverAddress || '').trim(); const detail = (order.receiverAddress || '').trim();
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict] const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
@@ -80,7 +70,6 @@ export default function OrderDetailPage() {
const router = useRouter(); const router = useRouter();
const orderId = router.params.id ?? ''; const orderId = router.params.id ?? '';
const [order, setOrder] = useState<OrderDetail | null>(null); const [order, setOrder] = useState<OrderDetail | null>(null);
const [onSitePickup, setOnSitePickup] = useState(false);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
useEffect(() => { useEffect(() => {
@@ -92,13 +81,8 @@ export default function OrderDetailPage() {
const isReship = !!order?.originOrderId; const isReship = !!order?.originOrderId;
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship; const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
const canOnSitePickup =
!!order && !isReship && ON_SITE_PICKUP_STATUSES.has(order.status || '');
const canConfirmReceive = const canConfirmReceive =
!!order && !!order && !isReship && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
!isReship &&
(['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '') ||
(onSitePickup && canOnSitePickup));
const item = order?.items?.[0]; const item = order?.items?.[0];
const productName = item?.productName || order?.productName || '杜康商品'; const productName = item?.productName || order?.productName || '杜康商品';
@@ -137,13 +121,9 @@ export default function OrderDetailPage() {
async function confirmReceive() { async function confirmReceive() {
if (!order || !canConfirmReceive || confirming) return; if (!order || !canConfirmReceive || confirming) return;
const useOnSite =
onSitePickup || !['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
const { confirm } = await Taro.showModal({ const { confirm } = await Taro.showModal({
title: useOnSite ? '确认现场取货?' : '确认收货?', title: '确认收货?',
content: useOnSite content: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
? '请确认您已在现场拿到商品。确认后订单将完成,好客权益即时可用,无法再安排配送。若尚未取到酒,请勿确认。'
: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
confirmText: '确认收货', confirmText: '确认收货',
cancelText: '再想想', cancelText: '再想想',
}); });
@@ -153,11 +133,10 @@ export default function OrderDetailPage() {
try { try {
const updated = await request<OrderDetail>(`/trade/orders/${order.id}/confirm-receive`, { const updated = await request<OrderDetail>(`/trade/orders/${order.id}/confirm-receive`, {
method: 'POST', method: 'POST',
data: { onSitePickup: useOnSite }, data: {},
}); });
setOrder(updated); setOrder(updated);
setOnSitePickup(false); toast('已确认收货');
toast(useOnSite ? '现场取货已确认,订单完成' : '已确认收货');
} catch (e) { } catch (e) {
toast(e instanceof Error ? e.message : '确认收货失败'); toast(e instanceof Error ? e.message : '确认收货失败');
} finally { } finally {
@@ -225,27 +204,6 @@ export default function OrderDetailPage() {
<Text className="u-muted">地址信息待完善</Text> <Text className="u-muted">地址信息待完善</Text>
)} )}
</View> </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"> <View className="order-card">
<Text className="order-card-title">订单信息</Text> <Text className="order-card-title">订单信息</Text>
<View className="order-row"> <View className="order-row">
@@ -303,7 +261,7 @@ export default function OrderDetailPage() {
className={`order-confirm-submit${confirming ? ' order-confirm-submit--disabled' : ''}`} className={`order-confirm-submit${confirming ? ' order-confirm-submit--disabled' : ''}`}
onClick={confirming ? undefined : () => void confirmReceive()} onClick={confirming ? undefined : () => void confirmReceive()}
> >
{confirming ? '提交中…' : onSitePickup ? '确认现场取货' : '确认收货'} {confirming ? '提交中…' : '确认收货'}
</View> </View>
) : null} ) : null}
</View> </View>
+14 -4
View File
@@ -29,6 +29,7 @@ export default function PayPage() {
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [orderNo, setOrderNo] = useState(''); const [orderNo, setOrderNo] = useState('');
const [payAmount, setPayAmount] = useState('—'); const [payAmount, setPayAmount] = useState('—');
const [deliveryType, setDeliveryType] = useState('');
const returnPath = orderId const returnPath = orderId
? `/pages/pay/index?orderId=${orderId}` ? `/pages/pay/index?orderId=${orderId}`
@@ -62,11 +63,15 @@ export default function PayPage() {
setPayAmount('—'); setPayAmount('—');
return; return;
} }
request<{ orderNo?: string; payAmount?: number | string; totalAmount?: number | string }>( request<{
`/trade/orders/${orderId}`, orderNo?: string;
) payAmount?: number | string;
totalAmount?: number | string;
deliveryType?: string;
}>(`/trade/orders/${orderId}`)
.then((order) => { .then((order) => {
setOrderNo(order.orderNo || ''); setOrderNo(order.orderNo || '');
setDeliveryType(order.deliveryType || '');
const amount = Number(order.payAmount ?? order.totalAmount ?? 0); const amount = Number(order.payAmount ?? order.totalAmount ?? 0);
if (Number.isFinite(amount) && amount > 0) { if (Number.isFinite(amount) && amount > 0) {
setPayAmount(amount.toFixed(2)); setPayAmount(amount.toFixed(2));
@@ -74,6 +79,7 @@ export default function PayPage() {
}) })
.catch((e) => { .catch((e) => {
setOrderNo(''); setOrderNo('');
setDeliveryType('');
toast(e instanceof Error ? e.message : '加载订单失败'); toast(e instanceof Error ? e.message : '加载订单失败');
}); });
}, [orderId]); }, [orderId]);
@@ -137,7 +143,11 @@ export default function PayPage() {
} else { } else {
toast('支付成功', 'success'); 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) { } catch (e) {
if (isWechatAuthRequiredError(e)) { if (isWechatAuthRequiredError(e)) {
setNeedsWechatAuth(true); 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>
);
}
+23 -9
View File
@@ -17,7 +17,12 @@ function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
/** 核销金额输入:最多两位小数;去掉前导 0;禁止非法字符 */ /**
* 核销金额输入清洗:
* - 只保留数字与一个小数点
* - 小数最多 2 位(再输入会被截断)
* - 去掉多余前导 0
*/
function sanitizeRedeemAmountInput(raw: string): string { function sanitizeRedeemAmountInput(raw: string): string {
let next = String(raw ?? '').replace(/[^\d.]/g, ''); let next = String(raw ?? '').replace(/[^\d.]/g, '');
if (!next) return ''; if (!next) return '';
@@ -28,19 +33,18 @@ function sanitizeRedeemAmountInput(raw: string): string {
const decRaw = next const decRaw = next
.slice(firstDot + 1) .slice(firstDot + 1)
.replace(/\D/g, '') .replace(/\D/g, '')
.replace(/\./g, '')
.slice(0, 2); .slice(0, 2);
const intPart = intRaw.replace(/^0+(?=\d)/, '') || '0'; const intPart = intRaw.replace(/^0+(?=\d)/, '') || '0';
// 正在输入小数点或小数位时保留点 if (next.endsWith('.') && decRaw.length === 0) {
if (decRaw.length > 0 || next.endsWith('.')) { return `${intPart}.`;
}
if (decRaw.length > 0) {
return `${intPart}.${decRaw}`; return `${intPart}.${decRaw}`;
} }
return intPart; return intPart;
} }
// 纯整数:忽略前导 0(保留单个 0) return next.replace(/^0+(?=\d)/, '');
next = next.replace(/^0+(?=\d)/, '');
return next;
} }
export default function RedeemPage() { export default function RedeemPage() {
@@ -50,6 +54,8 @@ export default function RedeemPage() {
const [balance, setBalance] = useState(0); const [balance, setBalance] = useState(0);
const [couponBalance, setCouponBalance] = useState<number | null>(null); const [couponBalance, setCouponBalance] = useState<number | null>(null);
const [amount, setAmount] = useState(() => sanitizeRedeemAmountInput(initialAmount)); const [amount, setAmount] = useState(() => sanitizeRedeemAmountInput(initialAmount));
/** 原生 input 在截断小数后偶发不同步,强制 remount 对齐受控值 */
const [inputKey, setInputKey] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const redeemableMax = couponId ? (couponBalance ?? 0) : balance; const redeemableMax = couponId ? (couponBalance ?? 0) : balance;
@@ -80,10 +86,16 @@ export default function RedeemPage() {
function fillMaxAmount() { function fillMaxAmount() {
if (redeemableMax < MIN_REDEEM_AMOUNT) return; if (redeemableMax < MIN_REDEEM_AMOUNT) return;
setAmount(sanitizeRedeemAmountInput(redeemableMax.toFixed(2))); setAmount(sanitizeRedeemAmountInput(redeemableMax.toFixed(2)));
setInputKey((k) => k + 1);
} }
function onAmountChange(raw: string) { function onAmountChange(raw: string) {
setAmount(sanitizeRedeemAmountInput(raw)); const next = sanitizeRedeemAmountInput(raw);
setAmount(next);
// 用户试图输入超过两位小数 / 非法字符时,强制刷新原生框显示
if (next !== raw) {
setInputKey((k) => k + 1);
}
} }
async function submit() { async function submit() {
@@ -130,11 +142,13 @@ export default function RedeemPage() {
</View> </View>
<View className="redeem-input-wrap"> <View className="redeem-input-wrap">
<Input <Input
key={inputKey}
className="redeem-input" className="redeem-input"
type="digit" type="digit"
placeholder="输入核销金额" placeholder="输入核销金额"
placeholderClass="redeem-input-placeholder" placeholderClass="redeem-input-placeholder"
value={amount} value={amount}
maxlength={12}
onInput={(e) => onAmountChange(e.detail.value)} onInput={(e) => onAmountChange(e.detail.value)}
onBlur={(e) => onAmountChange(e.detail.value)} onBlur={(e) => onAmountChange(e.detail.value)}
style={{ textAlign: 'center' }} style={{ textAlign: 'center' }}
@@ -150,7 +164,7 @@ export default function RedeemPage() {
</View> </View>
<View className="redeem-tips"> <View className="redeem-tips">
<Text className="redeem-tips-text"> <Text className="redeem-tips-text">
核销金额最低 0.01 元,且不超过可用权益余额。核销码有效期 3 分钟,请到店出示给收银员扫码。 核销金额最低 0.01 元,小数最多两位。不超过可用权益余额。核销码有效期 3 分钟,请到店出示给收银员扫码。
</Text> </Text>
</View> </View>
<View <View
+14
View File
@@ -191,6 +191,20 @@
padding: 0 var(--space-gutter) var(--space-gutter); padding: 0 var(--space-gutter) var(--space-gutter);
display: flex; display: flex;
justify-content: flex-end; 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 { .home-buy-btn {
-52
View File
@@ -73,58 +73,6 @@
pointer-events: none; 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 { .order-card {
background: var(--color-card); background: var(--color-card);
border-radius: var(--radius-lg); 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', 5, 2, 6).ok).toBe(false);
expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true); 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', () => { describe('validateRedeemAmount', () => {
+7 -1
View File
@@ -8,11 +8,17 @@ export function calcBenefitAmount(product: ProductPricing): number {
} }
export function validateMinPurchase( export function validateMinPurchase(
deliveryType: 'LOCAL' | 'CROSS_CITY', deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
quantity: number, quantity: number,
localMinQty: number, localMinQty: number,
crossMinQty: number, crossMinQty: number,
): { ok: boolean; message?: string } { ): { 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; const min = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
if (quantity < min) { if (quantity < min) {
return { return {
+2
View File
@@ -25,6 +25,8 @@ export interface ProductDto {
/** 详情长图(bizType=DETAIL 或 detailContent JSON) */ /** 详情长图(bizType=DETAIL 或 detailContent JSON) */
detailImageUrls?: string[]; detailImageUrls?: string[];
detailContent?: ProductDetailContentDto | null; detailContent?: ProductDetailContentDto | null;
/** 是否允许现场取货下单 */
allowOnSitePickup?: boolean;
} }
export interface ProductDetailFeatureDto { export interface ProductDetailFeatureDto {
+1
View File
@@ -91,6 +91,7 @@ export enum OrderTab {
export enum DeliveryType { export enum DeliveryType {
LOCAL = 'LOCAL', LOCAL = 'LOCAL',
CROSS_CITY = 'CROSS_CITY', CROSS_CITY = 'CROSS_CITY',
ON_SITE_PICKUP = 'ON_SITE_PICKUP',
} }
export enum AromaType { export enum AromaType {
+5 -1
View File
@@ -45,12 +45,16 @@ export type PromoCodeItem = {
}; };
export type PromoCodeStats = { export type PromoCodeStats = {
/** 扫码进入次数 */
scanCount: number; scanCount: number;
orderCount: number; orderCount: number;
conversionRate: number; conversionRate: number;
/** 归因用户数(user_promo_attribution) */
attributionCount?: number; attributionCount?: number;
/** 用户表 source_ref_id 指向本推广码的用户数 */ /** 扫码注册用户数:用户来源标记为本推广码 */
sourceMarkedCount?: number; sourceMarkedCount?: number;
/** @deprecated 同 sourceMarkedCount,兼容旧字段名 */
registerCount?: number;
}; };
export type PromoCodeAttributedUser = { export type PromoCodeAttributedUser = {
+2
View File
@@ -262,6 +262,7 @@ enum PayStatus {
enum DeliveryType { enum DeliveryType {
LOCAL LOCAL
CROSS_CITY CROSS_CITY
ON_SITE_PICKUP
} }
enum FreightPayType { enum FreightPayType {
@@ -451,6 +452,7 @@ model CommonProductItem {
benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2) benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2)
status ProductStatus @default(DRAFT) status ProductStatus @default(DRAFT)
sortOrder Int @default(0) @map("sort_order") sortOrder Int @default(0) @map("sort_order")
allowOnSitePickup Boolean @default(false) @map("allow_on_site_pickup")
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
detailContent Json? @map("detail_content") detailContent Json? @map("detail_content")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@ -26,7 +26,12 @@ export class PromoController {
touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) { touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined; const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
return this.promoCodeService.touch( return this.promoCodeService.touch(
{ promoCode: dto.promoCode, qrcodeId: dto.qrcodeId, promoId: dto.promoId }, {
promoCode: dto.promoCode,
qrcodeId: dto.qrcodeId,
promoId: dto.promoId,
countScan: dto.countScan,
},
userId, userId,
); );
} }
@@ -1,4 +1,5 @@
import { IsOptional, IsString } from 'class-validator'; import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { Transform } from 'class-transformer';
export class PromoTouchDto { export class PromoTouchDto {
@IsOptional() @IsOptional()
@@ -13,4 +14,17 @@ export class PromoTouchDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
promoId?: string; promoId?: string;
/**
* 是否累加扫码次数。扫码进入为 true;登录后归因可传 false,避免重复计数。
* 默认 true。
*/
@IsOptional()
@Transform(({ value }) => {
if (value === false || value === 'false' || value === 0 || value === '0') return false;
if (value === true || value === 'true' || value === 1 || value === '1') return true;
return undefined;
})
@IsBoolean()
countScan?: boolean;
} }
@@ -89,6 +89,7 @@ export class AdminProductsService {
benefitAmount: dto.benefitAmount ?? dto.price, benefitAmount: dto.benefitAmount ?? dto.price,
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE', status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
sortOrder: dto.sortOrder ?? 0, sortOrder: dto.sortOrder ?? 0,
allowOnSitePickup: dto.allowOnSitePickup ?? false,
...(dto.detailContent !== undefined ...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue } ? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}), : {}),
@@ -118,6 +119,7 @@ export class AdminProductsService {
...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}), ...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}), ...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.allowOnSitePickup !== undefined ? { allowOnSitePickup: dto.allowOnSitePickup } : {}),
...(dto.detailContent !== undefined ...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue } ? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}), : {}),
@@ -947,6 +947,10 @@ export class CreateProductDto {
@IsNumber() @IsNumber()
sortOrder?: number; sortOrder?: number;
@IsOptional()
@IsBoolean()
allowOnSitePickup?: boolean;
@IsOptional() @IsOptional()
@IsString() @IsString()
coverUrl?: string; coverUrl?: string;
@@ -995,6 +999,10 @@ export class UpdateProductDto {
@IsNumber() @IsNumber()
sortOrder?: number; sortOrder?: number;
@IsOptional()
@IsBoolean()
allowOnSitePickup?: boolean;
@IsOptional() @IsOptional()
@IsString() @IsString()
coverUrl?: string; coverUrl?: string;
@@ -55,6 +55,12 @@ export class CreatePromoCodeDto {
@IsString() @IsString()
@MaxLength(256) @MaxLength(256)
remark?: string; remark?: string;
/** 小程序码落地页路径,如 pages/home/index;留空用环境变量 WX_MINI_PROMO_PAGE */
@IsOptional()
@IsString()
@MaxLength(128)
page?: string;
} }
export class UpdatePromoCodeDto { export class UpdatePromoCodeDto {
@@ -235,14 +235,19 @@ export class PromoCodeService {
/** /**
* 调用微信 getwxacodeunlimit,scene=推广活动 ID,PNG 上传 OSS uploads/qrcode/ * 调用微信 getwxacodeunlimit,scene=推广活动 ID,PNG 上传 OSS uploads/qrcode/
*/ */
private async createQrcodeResource(promoId: bigint, code: string) { private async createQrcodeResource(promoId: bigint, code: string, pagePath?: string) {
const scene = promoId.toString(); const scene = promoId.toString();
if (scene.length > 32) { if (scene.length > 32) {
throw new BadRequestException('推广活动 ID 过长,无法写入小程序码 scene'); throw new BadRequestException('推广活动 ID 过长,无法写入小程序码 scene');
} }
const page = (
pagePath?.trim() ||
process.env.WX_MINI_PROMO_PAGE ||
'pages/home/index'
).replace(/^\//, '');
const pngBuffer = await this.wechat.getWxaCodeUnlimited({ const pngBuffer = await this.wechat.getWxaCodeUnlimited({
scene, scene,
page: process.env.WX_MINI_PROMO_PAGE || 'pages/home/index', page,
width: 430, width: 430,
checkPath: false, checkPath: false,
}); });
@@ -290,7 +295,7 @@ export class PromoCodeService {
}); });
try { try {
const resource = await this.createQrcodeResource(row.id, code); const resource = await this.createQrcodeResource(row.id, code, dto.page);
const updated = await this.prisma.commonPromoCode.update({ const updated = await this.prisma.commonPromoCode.update({
where: { id: row.id }, where: { id: row.id },
data: { qrcodeResourceId: resource.id }, data: { qrcodeResourceId: resource.id },
@@ -380,7 +385,13 @@ export class PromoCodeService {
/** C 端扫码/带参进入:累加 scan_count、归因、标记用户来源 */ /** C 端扫码/带参进入:累加 scan_count、归因、标记用户来源 */
async touch( async touch(
input: { promoCode?: string; qrcodeId?: string; promoId?: string }, input: {
promoCode?: string;
qrcodeId?: string;
promoId?: string;
/** 默认 true;登录后归因传 false 避免重复计扫码 */
countScan?: boolean;
},
userId?: bigint, userId?: bigint,
) { ) {
const promoCode = input.promoCode?.trim().toUpperCase(); const promoCode = input.promoCode?.trim().toUpperCase();
@@ -395,10 +406,13 @@ export class PromoCodeService {
throw new NotFoundException('推广码无效或已停用'); throw new NotFoundException('推广码无效或已停用');
} }
await this.prisma.commonPromoCode.update({ const shouldCountScan = input.countScan !== false;
where: { id: promo.id }, if (shouldCountScan) {
data: { scanCount: { increment: 1 } }, await this.prisma.commonPromoCode.update({
}); where: { id: promo.id },
data: { scanCount: { increment: 1 } },
});
}
let attributed = false; let attributed = false;
let sourceApplied = false; let sourceApplied = false;
@@ -428,6 +442,7 @@ export class PromoCodeService {
channelName: promo.name, channelName: promo.name,
attributed, attributed,
sourceApplied, sourceApplied,
scanCounted: shouldCountScan,
}); });
} }
@@ -472,7 +487,14 @@ export class PromoCodeService {
}, },
}), }),
]); ]);
return { scanCount, orderCount, conversionRate, attributionCount, sourceMarkedCount }; return {
scanCount,
orderCount,
conversionRate,
attributionCount,
sourceMarkedCount,
registerCount: sourceMarkedCount,
};
} }
/** 推广码关联用户:归因记录或用户来源指向本码 */ /** 推广码关联用户:归因记录或用户来源指向本码 */
@@ -51,7 +51,10 @@ export class TradeService {
private readonly fulfillmentService: FulfillmentService, 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)); const product = await this.catalogService.getProduct(BigInt(body.productId));
if (!product || product.status !== 'ON_SALE') { if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买'); throw new BadRequestException('商品不可购买');
@@ -59,8 +62,15 @@ export class TradeService {
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } }); const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市'); if (!city) throw new BadRequestException('暂无开城城市');
let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL'; const onSitePickup = !!body.onSitePickup;
if (body.addressId) { 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({ const address = await this.prisma.userAddress.findFirst({
where: { id: BigInt(body.addressId), userId }, where: { id: BigInt(body.addressId), userId },
}); });
@@ -84,13 +94,19 @@ export class TradeService {
}); });
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null; const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
const minQty =
deliveryType === 'ON_SITE_PICKUP'
? 1
: deliveryType === 'LOCAL'
? city.localMinQty
: city.crossMinQty;
return { return {
product, product,
quantity: body.quantity, quantity: body.quantity,
deliveryType, deliveryType,
productAmount, productAmount,
freightAmount: deliveryType === 'CROSS_CITY' ? 0 : 0, freightAmount: 0,
freightPayType, freightPayType,
payAmount: productAmount, payAmount: productAmount,
benefitAmount: benefitPerUnit * body.quantity, benefitAmount: benefitPerUnit * body.quantity,
@@ -98,7 +114,8 @@ export class TradeService {
/** 起购未满足时仍返回预览,供确认页改数量;下单接口仍会硬校验 */ /** 起购未满足时仍返回预览,供确认页改数量;下单接口仍会硬校验 */
quantityOk: check.ok, quantityOk: check.ok,
quantityMessage: check.ok ? null : (check.message ?? null), quantityMessage: check.ok ? null : (check.message ?? null),
minQty: deliveryType === 'LOCAL' ? city.localMinQty : city.crossMinQty, minQty,
onSitePickup,
}; };
} }
@@ -107,7 +124,8 @@ export class TradeService {
body: { body: {
productId: string; productId: string;
quantity: number; quantity: number;
addressId: string; addressId?: string;
onSitePickup?: boolean;
clientLocation?: unknown; clientLocation?: unknown;
}, },
req: Request, req: Request,
@@ -116,10 +134,35 @@ export class TradeService {
if (preview.quantityOk === false) { if (preview.quantityOk === false) {
throw new BadRequestException(preview.quantityMessage || '购买数量不满足起购要求'); throw new BadRequestException(preview.quantityMessage || '购买数量不满足起购要求');
} }
const address = await this.prisma.userAddress.findFirst({
where: { id: BigInt(body.addressId), userId }, const onSitePickup = !!body.onSitePickup || preview.deliveryType === 'ON_SITE_PICKUP';
}); let receiverName = '现场取货';
if (!address) throw new BadRequestException('请选择收货地址'); 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({ const product = await this.prisma.commonProductItem.findUniqueOrThrow({
where: { id: BigInt(body.productId) }, where: { id: BigInt(body.productId) },
@@ -149,7 +192,7 @@ export class TradeService {
cityId: city.id, cityId: city.id,
status: 'PENDING_PAY', status: 'PENDING_PAY',
payStatus: 'UNPAID', payStatus: 'UNPAID',
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY', deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
productId: product.id, productId: product.id,
barcode69: product.barcode69, barcode69: product.barcode69,
productName: product.name, productName: product.name,
@@ -159,12 +202,12 @@ export class TradeService {
listUnitPrice: product.price, listUnitPrice: product.price,
listAmount: preview.productAmount, listAmount: preview.productAmount,
productAmount: preview.productAmount, productAmount: preview.productAmount,
receiverName: address.receiverName, receiverName,
receiverPhone: address.phone, receiverPhone,
receiverAddress: `${address.province}${address.city}${address.district}${address.detail}`, receiverAddress,
receiverProvince: address.province, receiverProvince,
receiverCity: address.city, receiverCity,
receiverDistrict: address.district, receiverDistrict,
clientIp: location.clientIp, clientIp: location.clientIp,
ipProvince: location.ipProvince, ipProvince: location.ipProvince,
ipCity: location.ipCity, ipCity: location.ipCity,
@@ -203,6 +246,7 @@ export class TradeService {
orderId: order.id.toString(), orderId: order.id.toString(),
productId: body.productId, productId: body.productId,
quantity: body.quantity, quantity: body.quantity,
onSitePickup,
}, },
}); });
@@ -241,12 +285,14 @@ export class TradeService {
order.cityId, order.cityId,
order.receiverDistrict, order.receiverDistrict,
); );
const toStatus =
order.deliveryType === 'ON_SITE_PICKUP' ? 'PENDING_RECEIVE' : 'PENDING_SHIP';
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.order.update({ await tx.order.update({
where: { id: order.id }, where: { id: order.id },
data: { data: {
status: 'PENDING_SHIP', status: toStatus,
payStatus: 'PAID', payStatus: 'PAID',
paidAt: now, paidAt: now,
payExternalNo: externalNo, payExternalNo: externalNo,
@@ -269,7 +315,7 @@ export class TradeService {
data: buildOrderStatusEvent({ data: buildOrderStatusEvent({
orderId: order.id, orderId: order.id,
fromStatus: 'PENDING_PAY', fromStatus: 'PENDING_PAY',
toStatus: 'PENDING_SHIP', toStatus,
operator: 'MOCK_PAY', operator: 'MOCK_PAY',
}), }),
}); });
@@ -292,6 +338,20 @@ export class TradeService {
private async afterOrderPaid(orderId: bigint) { private async afterOrderPaid(orderId: bigint) {
await this.benefitService.grantOnOrderPaid(orderId); 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); await this.fulfillmentService.dispatchAfterPay(orderId);
const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } }); const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } });
if (refreshed?.status === 'PENDING_SHIP') { if (refreshed?.status === 'PENDING_SHIP') {
@@ -335,6 +395,8 @@ export class TradeService {
order.cityId, order.cityId,
order.receiverDistrict, order.receiverDistrict,
); );
const toStatus =
order.deliveryType === 'ON_SITE_PICKUP' ? 'PENDING_RECEIVE' : 'PENDING_SHIP';
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
const current = await tx.order.findUnique({ where: { id: order.id } }); const current = await tx.order.findUnique({ where: { id: order.id } });
if (!current || current.payStatus === 'PAID') return; if (!current || current.payStatus === 'PAID') return;
@@ -342,7 +404,7 @@ export class TradeService {
await tx.order.update({ await tx.order.update({
where: { id: order.id }, where: { id: order.id },
data: { data: {
status: 'PENDING_SHIP', status: toStatus,
payStatus: 'PAID', payStatus: 'PAID',
paidAt: now, paidAt: now,
payExternalNo: params.transactionId, payExternalNo: params.transactionId,
@@ -365,7 +427,7 @@ export class TradeService {
data: buildOrderStatusEvent({ data: buildOrderStatusEvent({
orderId: order.id, orderId: order.id,
fromStatus: 'PENDING_PAY', fromStatus: 'PENDING_PAY',
toStatus: 'PENDING_SHIP', toStatus,
operator: 'WECHAT_PAY', operator: 'WECHAT_PAY',
}), }),
}); });
@@ -470,25 +532,12 @@ export class TradeService {
async confirmReceive( async confirmReceive(
userId: bigint, userId: bigint,
orderId: bigint, orderId: bigint,
opts?: { onSitePickup?: boolean }, _opts?: { onSitePickup?: boolean },
) { ) {
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } }); const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
if (!order) throw new NotFoundException('订单不存在'); if (!order) throw new NotFoundException('订单不存在');
const onSitePickup = !!opts?.onSitePickup; if (!['PENDING_RECEIVE', 'DELIVERED'].includes(order.status)) {
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)) {
throw new BadRequestException('当前状态不可确认收货'); throw new BadRequestException('当前状态不可确认收货');
} }
@@ -496,8 +545,8 @@ export class TradeService {
order.id, order.id,
order.status, order.status,
'COMPLETED', 'COMPLETED',
onSitePickup ? 'USER_ON_SITE' : 'USER', order.deliveryType === 'ON_SITE_PICKUP' ? 'USER_ON_SITE' : 'USER',
onSitePickup ? '用户现场取货确认收货' : undefined, order.deliveryType === 'ON_SITE_PICKUP' ? '用户现场取货确认收货' : undefined,
); );
return this.getOrder(userId, orderId); return this.getOrder(userId, orderId);
} }