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