fix(server): 检出 SYB 规格塌缩并明确失败,不再静默共用同一映射 (#289)

sybimport.Parse 剥离 【...】,而括号里经常是真正的规格标识而非备注:
款号 白色【207A】、色号 黑色【M0059C1】、颜色组合 【深灰+淺灰】。剥离后
不同的虾皮规格塌缩成同一个 target_color,而它正是采购查映射的键,同键
即同映射,Agent 会为不同规格点击同一个 PDD 值。

线上实测(2026-09-16):颜色塌缩键 78 个、涉及 61 个商品、190 个原始
规格;尺码塌缩键 24 个;受影响明细 407 条。落在塌缩键上的采购任务 3 笔,
order_created 0 笔——至今没出事是因为每个塌缩键目前只有一条规格真正
下过单,不是设计上有保证。最高危的 6 个键全部尚未采购。

本次不改解析契约、不迁移数据,只让它明确失败:采购预览命中塌缩键时返回
SPEC_KEY_AMBIGUOUS 并提前返回,不再落到就绪判定;process_stage 一并拦截,
因为塌缩的键不能走 AI 匹配那条路——再匹配一次也只会为同一个键写一份映射,
而问题恰恰是多个规格共用这个键。

`[必须]` 解析原语下沉到新的叶子包 sybspec。sybimport 已依赖 purchase,
采购侧直接引用会成环;而两边各写一份镜像实现必然漂移,届时塌缩检测会拿错
半边去比,结论反而不可信。sybimport 保留别名转发,调用方无需改动。

`[必须]` 检测按虾皮商品加载全部明细,不是本次请求的那几条:塌缩是商品级
属性,只看请求内的明细会漏判。

TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher 的查询数由 7 改为 8,新增
的是一次有界批量查询。该断言守的是不得出现 N+1,不是具体数字。

测试样本全部取自线上真实数据。app/goauto/sybimport 的 12 个失败先于本次
存在(#285),未新增。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-09-16 10:20:09 +08:00
co-authored by Claude Opus 5
parent 41996f33b2
commit a27db5f960
9 changed files with 532 additions and 144 deletions
+18
View File
@@ -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.
+5 -2
View File
@@ -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)
@@ -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 ""
}
@@ -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])
}
}
@@ -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")
+11 -142
View File
@@ -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) }
+157
View File
@@ -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, ""))
}
+67
View File
@@ -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
}
@@ -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")
}
}