fix(trade): simplify proxy order and respect product whitelist
Drop SMS verification for HQ/partner proxy orders; keep form draft across product picker; hide whitelist-only products from partner catalog. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,8 +37,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
|
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
|
||||||
const [loadingOptions, setLoadingOptions] = useState(false);
|
const [loadingOptions, setLoadingOptions] = useState(false);
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [customerSmsCode, setCustomerSmsCode] = useState('');
|
|
||||||
const [operatorSmsCode, setOperatorSmsCode] = 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('');
|
||||||
@@ -47,11 +45,8 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
const [promoCodeId, setPromoCodeId] = useState<string>();
|
const [promoCodeId, setPromoCodeId] = useState<string>();
|
||||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
||||||
const [autoReceive, setAutoReceive] = useState(false);
|
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 [customerCooldown, setCustomerCooldown] = useState(0);
|
|
||||||
const [operatorCooldown, setOperatorCooldown] = useState(0);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
|
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
|
||||||
@@ -94,8 +89,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
setPhone('');
|
setPhone('');
|
||||||
setCustomerSmsCode('');
|
|
||||||
setOperatorSmsCode('');
|
|
||||||
setReceiverName('');
|
setReceiverName('');
|
||||||
setRegionCodes([]);
|
setRegionCodes([]);
|
||||||
setAddressDetail('');
|
setAddressDetail('');
|
||||||
@@ -103,10 +96,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
setPromoCodeId(undefined);
|
setPromoCodeId(undefined);
|
||||||
setDeliveryMode('ADDRESS');
|
setDeliveryMode('ADDRESS');
|
||||||
setAutoReceive(false);
|
setAutoReceive(false);
|
||||||
setConfirmStep(false);
|
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setCustomerCooldown(0);
|
|
||||||
setOperatorCooldown(0);
|
|
||||||
setProductId(options?.products[0]?.id);
|
setProductId(options?.products[0]?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,52 +105,8 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startCooldown(setter: (n: number | ((s: number) => number)) => void) {
|
|
||||||
setter(60);
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
setter((s) => {
|
|
||||||
if (s <= 1) {
|
|
||||||
window.clearInterval(timer);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return s - 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendCustomerSms() {
|
|
||||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
|
||||||
message.warning('请输入有效手机号');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await request<{ maskedPhone: string }>('/admin/proxy-orders/send-customer-sms', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ phone: phone.trim() }),
|
|
||||||
});
|
|
||||||
message.success(`客户验证码已发送至 ${res.maskedPhone}`);
|
|
||||||
startCooldown(setCustomerCooldown);
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '发送失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendOperatorSms() {
|
|
||||||
try {
|
|
||||||
const res = await request<{ maskedPhone: string }>('/admin/proxy-orders/send-operator-sms', {
|
|
||||||
method: 'POST',
|
|
||||||
body: '{}',
|
|
||||||
});
|
|
||||||
message.success(`确认验证码已发送至总部手机 ${res.maskedPhone}`);
|
|
||||||
startCooldown(setOperatorCooldown);
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '发送失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateForm(): string | null {
|
function validateForm(): string | null {
|
||||||
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||||
if (!customerSmsCode.trim()) return '请输入客户验证码';
|
|
||||||
if (!productId) return '请选择商品';
|
if (!productId) return '请选择商品';
|
||||||
if (deliveryMode === 'ADDRESS') {
|
if (deliveryMode === 'ADDRESS') {
|
||||||
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
||||||
@@ -174,32 +120,15 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openConfirmStep() {
|
|
||||||
const err = validateForm();
|
|
||||||
if (err) {
|
|
||||||
message.warning(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setConfirmStep(true);
|
|
||||||
setOperatorSmsCode('');
|
|
||||||
await sendOperatorSms();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const err = validateForm();
|
const err = validateForm();
|
||||||
if (err) {
|
if (err) {
|
||||||
message.warning(err);
|
message.warning(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!operatorSmsCode.trim()) {
|
|
||||||
message.warning('请输入总部确认验证码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: HqProxyOrderCreateRequest = {
|
const payload: HqProxyOrderCreateRequest = {
|
||||||
phone: phone.trim(),
|
phone: phone.trim(),
|
||||||
customerSmsCode: customerSmsCode.trim(),
|
|
||||||
operatorSmsCode: operatorSmsCode.trim(),
|
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
|
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
|
||||||
receiverName: receiverName.trim() || undefined,
|
receiverName: receiverName.trim() || undefined,
|
||||||
@@ -243,60 +172,29 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
width={640}
|
width={640}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
footer={
|
footer={
|
||||||
confirmStep ? (
|
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setConfirmStep(false);
|
|
||||||
setOperatorSmsCode('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
返回修改
|
|
||||||
</Button>
|
|
||||||
<Button type="primary" loading={submitting} onClick={() => void submit()}>
|
|
||||||
验证并提交
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
) : (
|
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={handleClose}>取消</Button>
|
<Button onClick={handleClose}>取消</Button>
|
||||||
<Button type="primary" loading={loadingOptions} onClick={() => void openConfirmStep()}>
|
<Button type="primary" loading={submitting || loadingOptions} onClick={() => void submit()}>
|
||||||
确认代下单
|
确认代下单
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
message="线下已收款:提交后订单直接完成并发放好客权益(客户验证码 + 总部确认码)"
|
message="线下已收款:提交后订单直接完成并发放好客权益"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!confirmStep ? (
|
|
||||||
<Form layout="vertical">
|
<Form layout="vertical">
|
||||||
<Form.Item label="用户手机号" required>
|
<Form.Item label="用户手机号" required>
|
||||||
<Space.Compact style={{ width: '100%' }}>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="11 位手机号"
|
placeholder="11 位手机号"
|
||||||
value={phone}
|
value={phone}
|
||||||
maxLength={11}
|
maxLength={11}
|
||||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
||||||
/>
|
/>
|
||||||
<Button disabled={customerCooldown > 0} onClick={() => void sendCustomerSms()}>
|
|
||||||
{customerCooldown > 0 ? `${customerCooldown}s` : '获取验证码'}
|
|
||||||
</Button>
|
|
||||||
</Space.Compact>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="客户验证码" required>
|
|
||||||
<Input
|
|
||||||
placeholder="发至客户手机"
|
|
||||||
value={customerSmsCode}
|
|
||||||
maxLength={6}
|
|
||||||
onChange={(e) => setCustomerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="酒品" required>
|
<Form.Item label="酒品" required>
|
||||||
@@ -315,7 +213,12 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="数量" required>
|
<Form.Item label="数量" required>
|
||||||
<InputNumber min={1} value={quantity} onChange={(v) => setQuantity(Math.max(1, Number(v) || 1))} style={{ width: '100%' }} />
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
value={quantity}
|
||||||
|
onChange={(v) => setQuantity(Math.max(1, Number(v) || 1))}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{(options?.promoCodes.length ?? 0) > 0 ? (
|
{(options?.promoCodes.length ?? 0) > 0 ? (
|
||||||
@@ -395,34 +298,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
) : (
|
|
||||||
<Form layout="vertical">
|
|
||||||
<Alert
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
message={`即将为 ${phone} 创建已完成订单并发放权益,请输入发至总部账号手机的确认验证码`}
|
|
||||||
/>
|
|
||||||
<Form.Item label="总部确认验证码" required>
|
|
||||||
<Space.Compact style={{ width: '100%' }}>
|
|
||||||
<Input
|
|
||||||
placeholder="发至当前登录 HQ 手机"
|
|
||||||
value={operatorSmsCode}
|
|
||||||
maxLength={6}
|
|
||||||
onChange={(e) => setOperatorSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
||||||
/>
|
|
||||||
<Button disabled={operatorCooldown > 0} onClick={() => void sendOperatorSms()}>
|
|
||||||
{operatorCooldown > 0 ? `${operatorCooldown}s` : '重新发送'}
|
|
||||||
</Button>
|
|
||||||
</Space.Compact>
|
|
||||||
</Form.Item>
|
|
||||||
{preview ? (
|
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
|
||||||
{deliveryLabel} · 实付 ¥{fmtMoney(preview.payAmount)} · 权益 ¥{fmtMoney(preview.benefitAmount)}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
) : null}
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { PartnerProxyDeliveryMode } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
const PROXY_ORDER_DRAFT_KEY = 'partner_proxy_order_draft_v2';
|
||||||
|
|
||||||
|
export type ProxyOrderDraft = {
|
||||||
|
phone: string;
|
||||||
|
receiverName: string;
|
||||||
|
regionCodes: string[];
|
||||||
|
addressDetail: string;
|
||||||
|
productId: string;
|
||||||
|
quantity: number;
|
||||||
|
promoCodeId: string;
|
||||||
|
deliveryMode: PartnerProxyDeliveryMode;
|
||||||
|
autoReceive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function saveProxyOrderDraft(draft: ProxyOrderDraft) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(PROXY_ORDER_DRAFT_KEY, JSON.stringify(draft));
|
||||||
|
} catch {
|
||||||
|
/* ignore quota / private mode */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadProxyOrderDraft(): ProxyOrderDraft | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(PROXY_ORDER_DRAFT_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<ProxyOrderDraft>;
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null;
|
||||||
|
return {
|
||||||
|
phone: typeof parsed.phone === 'string' ? parsed.phone : '',
|
||||||
|
receiverName: typeof parsed.receiverName === 'string' ? parsed.receiverName : '',
|
||||||
|
regionCodes: Array.isArray(parsed.regionCodes)
|
||||||
|
? parsed.regionCodes.filter((c): c is string => typeof c === 'string')
|
||||||
|
: [],
|
||||||
|
addressDetail: typeof parsed.addressDetail === 'string' ? parsed.addressDetail : '',
|
||||||
|
productId: typeof parsed.productId === 'string' ? parsed.productId : '',
|
||||||
|
quantity: typeof parsed.quantity === 'number' && parsed.quantity >= 1 ? parsed.quantity : 2,
|
||||||
|
promoCodeId: typeof parsed.promoCodeId === 'string' ? parsed.promoCodeId : '',
|
||||||
|
deliveryMode: parsed.deliveryMode === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'ADDRESS',
|
||||||
|
autoReceive: parsed.autoReceive === true,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearProxyOrderDraft() {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(PROXY_ORDER_DRAFT_KEY);
|
||||||
|
sessionStorage.removeItem('partner_proxy_order_draft_v1');
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ 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 {
|
||||||
|
clearProxyOrderDraft,
|
||||||
|
loadProxyOrderDraft,
|
||||||
|
saveProxyOrderDraft,
|
||||||
|
type ProxyOrderDraft,
|
||||||
|
} from '../lib/proxyOrderDraft';
|
||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
import type {
|
import type {
|
||||||
PartnerProxyDeliveryMode,
|
PartnerProxyDeliveryMode,
|
||||||
@@ -16,33 +22,60 @@ function fmtMoney(n: number) {
|
|||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initialDraft(): ProxyOrderDraft {
|
||||||
|
return (
|
||||||
|
loadProxyOrderDraft() ?? {
|
||||||
|
phone: '',
|
||||||
|
receiverName: '',
|
||||||
|
regionCodes: [],
|
||||||
|
addressDetail: '',
|
||||||
|
productId: '',
|
||||||
|
quantity: 2,
|
||||||
|
promoCodeId: '',
|
||||||
|
deliveryMode: 'ADDRESS',
|
||||||
|
autoReceive: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProxyOrderPage() {
|
export default function ProxyOrderPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const draft0 = useMemo(() => initialDraft(), []);
|
||||||
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(draft0.phone);
|
||||||
const [customerSmsCode, setCustomerSmsCode] = useState('');
|
const [receiverName, setReceiverName] = useState(draft0.receiverName);
|
||||||
const [partnerSmsCode, setPartnerSmsCode] = useState('');
|
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
|
||||||
const [receiverName, setReceiverName] = useState('');
|
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
|
||||||
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
const [productId, setProductId] = useState(draft0.productId);
|
||||||
const [addressDetail, setAddressDetail] = useState('');
|
const [quantity, setQuantity] = useState(draft0.quantity);
|
||||||
const [productId, setProductId] = useState('');
|
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
||||||
const [quantity, setQuantity] = useState(2);
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
||||||
const [promoCodeId, setPromoCodeId] = useState('');
|
const [autoReceive, setAutoReceive] = useState(draft0.autoReceive);
|
||||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
|
||||||
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 [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('');
|
||||||
|
|
||||||
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
|
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
|
||||||
const regionLabel = region ? formatRegionLabel(region) : '';
|
const regionLabel = region ? formatRegionLabel(region) : '';
|
||||||
|
|
||||||
|
function persistDraft(overrides?: Partial<ProxyOrderDraft>) {
|
||||||
|
saveProxyOrderDraft({
|
||||||
|
phone,
|
||||||
|
receiverName,
|
||||||
|
regionCodes,
|
||||||
|
addressDetail,
|
||||||
|
productId,
|
||||||
|
quantity,
|
||||||
|
promoCodeId,
|
||||||
|
deliveryMode,
|
||||||
|
autoReceive,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
|
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -53,6 +86,7 @@ export default function ProxyOrderPage() {
|
|||||||
const fromQuery = searchParams.get('productId');
|
const fromQuery = searchParams.get('productId');
|
||||||
if (fromQuery && data.products.some((p) => p.id === fromQuery)) {
|
if (fromQuery && data.products.some((p) => p.id === fromQuery)) {
|
||||||
setProductId(fromQuery);
|
setProductId(fromQuery);
|
||||||
|
persistDraft({ productId: fromQuery });
|
||||||
} else if (data.products[0] && !productId) {
|
} else if (data.products[0] && !productId) {
|
||||||
setProductId(data.products[0].id);
|
setProductId(data.products[0].id);
|
||||||
}
|
}
|
||||||
@@ -64,9 +98,28 @@ export default function ProxyOrderPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fromQuery = searchParams.get('productId');
|
const fromQuery = searchParams.get('productId');
|
||||||
if (fromQuery) setProductId(fromQuery);
|
if (fromQuery) {
|
||||||
|
setProductId(fromQuery);
|
||||||
|
persistDraft({ productId: fromQuery });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync productId from URL
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
persistDraft();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- keep draft in sync for product-picker roundtrip
|
||||||
|
}, [
|
||||||
|
phone,
|
||||||
|
receiverName,
|
||||||
|
regionCodes,
|
||||||
|
addressDetail,
|
||||||
|
productId,
|
||||||
|
quantity,
|
||||||
|
promoCodeId,
|
||||||
|
deliveryMode,
|
||||||
|
autoReceive,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!productId || quantity < 1) {
|
if (!productId || quantity < 1) {
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
@@ -92,64 +145,10 @@ export default function ProxyOrderPage() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
|
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
|
||||||
|
|
||||||
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-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 {
|
function validateForm(): string | null {
|
||||||
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||||
if (!customerSmsCode.trim()) return '请输入客户验证码';
|
|
||||||
if (!productId) return '请选择商品';
|
if (!productId) return '请选择商品';
|
||||||
if (deliveryMode !== 'ADDRESS') {
|
if (deliveryMode === 'ADDRESS') {
|
||||||
// 现场提货不需要地址与门店
|
|
||||||
} else {
|
|
||||||
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
||||||
if (!addressDetail.trim()) return '请填写详细地址';
|
if (!addressDetail.trim()) return '请填写详细地址';
|
||||||
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
||||||
@@ -158,18 +157,6 @@ export default function ProxyOrderPage() {
|
|||||||
return null;
|
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('');
|
||||||
const err = validateForm();
|
const err = validateForm();
|
||||||
@@ -177,15 +164,9 @@ export default function ProxyOrderPage() {
|
|||||||
setMsg(err);
|
setMsg(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!partnerSmsCode.trim()) {
|
|
||||||
setMsg('请输入合伙人确认验证码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: PartnerProxyOrderCreateRequest = {
|
const payload: PartnerProxyOrderCreateRequest = {
|
||||||
phone: phone.trim(),
|
phone: phone.trim(),
|
||||||
customerSmsCode: customerSmsCode.trim(),
|
|
||||||
partnerSmsCode: partnerSmsCode.trim(),
|
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
|
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
|
||||||
receiverName: receiverName.trim() || undefined,
|
receiverName: receiverName.trim() || undefined,
|
||||||
@@ -206,6 +187,7 @@ export default function ProxyOrderPage() {
|
|||||||
silent: true,
|
silent: true,
|
||||||
});
|
});
|
||||||
toastSuccess(`代下单成功:${order.orderNo}`);
|
toastSuccess(`代下单成功:${order.orderNo}`);
|
||||||
|
clearProxyOrderDraft();
|
||||||
navigate(`/orders/${order.id}`);
|
navigate(`/orders/${order.id}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||||
@@ -233,8 +215,7 @@ export default function ProxyOrderPage() {
|
|||||||
<>
|
<>
|
||||||
<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-row">
|
<div className="partner-input-wrap">
|
||||||
<div className="partner-input-wrap" style={{ flex: 1 }}>
|
|
||||||
<input
|
<input
|
||||||
className="partner-input"
|
className="partner-input"
|
||||||
placeholder="11 位手机号"
|
placeholder="11 位手机号"
|
||||||
@@ -242,27 +223,6 @@ export default function ProxyOrderPage() {
|
|||||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={customerCooldown > 0}
|
|
||||||
onClick={() => void sendCustomerSms()}
|
|
||||||
>
|
|
||||||
{customerCooldown > 0 ? `${customerCooldown}s` : '获取验证码'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="partner-form-section">
|
|
||||||
<label className="partner-form-label">客户验证码</label>
|
|
||||||
<div className="partner-input-wrap">
|
|
||||||
<input
|
|
||||||
className="partner-input"
|
|
||||||
placeholder="发至客户手机"
|
|
||||||
value={customerSmsCode}
|
|
||||||
onChange={(e) => setCustomerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="partner-form-section">
|
<section className="partner-form-section">
|
||||||
@@ -270,11 +230,12 @@ export default function ProxyOrderPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="partner-proxy-product-picker"
|
className="partner-proxy-product-picker"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
|
persistDraft();
|
||||||
navigate(
|
navigate(
|
||||||
`/proxy-order/products${productId ? `?selected=${encodeURIComponent(productId)}` : ''}`,
|
`/proxy-order/products${productId ? `?selected=${encodeURIComponent(productId)}` : ''}`,
|
||||||
)
|
);
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
{selectedProduct ? (
|
{selectedProduct ? (
|
||||||
<>
|
<>
|
||||||
@@ -428,73 +389,24 @@ export default function ProxyOrderPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{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}
|
|
||||||
|
|
||||||
{msg ? (
|
{msg ? (
|
||||||
<p className="partner-form-error" role="alert">
|
<p className="partner-form-error" role="alert">
|
||||||
{msg}
|
{msg}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!confirmStep ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-block"
|
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}
|
disabled={submitting || !preview}
|
||||||
onClick={() => void submit()}
|
onClick={() => void submit()}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
>
|
>
|
||||||
{submitting ? '提交中…' : '验证并提交'}
|
{submitting ? '提交中…' : '确认代下单'}
|
||||||
</button>
|
</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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -96,8 +96,6 @@ export type PartnerProxyOrderPreviewResult = {
|
|||||||
|
|
||||||
export type PartnerProxyOrderCreateRequest = {
|
export type PartnerProxyOrderCreateRequest = {
|
||||||
phone: string;
|
phone: string;
|
||||||
customerSmsCode: string;
|
|
||||||
partnerSmsCode: string;
|
|
||||||
deliveryMode: PartnerProxyDeliveryMode;
|
deliveryMode: PartnerProxyDeliveryMode;
|
||||||
/** 地址配送时必须为 true */
|
/** 地址配送时必须为 true */
|
||||||
autoReceive?: boolean;
|
autoReceive?: boolean;
|
||||||
@@ -112,11 +110,9 @@ export type PartnerProxyOrderCreateRequest = {
|
|||||||
promoCodeId?: string;
|
promoCodeId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 总部代下单(对齐合伙人双短信流程;确认码发至 HQ 登录手机) */
|
/** 总部代下单(线下完成发权益,无需短信验证) */
|
||||||
export type HqProxyOrderCreateRequest = {
|
export type HqProxyOrderCreateRequest = {
|
||||||
phone: string;
|
phone: string;
|
||||||
customerSmsCode: string;
|
|
||||||
operatorSmsCode: string;
|
|
||||||
deliveryMode: PartnerProxyDeliveryMode;
|
deliveryMode: PartnerProxyDeliveryMode;
|
||||||
autoReceive?: boolean;
|
autoReceive?: boolean;
|
||||||
receiverName?: string;
|
receiverName?: string;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
|||||||
export type CatalogViewer = {
|
export type CatalogViewer = {
|
||||||
/** C 端用户手机号;无则无法看到白名单商品 */
|
/** C 端用户手机号;无则无法看到白名单商品 */
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
/** 合伙人代下单等内部场景跳过白名单 */
|
/** 仅总部代下单等运营场景跳过白名单;合伙人端必须遵守白名单 */
|
||||||
bypassWhitelist?: boolean;
|
bypassWhitelist?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
|||||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
import { TradeService } from '../trade/trade.service';
|
import { TradeService } from '../trade/trade.service';
|
||||||
import {
|
import { HqProxyOrderCreateDto, HqProxyOrderPreviewDto } from './dto/hq-proxy-order.dto';
|
||||||
HqProxyOrderCreateDto,
|
|
||||||
HqProxyOrderPreviewDto,
|
|
||||||
HqProxyOrderSendCustomerSmsDto,
|
|
||||||
} from './dto/hq-proxy-order.dto';
|
|
||||||
|
|
||||||
@Controller('admin/proxy-orders')
|
@Controller('admin/proxy-orders')
|
||||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||||
@@ -29,17 +25,7 @@ export class AdminProxyOrdersController {
|
|||||||
|
|
||||||
@Post('preview')
|
@Post('preview')
|
||||||
preview(@Body() dto: HqProxyOrderPreviewDto) {
|
preview(@Body() dto: HqProxyOrderPreviewDto) {
|
||||||
return this.tradeService.previewPartnerProxyOrder(dto);
|
return this.tradeService.previewPartnerProxyOrder(dto, { bypassWhitelist: true });
|
||||||
}
|
|
||||||
|
|
||||||
@Post('send-customer-sms')
|
|
||||||
sendCustomerSms(@Body() dto: HqProxyOrderSendCustomerSmsDto) {
|
|
||||||
return this.tradeService.sendHqProxyCustomerSms(dto.phone);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('send-operator-sms')
|
|
||||||
sendOperatorSms(@CurrentUser() user: AuthUser) {
|
|
||||||
return this.tradeService.sendHqProxyOperatorSms(user.actorId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
|||||||
@@ -35,26 +35,11 @@ export class HqProxyOrderPreviewDto {
|
|||||||
receiverDistrict?: string;
|
receiverDistrict?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HqProxyOrderSendCustomerSmsDto {
|
|
||||||
@IsString()
|
|
||||||
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
|
|
||||||
phone: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HqProxyOrderCreateDto {
|
export class HqProxyOrderCreateDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
|
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
customerSmsCode: string;
|
|
||||||
|
|
||||||
/** 发至当前 HQ 登录手机号的确认验证码 */
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
operatorSmsCode: string;
|
|
||||||
|
|
||||||
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
|
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
|
||||||
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
|
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||||
|
|
||||||
|
|||||||
@@ -39,25 +39,11 @@ export class PartnerProxyOrderPreviewDto {
|
|||||||
receiverDistrict?: string;
|
receiverDistrict?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PartnerProxyOrderSendCustomerSmsDto {
|
|
||||||
@IsString()
|
|
||||||
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
|
|
||||||
phone: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PartnerProxyOrderCreateDto {
|
export class PartnerProxyOrderCreateDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
|
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
customerSmsCode: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
partnerSmsCode: string;
|
|
||||||
|
|
||||||
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
|
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
|
||||||
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
|
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
|||||||
import {
|
import {
|
||||||
PartnerProxyOrderCreateDto,
|
PartnerProxyOrderCreateDto,
|
||||||
PartnerProxyOrderPreviewDto,
|
PartnerProxyOrderPreviewDto,
|
||||||
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';
|
||||||
@@ -210,24 +209,8 @@ export class PartnerProxyOrderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('preview')
|
@Post('preview')
|
||||||
preview(@Body() dto: PartnerProxyOrderPreviewDto) {
|
preview(@CurrentUser() user: AuthUser, @Body() dto: PartnerProxyOrderPreviewDto) {
|
||||||
return this.tradeService.previewPartnerProxyOrder(dto);
|
return this.tradeService.previewPartnerProxyOrderForPartner(user.actorId, dto);
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated 兼容:转发客户短信 */
|
|
||||||
@Post('send-sms')
|
|
||||||
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()
|
@Post()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
orderTabToStatuses,
|
orderTabToStatuses,
|
||||||
validateMinPurchase,
|
validateMinPurchase,
|
||||||
} from '@dukang/domain';
|
} from '@dukang/domain';
|
||||||
import { loadAppConfig, ClientApp, SmsScene, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
|
import { loadAppConfig, ClientApp, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
@@ -1050,7 +1050,8 @@ export class TradeService {
|
|||||||
async getPartnerProxyOrderOptions(partnerAccountId: bigint) {
|
async getPartnerProxyOrderOptions(partnerAccountId: bigint) {
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
const [products, promoCodes, stores] = await Promise.all([
|
const [products, promoCodes, stores] = await Promise.all([
|
||||||
this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }),
|
// 遵守商品白名单:未开放的测试酒不对合伙人可见
|
||||||
|
this.catalogService.listProducts(undefined, undefined, { phone: primary.phone }),
|
||||||
this.promoCodeService.listActiveOptions(),
|
this.promoCodeService.listActiveOptions(),
|
||||||
this.prisma.store.findMany({
|
this.prisma.store.findMany({
|
||||||
where: { partnerAccountId: primary.id },
|
where: { partnerAccountId: primary.id },
|
||||||
@@ -1090,17 +1091,18 @@ export class TradeService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async previewPartnerProxyOrder(body: {
|
async previewPartnerProxyOrder(
|
||||||
|
body: {
|
||||||
productId: string;
|
productId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
|
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||||
storeId?: string;
|
storeId?: string;
|
||||||
receiverCity?: string;
|
receiverCity?: string;
|
||||||
receiverDistrict?: string;
|
receiverDistrict?: string;
|
||||||
}) {
|
},
|
||||||
const product = await this.catalogService.getProduct(BigInt(body.productId), {
|
viewer?: { phone?: string | null; bypassWhitelist?: boolean },
|
||||||
bypassWhitelist: true,
|
) {
|
||||||
});
|
const product = await this.catalogService.getProduct(BigInt(body.productId), viewer ?? {});
|
||||||
if (!product || product.status !== 'ON_SALE') {
|
if (!product || product.status !== 'ON_SALE') {
|
||||||
throw new BadRequestException('商品不可购买');
|
throw new BadRequestException('商品不可购买');
|
||||||
}
|
}
|
||||||
@@ -1146,45 +1148,26 @@ export class TradeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendPartnerProxyCustomerSms(phone: string) {
|
/** 合伙人代下单预览:按合伙人手机号遵守商品白名单 */
|
||||||
const normalizedPhone = phone.trim();
|
async previewPartnerProxyOrderForPartner(
|
||||||
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_CUSTOMER, {
|
partnerAccountId: bigint,
|
||||||
clientApp: ClientApp.PARTNER_H5,
|
body: {
|
||||||
});
|
productId: string;
|
||||||
const masked =
|
quantity: number;
|
||||||
normalizedPhone.length >= 7
|
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||||
? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}`
|
storeId?: string;
|
||||||
: normalizedPhone;
|
receiverCity?: string;
|
||||||
return { ok: true, maskedPhone: masked };
|
receiverDistrict?: string;
|
||||||
}
|
},
|
||||||
|
) {
|
||||||
/** @deprecated 兼容旧前端:转发为客户短信 */
|
|
||||||
async sendPartnerProxyOrderSms(phone: string) {
|
|
||||||
return this.sendPartnerProxyCustomerSms(phone);
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendPartnerProxyPartnerSms(partnerAccountId: bigint) {
|
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
const partnerPhone = primary.phone?.trim();
|
return this.previewPartnerProxyOrder(body, { phone: primary.phone });
|
||||||
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;
|
||||||
customerSmsCode: string;
|
|
||||||
partnerSmsCode: string;
|
|
||||||
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
|
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||||
autoReceive?: boolean;
|
autoReceive?: boolean;
|
||||||
storeId?: string;
|
storeId?: string;
|
||||||
@@ -1200,23 +1183,15 @@ export class TradeService {
|
|||||||
req: Request,
|
req: Request,
|
||||||
) {
|
) {
|
||||||
const normalizedPhone = body.phone.trim();
|
const normalizedPhone = body.phone.trim();
|
||||||
|
if (!/^1\d{10}$/.test(normalizedPhone)) {
|
||||||
|
throw new BadRequestException('请输入有效手机号');
|
||||||
|
}
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
const partnerPhone = primary.phone?.trim();
|
const partnerPhone = primary.phone?.trim();
|
||||||
if (!partnerPhone || !/^1\d{10}$/.test(partnerPhone)) {
|
if (!partnerPhone || !/^1\d{10}$/.test(partnerPhone)) {
|
||||||
throw new BadRequestException('合伙人手机号无效');
|
throw new BadRequestException('合伙人手机号无效');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.authService.verifySmsCode(
|
|
||||||
normalizedPhone,
|
|
||||||
body.customerSmsCode.trim(),
|
|
||||||
SmsScene.PARTNER_PROXY_CUSTOMER,
|
|
||||||
);
|
|
||||||
await this.authService.verifySmsCode(
|
|
||||||
partnerPhone,
|
|
||||||
body.partnerSmsCode.trim(),
|
|
||||||
SmsScene.PARTNER_PROXY_ORDER,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) {
|
if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) {
|
||||||
throw new BadRequestException('配送到址须勾选同意自动收货');
|
throw new BadRequestException('配送到址须勾选同意自动收货');
|
||||||
}
|
}
|
||||||
@@ -1232,14 +1207,17 @@ export class TradeService {
|
|||||||
sourceLabel: `代下单·${maskedPartnerPhone}`,
|
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,
|
deliveryMode: body.deliveryMode,
|
||||||
storeId: body.storeId,
|
storeId: body.storeId,
|
||||||
receiverCity: body.city,
|
receiverCity: body.city,
|
||||||
receiverDistrict: body.district,
|
receiverDistrict: body.district,
|
||||||
});
|
},
|
||||||
|
{ phone: primary.phone },
|
||||||
|
);
|
||||||
|
|
||||||
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
|
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
|
||||||
where: { id: BigInt(body.productId) },
|
where: { id: BigInt(body.productId) },
|
||||||
@@ -1384,7 +1362,7 @@ export class TradeService {
|
|||||||
return this.getPartnerOrder(partnerAccountId, order.id);
|
return this.getPartnerOrder(partnerAccountId, order.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HQ 代下单:商品/推广码选项(不绑定合伙人门店) */
|
/** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */
|
||||||
async getHqProxyOrderOptions() {
|
async getHqProxyOrderOptions() {
|
||||||
const [products, promoCodes] = await Promise.all([
|
const [products, promoCodes] = await Promise.all([
|
||||||
this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }),
|
this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }),
|
||||||
@@ -1405,43 +1383,10 @@ export class TradeService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendHqProxyCustomerSms(phone: string) {
|
|
||||||
const normalizedPhone = phone.trim();
|
|
||||||
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_CUSTOMER, {
|
|
||||||
clientApp: ClientApp.HQ_WEB,
|
|
||||||
});
|
|
||||||
const masked =
|
|
||||||
normalizedPhone.length >= 7
|
|
||||||
? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}`
|
|
||||||
: normalizedPhone;
|
|
||||||
return { ok: true, maskedPhone: masked };
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendHqProxyOperatorSms(hqAccountId: bigint) {
|
|
||||||
const hq = await this.prisma.hqAccount.findUnique({ where: { id: hqAccountId } });
|
|
||||||
if (!hq || hq.status !== 'ACTIVE') {
|
|
||||||
throw new BadRequestException('总部账号无效');
|
|
||||||
}
|
|
||||||
const operatorPhone = hq.phone?.trim();
|
|
||||||
if (!operatorPhone || !/^1\d{10}$/.test(operatorPhone)) {
|
|
||||||
throw new BadRequestException('总部账号手机号无效,无法发送确认验证码');
|
|
||||||
}
|
|
||||||
await this.authService.sendSms(operatorPhone, SmsScene.PARTNER_PROXY_ORDER, {
|
|
||||||
clientApp: ClientApp.HQ_WEB,
|
|
||||||
});
|
|
||||||
const masked =
|
|
||||||
operatorPhone.length >= 7
|
|
||||||
? `${operatorPhone.slice(0, 3)}****${operatorPhone.slice(-4)}`
|
|
||||||
: operatorPhone;
|
|
||||||
return { ok: true, maskedPhone: masked };
|
|
||||||
}
|
|
||||||
|
|
||||||
async createHqProxyOrder(
|
async createHqProxyOrder(
|
||||||
hqAccountId: bigint,
|
hqAccountId: bigint,
|
||||||
body: {
|
body: {
|
||||||
phone: string;
|
phone: string;
|
||||||
customerSmsCode: string;
|
|
||||||
operatorSmsCode: string;
|
|
||||||
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
|
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||||
autoReceive?: boolean;
|
autoReceive?: boolean;
|
||||||
receiverName?: string;
|
receiverName?: string;
|
||||||
@@ -1456,6 +1401,9 @@ export class TradeService {
|
|||||||
req: Request,
|
req: Request,
|
||||||
) {
|
) {
|
||||||
const normalizedPhone = body.phone.trim();
|
const normalizedPhone = body.phone.trim();
|
||||||
|
if (!/^1\d{10}$/.test(normalizedPhone)) {
|
||||||
|
throw new BadRequestException('请输入有效手机号');
|
||||||
|
}
|
||||||
const hq = await this.prisma.hqAccount.findUnique({ where: { id: hqAccountId } });
|
const hq = await this.prisma.hqAccount.findUnique({ where: { id: hqAccountId } });
|
||||||
if (!hq || hq.status !== 'ACTIVE') {
|
if (!hq || hq.status !== 'ACTIVE') {
|
||||||
throw new BadRequestException('总部账号无效');
|
throw new BadRequestException('总部账号无效');
|
||||||
@@ -1465,17 +1413,6 @@ export class TradeService {
|
|||||||
throw new BadRequestException('总部账号手机号无效');
|
throw new BadRequestException('总部账号手机号无效');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.authService.verifySmsCode(
|
|
||||||
normalizedPhone,
|
|
||||||
body.customerSmsCode.trim(),
|
|
||||||
SmsScene.PARTNER_PROXY_CUSTOMER,
|
|
||||||
);
|
|
||||||
await this.authService.verifySmsCode(
|
|
||||||
operatorPhone,
|
|
||||||
body.operatorSmsCode.trim(),
|
|
||||||
SmsScene.PARTNER_PROXY_ORDER,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) {
|
if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) {
|
||||||
throw new BadRequestException('配送到址须勾选同意自动收货');
|
throw new BadRequestException('配送到址须勾选同意自动收货');
|
||||||
}
|
}
|
||||||
@@ -1492,13 +1429,16 @@ export class TradeService {
|
|||||||
sourceLabel: `总部代下单·${maskedOperatorPhone}`,
|
sourceLabel: `总部代下单·${maskedOperatorPhone}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
deliveryMode: body.deliveryMode,
|
||||||
receiverCity: body.city,
|
receiverCity: body.city,
|
||||||
receiverDistrict: body.district,
|
receiverDistrict: body.district,
|
||||||
});
|
},
|
||||||
|
{ bypassWhitelist: true },
|
||||||
|
);
|
||||||
|
|
||||||
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
|
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
|
||||||
where: { id: BigInt(body.productId) },
|
where: { id: BigInt(body.productId) },
|
||||||
|
|||||||
Reference in New Issue
Block a user