diff --git a/server/app/goauto/sybimport/apply.go b/server/app/goauto/sybimport/apply.go index b4b021f..7acf7ab 100644 --- a/server/app/goauto/sybimport/apply.go +++ b/server/app/goauto/sybimport/apply.go @@ -100,6 +100,17 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta return err } if parsed.Status == models.SYBParseStatusSuccess && shopeeProduct != nil { + // `[必须]` 先消歧再入档。同一虾皮商品下 `黑色【短袖】` 与 `黑色【長袖】` + // 剥离后会塌缩成同一个 `黑色`,两个不同商品共用一份映射必然买错一半 + // (#301)。消歧要看到同组全部原始规格,所以放在拿到 shopeeProduct + // 之后、写档案之前。 + resolvedKeys, err := resyncShopeeProductKeys(tx, shopeeProduct.ID, rawProductSpecOf(rawJSON)) + if err != nil { + return err + } + if resolvedKeys.Color != "" || resolvedKeys.Size != "" { + parsed.Color, parsed.Size = resolvedKeys.Color, resolvedKeys.Size + } if err := mergeParsedSpec(tx, shopeeProduct.ID, parsed); err != nil { return err } diff --git a/server/app/goauto/sybimport/reparse.go b/server/app/goauto/sybimport/reparse.go index efdf1d0..d8de83a 100644 --- a/server/app/goauto/sybimport/reparse.go +++ b/server/app/goauto/sybimport/reparse.go @@ -80,6 +80,17 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool) if parsed.Status == models.SYBParseStatusUncertain { parsed.Status = models.SYBParseStatusFailed } + // `[必须]` 重解析也要走消歧,否则它会把键写回剥离后的塌缩形式,悄悄 + // 撤销导入时做的拆分(#301)。两条路径必须产出同一个键。 + if parsed.Status == models.SYBParseStatusSuccess && record.ShopeeProductID != nil { + resolved, err := resyncShopeeProductKeys(tx, *record.ShopeeProductID, raw.ProductSpec) + if err != nil { + return err + } + if resolved.Color != "" || resolved.Size != "" { + parsed.Color, parsed.Size = resolved.Color, resolved.Size + } + } outcome.NewStatus = parsed.Status if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus { diff --git a/server/app/goauto/sybimport/resolve_keys.go b/server/app/goauto/sybimport/resolve_keys.go new file mode 100644 index 0000000..0f12284 --- /dev/null +++ b/server/app/goauto/sybimport/resolve_keys.go @@ -0,0 +1,78 @@ +package sybimport + +import ( + "encoding/json" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybspec" + + "gorm.io/gorm" +) + +// resyncShopeeProductKeys recomputes the purchase keys for every detail of one +// Shopee product, including the one being imported, and writes back the +// siblings whose keys changed (#301). +// +// `[必须]` 键的消歧需要同组全部原始规格,单条明细判断不了自己是否安全。新明细 +// 的到来可能让一个原本安全的键变成歧义——`黑色【短袖】` 独自存在时键就是 `黑色`, +// 等 `黑色【長袖】` 进来才需要拆开。因此这里必须把兄弟明细一起重算并回写,否则 +// 同组内会出现一半旧键一半新键,比塌缩本身更难排查。 +func resyncShopeeProductKeys(tx *gorm.DB, shopeeProductID uint64, incomingRaw string) (sybspec.ParseResult, error) { + var siblings []models.SYBProduct + if err := tx.Where("shopee_product_id = ?", shopeeProductID).Find(&siblings).Error; err != nil { + return sybspec.ParseResult{}, err + } + + raws := []string{incomingRaw} + rawByID := make(map[uint64]string, len(siblings)) + for _, sibling := range siblings { + raw, ok := productSpecOf(sibling.RawJSON) + if !ok { + continue + } + rawByID[sibling.ID] = raw + raws = append(raws, raw) + } + + resolved := sybspec.ResolveKeys(raws) + + for _, sibling := range siblings { + raw, ok := rawByID[sibling.ID] + if !ok { + continue + } + keys := resolved[raw] + // `[必须]` 不碰人工或 AI 已确认的明细。它们的键是人(或经人确认的 AI) + // 定下的,消歧无权推翻——与 Reparse 不带 force 时跳过它们是同一条规则。 + // 解析未成功的行同理,它们的键不由本函数决定。 + if sibling.ParseStatus != models.SYBParseStatusSuccess || + sibling.ManuallyConfirmed || sibling.AIConfirmed { + continue + } + if sibling.TargetColor == keys.Color && sibling.TargetSize == keys.Size { + continue + } + if err := tx.Model(&models.SYBProduct{}).Where("id = ?", sibling.ID). + Updates(map[string]any{"target_color": keys.Color, "target_size": keys.Size}).Error; err != nil { + return sybspec.ParseResult{}, err + } + } + return resolved[incomingRaw], nil +} + +func productSpecOf(rawJSON string) (string, bool) { + var payload struct { + ProductSpec string `json:"productSpec"` + } + if json.Unmarshal([]byte(rawJSON), &payload) != nil { + return "", false + } + return payload.ProductSpec, payload.ProductSpec != "" +} + +// rawProductSpecOf extracts the productSpec from an already-normalised raw JSON +// payload, returning an empty string when it is absent. +func rawProductSpecOf(rawJSON string) string { + raw, _ := productSpecOf(rawJSON) + return raw +} diff --git a/server/app/goauto/sybspec/resolve_keys.go b/server/app/goauto/sybspec/resolve_keys.go new file mode 100644 index 0000000..0c18fa0 --- /dev/null +++ b/server/app/goauto/sybspec/resolve_keys.go @@ -0,0 +1,77 @@ +package sybspec + +import ( + "strings" + "unicode" +) + +// ResolveKeys turns every raw productSpec of one Shopee product into its +// purchase keys, keeping a 【...】 annotation only where dropping it would make +// two different specs collide (#301). +// +// 存在的理由:stripBrackets 无差别剥离 【...】,而括号里经常是商品本身而不是备注 +// ——`黑色【短袖】` 与 `黑色【長袖】` 剥离后都成了 `黑色`,档案里只有一个条目, +// 两个不同商品共用同一份映射,必然有一半买错。#289 因此拦截,但人工匹配救不了: +// 坏的是键本身。 +// +// `[必须]` 消歧必须看到同一虾皮商品下的全部原始规格,单条明细无法独立判断 +// `黑色` 是否安全。因此这里接收整组,而不是逐条处理。 +// +// `[必须]` 不塌缩的键必须与今天的行为逐字一致。线上一万四千多条明细里绝大多数 +// 不涉及塌缩,本次改动不能波及它们。 +func ResolveKeys(raws []string) map[string]ParseResult { + resolved := make(map[string]ParseResult, len(raws)) + colorRaws := map[string]map[string]bool{} + sizeRaws := map[string]map[string]bool{} + + for _, raw := range raws { + parsed := Parse(raw) + resolved[raw] = parsed + rawColor, rawSize := RawSpecHalves(raw) + collect(colorRaws, parsed.Color, rawColor) + collect(sizeRaws, parsed.Size, rawSize) + } + + for raw, parsed := range resolved { + rawColor, rawSize := RawSpecHalves(raw) + if ambiguous(colorRaws, parsed.Color) { + parsed.Color = normalizeKey(rawColor) + } + if ambiguous(sizeRaws, parsed.Size) { + parsed.Size = normalizeKey(rawSize) + } + resolved[raw] = parsed + } + return resolved +} + +func collect(index map[string]map[string]bool, key, rawHalf string) { + key, rawHalf = strings.TrimSpace(key), normalizeKey(rawHalf) + if key == "" || rawHalf == "" { + return + } + if index[key] == nil { + index[key] = map[string]bool{} + } + index[key][rawHalf] = true +} + +// ambiguous reports whether more than one distinct raw half collapses onto key. +func ambiguous(index map[string]map[string]bool, key string) bool { + return len(index[strings.TrimSpace(key)]) > 1 +} + +// normalizeKey drops every space so `黑色 【短袖】` and `黑色【短袖】` become the +// same key. +// +// `[必须]` 必须整体去空白,不能只是折叠成单个空格:SYB 对括号前的空格写法并不 +// 一致,同一个规格的两种写法若被当成两个不同的原始值,就会被误判为歧义,把本来 +// 安全的键也拆开。归一后的形式同时用于比较和产出,两边一致。 +func normalizeKey(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r + }, value) +} diff --git a/server/app/goauto/sybspec/resolve_keys_test.go b/server/app/goauto/sybspec/resolve_keys_test.go new file mode 100644 index 0000000..419c0d3 --- /dev/null +++ b/server/app/goauto/sybspec/resolve_keys_test.go @@ -0,0 +1,69 @@ +package sybspec + +import "testing" + +// `[必须]` 这是本单的核心用例。`【短袖】`/`【長袖】` 是商品本身而不是备注, +// 剥离后塌缩成同一个 `黑色`,两个不同商品会共用一份映射(#301)。 +func TestAnnotationIsKeptWhenItDistinguishesTwoProducts(t *testing.T) { + short := "黑色【短袖】,3XL 【建議62.5-67.5公斤】" + long := "黑色【長袖】,5XL 【建議72.5-77.5公斤】" + got := ResolveKeys([]string{short, long}) + + if got[short].Color != "黑色【短袖】" || got[long].Color != "黑色【長袖】" { + t.Fatalf("颜色键未拆开: %q / %q", got[short].Color, got[long].Color) + } + // 尺码不塌缩(3XL 与 5XL 本就不同),必须保持剥离后的短键。 + if got[short].Size != "3XL" || got[long].Size != "5XL" { + t.Fatalf("尺码键被误改: %q / %q", got[short].Size, got[long].Size) + } +} + +// `[必须]` 不塌缩时必须与今天的 Parse 逐字一致,否则会波及线上绝大多数明细。 +func TestNonCollidingKeysAreUnchanged(t *testing.T) { + raws := []string{ + "白色【短袖】,M 【建議42.5-47.5公斤】", + "藍色【短袖】,L 【建議47.5-52.5公斤】", + "香芋紫 【雙梅花】純棉,2XL 60.0-67.5公斤", + } + got := ResolveKeys(raws) + for _, raw := range raws { + want := Parse(raw) + if got[raw].Color != want.Color || got[raw].Size != want.Size { + t.Fatalf("%q 被改动了: 得到 %q/%q,应为 %q/%q", + raw, got[raw].Color, got[raw].Size, want.Color, want.Size) + } + } +} + +// 同一个原始规格出现多次不算歧义——那只是同款重复下单。 +func TestRepeatedIdenticalSpecIsNotAmbiguous(t *testing.T) { + raw := "黑色【短袖】,M 【建議42.5-47.5公斤】" + got := ResolveKeys([]string{raw, raw, raw}) + if got[raw].Color != "黑色" { + t.Fatalf("重复的同一规格不该触发消歧,得到 %q", got[raw].Color) + } +} + +// 尺码侧同样会塌缩,规则必须对称。 +func TestSizeAnnotationIsKeptWhenItDistinguishes(t *testing.T) { + a := "黑色,均碼【薄款】" + b := "黑色,均碼【加厚】" + got := ResolveKeys([]string{a, b}) + if got[a].Size != "均碼【薄款】" || got[b].Size != "均碼【加厚】" { + t.Fatalf("尺码键未拆开: %q / %q", got[a].Size, got[b].Size) + } + // 颜色两边相同,不该被消歧波及。 + if got[a].Color != "黑色" || got[b].Color != "黑色" { + t.Fatalf("颜色键被误改: %q / %q", got[a].Color, got[b].Color) + } +} + +// 括号前有无空格是 SYB 的不一致写法,不应产生两个不同的键。 +func TestWhitespaceBeforeBracketDoesNotSplitTheKey(t *testing.T) { + a := "黑色 【短袖】,M" + b := "黑色【短袖】,L" + got := ResolveKeys([]string{a, b}) + if got[a].Color != "黑色" || got[b].Color != "黑色" { + t.Fatalf("同一规格的两种写法不该被当成歧义: %q / %q", got[a].Color, got[b].Color) + } +}