Compare commits

..

2 Commits

Author SHA1 Message Date
jacy c6b01def4c feat(trade): v4.0.13 收货禁全市与小飞侠拒单可感知
CI / verify (push) Waiting to run
拦截伪区县写入与下单;推单失败挂 fulfillmentHold,HQ 可见超区/推单失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 17:20:40 +08:00
jacy 63fcf33416 v4.0.10版本更新
要求门店支持多个分类;
小程序C端“瓶”位置调整;
商品单元“立即购买”直接跳转到“确认订单”页;
2026-09-02 16:25:59 +08:00
21 changed files with 627 additions and 79 deletions
+19 -5
View File
@@ -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}
@@ -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>
); );
+55 -13
View File
@@ -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();
} }
+35
View File
@@ -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 ?? ''),
});
}
+27 -12
View File
@@ -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>
); );
+59 -38
View File
@@ -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"
+12 -1
View File
@@ -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) {
+28
View File
@@ -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;
+1 -1
View File
@@ -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);
+3 -1
View File
@@ -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.7HQ 城市合伙人活动图快链;单张/勾选导出合成图(PNG / zip) | | 2026-09-01 | v4.0.7HQ 城市合伙人活动图快链;单张/勾选导出合成图(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`(不做仓/收件坐标) |
+93
View File
@@ -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;
```
可选:运营通知用户进「地址管理」完善;本版不自动改写历史行。
+1
View File
@@ -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();
});
});
+75
View File
@@ -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 '详细地址含「全市」,建议改为具体街道门牌,以免配送拒单';
}
+6 -1
View File
@@ -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 = {
@@ -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);
} }
} }
@@ -559,6 +566,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 } });
@@ -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()}`;
} }