diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 17119da..19d1ae2 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -137,6 +137,11 @@ type batchPreviewDataset struct { activeCollectionByPDD map[uint64]uint64 collectionRuleAvailable bool skuCombinationsByPDD map[uint64][]pddSKUCombination + // collapsedKeysByShopee 记录该虾皮商品下哪些 target_color / target_size + // 是不安全的键——多个不同的虾皮原始规格剥离 【...】 后塌缩到了同一个值。 + // 见 #289 与 sybimport.RawSpecHalves。 + collapsedColorByShopee map[uint64]map[string]bool + collapsedSizeByShopee map[uint64]map[string]bool } // loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries. @@ -152,6 +157,8 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba latestCollectionByPDD: make(map[uint64]models.CollectionTask), activeCollectionByPDD: make(map[uint64]uint64), skuCombinationsByPDD: make(map[uint64][]pddSKUCombination), + collapsedColorByShopee: make(map[uint64]map[string]bool), + collapsedSizeByShopee: make(map[uint64]map[string]bool), } var sybProducts []models.SYBProduct if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil { @@ -165,6 +172,9 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba } } if len(shopeeIDs) > 0 { + if err := s.loadCollapsedSpecKeys(ctx, shopeeIDs, &dataset); err != nil { + return dataset, err + } var shopeeProducts []models.ShopeeProduct if err := s.DB.WithContext(ctx).Where("id IN ?", shopeeIDs).Find(&shopeeProducts).Error; err != nil { return dataset, err @@ -424,6 +434,14 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua } // #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。 } + // `[必须]` 塌缩的键不是这条明细独有的:同一个虾皮商品下,多个不同的虾皮原始 + // 规格剥离 【...】 后落到了同一个 target_color / target_size,于是共用同一份 + // 映射,Agent 会为不同规格点击同一个 PDD 值。此时必须明确失败,不能让它显示 + // 为采购就绪——静默买错比不能采购严重得多(#289)。 + if reason := collapsedSpecReason(dataset, syb); reason != "" { + item.ReasonCode, item.Reason, item.NextAction = CodeSpecKeyAmbiguous, reason, "open_mapping" + return item + } // Readiness must agree with the mapping shown in the product detail. A // deterministic suggestion is only a preview; it is not a persisted, // confirmed mapping and must not make the row appear purchase-ready. diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go index c362499..3be3c96 100644 --- a/server/app/goauto/purchase/batch_test.go +++ b/server/app/goauto/purchase/batch_test.go @@ -310,8 +310,11 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) { if matcher.calls != 0 { t.Fatalf("read-only preview called AI matcher %d times", matcher.calls) } - if queries != 7 { - t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries) + // #289 新增一次有界批量查询(按蕃皮商品拉全部明细用于塔缩检测), + // 因此从 7 变为 8。这条断言守的是“不得出现 N+1”,不是具体数字; + // 只有新增的查询确实有界时才允许上调。 + if queries != 8 { + t.Fatalf("batch preview used %d queries, want 8 bounded queries including collection eligibility, current purchase rule and collapsed spec keys", queries) } if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 { t.Fatalf("unresolved rows remain eligible for live probing but are not purchase-ready: %+v", response) diff --git a/server/app/goauto/purchase/collapsed_spec.go b/server/app/goauto/purchase/collapsed_spec.go new file mode 100644 index 0000000..1e9e1e7 --- /dev/null +++ b/server/app/goauto/purchase/collapsed_spec.go @@ -0,0 +1,95 @@ +package purchase + +import ( + "context" + "encoding/json" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybspec" +) + +// CodeSpecKeyAmbiguous marks a detail whose target colour or size is shared by +// several different Shopee specs, so the stored mapping cannot say which one +// the agent should click. +const CodeSpecKeyAmbiguous = "SPEC_KEY_AMBIGUOUS" + +// loadCollapsedSpecKeys finds, per Shopee product, the target colours and sizes +// that more than one raw Shopee spec collapses onto (#289). +// +// `[必须]` 必须按虾皮商品加载**全部**明细,而不是本次请求的那几条:塌缩是商品级 +// 的属性,只看请求内的明细会漏判——另一条同键不同原文的明细可能不在本次选择里。 +func (s *Service) loadCollapsedSpecKeys(ctx context.Context, shopeeIDs []uint64, dataset *batchPreviewDataset) error { + var rows []models.SYBProduct + if err := s.DB.WithContext(ctx). + Select("id", "shopee_product_id", "target_color", "target_size", "raw_json"). + Where("shopee_product_id IN ?", shopeeIDs). + Find(&rows).Error; err != nil { + return err + } + colorRaws := map[uint64]map[string][]string{} + sizeRaws := map[uint64]map[string][]string{} + for _, row := range rows { + if row.ShopeeProductID == nil { + continue + } + rawColor, rawSize := sybspec.RawSpecHalves(rawProductSpec(row)) + collect(colorRaws, *row.ShopeeProductID, row.TargetColor, rawColor) + collect(sizeRaws, *row.ShopeeProductID, row.TargetSize, rawSize) + } + dataset.collapsedColorByShopee = collapsedKeys(colorRaws) + dataset.collapsedSizeByShopee = collapsedKeys(sizeRaws) + return nil +} + +// rawProductSpec 取出存储的 SYB 原始 productSpec。解不开时返回空串, +// 由调用方当作“无原文可比”处理,而不是报错阻断整个预览。 +func rawProductSpec(row models.SYBProduct) string { + var payload struct { + ProductSpec string `json:"productSpec"` + } + if err := json.Unmarshal([]byte(row.RawJSON), &payload); err != nil { + return "" + } + return payload.ProductSpec +} + +func collect(into map[uint64]map[string][]string, shopeeID uint64, key, raw string) { + if key == "" || raw == "" { + return + } + if into[shopeeID] == nil { + into[shopeeID] = map[string][]string{} + } + into[shopeeID][key] = append(into[shopeeID][key], raw) +} + +func collapsedKeys(raws map[uint64]map[string][]string) map[uint64]map[string]bool { + result := make(map[uint64]map[string]bool, len(raws)) + for shopeeID, byKey := range raws { + for key, halves := range byKey { + if !sybspec.CollapsedSpecKey(halves) { + continue + } + if result[shopeeID] == nil { + result[shopeeID] = map[string]bool{} + } + result[shopeeID][key] = true + } + } + return result +} + +// collapsedSpecReason returns a human-readable reason when this detail's colour +// or size key is shared by several different Shopee specs, or "" when it is safe. +func collapsedSpecReason(dataset batchPreviewDataset, syb models.SYBProduct) string { + if syb.ShopeeProductID == nil { + return "" + } + if dataset.collapsedColorByShopee[*syb.ShopeeProductID][syb.TargetColor] { + return "同一蝦皮商品下有多个不同规格的颜色被识别成「" + syb.TargetColor + "」,无法确定该采购哪一个,请人工确认规格" + } + if dataset.collapsedSizeByShopee[*syb.ShopeeProductID][syb.TargetSize] { + return "同一蝦皮商品下有多个不同规格的尺码被识别成「" + syb.TargetSize + "」,无法确定该采购哪一个,请人工确认规格" + } + return "" +} diff --git a/server/app/goauto/purchase/collapsed_spec_test.go b/server/app/goauto/purchase/collapsed_spec_test.go new file mode 100644 index 0000000..2589272 --- /dev/null +++ b/server/app/goauto/purchase/collapsed_spec_test.go @@ -0,0 +1,104 @@ +package purchase + +import ( + "context" + "testing" + + "go-admin/app/goauto/models" +) + +// `[必须]` 塌缩的键不得显示为采购就绪。剥离 【...】 后两个不同的虾皮规格落到同一个 +// target_color,就会共用同一份映射,Agent 会为不同规格点击同一个 PDD 值。线上真实 +// 样本:虾皮 1355 的 6 个白色(白色【207A】/【209A】/…)全部塌缩成「白色」(#289)。 +func TestBatchPreviewRefusesCollapsedSpecKey(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + + // 第一条明细的原文带款号;既有 fixture 的 RawJSON 是 {},先补成真实形态。 + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID). + Update("raw_json", `{"productSpec":"黑色【204A】,XL"}`).Error; err != nil { + t.Fatal(err) + } + // 同一虾皮商品的第二条明细:不同款号,剥离后同样是「黑色」。 + sibling := f.syb + sibling.ID = 0 + sibling.OrderCode = "SYB-2" + sibling.DetailID = 2 + sibling.RawJSON = `{"productSpec":"黑色【208A】,XL"}` + if err := db.Create(&sibling).Error; err != nil { + t.Fatal(err) + } + + response, err := NewService(db).BatchPreview(context.Background(), + BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}}) + if err != nil { + t.Fatal(err) + } + if len(response.Items) != 1 { + t.Fatalf("expected one preview item, got %+v", response.Items) + } + item := response.Items[0] + if item.ReasonCode != CodeSpecKeyAmbiguous { + t.Fatalf("collapsed key must be refused, got code=%q reason=%q eligible=%v", + item.ReasonCode, item.Reason, item.Eligible) + } + if item.Eligible { + t.Fatal("a detail whose spec key is ambiguous must not stay eligible") + } +} + +// 同一条规格重复出现(同商品多条订单买同一个规格)不算塌缩,行为必须不变。 +func TestBatchPreviewAllowsRepeatedIdenticalSpec(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID). + Update("raw_json", `{"productSpec":"黑色【204A】,XL"}`).Error; err != nil { + t.Fatal(err) + } + sibling := f.syb + sibling.ID = 0 + sibling.OrderCode = "SYB-2" + sibling.DetailID = 2 + sibling.RawJSON = `{"productSpec":"黑色【204A】,XL"}` + if err := db.Create(&sibling).Error; err != nil { + t.Fatal(err) + } + + response, err := NewService(db).BatchPreview(context.Background(), + BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}}) + if err != nil { + t.Fatal(err) + } + if response.Items[0].ReasonCode == CodeSpecKeyAmbiguous { + t.Fatalf("identical repeated specs are not a collapse: %+v", response.Items[0]) + } +} + +// 尺码塌缩同样要检出(线上 24 个键)。 +func TestBatchPreviewRefusesCollapsedSizeKey(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID). + Update("raw_json", `{"productSpec":"黑色,XL【建議60-65公斤】"}`).Error; err != nil { + t.Fatal(err) + } + sibling := f.syb + sibling.ID = 0 + sibling.OrderCode = "SYB-2" + sibling.DetailID = 2 + sibling.RawJSON = `{"productSpec":"黑色,XL【建議65-70公斤】"}` + if err := db.Create(&sibling).Error; err != nil { + t.Fatal(err) + } + + response, err := NewService(db).BatchPreview(context.Background(), + BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}}) + if err != nil { + t.Fatal(err) + } + if response.Items[0].ReasonCode != CodeSpecKeyAmbiguous { + t.Fatalf("collapsed size key must be refused: %+v", response.Items[0]) + } +} diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go index 0fec4d4..ca70dc0 100644 --- a/server/app/goauto/purchase/process_stage.go +++ b/server/app/goauto/purchase/process_stage.go @@ -167,6 +167,11 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat } return stage(ProcessStagePDDPending, "拼多多商品尚未采集完成", "open_pdd") } + // `[必须]` 塔缩的键不能走 AI 匹配这条路:再匹配一次也只会为同一个键写一份 + // 映射,而问题恰恰是多个不同规格共用了这个键。必须人工处理(#289)。 + if preview.ReasonCode == CodeSpecKeyAmbiguous { + return stage(ProcessStageManualAction, preview.Reason, preview.NextAction) + } if preview.ReasonCode == CodeMappingRequired { if preview.AIMatchEligible { return stage(ProcessStageColorMapping, "采购规格尚未匹配并保存", "open_mapping") diff --git a/server/app/goauto/sybimport/parse.go b/server/app/goauto/sybimport/parse.go index c339869..b319db6 100644 --- a/server/app/goauto/sybimport/parse.go +++ b/server/app/goauto/sybimport/parse.go @@ -4,149 +4,18 @@ // purchase tasks or stores order/logistics fields (#41). package sybimport -import ( - "regexp" - "strings" +import "go-admin/app/goauto/sybspec" - "go-admin/app/goauto/models" -) +// `[必须]` 解析原语现在住在叶子包 sybspec,因为采购侧也要用同一份拆分与角色反转 +// 逻辑来检出规格塌缩(#289),而 sybimport 已依赖 purchase,直接引用会成环。 +// 这里只做别名转发,不复制实现——两份实现必然漂移。 +type ParseResult = sybspec.ParseResult -var bracketPattern = regexp.MustCompile(`【[^】]*】`) +// Parse implements the #41/#216 rule; see sybspec.Parse for the full contract. +func Parse(raw string) ParseResult { return sybspec.Parse(raw) } -// explicitSizePattern recognizes only values whose spelling carries a strong -// size signal. SYB has now been observed returning both "color,size" and -// "size,color". A color dictionary would inevitably guess at product-specific -// labels, so role reversal is allowed only when exactly one side matches this -// deliberately narrow pattern. -var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one\s*size|free\s*size|x{0,4}[sml]|[2-9]xl|(?:加大|大|中|小)(?:码|碼|号|號)|\d+(?:\.\d+)?(?:cm|mm|m|码|碼|号|號|公分)|\d+(?:\.\d+)?(?:[-~~至到]\d+(?:\.\d+)?)?(?:斤|公斤|千克|kg))$`) +// RawSpecHalves returns the productSpec halves with 【...】 still in place. +func RawSpecHalves(raw string) (rawColor, rawSize string) { return sybspec.RawSpecHalves(raw) } -// splitOnWhitespace applies the comma rule to a spec that has no comma. -// -// `[必须]` #274 removed the old ambiguity guard so that descriptive colors -// like "黑色+白色 簡約親膚" are accepted instead of rejected. That is right, but -// it also let "黑色 XL" through as one confident **color**, dropping the size and -// marking the row success — and success rows never reach the AI parse queue -// (ai_parse_batch.go selects parse_status IN ('uncertain','failed')), so nothing -// ever corrects them. 152 such rows existed in production on 2026-09-15. -// -// Whitespace is therefore treated exactly like the comma: split only when -// **exactly one** whitespace-delimited token carries an explicit size signal. -// That is the same non-guessing rule the comma branch already uses, so -// "黑色 XL" recovers as color+size while "黑色+白色 簡約親膚" (no size token) -// keeps #274's descriptive-color behaviour untouched. -func splitOnWhitespace(value string) (color string, size string, ok bool) { - fields := strings.Fields(value) - if len(fields) < 2 { - return "", "", false - } - sizeIndex := -1 - for i, field := range fields { - if explicitSizePattern.MatchString(field) { - if sizeIndex >= 0 { - // More than one size-looking token: no safe colour decision, - // same as the comma branch's both-sides-are-size case. - return "", "", false - } - sizeIndex = i - } - } - if sizeIndex < 0 { - return "", "", false - } - remainder := append(append([]string{}, fields[:sizeIndex]...), fields[sizeIndex+1:]...) - color = strings.Join(remainder, " ") - if color == "" { - return "", "", false - } - return color, fields[sizeIndex], true -} - -// ParseResult is the color/size candidate extracted from one productSpec -// string, plus how much the caller should trust it. -type ParseResult struct { - Color string - Size string - Status string - Note string -} - -// Parse implements the #41/#216 rule: split on the last comma, strip 【...】 -// annotations, and classify the result. It never guesses a -// missing value and never invents a color or size that is not literally -// present in the input. -// -// Rules, derived from real SYB samples (demo/shunyunbaoerp_stock_list.har) -// plus the boundary cases already confirmed in the #41 prototype: -// - empty/whitespace-only input -> failed, nothing to extract. -// - no comma present -> a single token becomes size when it carries an -// explicit size signal ("均碼") and colour otherwise (#274's descriptive -// colours). Multiple whitespace-delimited tokens are split by the same -// one-explicit-size rule the comma branch uses, so "黑色 XL" yields both -// dimensions instead of one over-long colour (see splitOnWhitespace). -// - comma present and exactly one side has an explicit size signal -> that -// side is size and the other side is color. This supports both observed -// SYB orders without allowing AI or a fuzzy color dictionary to swap roles. -// - comma present and neither side has an explicit size signal -> retain the -// established SYB color,size contract for backward compatibility. -// - comma present and both sides have explicit size signals -> uncertain; -// there is no safe color decision. -// - comma present but either side is empty after stripping, or the color -// candidate still carries a leftover '+' or internal whitespace -> the -// split happened but is not trustworthy -> uncertain. -func Parse(raw string) ParseResult { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return ParseResult{Status: models.SYBParseStatusFailed, Note: "productSpec 为空,无法拆分颜色尺码"} - } - - lastComma := strings.LastIndex(trimmed, ",") - // SYB samples use the ASCII comma; a full-width Chinese comma has not been - // observed, so it is deliberately not treated as a separator here rather - // than guessed at. - if lastComma < 0 { - size := stripBrackets(trimmed) - if size == "" { - return ParseResult{Status: models.SYBParseStatusFailed, Note: "productSpec 剥离备注后为空"} - } - if explicitSizePattern.MatchString(size) { - return ParseResult{Size: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"} - } - if color, sizeToken, ok := splitOnWhitespace(size); ok { - return ParseResult{Color: color, Size: sizeToken, Status: models.SYBParseStatusSuccess} - } - return ParseResult{Color: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"} - } - - firstPart := stripBrackets(trimmed[:lastComma]) - secondPart := stripBrackets(trimmed[lastComma+1:]) - colorPart, sizePart := firstPart, secondPart - - if colorPart == "" || sizePart == "" { - only := colorPart - if only == "" { - only = sizePart - } - if explicitSizePattern.MatchString(only) { - return ParseResult{Size: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"} - } - if color, sizeToken, ok := splitOnWhitespace(only); ok { - return ParseResult{Color: color, Size: sizeToken, Status: models.SYBParseStatusSuccess} - } - if only != "" { - return ParseResult{Color: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"} - } - return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusFailed, Note: "按逗号拆分后没有可靠规格"} - } - firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart) - if firstIsSize && secondIsSize { - return ParseResult{Color: firstPart, Size: secondPart, Status: models.SYBParseStatusUncertain, Note: "逗号两侧均具有尺码特征,无法安全识别颜色"} - } - if firstIsSize { - colorPart, sizePart = secondPart, firstPart - } - return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusSuccess} -} - -func stripBrackets(part string) string { - return strings.TrimSpace(bracketPattern.ReplaceAllString(part, "")) -} +// CollapsedSpecKey reports whether different raw halves share one parsed key. +func CollapsedSpecKey(rawHalves []string) bool { return sybspec.CollapsedSpecKey(rawHalves) } diff --git a/server/app/goauto/sybspec/parse.go b/server/app/goauto/sybspec/parse.go new file mode 100644 index 0000000..e0730d4 --- /dev/null +++ b/server/app/goauto/sybspec/parse.go @@ -0,0 +1,157 @@ +// Package sybspec owns the productSpec parsing primitives shared by the SYB +// import and the purchase readiness check. +// +// `[必须]` 它必须是叶子包。拆分、剥离、角色反转这三件事既决定了 target_color/ +// target_size,也决定了哪些原始规格会塔缩到同一个键(#289)。两边各写一份 +// 镜像实现必然漂移,到时塔缩检测会拿错半边去比,结论反而不可信。 +// +// 原属 sybimport;purchase 需要它而 sybimport 已依赖 purchase,直接引用会成环, +// 因此下沉。sybimport 保留别名转发,调用方无需改动。 +package sybspec + +import ( + "regexp" + "strings" + + "go-admin/app/goauto/models" +) + +var bracketPattern = regexp.MustCompile(`【[^】]*】`) + +// explicitSizePattern recognizes only values whose spelling carries a strong +// size signal. SYB has now been observed returning both "color,size" and +// "size,color". A color dictionary would inevitably guess at product-specific +// labels, so role reversal is allowed only when exactly one side matches this +// deliberately narrow pattern. +var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one\s*size|free\s*size|x{0,4}[sml]|[2-9]xl|(?:加大|大|中|小)(?:码|碼|号|號)|\d+(?:\.\d+)?(?:cm|mm|m|码|碼|号|號|公分)|\d+(?:\.\d+)?(?:[-~~至到]\d+(?:\.\d+)?)?(?:斤|公斤|千克|kg))$`) + +// splitOnWhitespace applies the comma rule to a spec that has no comma. +// +// `[必须]` #274 removed the old ambiguity guard so that descriptive colors +// like "黑色+白色 簡約親膚" are accepted instead of rejected. That is right, but +// it also let "黑色 XL" through as one confident **color**, dropping the size and +// marking the row success — and success rows never reach the AI parse queue +// (ai_parse_batch.go selects parse_status IN ('uncertain','failed')), so nothing +// ever corrects them. 152 such rows existed in production on 2026-09-15. +// +// Whitespace is therefore treated exactly like the comma: split only when +// **exactly one** whitespace-delimited token carries an explicit size signal. +// That is the same non-guessing rule the comma branch already uses, so +// "黑色 XL" recovers as color+size while "黑色+白色 簡約親膚" (no size token) +// keeps #274's descriptive-color behaviour untouched. +func splitOnWhitespace(value string) (color string, size string, ok bool) { + fields := strings.Fields(value) + if len(fields) < 2 { + return "", "", false + } + sizeIndex := -1 + for i, field := range fields { + if explicitSizePattern.MatchString(field) { + if sizeIndex >= 0 { + // More than one size-looking token: no safe colour decision, + // same as the comma branch's both-sides-are-size case. + return "", "", false + } + sizeIndex = i + } + } + if sizeIndex < 0 { + return "", "", false + } + remainder := append(append([]string{}, fields[:sizeIndex]...), fields[sizeIndex+1:]...) + color = strings.Join(remainder, " ") + if color == "" { + return "", "", false + } + return color, fields[sizeIndex], true +} + +// ParseResult is the color/size candidate extracted from one productSpec +// string, plus how much the caller should trust it. +type ParseResult struct { + Color string + Size string + Status string + Note string +} + +// Parse implements the #41/#216 rule: split on the last comma, strip 【...】 +// annotations, and classify the result. It never guesses a +// missing value and never invents a color or size that is not literally +// present in the input. +// +// Rules, derived from real SYB samples (demo/shunyunbaoerp_stock_list.har) +// plus the boundary cases already confirmed in the #41 prototype: +// - empty/whitespace-only input -> failed, nothing to extract. +// - no comma present -> a single token becomes size when it carries an +// explicit size signal ("均碼") and colour otherwise (#274's descriptive +// colours). Multiple whitespace-delimited tokens are split by the same +// one-explicit-size rule the comma branch uses, so "黑色 XL" yields both +// dimensions instead of one over-long colour (see splitOnWhitespace). +// - comma present and exactly one side has an explicit size signal -> that +// side is size and the other side is color. This supports both observed +// SYB orders without allowing AI or a fuzzy color dictionary to swap roles. +// - comma present and neither side has an explicit size signal -> retain the +// established SYB color,size contract for backward compatibility. +// - comma present and both sides have explicit size signals -> uncertain; +// there is no safe color decision. +// - comma present but either side is empty after stripping, or the color +// candidate still carries a leftover '+' or internal whitespace -> the +// split happened but is not trustworthy -> uncertain. +func Parse(raw string) ParseResult { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return ParseResult{Status: models.SYBParseStatusFailed, Note: "productSpec 为空,无法拆分颜色尺码"} + } + + lastComma := strings.LastIndex(trimmed, ",") + // SYB samples use the ASCII comma; a full-width Chinese comma has not been + // observed, so it is deliberately not treated as a separator here rather + // than guessed at. + if lastComma < 0 { + size := stripBrackets(trimmed) + if size == "" { + return ParseResult{Status: models.SYBParseStatusFailed, Note: "productSpec 剥离备注后为空"} + } + if explicitSizePattern.MatchString(size) { + return ParseResult{Size: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"} + } + if color, sizeToken, ok := splitOnWhitespace(size); ok { + return ParseResult{Color: color, Size: sizeToken, Status: models.SYBParseStatusSuccess} + } + return ParseResult{Color: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"} + } + + firstPart := stripBrackets(trimmed[:lastComma]) + secondPart := stripBrackets(trimmed[lastComma+1:]) + colorPart, sizePart := firstPart, secondPart + + if colorPart == "" || sizePart == "" { + only := colorPart + if only == "" { + only = sizePart + } + if explicitSizePattern.MatchString(only) { + return ParseResult{Size: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"} + } + if color, sizeToken, ok := splitOnWhitespace(only); ok { + return ParseResult{Color: color, Size: sizeToken, Status: models.SYBParseStatusSuccess} + } + if only != "" { + return ParseResult{Color: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"} + } + return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusFailed, Note: "按逗号拆分后没有可靠规格"} + } + firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart) + if firstIsSize && secondIsSize { + return ParseResult{Color: firstPart, Size: secondPart, Status: models.SYBParseStatusUncertain, Note: "逗号两侧均具有尺码特征,无法安全识别颜色"} + } + if firstIsSize { + colorPart, sizePart = secondPart, firstPart + } + return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusSuccess} +} + +func stripBrackets(part string) string { + return strings.TrimSpace(bracketPattern.ReplaceAllString(part, "")) +} diff --git a/server/app/goauto/sybspec/raw_spec.go b/server/app/goauto/sybspec/raw_spec.go new file mode 100644 index 0000000..aeb00a6 --- /dev/null +++ b/server/app/goauto/sybspec/raw_spec.go @@ -0,0 +1,67 @@ +package sybspec + +import "strings" + +// RawSpecHalves returns the productSpec text that produced Parse's Color and +// Size, with the 【...】 annotations still in place. +// +// `[必须]` 必须和 Parse 放在同一个包并镜像它的拆分与角色反转逻辑。两者一旦漂移, +// 塌缩检测就会拿错半边去比较,结论反而不可信(#289)。 +// +// 用途是检出塌缩:Parse 会剥掉 【...】,而括号里经常是真正的规格标识(款号 +// 白色【207A】、色号 黑色【M0059C1】、颜色组合 【深灰+淺灰】)。剥离后不同的 +// 虾皮规格会塌缩成同一个 target_color,而 target_color 正是采购查映射的键, +// 同键即同映射,Agent 会为不同规格点击同一个 PDD 值。 +// +// 返回空串表示该半边不存在(例如 productSpec 没有逗号时只有一个半边)。 +func RawSpecHalves(raw string) (rawColor, rawSize string) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", "" + } + parsed := Parse(raw) + + lastComma := strings.LastIndex(trimmed, ",") + if lastComma < 0 { + // 无逗号:整串要么是尺码、要么是颜色,要么被空格规则拆成两半。 + // 拆开的情况下两半都来自同一串原文,没有各自独立的原文可比, + // 因此把整串同时作为两边的原文返回:同一串原文不会与自己塌缩。 + if parsed.Color != "" && parsed.Size != "" { + return trimmed, trimmed + } + if parsed.Size != "" { + return "", trimmed + } + return trimmed, "" + } + + first := strings.TrimSpace(trimmed[:lastComma]) + second := strings.TrimSpace(trimmed[lastComma+1:]) + + // Parse 在“恰好一侧带明确尺码特征”时会交换两半的角色,这里必须跟着换, + // 否则会把尺码原文当成颜色原文去比。用剥离后的结果判断谁是谁。 + if parsed.Color != "" && stripBrackets(second) == parsed.Color { + return second, first + } + return first, second +} + +// CollapsedSpecKey reports whether two different raw halves collapse onto the +// same parsed value, i.e. the parsed value is not a safe key for that product. +func CollapsedSpecKey(rawHalves []string) bool { + seen := "" + for _, half := range rawHalves { + half = strings.TrimSpace(half) + if half == "" { + continue + } + if seen == "" { + seen = half + continue + } + if half != seen { + return true + } + } + return false +} diff --git a/server/app/goauto/sybspec/raw_spec_test.go b/server/app/goauto/sybspec/raw_spec_test.go new file mode 100644 index 0000000..b822e46 --- /dev/null +++ b/server/app/goauto/sybspec/raw_spec_test.go @@ -0,0 +1,70 @@ +package sybspec_test + +import ( + "testing" + + "go-admin/app/goauto/sybspec" +) + +// 样本全部取自线上真实数据(2026-09-16 核对)。 +func TestRawSpecHalvesKeepsAnnotations(t *testing.T) { + cases := []struct { + raw, wantColor, wantSize string + }{ + // 款号在颜色括号里:剥离后 6 个白色塌缩成一个键(虾皮 1355)。 + {"白色【207A】,XL", "白色【207A】", "XL"}, + {"黑色【M0059C1】,2XL", "黑色【M0059C1】", "2XL"}, + // 体重建议在尺码括号里:剥离正确,不塌缩(虾皮 2065)。 + // 这条同时覆盖角色反转——Parse 认出第二半才是颜色。 + {"S【建議40公斤以內】,圓領 彩藍色", "圓領 彩藍色", "S【建議40公斤以內】"}, + {"香芋紫 【雙梅花】純棉,2XL 60.0-67.5公斤", "香芋紫 【雙梅花】純棉", "2XL 60.0-67.5公斤"}, + } + for _, item := range cases { + gotColor, gotSize := sybspec.RawSpecHalves(item.raw) + if gotColor != item.wantColor || gotSize != item.wantSize { + t.Fatalf("%q -> color=%q size=%q, want color=%q size=%q", + item.raw, gotColor, gotSize, item.wantColor, item.wantSize) + } + } +} + +// `[必须]` 半边必须和 Parse 的输出对应。两者一旦漂移,塌缩检测会拿错半边去比。 +func TestRawSpecHalvesStayAlignedWithParse(t *testing.T) { + for _, raw := range []string{ + "白色【207A】,XL", + "S【建議40公斤以內】,圓領 彩藍色", + "黑色,2XL", + "均碼", + "黑色 XL", + } { + rawColor, rawSize := sybspec.RawSpecHalves(raw) + parsed := sybspec.Parse(raw) + if parsed.Color != "" && rawColor == "" { + t.Fatalf("%q parsed a colour %q but reported no raw colour half", raw, parsed.Color) + } + if parsed.Size != "" && rawSize == "" { + t.Fatalf("%q parsed a size %q but reported no raw size half", raw, parsed.Size) + } + } +} + +func TestCollapsedSpecKey(t *testing.T) { + // 虾皮 1355 的白色:6 个不同款号剥离后同键。 + if !sybspec.CollapsedSpecKey([]string{"白色【207A】", "白色【209A】"}) { + t.Fatal("different raw halves must be reported as collapsed") + } + // 同一条规格重复出现(同商品多条订单)不算塌缩。 + if sybspec.CollapsedSpecKey([]string{"白色【207A】", "白色【207A】", "白色【207A】"}) { + t.Fatal("repeats of one raw half are not a collapse") + } + if sybspec.CollapsedSpecKey([]string{"黑色"}) { + t.Fatal("a single half cannot collapse") + } + if sybspec.CollapsedSpecKey(nil) { + t.Fatal("no halves cannot collapse") + } + // 空串是“无原文可比”,不参与判定,不应与真实原文构成塌缩。 + if sybspec.CollapsedSpecKey([]string{"", "白色【207A】", ""}) { + t.Fatal("blank halves must be ignored, not treated as a different spec") + } +}