Merge pull request #348 from SzeMeng76/fix/sku-single-mode
fix: 修复商品无法关闭 SKU 规格配置
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user