From a01217539c44a257be3913bdc310964efd8e0e53 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Fri, 21 Aug 2026 15:48:15 +0800 Subject: [PATCH] =?UTF-8?q?v3.5.4=E7=89=88=E6=9C=AC=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ProductSpecsEditor.tsx | 445 ++++++++++++++++++ .../src/components/ProxyOrderModal.tsx | 66 ++- apps/admin-web/src/pages/ProductsPage.tsx | 18 + apps/h5-partner/src/pages/ProxyOrderPage.tsx | 68 ++- .../src/assets/icons/store-benefit.png | Bin 207 -> 2469 bytes .../src/assets/tabbar/benefit-active.png | Bin 207 -> 16879 bytes apps/mini-user/src/assets/tabbar/benefit.png | Bin 224 -> 3076 bytes apps/mini-user/src/components/CouponBadge.tsx | 9 +- apps/mini-user/src/lib/checkout-nav.ts | 5 + .../src/pages/order-confirm-pickup/index.tsx | 14 +- .../src/pages/order-confirm/index.tsx | 26 +- .../src/pages/product-detail/index.tsx | 148 +++++- .../src/pages/store-detail/index.tsx | 2 +- apps/mini-user/src/styles/product-detail.css | 50 ++ docs/杜康好客-v3.5.4-开发文档.md | 62 +++ packages/domain/src/index.test.ts | 26 + packages/domain/src/index.ts | 48 +- packages/shared-types/src/catalog.ts | 76 ++- packages/shared-types/src/trade.ts | 24 + .../prisma/backfill-product-skus.ts | 46 ++ .../prisma/migrate-product-sku-v354.sql | 97 ++++ server/dukang-api/prisma/schema.prisma | 103 +++- server/dukang-api/prisma/seed-v31.ts | 20 + .../src/modules/catalog/catalog.service.ts | 88 +++- .../src/modules/catalog/product-sku.util.ts | 175 +++++++ .../fulfillment/fulfillment.service.ts | 13 +- .../modules/ops/admin-products.controller.ts | 19 +- .../src/modules/ops/admin-products.service.ts | 433 ++++++++++++++++- .../src/modules/ops/dto/admin-mutate.dto.ts | 106 +++++ .../src/modules/ops/dto/hq-proxy-order.dto.ts | 8 + .../trade/dto/partner-proxy-order.dto.ts | 8 + .../src/modules/trade/trade.service.ts | 222 ++++++--- 32 files changed, 2278 insertions(+), 147 deletions(-) create mode 100644 apps/admin-web/src/components/ProductSpecsEditor.tsx create mode 100644 docs/杜康好客-v3.5.4-开发文档.md create mode 100644 server/dukang-api/prisma/backfill-product-skus.ts create mode 100644 server/dukang-api/prisma/migrate-product-sku-v354.sql create mode 100644 server/dukang-api/src/modules/catalog/product-sku.util.ts diff --git a/apps/admin-web/src/components/ProductSpecsEditor.tsx b/apps/admin-web/src/components/ProductSpecsEditor.tsx new file mode 100644 index 0000000..84fccba --- /dev/null +++ b/apps/admin-web/src/components/ProductSpecsEditor.tsx @@ -0,0 +1,445 @@ +import { useMemo, useState } from 'react'; +import { + Button, Input, InputNumber, Radio, Select, Space, Switch, Table, Typography, message, +} from 'antd'; +import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; +import { request } from '../lib/api'; +import { PRODUCT_STATUS_LABELS } from '../lib/constants'; + +type SpecValue = { id?: string; name: string; sortOrder?: number }; +type SpecAttr = { id?: string; name: string; sortOrder?: number; values: SpecValue[] }; +type SkuRow = { + id?: string; + specValueIds: string[]; + skuCode?: string; + barcode69: string; + price: number; + benefitAmount?: number; + status: string; + allowOnlinePurchase: boolean; + allowCrossCityDelivery: boolean; + allowOnSitePickup: boolean; + saleUnit: 'BOTTLE' | 'BOX'; + bottlesPerUnit: number; + isDefault: boolean; + sortOrder?: number; + specText?: string; +}; + +function cartesian(attrs: SpecAttr[]): string[][] { + if (!attrs.length) return [[]]; + return attrs.reduce( + (acc, attr) => { + const next: string[][] = []; + const values = attr.values.filter((v) => v.name?.trim()); + for (const prev of acc) { + for (const v of values) { + const vid = v.id || `__new__:${attr.name}:${v.name}`; + next.push([...prev, vid]); + } + } + return next.length ? next : acc; + }, + [[]], + ); +} + +function keyOf(ids: string[]) { + return [...ids].sort().join('_'); +} + +type Props = { + productId: string; + initialAttrs: SpecAttr[]; + initialSkus: SkuRow[]; + onSaved: () => void; +}; + +export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) { + const [attrs, setAttrs] = useState( + initialAttrs.length + ? initialAttrs + : [], + ); + const [skus, setSkus] = useState( + initialSkus.length + ? initialSkus.map((s) => ({ + ...s, + specValueIds: s.specValueIds ?? [], + status: s.status || 'ON_SALE', + allowOnlinePurchase: s.allowOnlinePurchase !== false, + allowCrossCityDelivery: s.allowCrossCityDelivery !== false, + allowOnSitePickup: !!s.allowOnSitePickup, + saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE', + bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1), + isDefault: !!s.isDefault, + })) + : [ + { + specValueIds: [], + barcode69: '', + price: 0, + status: 'ON_SALE', + allowOnlinePurchase: true, + allowCrossCityDelivery: true, + allowOnSitePickup: false, + saleUnit: 'BOTTLE', + bottlesPerUnit: 1, + isDefault: true, + }, + ], + ); + const [saving, setSaving] = useState(false); + + const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]); + + function regenerateMatrix() { + const byKey = new Map(skus.map((s) => [keyOf(s.specValueIds ?? []), s])); + const next: SkuRow[] = combos.map((ids, i) => { + const existing = byKey.get(keyOf(ids)); + if (existing) return { ...existing, specValueIds: ids, sortOrder: i }; + return { + specValueIds: ids, + barcode69: '', + price: skus[0]?.price ?? 0, + benefitAmount: skus[0]?.benefitAmount, + status: 'DRAFT', + allowOnlinePurchase: true, + allowCrossCityDelivery: true, + allowOnSitePickup: false, + saleUnit: 'BOTTLE', + bottlesPerUnit: 1, + isDefault: i === 0, + sortOrder: i, + }; + }); + if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true; + setSkus(next.length ? next : skus); + } + + async function handleSave() { + setSaving(true); + try { + // 1) 保存规格轴(新建值尚无 id,先提交 attrs) + const specsRes = await request<{ + specAttrs: SpecAttr[]; + skus: SkuRow[]; + }>(`/admin/products/${productId}/specs`, { + method: 'PUT', + body: JSON.stringify({ + attrs: attrs.map((a, i) => ({ + id: a.id, + name: a.name.trim(), + sortOrder: i, + values: a.values + .filter((v) => v.name.trim()) + .map((v, j) => ({ id: v.id, name: v.name.trim(), sortOrder: j })), + })), + }), + }); + + // 映射临时 id → 真实 id + const nameToId = new Map(); + for (const a of specsRes.specAttrs ?? []) { + for (const v of a.values ?? []) { + nameToId.set(`${a.name}:${v.name}`, v.id!); + } + } + const resolveIds = (ids: string[]) => + ids.map((id) => { + if (!id.startsWith('__new__:')) return id; + const [, attrName, valueName] = id.split(':'); + const real = nameToId.get(`${attrName}:${valueName}`); + if (!real) throw new Error(`规格值未创建:${attrName}/${valueName}`); + return real; + }); + + const payloadSkus = skus.map((s, i) => ({ + id: s.id, + specValueIds: resolveIds(s.specValueIds ?? []), + skuCode: s.skuCode, + barcode69: s.barcode69, + price: s.price, + benefitAmount: s.benefitAmount, + status: s.status, + allowOnlinePurchase: s.allowOnlinePurchase, + allowCrossCityDelivery: s.allowCrossCityDelivery, + allowOnSitePickup: s.allowOnSitePickup, + saleUnit: s.saleUnit, + bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1, + isDefault: !!s.isDefault, + sortOrder: i, + })); + + for (const row of payloadSkus) { + if (!row.barcode69?.trim()) throw new Error('请填写全部 SKU 的 69 码'); + } + + await request(`/admin/products/${productId}/skus`, { + method: 'PUT', + body: JSON.stringify({ skus: payloadSkus }), + }); + message.success('规格与 SKU 已保存'); + onSaved(); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + } + + return ( +
+ + 先配置销售规格轴(如「包装」),再生成 SKU 矩阵。未配置规格时仅保留默认一行 SKU,与基础信息价/码同步。 + + + 规格轴 + {attrs.map((attr, ai) => ( +
+ + { + const next = [...attrs]; + next[ai] = { ...attr, name: e.target.value }; + setAttrs(next); + }} + /> + +
+
+ ))} + + + + + + SKU 列表 + String(i)} + pagination={false} + scroll={{ x: 1100 }} + dataSource={skus} + columns={[ + { + title: '规格', + width: 140, + render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'), + }, + { + title: '69码', + width: 140, + render: (_, row, index) => ( + { + const next = [...skus]; + next[index] = { ...row, barcode69: e.target.value }; + setSkus(next); + }} + /> + ), + }, + { + title: '售价', + width: 100, + render: (_, row, index) => ( + { + const next = [...skus]; + next[index] = { ...row, price: Number(v) || 0 }; + setSkus(next); + }} + /> + ), + }, + { + title: '权益', + width: 100, + render: (_, row, index) => ( + { + const next = [...skus]; + next[index] = { ...row, benefitAmount: v == null ? undefined : Number(v) }; + setSkus(next); + }} + /> + ), + }, + { + title: '状态', + width: 110, + render: (_, row, index) => ( + { + const next = [...skus]; + next[index] = { + ...row, + saleUnit, + bottlesPerUnit: saleUnit === 'BOX' ? row.bottlesPerUnit || 6 : 1, + }; + setSkus(next); + }} + /> + {row.saleUnit === 'BOX' ? ( + { + const next = [...skus]; + next[index] = { ...row, bottlesPerUnit: Number(v) || 6 }; + setSkus(next); + }} + /> + ) : null} + + ), + }, + { + title: '履约', + width: 200, + render: (_, row, index) => ( + + { + const next = [...skus]; + next[index] = { + ...row, + allowOnlinePurchase: checked, + allowCrossCityDelivery: checked ? row.allowCrossCityDelivery : false, + }; + setSkus(next); + }} + /> + { + const next = [...skus]; + next[index] = { ...row, allowCrossCityDelivery: checked }; + setSkus(next); + }} + /> + { + const next = [...skus]; + next[index] = { ...row, allowOnSitePickup: checked }; + setSkus(next); + }} + /> + + ), + }, + { + title: '默认', + width: 70, + render: (_, row, index) => ( + { + setSkus(skus.map((s, i) => ({ ...s, isDefault: i === index }))); + }} + /> + ), + }, + ]} + /> + + + + ); +} diff --git a/apps/admin-web/src/components/ProxyOrderModal.tsx b/apps/admin-web/src/components/ProxyOrderModal.tsx index 9c387f0..e7b26ba 100644 --- a/apps/admin-web/src/components/ProxyOrderModal.tsx +++ b/apps/admin-web/src/components/ProxyOrderModal.tsx @@ -51,6 +51,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder const [regionCodes, setRegionCodes] = useState([]); const [addressDetail, setAddressDetail] = useState(''); const [productId, setProductId] = useState(); + const [skuId, setSkuId] = useState(); const [quantity, setQuantity] = useState(2); const [promoCodeId, setPromoCodeId] = useState(); const [deliveryMode, setDeliveryMode] = useState('ADDRESS'); @@ -87,9 +88,37 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder }, [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; + const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE'); + const selectedSku = + skuOptions.find((s) => s.id === skuId) || + skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) || + skuOptions[0]; + const allowOnline = selectedSku + ? selectedSku.allowOnlinePurchase !== false + : selectedProduct + ? selectedProduct.allowOnlinePurchase !== false + : true; + const allowOnSite = selectedSku + ? !!selectedSku.allowOnSitePickup + : !!selectedProduct?.allowOnSitePickup; + const allowCrossCity = selectedSku + ? selectedSku.allowCrossCityDelivery !== false + : selectedProduct + ? selectedProduct.allowCrossCityDelivery !== false + : true; + + useEffect(() => { + if (!open || !selectedProduct) return; + const def = + skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) || + skuOptions.find((s) => s.isDefault) || + skuOptions[0]; + if (def && skuId !== def.id && (!skuId || !skuOptions.some((s) => s.id === skuId))) { + setSkuId(def.id); + setQuantity(def.saleUnit === 'BOX' ? 1 : Math.max(quantity, 2)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, productId, selectedProduct?.id]); useEffect(() => { if (!open || !selectedProduct) return; @@ -115,6 +144,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder productId, quantity, deliveryMode, + skuId: skuId || undefined, receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined, receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined, }), @@ -124,7 +154,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder .finally(() => setPreviewLoading(false)); }, 300); return () => window.clearTimeout(timer); - }, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]); + }, [open, productId, skuId, quantity, deliveryMode, region?.city, region?.district, step]); useEffect(() => () => stopPoll(), []); @@ -135,6 +165,8 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder setRegionCodes([]); setAddressDetail(''); setQuantity(2); + setProductId(undefined); + setSkuId(undefined); setPromoCodeId(undefined); setDeliveryMode('ADDRESS'); setAutoReceive(false); @@ -202,6 +234,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder productId: productId!, quantity, promoCodeId: promoCodeId || undefined, + skuId: skuId || undefined, }; setSubmitting(true); @@ -317,7 +350,10 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder loading={loadingOptions} placeholder="选择商品" value={productId} - onChange={setProductId} + onChange={(id) => { + setProductId(id); + setSkuId(undefined); + }} options={(options?.products ?? []).map((p) => ({ value: p.id, label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`, @@ -325,7 +361,25 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder /> - + {skuOptions.length > 1 || selectedProduct?.specEnabled ? ( + + { + const id = e.target.value; + setSkuId(id); + const sku = skuOptions.find((s) => s.id === id); + if (sku?.saleUnit === 'BOX') setQuantity(1); + }} + > + {skuOptions.map((s) => ( + + ))} + + + + ) : null} +
- +
i5UE6UQh=r(H+q+G6H%WyM52%$$ zo`6&!A{Fr?68|J65&~ZOgwjx;5~>i0kQN>gf=Wn;ChJYpN=Q%&D)fPu{t)E>kRUj_ z4zbs16I7DccAWLSGY6fydsD~1>v*$!*Snu|wR`WKnLGE}bAM*eoO5P@Hri;TjW!|> z8jR=rc77yK_8ww<(1N!Ds)xvW0DX6HX?~Jf1%SP(K-ZY?U(c$yzBD=dceE0T`QGh2 zJ?;J5fWZt95ju#VNjg(~cNh%Az#L0E3?e{-5SU=s;H3+rs3Sbo8BFMs&jcy>Bt$S~ zg#;mE4ebcsTz?^5D?$PUD{WNTZwYlimC7FNCUtDunss)AahKA#&i0!F^C0JPUG7UcDUX;SHY7e$Di+Gj2gHCb zvvo$asz{%F%DG$*@ACmEkC-49>aY}Yz1fF1C)96ejKOBDG2`3M4qqz#4lzV*Byk}u z)TWir<9~$Mk_c_DMQY+cwY`RzA~x!Ls(q;%hCX&LhcPz4A#4ufAV<;(2irH)AcPY=7X6h(K2kaOcrIDJSKnp469i=C>PL zNz5mA9QKHRC8n@*Ij!@dJkOoAJ|B*>6ZR)+cRdhIaLY-yj}DcJKdYurRsGNPXLzgU z;YLLQY6Y&rOGhtJ%W+ADjc%oIH3%?Vg?DIGbZcrI_6h?-TJ| zq|7wbKM#$cdsD8xa9`#VzNLgbOO|hnh%KW$Tq^vfX-Tw1|Jb5E7-L-4mdxy(m4D;z@h45MY^5wvHc{x%>aJ^E7KxZ2|v*(go zmS0pn&0%N57_e_`Rc<1|NPm_^5@m_#8{R!IS$O@X$|rYxoxs0{Jlj*&e&gUo;U`$8 zolRP~yv7@B#ZF!H{CqOYTa?C>3+^wzzfy8lhf>b|y=f1N#)v&!TDhINPJb*MO3W?! zg98)ipYaI~%T4iEca!>YK9%0PWCQbFMG`v~;$$+zJ=(*&Yb3DgW@k&Z=kAmCMofzf4bVb`D8Ye zV0zXg=TJTJ$mIDq(267uOn(+$7Xl^kT7~)Be_W~j{3s7I7ka#ebLSr{6`w|(5gQT( z#1!UoI}g#y{uD`=7o6SEP>@9k}5?`q%t4vQuJ*x zX#&P@-TJw(KZqBOOUP2JJfSg{+L2yZKtwHyI4Y*IQILwjxkhx@^?w5~f@PWsze#2T zf!HoeVv_4Jc8WBLUsDJb!&|=R`qB-_S&S8lfJ>8o@2s*;UWh0n7g*~=R=#&BEdym; zHvz?xL}KzH8_=n=P0TG-PPAE;iv%12*~9XEMz(PfH6)cJRwP2vW-(o&X~ifzy|9QR zGbNOAM)gps`1H`k*nh95jO>Gf%UWPIkJi20h!dEP2!XniUvs#0esWO}S-ZO}X!EWS ze@9k3;&4#J-)dP%n!A|yyxYSQ#UETZq35PsX>n8>MXQp?#LkT+UZT~k z=7kYrLwsD+SyDehdN6^Te2jzs3* zOrqjblz(2+2JmwUlA+&ecHpcF&G>#ytqtLPeq3I zQp@YXi|79(9)EWsP=*8ooo-+7|NLgivJ$`dvcIbLw&kRqFotJVadbjNoiMA4l zv4FVyPbaf%tlc+w2RBmQF%t9J@88+Ocu8Jr+P)`>J%7sW5I7-MMD-*CSJc6fdT408 z_{vgk`I;_n>+Hl8Mg5efbO8q{U%&Zn7 zjy=SCrd0S}V|Cqv5x9t4_{V9@o&_%wSXpb|7JpVHkyhDAGgrf1r;Wt21X2ygL`h-m zSkm#d@!`2l3tm5oitsu4F9ubK!nMi)FuIgWA^6H_991<8oaoCO>+!s!WrSnLh89Z- z$2cMIT9uo|tN;6g{_K#akdtWK{BY$)gZMV(u$I3%P%8ei(fw(ojW*h7qm4G&Xah}) a{}lr}XSGuCF)~yD0000 z00s>$eYx)?-CYD-MFNqD%{$f`ryeO^|K*{Zuxy@5DwA8=pH1O28w3^8zX<_VE@xWD z^D|vPe$o9%7s0v0+_F3FM%z-_zC4Wt~$( F69BEkLRJ6( diff --git a/apps/mini-user/src/assets/tabbar/benefit-active.png b/apps/mini-user/src/assets/tabbar/benefit-active.png index d14eb8fabe6432a42eef0b4bcd10f33a1202aee7..917d17a024545f1ac97a23fe7522cbad1168ac11 100644 GIT binary patch literal 16879 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 XAL3;hE>9KxO9l%QYvW==_wauM_LoCU delta 180 zcmaFg%y^!0f=c}~PZ!6Kid%1Q9OP^;;BmO9_vq}+*uXDAg$+$#FQ|Nwdm?AYl^e~> z00s>$eYx)?-CYD-MFNqD%{$f`ryeO^|K*{Zuxy@5DwA8=pH1O28w3^8zX<_VE@xWD z^D|vPe$o9%7s0v0+_F3FM%z-_zC4Wt~$( F696waLlFP~ diff --git a/apps/mini-user/src/assets/tabbar/benefit.png b/apps/mini-user/src/assets/tabbar/benefit.png index 24d899852678ef3fddf6924b3535664d468eb79a..996943c5ac10f9cc0eac8572e51f5aab645de9e5 100644 GIT binary patch literal 3076 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 diff --git a/apps/mini-user/src/components/CouponBadge.tsx b/apps/mini-user/src/components/CouponBadge.tsx index 694db7c..5f0da61 100644 --- a/apps/mini-user/src/components/CouponBadge.tsx +++ b/apps/mini-user/src/components/CouponBadge.tsx @@ -1,19 +1,20 @@ import { Text, View } from '@tarojs/components'; -import BenefitFigure from './BenefitFigure'; type CouponBadgeProps = { amount: number | string; label?: string; }; -/** Taro 友好版权益角标(对齐 shared-ui CouponBadge) */ +/** 首页商品角标:纯文案「享{amount}好客权益」(无门店图标) */ export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) { const n = Number(amount); const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount); return ( - - + + 享{display} + {label} + ); } diff --git a/apps/mini-user/src/lib/checkout-nav.ts b/apps/mini-user/src/lib/checkout-nav.ts index e04e237..171481c 100644 --- a/apps/mini-user/src/lib/checkout-nav.ts +++ b/apps/mini-user/src/lib/checkout-nav.ts @@ -1,5 +1,6 @@ export type CheckoutContext = { productId?: string; + skuId?: string; qty?: string; addressId?: string; cross?: boolean; @@ -9,6 +10,7 @@ export type CheckoutContext = { export function buildQuery(ctx: CheckoutContext): string { const parts: string[] = []; if (ctx.productId) parts.push(`productId=${encodeURIComponent(ctx.productId)}`); + if (ctx.skuId) parts.push(`skuId=${encodeURIComponent(ctx.skuId)}`); if (ctx.qty) parts.push(`qty=${encodeURIComponent(ctx.qty)}`); if (ctx.addressId) parts.push(`addressId=${encodeURIComponent(ctx.addressId)}`); if (ctx.cross) parts.push('cross=1'); @@ -36,12 +38,14 @@ export function buildAddressEditUrl(id: string | undefined, ctx: CheckoutContext export function buildPayUrl(params: { orderId: string; productId?: string; + skuId?: string; qty?: string; addressId?: string; cross?: boolean; }): string { const parts = [`orderId=${encodeURIComponent(params.orderId)}`]; if (params.productId) parts.push(`productId=${encodeURIComponent(params.productId)}`); + if (params.skuId) parts.push(`skuId=${encodeURIComponent(params.skuId)}`); if (params.qty) parts.push(`qty=${encodeURIComponent(params.qty)}`); if (params.addressId) parts.push(`addressId=${encodeURIComponent(params.addressId)}`); if (params.cross) parts.push('cross=1'); @@ -51,6 +55,7 @@ export function buildPayUrl(params: { export function readCheckoutContext(params: Record): CheckoutContext { return { productId: params.productId, + skuId: params.skuId, qty: params.qty, addressId: params.addressId, cross: params.cross === '1', diff --git a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx index b15fb49..e3fef70 100644 --- a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx +++ b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx @@ -32,12 +32,14 @@ type OrderPreview = { quantityOk?: boolean; quantityMessage?: string | null; minQty?: number; + saleUnit?: 'BOTTLE' | 'BOX'; }; export default function OrderConfirmPickupPage() { const router = useRouter(); const productId = router.params.productId ?? ''; - const [quantity, setQuantity] = useState(Math.max(2, Number(router.params.qty || 2))); + const skuId = router.params.skuId ?? ''; + const [quantity, setQuantity] = useState(Math.max(1, Number(router.params.qty || 2))); const [preview, setPreview] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); const [loading, setLoading] = useState(false); @@ -50,7 +52,7 @@ export default function OrderConfirmPickupPage() { setPreviewLoading(true); request('/trade/orders/preview', { method: 'POST', - data: { productId, quantity, onSitePickup: true }, + data: { productId, quantity, onSitePickup: true, ...(skuId ? { skuId } : {}) }, }) .then((data) => { if (!cancelled) { @@ -70,15 +72,16 @@ export default function OrderConfirmPickupPage() { return () => { cancelled = true; }; - }, [productId, quantity]); + }, [productId, skuId, quantity]); + const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶'; const minQty = preview?.minQty ?? 2; const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false; const canSubmit = !!preview && quantityOk && !loading && !previewLoading; function updateQuantity(next: number) { if (next < minQty) { - const tip = `现场提货至少购买 ${minQty} 瓶`; + const tip = `现场提货至少购买 ${minQty}${unitLabel}`; toast(tip); setMsg(tip); if (next < 1) return; @@ -91,12 +94,13 @@ export default function OrderConfirmPickupPage() { async function doSubmit() { const order = await request<{ id: string }>('/trade/orders', { method: 'POST', - data: { productId, quantity, onSitePickup: true }, + data: { productId, quantity, onSitePickup: true, ...(skuId ? { skuId } : {}) }, }); Taro.redirectTo({ url: buildPayUrl({ orderId: order.id, productId, + skuId: skuId || undefined, qty: String(quantity), }), }); diff --git a/apps/mini-user/src/pages/order-confirm/index.tsx b/apps/mini-user/src/pages/order-confirm/index.tsx index 827025d..70bb272 100644 --- a/apps/mini-user/src/pages/order-confirm/index.tsx +++ b/apps/mini-user/src/pages/order-confirm/index.tsx @@ -54,6 +54,9 @@ type OrderPreview = { minQty?: number; allowCrossCityDelivery?: boolean; allowOnlinePurchase?: boolean; + skuId?: string; + saleUnit?: 'BOTTLE' | 'BOX'; + bottlesPerUnit?: number; }; const CROSS_CITY_BLOCK_MSG = '该商品不支持跨城配送,请更换为开城城市内的收货地址'; @@ -66,6 +69,7 @@ export default function OrderConfirmPage() { const router = useRouter(); const checkoutCtx = readCheckoutContext(router.params); const productId = checkoutCtx.productId ?? ''; + const skuId = checkoutCtx.skuId ?? ''; const forceCross = checkoutCtx.cross === true; const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2))); const [addresses, setAddresses] = useState([]); @@ -96,11 +100,12 @@ export default function OrderConfirmPage() { if (!productId) return; let cancelled = false; setPreviewLoading(true); - const body: { productId: string; quantity: number; addressId?: string } = { + const body: { productId: string; quantity: number; addressId?: string; skuId?: string } = { productId, quantity, }; if (addressId) body.addressId = addressId; + if (skuId) body.skuId = skuId; request('/trade/orders/preview', { method: 'POST', data: body }) .then((data) => { @@ -139,7 +144,7 @@ export default function OrderConfirmPage() { return () => { cancelled = true; }; - }, [productId, quantity, addressId]); + }, [productId, skuId, quantity, addressId]); const selectedAddress = useMemo( () => addresses.find((a) => String(a.id) === addressId), @@ -157,6 +162,7 @@ export default function OrderConfirmPage() { forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross; const crossBlocked = isCross && !allowCross; const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked; + const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶'; const minQty = preview?.minQty ?? (isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2)); @@ -171,8 +177,8 @@ export default function OrderConfirmPage() { function updateQuantity(next: number) { if (next < minQty) { const tip = isCross - ? `跨城配送至少购买 ${minQty} 瓶(1箱)` - : `同城配送至少购买 ${minQty} 瓶`; + ? `跨城配送至少购买 ${minQty}${unitLabel}` + : `同城配送至少购买 ${minQty}${unitLabel}`; toast(tip); setMsg(tip); if (next < 1) return; @@ -195,6 +201,7 @@ export default function OrderConfirmPage() { productId, quantity, addressId, + ...(skuId ? { skuId } : {}), ...(clientLocation ? { clientLocation } : {}), }, }); @@ -202,6 +209,7 @@ export default function OrderConfirmPage() { url: buildPayUrl({ orderId: order.id, productId, + skuId: skuId || undefined, qty: String(quantity), addressId, cross: forceCross, @@ -223,15 +231,15 @@ export default function OrderConfirmPage() { } if (!quantityOk) { const tip = isCross - ? `跨城配送至少购买 ${minQty} 瓶(1箱)` - : `同城配送至少购买 ${minQty} 瓶`; + ? `跨城配送至少购买 ${minQty}${unitLabel}` + : `同城配送至少购买 ${minQty}${unitLabel}`; setMsg(tip); toast(tip); } return; } - const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`; + const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${skuId ? `&skuId=${skuId}` : ''}${forceCross ? '&cross=1' : ''}`; if (!phonePromptSkipped.current) { try { @@ -371,8 +379,8 @@ export default function OrderConfirmPage() { {!quantityOk ? ( {isCross - ? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量` - : `同城配送至少购买 ${minQty} 瓶,请调整数量`} + ? `跨城配送至少购买 ${minQty}${unitLabel},请调整数量` + : `同城配送至少购买 ${minQty}${unitLabel},请调整数量`} ) : null} diff --git a/apps/mini-user/src/pages/product-detail/index.tsx b/apps/mini-user/src/pages/product-detail/index.tsx index 5b16120..ee824f7 100644 --- a/apps/mini-user/src/pages/product-detail/index.tsx +++ b/apps/mini-user/src/pages/product-detail/index.tsx @@ -8,7 +8,7 @@ import Taro, { useShareAppMessage, useShareTimeline, } from '@tarojs/taro'; -import type { ProductDetailContentDto } from '@dukang/shared-types'; +import type { ProductDetailContentDto, ProductSkuDto, ProductSpecAttrDto } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; import PageNavBar from '../../components/PageNavBar'; import ProductCarousel from '../../components/ProductCarousel'; @@ -47,8 +47,39 @@ type Product = ProductImageSource & { allowOnSitePickup?: boolean; allowOnlinePurchase?: boolean; allowCrossCityDelivery?: boolean; + specEnabled?: boolean; + specAttrs?: ProductSpecAttrDto[]; + skus?: ProductSkuDto[]; + defaultSkuId?: string; + saleUnit?: 'BOTTLE' | 'BOX'; }; +function findSku( + skus: ProductSkuDto[], + selected: Record, + attrs: ProductSpecAttrDto[], +): ProductSkuDto | undefined { + const valueIds = attrs.map((a) => selected[a.id]).filter(Boolean); + if (valueIds.length !== attrs.length) return undefined; + const key = [...valueIds].sort().join('_'); + return skus.find((s) => [...s.specValueIds].sort().join('_') === key); +} + +function isValueAvailable( + skus: ProductSkuDto[], + attrs: ProductSpecAttrDto[], + selected: Record, + attrId: string, + valueId: string, +): boolean { + const trial = { ...selected, [attrId]: valueId }; + const partialIds = attrs.map((a) => trial[a.id]).filter(Boolean); + return skus.some((s) => { + if (s.status !== 'ON_SALE') return false; + return partialIds.every((id) => s.specValueIds.includes(id)); + }); +} + export default function ProductDetailPage() { const router = useRouter(); const productId = router.params.id ?? ''; @@ -58,6 +89,7 @@ export default function ProductDetailPage() { ); const [product, setProduct] = useState(null); const [headerSolid, setHeaderSolid] = useState(false); + const [selected, setSelected] = useState>({}); usePageScroll(({ scrollTop }) => { setHeaderSolid(scrollTop > 100); @@ -72,7 +104,25 @@ export default function ProductDetailPage() { toast('商品不存在或暂未开放'); return; } - setProduct(normalizeFulfillmentFlags(p)); + const normalized = normalizeFulfillmentFlags(p); + setProduct(normalized); + const attrs = p.specAttrs ?? []; + const skus = p.skus ?? []; + const def = + skus.find((s) => s.id === p.defaultSkuId) || + skus.find((s) => s.isDefault && s.status === 'ON_SALE') || + skus.find((s) => s.status === 'ON_SALE') || + skus[0]; + if (def && attrs.length) { + const next: Record = {}; + for (const attr of attrs) { + const hit = attr.values.find((v) => def.specValueIds.includes(v.id)); + if (hit) next[attr.id] = hit.id; + } + setSelected(next); + } else { + setSelected({}); + } }) .catch((e) => { setProduct(null); @@ -84,11 +134,38 @@ export default function ProductDetailPage() { loadProduct(); }, [loadProduct]); - // 登录后返回详情须带 token 重拉,否则白名单商品会一直空白 useDidShow(() => { loadProduct(); }); + const attrs = product?.specAttrs ?? []; + const skus = product?.skus ?? []; + const specEnabled = !!(product?.specEnabled && attrs.length > 0); + const activeSku = useMemo(() => { + if (!product) return undefined; + if (!specEnabled) { + return ( + skus.find((s) => s.id === product.defaultSkuId) || + skus.find((s) => s.isDefault) || + skus.find((s) => s.status === 'ON_SALE') || + skus[0] + ); + } + return findSku(skus, selected, attrs); + }, [product, specEnabled, skus, selected, attrs]); + + const displayPrice = activeSku ? Number(activeSku.price) : Number(product?.price ?? 0); + const displayBenefit = activeSku + ? Number(activeSku.benefitAmount) + : Number(product?.benefitDisplay ?? product?.benefitAmount ?? product?.price ?? 0); + const fulfillment = activeSku + ? { + allowOnlinePurchase: activeSku.allowOnlinePurchase, + allowCrossCityDelivery: activeSku.allowCrossCityDelivery, + allowOnSitePickup: activeSku.allowOnSitePickup, + } + : product; + const sharePayload = useMemo( () => buildSceneSharePayload('productDetail', { @@ -118,9 +195,23 @@ export default function ProductDetailPage() { Taro.switchTab({ url: '/pages/home/index' }); } + function ensureSkuSelected(): string | null { + if (!specEnabled) return activeSku?.id ?? product?.defaultSkuId ?? null; + if (!activeSku || activeSku.status !== 'ON_SALE') { + toast('请选择完整规格'); + return null; + } + return activeSku.id; + } + async function goBuy() { if (!productId) return; - const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=2`; + const skuId = ensureSkuSelected(); + if (specEnabled && !skuId) return; + const qty = activeSku?.saleUnit === 'BOX' ? 1 : 2; + const qs = [`productId=${productId}`, `qty=${qty}`]; + if (skuId) qs.push(`skuId=${skuId}`); + const returnPath = `/pages/order-confirm/index?${qs.join('&')}`; if (!isLoggedIn()) { goLogin(returnPath); return; @@ -132,7 +223,12 @@ export default function ProductDetailPage() { async function goOnSitePickup() { if (!productId) return; - const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`; + const skuId = ensureSkuSelected(); + if (specEnabled && !skuId) return; + const qty = activeSku?.saleUnit === 'BOX' ? 1 : 1; + const qs = [`productId=${productId}`, `qty=${qty}`]; + if (skuId) qs.push(`skuId=${skuId}`); + const returnPath = `/pages/order-confirm-pickup/index?${qs.join('&')}`; if (!isLoggedIn()) { goLogin(returnPath); return; @@ -151,9 +247,8 @@ export default function ProductDetailPage() { ); } - const allowOnline = canBuyOnline(product); - const allowOnSite = canPickupOnSite(product); - const benefit = Number(product.benefitDisplay ?? product.benefitAmount ?? product.price); + const allowOnline = canBuyOnline(fulfillment ?? {}); + const allowOnSite = canPickupOnSite(fulfillment ?? {}); const carouselImages = getProductCarouselImages(product); const detailImages = getProductDetailImages(product); const detail = product.detailContent ?? {}; @@ -177,12 +272,45 @@ export default function ProductDetailPage() { ¥ - {Number(product.price).toFixed(2)} + {displayPrice.toFixed(2)} {product.name} {product.subtitle ? ( {product.subtitle} ) : null} + {activeSku?.specText ? ( + {activeSku.specText} + ) : null} + + {specEnabled ? ( + + {attrs.map((attr) => ( + + {attr.name} + + {attr.values.map((val) => { + const active = selected[attr.id] === val.id; + const available = isValueAvailable(skus, attrs, selected, attr.id, val.id); + return ( + { + if (!available) return; + setSelected((prev) => ({ ...prev, [attr.id]: val.id })); + }} + > + {val.name} + + ); + })} + + + ))} + + ) : null} @@ -192,7 +320,7 @@ export default function ProductDetailPage() { 买杜康美酒 · 享全城好客礼遇 - + diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index 04e33eb..341dea7 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -453,7 +453,7 @@ export default function StoreDetailPage() { {benefitRule ? ( - 好客权益券使用规则 + 好客权益使用规则 {benefitRule} ) : null} diff --git a/apps/mini-user/src/styles/product-detail.css b/apps/mini-user/src/styles/product-detail.css index fe1b09d..ede1e43 100644 --- a/apps/mini-user/src/styles/product-detail.css +++ b/apps/mini-user/src/styles/product-detail.css @@ -375,3 +375,53 @@ font-size: 16px; font-weight: 700; } + +.product-detail-specs { + margin: 12px 0 4px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.product-detail-spec-row { + display: flex; + flex-direction: column; + gap: 8px; +} + +.product-detail-spec-label { + font-size: 13px; + color: var(--color-on-surface-variant, #666); +} + +.product-detail-spec-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.product-detail-spec-chip { + padding: 6px 14px; + border-radius: 8px; + background: #f5f5f5; + border: 1px solid transparent; +} + +.product-detail-spec-chip.is-active { + background: rgba(166, 29, 36, 0.08); + border-color: var(--color-heritage-red); +} + +.product-detail-spec-chip.is-disabled { + opacity: 0.35; +} + +.product-detail-spec-chip-text { + font-size: 13px; + color: #333; +} + +.product-detail-spec-chip.is-active .product-detail-spec-chip-text { + color: var(--color-heritage-red); + font-weight: 600; +} diff --git a/docs/杜康好客-v3.5.4-开发文档.md b/docs/杜康好客-v3.5.4-开发文档.md new file mode 100644 index 0000000..2132147 --- /dev/null +++ b/docs/杜康好客-v3.5.4-开发文档.md @@ -0,0 +1,62 @@ +# 杜康好客 · v3.5.4 商品规格(SPU + SKU) + +> **2026-08-21** · mini-user / admin-web / h5-partner / API / Prisma +> 目标:商品详情页内可选销售规格;价格、权益、69 码、履约、上下架按 SKU;箱装按瓶当量起购与物流。 + +## 范围 + +| # | 项 | 交付 | +|---|----|------| +| 1 | 数据模型 | `common_product_item` 升级为 SPU;新增 `spec_attr` / `spec_value` / `sku` / `sku_spec`;订单快照 `sku_id` / `sale_unit` / `bottles_per_unit` | +| 2 | 存量迁移 | 每商品 1 个默认瓶装 SKU;SQL:`prisma/migrate-product-sku-v354.sql`;脚本:`prisma/backfill-product-skus.ts` | +| 3 | Catalog | 列表附加 `specEnabled`/`saleUnit`;详情附加 `specAttrs`/`skus`/`defaultSkuId`;旧字段拍平保留 | +| 4 | Trade | preview/create 可选 `skuId`;单可售 SKU 自动回落;起购按瓶当量 | +| 5 | Admin | `PUT /admin/products/:id/specs`、`PUT .../skus`;商品抽屉「规格与 SKU」 | +| 6 | mini-user | 详情规格 chips;确认页带 `skuId`;箱装数量文案 | +| 7 | 代下单 | HQ / 合伙人可选规格 SKU | +| 8 | 权益文案 | 「好客权益券」→「好客权益」;首页角标「享{amount}好客权益」(去掉门店 icon) | + +**不做**:SKU 规格图、库存、把现有酒祖 10/15/20 合并为一个 SPU。 + +## 兼容规则(线上) + +- 不改 `/api/v1` 前缀;**只增字段,不删旧字段语义**。 +- 旧客户端不传 `skuId`:若该 SPU **恰好 1 个可售 SKU** → 自动使用(现网行为);多个可售 → `400 请选择规格`。 +- 旧 admin `PUT /admin/products/:id` 不传规格时:仅同步**唯一**默认 SKU(多规格时不同步,防误改)。 +- SPU 上 `sku_code` / `barcode_69` 去掉唯一约束,唯一下沉到 `common_product_sku`;SPU 列保留为默认 SKU 冗余。 + +## 发版顺序 + +1. API + DB 迁移(每商品仅默认 SKU)。**此时不要给任何商品加第二 SKU。** +2. 发布新 mini-user + admin 规格页 + 代下单 SKU 选择。 +3. 运营再配置「单瓶 / 整箱」等第二规格。 + +## 起购与物流 + +- `bottleQty = quantity × bottlesPerUnit` +- 城市起购阈值仍是瓶;箱装 SKU(`bottlesPerUnit=6`)数量 1 即可过同城 2 / 跨城 6。 +- 大单拦截、承运商 `goodsNum` 使用瓶当量。 + +## 关键表摘要 + +- `common_product_spec_attr` / `common_product_spec_value` +- `common_product_sku`(`sale_unit`=`BOTTLE|BOX`,`bottles_per_unit`) +- `common_product_sku_spec` +- `user_order.sku_id` / `sale_unit` / `bottles_per_unit` + +## 权益文案(mini-user) + +| 位置 | 变更 | +|------|------| +| 门店详情等 | 「好客权益券」→「好客权益」(如「好客权益使用规则」) | +| 首页商品角标 `CouponBadge` | 由「享 + 门店 icon + 金额 + 好客权益」改为纯文案 **「享{amount}好客权益」** | + +其它页 `BenefitFigure`(我的/权益/核销等)仍保留门店核销图标,仅首页角标去 icon。 + +## 验收 + +- [ ] 未配规格:旧小程序/H5/代下单路径与现网一致 +- [ ] 多规格:详情选规格后价格/权益/履约变化;漏选下单 400 +- [ ] 整箱 SKU:数量 1 过起购;订单快照 `bottles_per_unit=6` +- [ ] `GET /catalog/products/:id` 仍含 `id/name/price/spec/skuCode/...` +- [ ] 小程序无「好客权益券」字样;首页角标为「享{金额}好客权益」且无门店 icon diff --git a/packages/domain/src/index.test.ts b/packages/domain/src/index.test.ts index 2392f1e..279592b 100644 --- a/packages/domain/src/index.test.ts +++ b/packages/domain/src/index.test.ts @@ -7,6 +7,8 @@ import { calcOrderBoxCount, shouldHoldAutoCourierDispatch, validateMinPurchase, + toBottleQuantity, + toMinSaleQuantity, validateBusinessHours, formatBusinessHours, validateRedeemAmount, @@ -47,6 +49,30 @@ describe('validateMinPurchase', () => { expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false); expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true); }); + + it('box SKU: 1 box (6 bottles) satisfies local and cross mins', () => { + const opts = { bottlesPerUnit: 6, saleUnit: 'BOX' as const }; + expect(validateMinPurchase('LOCAL', 1, 2, 6, opts).ok).toBe(true); + expect(validateMinPurchase('CROSS_CITY', 1, 2, 6, opts).ok).toBe(true); + expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6, opts).ok).toBe(true); + }); + + it('box SKU message uses 箱', () => { + const r = validateMinPurchase('LOCAL', 0, 2, 6, { bottlesPerUnit: 6, saleUnit: 'BOX' }); + expect(r.ok).toBe(false); + expect(r.message).toContain('箱'); + }); +}); + +describe('toBottleQuantity / toMinSaleQuantity', () => { + it('converts sale qty to bottles', () => { + expect(toBottleQuantity(2, 1)).toBe(2); + expect(toBottleQuantity(1, 6)).toBe(6); + expect(toMinSaleQuantity(2, 1)).toBe(2); + expect(toMinSaleQuantity(2, 6)).toBe(1); + expect(toMinSaleQuantity(6, 6)).toBe(1); + expect(toMinSaleQuantity(7, 6)).toBe(2); + }); }); describe('validateBusinessHours', () => { diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index d1ea227..0f7dcfb 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -7,27 +7,61 @@ export function calcBenefitAmount(product: ProductPricing): number { return product.benefitAmount ?? product.price; } +export type ProductSaleUnit = 'BOTTLE' | 'BOX'; + +/** 销售数量 → 瓶当量 */ +export function toBottleQuantity(quantity: number, bottlesPerUnit = 1): number { + const qty = Math.floor(Number(quantity) || 0); + const per = Math.floor(Number(bottlesPerUnit) || 0); + if (qty <= 0 || per <= 0) return 0; + return qty * per; +} + +/** + * 城市起购瓶数 → 最少销售单位数量(向上取整) + * 例:同城 2 瓶、整箱 6 瓶/箱 → minSaleQty = 1 + */ +export function toMinSaleQuantity(minBottleQty: number, bottlesPerUnit = 1): number { + const minBottles = Math.floor(Number(minBottleQty) || 0); + const per = Math.floor(Number(bottlesPerUnit) || 0); + if (minBottles <= 0) return 1; + if (per <= 0) return minBottles; + return Math.max(1, Math.ceil(minBottles / per)); +} + +export function saleUnitLabel(saleUnit: ProductSaleUnit | string | null | undefined): string { + return saleUnit === 'BOX' ? '箱' : '瓶'; +} + export function validateMinPurchase( deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP', quantity: number, localMinQty: number, crossMinQty: number, + options?: { bottlesPerUnit?: number; saleUnit?: ProductSaleUnit }, ): { ok: boolean; message?: string } { + const bottlesPerUnit = options?.bottlesPerUnit ?? 1; + const saleUnit = options?.saleUnit ?? (bottlesPerUnit > 1 ? 'BOX' : 'BOTTLE'); + const bottleQty = toBottleQuantity(quantity, bottlesPerUnit); + const unit = saleUnitLabel(saleUnit); + if (deliveryType === 'ON_SITE_PICKUP') { - const min = localMinQty > 0 ? localMinQty : 2; - if (quantity < min) { - return { ok: false, message: `现场提货至少购买 ${min} 瓶` }; + const minBottles = localMinQty > 0 ? localMinQty : 2; + if (bottleQty < minBottles) { + const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit); + return { ok: false, message: `现场提货至少购买 ${minSale}${unit}` }; } return { ok: true }; } - const min = deliveryType === 'LOCAL' ? localMinQty : crossMinQty; - if (quantity < min) { + const minBottles = deliveryType === 'LOCAL' ? localMinQty : crossMinQty; + if (bottleQty < minBottles) { + const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit); return { ok: false, message: deliveryType === 'LOCAL' - ? `同城配送至少购买 ${min} 瓶` - : `跨城配送至少购买 ${min} 瓶(1箱)`, + ? `同城配送至少购买 ${minSale}${unit}` + : `跨城配送至少购买 ${minSale}${unit}`, }; } return { ok: true }; diff --git a/packages/shared-types/src/catalog.ts b/packages/shared-types/src/catalog.ts index e6817c3..b389297 100644 --- a/packages/shared-types/src/catalog.ts +++ b/packages/shared-types/src/catalog.ts @@ -8,16 +8,48 @@ export interface CityDto { maxPartnerCommissionRate?: number; } +export type ProductSaleUnit = 'BOTTLE' | 'BOX'; + +export interface ProductSpecValueDto { + id: string; + name: string; +} + +export interface ProductSpecAttrDto { + id: string; + name: string; + values: ProductSpecValueDto[]; +} + +export interface ProductSkuDto { + id: string; + /** 规格值 id 列表(与 specAttrs 轴顺序对应) */ + specValueIds: string[]; + specText: string; + price: number; + benefitAmount: number; + status: string; + allowOnlinePurchase: boolean; + allowCrossCityDelivery: boolean; + allowOnSitePickup: boolean; + saleUnit: ProductSaleUnit; + bottlesPerUnit: number; + isDefault: boolean; +} + export interface ProductDto { id: string; skuCode: string; name: string; subtitle?: string | null; + /** 拍平价:可售 SKU 最低价 / 默认 SKU 价 */ price: number; benefitAmount: number; benefitDisplay?: number; aromaType: string; status: string; + /** 规格文案(默认/展示 SKU) */ + spec?: string; /** 封面图(common_resource COVER / cover_resource_id) */ mainImageUrl?: string | null; /** 轮播图(bizType=CAROUSEL;无则回退封面) */ @@ -25,12 +57,21 @@ export interface ProductDto { /** 详情长图(bizType=DETAIL 或 detailContent JSON) */ detailImageUrls?: string[]; detailContent?: ProductDetailContentDto | null; - /** 是否允许现场取货下单 */ + /** 是否允许现场取货下单(拍平自展示 SKU) */ allowOnSitePickup?: boolean; /** 是否允许配送到址(同城线上购买) */ allowOnlinePurchase?: boolean; /** 是否允许跨城配送 */ allowCrossCityDelivery?: boolean; + /** v3.5.4:是否配置了销售规格(多 SKU) */ + specEnabled?: boolean; + /** 列表轻量:展示 SKU 销售单位 */ + saleUnit?: ProductSaleUnit; + /** 详情:规格轴 */ + specAttrs?: ProductSpecAttrDto[]; + /** 详情:可售 SKU(含 OFF_SALE 供前端灰置时可带 status) */ + skus?: ProductSkuDto[]; + defaultSkuId?: string; } export interface ProductDetailFeatureDto { @@ -68,3 +109,36 @@ export interface ProductDetailTemplateDto { createdAt: string; updatedAt: string; } + +/** Admin 保存规格轴 */ +export interface AdminProductSpecValueInput { + /** 已有值 id;新建可省略 */ + id?: string; + name: string; + sortOrder?: number; +} + +export interface AdminProductSpecAttrInput { + id?: string; + name: string; + sortOrder?: number; + values: AdminProductSpecValueInput[]; +} + +export interface AdminProductSkuInput { + id?: string; + /** 规格值 id;无规格时为空数组 */ + specValueIds?: string[]; + skuCode?: string; + barcode69: string; + price: number; + benefitAmount?: number; + status?: string; + allowOnSitePickup?: boolean; + allowOnlinePurchase?: boolean; + allowCrossCityDelivery?: boolean; + saleUnit?: ProductSaleUnit; + bottlesPerUnit?: number; + isDefault?: boolean; + sortOrder?: number; +} diff --git a/packages/shared-types/src/trade.ts b/packages/shared-types/src/trade.ts index dfedac7..69d5bac 100644 --- a/packages/shared-types/src/trade.ts +++ b/packages/shared-types/src/trade.ts @@ -114,6 +114,22 @@ export type PartnerProxyOrderProductOption = { allowOnSitePickup?: boolean; allowOnlinePurchase?: boolean; allowCrossCityDelivery?: boolean; + specEnabled?: boolean; + saleUnit?: 'BOTTLE' | 'BOX'; + defaultSkuId?: string; + skus?: Array<{ + id: string; + specText: string; + price: number; + benefitAmount: number; + status: string; + allowOnSitePickup: boolean; + allowOnlinePurchase: boolean; + allowCrossCityDelivery: boolean; + saleUnit: 'BOTTLE' | 'BOX'; + bottlesPerUnit: number; + isDefault: boolean; + }>; }; export type PartnerProxyOrderPromoOption = { @@ -145,6 +161,7 @@ export type PartnerProxyOrderPreviewRequest = { storeId?: string; receiverCity?: string; receiverDistrict?: string; + skuId?: string; }; export type PartnerProxyOrderPreviewResult = { @@ -153,6 +170,11 @@ export type PartnerProxyOrderPreviewResult = { benefitAmount: number; deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP'; unitPrice: number; + skuId?: string; + saleUnit?: 'BOTTLE' | 'BOX'; + bottlesPerUnit?: number; + bottleQuantity?: number; + minQuantity?: number; }; export type PartnerProxyOrderCreateRequest = { @@ -169,6 +191,7 @@ export type PartnerProxyOrderCreateRequest = { productId: string; quantity: number; promoCodeId?: string; + skuId?: string; }; /** 合伙人代下单列表项(与 OrderDto 兼容,附带收货信息) */ @@ -203,4 +226,5 @@ export type HqProxyOrderCreateRequest = { productId: string; quantity: number; promoCodeId?: string; + skuId?: string; }; diff --git a/server/dukang-api/prisma/backfill-product-skus.ts b/server/dukang-api/prisma/backfill-product-skus.ts new file mode 100644 index 0000000..14ecb67 --- /dev/null +++ b/server/dukang-api/prisma/backfill-product-skus.ts @@ -0,0 +1,46 @@ +/** + * v3.5.4:为尚无 SKU 的商品回填默认 SKU(与 migrate-product-sku-v354.sql 一致) + * 用法:cd server/dukang-api && npx ts-node prisma/backfill-product-skus.ts + */ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + const products = await prisma.commonProductItem.findMany({ + include: { skus: { select: { id: true }, take: 1 } }, + }); + let created = 0; + for (const p of products) { + if (p.skus.length > 0) continue; + await prisma.commonProductSku.create({ + data: { + productId: p.id, + skuCode: p.skuCode, + barcode69: p.barcode69, + specKey: '', + specText: p.spec, + price: p.price, + benefitAmount: p.benefitAmount, + status: p.status, + allowOnSitePickup: p.allowOnSitePickup, + allowOnlinePurchase: p.allowOnlinePurchase, + allowCrossCityDelivery: p.allowCrossCityDelivery, + saleUnit: 'BOTTLE', + bottlesPerUnit: 1, + isDefault: true, + sortOrder: 0, + }, + }); + created += 1; + console.log(`created default sku for product ${p.id} ${p.skuCode}`); + } + console.log(`done, created=${created}, scanned=${products.length}`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/server/dukang-api/prisma/migrate-product-sku-v354.sql b/server/dukang-api/prisma/migrate-product-sku-v354.sql new file mode 100644 index 0000000..e651c93 --- /dev/null +++ b/server/dukang-api/prisma/migrate-product-sku-v354.sql @@ -0,0 +1,97 @@ +-- v3.5.4 商品规格:SPU + SKU +-- 1) 新建规格/SKU 表 2) 订单快照列 3) SPU 去掉 sku/barcode 唯一 4) 存量默认 SKU 回填 + +-- 销售单位枚举(Prisma 用字符串枚举映射;MySQL 用 VARCHAR,此处仅建表) + +CREATE TABLE IF NOT EXISTS common_product_spec_attr ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(32) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_spec_attr_product (product_id, sort_order), + CONSTRAINT fk_spec_attr_product FOREIGN KEY (product_id) REFERENCES common_product_item (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS common_product_spec_value ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + attr_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(64) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_spec_value_attr (attr_id, sort_order), + CONSTRAINT fk_spec_value_attr FOREIGN KEY (attr_id) REFERENCES common_product_spec_attr (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS common_product_sku ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + sku_code VARCHAR(32) NOT NULL, + barcode_69 VARCHAR(32) NOT NULL, + spec_key VARCHAR(128) NOT NULL DEFAULT '', + spec_text VARCHAR(128) NOT NULL DEFAULT '', + price DECIMAL(10, 2) NOT NULL, + benefit_amount DECIMAL(10, 2) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'DRAFT', + allow_on_site_pickup TINYINT(1) NOT NULL DEFAULT 0, + allow_online_purchase TINYINT(1) NOT NULL DEFAULT 1, + allow_cross_city_delivery TINYINT(1) NOT NULL DEFAULT 1, + sale_unit VARCHAR(16) NOT NULL DEFAULT 'BOTTLE', + bottles_per_unit INT NOT NULL DEFAULT 1, + is_default TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_product_sku_code (sku_code), + UNIQUE KEY uk_product_sku_barcode (barcode_69), + UNIQUE KEY uk_product_sku_spec_key (product_id, spec_key), + KEY idx_product_sku_status (product_id, status), + KEY idx_product_sku_default (product_id, is_default), + CONSTRAINT fk_product_sku_product FOREIGN KEY (product_id) REFERENCES common_product_item (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS common_product_sku_spec ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + sku_id BIGINT UNSIGNED NOT NULL, + value_id BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_sku_spec (sku_id, value_id), + KEY idx_sku_spec_value (value_id), + CONSTRAINT fk_sku_spec_sku FOREIGN KEY (sku_id) REFERENCES common_product_sku (id) ON DELETE CASCADE, + CONSTRAINT fk_sku_spec_value FOREIGN KEY (value_id) REFERENCES common_product_spec_value (id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 订单快照列 +ALTER TABLE user_order + ADD COLUMN sku_id BIGINT UNSIGNED NULL AFTER product_id, + ADD COLUMN sale_unit VARCHAR(16) NOT NULL DEFAULT 'BOTTLE' AFTER quantity, + ADD COLUMN bottles_per_unit INT NOT NULL DEFAULT 1 AFTER sale_unit; + +ALTER TABLE user_order + ADD KEY idx_order_sku_id (sku_id); + +-- SPU:去掉 sku_code / barcode_69 唯一(保留普通索引);若索引名不同可手工调整 +ALTER TABLE common_product_item DROP INDEX sku_code; +ALTER TABLE common_product_item DROP INDEX barcode_69; +ALTER TABLE common_product_item ADD INDEX idx_product_item_sku_code (sku_code); +ALTER TABLE common_product_item ADD INDEX idx_product_item_barcode_69 (barcode_69); + +-- 存量商品回填默认 SKU(无规格,瓶装) +INSERT INTO common_product_sku ( + product_id, sku_code, barcode_69, spec_key, spec_text, price, benefit_amount, status, + allow_on_site_pickup, allow_online_purchase, allow_cross_city_delivery, + sale_unit, bottles_per_unit, is_default, sort_order, created_at, updated_at +) +SELECT + p.id, p.sku_code, p.barcode_69, '', COALESCE(p.spec, ''), p.price, p.benefit_amount, p.status, + p.allow_on_site_pickup, p.allow_online_purchase, p.allow_cross_city_delivery, + 'BOTTLE', 1, 1, 0, NOW(3), NOW(3) +FROM common_product_item p +WHERE NOT EXISTS ( + SELECT 1 FROM common_product_sku s WHERE s.product_id = p.id +); diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 6ab8e09..2ddeb17 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -164,6 +164,12 @@ enum ProductStatus { OFF_SALE } +/// SKU 销售单位:瓶 / 箱(起购与物流按瓶当量) +enum ProductSaleUnit { + BOTTLE + BOX +} + enum DetailTemplateStatus { ACTIVE DISABLED @@ -791,10 +797,13 @@ model DevPlanTaskDispatch { @@map("dev_plan_task_dispatch") } +/// 商品 SPU(详情页实体);价格/码/履约等列为默认 SKU 冗余,供旧客户端拍平读取 model CommonProductItem { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt - skuCode String @unique @map("sku_code") @db.VarChar(32) - barcode69 String @unique @map("barcode_69") @db.VarChar(32) + /// 默认 SKU 冗余;唯一约束已下放到 common_product_sku + skuCode String @map("sku_code") @db.VarChar(32) + /// 默认 SKU 冗余;唯一约束已下放到 common_product_sku + barcode69 String @map("barcode_69") @db.VarChar(32) name String @db.VarChar(128) subtitle String? @db.VarChar(256) aromaType AromaType @map("aroma_type") @@ -818,11 +827,94 @@ model CommonProductItem { coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull) orders Order[] visibilityPhones CommonProductVisibilityPhone[] + specAttrs CommonProductSpecAttr[] + skus CommonProductSku[] + @@index([skuCode]) + @@index([barcode69]) @@index([status, aromaType]) @@map("common_product_item") } +/// 销售规格轴(如「包装」) +model CommonProductSpecAttr { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + productId BigInt @map("product_id") @db.UnsignedBigInt + name String @db.VarChar(32) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Cascade) + values CommonProductSpecValue[] + + @@index([productId, sortOrder]) + @@map("common_product_spec_attr") +} + +/// 规格值(如「单瓶」「整箱6瓶」) +model CommonProductSpecValue { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + attrId BigInt @map("attr_id") @db.UnsignedBigInt + name String @db.VarChar(64) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + attr CommonProductSpecAttr @relation(fields: [attrId], references: [id], onDelete: Cascade) + skuSpecs CommonProductSkuSpec[] + + @@index([attrId, sortOrder]) + @@map("common_product_spec_value") +} + +/// 可售 SKU(规格组合) +model CommonProductSku { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + productId BigInt @map("product_id") @db.UnsignedBigInt + skuCode String @unique @map("sku_code") @db.VarChar(32) + barcode69 String @unique @map("barcode_69") @db.VarChar(32) + /// 规格值 id 按 attr.sortOrder 拼接,无规格为 "" + specKey String @default("") @map("spec_key") @db.VarChar(128) + specText String @default("") @map("spec_text") @db.VarChar(128) + price Decimal @db.Decimal(10, 2) + benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2) + status ProductStatus @default(DRAFT) + 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") + saleUnit ProductSaleUnit @default(BOTTLE) @map("sale_unit") + /// 每销售单位对应瓶数(瓶=1,箱默认=6) + bottlesPerUnit Int @default(1) @map("bottles_per_unit") + isDefault Boolean @default(false) @map("is_default") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Cascade) + skuSpecs CommonProductSkuSpec[] + orders Order[] + + @@unique([productId, specKey]) + @@index([productId, status]) + @@index([productId, isDefault]) + @@map("common_product_sku") +} + +/// SKU ↔ 规格值 +model CommonProductSkuSpec { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + skuId BigInt @map("sku_id") @db.UnsignedBigInt + valueId BigInt @map("value_id") @db.UnsignedBigInt + + sku CommonProductSku @relation(fields: [skuId], references: [id], onDelete: Cascade) + value CommonProductSpecValue @relation(fields: [valueId], references: [id], onDelete: Restrict) + + @@unique([skuId, valueId]) + @@index([valueId]) + @@map("common_product_sku_spec") +} + /// Product visibility whitelist phones (match by bound phone) model CommonProductVisibilityPhone { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt @@ -1495,11 +1587,16 @@ model Order { promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt channelSource String? @map("channel_source") @db.VarChar(128) productId BigInt @map("product_id") @db.UnsignedBigInt + /// v3.5.4 起可空;历史单无 sku 快照时仍用 product 冗余字段展示 + skuId BigInt? @map("sku_id") @db.UnsignedBigInt barcode69 String @map("barcode_69") @db.VarChar(32) productName String @map("product_name") @db.VarChar(128) productSpec String @map("product_spec") @db.VarChar(128) imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt + /// 下单数量(销售单位:瓶或箱) quantity Int + saleUnit ProductSaleUnit @default(BOTTLE) @map("sale_unit") + bottlesPerUnit Int @default(1) @map("bottles_per_unit") listUnitPrice Decimal @map("list_unit_price") @db.Decimal(10, 2) listAmount Decimal @map("list_amount") @db.Decimal(10, 2) discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2) @@ -1553,6 +1650,7 @@ model Order { reshipments Order[] @relation("OrderReshipment") promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull) product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Restrict) + sku CommonProductSku? @relation(fields: [skuId], references: [id], onDelete: Restrict) imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull) delivery OrderDelivery? benefitCoupon BenefitCoupon? @@ -1561,6 +1659,7 @@ model Order { @@index([userId, status]) @@index([cityId, createdAt]) @@index([productId]) + @@index([skuId]) @@index([barcode69]) @@index([payExternalNo]) @@index([ipCity]) diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts index d8961ca..be52b9e 100644 --- a/server/dukang-api/prisma/seed-v31.ts +++ b/server/dukang-api/prisma/seed-v31.ts @@ -412,6 +412,26 @@ async function main() { }); + await prisma.commonProductSku.create({ + data: { + productId: product.id, + skuCode: def.skuCode, + barcode69: `69000000000${i + 1}`, + specKey: '', + specText: '500ml | 53度', + price: def.price, + benefitAmount: def.price, + status: 'ON_SALE', + allowOnSitePickup: false, + allowOnlinePurchase: true, + allowCrossCityDelivery: true, + saleUnit: 'BOTTLE', + bottlesPerUnit: 1, + isDefault: true, + sortOrder: 0, + }, + }); + const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img); await prisma.commonProductItem.update({ diff --git a/server/dukang-api/src/modules/catalog/catalog.service.ts b/server/dukang-api/src/modules/catalog/catalog.service.ts index 36d4d2d..546ec00 100644 --- a/server/dukang-api/src/modules/catalog/catalog.service.ts +++ b/server/dukang-api/src/modules/catalog/catalog.service.ts @@ -6,6 +6,14 @@ import { normalizeTestPhone, } from '../../common/test-whitelist/test-whitelist.service'; import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper'; +import { + flattenSkuOntoProduct, + listMinOnSalePrice, + mapSkuDto, + mapSpecAttrsDto, + pickDisplaySku, + resolveOrderSku, +} from './product-sku.util'; export type CatalogViewer = { /** C 端用户手机号;无则无法看到白名单商品 */ @@ -81,6 +89,8 @@ export class CatalogService { orderBy: { sortOrder: 'asc' }, include: { coverResource: true, + skus: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, + specAttrs: { select: { id: true } }, }, }); @@ -106,12 +116,25 @@ export class CatalogService { return serializeBigInt( visible.map((p) => { const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []); - const { visibilityWhitelistEnabled: _wl, ...rest } = p; + const { visibilityWhitelistEnabled: _wl, skus, specAttrs, ...rest } = p; + const display = pickDisplaySku(skus); + const flat = flattenSkuOntoProduct(p, display); + const minPrice = listMinOnSalePrice(skus); + const price = minPrice ?? flat.price; + const benefitAmount = flat.benefitAmount; return { ...rest, - benefitAmount: p.benefitAmount ?? p.price, - price: Number(p.price), - benefitDisplay: Number(p.benefitAmount ?? p.price), + skuCode: flat.skuCode, + barcode69: undefined, + spec: flat.spec, + price, + benefitAmount, + benefitDisplay: benefitAmount, + allowOnSitePickup: flat.allowOnSitePickup, + allowOnlinePurchase: flat.allowOnlinePurchase, + allowCrossCityDelivery: flat.allowCrossCityDelivery, + specEnabled: specAttrs.length > 0 || skus.length > 1, + saleUnit: flat.saleUnit, ...media, }; }), @@ -123,6 +146,14 @@ export class CatalogService { where: { id }, include: { coverResource: true, + skus: { + orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + include: { skuSpecs: { select: { valueId: true } } }, + }, + specAttrs: { + orderBy: { sortOrder: 'asc' }, + include: { values: { orderBy: { sortOrder: 'asc' } } }, + }, }, }); if (!product) return null; @@ -144,30 +175,57 @@ export class CatalogService { }); const media = mapProductMedia(product, resources); - const { visibilityWhitelistEnabled: _wl, ...rest } = product; + const { visibilityWhitelistEnabled: _wl, skus, specAttrs, ...rest } = product; + const display = pickDisplaySku(skus); + const flat = flattenSkuOntoProduct(product, display); + const defaultSku = skus.find((s) => s.isDefault) ?? display; + const cSkus = skus.map((s) => { + const dto = mapSkuDto(s); + const { skuCode: _c, barcode69: _b, sortOrder: _o, ...publicSku } = dto; + return publicSku; + }); + return serializeBigInt({ ...rest, - benefitAmount: product.benefitAmount ?? product.price, - price: Number(product.price), + skuCode: flat.skuCode, + spec: flat.spec, + price: flat.price, + benefitAmount: flat.benefitAmount, + benefitDisplay: flat.benefitAmount, + allowOnSitePickup: flat.allowOnSitePickup, + allowOnlinePurchase: flat.allowOnlinePurchase, + allowCrossCityDelivery: flat.allowCrossCityDelivery, + saleUnit: flat.saleUnit, + specEnabled: specAttrs.length > 0 || skus.length > 1, + specAttrs: mapSpecAttrsDto(specAttrs), + skus: cSkus, + defaultSkuId: defaultSku?.id.toString(), ...media, }); } - /** 下单前校验:白名单商品仅全局测试白名单手机号可买 */ - async assertPurchasable(productId: bigint, viewerPhone?: string | null) { + /** 下单前校验:白名单商品仅全局测试白名单手机号可买;返回 SPU + 解析后的 SKU */ + async assertPurchasable( + productId: bigint, + viewerPhone?: string | null, + skuId?: string | null, + options?: { bypassWhitelist?: boolean }, + ) { const product = await this.prisma.commonProductItem.findUnique({ where: { id: productId }, + include: { skus: true }, }); if (!product || product.status !== 'ON_SALE') { throw new BadRequestException('商品不可购买'); } - if (product.visibilityWhitelistEnabled) { + if (!options?.bypassWhitelist && product.visibilityWhitelistEnabled) { const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone); if (!ok) { throw new BadRequestException('该商品暂不对当前账号开放'); } } - return product; + const sku = resolveOrderSku(product.skus, skuId); + return { product, sku }; } async resolveUserPhone(userId: bigint): Promise { @@ -177,4 +235,12 @@ export class CatalogService { }); return user?.phone ?? null; } + + async listSkusForProduct(productId: bigint) { + return this.prisma.commonProductSku.findMany({ + where: { productId }, + orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + include: { skuSpecs: { select: { valueId: true } } }, + }); + } } diff --git a/server/dukang-api/src/modules/catalog/product-sku.util.ts b/server/dukang-api/src/modules/catalog/product-sku.util.ts new file mode 100644 index 0000000..6377f30 --- /dev/null +++ b/server/dukang-api/src/modules/catalog/product-sku.util.ts @@ -0,0 +1,175 @@ +import type { CommonProductItem, CommonProductSku, ProductSaleUnit } from '@prisma/client'; +import { BadRequestException } from '@nestjs/common'; +import { BOTTLES_PER_BOX } from '@dukang/domain'; + +export type SkuWithSpecs = CommonProductSku & { + skuSpecs?: Array<{ valueId: bigint; value?: { id: bigint; name: string; attrId: bigint } }>; +}; + +export type SpecAttrWithValues = { + id: bigint; + name: string; + sortOrder: number; + values: Array<{ id: bigint; name: string; sortOrder: number }>; +}; + +/** 可售 SKU */ +export function isSkuOnSale(sku: { status: string }): boolean { + return sku.status === 'ON_SALE'; +} + +export function buildSpecKey(valueIds: bigint[]): string { + if (!valueIds.length) return ''; + return [...valueIds] + .map((id) => id.toString()) + .sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0)) + .join('_'); +} + +export function buildSpecText( + valueIds: bigint[], + valueNameById: Map, +): string { + if (!valueIds.length) return ''; + return valueIds + .map((id) => valueNameById.get(id.toString()) ?? '') + .filter(Boolean) + .join(' / '); +} + +/** + * 解析下单 SKU:显式 skuId,或单可售自动回落;多可售且未传则 400。 + */ +export function resolveOrderSku( + skus: CommonProductSku[], + skuId?: string | null, +): CommonProductSku { + const onSale = skus.filter(isSkuOnSale); + if (skuId) { + const found = skus.find((s) => s.id.toString() === String(skuId)); + if (!found) throw new BadRequestException('规格不存在'); + if (!isSkuOnSale(found)) throw new BadRequestException('该规格暂不可购买'); + return found; + } + if (onSale.length === 1) return onSale[0]; + if (onSale.length === 0) throw new BadRequestException('商品暂无可售规格'); + throw new BadRequestException('请选择规格'); +} + +/** 列表/拍平:优先默认可售 → 最低价可售 → 默认任意 → 任意 */ +export function pickDisplaySku(skus: CommonProductSku[]): CommonProductSku | null { + if (!skus.length) return null; + const onSale = skus.filter(isSkuOnSale); + const pool = onSale.length ? onSale : skus; + const def = pool.find((s) => s.isDefault); + if (def) return def; + return [...pool].sort((a, b) => Number(a.price) - Number(b.price) || a.sortOrder - b.sortOrder)[0]; +} + +export function listMinOnSalePrice(skus: CommonProductSku[]): number | null { + const onSale = skus.filter(isSkuOnSale); + if (!onSale.length) return null; + return Math.min(...onSale.map((s) => Number(s.price))); +} + +export function flattenSkuOntoProduct( + product: CommonProductItem, + sku: CommonProductSku | null, +): { + skuCode: string; + barcode69: string; + spec: string; + price: number; + benefitAmount: number; + allowOnSitePickup: boolean; + allowOnlinePurchase: boolean; + allowCrossCityDelivery: boolean; + saleUnit: ProductSaleUnit; + bottlesPerUnit: number; +} { + if (!sku) { + return { + skuCode: product.skuCode, + barcode69: product.barcode69, + spec: product.spec, + price: Number(product.price), + benefitAmount: Number(product.benefitAmount ?? product.price), + allowOnSitePickup: product.allowOnSitePickup, + allowOnlinePurchase: product.allowOnlinePurchase, + allowCrossCityDelivery: product.allowCrossCityDelivery, + saleUnit: 'BOTTLE', + bottlesPerUnit: 1, + }; + } + return { + skuCode: sku.skuCode, + barcode69: sku.barcode69, + spec: sku.specText || product.spec, + price: Number(sku.price), + benefitAmount: Number(sku.benefitAmount ?? sku.price), + allowOnSitePickup: sku.allowOnSitePickup, + allowOnlinePurchase: sku.allowOnlinePurchase, + allowCrossCityDelivery: sku.allowCrossCityDelivery, + saleUnit: sku.saleUnit, + bottlesPerUnit: sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : sku.saleUnit === 'BOX' ? BOTTLES_PER_BOX : 1, + }; +} + +export function mapSkuDto(sku: SkuWithSpecs) { + const specValueIds = (sku.skuSpecs ?? []).map((row) => row.valueId.toString()); + return { + id: sku.id.toString(), + specValueIds, + specText: sku.specText, + price: Number(sku.price), + benefitAmount: Number(sku.benefitAmount ?? sku.price), + status: sku.status, + allowOnlinePurchase: sku.allowOnlinePurchase, + allowCrossCityDelivery: sku.allowCrossCityDelivery, + allowOnSitePickup: sku.allowOnSitePickup, + saleUnit: sku.saleUnit as 'BOTTLE' | 'BOX', + bottlesPerUnit: sku.bottlesPerUnit, + isDefault: sku.isDefault, + skuCode: sku.skuCode, + barcode69: sku.barcode69, + sortOrder: sku.sortOrder, + }; +} + +export function mapSpecAttrsDto(attrs: SpecAttrWithValues[]) { + return attrs + .slice() + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((attr) => ({ + id: attr.id.toString(), + name: attr.name, + values: attr.values + .slice() + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((v) => ({ id: v.id.toString(), name: v.name })), + })); +} + +export function syncDefaultSkuFieldsFromProduct(product: { + skuCode: string; + barcode69: string; + spec: string; + price: unknown; + benefitAmount: unknown; + status: string; + allowOnSitePickup: boolean; + allowOnlinePurchase: boolean; + allowCrossCityDelivery: boolean; +}) { + return { + skuCode: product.skuCode, + barcode69: product.barcode69, + specText: product.spec, + price: product.price as never, + benefitAmount: product.benefitAmount as never, + status: product.status as never, + allowOnSitePickup: product.allowOnSitePickup, + allowOnlinePurchase: product.allowOnlinePurchase, + allowCrossCityDelivery: product.allowCrossCityDelivery, + }; +} diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts index a8024c2..dde846a 100644 --- a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts +++ b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts @@ -76,11 +76,12 @@ export class FulfillmentService { return; } - // 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送 - if (shouldHoldAutoCourierDispatch(order.quantity)) { - const boxes = calcOrderBoxCount(order.quantity); + // 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送(按瓶当量) + const bottleQty = order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1); + if (shouldHoldAutoCourierDispatch(bottleQty)) { + const boxes = calcOrderBoxCount(bottleQty); this.logger.warn( - `大单拦截自动推单:${order.orderNo} quantity=${order.quantity} bottles≈${boxes}箱(阈值 ${XFX_AUTO_DISPATCH_MAX_BOXES}箱/${BOTTLES_PER_BOX}瓶)`, + `大单拦截自动推单:${order.orderNo} quantity=${order.quantity}×${order.bottlesPerUnit} bottles≈${boxes}箱(阈值 ${XFX_AUTO_DISPATCH_MAX_BOXES}箱/${BOTTLES_PER_BOX}瓶)`, ); await this.prisma.order.update({ where: { id: orderId }, @@ -97,7 +98,7 @@ export class FulfillmentService { refType: 'ORDER', refId: order.id, status: 'PENDING', - errorMessage: `大单拦截:${order.quantity}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice( + errorMessage: `大单拦截:${bottleQty}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice( 0, 512, ), @@ -155,7 +156,7 @@ export class FulfillmentService { addressDetail: order.receiverAddress, }, goodsName: order.productName, - goodsNum: order.quantity, + goodsNum: order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1), weight: 2, payMode: CourierPayMode.SENDER, remark: `仓配自动发货 ${order.orderNo}`, diff --git a/server/dukang-api/src/modules/ops/admin-products.controller.ts b/server/dukang-api/src/modules/ops/admin-products.controller.ts index a09e251..678721a 100644 --- a/server/dukang-api/src/modules/ops/admin-products.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-products.controller.ts @@ -4,7 +4,12 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { AdminProductsService } from './admin-products.service'; import { AdminProductsQueryDto } from './dto/admin-query.dto'; -import { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto'; +import { + CreateProductDto, + SaveProductSkusDto, + SaveProductSpecsDto, + UpdateProductDto, +} from './dto/admin-mutate.dto'; @Controller('admin/products') @UseGuards(HqAuthGuard) @@ -33,6 +38,18 @@ export class AdminProductsController { return this.service.update(BigInt(id), dto); } + @Put(':id/specs') + @HqOperation({ action: HqOperationAction.PRODUCT_UPDATE, refType: 'PRODUCT', refIdParam: 'id', includeBody: true }) + saveSpecs(@Param('id') id: string, @Body() dto: SaveProductSpecsDto) { + return this.service.saveSpecs(BigInt(id), dto); + } + + @Put(':id/skus') + @HqOperation({ action: HqOperationAction.PRODUCT_UPDATE, refType: 'PRODUCT', refIdParam: 'id', includeBody: true }) + saveSkus(@Param('id') id: string, @Body() dto: SaveProductSkusDto) { + return this.service.saveSkus(BigInt(id), dto); + } + @Delete(':id') @HqOperation({ action: HqOperationAction.PRODUCT_DELETE, refType: 'PRODUCT', refIdParam: 'id' }) remove(@Param('id') id: string) { 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 856d714..b3768b1 100644 --- a/server/dukang-api/src/modules/ops/admin-products.service.ts +++ b/server/dukang-api/src/modules/ops/admin-products.service.ts @@ -1,10 +1,22 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; +import { BOTTLES_PER_BOX } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper'; +import { + buildSpecKey, + buildSpecText, + mapSkuDto, + mapSpecAttrsDto, +} from '../catalog/product-sku.util'; import type { AdminProductsQueryDto } from './dto/admin-query.dto'; -import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto'; +import type { + CreateProductDto, + SaveProductSkusDto, + SaveProductSpecsDto, + UpdateProductDto, +} from './dto/admin-mutate.dto'; import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service'; function normalizePhones(phones?: string[]): string[] { @@ -27,6 +39,8 @@ function normalizePhones(phones?: string[]): string[] { const SKU_AUTO_PREFIX = 'DK'; const SKU_AUTO_PAD = 6; +const MAX_SPEC_ATTRS = 3; +const MAX_SPEC_VALUES = 10; /** 解析履约开关:无线上则强制不可跨城;须至少线上或现场之一 */ function resolveFulfillmentFlags(input: { @@ -80,6 +94,8 @@ export class AdminProductsService { include: { coverResource: true, visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } }, + skus: { select: { id: true }, take: 2 }, + specAttrs: { select: { id: true } }, }, }), this.prisma.commonProductItem.count({ where }), @@ -100,7 +116,14 @@ export class AdminProductsService { const resourceMap = groupResourcesByProductId(resources); return serializeBigInt({ - items: items.map((p) => this.formatProduct(p, resourceMap.get(p.id.toString()) ?? [])), + items: items.map((p) => { + const { skus, specAttrs, ...rest } = p; + return { + ...this.formatProduct(rest as never, resourceMap.get(p.id.toString()) ?? []), + specEnabled: specAttrs.length > 0 || skus.length > 1, + skuCount: skus.length, + }; + }), total, page, pageSize, @@ -113,6 +136,14 @@ export class AdminProductsService { include: { coverResource: true, visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } }, + skus: { + orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + include: { skuSpecs: { select: { valueId: true } } }, + }, + specAttrs: { + orderBy: { sortOrder: 'asc' }, + include: { values: { orderBy: { sortOrder: 'asc' } } }, + }, }, }); if (!product) throw new NotFoundException('商品不存在'); @@ -127,11 +158,18 @@ export class AdminProductsService { orderBy: { sortOrder: 'asc' }, }); - return serializeBigInt(this.formatProduct(product, resources)); + const { skus, specAttrs, ...rest } = product; + return serializeBigInt({ + ...this.formatProduct(rest as never, resources), + specEnabled: specAttrs.length > 0 || skus.length > 1, + specAttrs: mapSpecAttrsDto(specAttrs), + skus: skus.map(mapSkuDto), + defaultSkuId: skus.find((s) => s.isDefault)?.id.toString() ?? skus[0]?.id.toString(), + }); } async create(dto: CreateProductDto) { - const barcodeExists = await this.prisma.commonProductItem.findFirst({ + const barcodeExists = await this.prisma.commonProductSku.findFirst({ where: { barcode69: dto.barcode69 }, }); if (barcodeExists) throw new BadRequestException('69 码已存在'); @@ -147,7 +185,6 @@ export class AdminProductsService { if (whitelistEnabled) { await this.testWhitelist.assertGlobalWhitelistNotEmpty(); } - // 手机号统一在「白名单管理」维护;此处忽略分实体 phones(兼容旧客户端传参) void phones; const product = await this.createWithGeneratedSku({ @@ -169,6 +206,26 @@ export class AdminProductsService { : {}), }); + await this.prisma.commonProductSku.create({ + data: { + productId: product.id, + skuCode: product.skuCode, + barcode69: product.barcode69, + specKey: '', + specText: product.spec, + price: product.price, + benefitAmount: product.benefitAmount, + status: product.status, + allowOnSitePickup: product.allowOnSitePickup, + allowOnlinePurchase: product.allowOnlinePurchase, + allowCrossCityDelivery: product.allowCrossCityDelivery, + saleUnit: 'BOTTLE', + bottlesPerUnit: 1, + isDefault: true, + sortOrder: 0, + }, + }); + if (dto.coverUrl) { await this.syncCover(product.id, dto.coverUrl); } @@ -230,7 +287,9 @@ export class AdminProductsService { if (dto.visibilityWhitelistEnabled) { await this.testWhitelist.assertGlobalWhitelistNotEmpty(); } - // 分实体手机号已废弃;忽略 dto.visibilityPhones + + // 无规格 payload 时:同步默认 SKU(兼容旧 admin 表单) + await this.syncDefaultSkuFromProduct(id); if (dto.coverUrl) { await this.syncCover(id, dto.coverUrl); @@ -243,6 +302,281 @@ export class AdminProductsService { return this.detail(id); } + async saveSpecs(productId: bigint, dto: SaveProductSpecsDto) { + const product = await this.prisma.commonProductItem.findUnique({ where: { id: productId } }); + if (!product) throw new NotFoundException('商品不存在'); + + const attrs = dto.attrs ?? []; + if (attrs.length > MAX_SPEC_ATTRS) { + throw new BadRequestException(`规格轴最多 ${MAX_SPEC_ATTRS} 个`); + } + for (const attr of attrs) { + if ((attr.values?.length ?? 0) > MAX_SPEC_VALUES) { + throw new BadRequestException(`每个规格轴最多 ${MAX_SPEC_VALUES} 个值`); + } + if (!attr.values?.length) { + throw new BadRequestException(`规格「${attr.name}」至少需要一个值`); + } + } + + const existingAttrs = await this.prisma.commonProductSpecAttr.findMany({ + where: { productId }, + include: { values: true }, + }); + const existingValueIds = existingAttrs.flatMap((a) => a.values.map((v) => v.id)); + const keepValueIds = new Set( + attrs.flatMap((a) => (a.values ?? []).map((v) => v.id).filter(Boolean) as string[]), + ); + + for (const vid of existingValueIds) { + if (keepValueIds.has(vid.toString())) continue; + const used = await this.prisma.commonProductSkuSpec.count({ where: { valueId: vid } }); + if (used > 0) { + const orderCount = await this.prisma.order.count({ + where: { sku: { skuSpecs: { some: { valueId: vid } } } }, + }); + if (orderCount > 0) { + throw new BadRequestException('有订单关联的规格值不可删除'); + } + } + } + + await this.prisma.$transaction(async (tx) => { + // 删除未保留的轴(级联值);先清 sku_spec 中将被删的 value + const keepAttrIds = new Set(attrs.map((a) => a.id).filter(Boolean) as string[]); + for (const old of existingAttrs) { + if (!keepAttrIds.has(old.id.toString())) { + await tx.commonProductSkuSpec.deleteMany({ + where: { valueId: { in: old.values.map((v) => v.id) } }, + }); + await tx.commonProductSpecAttr.delete({ where: { id: old.id } }); + } + } + + for (let ai = 0; ai < attrs.length; ai++) { + const attr = attrs[ai]; + let attrId: bigint; + if (attr.id) { + attrId = BigInt(attr.id); + await tx.commonProductSpecAttr.update({ + where: { id: attrId }, + data: { name: attr.name.trim(), sortOrder: attr.sortOrder ?? ai }, + }); + } else { + const created = await tx.commonProductSpecAttr.create({ + data: { + productId, + name: attr.name.trim(), + sortOrder: attr.sortOrder ?? ai, + }, + }); + attrId = created.id; + } + + const oldValues = await tx.commonProductSpecValue.findMany({ where: { attrId } }); + const keepVids = new Set((attr.values ?? []).map((v) => v.id).filter(Boolean) as string[]); + for (const ov of oldValues) { + if (!keepVids.has(ov.id.toString())) { + await tx.commonProductSkuSpec.deleteMany({ where: { valueId: ov.id } }); + await tx.commonProductSpecValue.delete({ where: { id: ov.id } }); + } + } + + for (let vi = 0; vi < (attr.values ?? []).length; vi++) { + const val = attr.values[vi]; + if (val.id) { + await tx.commonProductSpecValue.update({ + where: { id: BigInt(val.id) }, + data: { name: val.name.trim(), sortOrder: val.sortOrder ?? vi }, + }); + } else { + await tx.commonProductSpecValue.create({ + data: { + attrId, + name: val.name.trim(), + sortOrder: val.sortOrder ?? vi, + }, + }); + } + } + } + }); + + return this.detail(productId); + } + + async saveSkus(productId: bigint, dto: SaveProductSkusDto) { + const product = await this.prisma.commonProductItem.findUnique({ + where: { id: productId }, + include: { + specAttrs: { include: { values: true }, orderBy: { sortOrder: 'asc' } }, + }, + }); + if (!product) throw new NotFoundException('商品不存在'); + + const rows = dto.skus ?? []; + if (!rows.length) throw new BadRequestException('至少保留一个 SKU'); + + const valueNameById = new Map(); + const attrValueSets = product.specAttrs.map((a) => { + const set = new Set(a.values.map((v) => v.id.toString())); + for (const v of a.values) valueNameById.set(v.id.toString(), v.name); + return set; + }); + + let defaultCount = 0; + const seenKeys = new Set(); + for (const row of rows) { + const flags = resolveFulfillmentFlags({ + allowOnlinePurchase: row.allowOnlinePurchase, + allowCrossCityDelivery: row.allowCrossCityDelivery, + allowOnSitePickup: row.allowOnSitePickup, + defaults: { + allowOnlinePurchase: product.allowOnlinePurchase, + allowCrossCityDelivery: product.allowCrossCityDelivery, + allowOnSitePickup: product.allowOnSitePickup, + }, + }); + void flags; + if (row.isDefault) defaultCount += 1; + const valueIds = (row.specValueIds ?? []).map((id) => BigInt(id)); + if (attrValueSets.length) { + const idSet = new Set(valueIds.map((id) => id.toString())); + if (idSet.size !== valueIds.length) { + throw new BadRequestException('规格值不可重复'); + } + for (const set of attrValueSets) { + const hits = [...set].filter((id) => idSet.has(id)); + if (hits.length !== 1) { + throw new BadRequestException('每个 SKU 须选择每个规格轴的一个值'); + } + } + if (valueIds.length !== attrValueSets.length) { + throw new BadRequestException('每个 SKU 须选择每个规格轴的一个值'); + } + } else if (valueIds.length) { + throw new BadRequestException('商品尚未配置规格轴'); + } + const key = buildSpecKey(valueIds); + if (seenKeys.has(key)) throw new BadRequestException('存在重复规格组合'); + seenKeys.add(key); + } + if (defaultCount !== 1) { + throw new BadRequestException('请且仅指定一个默认 SKU'); + } + + const barcodes = rows.map((r) => r.barcode69.trim()); + if (new Set(barcodes).size !== barcodes.length) { + throw new BadRequestException('69 码不可重复'); + } + + await this.prisma.$transaction(async (tx) => { + const existing = await tx.commonProductSku.findMany({ where: { productId } }); + const keepIds = new Set(rows.map((r) => r.id).filter(Boolean) as string[]); + + for (const old of existing) { + if (keepIds.has(old.id.toString())) continue; + const orderCount = await tx.order.count({ where: { skuId: old.id } }); + if (orderCount > 0) { + throw new BadRequestException(`SKU ${old.skuCode} 已有订单,无法删除`); + } + await tx.commonProductSkuSpec.deleteMany({ where: { skuId: old.id } }); + await tx.commonProductSku.delete({ where: { id: old.id } }); + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const valueIds = (row.specValueIds ?? []).map((id) => BigInt(id)); + const specKey = buildSpecKey(valueIds); + const specText = + buildSpecText(valueIds, valueNameById) || product.spec || row.barcode69; + const flags = resolveFulfillmentFlags({ + allowOnlinePurchase: row.allowOnlinePurchase, + allowCrossCityDelivery: row.allowCrossCityDelivery, + allowOnSitePickup: row.allowOnSitePickup, + defaults: { + allowOnlinePurchase: product.allowOnlinePurchase, + allowCrossCityDelivery: product.allowCrossCityDelivery, + allowOnSitePickup: product.allowOnSitePickup, + }, + }); + const saleUnit = row.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE'; + const bottlesPerUnit = + row.bottlesPerUnit && row.bottlesPerUnit > 0 + ? Math.floor(row.bottlesPerUnit) + : saleUnit === 'BOX' + ? BOTTLES_PER_BOX + : 1; + const status = (row.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE'; + const skuCode = row.skuCode?.trim() || (await this.nextAutoSkuCodeTx(tx)); + + let skuId: bigint; + if (row.id) { + skuId = BigInt(row.id); + await tx.commonProductSku.update({ + where: { id: skuId }, + data: { + skuCode, + barcode69: row.barcode69.trim(), + specKey, + specText, + price: row.price, + benefitAmount: row.benefitAmount ?? row.price, + status, + ...flags, + saleUnit, + bottlesPerUnit, + isDefault: !!row.isDefault, + sortOrder: row.sortOrder ?? i, + }, + }); + await tx.commonProductSkuSpec.deleteMany({ where: { skuId } }); + } else { + const created = await tx.commonProductSku.create({ + data: { + productId, + skuCode, + barcode69: row.barcode69.trim(), + specKey, + specText, + price: row.price, + benefitAmount: row.benefitAmount ?? row.price, + status, + ...flags, + saleUnit, + bottlesPerUnit, + isDefault: !!row.isDefault, + sortOrder: row.sortOrder ?? i, + }, + }); + skuId = created.id; + } + + if (valueIds.length) { + await tx.commonProductSkuSpec.createMany({ + data: valueIds.map((valueId) => ({ skuId, valueId })), + }); + } + + if (row.isDefault) { + await tx.commonProductItem.update({ + where: { id: productId }, + data: { + skuCode, + barcode69: row.barcode69.trim(), + spec: specText, + price: row.price, + benefitAmount: row.benefitAmount ?? row.price, + ...flags, + }, + }); + } + } + }); + + return this.detail(productId); + } + async remove(id: bigint) { const product = await this.prisma.commonProductItem.findUnique({ where: { id } }); if (!product) throw new NotFoundException('商品不存在'); @@ -260,15 +594,82 @@ export class AdminProductsService { return { ok: true }; } + private async syncDefaultSkuFromProduct(productId: bigint) { + const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: productId } }); + const defaultSku = + (await this.prisma.commonProductSku.findFirst({ + where: { productId, isDefault: true }, + })) ?? + (await this.prisma.commonProductSku.findFirst({ + where: { productId }, + orderBy: { id: 'asc' }, + })); + + if (!defaultSku) { + await this.prisma.commonProductSku.create({ + data: { + productId, + skuCode: product.skuCode, + barcode69: product.barcode69, + specKey: '', + specText: product.spec, + price: product.price, + benefitAmount: product.benefitAmount, + status: product.status, + allowOnSitePickup: product.allowOnSitePickup, + allowOnlinePurchase: product.allowOnlinePurchase, + allowCrossCityDelivery: product.allowCrossCityDelivery, + saleUnit: 'BOTTLE', + bottlesPerUnit: 1, + isDefault: true, + sortOrder: 0, + }, + }); + return; + } + + // 仅当该商品只有 1 个 SKU 时,旧表单字段同步到默认 SKU(避免误改多规格) + const skuCount = await this.prisma.commonProductSku.count({ where: { productId } }); + if (skuCount > 1) return; + + await this.prisma.commonProductSku.update({ + where: { id: defaultSku.id }, + data: { + skuCode: product.skuCode, + barcode69: product.barcode69, + specText: product.spec, + price: product.price, + benefitAmount: product.benefitAmount, + status: product.status, + allowOnSitePickup: product.allowOnSitePickup, + allowOnlinePurchase: product.allowOnlinePurchase, + allowCrossCityDelivery: product.allowCrossCityDelivery, + isDefault: true, + }, + }); + } + /** 生成 DK + 6 位自增 SKU,冲突重试 */ private async nextAutoSkuCode(): Promise { - const rows = await this.prisma.commonProductItem.findMany({ - where: { skuCode: { startsWith: SKU_AUTO_PREFIX } }, - select: { skuCode: true }, - }); + return this.nextAutoSkuCodeTx(this.prisma); + } + + private async nextAutoSkuCodeTx( + db: Prisma.TransactionClient | PrismaService, + ): Promise { + const [fromItem, fromSku] = await Promise.all([ + db.commonProductItem.findMany({ + where: { skuCode: { startsWith: SKU_AUTO_PREFIX } }, + select: { skuCode: true }, + }), + db.commonProductSku.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) { + for (const row of [...fromItem, ...fromSku]) { const m = re.exec(row.skuCode); if (!m) continue; const n = Number(m[1]); @@ -300,16 +701,6 @@ export class AdminProductsService { throw new BadRequestException('SKU 生成失败,请重试'); } - private async syncVisibilityPhones(productId: bigint, phones: string[]) { - await this.prisma.$transaction(async (tx) => { - await tx.commonProductVisibilityPhone.deleteMany({ where: { productId } }); - if (!phones.length) return; - await tx.commonProductVisibilityPhone.createMany({ - data: phones.map((phone) => ({ productId, phone })), - }); - }); - } - private formatProduct( product: Prisma.CommonProductItemGetPayload<{ include: { 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 b93591f..37daa57 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 @@ -13,6 +13,7 @@ import { Max, MinLength, ValidateIf, + ValidateNested, } from 'class-validator'; export class UpdateStoreStatusDto { @@ -1302,6 +1303,111 @@ export class UpdateProductDto { detailContent?: Record; } +class AdminSpecValueDto { + @IsOptional() + @IsString() + id?: string; + + @IsString() + @IsNotEmpty() + name: string; + + @IsOptional() + @IsNumber() + sortOrder?: number; +} + +class AdminSpecAttrDto { + @IsOptional() + @IsString() + id?: string; + + @IsString() + @IsNotEmpty() + name: string; + + @IsOptional() + @IsNumber() + sortOrder?: number; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AdminSpecValueDto) + values: AdminSpecValueDto[]; +} + +export class SaveProductSpecsDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AdminSpecAttrDto) + attrs: AdminSpecAttrDto[]; +} + +class AdminSkuRowDto { + @IsOptional() + @IsString() + id?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + specValueIds?: string[]; + + @IsOptional() + @IsString() + skuCode?: string; + + @IsString() + @IsNotEmpty() + barcode69: string; + + @IsNumber() + price: number; + + @IsOptional() + @IsNumber() + benefitAmount?: number; + + @IsOptional() + @IsIn(['DRAFT', 'ON_SALE', 'OFF_SALE']) + status?: string; + + @IsOptional() + @IsBoolean() + allowOnSitePickup?: boolean; + + @IsOptional() + @IsBoolean() + allowOnlinePurchase?: boolean; + + @IsOptional() + @IsBoolean() + allowCrossCityDelivery?: boolean; + + @IsOptional() + @IsIn(['BOTTLE', 'BOX']) + saleUnit?: 'BOTTLE' | 'BOX'; + + @IsOptional() + @IsNumber() + bottlesPerUnit?: number; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @IsOptional() + @IsNumber() + sortOrder?: number; +} + +export class SaveProductSkusDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AdminSkuRowDto) + skus: AdminSkuRowDto[]; +} + class ProductDetailFeatureInputDto { @IsString() icon: string; diff --git a/server/dukang-api/src/modules/ops/dto/hq-proxy-order.dto.ts b/server/dukang-api/src/modules/ops/dto/hq-proxy-order.dto.ts index e6441c7..ab62e39 100644 --- a/server/dukang-api/src/modules/ops/dto/hq-proxy-order.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/hq-proxy-order.dto.ts @@ -17,6 +17,10 @@ export class HqProxyOrderPreviewDto { @IsNotEmpty() productId: string; + @IsOptional() + @IsString() + skuId?: string; + @Type(() => Number) @IsInt() @Min(1) @@ -76,6 +80,10 @@ export class HqProxyOrderCreateDto { @IsNotEmpty() productId: string; + @IsOptional() + @IsString() + skuId?: string; + @Type(() => Number) @IsInt() @Min(1) diff --git a/server/dukang-api/src/modules/trade/dto/partner-proxy-order.dto.ts b/server/dukang-api/src/modules/trade/dto/partner-proxy-order.dto.ts index 1b120a8..a669f33 100644 --- a/server/dukang-api/src/modules/trade/dto/partner-proxy-order.dto.ts +++ b/server/dukang-api/src/modules/trade/dto/partner-proxy-order.dto.ts @@ -17,6 +17,10 @@ export class PartnerProxyOrderPreviewDto { @IsNotEmpty() productId: string; + @IsOptional() + @IsString() + skuId?: string; + @Type(() => Number) @IsInt() @Min(1) @@ -84,6 +88,10 @@ export class PartnerProxyOrderCreateDto { @IsNotEmpty() productId: string; + @IsOptional() + @IsString() + skuId?: string; + @Type(() => Number) @IsInt() @Min(1) diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 4d6832b..e52edd1 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -11,6 +11,7 @@ import { calcBenefitAmount, generateOrderNo, orderTabToStatuses, + toMinSaleQuantity, validateMinPurchase, } from '@dukang/domain'; import { loadAppConfig, ClientApp, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types'; @@ -37,6 +38,7 @@ import { AlertService } from '../../common/alert/alert.service'; import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import type { Request } from 'express'; +import type { CommonProductSku } from '@prisma/client'; @Injectable() export class TradeService { @@ -62,22 +64,54 @@ export class TradeService { private readonly logger = new Logger(TradeService.name); + private overlayProductWithSku( + productDto: Record, + sku: CommonProductSku, + ): Record { + return { + ...productDto, + skuCode: sku.skuCode, + spec: sku.specText || productDto.spec, + price: Number(sku.price), + benefitAmount: Number(sku.benefitAmount ?? sku.price), + benefitDisplay: Number(sku.benefitAmount ?? sku.price), + allowOnSitePickup: sku.allowOnSitePickup, + allowOnlinePurchase: sku.allowOnlinePurchase, + allowCrossCityDelivery: sku.allowCrossCityDelivery, + saleUnit: sku.saleUnit, + bottlesPerUnit: sku.bottlesPerUnit, + selectedSkuId: sku.id.toString(), + }; + } async preview( userId: bigint, - body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean }, + body: { + productId: string; + quantity: number; + addressId?: string; + onSitePickup?: boolean; + skuId?: string; + }, ) { const viewerPhone = await this.catalogService.resolveUserPhone(userId); - const product = await this.catalogService.getProduct(BigInt(body.productId), { phone: viewerPhone }); - if (!product || product.status !== 'ON_SALE') { + const { product: spu, sku } = await this.catalogService.assertPurchasable( + BigInt(body.productId), + viewerPhone, + body.skuId, + ); + const productDto = await this.catalogService.getProduct(spu.id, { phone: viewerPhone }); + if (!productDto || productDto.status !== 'ON_SALE') { throw new BadRequestException('商品不可购买'); } + const product = this.overlayProductWithSku(productDto as Record, sku); + const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } }); if (!city) throw new BadRequestException('暂无开城城市'); const onSitePickup = !!body.onSitePickup; - if (onSitePickup && !product.allowOnSitePickup) { - throw new BadRequestException('该商品不支持现场取货'); + if (onSitePickup && !sku.allowOnSitePickup) { + throw new BadRequestException('该规格不支持现场取货'); } let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = onSitePickup @@ -95,43 +129,46 @@ export class TradeService { let addressOk = true; let addressMessage: string | null = null; if (!onSitePickup) { - const allowOnline = product.allowOnlinePurchase !== false; - const allowCross = product.allowCrossCityDelivery !== false; + const allowOnline = sku.allowOnlinePurchase !== false; + const allowCross = sku.allowCrossCityDelivery !== false; if (deliveryType === 'LOCAL' && !allowOnline) { addressOk = false; - addressMessage = '该商品不支持线上购买'; + addressMessage = '该规格不支持线上购买'; } else if (deliveryType === 'CROSS_CITY') { if (!allowOnline) { addressOk = false; - addressMessage = '该商品不支持线上购买'; + addressMessage = '该规格不支持线上购买'; } else if (!allowCross) { addressOk = false; - addressMessage = '该商品不支持跨城配送,请更换为开城城市内的收货地址'; + addressMessage = '该规格不支持跨城配送,请更换为开城城市内的收货地址'; } } } + const bottlesPerUnit = sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : 1; const check = validateMinPurchase( deliveryType, body.quantity, city.localMinQty, city.crossMinQty, + { bottlesPerUnit, saleUnit: sku.saleUnit }, ); - const unitPrice = Number(product.price); + const unitPrice = Number(sku.price); const productAmount = unitPrice * body.quantity; const benefitPerUnit = calcBenefitAmount({ price: unitPrice, - benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null, + benefitAmount: sku.benefitAmount != null ? Number(sku.benefitAmount) : null, }); const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null; - const minQty = + const minBottleQty = deliveryType === 'ON_SITE_PICKUP' ? city.localMinQty : deliveryType === 'LOCAL' ? city.localMinQty : city.crossMinQty; + const minQty = toMinSaleQuantity(minBottleQty, bottlesPerUnit); return { product, @@ -143,16 +180,18 @@ export class TradeService { payAmount: productAmount, benefitAmount: benefitPerUnit * body.quantity, city: serializeBigInt(city), - /** 起购未满足时仍返回预览,供确认页改数量;下单接口仍会硬校验 */ quantityOk: check.ok, quantityMessage: check.ok ? null : (check.message ?? null), - /** 地址/履约未满足时仍返回预览,供确认页提示换地址;下单接口仍会硬校验 */ addressOk, addressMessage, minQty, onSitePickup, - allowCrossCityDelivery: product.allowCrossCityDelivery !== false, - allowOnlinePurchase: product.allowOnlinePurchase !== false, + allowCrossCityDelivery: sku.allowCrossCityDelivery !== false, + allowOnlinePurchase: sku.allowOnlinePurchase !== false, + skuId: sku.id.toString(), + saleUnit: sku.saleUnit, + bottlesPerUnit, + bottleQuantity: body.quantity * bottlesPerUnit, }; } @@ -164,6 +203,7 @@ export class TradeService { addressId?: string; onSitePickup?: boolean; clientLocation?: unknown; + skuId?: string; }, req: Request, ) { @@ -207,6 +247,9 @@ export class TradeService { const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: BigInt(body.productId) }, }); + const sku = await this.prisma.commonProductSku.findUniqueOrThrow({ + where: { id: BigInt(preview.skuId) }, + }); const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } }); const orderNo = generateOrderNo(); const payExpireAt = new Date(Date.now() + 30 * 60 * 1000); @@ -239,12 +282,15 @@ export class TradeService { payStatus: 'UNPAID', deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP', productId: product.id, - barcode69: product.barcode69, + skuId: sku.id, + barcode69: sku.barcode69, productName: product.name, - productSpec: product.spec, + productSpec: sku.specText || product.spec, imageResourceId: product.coverResourceId, quantity: body.quantity, - listUnitPrice: product.price, + saleUnit: sku.saleUnit, + bottlesPerUnit: preview.bottlesPerUnit, + listUnitPrice: sku.price, listAmount: preview.productAmount, productAmount: preview.productAmount, receiverName, @@ -300,6 +346,7 @@ export class TradeService { extraJson: { orderId: order.id.toString(), productId: body.productId, + skuId: sku.id.toString(), quantity: body.quantity, onSitePickup, }, @@ -1503,18 +1550,28 @@ export class TradeService { }, }), ]); + const details = await Promise.all( + products.map((p) => + this.catalogService.getProduct(BigInt(String(p.id)), { phone: primary.phone }), + ), + ); return serializeBigInt({ - products: products.map((p) => ({ - id: p.id, - name: p.name, - spec: p.spec, - price: Number(p.price), - 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, + products: details.filter(Boolean).map((p) => ({ + id: p!.id, + name: p!.name, + spec: p!.spec, + price: Number(p!.price), + benefitAmount: p!.benefitAmount != null ? Number(p!.benefitAmount) : null, + coverUrl: (p as { mainImageUrl?: string | null } | null)?.mainImageUrl ?? null, + allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean } | null)?.allowOnSitePickup, + allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean } | null)?.allowOnlinePurchase !== false, allowCrossCityDelivery: - (p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false, + (p as { allowCrossCityDelivery?: boolean } | null)?.allowCrossCityDelivery !== false, + specEnabled: !!(p as { specEnabled?: boolean } | null)?.specEnabled, + saleUnit: (p as { saleUnit?: string } | null)?.saleUnit, + skus: (p as { skus?: unknown[] } | null)?.skus ?? [], + defaultSkuId: (p as { defaultSkuId?: string } | null)?.defaultSkuId, + specAttrs: (p as { specAttrs?: unknown[] } | null)?.specAttrs ?? [], })), promoCodes, stores: stores.map((s) => ({ @@ -1537,13 +1594,25 @@ export class TradeService { storeId?: string; receiverCity?: string; receiverDistrict?: string; + skuId?: string; }, viewer?: { phone?: string | null; bypassWhitelist?: boolean }, ) { - const product = await this.catalogService.getProduct(BigInt(body.productId), viewer ?? {}); - if (!product || product.status !== 'ON_SALE') { + const { product: spu, sku } = await this.catalogService.assertPurchasable( + BigInt(body.productId), + viewer?.phone, + body.skuId, + { bypassWhitelist: !!viewer?.bypassWhitelist }, + ); + if (spu.status !== 'ON_SALE') { throw new BadRequestException('商品不可购买'); } + // 非 bypass 时再走可见性(与 getProduct 一致) + if (!viewer?.bypassWhitelist) { + const dto = await this.catalogService.getProduct(spu.id, viewer ?? {}); + if (!dto) throw new BadRequestException('商品不可购买'); + } + const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } }); if (!city) throw new BadRequestException('暂无开城城市'); @@ -1551,8 +1620,8 @@ export class TradeService { let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = 'LOCAL'; if (deliveryMode === 'ON_SITE_PICKUP') { - if (!product.allowOnSitePickup) { - throw new BadRequestException('该商品不支持现场提货'); + if (!sku.allowOnSitePickup) { + throw new BadRequestException('该规格不支持现场提货'); } deliveryType = 'ON_SITE_PICKUP'; } else { @@ -1560,34 +1629,36 @@ export class TradeService { if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') { deliveryType = 'CROSS_CITY'; } - const allowOnline = product.allowOnlinePurchase !== false; - const allowCross = product.allowCrossCityDelivery !== false; + const allowOnline = sku.allowOnlinePurchase !== false; + const allowCross = sku.allowCrossCityDelivery !== false; if (deliveryType === 'LOCAL' && !allowOnline) { - throw new BadRequestException('该商品不支持线上购买'); + throw new BadRequestException('该规格不支持线上购买'); } if (deliveryType === 'CROSS_CITY') { if (!allowOnline) { - throw new BadRequestException('该商品不支持线上购买'); + throw new BadRequestException('该规格不支持线上购买'); } if (!allowCross) { - throw new BadRequestException('该商品不支持跨城配送'); + throw new BadRequestException('该规格不支持跨城配送'); } } } + const bottlesPerUnit = sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : 1; const check = validateMinPurchase( - deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : 'LOCAL', + deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : deliveryType === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'LOCAL', body.quantity, city.localMinQty, city.crossMinQty, + { bottlesPerUnit, saleUnit: sku.saleUnit }, ); if (!check.ok) throw new BadRequestException(check.message); - const unitPrice = Number(product.price); + const unitPrice = Number(sku.price); const productAmount = unitPrice * body.quantity; const benefitPerUnit = calcBenefitAmount({ price: unitPrice, - benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null, + benefitAmount: sku.benefitAmount != null ? Number(sku.benefitAmount) : null, }); return { @@ -1596,6 +1667,14 @@ export class TradeService { benefitAmount: benefitPerUnit * body.quantity, deliveryType, unitPrice, + skuId: sku.id.toString(), + saleUnit: sku.saleUnit, + bottlesPerUnit, + bottleQuantity: body.quantity * bottlesPerUnit, + minQuantity: toMinSaleQuantity( + deliveryType === 'CROSS_CITY' ? city.crossMinQty : city.localMinQty, + bottlesPerUnit, + ), }; } @@ -1609,6 +1688,7 @@ export class TradeService { storeId?: string; receiverCity?: string; receiverDistrict?: string; + skuId?: string; }, ) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); @@ -1630,6 +1710,7 @@ export class TradeService { productId: string; quantity: number; promoCodeId?: string; + skuId?: string; }, req: Request, ) { @@ -1666,6 +1747,7 @@ export class TradeService { storeId: body.storeId, receiverCity: body.city, receiverDistrict: body.district, + skuId: body.skuId, }, { phone: primary.phone }, ); @@ -1673,6 +1755,9 @@ export class TradeService { const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: BigInt(body.productId) }, }); + const sku = await this.prisma.commonProductSku.findUniqueOrThrow({ + where: { id: BigInt(preview.skuId) }, + }); const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } }); let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`; @@ -1730,12 +1815,15 @@ export class TradeService { channelSource: 'PROXY_ONLINE', promoCodeId, productId: product.id, - barcode69: product.barcode69, + skuId: sku.id, + barcode69: sku.barcode69, productName: product.name, - productSpec: product.spec, + productSpec: sku.specText || product.spec, imageResourceId: product.coverResourceId, quantity: body.quantity, - listUnitPrice: product.price, + saleUnit: sku.saleUnit, + bottlesPerUnit: preview.bottlesPerUnit, + listUnitPrice: sku.price, listAmount: preview.productAmount, productAmount: preview.productAmount, payAmount: preview.payAmount, @@ -1993,18 +2081,28 @@ export class TradeService { this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }), this.promoCodeService.listActiveOptions(), ]); + const details = await Promise.all( + products.map((p) => + this.catalogService.getProduct(BigInt(String(p.id)), { bypassWhitelist: true }), + ), + ); return serializeBigInt({ - products: products.map((p) => ({ - id: p.id, - name: p.name, - spec: p.spec, - price: Number(p.price), - 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, + products: details.filter(Boolean).map((p) => ({ + id: p!.id, + name: p!.name, + spec: p!.spec, + price: Number(p!.price), + benefitAmount: p!.benefitAmount != null ? Number(p!.benefitAmount) : null, + coverUrl: (p as { mainImageUrl?: string | null } | null)?.mainImageUrl ?? null, + allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean } | null)?.allowOnSitePickup, + allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean } | null)?.allowOnlinePurchase !== false, allowCrossCityDelivery: - (p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false, + (p as { allowCrossCityDelivery?: boolean } | null)?.allowCrossCityDelivery !== false, + specEnabled: !!(p as { specEnabled?: boolean } | null)?.specEnabled, + saleUnit: (p as { saleUnit?: string } | null)?.saleUnit, + skus: (p as { skus?: unknown[] } | null)?.skus ?? [], + defaultSkuId: (p as { defaultSkuId?: string } | null)?.defaultSkuId, + specAttrs: (p as { specAttrs?: unknown[] } | null)?.specAttrs ?? [], })), promoCodes, stores: [], @@ -2025,6 +2123,7 @@ export class TradeService { productId: string; quantity: number; promoCodeId?: string; + skuId?: string; }, req: Request, ) { @@ -2064,6 +2163,7 @@ export class TradeService { deliveryMode: body.deliveryMode, receiverCity: body.city, receiverDistrict: body.district, + skuId: body.skuId, }, { bypassWhitelist: true }, ); @@ -2071,6 +2171,9 @@ export class TradeService { const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: BigInt(body.productId) }, }); + const sku = await this.prisma.commonProductSku.findUniqueOrThrow({ + where: { id: BigInt(preview.skuId) }, + }); const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } }); let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`; @@ -2128,12 +2231,15 @@ export class TradeService { channelSource: 'PROXY_ONLINE', promoCodeId, productId: product.id, - barcode69: product.barcode69, + skuId: sku.id, + barcode69: sku.barcode69, productName: product.name, - productSpec: product.spec, + productSpec: sku.specText || product.spec, imageResourceId: product.coverResourceId, quantity: body.quantity, - listUnitPrice: product.price, + saleUnit: sku.saleUnit, + bottlesPerUnit: preview.bottlesPerUnit, + listUnitPrice: sku.price, listAmount: preview.productAmount, productAmount: preview.productAmount, payAmount: preview.payAmount,