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:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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, ""))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user