Merge pull request #348 from SzeMeng76/fix/sku-single-mode

fix: 修复商品无法关闭 SKU 规格配置
This commit is contained in:
Assimon authored and GitHub committed 2026-10-06 18:14:24 +08:00
commit db4b52a108
7 files changed
+352 -37

No files matched your search

@@ -118,6 +118,19 @@ const createEmptyLocaleText = () => ({
'en-US': '',
})
// resolveEditableSKURows 把接口返回的 SKU 列表转换为表单可编辑的规格行。
//
// 单规格商品在后端固定表示为「恰好一行 DEFAULT」,它承载价格与库存,并不是一条用户可见的
// 规格。若把它填进规格列表,用户删掉它再保存时会被后端重新生成,表现为「关不掉规格配置」,
// 所以这里只隐藏「有且仅有一行且编码为 DEFAULT」的情况;多规格中恰好叫 DEFAULT 的自定义
// 编码属于真实规格,照常展示。
const resolveEditableSKURows = (product: AdminProduct): SKUFormItem[] => {
const rows = Array.isArray(product.skus) ? product.skus : []
const isSingleSpecMode = rows.length === 1 && String(rows[0]?.sku_code || '').trim().toUpperCase() === 'DEFAULT'
if (isSingleSpecMode) return []
return rows.map((item: AdminProductSKU) => createSKUFormItem(item))
}
const createSKUFormItem = (raw?: Partial<AdminProductSKU>): SKUFormItem => ({
id: Number(raw?.id || 0),
sku_code: String(raw?.sku_code || '').trim(),
@@ -546,7 +559,7 @@ const populateForm = (product: AdminProduct) => {
stock_display_mode: product.stock_display_mode || 'exact',
fulfillment_type: product.fulfillment_type || 'manual',
manual_stock_total: resolveManualStockMetrics(product).total,
skus: Array.isArray(product.skus) ? product.skus.map((item: AdminProductSKU) => createSKUFormItem(item)) : [],
skus: resolveEditableSKURows(product),
category_id: Number(product.category_id || 0) || null,
payment_channel_ids: parsePaymentChannelIDs(product.payment_channel_ids),
is_affiliate_enabled: Boolean(product.is_affiliate_enabled),
@@ -142,7 +142,7 @@ func (s *WriteService) Create(input CreateProductInput) (*productdomain.Product,
if err := s.applyProductSKUsWithStockGuard(skuRepo, cardSecretRepo, product.ID, fulfillmentType, normalizedSKUs); err != nil {
return err
}
} else if err := s.syncSingleProductSKU(skuRepo, product.ID, priceAmount, costPriceAmount, manualStockTotal, true); err != nil {
} else if err := s.syncSingleProductSKU(skuRepo, cardSecretRepo, product.ID, fulfillmentType, priceAmount, costPriceAmount, manualStockTotal, true); err != nil {
return err
}
if input.WholesalePrices != nil {
@@ -13,7 +13,23 @@ import (
"github.com/shopspring/decimal"
)
func (s *WriteService) syncSingleProductSKU(skuRepo SKURepository, productID uint, priceAmount decimal.Decimal, costPriceAmount decimal.Decimal, manualStockTotal int, createWhenMissing bool) error {
// syncSingleProductSKU 把商品收敛为单规格模式:恰好保留一行 DEFAULT SKU,删除其余规格行。
//
// DEFAULT 行是单规格约定的内部表示(见 productdomain.DefaultSKUCode),后台不把它当作
// 一条"规格"展示,因此用户在界面上删除全部规格后保存,得到的就是这个状态。
//
// 删除会连带让被删 SKU 的卡密库存失去归属,因此复用多规格路径的库存守卫:
// 待删 SKU 只要还有可用或未核销的卡密库存,就拒绝而不是静默删除。
func (s *WriteService) syncSingleProductSKU(
skuRepo SKURepository,
cardSecretRepo CardSecretStockRepository,
productID uint,
fulfillmentType string,
priceAmount decimal.Decimal,
costPriceAmount decimal.Decimal,
manualStockTotal int,
createWhenMissing bool,
) error {
if skuRepo == nil || productID == 0 {
return nil
}
@@ -43,12 +59,27 @@ func (s *WriteService) syncSingleProductSKU(skuRepo SKURepository, productID uin
return productcontract.ErrProductSKUInvalid
}
// 先确认待删除的行可以安全删除,避免删到一半才发现有卡密库存。
for i := range skus {
if i == targetIndex {
continue
}
if err := ensureSKURemovable(cardSecretRepo, productID, fulfillmentType, skus[i]); err != nil {
return err
}
}
target := skus[targetIndex]
target.PriceAmount = money.FromDecimal(priceAmount)
target.CostPriceAmount = money.FromDecimal(costPriceAmount)
target.ManualStockTotal = manualStockTotal
target.IsActive = true
if strings.TrimSpace(target.SKUCode) == "" {
target.SpecValuesJSON = jsonmap.JSON{}
if strings.ToUpper(strings.TrimSpace(target.SKUCode)) != strings.ToUpper(productdomain.DefaultSKUCode) {
// 改名为 DEFAULT 前先清理同编码的软删除残留,否则会撞唯一索引。
if err := skuRepo.PurgeSoftDeletedByProductAndCode(productID, productdomain.DefaultSKUCode); err != nil {
return err
}
target.SKUCode = productdomain.DefaultSKUCode
}
if err := skuRepo.Update(&target); err != nil {
@@ -66,6 +97,25 @@ func (s *WriteService) syncSingleProductSKU(skuRepo SKURepository, productID uin
return nil
}
// ensureSKURemovable 在自动发货场景下校验该 SKU 是否可以安全删除(无卡密库存占用)。
// 非自动发货或未注入卡密仓储时直接放行。
func ensureSKURemovable(cardSecretRepo CardSecretStockRepository, productID uint, fulfillmentType string, sku productdomain.ProductSKU) error {
if cardSecretRepo == nil || productID == 0 || strings.TrimSpace(fulfillmentType) != constants.FulfillmentTypeAuto {
return nil
}
total, available, used, err := cardSecretRepo.CountByProduct(productID, sku.ID)
if err != nil {
return err
}
if available > 0 || total-used > 0 {
return productcontract.ErrProductSKUHasCardSecretStock
}
return nil
}
// pickSingleModeTargetSKUIndex 选择单规格模式下存续的那一行。
// 单规格模式的唯一存续行必须是 DEFAULT,因此优先复用已存在的 DEFAULT 行(无论当前是否启用),
// 避免每次保存都新建一行;只有确实没有 DEFAULT 行时才退化为挑一个现有行改名。
func pickSingleModeTargetSKUIndex(skus []productdomain.ProductSKU) int {
if len(skus) == 0 {
return -1
@@ -73,9 +123,6 @@ func pickSingleModeTargetSKUIndex(skus []productdomain.ProductSKU) int {
defaultCode := strings.ToUpper(strings.TrimSpace(productdomain.DefaultSKUCode))
for i := range skus {
if !skus[i].IsActive {
continue
}
if strings.ToUpper(strings.TrimSpace(skus[i].SKUCode)) == defaultCode {
return i
}
@@ -85,11 +132,6 @@ func pickSingleModeTargetSKUIndex(skus []productdomain.ProductSKU) int {
return i
}
}
for i := range skus {
if strings.ToUpper(strings.TrimSpace(skus[i].SKUCode)) == defaultCode {
return i
}
}
return 0
}
@@ -1,11 +1,15 @@
package productwrite
import (
"errors"
"sort"
"strings"
"testing"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
"github.com/dujiao-next/internal/constants"
productcontract "github.com/dujiao-next/internal/modules/catalog/product/contract"
"github.com/dujiao-next/internal/shared/money"
"github.com/shopspring/decimal"
@@ -55,7 +59,7 @@ func TestSyncSingleProductSKUMultipleRowsKeepsSingleActive(t *testing.T) {
}
targetPrice := decimal.RequireFromString("88.88")
if err := service.syncSingleProductSKU(repo, productID, targetPrice, decimal.Zero, 5, true); err != nil {
if err := service.syncSingleProductSKU(repo, nil, productID, "", targetPrice, decimal.Zero, 5, true); err != nil {
t.Fatalf("sync single sku failed: %v", err)
}
@@ -63,22 +67,125 @@ func TestSyncSingleProductSKUMultipleRowsKeepsSingleActive(t *testing.T) {
if err != nil {
t.Fatalf("list sku failed: %v", err)
}
activeCount := 0
for _, sku := range skus {
if !sku.IsActive {
continue
}
activeCount++
if sku.ID != firstActive.ID {
t.Fatalf("expected higher sort_order active sku id=%d, got id=%d", firstActive.ID, sku.ID)
}
if !sku.PriceAmount.Equal(targetPrice) || sku.ManualStockTotal != 5 {
t.Fatalf("unexpected synchronized SKU: %+v", sku)
}
if len(skus) != 1 {
t.Fatalf("expected exactly one sku row after collapsing to single spec, got %d: %+v", len(skus), skus)
}
if activeCount != 1 {
t.Fatalf("expected exactly one active sku, got %d", activeCount)
// 存续的必须是 DEFAULT 行:单规格模式的唯一表示,且承载价格与库存。
if skus[0].ID != inactiveDefault.ID {
t.Fatalf("expected surviving row to be the DEFAULT sku id=%d, got id=%d", inactiveDefault.ID, skus[0].ID)
}
if !strings.EqualFold(skus[0].SKUCode, productdomain.DefaultSKUCode) {
t.Fatalf("expected surviving row code DEFAULT, got %q", skus[0].SKUCode)
}
if !skus[0].IsActive {
t.Fatalf("expected surviving DEFAULT row to be active: %+v", skus[0])
}
if !skus[0].PriceAmount.Equal(targetPrice) || skus[0].ManualStockTotal != 5 {
t.Fatalf("unexpected synchronized SKU: %+v", skus[0])
}
}
func TestSyncSingleProductSKURenamesSurvivorToDefault(t *testing.T) {
service := NewWriteService(Options{})
repo := newSyncSingleSKURepo(t)
productID := uint(2003)
// 没有任何 DEFAULT 行时,退化挑一行现存规格改名,保证存续行编码为 DEFAULT。
first := productdomain.ProductSKU{
ProductID: productID,
SKUCode: "A",
PriceAmount: money.FromDecimal(decimal.NewFromInt(20)),
ManualStockTotal: 2,
IsActive: true,
SortOrder: 2,
}
second := productdomain.ProductSKU{
ProductID: productID,
SKUCode: "B",
PriceAmount: money.FromDecimal(decimal.NewFromInt(30)),
ManualStockTotal: 4,
IsActive: true,
SortOrder: 1,
}
if err := repo.Create(&first); err != nil {
t.Fatalf("create sku A failed: %v", err)
}
if err := repo.Create(&second); err != nil {
t.Fatalf("create sku B failed: %v", err)
}
targetPrice := decimal.RequireFromString("12.34")
if err := service.syncSingleProductSKU(repo, nil, productID, "", targetPrice, decimal.Zero, 7, true); err != nil {
t.Fatalf("sync single sku failed: %v", err)
}
skus, err := repo.ListByProduct(productID, false)
if err != nil {
t.Fatalf("list sku failed: %v", err)
}
if len(skus) != 1 {
t.Fatalf("expected exactly one sku row, got %d: %+v", len(skus), skus)
}
if !strings.EqualFold(skus[0].SKUCode, productdomain.DefaultSKUCode) {
t.Fatalf("expected survivor renamed to DEFAULT, got %q", skus[0].SKUCode)
}
if !skus[0].PriceAmount.Equal(targetPrice) || skus[0].ManualStockTotal != 7 {
t.Fatalf("unexpected synchronized SKU: %+v", skus[0])
}
}
// 自动发货商品通过单规格路径删规格时,同样不能删掉还压着卡密库存的行。
func TestSyncSingleProductSKURejectsRemovingSKUWithCardSecretStock(t *testing.T) {
service := NewWriteService(Options{})
repo := newSyncSingleSKURepo(t)
productID := uint(2004)
target := productdomain.ProductSKU{
ProductID: productID,
SKUCode: productdomain.DefaultSKUCode,
PriceAmount: money.FromDecimal(decimal.NewFromInt(10)),
ManualStockTotal: 0,
IsActive: true,
SortOrder: 0,
}
withStock := productdomain.ProductSKU{
ProductID: productID,
SKUCode: "A",
PriceAmount: money.FromDecimal(decimal.NewFromInt(20)),
ManualStockTotal: 0,
IsActive: true,
SortOrder: 1,
}
if err := repo.Create(&target); err != nil {
t.Fatalf("create default sku failed: %v", err)
}
if err := repo.Create(&withStock); err != nil {
t.Fatalf("create sku A failed: %v", err)
}
cardSecrets := &memoryCardSecretRepo{available: map[uint]int64{withStock.ID: 3}}
err := service.syncSingleProductSKU(repo, cardSecrets, productID, constants.FulfillmentTypeAuto, decimal.NewFromInt(10), decimal.Zero, 0, true)
if !errors.Is(err, productcontract.ErrProductSKUHasCardSecretStock) {
t.Fatalf("expected ErrProductSKUHasCardSecretStock, got %v", err)
}
// 拒绝时不应留下半删状态。
skus, listErr := repo.ListByProduct(productID, false)
if listErr != nil {
t.Fatalf("list sku failed: %v", listErr)
}
if len(skus) != 2 {
t.Fatalf("expected both rows to survive rejection, got %d: %+v", len(skus), skus)
}
}
type memoryCardSecretRepo struct {
available map[uint]int64
}
func (repo *memoryCardSecretRepo) CountByProduct(_ uint, skuID uint) (int64, int64, int64, error) {
available := repo.available[skuID]
return available, available, 0, nil
}
func TestSyncSingleProductSKUNoActivePrefersDefaultCode(t *testing.T) {
@@ -118,7 +225,7 @@ func TestSyncSingleProductSKUNoActivePrefersDefaultCode(t *testing.T) {
}
targetPrice := decimal.RequireFromString("19.90")
if err := service.syncSingleProductSKU(repo, productID, targetPrice, decimal.Zero, 6, true); err != nil {
if err := service.syncSingleProductSKU(repo, nil, productID, "", targetPrice, decimal.Zero, 6, true); err != nil {
t.Fatalf("sync single sku failed: %v", err)
}
@@ -153,26 +153,35 @@ func (s *WriteService) Update(id string, input CreateProductInput) (*productdoma
if err := s.applyProductSKUsWithStockGuard(skuRepo, cardSecretRepo, product.ID, fulfillmentType, normalizedSKUs); err != nil {
return err
}
} else if err := s.syncSingleProductSKU(skuRepo, product.ID, priceAmount, product.CostPriceAmount.Decimal, product.ManualStockTotal, true); err != nil {
} else if err := s.syncSingleProductSKU(skuRepo, cardSecretRepo, product.ID, fulfillmentType, priceAmount, product.CostPriceAmount.Decimal, product.ManualStockTotal, true); err != nil {
return err
}
// SKU 落库之后才处理批发价:此时的规格集合才是最终形态。
var skus []productdomain.ProductSKU
if skuRepo != nil {
var err error
skus, err = skuRepo.ListByProduct(product.ID, false)
if err != nil {
return err
}
}
// 仅当请求显式携带批发价字段时才覆盖,省略字段(nil)保留原有配置,
// 避免不关心批发价的局部更新静默清空已配阶梯。
if input.WholesalePrices != nil {
var skus []productdomain.ProductSKU
if skuRepo != nil {
var err error
skus, err = skuRepo.ListByProduct(product.ID, false)
if err != nil {
return err
}
}
wholesalePrices, err := productdomain.NormalizeWholesalePricesForSKUs(*input.WholesalePrices, skus)
if err != nil {
return err
}
product.WholesalePrices = wholesalePrices
} else {
// 上面可能删掉了规格行。保留的阶梯若仍指向已删除的 SKU,会让后续任何
// 批发价编辑都因为「引用了不存在的 SKU」而失败,这里顺手清掉悬空阶梯。
product.WholesalePrices = productdomain.PruneWholesalePricesForSKUs(product.WholesalePrices, skus)
}
// product 是读取时预加载出来的,带着一份过期的 SKU 快照。Save 默认会级联保存关联,
// 那份旧快照会把上面刚删掉的规格行按原 ID 重新插回来(issue #344 的「删了又回来」),
// 所以这里只落商品自身字段,规格由上面的 SKU 路径独占管理。
product.SKUs = nil
if err := productRepo.Update(product); err != nil {
return err
}
@@ -125,6 +125,43 @@ func wholesaleTierUniqueKey(skuID uint, skuCode string, minQuantity int) string
return fmt.Sprintf("%s:%d", wholesaleTierScopeKey(skuID, skuCode), minQuantity)
}
// PruneWholesalePricesForSKUs 丢弃引用了已不存在 SKU 的批发价阶梯。
//
// 与 NormalizeWholesalePricesForSKUs 不同,这里不返回错误:删除 SKU 之后残留的
// 阶梯属于预期内的清理场景,直接丢弃即可。若不清理,残留阶梯会让后续的批发价
// 编辑因为「引用了不存在的 SKU」而校验失败。
func PruneWholesalePricesForSKUs(tiers WholesalePriceTiers, skus []ProductSKU) WholesalePriceTiers {
if len(tiers) == 0 {
return tiers
}
liveIDs := make(map[uint]struct{}, len(skus))
liveCodes := make(map[string]struct{}, len(skus))
for _, sku := range skus {
if sku.ID > 0 {
liveIDs[sku.ID] = struct{}{}
}
if code := strings.ToLower(strings.TrimSpace(sku.SKUCode)); code != "" {
liveCodes[code] = struct{}{}
}
}
kept := make(WholesalePriceTiers, 0, len(tiers))
for _, tier := range tiers {
if tier.SKUID > 0 {
if _, ok := liveIDs[tier.SKUID]; !ok {
continue
}
}
if code := strings.ToLower(strings.TrimSpace(tier.SKUCode)); code != "" {
if _, ok := liveCodes[code]; !ok {
continue
}
}
kept = append(kept, tier)
}
return kept
}
func wholesaleTierScopeKey(skuID uint, skuCode string) string {
if code := strings.ToLower(strings.TrimSpace(skuCode)); code != "" {
return "code:" + code
@@ -2,6 +2,7 @@ package integrationtest
import (
"strconv"
"strings"
"testing"
cardsecretdomain "github.com/dujiao-next/internal/modules/cardsecret/domain"
@@ -10,6 +11,7 @@ import (
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
categorydomain "github.com/dujiao-next/internal/modules/catalog/category/domain"
productgormstore "github.com/dujiao-next/internal/modules/catalog/product/store/gormstore"
"github.com/dujiao-next/internal/constants"
"github.com/dujiao-next/internal/shared/jsonmap"
@@ -106,3 +108,108 @@ func TestProductServiceUpdateRejectsDisablingAutoSKUWithCardSecretStock(t *testi
t.Fatalf("update product error want %v got %v", productcontract.ErrProductSKUHasCardSecretStock, err)
}
}
// 复现 issue #344:后台删光全部规格后保存,商品必须真正回落为单规格。
//
// 之前的实现会在收到空 SKUs 时"复活"一行已存在的规格行,用户重新打开商品就看到规格
// 又回来了,表现为"关不掉 SKU 规格配置"。这里走完整的 Update 路径(含 db.Save 关联
// 级联),确认落库后只剩一行 DEFAULT,并且反复保存保持稳定。
func TestProductServiceUpdateWithEmptySKUsCollapsesToSingleSpec(t *testing.T) {
svc, db := newProductServiceForTest(t)
category := categorydomain.Category{
Slug: "collapse-single-spec-category",
NameJSON: jsonmap.JSON{"zh-CN": "collapse-single-spec-category"},
}
if err := db.Create(&category).Error; err != nil {
t.Fatalf("create category failed: %v", err)
}
product := productdomain.Product{
CategoryID: category.ID,
Slug: "collapse-single-spec-product",
TitleJSON: jsonmap.JSON{"zh-CN": "collapse-single-spec-product"},
PriceAmount: money.FromDecimal(decimal.NewFromInt(10)),
PurchaseType: constants.ProductPurchaseMember,
FulfillmentType: constants.FulfillmentTypeAuto,
IsActive: true,
}
if err := db.Create(&product).Error; err != nil {
t.Fatalf("create product failed: %v", err)
}
defaultSKU := productdomain.ProductSKU{
ProductID: product.ID,
SKUCode: productdomain.DefaultSKUCode,
SpecValuesJSON: jsonmap.JSON{},
PriceAmount: money.FromDecimal(decimal.NewFromInt(10)),
IsActive: true,
SortOrder: 0,
}
extraSKU := productdomain.ProductSKU{
ProductID: product.ID,
SKUCode: "SKU-EXTRA",
SpecValuesJSON: jsonmap.JSON{"zh-CN": "规格一"},
PriceAmount: money.FromDecimal(decimal.NewFromInt(20)),
IsActive: true,
SortOrder: 1,
}
if err := db.Create(&defaultSKU).Error; err != nil {
t.Fatalf("create default sku failed: %v", err)
}
if err := db.Create(&extraSKU).Error; err != nil {
t.Fatalf("create extra sku failed: %v", err)
}
productID := strconv.FormatUint(uint64(product.ID), 10)
buildInput := func() productwrite.CreateProductInput {
return productwrite.CreateProductInput{
CategoryID: category.ID,
Slug: product.Slug,
TitleJSON: map[string]interface{}{"zh-CN": "collapse-single-spec-product"},
PriceAmount: decimal.NewFromInt(10),
PurchaseType: constants.ProductPurchaseMember,
FulfillmentType: constants.FulfillmentTypeAuto,
SKUs: nil, // 后台把规格全删了
IsActive: func() *bool {
value := true
return &value
}(),
}
}
// 连续保存两次:既验证首次收敛,也验证重开后再次保存不会把规格带回来。
for round := 1; round <= 2; round++ {
if _, err := svc.Write.Update(productID, buildInput()); err != nil {
t.Fatalf("round %d: update product failed: %v", round, err)
}
stored, err := productgormstore.NewProductStore(db).GetAdminByID(productID)
if err != nil {
t.Fatalf("round %d: reload product failed: %v", round, err)
}
if stored == nil {
t.Fatalf("round %d: product disappeared", round)
}
if len(stored.SKUs) != 1 {
t.Fatalf("round %d: expected exactly one sku after collapsing, got %d: %+v", round, len(stored.SKUs), stored.SKUs)
}
if !strings.EqualFold(stored.SKUs[0].SKUCode, productdomain.DefaultSKUCode) {
t.Fatalf("round %d: expected surviving sku code DEFAULT, got %q", round, stored.SKUs[0].SKUCode)
}
if !stored.SKUs[0].IsActive {
t.Fatalf("round %d: expected surviving DEFAULT sku to be active: %+v", round, stored.SKUs[0])
}
}
// 规格表里也不能留下软删除之外的残留行。
var rowCount int64
if err := db.Model(&productdomain.ProductSKU{}).
Where("product_id = ? AND deleted_at IS NULL", product.ID).
Count(&rowCount).Error; err != nil {
t.Fatalf("count sku rows failed: %v", err)
}
if rowCount != 1 {
t.Fatalf("expected exactly one live sku row in db, got %d", rowCount)
}
}