feat(partner): complete proxy order with dual SMS and pickup
CI / verify (pull_request) Has been cancelled

Add partner/customer SMS confirm, address auto-receive or on-site
pickup, proxy placer fields, PARTNER_PROXY user source, and C-end badge.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-28 00:04:59 +08:00
parent cd23119470
commit b59d4484ec
21 changed files with 890 additions and 170 deletions
+1 -1
View File
@@ -51,7 +51,7 @@
|-----|------|----------|
| ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 |
| ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 |
| ACC-012 | 代下单 | W3手机号建用户;权益自动发 |
| ACC-012 | 代下单 | W3客户+合伙人双短信;手机号建用户(来源 PARTNER_PROXY);线下确认后已付款已完成并发权益;C 端展示代下单人 |
| ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 |
| ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 |
| ACC-018 | 话术 | OPT-012 3 日内对齐 |
+2
View File
@@ -15,6 +15,7 @@ import BillsPage from './pages/BillsPage';
import SettlementPage from './pages/SettlementPage';
import WeeklyReportPage from './pages/WeeklyReportPage';
import ProxyOrderPage from './pages/ProxyOrderPage';
import ProxyOrderProductsPage from './pages/ProxyOrderProductsPage';
import StaffListPage from './pages/StaffListPage';
import StaffCreatePage from './pages/StaffCreatePage';
import LeaderboardPage from './pages/LeaderboardPage';
@@ -33,6 +34,7 @@ function PrimaryRoutes() {
<Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/proxy-order" element={<ProxyOrderPage />} />
<Route path="/proxy-order/products" element={<ProxyOrderProductsPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
+316 -112
View File
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import ChinaRegionPicker from '../components/ChinaRegionPicker';
import { request } from '../lib/api';
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
import { toastError, toastSuccess } from '../lib/toast';
import type {
PartnerProxyDeliveryMode,
PartnerProxyOrderCreateRequest,
PartnerProxyOrderOptions,
PartnerProxyOrderPreviewResult,
@@ -17,19 +18,26 @@ function fmtMoney(n: number) {
export default function ProxyOrderPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(true);
const [phone, setPhone] = useState('');
const [smsCode, setSmsCode] = useState('');
const [customerSmsCode, setCustomerSmsCode] = useState('');
const [partnerSmsCode, setPartnerSmsCode] = useState('');
const [receiverName, setReceiverName] = useState('');
const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState('');
const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState('');
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
const [storeId, setStoreId] = useState('');
const [autoReceive, setAutoReceive] = useState(false);
const [confirmStep, setConfirmStep] = useState(false);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [smsCooldown, setSmsCooldown] = useState(0);
const [customerCooldown, setCustomerCooldown] = useState(0);
const [partnerCooldown, setPartnerCooldown] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState('');
@@ -39,12 +47,27 @@ export default function ProxyOrderPage() {
useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => {
setOptions(data);
if (data.products[0]) setProductId(data.products[0].id);
setOptions({
...data,
stores: Array.isArray(data.stores) ? data.stores : [],
});
const fromQuery = searchParams.get('productId');
if (fromQuery && data.products.some((p) => p.id === fromQuery)) {
setProductId(fromQuery);
} else if (data.products[0] && !productId) {
setProductId(data.products[0].id);
}
if (data.stores?.[0] && !storeId) setStoreId(data.stores[0].id);
})
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoadingOptions(false));
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps -- hydrate once; productId from query
}, [searchParams]);
useEffect(() => {
const fromQuery = searchParams.get('productId');
if (fromQuery) setProductId(fromQuery);
}, [searchParams]);
useEffect(() => {
if (!productId || quantity < 1) {
@@ -58,8 +81,10 @@ export default function ProxyOrderPage() {
body: JSON.stringify({
productId,
quantity,
receiverCity: region.city || undefined,
receiverDistrict: region.district || undefined,
deliveryMode,
storeId: deliveryMode === 'ON_SITE_PICKUP' ? storeId || undefined : undefined,
receiverCity: deliveryMode === 'ADDRESS' ? region.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region.district || undefined : undefined,
}),
silent: true,
})
@@ -68,67 +93,110 @@ export default function ProxyOrderPage() {
.finally(() => setPreviewLoading(false));
}, 300);
return () => clearTimeout(timer);
}, [productId, quantity, region.city, region.district]);
}, [productId, quantity, deliveryMode, storeId, region.city, region.district]);
async function sendSms() {
function startCooldown(setter: (n: number | ((s: number) => number)) => void) {
setter(60);
const timer = setInterval(() => {
setter((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
}
async function sendCustomerSms() {
setMsg('');
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入有效手机号');
return;
}
try {
const res = await request<{ maskedPhone: string }>('PARTNER_H5', '/partner/proxy-orders/send-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
silent: true,
});
setMsg(`验证码已发送至 ${res.maskedPhone}`);
setSmsCooldown(60);
const timer = setInterval(() => {
setSmsCooldown((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
const res = await request<{ maskedPhone: string }>(
'PARTNER_H5',
'/partner/proxy-orders/send-customer-sms',
{
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
silent: true,
},
);
setMsg(`客户验证码已发送至 ${res.maskedPhone}`);
startCooldown(setCustomerCooldown);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
}
}
async function sendPartnerSms() {
setMsg('');
try {
const res = await request<{ maskedPhone: string }>(
'PARTNER_H5',
'/partner/proxy-orders/send-partner-sms',
{ method: 'POST', body: '{}', silent: true },
);
setMsg(`确认验证码已发送至合伙人手机 ${res.maskedPhone}`);
startCooldown(setPartnerCooldown);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
}
}
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!customerSmsCode.trim()) return '请输入客户验证码';
if (!productId) return '请选择商品';
if (deliveryMode === 'ON_SITE_PICKUP') {
if (!storeId) return '请选择提货门店';
} else {
if (!region?.province || !region.city || !region.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
}
if (!preview) return '请等待费用计算完成';
return null;
}
async function openConfirmStep() {
setMsg('');
const err = validateForm();
if (err) {
setMsg(err);
return;
}
setConfirmStep(true);
setPartnerSmsCode('');
await sendPartnerSms();
}
async function submit() {
setMsg('');
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入有效手机号');
const err = validateForm();
if (err) {
setMsg(err);
return;
}
if (!smsCode.trim()) {
setMsg('请输入验证码');
return;
}
if (!region?.province || !region.city || !region.district) {
setMsg('请选择省市区');
return;
}
if (!addressDetail.trim()) {
setMsg('请填写详细地址');
return;
}
if (!productId) {
setMsg('请选择商品');
if (!partnerSmsCode.trim()) {
setMsg('请输入合伙人确认验证码');
return;
}
const payload: PartnerProxyOrderCreateRequest = {
phone: phone.trim(),
smsCode: smsCode.trim(),
customerSmsCode: customerSmsCode.trim(),
partnerSmsCode: partnerSmsCode.trim(),
deliveryMode,
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
storeId: deliveryMode === 'ON_SITE_PICKUP' ? storeId : undefined,
receiverName: receiverName.trim() || undefined,
province: region.province,
city: region.city,
district: region.district,
addressDetail: addressDetail.trim(),
province: deliveryMode === 'ADDRESS' ? region.province : undefined,
city: deliveryMode === 'ADDRESS' ? region.city : undefined,
district: deliveryMode === 'ADDRESS' ? region.district : undefined,
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
productId,
quantity,
promoCodeId: promoCodeId || undefined,
@@ -151,6 +219,12 @@ export default function ProxyOrderPage() {
}
const selectedProduct = options?.products.find((p) => p.id === productId);
const deliveryLabel =
preview?.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: preview?.deliveryType === 'CROSS_CITY'
? '跨城配送'
: '同城配送';
return (
<div className="page partner-proxy-order-page">
@@ -158,7 +232,7 @@ export default function ProxyOrderPage() {
<main className="partner-form-card" style={{ margin: '0 16px 24px' }}>
{loadingOptions ? (
<p className="label-md text-muted">广</p>
<p className="label-md text-muted"></p>
) : (
<>
<section className="partner-form-section">
@@ -175,70 +249,49 @@ export default function ProxyOrderPage() {
<button
type="button"
className="btn btn-secondary"
disabled={smsCooldown > 0}
onClick={() => void sendSms()}
disabled={customerCooldown > 0}
onClick={() => void sendCustomerSms()}
>
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
{customerCooldown > 0 ? `${customerCooldown}s` : '获取验证码'}
</button>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="线下代发货确认码"
value={smsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="发至客户手机"
value={customerSmsCode}
onChange={(e) => setCustomerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="默认:用户+手机尾号"
value={receiverName}
onChange={(e) => setReceiverName(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
{regionLabel && <p className="label-md text-muted" style={{ marginTop: 8 }}>{regionLabel}</p>}
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-field-input partner-field-input--block">
<textarea
rows={3}
placeholder="街道、门牌号等"
value={addressDetail}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<select
className="partner-input"
value={productId}
onChange={(e) => setProductId(e.target.value)}
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
<label className="partner-form-label"></label>
<button
type="button"
className="partner-proxy-product-picker"
onClick={() =>
navigate(
`/proxy-order/products${productId ? `?selected=${encodeURIComponent(productId)}` : ''}`,
)
}
>
{(options?.products ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.spec}¥{fmtMoney(p.price)}
</option>
))}
</select>
{selectedProduct ? (
<>
<span className="partner-proxy-product-name">{selectedProduct.name}</span>
<span className="label-md text-muted">
{selectedProduct.spec} · ¥{fmtMoney(selectedProduct.price)}
</span>
</>
) : (
<span className="label-md text-muted"></span>
)}
<span className="partner-proxy-product-picker-arrow"></span>
</button>
</section>
<section className="partner-form-section">
@@ -254,6 +307,99 @@ export default function ProxyOrderPage() {
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-proxy-mode-tabs">
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ADDRESS' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ADDRESS')}
>
</button>
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ON_SITE_PICKUP' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ON_SITE_PICKUP')}
>
</button>
</div>
</section>
{deliveryMode === 'ON_SITE_PICKUP' ? (
<section className="partner-form-section">
<label className="partner-form-label"></label>
{(options?.stores.length ?? 0) === 0 ? (
<p className="label-md text-muted"></p>
) : (
<select
className="partner-input"
value={storeId}
onChange={(e) => setStoreId(e.target.value)}
style={{
width: '100%',
padding: '12px 14px',
borderRadius: 12,
border: '1px solid var(--color-border)',
}}
>
{options!.stores.map((s) => (
<option key={s.id} value={s.id}>
{s.name}{s.district}
{s.address}
</option>
))}
</select>
)}
</section>
) : (
<>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="默认:用户+手机尾号"
value={receiverName}
onChange={(e) => setReceiverName(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
{regionLabel ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{regionLabel}
</p>
) : null}
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-field-input partner-field-input--block">
<textarea
rows={3}
placeholder="街道、门牌号等"
value={addressDetail}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</div>
</section>
<label className="partner-proxy-auto-receive">
<input
type="checkbox"
checked={autoReceive}
onChange={(e) => setAutoReceive(e.target.checked)}
/>
<span>线</span>
</label>
</>
)}
{(options?.promoCodes.length ?? 0) > 0 && (
<section className="partner-form-section">
<label className="partner-form-label">广</label>
@@ -261,7 +407,12 @@ export default function ProxyOrderPage() {
className="partner-input"
value={promoCodeId}
onChange={(e) => setPromoCodeId(e.target.value)}
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
style={{
width: '100%',
padding: '12px 14px',
borderRadius: 12,
border: '1px solid var(--color-border)',
}}
>
<option value=""></option>
{options!.promoCodes.map((p) => (
@@ -288,8 +439,8 @@ export default function ProxyOrderPage() {
<span className="body-md">×{quantity}</span>
</div>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">{preview.deliveryType === 'LOCAL' ? '同城' : '跨城'}</span>
<span className="label-md text-muted"></span>
<span className="body-md">{deliveryLabel}</span>
</div>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
@@ -302,25 +453,78 @@ export default function ProxyOrderPage() {
</>
) : (
<p className="label-md text-muted">
{selectedProduct ? '请确认数量与地址后查看费用' : '请选择商品'}
{selectedProduct ? '请确认数量与履约信息后查看费用' : '请选择商品'}
</p>
)}
</section>
{msg && <p className="partner-form-error" role="alert">{msg}</p>}
{confirmStep ? (
<section className="partner-form-section partner-proxy-confirm">
<label className="partner-form-label"></label>
<p className="label-md text-muted" style={{ marginBottom: 8 }}>
线
</p>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<input
className="partner-input"
placeholder="发至合伙人手机"
value={partnerSmsCode}
onChange={(e) => setPartnerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={partnerCooldown > 0}
onClick={() => void sendPartnerSms()}
>
{partnerCooldown > 0 ? `${partnerCooldown}s` : '重新发送'}
</button>
</div>
</section>
) : null}
<button
type="button"
className="btn btn-primary btn-block"
disabled={submitting || !preview}
onClick={() => void submit()}
style={{ marginTop: 16 }}
>
{submitting ? '提交中…' : '验证码确认并代下单'}
</button>
{msg ? (
<p className="partner-form-error" role="alert">
{msg}
</p>
) : null}
{!confirmStep ? (
<button
type="button"
className="btn btn-primary btn-block"
disabled={!preview}
onClick={() => void openConfirmStep()}
style={{ marginTop: 16 }}
>
</button>
) : (
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={() => setConfirmStep(false)}
>
</button>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
disabled={submitting || !preview}
onClick={() => void submit()}
>
{submitting ? '提交中…' : '验证并提交'}
</button>
</div>
)}
<p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}>
/线
线/
</p>
</>
)}
@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import { toastError } from '../lib/toast';
import type { PartnerProxyOrderOptions, PartnerProxyOrderProductOption } from '@dukang/shared-types';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function ProxyOrderProductsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const selectedId = searchParams.get('selected') || '';
const [products, setProducts] = useState<PartnerProxyOrderProductOption[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => setProducts(Array.isArray(data.products) ? data.products : []))
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, []);
function pick(product: PartnerProxyOrderProductOption) {
navigate(`/proxy-order?productId=${encodeURIComponent(product.id)}`, { replace: true });
}
return (
<div className="page partner-proxy-order-page">
<PageHeader title="选择酒品" onBack={() => navigate(-1)} />
<main className="partner-proxy-product-list">
{loading ? <p className="label-md text-muted"></p> : null}
{!loading && products.length === 0 ? (
<p className="label-md text-muted"></p>
) : null}
{products.map((p) => {
const benefit = p.benefitAmount != null ? Number(p.benefitAmount) : Number(p.price);
const active = p.id === selectedId;
return (
<button
key={p.id}
type="button"
className={`partner-proxy-product-card${active ? ' partner-proxy-product-card--active' : ''}`}
onClick={() => pick(p)}
>
<div className="partner-proxy-product-thumb">
{p.coverUrl ? (
<img src={p.coverUrl} alt="" />
) : (
<span className="partner-proxy-product-thumb-empty" />
)}
</div>
<div className="partner-proxy-product-main">
<div className="partner-proxy-product-row">
<span className="partner-proxy-product-name">{p.name}</span>
<span className="partner-proxy-product-price">¥{fmtMoney(p.price)}</span>
</div>
<p className="partner-proxy-product-spec">{p.spec}</p>
<p className="partner-proxy-product-benefit"> ¥{fmtMoney(benefit)}</p>
</div>
</button>
);
})}
</main>
</div>
);
}
+139
View File
@@ -3315,6 +3315,145 @@ body {
border-top: 1px dashed rgba(166, 29, 36, 0.15);
}
.partner-proxy-mode-tabs {
display: flex;
gap: 8px;
}
.partner-proxy-mode-tab {
flex: 1;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--color-border);
background: #fff;
font-size: 14px;
color: var(--color-ink-black);
}
.partner-proxy-mode-tab.is-active {
border-color: var(--color-heritage-red);
color: var(--color-heritage-red);
background: rgba(166, 29, 36, 0.06);
font-weight: 600;
}
.partner-proxy-auto-receive {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 4px 0 16px;
font-size: 13px;
line-height: 1.45;
color: var(--color-ink-black);
}
.partner-proxy-auto-receive input {
margin-top: 3px;
flex-shrink: 0;
}
.partner-proxy-product-picker {
position: relative;
width: 100%;
text-align: left;
padding: 12px 36px 12px 14px;
border-radius: 12px;
border: 1px solid var(--color-border);
background: #fff;
display: flex;
flex-direction: column;
gap: 4px;
}
.partner-proxy-product-picker-arrow {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 22px;
color: var(--color-muted, #999);
}
.partner-proxy-product-list {
padding: 0 16px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.partner-proxy-product-card {
display: flex;
gap: 12px;
width: 100%;
text-align: left;
padding: 12px;
border-radius: 16px;
border: 1px solid var(--color-border);
background: #fff;
}
.partner-proxy-product-card--active {
border-color: var(--color-heritage-red);
box-shadow: 0 0 0 1px rgba(166, 29, 36, 0.2);
}
.partner-proxy-product-thumb {
width: 72px;
height: 72px;
border-radius: 12px;
overflow: hidden;
background: #f3f1ee;
flex-shrink: 0;
}
.partner-proxy-product-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.partner-proxy-product-thumb-empty {
display: block;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #f3f1ee, #e8e4de);
}
.partner-proxy-product-main {
flex: 1;
min-width: 0;
}
.partner-proxy-product-row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.partner-proxy-product-name {
font-weight: 600;
font-size: 15px;
color: var(--color-ink-black);
}
.partner-proxy-product-price {
color: var(--color-heritage-red);
font-weight: 600;
flex-shrink: 0;
}
.partner-proxy-product-spec,
.partner-proxy-product-benefit {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-muted, #888);
}
.partner-proxy-confirm {
padding-top: 8px;
border-top: 1px dashed rgba(0, 0, 0, 0.08);
}
.partner-warehouse-denied {
display: flex;
flex-direction: column;
@@ -37,6 +37,8 @@ type Order = {
id: string;
orderNo: string;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
status: string;
originOrderId?: string | null;
remark?: string | null;
@@ -138,6 +140,7 @@ export default function OrderDetailPage() {
const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]);
const isReship = order?.orderType === 'RESHIPMENT';
const isProxy = order?.orderType === 'PROXY' || order?.isProxyOrder === true;
async function loadTrack() {
if (!id) return;
@@ -240,6 +243,11 @@ export default function OrderDetailPage() {
<h2>{banner.title}</h2>
</div>
<p>{banner.subtitle}</p>
{isProxy && order?.proxyPartnerName ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
· {order.proxyPartnerName}
</p>
) : null}
</div>
</section>
+11
View File
@@ -52,6 +52,7 @@ export default function OrderListPage() {
const items = (o.items as Array<Record<string, unknown>>) || [];
const item = items[0];
const isReship = isReshipOrder(o);
const isProxy = String(o.orderType) === 'PROXY' || o.isProxyOrder === true;
const isPendingPay = String(o.status) === 'PENDING_PAY';
return (
<div key={String(o.id)} className="card">
@@ -63,9 +64,19 @@ export default function OrderListPage() {
</span>
)}
{isProxy && (
<span className="tag-reship" style={{ marginLeft: 8 }}>
</span>
)}
</span>
<span className="status-tag">{orderStatusLabel(o)}</span>
</div>
{isProxy && o.proxyPartnerName ? (
<div className="label-md text-muted" style={{ marginBottom: 8 }}>
{String(o.proxyPartnerName)}
</div>
) : null}
{item && (
<div className="card-row">
<AppImage
@@ -39,6 +39,9 @@ type OrderDetail = {
receiverAddress?: string;
createdAt?: string;
originOrderId?: string | null;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
items?: OrderItem[];
};
@@ -80,9 +83,10 @@ export default function OrderDetailPage() {
}, [orderId]);
const isReship = !!order?.originOrderId;
const isProxy = !!order && (order.isProxyOrder || order.orderType === 'PROXY');
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
const canConfirmReceive =
!!order && !isReship && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
!!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
const item = order?.items?.[0];
const productName = item?.productName || order?.productName || '杜康商品';
@@ -166,9 +170,15 @@ export default function OrderDetailPage() {
<>
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="order-list-status">
{STATUS_LABELS[order.status || ''] || order.status || '处理中'}
</Text>
<View className="order-status-row">
<Text className="order-list-status">
{STATUS_LABELS[order.status || ''] || order.status || '处理中'}
</Text>
{isProxy ? <Text className="order-proxy-badge"></Text> : null}
</View>
{isProxy && order.proxyPartnerName ? (
<Text className="order-proxy-hint"> {order.proxyPartnerName} </Text>
) : null}
</View>
<View className="order-card">
<Text className="order-card-title"></Text>
+11 -1
View File
@@ -39,6 +39,9 @@ type OrderRow = {
qty?: number;
quantity?: number;
originOrderId?: string | null;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
items?: OrderItem[];
};
@@ -102,6 +105,7 @@ export default function OrdersPage() {
const qty = item?.quantity ?? o.quantity ?? o.qty ?? 1;
const unitPrice = Number(item?.unitPrice ?? 0);
const canPay = o.status === 'PENDING_PAY' && !o.originOrderId;
const isProxy = o.isProxyOrder || o.orderType === 'PROXY';
return (
<View
@@ -110,11 +114,17 @@ export default function OrdersPage() {
onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })}
>
<View className="order-list-head">
<Text className="order-list-no">{o.orderNo || o.id}</Text>
<View className="order-list-head-left">
<Text className="order-list-no">{o.orderNo || o.id}</Text>
{isProxy ? <Text className="order-proxy-badge"></Text> : null}
</View>
<Text className="order-list-status">
{orderStatusLabel(tab, o.status)}
</Text>
</View>
{isProxy && o.proxyPartnerName ? (
<Text className="order-proxy-hint"> {o.proxyPartnerName} </Text>
) : null}
<View className="order-list-body">
<View className="order-list-thumb">
{productImage ? (
+30
View File
@@ -297,11 +297,41 @@
margin-bottom: 12px;
}
.order-list-head-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.order-list-no {
font-size: 12px;
color: var(--color-subtle-gray);
}
.order-status-row {
display: flex;
align-items: center;
gap: 8px;
}
.order-proxy-badge {
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
color: var(--color-heritage-red);
background: rgba(166, 29, 36, 0.08);
padding: 2px 6px;
border-radius: 4px;
}
.order-proxy-hint {
display: block;
font-size: 12px;
color: var(--color-subtle-gray);
margin: -4px 0 10px;
}
.order-list-status {
font-size: 13px;
font-weight: 600;
+1 -1
View File
@@ -17,7 +17,7 @@ export interface AppConfig {
aliyunSmsTemplateCode: string;
/** 核销确认短信模板(REDEEM_PHONE_CONFIRM);env: ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM */
aliyunSmsRedeemConfirmTemplateCode: string;
/** 合伙人代下单短信模板(PARTNER_PROXY_ORDER);env: ALIYUN_SMS_TEMPLATE_PROXY_ORDER */
/** 合伙人代下单短信模板(PARTNER_PROXY_ORDER / PARTNER_PROXY_CUSTOMER);env: ALIYUN_SMS_TEMPLATE_PROXY_ORDER */
aliyunSmsProxyOrderTemplateCode: string;
aliyunSmsAccessKeyId: string;
aliyunSmsAccessKeySecret: string;
+5 -1
View File
@@ -22,6 +22,7 @@ export enum UserSourceType {
SHARE_LINK = 'SHARE_LINK',
FRIEND_REFERRAL = 'FRIEND_REFERRAL',
OFFLINE_EVENT = 'OFFLINE_EVENT',
PARTNER_PROXY = 'PARTNER_PROXY',
OTHER = 'OTHER',
}
@@ -31,6 +32,7 @@ export const USER_SOURCE_TYPE_LABELS: Record<UserSourceType, string> = {
[UserSourceType.SHARE_LINK]: '分享链接',
[UserSourceType.FRIEND_REFERRAL]: '好友推荐',
[UserSourceType.OFFLINE_EVENT]: '线下活动',
[UserSourceType.PARTNER_PROXY]: '代下单',
[UserSourceType.OTHER]: '其他',
};
@@ -46,7 +48,9 @@ export enum SmsScene {
REDEEM_PHONE_LOOKUP = 'REDEEM_PHONE_LOOKUP',
/** 门店手机号核销:核销确认验证码(阿里云模板「核销确认」) */
REDEEM_PHONE_CONFIRM = 'REDEEM_PHONE_CONFIRM',
/** 合伙人代下单:线下代发货确认验证码(发至户手机) */
/** 合伙人代下单:客户手机号归属验证码(发至户手机) */
PARTNER_PROXY_CUSTOMER = 'PARTNER_PROXY_CUSTOMER',
/** 合伙人代下单:线下确认验证码(发至合伙人手机) */
PARTNER_PROXY_ORDER = 'PARTNER_PROXY_ORDER',
/** 合伙人录店:门店登录手机号验证码(发至门店负责人手机) */
PARTNER_STORE_OPEN = 'PARTNER_STORE_OPEN',
+32 -6
View File
@@ -8,6 +8,10 @@ export interface OrderDto {
quantity: number;
productName: string;
createdAt: string;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
}
export interface OrderPreviewRequest {
@@ -39,12 +43,16 @@ export const FULFILLMENT_HOLD_REASON_LABELS: Record<string, string> = {
LARGE_ORDER_GE_10_BOXES: '大单≥10箱,待总部确认推单/自配送',
};
export type PartnerProxyDeliveryMode = 'ADDRESS' | 'ON_SITE_PICKUP';
export type PartnerProxyOrderProductOption = {
id: string;
name: string;
spec: string;
price: number;
benefitAmount: number | null;
coverUrl?: string | null;
allowOnSitePickup?: boolean;
};
export type PartnerProxyOrderPromoOption = {
@@ -53,14 +61,27 @@ export type PartnerProxyOrderPromoOption = {
name: string;
};
export type PartnerProxyOrderStoreOption = {
id: string;
name: string;
address?: string | null;
phone?: string | null;
province?: string | null;
cityName?: string | null;
district?: string | null;
};
export type PartnerProxyOrderOptions = {
products: PartnerProxyOrderProductOption[];
promoCodes: PartnerProxyOrderPromoOption[];
stores: PartnerProxyOrderStoreOption[];
};
export type PartnerProxyOrderPreviewRequest = {
productId: string;
quantity: number;
deliveryMode?: PartnerProxyDeliveryMode;
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
};
@@ -69,18 +90,23 @@ export type PartnerProxyOrderPreviewResult = {
productAmount: number;
payAmount: number;
benefitAmount: number;
deliveryType: 'LOCAL' | 'CROSS_CITY';
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP';
unitPrice: number;
};
export type PartnerProxyOrderCreateRequest = {
phone: string;
smsCode: string;
customerSmsCode: string;
partnerSmsCode: string;
deliveryMode: PartnerProxyDeliveryMode;
/** 地址配送时必须为 true */
autoReceive?: boolean;
storeId?: string;
receiverName?: string;
province: string;
city: string;
district: string;
addressDetail: string;
province?: string;
city?: string;
district?: string;
addressDetail?: string;
productId: string;
quantity: number;
promoCodeId?: string;
+6
View File
@@ -256,6 +256,7 @@ enum UserSourceType {
SHARE_LINK
FRIEND_REFERRAL
OFFLINE_EVENT
PARTNER_PROXY
OTHER
}
@@ -1153,6 +1154,10 @@ model Order {
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt
orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4)
/// 代下单操作人(与佣金归属 partnerAccountIdAtPay 分离)
proxyPartnerAccountId BigInt? @map("proxy_partner_account_id") @db.UnsignedBigInt
proxyPartnerName String? @map("proxy_partner_name") @db.VarChar(64)
proxyPartnerPhone String? @map("proxy_partner_phone") @db.VarChar(20)
fulfillmentWarehouseId BigInt? @map("fulfillment_warehouse_id") @db.UnsignedBigInt
/// 大单等场景拦截自动推承运商,待总部确认后推单或自配送
fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
@@ -1181,6 +1186,7 @@ model Order {
@@index([ipCity])
@@index([gpsCity])
@@index([fulfillmentWarehouseId])
@@index([proxyPartnerAccountId])
@@map("user_order")
}
@@ -22,10 +22,19 @@ export function mapOrderItemCompat(order: OrderLike) {
};
}
export function mapOrderCompat<T extends OrderLike>(order: T) {
export function mapOrderCompat<T extends OrderLike & {
orderType?: string | null;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
proxyPartnerAccountId?: bigint | number | string | null;
}>(order: T) {
const payStatus = order.payStatus ?? 'UNPAID';
const isProxyOrder = order.orderType === 'PROXY';
return {
...order,
isProxyOrder,
proxyPartnerName: order.proxyPartnerName ?? null,
proxyPartnerPhone: order.proxyPartnerPhone ?? null,
items: [mapOrderItemCompat(order)],
payment: {
status: payStatus === 'PAID' ? 'SUCCESS' : payStatus,
@@ -66,7 +66,7 @@ export class SmsAliyunProvider implements ISmsProvider {
return this.config.aliyunSmsRedeemConfirmTemplateCode;
}
if (
scene === 'PARTNER_PROXY_ORDER' &&
(scene === 'PARTNER_PROXY_ORDER' || scene === 'PARTNER_PROXY_CUSTOMER') &&
this.config.aliyunSmsProxyOrderTemplateCode
) {
return this.config.aliyunSmsProxyOrderTemplateCode;
@@ -97,6 +97,7 @@ export class AuthService {
case SmsScene.REDEEM_PHONE_CONFIRM:
return ClientApp.SHOP_H5;
case SmsScene.PARTNER_PROXY_ORDER:
case SmsScene.PARTNER_PROXY_CUSTOMER:
return ClientApp.PARTNER_H5;
default:
return ClientApp.USER_H5;
@@ -155,13 +156,20 @@ export class AuthService {
});
return user ? { refType: 'USER', refId: user.id } : undefined;
}
case SmsScene.PARTNER_PROXY_ORDER: {
case SmsScene.PARTNER_PROXY_CUSTOMER: {
const user = await this.prisma.user.findFirst({
where: { phone, mergedIntoUserId: null, status: 1 },
select: { id: true },
});
return user ? { refType: 'USER', refId: user.id } : undefined;
}
case SmsScene.PARTNER_PROXY_ORDER: {
const partner = await this.prisma.partnerAccount.findUnique({
where: { phone },
select: { id: true },
});
return partner ? { refType: 'PARTNER', refId: partner.id } : undefined;
}
default:
return undefined;
}
@@ -334,7 +342,11 @@ export class AuthService {
if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销');
return;
}
if (scene === SmsScene.PARTNER_PROXY_CUSTOMER) {
return;
}
if (scene === SmsScene.PARTNER_PROXY_ORDER) {
await this.assertPartnerAccountByPhone(phone);
return;
}
if (scene === SmsScene.PARTNER_STORE_OPEN) {
@@ -348,7 +360,14 @@ export class AuthService {
}
/** 合伙人代下单:按手机号查找或创建已验证用户 */
async findOrCreateUserByPhone(phone: string) {
async findOrCreateUserByPhone(
phone: string,
source?: {
sourceType?: 'PARTNER_PROXY';
sourceRefId?: bigint;
sourceLabel?: string;
},
) {
const normalizedPhone = this.assertMobilePhone(phone);
let user = await this.prisma.user.findUnique({
where: { phone: normalizedPhone },
@@ -362,6 +381,9 @@ export class AuthService {
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`,
sourceType: source?.sourceType ?? 'ORGANIC',
sourceRefId: source?.sourceRefId,
sourceLabel: source?.sourceLabel,
cityPreference: {
create: {
selectedCityCode: '410100',
@@ -1,5 +1,16 @@
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength, Min } from 'class-validator';
import {
IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
export class PartnerProxyOrderPreviewDto {
@IsString()
@@ -11,6 +22,14 @@ export class PartnerProxyOrderPreviewDto {
@Min(1)
quantity: number;
@IsOptional()
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
@IsOptional()
@IsString()
storeId?: string;
@IsOptional()
@IsString()
receiverCity?: string;
@@ -20,7 +39,7 @@ export class PartnerProxyOrderPreviewDto {
receiverDistrict?: string;
}
export class PartnerProxyOrderSendSmsDto {
export class PartnerProxyOrderSendCustomerSmsDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
@@ -33,28 +52,48 @@ export class PartnerProxyOrderCreateDto {
@IsString()
@IsNotEmpty()
smsCode: string;
customerSmsCode: string;
@IsString()
@IsNotEmpty()
partnerSmsCode: string;
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsBoolean()
autoReceive?: boolean;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ON_SITE_PICKUP')
@IsString()
@IsNotEmpty()
storeId?: string;
@IsOptional()
@IsString()
@MaxLength(32)
receiverName?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
province: string;
province?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
city: string;
city?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
district: string;
district?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(256)
addressDetail: string;
addressDetail?: string;
@IsString()
@IsNotEmpty()
@@ -9,7 +9,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
PartnerProxyOrderCreateDto,
PartnerProxyOrderPreviewDto,
PartnerProxyOrderSendSmsDto,
PartnerProxyOrderSendCustomerSmsDto,
} from './dto/partner-proxy-order.dto';
import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto';
import { CreateAfterSaleTicketDto, CreateInvoiceDto } from './dto/after-sale.dto';
@@ -204,8 +204,8 @@ export class PartnerProxyOrderController {
constructor(private readonly tradeService: TradeService) {}
@Get('options')
options() {
return this.tradeService.getPartnerProxyOrderOptions();
options(@CurrentUser() user: AuthUser) {
return this.tradeService.getPartnerProxyOrderOptions(user.actorId);
}
@Post('preview')
@@ -213,9 +213,20 @@ export class PartnerProxyOrderController {
return this.tradeService.previewPartnerProxyOrder(dto);
}
/** @deprecated 兼容:转发客户短信 */
@Post('send-sms')
sendSms(@Body() dto: PartnerProxyOrderSendSmsDto) {
return this.tradeService.sendPartnerProxyOrderSms(dto.phone);
sendSms(@Body() dto: PartnerProxyOrderSendCustomerSmsDto) {
return this.tradeService.sendPartnerProxyCustomerSms(dto.phone);
}
@Post('send-customer-sms')
sendCustomerSms(@Body() dto: PartnerProxyOrderSendCustomerSmsDto) {
return this.tradeService.sendPartnerProxyCustomerSms(dto.phone);
}
@Post('send-partner-sms')
sendPartnerSms(@CurrentUser() user: AuthUser) {
return this.tradeService.sendPartnerProxyPartnerSms(user.actorId);
}
@Post()
@@ -1023,10 +1023,25 @@ export class TradeService {
});
}
async getPartnerProxyOrderOptions() {
const [products, promoCodes] = await Promise.all([
async getPartnerProxyOrderOptions(partnerAccountId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const [products, promoCodes, stores] = await Promise.all([
this.catalogService.listProducts(),
this.promoCodeService.listActiveOptions(),
this.prisma.store.findMany({
where: { partnerAccountId: primary.id },
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
address: true,
phone: true,
province: true,
cityName: true,
district: true,
status: true,
},
}),
]);
return serializeBigInt({
products: products.map((p) => ({
@@ -1035,14 +1050,27 @@ export class TradeService {
spec: p.spec,
price: Number(p.price),
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
})),
promoCodes,
stores: stores.map((s) => ({
id: s.id,
name: s.name,
address: s.address,
phone: s.phone,
province: s.province,
cityName: s.cityName,
district: s.district,
})),
});
}
async previewPartnerProxyOrder(body: {
productId: string;
quantity: number;
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
}) {
@@ -1053,14 +1081,23 @@ export class TradeService {
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市');
let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL';
const receiverCity = body.receiverCity?.trim();
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
deliveryType = 'CROSS_CITY';
const deliveryMode = body.deliveryMode ?? 'ADDRESS';
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = 'LOCAL';
if (deliveryMode === 'ON_SITE_PICKUP') {
if (!product.allowOnSitePickup) {
throw new BadRequestException('该商品不支持现场提货');
}
deliveryType = 'ON_SITE_PICKUP';
} else {
const receiverCity = body.receiverCity?.trim();
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
deliveryType = 'CROSS_CITY';
}
}
const check = validateMinPurchase(
deliveryType,
deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : 'LOCAL',
body.quantity,
city.localMinQty,
city.crossMinQty,
@@ -1083,9 +1120,9 @@ export class TradeService {
};
}
async sendPartnerProxyOrderSms(phone: string) {
async sendPartnerProxyCustomerSms(phone: string) {
const normalizedPhone = phone.trim();
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_ORDER, {
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_CUSTOMER, {
clientApp: ClientApp.PARTNER_H5,
});
const masked =
@@ -1095,16 +1132,41 @@ export class TradeService {
return { ok: true, maskedPhone: masked };
}
/** @deprecated 兼容旧前端:转发为客户短信 */
async sendPartnerProxyOrderSms(phone: string) {
return this.sendPartnerProxyCustomerSms(phone);
}
async sendPartnerProxyPartnerSms(partnerAccountId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const partnerPhone = primary.phone?.trim();
if (!partnerPhone || !/^1\d{10}$/.test(partnerPhone)) {
throw new BadRequestException('合伙人手机号无效,无法发送确认验证码');
}
await this.authService.sendSms(partnerPhone, SmsScene.PARTNER_PROXY_ORDER, {
clientApp: ClientApp.PARTNER_H5,
});
const masked =
partnerPhone.length >= 7
? `${partnerPhone.slice(0, 3)}****${partnerPhone.slice(-4)}`
: partnerPhone;
return { ok: true, maskedPhone: masked };
}
async createPartnerProxyOrder(
partnerAccountId: bigint,
body: {
phone: string;
smsCode: string;
customerSmsCode: string;
partnerSmsCode: string;
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
autoReceive?: boolean;
storeId?: string;
receiverName?: string;
province: string;
city: string;
district: string;
addressDetail: string;
province?: string;
city?: string;
district?: string;
addressDetail?: string;
productId: string;
quantity: number;
promoCodeId?: string;
@@ -1112,16 +1174,43 @@ export class TradeService {
req: Request,
) {
const normalizedPhone = body.phone.trim();
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const partnerPhone = primary.phone?.trim();
if (!partnerPhone || !/^1\d{10}$/.test(partnerPhone)) {
throw new BadRequestException('合伙人手机号无效');
}
await this.authService.verifySmsCode(
normalizedPhone,
body.smsCode.trim(),
body.customerSmsCode.trim(),
SmsScene.PARTNER_PROXY_CUSTOMER,
);
await this.authService.verifySmsCode(
partnerPhone,
body.partnerSmsCode.trim(),
SmsScene.PARTNER_PROXY_ORDER,
);
const user = await this.authService.findOrCreateUserByPhone(normalizedPhone);
if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) {
throw new BadRequestException('配送到址须勾选同意自动收货');
}
const maskedPartnerPhone =
partnerPhone.length >= 7
? `${partnerPhone.slice(0, 3)}****${partnerPhone.slice(-4)}`
: partnerPhone;
const user = await this.authService.findOrCreateUserByPhone(normalizedPhone, {
sourceType: 'PARTNER_PROXY',
sourceRefId: primary.id,
sourceLabel: `代下单·${maskedPartnerPhone}`,
});
const preview = await this.previewPartnerProxyOrder({
productId: body.productId,
quantity: body.quantity,
deliveryMode: body.deliveryMode,
storeId: body.storeId,
receiverCity: body.city,
receiverDistrict: body.district,
});
@@ -1130,8 +1219,37 @@ export class TradeService {
where: { id: BigInt(body.productId) },
});
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, body.district);
let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
let receiverProvince = body.province?.trim() || '';
let receiverCity = body.city?.trim() || '';
let receiverDistrict = body.district?.trim() || '';
let receiverAddress = '';
let commissionDistrict = receiverDistrict;
if (body.deliveryMode === 'ON_SITE_PICKUP') {
if (!body.storeId?.trim()) throw new BadRequestException('请选择提货门店');
const store = await this.prisma.store.findFirst({
where: { id: BigInt(body.storeId.trim()), partnerAccountId: primary.id },
});
if (!store) throw new BadRequestException('门店不存在或不属于当前合伙人');
receiverName = store.name;
receiverProvince = store.province;
receiverCity = store.cityName;
receiverDistrict = store.district;
receiverAddress = `现场提货·${store.name}·${store.province}${store.cityName}${store.district}${store.address}`;
commissionDistrict = store.district;
} else {
if (!receiverProvince || !receiverCity || !receiverDistrict) {
throw new BadRequestException('请选择省市区');
}
if (!body.addressDetail?.trim()) {
throw new BadRequestException('请填写详细地址');
}
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
}
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
let promoCodeId: bigint | undefined;
if (body.promoCodeId?.trim()) {
@@ -1139,11 +1257,8 @@ export class TradeService {
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
}
const receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
const receiverAddress = `${body.province}${body.city}${body.district}${body.addressDetail}`;
const orderNo = generateOrderNo();
const now = new Date();
const location = buildOrderClientLocationSnapshot(
req,
this.ipGeoService.resolve(extractClientIp(req)),
@@ -1178,9 +1293,9 @@ export class TradeService {
receiverName,
receiverPhone: normalizedPhone,
receiverAddress,
receiverProvince: body.province,
receiverCity: body.city,
receiverDistrict: body.district,
receiverProvince,
receiverCity,
receiverDistrict,
clientIp: location.clientIp,
ipProvince: location.ipProvince,
ipCity: location.ipCity,
@@ -1190,7 +1305,10 @@ export class TradeService {
completedAt: now,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? primary.id,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
remark: `合伙人代下单 partnerAccountId=${primary.id}`,
proxyPartnerAccountId: primary.id,
proxyPartnerName: primary.name,
proxyPartnerPhone: partnerPhone,
remark: `合伙人代下单 partnerAccountId=${primary.id} deliveryMode=${body.deliveryMode} customer=${normalizedPhone}`,
},
include: { product: true, imageResource: true },
});
@@ -1211,7 +1329,7 @@ export class TradeService {
fromStatus: 'PENDING_PAY',
toStatus: 'COMPLETED',
operator: 'PARTNER_PROXY',
remark: '合伙人线下代下单',
remark: `合伙人线下代下单 mode=${body.deliveryMode} customer=${normalizedPhone} partner=${primary.id}`,
}),
});
@@ -1237,6 +1355,7 @@ export class TradeService {
userId: user.id.toString(),
productId: body.productId,
quantity: body.quantity,
deliveryMode: body.deliveryMode,
promoCodeId: promoCodeId?.toString() ?? null,
},
});
+3 -2
View File
@@ -97,7 +97,7 @@
| SC-04 | 门店核销 | 出码/报手机号→核销→权益扣减→门店账本×60% |
| SC-05 | 拓店入驻 | 合伙人录入→负责人复核→总部审核→试核销100元→营业 |
| SC-06 | 售后工单 | 用户四类型→总部审→仓/合伙人协同→补发/退款 |
| SC-07 | 代下单 | 总部/合伙人手机号建用户下单(Wave 3) |
| SC-07 | 代下单 | 总部/合伙人手机号建用户下单(Wave 3);合伙人侧:双短信确认、线下完成发权益、订单记代下单人;总部本期对齐后续 |
| SC-08 | 问卷+评价 | 成交后问卷;核销后门店评价 |
| SC-09 | 推广归因 | 推广码进小程序→绑定合伙人→统计成交/佣金 |
@@ -403,7 +403,7 @@
| OPT | 波次 | 说明 |
|-----|------|------|
| OPT-002 代下单 | W3 | W2 前运营人工代下单 |
| OPT-002 代下单 | W3 | 合伙人主账号:客户验码→选品/履约(配送须勾选自动收货或现场提货选门店)→合伙人确认码;线下已收款直接完成发权益;新用户 sourceType=PARTNER_PROXY;总部代下单后续对齐 |
| OPT-006 弱网 | W3 | W1~2 重试+人工补核销 |
| OPT-010 未出账提现 | W2 | 含 FIN-001~003 |
| OPT-005 现场提货 | W2 | — |
@@ -432,6 +432,7 @@
| 版本 | 日期 | 说明 |
|------|------|------|
| v3.0.1 | 2026-07-27 | ACC-012/OPT-002/SC-07:明确合伙人代下单双短信、线下完成、来源 PARTNER_PROXY、C 端展示代下单人 |
| v3.0 | 2026-07-11 | 由产品 PRD v1.3 整理为工程 V3.0 事实源;配套 `@dukang-v3` skill 与 `v3-delivery-lead` agent |
---