diff --git a/apps/admin-web/src/components/ProxyOrderModal.tsx b/apps/admin-web/src/components/ProxyOrderModal.tsx index 2aeccde..19d53a6 100644 --- a/apps/admin-web/src/components/ProxyOrderModal.tsx +++ b/apps/admin-web/src/components/ProxyOrderModal.tsx @@ -86,6 +86,22 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder .finally(() => setLoadingOptions(false)); }, [open]); + const selectedProduct = options?.products.find((p) => p.id === productId); + const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true; + const allowOnSite = !!selectedProduct?.allowOnSitePickup; + const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true; + + useEffect(() => { + if (!open || !selectedProduct) return; + if (!allowOnline && allowOnSite && deliveryMode !== 'ON_SITE_PICKUP') { + setDeliveryMode('ON_SITE_PICKUP'); + return; + } + if (allowOnline && !allowOnSite && deliveryMode !== 'ADDRESS') { + setDeliveryMode('ADDRESS'); + } + }, [open, selectedProduct, allowOnline, allowOnSite, deliveryMode]); + useEffect(() => { if (!open || !productId || quantity < 1 || step !== 'form') { if (step === 'form') setPreview(null); @@ -128,7 +144,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder setCreated(null); setCodeUrl(null); setPayLoading(false); - setMockConfirming(false); } function handleClose() { @@ -140,12 +155,12 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号'; if (!productId) return '请选择商品'; if (deliveryMode === 'ADDRESS') { + if (!allowOnline) return '该商品不支持线上购买'; if (!region?.province || !region?.city || !region?.district) return '请选择省市区'; if (!addressDetail.trim()) return '请填写详细地址'; if (!autoReceive) return '配送到址须勾选同意自动收货'; - } else { - const product = options?.products.find((p) => p.id === productId); - if (product && product.allowOnSitePickup === false) return '该商品不支持现场提货'; + } else if (!allowOnSite) { + return '该商品不支持现场提货'; } if (!preview) return '请等待费用计算完成'; return null; @@ -329,20 +344,32 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder ) : null} - { - setDeliveryMode(e.target.value); - if (e.target.value !== 'ADDRESS') setAutoReceive(false); - }} - options={[ - { value: 'ADDRESS', label: '配送到址' }, - { value: 'ON_SITE_PICKUP', label: '现场提货' }, - ]} - /> + {allowOnline && allowOnSite ? ( + { + setDeliveryMode(e.target.value); + if (e.target.value !== 'ADDRESS') setAutoReceive(false); + }} + options={[ + { value: 'ADDRESS', label: '配送到址' }, + { value: 'ON_SITE_PICKUP', label: '现场提货' }, + ]} + /> + ) : ( + + {!allowOnline && allowOnSite + ? '该商品仅支持现场提货' + : allowOnline && !allowOnSite + ? allowCrossCity + ? '该商品仅支持配送到址(含跨城)' + : '该商品仅支持配送到址(不可跨城)' + : '该商品暂无可选履约方式'} + + )} - {deliveryMode === 'ADDRESS' ? ( + {deliveryMode === 'ADDRESS' && allowOnline ? ( <> ) { const detail = (d.detailContent ?? {}) as ProductDetailContentDto; + const row = d as Row; return { ...d, coverUrl: (d as { mainImageUrl?: string }).mainImageUrl, - carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[], - detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) as string[], - visibilityWhitelistEnabled: !!(d as Row).visibilityWhitelistEnabled, - visibilityPhones: ((d as Row).visibilityPhones ?? []) as string[], + carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[], + detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[], + allowOnlinePurchase: row.allowOnlinePurchase !== false, + allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false, + allowOnSitePickup: !!row.allowOnSitePickup, + visibilityWhitelistEnabled: !!row.visibilityWhitelistEnabled, + visibilityPhones: (row.visibilityPhones ?? []) as string[], storyTitle: detail.storyTitle ?? '', storyText: detail.storyText ?? '', features: detail.features?.length @@ -109,7 +117,6 @@ function buildProductPayload(v: ProductFormValues) { .filter(Boolean); return { - skuCode: v.skuCode, barcode69: v.barcode69, name: v.name, subtitle: v.subtitle, @@ -120,6 +127,9 @@ function buildProductPayload(v: ProductFormValues) { status: v.status, sortOrder: v.sortOrder, allowOnSitePickup: !!v.allowOnSitePickup, + allowOnlinePurchase: v.allowOnlinePurchase !== false, + allowCrossCityDelivery: + v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false, visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled, visibilityPhones, coverUrl: v.coverUrl, @@ -288,8 +298,8 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns <> {mode === 'create' && ( <> - - + + @@ -320,6 +330,36 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns + + { + if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false }); + }} + /> + + prev.allowOnlinePurchase !== cur.allowOnlinePurchase}> + {() => ( + + + + )} + @@ -383,10 +423,19 @@ export default function ProductsPage() { v ? 限{row.visibilityPhones?.length ?? 0}人 : 公开, }, { - title: '现场取货', - dataIndex: 'allowOnSitePickup', - width: 90, - render: (v: boolean) => (v ? 允许 : ), + title: '履约', + key: 'fulfillment', + width: 160, + render: (_, row) => ( + + {row.allowOnlinePurchase !== false ? 线上 : null} + {row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? ( + 跨城 + ) : null} + {row.allowOnSitePickup ? 现场 : null} + {row.allowOnlinePurchase === false && !row.allowOnSitePickup ? : null} + + ), }, { title: '排序', dataIndex: 'sortOrder', width: 60 }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, @@ -431,12 +480,16 @@ export default function ProductsPage() { - { setPage(p); setPageSize(ps); } }} /> setDrawerOpen(false)} extra={detail && ( - - + {allowOnline && allowOnSite ? ( +
+ + +
+ ) : ( +

+ {!allowOnline && allowOnSite + ? '该商品仅支持现场提货' + : allowOnline && !allowOnSite + ? allowCrossCity + ? '该商品仅支持配送到址(含跨城)' + : '该商品仅支持配送到址(不可跨城)' + : '该商品暂无可选履约方式'} +

+ )} - {deliveryMode === 'ADDRESS' ? ( + {deliveryMode === 'ADDRESS' && allowOnline ? ( <>
diff --git a/apps/h5-partner/src/pages/ProxyOrderProductsPage.tsx b/apps/h5-partner/src/pages/ProxyOrderProductsPage.tsx index 6feb39f..6536436 100644 --- a/apps/h5-partner/src/pages/ProxyOrderProductsPage.tsx +++ b/apps/h5-partner/src/pages/ProxyOrderProductsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import PageHeader from '@dukang/shared-ui/PageHeader'; import { request } from '../lib/api'; @@ -9,12 +9,30 @@ function fmtMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } +type FulfillmentFilter = 'ALL' | 'ONLINE' | 'CROSS_CITY' | 'ON_SITE'; + +const FILTERS: Array<{ key: FulfillmentFilter; label: string }> = [ + { key: 'ALL', label: '全部' }, + { key: 'ONLINE', label: '线上' }, + { key: 'CROSS_CITY', label: '跨城' }, + { key: 'ON_SITE', label: '现场' }, +]; + +function matchFilter(p: PartnerProxyOrderProductOption, filter: FulfillmentFilter): boolean { + if (filter === 'ALL') return true; + if (filter === 'ONLINE') return p.allowOnlinePurchase !== false; + if (filter === 'CROSS_CITY') return p.allowCrossCityDelivery !== false; + return !!p.allowOnSitePickup; +} + export default function ProxyOrderProductsPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const selectedId = searchParams.get('selected') || ''; const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); + const [keyword, setKeyword] = useState(''); + const [filter, setFilter] = useState('ALL'); useEffect(() => { request('PARTNER_H5', '/partner/proxy-orders/options') @@ -23,6 +41,15 @@ export default function ProxyOrderProductsPage() { .finally(() => setLoading(false)); }, []); + const filtered = useMemo(() => { + const q = keyword.trim().toLowerCase(); + return products.filter((p) => { + if (!matchFilter(p, filter)) return false; + if (!q) return true; + return `${p.name} ${p.spec}`.toLowerCase().includes(q); + }); + }, [products, keyword, filter]); + function pick(product: PartnerProxyOrderProductOption) { navigate(`/proxy-order?productId=${encodeURIComponent(product.id)}`, { replace: true }); } @@ -30,12 +57,32 @@ export default function ProxyOrderProductsPage() { return (
navigate(-1)} /> +
+ setKeyword(e.target.value)} + /> +
+ {FILTERS.map((f) => ( + + ))} +
+
{loading ?

加载中…

: null} - {!loading && products.length === 0 ? ( -

暂无可售商品

+ {!loading && filtered.length === 0 ? ( +

暂无符合条件的商品

) : null} - {products.map((p) => { + {filtered.map((p) => { const benefit = p.benefitAmount != null ? Number(p.benefitAmount) : Number(p.price); const active = p.id === selectedId; return ( @@ -59,6 +106,11 @@ export default function ProxyOrderProductsPage() {

{p.spec}

好客权益 ¥{fmtMoney(benefit)}

+
+ {p.allowOnlinePurchase !== false ? 线上 : null} + {p.allowCrossCityDelivery !== false ? 跨城 : null} + {p.allowOnSitePickup ? 现场 : null} +
); diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index 83582bc..47a1f7b 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -3374,6 +3374,50 @@ body { color: var(--color-muted, #999); } +.partner-proxy-product-toolbar { + padding: 0 16px 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.partner-proxy-product-filters { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.partner-proxy-product-filter { + border: 1px solid var(--color-border); + background: #fff; + border-radius: 999px; + padding: 4px 12px; + font-size: 12px; + color: var(--color-muted, #888); +} + +.partner-proxy-product-filter.is-active { + border-color: var(--color-heritage-red); + color: var(--color-heritage-red); + background: rgba(166, 29, 36, 0.06); +} + +.partner-proxy-product-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.partner-proxy-tag { + font-size: 11px; + line-height: 16px; + padding: 1px 6px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.04); + color: var(--color-muted, #666); +} + .partner-proxy-product-list { padding: 0 16px 24px; display: flex; diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx index a6274be..d41b2fb 100644 --- a/apps/mini-user/src/pages/home/index.tsx +++ b/apps/mini-user/src/pages/home/index.tsx @@ -35,8 +35,26 @@ type Product = { carouselUrls?: string[] | null; aromaType: string; allowOnSitePickup?: boolean; + allowOnlinePurchase?: boolean; + allowCrossCityDelivery?: boolean; }; +type FulfillmentFilter = 'ALL' | 'ONLINE' | 'CROSS_CITY' | 'ON_SITE'; + +const FULFILLMENT_FILTERS: Array<{ key: FulfillmentFilter; label: string }> = [ + { key: 'ALL', label: '全部' }, + { key: 'ONLINE', label: '线上' }, + { key: 'CROSS_CITY', label: '跨城' }, + { key: 'ON_SITE', label: '现场' }, +]; + +function matchFulfillmentFilter(p: Product, filter: FulfillmentFilter): boolean { + if (filter === 'ALL') return true; + if (filter === 'ONLINE') return p.allowOnlinePurchase !== false; + if (filter === 'CROSS_CITY') return p.allowCrossCityDelivery !== false; + return !!p.allowOnSitePickup; +} + type MiniHomeConfig = { banners: string[]; footerUrl: string | null; @@ -59,6 +77,7 @@ function aromaSectionId(key: AromaKey) { export default function HomePage() { const [activeAroma, setActiveAroma] = useState('QINGXIANG'); + const [fulfillmentFilter, setFulfillmentFilter] = useState('ALL'); const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); const [displayCity, setDisplayCity] = useState('郑州市'); @@ -144,18 +163,23 @@ export default function HomePage() { Taro.navigateTo({ url: returnPath }); } + const filteredProducts = useMemo( + () => products.filter((p) => matchFulfillmentFilter(p, fulfillmentFilter)), + [products, fulfillmentFilter], + ); + const productsByAroma = useMemo(() => { const map: Record = { QINGXIANG: [], JIANGXIANG: [], NONGXIANG: [], }; - for (const p of products) { + for (const p of filteredProducts) { const key = p.aromaType as AromaKey; if (key in map) map[key].push(p); } return map; - }, [products]); + }, [filteredProducts]); const banners = miniHome.banners; const footerUrl = miniHome.footerUrl; @@ -255,15 +279,17 @@ export default function HomePage() { 现场取货 ) : null} - { - e.stopPropagation?.(); - openProductDetail(p.id); - }} - > - 立即购买 - + {p.allowOnlinePurchase !== false ? ( + { + e.stopPropagation?.(); + openProductDetail(p.id); + }} + > + 立即购买 + + ) : null} @@ -309,13 +335,28 @@ export default function HomePage() { {displayCity} + + {FULFILLMENT_FILTERS.map((f) => ( + setFulfillmentFilter(f.key)} + > + {f.label} + + ))} + + {loading ? 加载中… : null} {!loading && products.length === 0 ? ( 当前城市暂无在售商品 ) : null} + {!loading && products.length > 0 && filteredProducts.length === 0 ? ( + 暂无符合履约方式的商品 + ) : null} {!loading && - products.length > 0 && + filteredProducts.length > 0 && AROMA_TABS.map((t) => { const list = productsByAroma[t.key]; return ( diff --git a/apps/mini-user/src/pages/product-detail/index.tsx b/apps/mini-user/src/pages/product-detail/index.tsx index 0b312d4..c3248d2 100644 --- a/apps/mini-user/src/pages/product-detail/index.tsx +++ b/apps/mini-user/src/pages/product-detail/index.tsx @@ -31,6 +31,9 @@ type Product = ProductImageSource & { benefitAmount?: number; benefitDisplay?: number; detailContent?: ProductDetailContentDto | null; + allowOnSitePickup?: boolean; + allowOnlinePurchase?: boolean; + allowCrossCityDelivery?: boolean; }; export default function ProductDetailPage() { @@ -92,6 +95,21 @@ export default function ProductDetailPage() { Taro.navigateTo({ url: returnPath }); } + async function goOnSitePickup() { + if (!productId) return; + const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`; + if (!isLoggedIn()) { + goLogin(returnPath); + return; + } + const ready = await ensurePayReady(returnPath); + if (!ready) return; + Taro.navigateTo({ url: returnPath }); + } + + const allowOnline = product?.allowOnlinePurchase !== false; + const allowOnSite = !!product?.allowOnSitePickup; + if (!product) { return ( @@ -199,9 +217,21 @@ export default function ProductDetailPage() { 首页 - void goBuy()}> - 立即购买 - + {allowOnSite ? ( + void goOnSitePickup()}> + 现场取货 + + ) : null} + {allowOnline ? ( + void goBuy()}> + 立即购买 + + ) : null} + {!allowOnline && !allowOnSite ? ( + + 暂不可购 + + ) : null} ); diff --git a/apps/mini-user/src/styles/home.css b/apps/mini-user/src/styles/home.css index 2aef509..15bfbad 100644 --- a/apps/mini-user/src/styles/home.css +++ b/apps/mini-user/src/styles/home.css @@ -103,6 +103,30 @@ border-bottom-color: var(--color-heritage-red); } +.home-fulfillment-filters { + display: flex; + align-items: center; + gap: 8px; + padding: 4px var(--space-page) 2px; + overflow-x: auto; +} + +.home-fulfillment-chip { + flex-shrink: 0; + padding: 4px 12px; + font-size: 12px; + line-height: 18px; + color: var(--color-subtle-gray); + background: rgba(0, 0, 0, 0.04); + border-radius: 999px; +} + +.home-fulfillment-chip--active { + color: var(--color-heritage-red); + background: rgba(179, 38, 30, 0.1); + font-weight: 600; +} + .home-product-list { display: flex; flex-direction: column; diff --git a/apps/mini-user/src/styles/product-detail.css b/apps/mini-user/src/styles/product-detail.css index 9ca8194..e115655 100644 --- a/apps/mini-user/src/styles/product-detail.css +++ b/apps/mini-user/src/styles/product-detail.css @@ -301,6 +301,25 @@ color: var(--color-on-surface-variant); } +.product-detail-pickup-btn { + flex: 1; + height: 48px; + border-radius: 999px; + background: transparent; + border: 1px solid var(--color-heritage-red); + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.product-detail-pickup-btn-text { + color: var(--color-heritage-red); + font-family: var(--font-headline); + font-size: 15px; + font-weight: 700; +} + .product-detail-buy-btn { flex: 1; height: 48px; @@ -312,6 +331,12 @@ box-shadow: 0 8px 24px rgba(166, 29, 36, 0.2); } +.product-detail-buy-btn--disabled { + background: #c4c4c4; + box-shadow: none; + pointer-events: none; +} + .product-detail-buy-btn-text { color: #fff; font-family: var(--font-headline); diff --git a/packages/shared-types/src/catalog.ts b/packages/shared-types/src/catalog.ts index b522834..e6817c3 100644 --- a/packages/shared-types/src/catalog.ts +++ b/packages/shared-types/src/catalog.ts @@ -27,6 +27,10 @@ export interface ProductDto { detailContent?: ProductDetailContentDto | null; /** 是否允许现场取货下单 */ allowOnSitePickup?: boolean; + /** 是否允许配送到址(同城线上购买) */ + allowOnlinePurchase?: boolean; + /** 是否允许跨城配送 */ + allowCrossCityDelivery?: boolean; } export interface ProductDetailFeatureDto { diff --git a/packages/shared-types/src/trade.ts b/packages/shared-types/src/trade.ts index 3f02fa9..9c38a61 100644 --- a/packages/shared-types/src/trade.ts +++ b/packages/shared-types/src/trade.ts @@ -85,6 +85,8 @@ export type PartnerProxyOrderProductOption = { benefitAmount: number | null; coverUrl?: string | null; allowOnSitePickup?: boolean; + allowOnlinePurchase?: boolean; + allowCrossCityDelivery?: boolean; }; export type PartnerProxyOrderPromoOption = { diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 343c5d1..78e53ec 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -593,6 +593,10 @@ model CommonProductItem { status ProductStatus @default(DRAFT) sortOrder Int @default(0) @map("sort_order") allowOnSitePickup Boolean @default(false) @map("allow_on_site_pickup") + /// 允许配送到址(同城线上购买) + allowOnlinePurchase Boolean @default(true) @map("allow_online_purchase") + /// 允许跨城配送(依附线上购买) + allowCrossCityDelivery Boolean @default(true) @map("allow_cross_city_delivery") /// Online test: only listed phones can see/buy when enabled visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled") coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt diff --git a/server/dukang-api/src/modules/ops/admin-products.service.ts b/server/dukang-api/src/modules/ops/admin-products.service.ts index 71a40c5..a9275d0 100644 --- a/server/dukang-api/src/modules/ops/admin-products.service.ts +++ b/server/dukang-api/src/modules/ops/admin-products.service.ts @@ -24,6 +24,37 @@ function normalizePhones(phones?: string[]): string[] { return out; } +const SKU_AUTO_PREFIX = 'DK'; +const SKU_AUTO_PAD = 6; + +/** 解析履约开关:无线上则强制不可跨城;须至少线上或现场之一 */ +function resolveFulfillmentFlags(input: { + allowOnlinePurchase?: boolean; + allowCrossCityDelivery?: boolean; + allowOnSitePickup?: boolean; + defaults?: { + allowOnlinePurchase: boolean; + allowCrossCityDelivery: boolean; + allowOnSitePickup: boolean; + }; +}) { + const d = input.defaults ?? { + allowOnlinePurchase: true, + allowCrossCityDelivery: true, + allowOnSitePickup: false, + }; + const allowOnlinePurchase = input.allowOnlinePurchase ?? d.allowOnlinePurchase; + const allowOnSitePickup = input.allowOnSitePickup ?? d.allowOnSitePickup; + let allowCrossCityDelivery = input.allowCrossCityDelivery ?? d.allowCrossCityDelivery; + if (!allowOnlinePurchase) { + allowCrossCityDelivery = false; + } + if (!allowOnlinePurchase && !allowOnSitePickup) { + throw new BadRequestException('请至少勾选「允许线上购买」或「允许现场取货」之一'); + } + return { allowOnlinePurchase, allowCrossCityDelivery, allowOnSitePickup }; +} + @Injectable() export class AdminProductsService { constructor(private readonly prisma: PrismaService) {} @@ -96,39 +127,44 @@ export class AdminProductsService { } async create(dto: CreateProductDto) { - const exists = await this.prisma.commonProductItem.findFirst({ - where: { OR: [{ skuCode: dto.skuCode }, { barcode69: dto.barcode69 }] }, + const barcodeExists = await this.prisma.commonProductItem.findFirst({ + where: { barcode69: dto.barcode69 }, + }); + if (barcodeExists) throw new BadRequestException('69 码已存在'); + + const flags = resolveFulfillmentFlags({ + allowOnlinePurchase: dto.allowOnlinePurchase, + allowCrossCityDelivery: dto.allowCrossCityDelivery, + allowOnSitePickup: dto.allowOnSitePickup, }); - if (exists) throw new BadRequestException('SKU 或 69 码已存在'); const phones = normalizePhones(dto.visibilityPhones); const whitelistEnabled = !!dto.visibilityWhitelistEnabled; - const product = await this.prisma.commonProductItem.create({ - data: { - skuCode: dto.skuCode, - barcode69: dto.barcode69, - name: dto.name, - subtitle: dto.subtitle, - aromaType: dto.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG', - spec: dto.spec, - price: dto.price, - benefitAmount: dto.benefitAmount ?? dto.price, - status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE', - sortOrder: dto.sortOrder ?? 0, - allowOnSitePickup: dto.allowOnSitePickup ?? false, - visibilityWhitelistEnabled: whitelistEnabled, - ...(dto.detailContent !== undefined - ? { detailContent: dto.detailContent as Prisma.InputJsonValue } - : {}), - ...(phones.length - ? { - visibilityPhones: { - create: phones.map((phone) => ({ phone })), - }, - } - : {}), - }, + const product = await this.createWithGeneratedSku({ + barcode69: dto.barcode69, + name: dto.name, + subtitle: dto.subtitle, + aromaType: dto.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG', + spec: dto.spec, + price: dto.price, + benefitAmount: dto.benefitAmount ?? dto.price, + status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE', + sortOrder: dto.sortOrder ?? 0, + allowOnSitePickup: flags.allowOnSitePickup, + allowOnlinePurchase: flags.allowOnlinePurchase, + allowCrossCityDelivery: flags.allowCrossCityDelivery, + visibilityWhitelistEnabled: whitelistEnabled, + ...(dto.detailContent !== undefined + ? { detailContent: dto.detailContent as Prisma.InputJsonValue } + : {}), + ...(phones.length + ? { + visibilityPhones: { + create: phones.map((phone) => ({ phone })), + }, + } + : {}), }); if (dto.coverUrl) { @@ -143,7 +179,26 @@ export class AdminProductsService { } async update(id: bigint, dto: UpdateProductDto) { - await this.detail(id); + const existing = await this.prisma.commonProductItem.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('商品不存在'); + + const fulfillmentTouched = + dto.allowOnlinePurchase !== undefined || + dto.allowCrossCityDelivery !== undefined || + dto.allowOnSitePickup !== undefined; + const flags = fulfillmentTouched + ? resolveFulfillmentFlags({ + allowOnlinePurchase: dto.allowOnlinePurchase, + allowCrossCityDelivery: dto.allowCrossCityDelivery, + allowOnSitePickup: dto.allowOnSitePickup, + defaults: { + allowOnlinePurchase: existing.allowOnlinePurchase, + allowCrossCityDelivery: existing.allowCrossCityDelivery, + allowOnSitePickup: existing.allowOnSitePickup, + }, + }) + : null; + await this.prisma.commonProductItem.update({ where: { id }, data: { @@ -154,7 +209,13 @@ export class AdminProductsService { ...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}), ...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}), ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), - ...(dto.allowOnSitePickup !== undefined ? { allowOnSitePickup: dto.allowOnSitePickup } : {}), + ...(flags + ? { + allowOnSitePickup: flags.allowOnSitePickup, + allowOnlinePurchase: flags.allowOnlinePurchase, + allowCrossCityDelivery: flags.allowCrossCityDelivery, + } + : {}), ...(dto.visibilityWhitelistEnabled !== undefined ? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled } : {}), @@ -196,6 +257,46 @@ export class AdminProductsService { return { ok: true }; } + /** 生成 DK + 6 位自增 SKU,冲突重试 */ + private async nextAutoSkuCode(): Promise { + const rows = await this.prisma.commonProductItem.findMany({ + where: { skuCode: { startsWith: SKU_AUTO_PREFIX } }, + select: { skuCode: true }, + }); + let maxSeq = 0; + const re = new RegExp(`^${SKU_AUTO_PREFIX}(\\d+)$`); + for (const row of rows) { + const m = re.exec(row.skuCode); + if (!m) continue; + const n = Number(m[1]); + if (Number.isFinite(n) && n > maxSeq) maxSeq = n; + } + return `${SKU_AUTO_PREFIX}${String(maxSeq + 1).padStart(SKU_AUTO_PAD, '0')}`; + } + + private async createWithGeneratedSku( + data: Omit, + ) { + for (let attempt = 0; attempt < 8; attempt++) { + const skuCode = await this.nextAutoSkuCode(); + try { + return await this.prisma.commonProductItem.create({ + data: { ...data, skuCode }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + const target = err.meta?.target; + const fields = Array.isArray(target) ? target.map(String) : [String(target ?? '')]; + if (fields.some((f) => f.includes('sku'))) { + continue; + } + } + throw err; + } + } + throw new BadRequestException('SKU 生成失败,请重试'); + } + private async syncVisibilityPhones(productId: bigint, phones: string[]) { await this.prisma.$transaction(async (tx) => { await tx.commonProductVisibilityPhone.deleteMany({ where: { productId } }); diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 2c744b1..4ed54c2 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -1068,9 +1068,10 @@ export class SaveHqAccountPermissionsDto { } export class CreateProductDto { + /** 可选;创建时由服务端自增生成,忽略客户端传入 */ + @IsOptional() @IsString() - @IsNotEmpty() - skuCode: string; + skuCode?: string; @IsString() @IsNotEmpty() @@ -1110,6 +1111,14 @@ export class CreateProductDto { @IsBoolean() allowOnSitePickup?: boolean; + @IsOptional() + @IsBoolean() + allowOnlinePurchase?: boolean; + + @IsOptional() + @IsBoolean() + allowCrossCityDelivery?: boolean; + /** 开启后仅白名单手机号在 C 端可见/可购 */ @IsOptional() @IsBoolean() @@ -1173,6 +1182,14 @@ export class UpdateProductDto { @IsBoolean() allowOnSitePickup?: boolean; + @IsOptional() + @IsBoolean() + allowOnlinePurchase?: boolean; + + @IsOptional() + @IsBoolean() + allowCrossCityDelivery?: boolean; + /** 开启后仅白名单手机号在 C 端可见/可购 */ @IsOptional() @IsBoolean() diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 2b52c77..b10c9ae 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -86,6 +86,22 @@ export class TradeService { } } + if (!onSitePickup) { + const allowOnline = product.allowOnlinePurchase !== false; + const allowCross = product.allowCrossCityDelivery !== false; + if (deliveryType === 'LOCAL' && !allowOnline) { + throw new BadRequestException('该商品不支持线上购买'); + } + if (deliveryType === 'CROSS_CITY') { + if (!allowOnline) { + throw new BadRequestException('该商品不支持线上购买'); + } + if (!allowCross) { + throw new BadRequestException('该商品不支持跨城配送'); + } + } + } + const check = validateMinPurchase( deliveryType, body.quantity, @@ -1121,6 +1137,9 @@ export class TradeService { benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null, coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null, allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup, + allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false, + allowCrossCityDelivery: + (p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false, })), promoCodes, stores: stores.map((s) => ({ @@ -1166,6 +1185,19 @@ export class TradeService { if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') { deliveryType = 'CROSS_CITY'; } + const allowOnline = product.allowOnlinePurchase !== false; + const allowCross = product.allowCrossCityDelivery !== false; + if (deliveryType === 'LOCAL' && !allowOnline) { + throw new BadRequestException('该商品不支持线上购买'); + } + if (deliveryType === 'CROSS_CITY') { + if (!allowOnline) { + throw new BadRequestException('该商品不支持线上购买'); + } + if (!allowCross) { + throw new BadRequestException('该商品不支持跨城配送'); + } + } } const check = validateMinPurchase( @@ -1486,6 +1518,9 @@ export class TradeService { benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null, coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null, allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup, + allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false, + allowCrossCityDelivery: + (p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false, })), promoCodes, stores: [],