Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5fe8b903a | |||
| c6b01def4c | |||
| 63fcf33416 |
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -140,6 +141,14 @@ export default function OrderTrackDrawer({
|
|||||||
</Space>
|
</Space>
|
||||||
</Image.PreviewGroup>
|
</Image.PreviewGroup>
|
||||||
</div>
|
</div>
|
||||||
|
) : nodes.some((n) => n.statusName?.includes('签收') || n.trackInfo?.includes('签收')) ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="暂无签收照片"
|
||||||
|
description="小飞侠 100108 未返回图片(可能是本人签收未拍照,或照片已过期)。可在「小飞侠联调」用运单号复测。"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
|||||||
@@ -721,7 +721,15 @@ export default function OrdersPage() {
|
|||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
<span>{row.productName || '—'}</span>
|
<span>{row.productName || '—'}</span>
|
||||||
{row.fulfillmentHold ? <Tag color="orange">大单</Tag> : null}
|
{row.fulfillmentHold ? (
|
||||||
|
<Tag color="orange">
|
||||||
|
{row.fulfillmentHoldReason === 'LARGE_ORDER_GE_10_BOXES'
|
||||||
|
? '大单'
|
||||||
|
: row.fulfillmentHoldReason === 'COURIER_OUT_OF_SERVICE'
|
||||||
|
? '超区'
|
||||||
|
: '推单失败'}
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
{row.orderType === 'PROXY' || row.isProxyOrder ? (
|
{row.orderType === 'PROXY' || row.isProxyOrder ? (
|
||||||
<Tag color="purple">代下单</Tag>
|
<Tag color="purple">代下单</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1023,7 +1031,7 @@ export default function OrdersPage() {
|
|||||||
) : <span />}
|
) : <span />}
|
||||||
<Space size={12} wrap>
|
<Space size={12} wrap>
|
||||||
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
|
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
|
||||||
<Checkbox>大单拦截</Checkbox>
|
<Checkbox>履约拦截</Checkbox>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1133,7 +1141,7 @@ export default function OrdersPage() {
|
|||||||
{detail.fulfillmentHold ? (
|
{detail.fulfillmentHold ? (
|
||||||
<Tag color="orange">
|
<Tag color="orange">
|
||||||
{FULFILLMENT_HOLD_REASON_LABELS[detail.fulfillmentHoldReason || ''] ||
|
{FULFILLMENT_HOLD_REASON_LABELS[detail.fulfillmentHoldReason || ''] ||
|
||||||
'大单待确认'}
|
'履约待确认'}
|
||||||
</Tag>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
{detail.orderType === 'PROXY' || detail.isProxyOrder ? (
|
{detail.orderType === 'PROXY' || detail.isProxyOrder ? (
|
||||||
@@ -1530,10 +1538,16 @@ export default function OrdersPage() {
|
|||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
message="大单已拦截自动推小飞侠"
|
message={
|
||||||
|
shipTarget.fulfillmentHoldReason === 'LARGE_ORDER_GE_10_BOXES'
|
||||||
|
? '大单已拦截自动推小飞侠'
|
||||||
|
: shipTarget.fulfillmentHoldReason === 'COURIER_OUT_OF_SERVICE'
|
||||||
|
? '小飞侠超出服务区'
|
||||||
|
: '自动推配送失败'
|
||||||
|
}
|
||||||
description={
|
description={
|
||||||
FULFILLMENT_HOLD_REASON_LABELS[shipTarget.fulfillmentHoldReason || ''] ||
|
FULFILLMENT_HOLD_REASON_LABELS[shipTarget.fulfillmentHoldReason || ''] ||
|
||||||
'≥10箱订单需总部确认:可选仓推小飞侠,或改用快递自配送。'
|
'请确认后:可选仓重推小飞侠,或改用快递自配送。'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
|
Alert, Button, Card, Col, Descriptions, Form, Image, Input, InputNumber, Row, Select, Space,
|
||||||
Tabs, Tag, Typography, message,
|
Tabs, Tag, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
@@ -53,6 +53,22 @@ const CREATE_DEFAULTS = {
|
|||||||
|
|
||||||
function ResultPanel({ result }: { result: ApiResult | null }) {
|
function ResultPanel({ result }: { result: ApiResult | null }) {
|
||||||
if (!result) return <Typography.Text type="secondary">点击「调用接口」后在此显示响应</Typography.Text>;
|
if (!result) return <Typography.Text type="secondary">点击「调用接口」后在此显示响应</Typography.Text>;
|
||||||
|
const display = (() => {
|
||||||
|
if (!result.data || typeof result.data !== 'object') return result;
|
||||||
|
const data = result.data as { count?: number; data?: string[] };
|
||||||
|
if (!Array.isArray(data.data)) return result;
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
data: data.data.map((item) =>
|
||||||
|
typeof item === 'string' && item.length > 120
|
||||||
|
? `${item.slice(0, 80)}…(len=${item.length})`
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 8 }}>
|
<Space style={{ marginBottom: 8 }}>
|
||||||
@@ -64,7 +80,7 @@ function ResultPanel({ result }: { result: ApiResult | null }) {
|
|||||||
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
||||||
maxHeight: 360, overflow: 'auto', fontSize: 12,
|
maxHeight: 360, overflow: 'auto', fontSize: 12,
|
||||||
}}>
|
}}>
|
||||||
{JSON.stringify(result, null, 2)}
|
{JSON.stringify(display, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -81,7 +97,9 @@ export default function XiaofeixiaTestPage() {
|
|||||||
const [queryForm] = Form.useForm();
|
const [queryForm] = Form.useForm();
|
||||||
const [batchForm] = Form.useForm();
|
const [batchForm] = Form.useForm();
|
||||||
const [trackForm] = Form.useForm();
|
const [trackForm] = Form.useForm();
|
||||||
|
const [signPhotoForm] = Form.useForm();
|
||||||
const [cancelForm] = Form.useForm();
|
const [cancelForm] = Form.useForm();
|
||||||
|
const [signPhotoPreview, setSignPhotoPreview] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<XfxConfig>('/admin/courier/xiaofeixia/config').then(setConfig);
|
void request<XfxConfig>('/admin/courier/xiaofeixia/config').then(setConfig);
|
||||||
@@ -99,12 +117,19 @@ export default function XiaofeixiaTestPage() {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
setResult(res);
|
setResult(res);
|
||||||
|
if (path.endsWith('/get-sign-photos')) {
|
||||||
|
const data = (res.data as { data?: string[] } | undefined)?.data;
|
||||||
|
setSignPhotoPreview(Array.isArray(data) ? data.filter((u) => typeof u === 'string') : []);
|
||||||
|
} else {
|
||||||
|
setSignPhotoPreview([]);
|
||||||
|
}
|
||||||
if (res.ok) message.success('调用成功');
|
if (res.ok) message.success('调用成功');
|
||||||
else message.warning(res.error || '调用失败');
|
else message.warning(res.error || '调用失败');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e.message : String(e);
|
const err = e instanceof Error ? e.message : String(e);
|
||||||
message.error(err);
|
message.error(err);
|
||||||
setResult({ ok: false, elapsedMs: 0, error: err });
|
setResult({ ok: false, elapsedMs: 0, error: err });
|
||||||
|
setSignPhotoPreview([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -118,7 +143,7 @@ export default function XiaofeixiaTestPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>小飞侠接口联调</Typography.Title>
|
<Typography.Title level={4}>小飞侠接口联调</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。凭证请在
|
通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301,含签收图片 100108)。凭证请在
|
||||||
<Link to="/fulfillment-providers">仓配管理</Link>
|
<Link to="/fulfillment-providers">仓配管理</Link>
|
||||||
中配置;联调会优先使用仓配管理里启用的小飞侠承运商。
|
中配置;联调会优先使用仓配管理里启用的小飞侠承运商。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
@@ -273,6 +298,25 @@ export default function XiaofeixiaTestPage() {
|
|||||||
</Form>
|
</Form>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'signPhotos',
|
||||||
|
label: '签收图片 (100108)',
|
||||||
|
children: (
|
||||||
|
<Form
|
||||||
|
form={signPhotoForm}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={(v) => void invoke('/admin/courier/xiaofeixia/get-sign-photos', v)}
|
||||||
|
>
|
||||||
|
<Form.Item name="trackingNumber" label="运单号 number">
|
||||||
|
<Input placeholder="小飞侠运单号,与商家单号二选一" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="outNumber" label="商家单号 outNumber">
|
||||||
|
<Input placeholder="外部单号,与运单号二选一" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'cancel',
|
key: 'cancel',
|
||||||
label: '取消订单 (100103)',
|
label: '取消订单 (100103)',
|
||||||
@@ -290,6 +334,24 @@ export default function XiaofeixiaTestPage() {
|
|||||||
<Col xs={24} lg={10}>
|
<Col xs={24} lg={10}>
|
||||||
<Card title="响应结果" size="small">
|
<Card title="响应结果" size="small">
|
||||||
<ResultPanel result={result} />
|
<ResultPanel result={result} />
|
||||||
|
{signPhotoPreview.length > 0 ? (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Typography.Text strong>签收照片预览({signPhotoPreview.length})</Typography.Text>
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap style={{ marginTop: 8 }}>
|
||||||
|
{signPhotoPreview.map((url, index) => (
|
||||||
|
<Image
|
||||||
|
key={`${index}-${url.slice(0, 32)}`}
|
||||||
|
src={url}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import { invokeWechatPay } from '@dukang/weixin-sdk';
|
import { invokeWechatPay } from '@dukang/weixin-sdk';
|
||||||
|
import { validateShippingAddress } from '@dukang/domain';
|
||||||
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
||||||
import { getToken, request } from '../lib/api';
|
import { getToken, request } from '../lib/api';
|
||||||
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
|
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
|
||||||
@@ -243,6 +244,13 @@ export default function ProxyOrderPage() {
|
|||||||
if (!allowOnline) return '该商品不支持线上购买';
|
if (!allowOnline) return '该商品不支持线上购买';
|
||||||
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
||||||
if (!addressDetail.trim()) return '请填写详细地址';
|
if (!addressDetail.trim()) return '请填写详细地址';
|
||||||
|
const shipping = validateShippingAddress({
|
||||||
|
province: region.province,
|
||||||
|
city: region.city,
|
||||||
|
district: region.district,
|
||||||
|
detail: addressDetail,
|
||||||
|
});
|
||||||
|
if (!shipping.ok) return shipping.message || '请完善收货地址';
|
||||||
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
||||||
} else if (!allowOnSite) {
|
} else if (!allowOnSite) {
|
||||||
return '该商品不支持现场提货';
|
return '该商品不支持现场提货';
|
||||||
|
|||||||
@@ -53,10 +53,10 @@ export default function OrderQtyControls({ value, unitLabel, onChange }: OrderQt
|
|||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onConfirm={commit}
|
onConfirm={commit}
|
||||||
/>
|
/>
|
||||||
<Text className="order-qty-unit">{unitLabel}</Text>
|
|
||||||
<View className="order-qty-btn" onClick={() => onChange(Math.min(MAX_QTY, current() + 1))}>
|
<View className="order-qty-btn" onClick={() => onChange(Math.min(MAX_QTY, current() + 1))}>
|
||||||
<Text>+</Text>
|
<Text>+</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<Text className="order-qty-unit">{unitLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
getCitiesForPicker,
|
getCitiesForPicker,
|
||||||
getDistricts,
|
getDistricts,
|
||||||
getDistrictsForPicker,
|
getDistrictsForPicker,
|
||||||
|
getDistrictsForShippingPicker,
|
||||||
getProvincesForPicker,
|
getProvincesForPicker,
|
||||||
normalizeRegionSelection,
|
normalizeRegionSelection,
|
||||||
|
normalizeShippingRegionSelection,
|
||||||
toCityLevelRegion,
|
toCityLevelRegion,
|
||||||
type RegionSelection,
|
type RegionSelection,
|
||||||
} from '../lib/region-data';
|
} from '../lib/region-data';
|
||||||
@@ -18,6 +20,8 @@ type RegionPickerProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (region: RegionSelection) => void;
|
onConfirm: (region: RegionSelection) => void;
|
||||||
levels?: 2 | 3;
|
levels?: 2 | 3;
|
||||||
|
/** filter:门店筛选可「全市」;shipping:收货地址禁止伪区县 */
|
||||||
|
mode?: 'filter' | 'shipping';
|
||||||
};
|
};
|
||||||
|
|
||||||
type PickerLevel = 'province' | 'city' | 'district';
|
type PickerLevel = 'province' | 'city' | 'district';
|
||||||
@@ -28,8 +32,13 @@ const ALL_TABS: Array<{ key: PickerLevel; label: string }> = [
|
|||||||
{ key: 'district', label: '区县' },
|
{ key: 'district', label: '区县' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function initialTab(value: RegionSelection, levels: 2 | 3): PickerLevel {
|
function initialTab(value: RegionSelection, levels: 2 | 3, mode: 'filter' | 'shipping'): PickerLevel {
|
||||||
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
|
const normalized =
|
||||||
|
levels === 2
|
||||||
|
? toCityLevelRegion(value)
|
||||||
|
: mode === 'shipping'
|
||||||
|
? normalizeShippingRegionSelection(value)
|
||||||
|
: normalizeRegionSelection(value);
|
||||||
if (levels === 2) {
|
if (levels === 2) {
|
||||||
return normalized.province && normalized.province !== REGION_ALL ? 'city' : 'province';
|
return normalized.province && normalized.province !== REGION_ALL ? 'city' : 'province';
|
||||||
}
|
}
|
||||||
@@ -54,24 +63,35 @@ export default function RegionPicker({
|
|||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
levels = 3,
|
levels = 3,
|
||||||
|
mode = 'filter',
|
||||||
}: RegionPickerProps) {
|
}: RegionPickerProps) {
|
||||||
const [draft, setDraft] = useState<RegionSelection>(value);
|
const [draft, setDraft] = useState<RegionSelection>(value);
|
||||||
const [activeTab, setActiveTab] = useState<PickerLevel>('province');
|
const [activeTab, setActiveTab] = useState<PickerLevel>('province');
|
||||||
|
|
||||||
const tabs = levels === 2 ? ALL_TABS.slice(0, 2) : ALL_TABS;
|
const tabs = levels === 2 ? ALL_TABS.slice(0, 2) : ALL_TABS;
|
||||||
|
const shipping = mode === 'shipping';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
|
const normalized =
|
||||||
|
levels === 2
|
||||||
|
? toCityLevelRegion(value)
|
||||||
|
: shipping
|
||||||
|
? normalizeShippingRegionSelection(value)
|
||||||
|
: normalizeRegionSelection(value);
|
||||||
setDraft(normalized);
|
setDraft(normalized);
|
||||||
setActiveTab(initialTab(value, levels));
|
setActiveTab(initialTab(value, levels, mode));
|
||||||
}, [open, value, levels]);
|
}, [open, value, levels, mode, shipping]);
|
||||||
|
|
||||||
const listItems = useMemo(() => {
|
const listItems = useMemo(() => {
|
||||||
if (activeTab === 'province') return getProvincesForPicker();
|
if (activeTab === 'province') return getProvincesForPicker();
|
||||||
if (activeTab === 'city') return getCitiesForPicker(draft.province);
|
if (activeTab === 'city') {
|
||||||
|
if (shipping) return getCities(draft.province);
|
||||||
|
return getCitiesForPicker(draft.province);
|
||||||
|
}
|
||||||
|
if (shipping) return getDistrictsForShippingPicker(draft.province, draft.city);
|
||||||
return getDistrictsForPicker(draft.province, draft.city);
|
return getDistrictsForPicker(draft.province, draft.city);
|
||||||
}, [activeTab, draft.province, draft.city]);
|
}, [activeTab, draft.province, draft.city, shipping]);
|
||||||
|
|
||||||
const selectedValue =
|
const selectedValue =
|
||||||
activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district;
|
activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district;
|
||||||
@@ -79,12 +99,19 @@ export default function RegionPicker({
|
|||||||
const canConfirm =
|
const canConfirm =
|
||||||
levels === 2
|
levels === 2
|
||||||
? Boolean(draft.province && draft.city)
|
? Boolean(draft.province && draft.city)
|
||||||
: Boolean(draft.province && draft.city && draft.district);
|
: shipping
|
||||||
|
? Boolean(
|
||||||
|
draft.province &&
|
||||||
|
draft.city &&
|
||||||
|
draft.district &&
|
||||||
|
draft.district !== REGION_ALL,
|
||||||
|
)
|
||||||
|
: Boolean(draft.province && draft.city && draft.district);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
function selectProvince(province: string) {
|
function selectProvince(province: string) {
|
||||||
if (province === REGION_ALL) {
|
if (!shipping && province === REGION_ALL) {
|
||||||
setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL });
|
setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL });
|
||||||
setActiveTab('city');
|
setActiveTab('city');
|
||||||
return;
|
return;
|
||||||
@@ -97,12 +124,16 @@ export default function RegionPicker({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextDistricts = getDistricts(province, city);
|
const nextDistricts = getDistricts(province, city);
|
||||||
setDraft({ province, city, district: nextDistricts[0] ?? '' });
|
setDraft({
|
||||||
|
province,
|
||||||
|
city,
|
||||||
|
district: shipping ? '' : (nextDistricts[0] ?? ''),
|
||||||
|
});
|
||||||
setActiveTab('city');
|
setActiveTab('city');
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectCity(city: string) {
|
function selectCity(city: string) {
|
||||||
if (city === REGION_ALL) {
|
if (!shipping && city === REGION_ALL) {
|
||||||
setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL });
|
setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL });
|
||||||
if (levels === 3) setActiveTab('district');
|
if (levels === 3) setActiveTab('district');
|
||||||
return;
|
return;
|
||||||
@@ -112,11 +143,16 @@ export default function RegionPicker({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextDistricts = getDistricts(draft.province, city);
|
const nextDistricts = getDistricts(draft.province, city);
|
||||||
setDraft({ ...draft, city, district: nextDistricts[0] ?? '' });
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
city,
|
||||||
|
district: shipping ? '' : (nextDistricts[0] ?? ''),
|
||||||
|
});
|
||||||
setActiveTab('district');
|
setActiveTab('district');
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectDistrict(district: string) {
|
function selectDistrict(district: string) {
|
||||||
|
if (shipping && district === REGION_ALL) return;
|
||||||
setDraft({ ...draft, district });
|
setDraft({ ...draft, district });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +170,13 @@ export default function RegionPicker({
|
|||||||
|
|
||||||
function handleConfirm() {
|
function handleConfirm() {
|
||||||
if (!canConfirm) return;
|
if (!canConfirm) return;
|
||||||
const next = levels === 2 ? toCityLevelRegion(draft) : normalizeRegionSelection(draft);
|
const next =
|
||||||
|
levels === 2
|
||||||
|
? toCityLevelRegion(draft)
|
||||||
|
: shipping
|
||||||
|
? normalizeShippingRegionSelection(draft)
|
||||||
|
: normalizeRegionSelection(draft);
|
||||||
|
if (shipping && (!next.district || next.district === REGION_ALL)) return;
|
||||||
onConfirm(next);
|
onConfirm(next);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export function getDistrictsForPicker(province: string, city: string): string[]
|
|||||||
return [REGION_ALL, ...getDistricts(province, city)];
|
return [REGION_ALL, ...getDistricts(province, city)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 收货地址:不含「全市」 */
|
||||||
|
export function getDistrictsForShippingPicker(province: string, city: string): string[] {
|
||||||
|
if (province === REGION_ALL || city === REGION_ALL) return [];
|
||||||
|
return [...getDistricts(province, city)];
|
||||||
|
}
|
||||||
|
|
||||||
export function formatRegion(province: string, city: string, district: string): string {
|
export function formatRegion(province: string, city: string, district: string): string {
|
||||||
if (!province) return '';
|
if (!province) return '';
|
||||||
if (province === REGION_ALL) return REGION_ALL;
|
if (province === REGION_ALL) return REGION_ALL;
|
||||||
@@ -96,6 +102,35 @@ export const DEFAULT_REGION: RegionSelection = {
|
|||||||
district: REGION_ALL,
|
district: REGION_ALL,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 收货地址默认:不预填伪区县,迫使用户选真实区县 */
|
||||||
|
export const DEFAULT_SHIPPING_REGION: RegionSelection = {
|
||||||
|
province: '河南省',
|
||||||
|
city: '郑州市',
|
||||||
|
district: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 收货场景:区县必须在省市区树内且非全市 */
|
||||||
|
export function normalizeShippingRegionSelection(selection: RegionSelection): RegionSelection {
|
||||||
|
const province = PROVINCES.includes(selection.province)
|
||||||
|
? selection.province
|
||||||
|
: DEFAULT_SHIPPING_REGION.province;
|
||||||
|
const cities = getCities(province);
|
||||||
|
const city = cities.includes(selection.city) ? selection.city : (cities[0] ?? DEFAULT_SHIPPING_REGION.city);
|
||||||
|
const districts = getDistricts(province, city);
|
||||||
|
const district =
|
||||||
|
selection.district &&
|
||||||
|
selection.district !== REGION_ALL &&
|
||||||
|
districts.includes(selection.district)
|
||||||
|
? selection.district
|
||||||
|
: '';
|
||||||
|
return { province, city, district };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isShippingRegionComplete(selection: RegionSelection): boolean {
|
||||||
|
const n = normalizeShippingRegionSelection(selection);
|
||||||
|
return Boolean(n.province && n.city && n.district && n.district !== REGION_ALL);
|
||||||
|
}
|
||||||
|
|
||||||
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
|
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
|
||||||
const cityName = city.endsWith('市') ? city : `${city}市`;
|
const cityName = city.endsWith('市') ? city : `${city}市`;
|
||||||
const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province;
|
const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province;
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
REGION_ALL,
|
||||||
|
isShippingRegionComplete,
|
||||||
|
normalizeShippingRegionSelection,
|
||||||
|
type RegionSelection,
|
||||||
|
} from './region-data';
|
||||||
|
|
||||||
|
export const SHIPPING_DETAIL_MIN_LEN = 8;
|
||||||
|
export const SHIPPING_REGION_REQUIRED_MSG = '请选择具体区县';
|
||||||
|
export const SHIPPING_DETAIL_REQUIRED_MSG = '请填写详细地址';
|
||||||
|
export const SHIPPING_DETAIL_TOO_SHORT_MSG = '请填写更详细的收货地址(含街道门牌)';
|
||||||
|
|
||||||
|
export type ShippingAddressLike = {
|
||||||
|
province?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
district?: string | null;
|
||||||
|
detail?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isDirtyShippingAddress(addr: ShippingAddressLike): boolean {
|
||||||
|
return !validateClientShippingAddress(addr).ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateClientShippingAddress(addr: ShippingAddressLike): {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
} {
|
||||||
|
const province = String(addr.province ?? '').trim();
|
||||||
|
const city = String(addr.city ?? '').trim();
|
||||||
|
const district = String(addr.district ?? '').trim();
|
||||||
|
const detail = String(addr.detail ?? '').trim();
|
||||||
|
|
||||||
|
if (!province || !city) return { ok: false, message: '请选择所在地区' };
|
||||||
|
if (!district || district === REGION_ALL || district === '全部') {
|
||||||
|
return { ok: false, message: SHIPPING_REGION_REQUIRED_MSG };
|
||||||
|
}
|
||||||
|
const regionOk = isShippingRegionComplete({ province, city, district });
|
||||||
|
if (!regionOk) {
|
||||||
|
return { ok: false, message: SHIPPING_REGION_REQUIRED_MSG };
|
||||||
|
}
|
||||||
|
if (!detail) return { ok: false, message: SHIPPING_DETAIL_REQUIRED_MSG };
|
||||||
|
if (detail.length < SHIPPING_DETAIL_MIN_LEN) {
|
||||||
|
return { ok: false, message: SHIPPING_DETAIL_TOO_SHORT_MSG };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shippingDetailCityWideHint(detail: string | null | undefined): string | null {
|
||||||
|
const d = String(detail ?? '').trim();
|
||||||
|
if (!d.includes('全市')) return null;
|
||||||
|
if (/路|街|巷|号|大厦|广场|小区|村|镇|乡/.test(d)) return null;
|
||||||
|
return '详细地址含「全市」,建议改为具体街道门牌,以免配送拒单';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toShippingRegion(addr: ShippingAddressLike): RegionSelection {
|
||||||
|
return normalizeShippingRegionSelection({
|
||||||
|
province: String(addr.province ?? ''),
|
||||||
|
city: String(addr.city ?? ''),
|
||||||
|
district: String(addr.district ?? ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,12 +7,16 @@ import SubPageHeader from '../../components/SubPageHeader';
|
|||||||
import RegionPicker from '../../components/RegionPicker';
|
import RegionPicker from '../../components/RegionPicker';
|
||||||
import { buildAddressListUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
import { buildAddressListUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
||||||
import {
|
import {
|
||||||
DEFAULT_REGION,
|
DEFAULT_SHIPPING_REGION,
|
||||||
REGION_ALL,
|
REGION_ALL,
|
||||||
formatRegion,
|
formatRegion,
|
||||||
type RegionSelection,
|
type RegionSelection,
|
||||||
} from '../../lib/region-data';
|
} from '../../lib/region-data';
|
||||||
import { normalizePhoneInput, validateMobilePhone } from '../../lib/phone';
|
import { normalizePhoneInput, validateMobilePhone } from '../../lib/phone';
|
||||||
|
import {
|
||||||
|
shippingDetailCityWideHint,
|
||||||
|
validateClientShippingAddress,
|
||||||
|
} from '../../lib/shipping-address';
|
||||||
import { getStoredUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
|
import { getStoredUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
|
||||||
import { resolveUserCity } from '../../lib/user-location';
|
import { resolveUserCity } from '../../lib/user-location';
|
||||||
import { request, toast, type UserProfile } from '../../lib/api';
|
import { request, toast, type UserProfile } from '../../lib/api';
|
||||||
@@ -36,12 +40,13 @@ export default function AddressEditPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [locating, setLocating] = useState(false);
|
const [locating, setLocating] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [hint, setHint] = useState('');
|
||||||
const [form, setForm] = useState<AddressForm>(() => ({
|
const [form, setForm] = useState<AddressForm>(() => ({
|
||||||
receiverName: '',
|
receiverName: '',
|
||||||
phone: id ? '' : getStoredUserPhone(),
|
phone: id ? '' : getStoredUserPhone(),
|
||||||
province: DEFAULT_REGION.province,
|
province: DEFAULT_SHIPPING_REGION.province,
|
||||||
city: DEFAULT_REGION.city,
|
city: DEFAULT_SHIPPING_REGION.city,
|
||||||
district: DEFAULT_REGION.district,
|
district: DEFAULT_SHIPPING_REGION.district,
|
||||||
detail: '',
|
detail: '',
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
}));
|
}));
|
||||||
@@ -69,7 +74,7 @@ export default function AddressEditPage() {
|
|||||||
? resolved.region.district
|
? resolved.region.district
|
||||||
: resolved.district && resolved.district !== REGION_ALL
|
: resolved.district && resolved.district !== REGION_ALL
|
||||||
? resolved.district
|
? resolved.district
|
||||||
: DEFAULT_REGION.district;
|
: '';
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
province: resolved.region.province || prev.province,
|
province: resolved.region.province || prev.province,
|
||||||
@@ -91,12 +96,13 @@ export default function AddressEditPage() {
|
|||||||
request<Array<Record<string, unknown>>>('/user/addresses').then((list) => {
|
request<Array<Record<string, unknown>>>('/user/addresses').then((list) => {
|
||||||
const found = list.find((a) => String(a.id) === id);
|
const found = list.find((a) => String(a.id) === id);
|
||||||
if (found) {
|
if (found) {
|
||||||
|
const district = String(found.district ?? '');
|
||||||
setForm({
|
setForm({
|
||||||
receiverName: String(found.receiverName ?? ''),
|
receiverName: String(found.receiverName ?? ''),
|
||||||
phone: String(found.phone ?? ''),
|
phone: String(found.phone ?? ''),
|
||||||
province: String(found.province ?? DEFAULT_REGION.province),
|
province: String(found.province ?? DEFAULT_SHIPPING_REGION.province),
|
||||||
city: String(found.city ?? DEFAULT_REGION.city),
|
city: String(found.city ?? DEFAULT_SHIPPING_REGION.city),
|
||||||
district: String(found.district ?? DEFAULT_REGION.district),
|
district: district === REGION_ALL ? '' : district,
|
||||||
detail: String(found.detail ?? ''),
|
detail: String(found.detail ?? ''),
|
||||||
isDefault: found.isDefault === 1 || found.isDefault === true,
|
isDefault: found.isDefault === 1 || found.isDefault === true,
|
||||||
});
|
});
|
||||||
@@ -104,14 +110,21 @@ export default function AddressEditPage() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const regionText = formatRegion(form.province, form.city, form.district);
|
useEffect(() => {
|
||||||
|
setHint(shippingDetailCityWideHint(form.detail) ?? '');
|
||||||
|
}, [form.detail]);
|
||||||
|
|
||||||
|
const regionText =
|
||||||
|
form.province && form.city && form.district
|
||||||
|
? formatRegion(form.province, form.city, form.district)
|
||||||
|
: '';
|
||||||
|
|
||||||
function validateForm(): string | null {
|
function validateForm(): string | null {
|
||||||
if (!form.receiverName.trim()) return '请输入收货人姓名';
|
if (!form.receiverName.trim()) return '请输入收货人姓名';
|
||||||
const phoneCheck = validateMobilePhone(form.phone);
|
const phoneCheck = validateMobilePhone(form.phone);
|
||||||
if (!phoneCheck.ok) return phoneCheck.message ?? '请输入正确的手机号码';
|
if (!phoneCheck.ok) return phoneCheck.message ?? '请输入正确的手机号码';
|
||||||
if (!form.province || !form.city || !form.district) return '请选择所在地区';
|
const shipping = validateClientShippingAddress(form);
|
||||||
if (!form.detail.trim()) return '请输入详细地址';
|
if (!shipping.ok) return shipping.message ?? '请完善收货地址';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +214,7 @@ export default function AddressEditPage() {
|
|||||||
<Text>
|
<Text>
|
||||||
{locating && !isEdit
|
{locating && !isEdit
|
||||||
? '定位中…'
|
? '定位中…'
|
||||||
: regionText || '请选择省市区'}
|
: regionText || '请选择省 / 市 / 区县'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -228,6 +241,7 @@ export default function AddressEditPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
{hint ? <Text className="address-form-hint">{hint}</Text> : null}
|
||||||
<View className="address-form-row">
|
<View className="address-form-row">
|
||||||
<Text>设为默认地址</Text>
|
<Text>设为默认地址</Text>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -248,6 +262,7 @@ export default function AddressEditPage() {
|
|||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
onConfirm={onRegionConfirm}
|
onConfirm={onRegionConfirm}
|
||||||
levels={3}
|
levels={3}
|
||||||
|
mode="shipping"
|
||||||
/>
|
/>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
buildOrderConfirmUrl,
|
buildOrderConfirmUrl,
|
||||||
readCheckoutContext,
|
readCheckoutContext,
|
||||||
} from '../../lib/checkout-nav';
|
} from '../../lib/checkout-nav';
|
||||||
|
import { isDirtyShippingAddress } from '../../lib/shipping-address';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
|
|
||||||
type Address = {
|
type Address = {
|
||||||
@@ -51,6 +52,11 @@ export default function AddressesPage() {
|
|||||||
|
|
||||||
function selectAddress(addr: Address) {
|
function selectAddress(addr: Address) {
|
||||||
if (!selectMode) return;
|
if (!selectMode) return;
|
||||||
|
if (isDirtyShippingAddress(addr)) {
|
||||||
|
toast('该地址缺少具体区县,请先完善');
|
||||||
|
Taro.navigateTo({ url: buildAddressEditUrl(addr.id, checkoutCtx) }).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
Taro.redirectTo({
|
Taro.redirectTo({
|
||||||
url: buildOrderConfirmUrl({
|
url: buildOrderConfirmUrl({
|
||||||
productId: checkoutCtx.productId,
|
productId: checkoutCtx.productId,
|
||||||
@@ -76,6 +82,12 @@ export default function AddressesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sorted = [...list].sort((a, b) => {
|
||||||
|
const da = isDirtyShippingAddress(a) ? 0 : 1;
|
||||||
|
const db = isDirtyShippingAddress(b) ? 0 : 1;
|
||||||
|
return da - db;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="sub" className="address-page" hasFixedFooter>
|
<PageShell variant="sub" className="address-page" hasFixedFooter>
|
||||||
<SubPageHeader title={selectMode ? '选择收货地址' : '地址管理'} />
|
<SubPageHeader title={selectMode ? '选择收货地址' : '地址管理'} />
|
||||||
@@ -84,46 +96,55 @@ export default function AddressesPage() {
|
|||||||
{!loading && list.length === 0 ? (
|
{!loading && list.length === 0 ? (
|
||||||
<View className="u-empty">暂无收货地址</View>
|
<View className="u-empty">暂无收货地址</View>
|
||||||
) : null}
|
) : null}
|
||||||
{list.map((a) => (
|
{sorted.map((a) => {
|
||||||
<View
|
const dirty = isDirtyShippingAddress(a);
|
||||||
key={a.id}
|
return (
|
||||||
className="address-item"
|
<View
|
||||||
onClick={() => selectAddress(a)}
|
key={a.id}
|
||||||
>
|
className={`address-item${dirty ? ' address-item--dirty' : ''}`}
|
||||||
<View className="address-item-head">
|
onClick={() => selectAddress(a)}
|
||||||
<Text className="address-item-name">{a.receiverName}</Text>
|
>
|
||||||
<Text className="address-item-phone">{a.phone}</Text>
|
<View className="address-item-head">
|
||||||
{a.isDefault === 1 || a.isDefault === true ? (
|
<Text className="address-item-name">{a.receiverName}</Text>
|
||||||
<Text className="address-default-tag">默认</Text>
|
<Text className="address-item-phone">{a.phone}</Text>
|
||||||
|
{dirty ? <Text className="address-need-fix-tag">需完善</Text> : null}
|
||||||
|
{a.isDefault === 1 || a.isDefault === true ? (
|
||||||
|
<Text className="address-default-tag">默认</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text className="address-item-detail">{formatAddress(a)}</Text>
|
||||||
|
{dirty ? (
|
||||||
|
<Text className="address-item-warn">缺少具体区县,同城配送可能失败,请编辑完善</Text>
|
||||||
|
) : null}
|
||||||
|
{!selectMode || dirty ? (
|
||||||
|
<View className="address-item-actions">
|
||||||
|
<Text
|
||||||
|
className="address-action"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Taro.navigateTo({ url: buildAddressEditUrl(a.id, checkoutCtx) }).catch((err) => {
|
||||||
|
toast(err instanceof Error ? err.message : '无法打开编辑页');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dirty ? '去完善' : '编辑'}
|
||||||
|
</Text>
|
||||||
|
{!selectMode ? (
|
||||||
|
<Text
|
||||||
|
className="address-action"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void removeAddress(a.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<Text className="address-item-detail">{formatAddress(a)}</Text>
|
);
|
||||||
{!selectMode ? (
|
})}
|
||||||
<View className="address-item-actions">
|
|
||||||
<Text
|
|
||||||
className="address-action"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
Taro.navigateTo({ url: buildAddressEditUrl(a.id, checkoutCtx) }).catch((err) => {
|
|
||||||
toast(err instanceof Error ? err.message : '无法打开编辑页');
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
className="address-action"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
void removeAddress(a.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
className="address-fab"
|
className="address-fab"
|
||||||
|
|||||||
@@ -196,6 +196,17 @@ export default function HomePage() {
|
|||||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function goBuyOnline(productId: string) {
|
||||||
|
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=2`;
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
goLogin(returnPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ready = await ensurePayReady(returnPath);
|
||||||
|
if (!ready) return;
|
||||||
|
Taro.navigateTo({ url: returnPath });
|
||||||
|
}
|
||||||
|
|
||||||
async function goOnSitePickup(productId: string) {
|
async function goOnSitePickup(productId: string) {
|
||||||
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=2`;
|
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=2`;
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
@@ -348,7 +359,7 @@ export default function HomePage() {
|
|||||||
className="home-buy-btn"
|
className="home-buy-btn"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation?.();
|
e.stopPropagation?.();
|
||||||
openProductDetail(p.id);
|
void goBuyOnline(p.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
立即购买
|
立即购买
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import DeliveryHintHtml from '../../components/DeliveryHintHtml';
|
|||||||
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
|
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
|
||||||
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
|
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
|
||||||
import { getProductMainImage } from '../../lib/product-images';
|
import { getProductMainImage } from '../../lib/product-images';
|
||||||
|
import { isDirtyShippingAddress } from '../../lib/shipping-address';
|
||||||
import BenefitFigure from '../../components/BenefitFigure';
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
import OrderQtyControls from '../../components/OrderQtyControls';
|
import OrderQtyControls from '../../components/OrderQtyControls';
|
||||||
|
|
||||||
@@ -177,7 +178,12 @@ export default function OrderConfirmPage() {
|
|||||||
const isCross =
|
const isCross =
|
||||||
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
|
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
|
||||||
const crossBlocked = isCross && !allowCross;
|
const crossBlocked = isCross && !allowCross;
|
||||||
const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked;
|
const dirtyAddress = !!selectedAddress && isDirtyShippingAddress(selectedAddress);
|
||||||
|
const addressOk = dirtyAddress
|
||||||
|
? false
|
||||||
|
: preview
|
||||||
|
? preview.addressOk !== false && !crossBlocked
|
||||||
|
: !crossBlocked;
|
||||||
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
|
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
|
||||||
const minQty =
|
const minQty =
|
||||||
preview?.minQty ??
|
preview?.minQty ??
|
||||||
@@ -186,9 +192,11 @@ export default function OrderConfirmPage() {
|
|||||||
const canSubmit =
|
const canSubmit =
|
||||||
!!addressId && !!preview && quantityOk && addressOk && !loading && !previewLoading;
|
!!addressId && !!preview && quantityOk && addressOk && !loading && !previewLoading;
|
||||||
|
|
||||||
const addressHint = !addressOk
|
const addressHint = dirtyAddress
|
||||||
? preview?.addressMessage || CROSS_CITY_BLOCK_MSG
|
? '请完善收货地址(需选择具体区县)'
|
||||||
: '';
|
: !addressOk
|
||||||
|
? preview?.addressMessage || CROSS_CITY_BLOCK_MSG
|
||||||
|
: '';
|
||||||
|
|
||||||
function updateQuantity(next: number) {
|
function updateQuantity(next: number) {
|
||||||
if (next < minQty) {
|
if (next < minQty) {
|
||||||
|
|||||||
@@ -38,6 +38,34 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.address-need-fix-tag {
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(200, 120, 20, 0.12);
|
||||||
|
color: #b36b00;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-item--dirty {
|
||||||
|
border: 1px solid rgba(200, 120, 20, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-item-warn {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b36b00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-form-hint {
|
||||||
|
display: block;
|
||||||
|
margin: 0 var(--space-page) 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b36b00;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.address-item-detail {
|
.address-item-detail {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -191,7 +191,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.order-qty-unit {
|
.order-qty-unit {
|
||||||
margin-right: 8px;
|
margin-left: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
| 维度 | 结论 |
|
| 维度 | 结论 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 版本线 | **v4.0.9** 合伙人 H5 周结算、预付款、子账号用户管理 |
|
| 版本线 | **v4.0.13** 收货地址禁「全市」+ 小飞侠拒单履约可感知 |
|
||||||
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
||||||
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
||||||
| 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
| 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
| 4.0.6 | [`酒厂对账核对`](./杜康好客-v4.0.6-开发文档.md) | ✅ 已实现 |
|
| 4.0.6 | [`酒厂对账核对`](./杜康好客-v4.0.6-开发文档.md) | ✅ 已实现 |
|
||||||
| 4.0.7 | [`活动图快链与勾选导出`](./杜康好客-v4.0.7-开发文档.md) | ✅ 已实现 |
|
| 4.0.7 | [`活动图快链与勾选导出`](./杜康好客-v4.0.7-开发文档.md) | ✅ 已实现 |
|
||||||
| 4.0.9 | [`合伙人 H5 周结算与用户管理`](./杜康好客-v4.0.9-开发文档.md) | ✅ 已实现 |
|
| 4.0.9 | [`合伙人 H5 周结算与用户管理`](./杜康好客-v4.0.9-开发文档.md) | ✅ 已实现 |
|
||||||
|
| 4.0.13 | [`收货地址把关与拒单可感知`](./杜康好客-v4.0.13-开发文档.md) | ✅ 已实现 |
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -31,3 +32,4 @@
|
|||||||
| 2026-08-31 | v4.0.6:酒厂 T+3=每 3 天出一期(非每日);含现场提货;零应付仍出账;核对补生成 |
|
| 2026-08-31 | v4.0.6:酒厂 T+3=每 3 天出一期(非每日);含现场提货;零应付仍出账;核对补生成 |
|
||||||
| 2026-09-01 | v4.0.7:HQ 城市合伙人活动图快链;单张/勾选导出合成图(PNG / zip) |
|
| 2026-09-01 | v4.0.7:HQ 城市合伙人活动图快链;单张/勾选导出合成图(PNG / zip) |
|
||||||
| 2026-09-02 | v4.0.9:子账号默认启用;关联码已扫码;零元账单不同步;银行账号自填;周账周一 08:00;预付款预估;子账号用户管理无活动图 |
|
| 2026-09-02 | v4.0.9:子账号默认启用;关联码已扫码;零元账单不同步;银行账号自填;周账周一 08:00;预付款预估;子账号用户管理无活动图 |
|
||||||
|
| 2026-09-02 | v4.0.13:收货禁「全市」;脏地址下单拦截;小飞侠超区/推单失败挂 `fulfillmentHold`(不做仓/收件坐标) |
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 杜康好客 · v4.0.13 开发文档
|
||||||
|
|
||||||
|
> **2026-09-02** · mini-user / iam / trade / fulfillment / admin-web / h5-partner / domain
|
||||||
|
> **主题**:收货地址严格把关(禁「全市」)+ 小飞侠拒单履约可感知
|
||||||
|
|
||||||
|
需求背景:订单 `DK2026090220803` 自动推小飞侠失败,原因 `超出服务区`;收货区县为门店筛选用伪值「全市」。
|
||||||
|
|
||||||
|
**不做(本版明确排除)**:仓坐标补全、收件 `chooseLocation` / `toCoordinate` 坐标链路。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
| # | 任务 | 类型 | 交付 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | 收货选区与门店筛选语义拆分 | 缺陷/体验 | `RegionPicker mode=shipping` 无「全市」 |
|
||||||
|
| 2 | 地址保存硬闸 | 缺陷 | 前后端拒伪区县;详细地址最短 8 字 |
|
||||||
|
| 3 | 下单/预览拦截脏地址 | 缺陷 | preview `addressOk=false`;确认页不可提交 |
|
||||||
|
| 4 | 地址簿存量提示 | 体验 | 「需完善」置顶;结账选址强制去编辑 |
|
||||||
|
| 5 | 推单失败可感知 | 缺陷 | `fulfillmentHold` + 原因码;HQ 标签/发货弹窗 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 规则
|
||||||
|
|
||||||
|
### 2.1 收货地址
|
||||||
|
|
||||||
|
- 伪区县:`全市`、`全部`、空 —— **禁止**写入 `user_address` / 下单快照 / 合伙人代下单。
|
||||||
|
- 详细地址:必填且长度 ≥ 8;含「全市」且无街道路牌关键词时前端软提示(不单独硬失败)。
|
||||||
|
- 门店列表筛选仍可使用「全市」(`mode=filter`)。
|
||||||
|
- 现场取货地址快照(`现场/现场/取货`)不走本校验。
|
||||||
|
|
||||||
|
### 2.2 履约拦截(推单失败)
|
||||||
|
|
||||||
|
| `fulfillment_hold_reason` | 含义 | HQ 展示 |
|
||||||
|
|---------------------------|------|---------|
|
||||||
|
| `LARGE_ORDER_GE_10_BOXES` | 大单≥10箱 | 大单 |
|
||||||
|
| `COURIER_OUT_OF_SERVICE` | 小飞侠返回超出服务区 | 超区 |
|
||||||
|
| `COURIER_DISPATCH_FAILED` | 其它自动推单失败 | 推单失败 |
|
||||||
|
|
||||||
|
- 失败后仍写 `MANUAL` 配送记录(待发货),**同时**挂 `fulfillmentHold`,避免静默像「没推过」。
|
||||||
|
- HQ 手动推小飞侠或填快递成功后仍清 hold(既有逻辑)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 变更面
|
||||||
|
|
||||||
|
| 层 | 路径 |
|
||||||
|
|----|------|
|
||||||
|
| domain | `packages/domain/src/shipping-address.ts` |
|
||||||
|
| shared-types | `FULFILLMENT_HOLD_REASON_LABELS` + 常量 |
|
||||||
|
| API | `UserAddressService`;`TradeService` preview/create/代下单;`FulfillmentService.markCourierDispatchHold` |
|
||||||
|
| C 端 | `RegionPicker`、`address-edit`、`addresses`、`order-confirm`、`shipping-address.ts` |
|
||||||
|
| 合伙人 | `ProxyOrderPage` 校验 |
|
||||||
|
| HQ | `OrdersPage` 拦截筛选文案与标签 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 行为变化
|
||||||
|
|
||||||
|
| 接口 | 变化 |
|
||||||
|
|------|------|
|
||||||
|
| `POST/PUT /user/addresses` | 伪区县 / 详情过短 → `400` |
|
||||||
|
| `POST /trade/orders/preview` | 脏地址 → `addressOk=false` + message |
|
||||||
|
| `POST /trade/orders` | 脏地址 → `400` |
|
||||||
|
| 合伙人代下单 | 同上校验 |
|
||||||
|
| 支付后自动推单 | 失败写 hold reason(库字段,无新 path) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验收
|
||||||
|
|
||||||
|
- [ ] 新增/编辑地址:区县列表无「全市」;选真实区县 + 足够详细地址可保存
|
||||||
|
- [ ] 保存「全市」或过短详情被拒(前端 toast + 后端 400)
|
||||||
|
- [ ] 历史脏地址在地址簿标「需完善」;结账选择时跳转编辑
|
||||||
|
- [ ] 确认订单页脏地址不可提交;preview 提示完善区县
|
||||||
|
- [ ] 合伙人代下单伪区县/过短详情被拒
|
||||||
|
- [ ] 模拟小飞侠「超出服务区」后:订单 `fulfillment_hold=1`、`reason=COURIER_OUT_OF_SERVICE`;HQ 列表「超区」、发货弹窗有说明
|
||||||
|
- [ ] 门店列表筛选「全市」行为不变
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 存量建议(运维,非代码)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 生产排查脏地址(只读)
|
||||||
|
SELECT id, user_id, province, city, district, detail, updated_at
|
||||||
|
FROM user_address
|
||||||
|
WHERE district IN ('全市','全部','') OR district IS NULL
|
||||||
|
ORDER BY id DESC LIMIT 100;
|
||||||
|
```
|
||||||
|
|
||||||
|
可选:运营通知用户进「地址管理」完善;本版不自动改写历史行。
|
||||||
@@ -410,3 +410,4 @@ export * from './dev-plan';
|
|||||||
export * from './support-ticket';
|
export * from './support-ticket';
|
||||||
export * from './phone';
|
export * from './phone';
|
||||||
export * from './shanghai-date';
|
export * from './shanghai-date';
|
||||||
|
export * from './shipping-address';
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
isConcreteShippingDistrict,
|
||||||
|
isPseudoShippingDistrict,
|
||||||
|
shippingDetailCityWideHint,
|
||||||
|
validateShippingAddress,
|
||||||
|
} from './shipping-address';
|
||||||
|
|
||||||
|
describe('shipping address', () => {
|
||||||
|
it('rejects pseudo districts', () => {
|
||||||
|
expect(isPseudoShippingDistrict('全市')).toBe(true);
|
||||||
|
expect(isPseudoShippingDistrict('全部')).toBe(true);
|
||||||
|
expect(isPseudoShippingDistrict('')).toBe(true);
|
||||||
|
expect(isConcreteShippingDistrict('金水区')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires concrete district and detail length', () => {
|
||||||
|
expect(
|
||||||
|
validateShippingAddress({
|
||||||
|
province: '河南省',
|
||||||
|
city: '郑州市',
|
||||||
|
district: '全市',
|
||||||
|
detail: '上馆子信阳菜尚购生活广场店',
|
||||||
|
}).ok,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateShippingAddress({
|
||||||
|
province: '河南省',
|
||||||
|
city: '郑州市',
|
||||||
|
district: '金水区',
|
||||||
|
detail: '短',
|
||||||
|
}).message,
|
||||||
|
).toMatch(/详细/);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateShippingAddress({
|
||||||
|
province: '河南省',
|
||||||
|
city: '郑州市',
|
||||||
|
district: '金水区',
|
||||||
|
detail: '沙口路8号院尚购生活广场',
|
||||||
|
}).ok,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft-hints bare 全市 in detail', () => {
|
||||||
|
expect(shippingDetailCityWideHint('河南省郑州市全市上馆子')).toBeTruthy();
|
||||||
|
expect(shippingDetailCityWideHint('金水区沙口路8号')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/** 门店筛选用伪区县,禁止写入收货地址 */
|
||||||
|
export const PSEUDO_SHIPPING_DISTRICTS = ['全市', '全部'] as const;
|
||||||
|
|
||||||
|
export const SHIPPING_DETAIL_MIN_LEN = 8;
|
||||||
|
|
||||||
|
export const SHIPPING_REGION_REQUIRED_MSG = '请选择具体区县';
|
||||||
|
export const SHIPPING_DETAIL_REQUIRED_MSG = '请填写详细地址';
|
||||||
|
export const SHIPPING_DETAIL_TOO_SHORT_MSG = '请填写更详细的收货地址(含街道门牌)';
|
||||||
|
|
||||||
|
export type ShippingAddressFields = {
|
||||||
|
province?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
district?: string | null;
|
||||||
|
detail?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShippingAddressValidation = {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function trim(v: string | null | undefined): string {
|
||||||
|
return String(v ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPseudoShippingDistrict(district: string | null | undefined): boolean {
|
||||||
|
const d = trim(district);
|
||||||
|
if (!d) return true;
|
||||||
|
return (PSEUDO_SHIPPING_DISTRICTS as readonly string[]).includes(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 区县是否可作为收货地址(非空且非全市/全部) */
|
||||||
|
export function isConcreteShippingDistrict(district: string | null | undefined): boolean {
|
||||||
|
return !isPseudoShippingDistrict(district);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验收货省市区 + 详细地址。
|
||||||
|
* - 现场取货等特殊值请勿调用本函数
|
||||||
|
* - 不依赖完整省市区树;前端另做树内白名单
|
||||||
|
*/
|
||||||
|
export function validateShippingAddress(
|
||||||
|
input: ShippingAddressFields,
|
||||||
|
options?: { requireDetail?: boolean },
|
||||||
|
): ShippingAddressValidation {
|
||||||
|
const province = trim(input.province);
|
||||||
|
const city = trim(input.city);
|
||||||
|
const district = trim(input.district);
|
||||||
|
const detail = trim(input.detail);
|
||||||
|
const requireDetail = options?.requireDetail !== false;
|
||||||
|
|
||||||
|
if (!province || !city) {
|
||||||
|
return { ok: false, message: '请选择所在地区' };
|
||||||
|
}
|
||||||
|
if (!isConcreteShippingDistrict(district)) {
|
||||||
|
return { ok: false, message: SHIPPING_REGION_REQUIRED_MSG };
|
||||||
|
}
|
||||||
|
if (requireDetail) {
|
||||||
|
if (!detail) {
|
||||||
|
return { ok: false, message: SHIPPING_DETAIL_REQUIRED_MSG };
|
||||||
|
}
|
||||||
|
if (detail.length < SHIPPING_DETAIL_MIN_LEN) {
|
||||||
|
return { ok: false, message: SHIPPING_DETAIL_TOO_SHORT_MSG };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详细地址含「全市」时的软提示(不单独作为硬失败条件) */
|
||||||
|
export function shippingDetailCityWideHint(detail: string | null | undefined): string | null {
|
||||||
|
const d = trim(detail);
|
||||||
|
if (!d.includes('全市')) return null;
|
||||||
|
if (/路|街|巷|号|大厦|广场|小区|村|镇|乡/.test(d)) return null;
|
||||||
|
return '详细地址含「全市」,建议改为具体街道门牌,以免配送拒单';
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ export interface FulfillmentProviderDto {
|
|||||||
capabilities?: {
|
capabilities?: {
|
||||||
createShipment?: boolean;
|
createShipment?: boolean;
|
||||||
getTrack?: boolean;
|
getTrack?: boolean;
|
||||||
|
getSignPhotos?: boolean;
|
||||||
callback?: boolean;
|
callback?: boolean;
|
||||||
cancel?: boolean;
|
cancel?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
|
|||||||
@@ -99,11 +99,16 @@ export interface OrderTrackDto {
|
|||||||
queryError?: string | null;
|
queryError?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 大单拦截原因:≥10 箱不自动推小飞侠 */
|
/** 履约拦截原因(大单 / 承运商拒单等) */
|
||||||
export const FULFILLMENT_HOLD_REASON_LABELS: Record<string, string> = {
|
export const FULFILLMENT_HOLD_REASON_LABELS: Record<string, string> = {
|
||||||
LARGE_ORDER_GE_10_BOXES: '大单≥10箱,待总部确认推单/自配送',
|
LARGE_ORDER_GE_10_BOXES: '大单≥10箱,待总部确认推单/自配送',
|
||||||
|
COURIER_OUT_OF_SERVICE: '小飞侠超出服务区,待改址后重推或自配送',
|
||||||
|
COURIER_DISPATCH_FAILED: '自动推配送失败,待总部确认重推或自配送',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const FULFILLMENT_HOLD_COURIER_OUT_OF_SERVICE = 'COURIER_OUT_OF_SERVICE';
|
||||||
|
export const FULFILLMENT_HOLD_COURIER_DISPATCH_FAILED = 'COURIER_DISPATCH_FAILED';
|
||||||
|
|
||||||
export type PartnerProxyDeliveryMode = 'ADDRESS' | 'ON_SITE_PICKUP';
|
export type PartnerProxyDeliveryMode = 'ADDRESS' | 'ON_SITE_PICKUP';
|
||||||
|
|
||||||
export type PartnerProxyOrderProductOption = {
|
export type PartnerProxyOrderProductOption = {
|
||||||
|
|||||||
@@ -47,13 +47,27 @@ export function sceneForXfxCmd(cmd: string) {
|
|||||||
return CMD_SCENE[cmd] ?? `XFX_CMD_${cmd}`;
|
return CMD_SCENE[cmd] ?? `XFX_CMD_${cmd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 签收照等大字段入库前截断,避免撑爆 JSON / 影响后续业务返回 */
|
||||||
|
function sanitizeCourierResponseBody(scene: string, body: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (body === undefined) return undefined;
|
||||||
|
if (typeof body !== 'object' || body === null) {
|
||||||
|
return { value: body };
|
||||||
|
}
|
||||||
|
const root = { ...(body as Record<string, unknown>) };
|
||||||
|
if (scene === 'GET_SIGN_PHOTOS' && Array.isArray(root.data)) {
|
||||||
|
const photos = root.data as unknown[];
|
||||||
|
root.data = photos.map((item) => {
|
||||||
|
if (typeof item !== 'string') return item;
|
||||||
|
if (item.length <= 120) return item;
|
||||||
|
return `${item.slice(0, 80)}…(len=${item.length})`;
|
||||||
|
});
|
||||||
|
root.dataCount = photos.length;
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
export async function logCourierCall(prisma: PrismaService, input: LogCourierCallInput) {
|
export async function logCourierCall(prisma: PrismaService, input: LogCourierCallInput) {
|
||||||
const responseBody =
|
const responseBody = sanitizeCourierResponseBody(input.scene, input.responseBody);
|
||||||
input.responseBody === undefined
|
|
||||||
? undefined
|
|
||||||
: typeof input.responseBody === 'object' && input.responseBody !== null
|
|
||||||
? (input.responseBody as Record<string, unknown>)
|
|
||||||
: { value: input.responseBody };
|
|
||||||
|
|
||||||
const row = await prisma.logThirdParty.create({
|
const row = await prisma.logThirdParty.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export class FulfillmentProviderService {
|
|||||||
? JSON.stringify({
|
? JSON.stringify({
|
||||||
createShipment: true,
|
createShipment: true,
|
||||||
getTrack: true,
|
getTrack: true,
|
||||||
|
getSignPhotos: true,
|
||||||
callback: true,
|
callback: true,
|
||||||
cancel: true,
|
cancel: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
||||||
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
|
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
|
||||||
import { isXfxProviderCode } from '@dukang/shared-types';
|
import {
|
||||||
|
FULFILLMENT_HOLD_COURIER_DISPATCH_FAILED,
|
||||||
|
FULFILLMENT_HOLD_COURIER_OUT_OF_SERVICE,
|
||||||
|
isXfxProviderCode,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import {
|
import {
|
||||||
BOTTLES_PER_BOX,
|
BOTTLES_PER_BOX,
|
||||||
XFX_AUTO_DISPATCH_MAX_BOXES,
|
XFX_AUTO_DISPATCH_MAX_BOXES,
|
||||||
@@ -29,6 +33,7 @@ export type ManualShipInput = {
|
|||||||
export type HqLogisticsShipInput = ManualShipInput;
|
export type HqLogisticsShipInput = ManualShipInput;
|
||||||
|
|
||||||
export const FULFILLMENT_HOLD_LARGE_ORDER = 'LARGE_ORDER_GE_10_BOXES';
|
export const FULFILLMENT_HOLD_LARGE_ORDER = 'LARGE_ORDER_GE_10_BOXES';
|
||||||
|
export { FULFILLMENT_HOLD_COURIER_OUT_OF_SERVICE, FULFILLMENT_HOLD_COURIER_DISPATCH_FAILED };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FulfillmentService {
|
export class FulfillmentService {
|
||||||
@@ -138,6 +143,7 @@ export class FulfillmentService {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
await this.logDispatchFailure(order, provider, message);
|
await this.logDispatchFailure(order, provider, message);
|
||||||
|
await this.markCourierDispatchHold(order.id, message);
|
||||||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -213,6 +219,7 @@ export class FulfillmentService {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
await this.logDispatchFailure(order, provider, message);
|
await this.logDispatchFailure(order, provider, message);
|
||||||
|
await this.markCourierDispatchHold(order.id, message);
|
||||||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,8 +313,9 @@ export class FulfillmentService {
|
|||||||
trackingNumber: order.delivery.trackingNo ?? undefined,
|
trackingNumber: order.delivery.trackingNo ?? undefined,
|
||||||
outNumber: order.orderNo,
|
outNumber: order.orderNo,
|
||||||
};
|
};
|
||||||
|
const cachedSignPhotoUrl = order.delivery.signPhotoResource?.url ?? null;
|
||||||
|
|
||||||
const [trackResult, signPhotoDataUris] = await Promise.all([
|
const [trackResult, signPhotoResult] = await Promise.all([
|
||||||
this.courier
|
this.courier
|
||||||
.getTrack(shipmentQuery, options)
|
.getTrack(shipmentQuery, options)
|
||||||
.then((nodes) => ({ nodes: Array.isArray(nodes) ? nodes : [], error: null as string | null }))
|
.then((nodes) => ({ nodes: Array.isArray(nodes) ? nodes : [], error: null as string | null }))
|
||||||
@@ -315,12 +323,30 @@ export class FulfillmentService {
|
|||||||
nodes: [] as TrackNode[],
|
nodes: [] as TrackNode[],
|
||||||
error: err instanceof Error ? err.message : '查询路由失败',
|
error: err instanceof Error ? err.message : '查询路由失败',
|
||||||
})),
|
})),
|
||||||
this.courier.getSignPhotos(shipmentQuery, options).catch(() => [] as string[]),
|
cachedSignPhotoUrl
|
||||||
|
? Promise.resolve({ dataUris: [] as string[], error: null as string | null })
|
||||||
|
: this.courier
|
||||||
|
.getSignPhotos(shipmentQuery, options)
|
||||||
|
.then((dataUris) => ({
|
||||||
|
dataUris: Array.isArray(dataUris) ? dataUris : [],
|
||||||
|
error: null as string | null,
|
||||||
|
}))
|
||||||
|
.catch((err: unknown) => ({
|
||||||
|
dataUris: [] as string[],
|
||||||
|
error: err instanceof Error ? err.message : '查询签收照片失败',
|
||||||
|
})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
base.nodes = this.sortTrackNodesOldestFirst(trackResult.nodes);
|
base.nodes = this.sortTrackNodesOldestFirst(trackResult.nodes);
|
||||||
base.queryError = trackResult.error;
|
base.queryError = trackResult.error;
|
||||||
base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoDataUris);
|
if (cachedSignPhotoUrl) {
|
||||||
|
base.signPhotoUrls = [cachedSignPhotoUrl];
|
||||||
|
} else {
|
||||||
|
base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoResult.dataUris);
|
||||||
|
if (base.signPhotoUrls.length === 0 && signPhotoResult.error && !base.queryError) {
|
||||||
|
base.queryError = signPhotoResult.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) {
|
if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) {
|
||||||
const toAddress = this.buildReceiverAddress(order);
|
const toAddress = this.buildReceiverAddress(order);
|
||||||
@@ -424,43 +450,62 @@ export class FulfillmentService {
|
|||||||
},
|
},
|
||||||
dataUris: string[],
|
dataUris: string[],
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
if (!order.delivery || dataUris.length === 0) {
|
if (!order.delivery) return [];
|
||||||
return order.delivery?.signPhotoResource?.url ? [order.delivery.signPhotoResource.url] : [];
|
if (order.delivery.signPhotoResource?.url) {
|
||||||
|
return [order.delivery.signPhotoResource.url];
|
||||||
}
|
}
|
||||||
|
if (dataUris.length === 0) return [];
|
||||||
|
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
let firstResourceId: bigint | null = order.delivery.signPhotoResourceId;
|
let firstResourceId: bigint | null = order.delivery.signPhotoResourceId;
|
||||||
|
|
||||||
for (let i = 0; i < dataUris.length; i += 1) {
|
for (let i = 0; i < dataUris.length; i += 1) {
|
||||||
const parsed = this.parseDataUri(dataUris[i]);
|
const raw = dataUris[i];
|
||||||
|
const parsed = this.parseDataUri(raw);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
|
|
||||||
const result = await this.oss.putObject({
|
try {
|
||||||
bizType: 'SIGN_PHOTO',
|
if (!this.oss.isEnabled()) {
|
||||||
mediaType: 'IMAGE',
|
throw new Error('OSS 未配置');
|
||||||
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
|
}
|
||||||
buffer: parsed.buffer,
|
const result = await this.oss.putObject({
|
||||||
mimeType: parsed.mimeType,
|
bizType: 'SIGN_PHOTO',
|
||||||
});
|
mediaType: 'IMAGE',
|
||||||
urls.push(result.url);
|
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
|
||||||
|
buffer: parsed.buffer,
|
||||||
if (!firstResourceId) {
|
mimeType: parsed.mimeType,
|
||||||
const resource = await this.prisma.commonResource.create({
|
|
||||||
data: {
|
|
||||||
ownerType: 'ORDER',
|
|
||||||
ownerId: order.id,
|
|
||||||
bizType: 'SIGN_PHOTO',
|
|
||||||
mediaType: 'IMAGE',
|
|
||||||
ossBucket: result.bucket,
|
|
||||||
ossKey: result.ossKey,
|
|
||||||
url: result.url,
|
|
||||||
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
|
|
||||||
fileSize: BigInt(parsed.buffer.length),
|
|
||||||
mimeType: parsed.mimeType,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
firstResourceId = resource.id;
|
urls.push(result.url);
|
||||||
|
|
||||||
|
if (!firstResourceId) {
|
||||||
|
const resource = await this.prisma.commonResource.create({
|
||||||
|
data: {
|
||||||
|
ownerType: 'ORDER',
|
||||||
|
ownerId: order.id,
|
||||||
|
bizType: 'SIGN_PHOTO',
|
||||||
|
mediaType: 'IMAGE',
|
||||||
|
ossBucket: result.bucket,
|
||||||
|
ossKey: result.ossKey,
|
||||||
|
url: result.url,
|
||||||
|
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
|
||||||
|
fileSize: BigInt(parsed.buffer.length),
|
||||||
|
mimeType: parsed.mimeType,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
firstResourceId = resource.id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// OSS 失败不丢图:回退 data URI,保证 C 端 / HQ 仍可预览
|
||||||
|
this.logger.warn(
|
||||||
|
`签收照上传 OSS 失败,回退 dataURI:orderId=${order.id} err=${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
const dataUri = raw.startsWith('data:')
|
||||||
|
? raw
|
||||||
|
: `data:${parsed.mimeType};base64,${parsed.buffer.toString('base64')}`;
|
||||||
|
urls.push(dataUri);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,6 +604,22 @@ export class FulfillmentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 自动推单失败:挂履约拦截,HQ 可见,避免静默 MANUAL 像「没推单」 */
|
||||||
|
private async markCourierDispatchHold(orderId: bigint, error: string) {
|
||||||
|
const outOfService = /超出服务区/.test(error);
|
||||||
|
const reason = outOfService
|
||||||
|
? FULFILLMENT_HOLD_COURIER_OUT_OF_SERVICE
|
||||||
|
: FULFILLMENT_HOLD_COURIER_DISPATCH_FAILED;
|
||||||
|
this.logger.warn(`承运商推单失败挂起:orderId=${orderId} reason=${reason} err=${error}`);
|
||||||
|
await this.prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
fulfillmentHold: true,
|
||||||
|
fulfillmentHoldReason: reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async buildQueryUrlFromTemplate(warehouseId: bigint | null, trackingNo: string) {
|
private async buildQueryUrlFromTemplate(warehouseId: bigint | null, trackingNo: string) {
|
||||||
if (!warehouseId) return undefined;
|
if (!warehouseId) return undefined;
|
||||||
const wh = await this.prisma.cityWarehouse.findUnique({ where: { id: warehouseId } });
|
const wh = await this.prisma.cityWarehouse.findUnique({ where: { id: warehouseId } });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { validateShippingAddress } from '@dukang/domain';
|
||||||
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';
|
||||||
|
|
||||||
@@ -27,7 +28,25 @@ export class UserAddressService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertShippingFields(body: Record<string, unknown>, existing?: {
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
detail: string;
|
||||||
|
}) {
|
||||||
|
const check = validateShippingAddress({
|
||||||
|
province: body.province != null ? String(body.province) : existing?.province,
|
||||||
|
city: body.city != null ? String(body.city) : existing?.city,
|
||||||
|
district: body.district != null ? String(body.district) : existing?.district,
|
||||||
|
detail: body.detail != null ? String(body.detail) : existing?.detail,
|
||||||
|
});
|
||||||
|
if (!check.ok) {
|
||||||
|
throw new BadRequestException(check.message || '收货地址不完整');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(userId: bigint, body: Record<string, unknown>) {
|
async create(userId: bigint, body: Record<string, unknown>) {
|
||||||
|
this.assertShippingFields(body);
|
||||||
const isDefault = body.isDefault ? 1 : 0;
|
const isDefault = body.isDefault ? 1 : 0;
|
||||||
const address = await this.prisma.$transaction(async (tx) => {
|
const address = await this.prisma.$transaction(async (tx) => {
|
||||||
if (isDefault) {
|
if (isDefault) {
|
||||||
@@ -52,6 +71,7 @@ export class UserAddressService {
|
|||||||
async update(userId: bigint, id: bigint, body: Record<string, unknown>) {
|
async update(userId: bigint, id: bigint, body: Record<string, unknown>) {
|
||||||
const existing = await this.prisma.userAddress.findFirst({ where: { id, userId } });
|
const existing = await this.prisma.userAddress.findFirst({ where: { id, userId } });
|
||||||
if (!existing) throw new NotFoundException('地址不存在');
|
if (!existing) throw new NotFoundException('地址不存在');
|
||||||
|
this.assertShippingFields(body, existing);
|
||||||
const address = await this.prisma.$transaction(async (tx) => {
|
const address = await this.prisma.$transaction(async (tx) => {
|
||||||
if (body.isDefault) {
|
if (body.isDefault) {
|
||||||
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
|
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
|
||||||
|
|||||||
@@ -59,4 +59,9 @@ export class AdminXiaofeixiaController {
|
|||||||
getTrack(@Body() body: XiaofeixiaShipmentQueryDto) {
|
getTrack(@Body() body: XiaofeixiaShipmentQueryDto) {
|
||||||
return this.service.getTrack(body);
|
return this.service.getTrack(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('get-sign-photos')
|
||||||
|
getSignPhotos(@Body() body: XiaofeixiaShipmentQueryDto) {
|
||||||
|
return this.service.getSignPhotos(body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ export class AdminXiaofeixiaService {
|
|||||||
return this.wrap(() => this.courier.getTrack(query, options));
|
return this.wrap(() => this.courier.getTrack(query, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSignPhotos(dto: XiaofeixiaShipmentQueryDto) {
|
||||||
|
const query = this.mapShipmentQuery(dto);
|
||||||
|
const options = await this.callOptions();
|
||||||
|
return this.wrap(async () => {
|
||||||
|
const dataUris = await this.courier.getSignPhotos(query, options);
|
||||||
|
return {
|
||||||
|
count: dataUris.length,
|
||||||
|
// 联调预览用:完整 dataURI;日志侧已截断
|
||||||
|
data: dataUris,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async callOptions() {
|
private async callOptions() {
|
||||||
const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
|
const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
|
||||||
return fromDb ? { xiaofeixia: fromDb } : undefined;
|
return fromDb ? { xiaofeixia: fromDb } : undefined;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
orderTabToStatuses,
|
orderTabToStatuses,
|
||||||
toMinSaleQuantity,
|
toMinSaleQuantity,
|
||||||
validateMinPurchase,
|
validateMinPurchase,
|
||||||
|
validateShippingAddress,
|
||||||
} from '@dukang/domain';
|
} from '@dukang/domain';
|
||||||
import { loadAppConfig, ClientApp, 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';
|
||||||
@@ -155,6 +156,19 @@ export class TradeService {
|
|||||||
addressMessage = '该规格不支持跨城配送,请更换为开城城市内的收货地址';
|
addressMessage = '该规格不支持跨城配送,请更换为开城城市内的收货地址';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (addressOk && body.addressId) {
|
||||||
|
const address = await this.prisma.userAddress.findFirst({
|
||||||
|
where: { id: BigInt(body.addressId), userId },
|
||||||
|
});
|
||||||
|
if (address) {
|
||||||
|
const shipping = validateShippingAddress(address);
|
||||||
|
if (!shipping.ok) {
|
||||||
|
addressOk = false;
|
||||||
|
addressMessage = shipping.message || '请完善收货地址(需具体区县)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1;
|
const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1;
|
||||||
@@ -276,6 +290,10 @@ export class TradeService {
|
|||||||
where: { id: BigInt(body.addressId), userId },
|
where: { id: BigInt(body.addressId), userId },
|
||||||
});
|
});
|
||||||
if (!address) throw new BadRequestException('请选择收货地址');
|
if (!address) throw new BadRequestException('请选择收货地址');
|
||||||
|
const shipping = validateShippingAddress(address);
|
||||||
|
if (!shipping.ok) {
|
||||||
|
throw new BadRequestException(shipping.message || '请完善收货地址(需具体区县)');
|
||||||
|
}
|
||||||
receiverName = address.receiverName;
|
receiverName = address.receiverName;
|
||||||
receiverPhone = address.phone;
|
receiverPhone = address.phone;
|
||||||
receiverAddress = `${address.province}${address.city}${address.district}${address.detail}`;
|
receiverAddress = `${address.province}${address.city}${address.district}${address.detail}`;
|
||||||
@@ -1866,6 +1884,15 @@ export class TradeService {
|
|||||||
if (!body.addressDetail?.trim()) {
|
if (!body.addressDetail?.trim()) {
|
||||||
throw new BadRequestException('请填写详细地址');
|
throw new BadRequestException('请填写详细地址');
|
||||||
}
|
}
|
||||||
|
const shipping = validateShippingAddress({
|
||||||
|
province: receiverProvince,
|
||||||
|
city: receiverCity,
|
||||||
|
district: receiverDistrict,
|
||||||
|
detail: body.addressDetail,
|
||||||
|
});
|
||||||
|
if (!shipping.ok) {
|
||||||
|
throw new BadRequestException(shipping.message || '请完善收货地址(需具体区县)');
|
||||||
|
}
|
||||||
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
|
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2281,6 +2308,15 @@ export class TradeService {
|
|||||||
if (!body.addressDetail?.trim()) {
|
if (!body.addressDetail?.trim()) {
|
||||||
throw new BadRequestException('请填写详细地址');
|
throw new BadRequestException('请填写详细地址');
|
||||||
}
|
}
|
||||||
|
const shipping = validateShippingAddress({
|
||||||
|
province: receiverProvince,
|
||||||
|
city: receiverCity,
|
||||||
|
district: receiverDistrict,
|
||||||
|
detail: body.addressDetail,
|
||||||
|
});
|
||||||
|
if (!shipping.ok) {
|
||||||
|
throw new BadRequestException(shipping.message || '请完善收货地址(需具体区县)');
|
||||||
|
}
|
||||||
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
|
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user