diff --git a/frontend/admin/src/views/admin/components/ProductEditModal.vue b/frontend/admin/src/views/admin/components/ProductEditModal.vue index 145707e7..bd50f68b 100644 --- a/frontend/admin/src/views/admin/components/ProductEditModal.vue +++ b/frontend/admin/src/views/admin/components/ProductEditModal.vue @@ -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): 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), diff --git a/internal/modules/catalog/product/application/write/create.go b/internal/modules/catalog/product/application/write/create.go index 3d21032a..17213667 100644 --- a/internal/modules/catalog/product/application/write/create.go +++ b/internal/modules/catalog/product/application/write/create.go @@ -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 { diff --git a/internal/modules/catalog/product/application/write/skus.go b/internal/modules/catalog/product/application/write/skus.go index cb8c4d45..a453109e 100644 --- a/internal/modules/catalog/product/application/write/skus.go +++ b/internal/modules/catalog/product/application/write/skus.go @@ -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 } diff --git a/internal/modules/catalog/product/application/write/skus_test.go b/internal/modules/catalog/product/application/write/skus_test.go index 532e05f8..92a4c931 100644 --- a/internal/modules/catalog/product/application/write/skus_test.go +++ b/internal/modules/catalog/product/application/write/skus_test.go @@ -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) } diff --git a/internal/modules/catalog/product/application/write/update.go b/internal/modules/catalog/product/application/write/update.go index be30cd73..2e870d0a 100644 --- a/internal/modules/catalog/product/application/write/update.go +++ b/internal/modules/catalog/product/application/write/update.go @@ -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 } diff --git a/internal/modules/catalog/product/domain/pricing.go b/internal/modules/catalog/product/domain/pricing.go index 3d829224..b2d41ad9 100644 --- a/internal/modules/catalog/product/domain/pricing.go +++ b/internal/modules/catalog/product/domain/pricing.go @@ -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 diff --git a/internal/modules/catalog/product/integrationtest/application/product_sku_test.go b/internal/modules/catalog/product/integrationtest/application/product_sku_test.go index 79c970e5..dbb54fd6 100644 --- a/internal/modules/catalog/product/integrationtest/application/product_sku_test.go +++ b/internal/modules/catalog/product/integrationtest/application/product_sku_test.go @@ -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) + } +}