From c7cd0f6cf2f414c6917b4bea1546aeea035ab857 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 26 Aug 2026 07:43:26 +0800 Subject: [PATCH 1/3] =?UTF-8?q?C=E7=AB=AFSKU=E6=95=B0=E9=87=8F=E5=B0=8F?= =?UTF-8?q?=E4=BA=8E=E7=AD=89=E4=BA=8E1=E4=B8=8D=E5=B1=95=E7=A4=BAchip=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E4=BF=AE=E5=A4=8D=E8=A7=84=E6=A0=BC=E8=BD=B4?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E9=94=99=E4=B9=B1=E6=98=BE=E7=A4=BA=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ProductSpecsEditor.tsx | 79 ++++++++++--------- apps/admin-web/src/pages/ProductsPage.tsx | 1 + .../src/pages/product-detail/index.tsx | 3 +- packages/shared-types/src/catalog.ts | 2 +- .../src/modules/catalog/catalog.service.ts | 5 +- .../src/modules/catalog/product-sku.util.ts | 8 ++ 6 files changed, 58 insertions(+), 40 deletions(-) diff --git a/apps/admin-web/src/components/ProductSpecsEditor.tsx b/apps/admin-web/src/components/ProductSpecsEditor.tsx index b89901b..16201f1 100644 --- a/apps/admin-web/src/components/ProductSpecsEditor.tsx +++ b/apps/admin-web/src/components/ProductSpecsEditor.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message, } from 'antd'; @@ -50,6 +50,38 @@ function keyOf(ids: string[]) { return [...ids].sort().join('_'); } +function normalizeSkuRows(initialSkus: SkuRow[]): SkuRow[] { + if (!initialSkus.length) { + return [ + { + specValueIds: [], + barcode69: '', + price: 0, + status: 'ON_SALE', + allowOnlinePurchase: true, + allowCrossCityDelivery: true, + allowOnSitePickup: false, + saleUnit: 'BOTTLE', + bottlesPerUnit: 1, + isDefault: true, + imageUrl: '', + }, + ]; + } + return 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, + imageUrl: s.imageUrl ?? '', + })); +} + type Props = { productId: string; initialAttrs: SpecAttr[]; @@ -58,44 +90,19 @@ type Props = { }; 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, - imageUrl: s.imageUrl ?? '', - })) - : [ - { - specValueIds: [], - barcode69: '', - price: 0, - status: 'ON_SALE', - allowOnlinePurchase: true, - allowCrossCityDelivery: true, - allowOnSitePickup: false, - saleUnit: 'BOTTLE', - bottlesPerUnit: 1, - isDefault: true, - imageUrl: '', - }, - ], - ); + const [attrs, setAttrs] = useState(() => (initialAttrs.length ? initialAttrs : [])); + const [skus, setSkus] = useState(() => normalizeSkuRows(initialSkus)); const [saving, setSaving] = useState(false); const [editIndex, setEditIndex] = useState(null); + useEffect(() => { + setAttrs(initialAttrs.length ? initialAttrs : []); + setSkus(normalizeSkuRows(initialSkus)); + setEditIndex(null); + // 仅切商品时重载,避免父组件重渲染冲掉编辑中内容 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [productId]); + const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]); function regenerateMatrix() { diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx index c9f8c35..88c73e3 100644 --- a/apps/admin-web/src/pages/ProductsPage.tsx +++ b/apps/admin-web/src/pages/ProductsPage.tsx @@ -610,6 +610,7 @@ export default function ProductsPage() { label: '规格与 SKU', children: ( 0); + const onSaleSkuCount = skus.filter((s) => s.status === 'ON_SALE').length; + const specEnabled = onSaleSkuCount > 1 && attrs.length > 0; const activeSku = useMemo(() => { if (!product) return undefined; if (!specEnabled) { diff --git a/packages/shared-types/src/catalog.ts b/packages/shared-types/src/catalog.ts index 1e3a516..d2451b9 100644 --- a/packages/shared-types/src/catalog.ts +++ b/packages/shared-types/src/catalog.ts @@ -65,7 +65,7 @@ export interface ProductDto { allowOnlinePurchase?: boolean; /** 是否允许跨城配送 */ allowCrossCityDelivery?: boolean; - /** v3.5.4:是否配置了销售规格(多 SKU) */ + /** C 端是否展示规格选择(可售 SKU > 1 且已配规格轴) */ specEnabled?: boolean; /** 列表轻量:展示 SKU 销售单位 */ saleUnit?: ProductSaleUnit; diff --git a/server/dukang-api/src/modules/catalog/catalog.service.ts b/server/dukang-api/src/modules/catalog/catalog.service.ts index 5273665..a6b5452 100644 --- a/server/dukang-api/src/modules/catalog/catalog.service.ts +++ b/server/dukang-api/src/modules/catalog/catalog.service.ts @@ -7,6 +7,7 @@ import { } from '../../common/test-whitelist/test-whitelist.service'; import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper'; import { + catalogSpecPickerEnabled, flattenSkuOntoProduct, listMinOnSalePrice, mapSkuDto, @@ -133,7 +134,7 @@ export class CatalogService { allowOnSitePickup: flat.allowOnSitePickup, allowOnlinePurchase: flat.allowOnlinePurchase, allowCrossCityDelivery: flat.allowCrossCityDelivery, - specEnabled: specAttrs.length > 0 || skus.length > 1, + specEnabled: catalogSpecPickerEnabled(skus, specAttrs.length), saleUnit: flat.saleUnit, ...media, }; @@ -196,7 +197,7 @@ export class CatalogService { allowOnlinePurchase: flat.allowOnlinePurchase, allowCrossCityDelivery: flat.allowCrossCityDelivery, saleUnit: flat.saleUnit, - specEnabled: specAttrs.length > 0 || skus.length > 1, + specEnabled: catalogSpecPickerEnabled(skus, specAttrs.length), specAttrs: mapSpecAttrsDto(specAttrs), skus: cSkus, defaultSkuId: defaultSku?.id.toString(), diff --git a/server/dukang-api/src/modules/catalog/product-sku.util.ts b/server/dukang-api/src/modules/catalog/product-sku.util.ts index 505c964..6cca998 100644 --- a/server/dukang-api/src/modules/catalog/product-sku.util.ts +++ b/server/dukang-api/src/modules/catalog/product-sku.util.ts @@ -18,6 +18,14 @@ export function isSkuOnSale(sku: { status: string }): boolean { return sku.status === 'ON_SALE'; } +/** C 端是否展示规格选择:可售 SKU 多于 1 且已配规格轴 */ +export function catalogSpecPickerEnabled( + skus: Array<{ status: string }>, + specAttrCount: number, +): boolean { + return skus.filter(isSkuOnSale).length > 1 && specAttrCount > 0; +} + export function buildSpecKey(valueIds: bigint[]): string { if (!valueIds.length) return ''; return [...valueIds] From 632539e8dc2f460bdd337cfe30939e1886a693b8 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 26 Aug 2026 09:22:29 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=8E=B0=E5=9C=BA?= =?UTF-8?q?=E6=8F=90=E8=B4=A7=E7=9A=84=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/skills/dukang-coding/SKILL.md | 2 +- .cursor/skills/dukang-v3/SKILL.md | 2 +- .../skills/dukang-v3/reference-req-index.md | 6 +- AGENTS.md | 2 +- agent.md | 2 +- apps/admin-web/src/pages/CitiesPage.tsx | 25 ++++- .../src/pages/FulfillmentProvidersPage.tsx | 1 + .../src/components/ContactCsButton.tsx | 6 +- apps/mini-user/src/lib/wecom-cs.ts | 99 +++++++++++------ apps/mini-user/src/pages/home/index.tsx | 2 +- .../src/pages/order-confirm-pickup/index.tsx | 11 +- .../src/pages/order-confirm/index.tsx | 4 +- .../src/pages/product-detail/index.tsx | 2 +- docs/杜康好客-v3-PRD.md | 3 + docs/杜康好客-v3-现状对照.md | 2 + docs/杜康好客-v3.5.11-开发文档.md | 102 ++++++++++++++++++ docs/杜康好客-v3编码手册.md | 4 +- packages/domain/src/index.test.ts | 45 +++++--- packages/domain/src/index.ts | 50 +++++---- .../src/fulfillment-provider.test.ts | 39 ++++++- .../shared-types/src/fulfillment-provider.ts | 46 ++++++-- server/dukang-api/prisma/init_v3.sql | 1 + .../migrate-city-pickup-min-qty-v3511.sql | 3 + server/dukang-api/prisma/schema.prisma | 1 + server/dukang-api/prisma/seed-v31.ts | 2 + .../src/modules/ops/admin-cities.service.ts | 1 + .../src/modules/ops/dto/admin-mutate.dto.ts | 9 ++ .../src/modules/trade/trade.service.ts | 26 +++-- 28 files changed, 393 insertions(+), 105 deletions(-) create mode 100644 docs/杜康好客-v3.5.11-开发文档.md create mode 100644 server/dukang-api/prisma/migrate-city-pickup-min-qty-v3511.sql diff --git a/.cursor/skills/dukang-coding/SKILL.md b/.cursor/skills/dukang-coding/SKILL.md index b2b2417..58e1c3c 100644 --- a/.cursor/skills/dukang-coding/SKILL.md +++ b/.cursor/skills/dukang-coding/SKILL.md @@ -89,7 +89,7 @@ await this.prisma.order.update({ ... }); ```typescript const benefitAmount = product.benefitAmount ?? product.price; // 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额 -// 同城 min 2 瓶 / 跨城 min 6 瓶 +// 起购:提货/同城 min 2 瓶 / 跨城 min 6 瓶(城市配置,非承运商) // 支付成功 → log_third_party + user_order.pay_status=PAID // → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT) diff --git a/.cursor/skills/dukang-v3/SKILL.md b/.cursor/skills/dukang-v3/SKILL.md index 13de81f..4ea8f56 100644 --- a/.cursor/skills/dukang-v3/SKILL.md +++ b/.cursor/skills/dukang-v3/SKILL.md @@ -37,7 +37,7 @@ disable-model-invocation: true ``` 权益额 = 订单实付 1:1(永久) -同城起购 2 瓶 / 跨城 1 箱(6瓶) +起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 1 箱(6瓶);属城市,非承运商 门店结算 = 核销额 × 60% 单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%) 核销码 TTL = 3 分钟;试核销固定 ¥100 diff --git a/.cursor/skills/dukang-v3/reference-req-index.md b/.cursor/skills/dukang-v3/reference-req-index.md index 0715e2c..c5636b0 100644 --- a/.cursor/skills/dukang-v3/reference-req-index.md +++ b/.cursor/skills/dukang-v3/reference-req-index.md @@ -12,9 +12,9 @@ | REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 | | REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 | | REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 | -| REQ-U-007 | 同城 | 小飞侠;≥2 瓶免运费;物流节点+拍照;24h 自动完成 | -| REQ-U-008 | 现场提货 | 隐藏入口;支付即完成;强提示在现场 | -| REQ-U-009 | 跨城 | 未开通城市;≥1 箱;总部物流到付 | +| REQ-U-007 | 同城 | 小飞侠;起购 ≥2 瓶(城市 `localMinQty`);≥2 瓶免运费;物流节点+拍照;24h 自动完成 | +| REQ-U-008 | 现场提货 | 隐藏入口;起购 ≥2 瓶(城市 `pickupMinQty`);支付即完成;强提示在现场 | +| REQ-U-009 | 跨城 | 未开通城市;起购 ≥1 箱(城市 `crossMinQty`=6 瓶当量);总部物流到付 | | REQ-U-010 | 锁单 | 待付款 30 分钟取消 | | REQ-U-011 | 权益 | 支付成功 1:1 永久 | | REQ-U-012 | 客服 | 已付款起电话/微信客服 | diff --git a/AGENTS.md b/AGENTS.md index be10f31..ab08559 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ ``` 权益额 = benefit_amount ?? price -同城起购 2 瓶 / 跨城 6 瓶 +起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商 核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟 C 端门店仅 status=OPEN 订单 Tab:待付款 | 已付款 | 已完成 diff --git a/agent.md b/agent.md index ff0be89..8d3240b 100644 --- a/agent.md +++ b/agent.md @@ -92,7 +92,7 @@ ```text 权益发放额 = common_product_item.benefit_amount ?? price -同城起购 2 瓶 / 跨城 6 瓶 +起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商 核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额 C 端门店列表仅 status=OPEN 订单 Tab:all | pending_pay | pending_ship | pending_receive | completed diff --git a/apps/admin-web/src/pages/CitiesPage.tsx b/apps/admin-web/src/pages/CitiesPage.tsx index b1dc8ff..883e67d 100644 --- a/apps/admin-web/src/pages/CitiesPage.tsx +++ b/apps/admin-web/src/pages/CitiesPage.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import { Button, Descriptions, + Divider, Drawer, Form, Input, @@ -362,8 +363,28 @@ export default function CitiesPage() {