From e99bbc2327f8f6e456ae709469c9a99b3d9523bb Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 28 Sep 2026 10:31:11 +0800 Subject: [PATCH] fix(sybinnercode): align stall article matching with cmautobuy rules (#344) Port cmautobuy's innerCodeStallMatches rule set (#259/#273 fixes) into strictStall's underlying match: split stall on the last '#', compare the article only against alphanumeric tokens, require leading-zero equivalence plus stall-name confirmation for numeric articles, exact token match for non-numeric articles, and a ProductSpec-prefix rule. This replaces the old plain substring containment that could bind an inbound code to the wrong SYB product detail (weight numbers mistaken for articles, short numeric articles matching inside long codes, 067/67 not aligning, stall names containing '#' splitting incorrectly). Also fixes #289: when N single-piece inbound codes are matched against N existing qty=1 SYB details and some details already carry a correctly bound code out of ID order, planRecord now preserves those existing bindings (matching by code value first via assignExistingBoundItems) and only assigns the remaining blank details to the missing codes, instead of reassigning by index/ID order and overwriting a correct binding. Added regression tests for both fixes, including an end-to-end RunMatchJob test reproducing the #289 bug against the pre-fix assignment (verified to fail on the old code, pass on the new code). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/sybinnercode/match.go | 183 ++++++++++++++++++- server/app/goauto/sybinnercode/match_test.go | 150 +++++++++++++++ 2 files changed, 328 insertions(+), 5 deletions(-) diff --git a/server/app/goauto/sybinnercode/match.go b/server/app/goauto/sybinnercode/match.go index 31c63f5..7e3ab4c 100644 --- a/server/app/goauto/sybinnercode/match.go +++ b/server/app/goauto/sybinnercode/match.go @@ -10,6 +10,7 @@ import ( "sort" "strings" "time" + "unicode" "go-admin/app/goauto/models" "go-admin/app/goauto/sybclient" @@ -193,13 +194,20 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC if len(matches) == 1 && matches[0].ProductQty == count { chosen = []sybclient.DetailItem{matches[0]} } else if len(matches) == count && count > 1 { - sort.Slice(matches, func(i, j int) bool { return matches[i].ID < matches[j].ID }) for _, item := range matches { if item.ProductQty != 1 { return nil, models.SYBInnerCodeSkipped, "相同规格候选数量不明确,不能自动分配", nil } } - chosen = matches + codes := make([]string, count) + for i, it := range record.Items { + codes[i] = it.Code + } + assigned, reason := assignExistingBoundItems(codes, matches) + if reason != "" { + return nil, models.SYBInnerCodeSkipped, reason, nil + } + chosen = assigned } else if len(matches) > 1 { return nil, models.SYBInnerCodeSkipped, "同一订单存在多条相同规格候选商品,不能自动选择", nil } else { @@ -246,6 +254,55 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC return plan, models.SYBInnerCodeReady, "唯一匹配,等待确认回写", nil } +// assignExistingBoundItems 把 N 个待写入入库码按顺序分配给 N 个数量为 1 的候选商品明细。 +// 修复 #289:候选明细的匹配顺序(按 ID 排序)未必与目标码顺序一致,若单纯按下标 +// 对应,会把已经正确绑定某个目标码的明细错误地重新分配给另一个码。这里先按“候选 +// 明细已有的入库码值”精确匹配对应的目标码,保留既有正确绑定不动;再把剩余尚未 +// 写入任何码的空白明细(按 ID 排序)依次填充给还没有候选的目标码位置。 +func assignExistingBoundItems(codes []string, matches []sybclient.DetailItem) ([]sybclient.DetailItem, string) { + sorted := append([]sybclient.DetailItem(nil), matches...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID }) + assigned := make([]sybclient.DetailItem, len(codes)) + taken := make([]bool, len(codes)) + codeIndex := make(map[string]int, len(codes)) + for i, code := range codes { + codeIndex[code] = i + } + blanks := make([]int, 0, len(sorted)) + for si, item := range sorted { + remote := rawText(item.Raw["innerExpCode"]) + if remote == "" { + blanks = append(blanks, si) + continue + } + idx, ok := codeIndex[remote] + if !ok { + return nil, "候选商品明细存在非目标入库码,不能自动逐件分配" + } + if taken[idx] { + return nil, "同一入库码在候选商品中出现多次,不能自动逐件分配" + } + assigned[idx] = item + taken[idx] = true + } + bi := 0 + for i := range assigned { + if taken[i] { + continue + } + if bi >= len(blanks) { + return nil, "现成空白明细不足,不能完成逐件分配" + } + assigned[i] = sorted[blanks[bi]] + taken[i] = true + bi++ + } + if bi != len(blanks) { + return nil, "现成空白明细多于待写入入库码,不能自动逐件分配" + } + return assigned, "" +} + func matchSpec(spec string, items []sybclient.DetailItem) []sybclient.DetailItem { result := []sybclient.DetailItem{} for _, item := range items { @@ -305,15 +362,131 @@ func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailI return nil } result := []sybclient.DetailItem{} - name, article, has := strings.Cut(stall, "#") for _, item := range items { - blob := rawText(item.Raw["sku"]) + " " + rawText(item.Raw["variationSku"]) + " " + item.ProductSpec - if strings.Contains(blob, stall) || (has && strings.Contains(blob, strings.TrimSpace(name)) && strings.Contains(blob, strings.TrimSpace(article))) { + if stallMatches(stall, item) { result = append(result, item) } } return result } + +// stallMatches 移植自 cmautobuy `innerCodeStallMatches`(#259/#273 修复): +// 档口名与货号以最后一个 `#` 切分;货号只与字母数字 token 比较;纯数字货号要求 +// 候选中同时包含档口名才允许前导零等价(如 "067"≡"67");非数字货号要求精确 +// token 匹配;ProductSpec 只在以货号开头时才算命中;货号为空时回退为档口名包含判断。 +func stallMatches(stall string, item sybclient.DetailItem) bool { + sku := rawText(item.Raw["sku"]) + variation := rawText(item.Raw["variationSku"]) + blob := sku + " " + variation + " " + item.ProductSpec + if strings.Contains(blob, stall) { + return true + } + name, article, hasArticle := splitStall(stall) + if !hasArticle { + return false + } + if article == "" { + return name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name)) + } + if isNumericArticle(article) { + nameMatches := name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name)) + if !nameMatches { + return false + } + return textHasNumericArticle(sku, article) || + textHasNumericArticle(variation, article) || + productSpecStartsWithArticle(item.ProductSpec, article, true) + } + return textHasExactArticle(sku, article) || + textHasExactArticle(variation, article) || + productSpecStartsWithArticle(item.ProductSpec, article, false) +} + +// splitStall 从档口名称#货号取最后一个 #,避免档口名称本身含 # 时截错。 +func splitStall(stall string) (name, article string, ok bool) { + stall = strings.TrimSpace(stall) + separator := strings.LastIndex(stall, "#") + if separator < 0 { + return stall, "", false + } + return strings.TrimSpace(stall[:separator]), strings.TrimSpace(stall[separator+1:]), true +} + +func isNumericArticle(article string) bool { + if article == "" { + return false + } + for _, char := range article { + if !unicode.IsDigit(char) { + return false + } + } + return true +} + +func textHasNumericArticle(text, article string) bool { + target := normalizeNumericArticle(article) + for _, token := range articleTokens(text) { + if isNumericArticle(token) && normalizeNumericArticle(token) == target { + return true + } + } + return false +} + +func normalizeNumericArticle(article string) string { + normalized := strings.TrimLeft(article, "0") + if normalized == "" { + return "0" + } + return normalized +} + +func textHasExactArticle(text, article string) bool { + for _, token := range articleTokens(text) { + if token == article { + return true + } + } + return false +} + +func productSpecStartsWithArticle(productSpec, article string, numeric bool) bool { + productSpec = strings.TrimSpace(productSpec) + separator := strings.IndexAny(productSpec, " ,,") + if separator <= 0 { + return false + } + prefix := strings.TrimSpace(productSpec[:separator]) + if numeric { + return isNumericArticle(prefix) && normalizeNumericArticle(prefix) == normalizeNumericArticle(article) + } + return prefix == article +} + +// articleTokens 只把连续字母或数字视为货号候选,标点、【】、#、横线、空格自然成为 +// 边界:能识别 "067【档口】",又不会把 "PDD256437" 中间的数字误认为独立货号。 +func articleTokens(text string) []string { + tokens := make([]string, 0) + start := -1 + runes := []rune(text) + for index, char := range runes { + if unicode.IsLetter(char) || unicode.IsDigit(char) { + if start < 0 { + start = index + } + continue + } + if start >= 0 { + tokens = append(tokens, string(runes[start:index])) + start = -1 + } + } + if start >= 0 { + tokens = append(tokens, string(runes[start:])) + } + return tokens +} func rawText(value any) string { switch v := value.(type) { case string: diff --git a/server/app/goauto/sybinnercode/match_test.go b/server/app/goauto/sybinnercode/match_test.go index 3ef7209..61e79d2 100644 --- a/server/app/goauto/sybinnercode/match_test.go +++ b/server/app/goauto/sybinnercode/match_test.go @@ -147,6 +147,156 @@ func TestRunMatchJobReservesDifferentDetailsForRecordsInOneBatch(t *testing.T) { } } +// --- #259/#273/#289 stall-matching regression tests (ported from cmautobuy) --- + +func TestStallMatchesIgnoresWeightLikeNumberAsArticle(t *testing.T) { + // #273: "50公斤" must not be treated as if the article were the bare number 50. + item := detail(1, "50公斤,黑色", 1, "SKU-X", "", "") + if stallMatches("档口甲#50", item) { + t.Fatalf("weight-like text must not match numeric article 50") + } +} + +func TestStallMatchesRejectsSubstringInsideLongCode(t *testing.T) { + // #273: a long code with internal digits (PDD256437) must not spuriously + // match a short numeric article (256) via substring containment. + item := detail(1, "黑色,L", 1, "PDD256437", "档口甲", "") + if stallMatches("档口甲#256", item) { + t.Fatalf("long code must not match numeric article 256 via substring") + } +} + +func TestStallMatchesLeadingZeroEquivalenceRequiresStallName(t *testing.T) { + // #259/#273: "067" and "67" are equivalent articles only when the stall + // name also matches; a different stall name must not match. + sameStall := detail(1, "黑色,L", 1, "档口甲-067", "", "") + if !stallMatches("档口甲#67", sameStall) { + t.Fatalf("067 should be treated as equivalent to 67 when stall name matches") + } + differentStall := detail(2, "黑色,L", 1, "档口乙-067", "", "") + if stallMatches("档口甲#67", differentStall) { + t.Fatalf("067 must not match 67 when the stall name differs") + } +} + +func TestStallMatchesNonNumericArticleRequiresExactToken(t *testing.T) { + item := detail(1, "黑色,L", 1, "ABC12", "", "") + if stallMatches("档口甲#AB", item) { + t.Fatalf("non-numeric article must require an exact token match, not substring") + } + exact := detail(2, "黑色,L", 1, "AB", "", "") + if !stallMatches("档口甲#AB", exact) { + t.Fatalf("exact non-numeric token should match") + } +} + +func TestStallMatchesHandlesHashInsideStallName(t *testing.T) { + // Splits on the LAST '#' so a stall name that itself contains '#' still + // yields the correct article. + item := detail(1, "黑色,L", 1, "档口#甲-67", "", "") + if !stallMatches("档口#甲#67", item) { + t.Fatalf("stall name containing '#' should still resolve article via last '#'") + } +} + +func TestStallMatchesProductSpecPrefixMatchesArticle(t *testing.T) { + // Non-numeric article: ProductSpec prefix match does not additionally + // require the stall name to appear in sku/variationSku. + item := detail(1, "AB 黑色,L", 1, "", "", "") + if !stallMatches("档口甲#AB", item) { + t.Fatalf("ProductSpec starting with the article should match") + } + notPrefix := detail(2, "黑色,ABL", 1, "", "", "") + if stallMatches("档口甲#AB", notPrefix) { + t.Fatalf("article appearing mid-spec (not as prefix) must not match") + } +} + +func TestStallMatchesEmptyArticleFallsBackToStallName(t *testing.T) { + item := detail(1, "黑色,L", 1, "档口甲专柜", "", "") + if !stallMatches("档口甲#", item) { + t.Fatalf("empty article should fall back to stall-name containment") + } +} + +// --- #289: existing-binding-preserving multi-piece assignment --- + +func TestAssignExistingBoundItemsPreservesOutOfOrderBindings(t *testing.T) { + // Two single-piece candidates already carry codes, but the previously + // bound code (IC-2) sits on the LOWER-ID detail while the target order + // expects it second. A naive ID-order/index assignment would strip the + // existing correct binding from detail 20 and try to overwrite it. + d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2") + d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "") + assigned, reason := assignExistingBoundItems([]string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) + if reason != "" { + t.Fatalf("unexpected reason: %s", reason) + } + if assigned[1].ID != 20 { + t.Fatalf("expected detail 20 (already bound to IC-2) preserved at index 1, got %+v", assigned[1]) + } + if assigned[0].ID != 21 { + t.Fatalf("expected the blank detail 21 filled in at index 0, got %+v", assigned[0]) + } +} + +func TestAssignExistingBoundItemsRejectsForeignCode(t *testing.T) { + d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-OTHER") + d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "") + _, reason := assignExistingBoundItems([]string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) + if reason == "" { + t.Fatalf("expected rejection for detail already holding a non-target code") + } +} + +func TestRunMatchJobPreservesExistingBindingWhenCandidateOrderDiffers(t *testing.T) { + // End-to-end regression for #289: N=2 single-piece candidates already + // carrying one previously bound code, with the bound detail's ID not + // matching sequential/ID order relative to the target codes. The plan + // must leave the already-correct binding untouched and only place the + // missing code onto the still-blank detail. + db := testDB(t) + // SourceSKURaw is intentionally left blank: with the same source SKU on + // both candidate rows the source-SKU path would reject as "duplicate" + // before ever reaching the stall-based multi-item assignment being + // regression-tested here. + record := models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: "ORDER-1", Stall: "A#1", SpecKey: "黑色,L", SpecRaw: "黑色,L", Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: "IC-1", Ordinal: 1, SourceRow: 2}, {BusinessDate: "2026-08-28", Code: "IC-2", Ordinal: 2, SourceRow: 3}}} + records := []models.SYBInnerCodeRecord{record} + jobID := createMatchJob(t, db, records) + recordID := records[0].ID + // Detail 20 (lower ID) already carries IC-2 (bound out of sequence); + // detail 21 (higher ID) is still blank and should receive IC-1. + stock := sybclient.StockDetail{ID: 10, Details: []sybclient.DetailItem{ + detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2"), + detail(21, "黑色,L", 1, "SKU-1", "A#1", ""), + }} + reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: stock}} + if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil { + t.Fatal(err) + } + var plan models.SYBInnerCodePlan + if err := db.First(&plan, "record_id = ?", recordID).Error; err != nil { + t.Fatal(err) + } + var items []plannedRemoteItem + if err := json.Unmarshal([]byte(plan.RemoteItemsJSON), &items); err != nil { + t.Fatal(err) + } + if len(items) != 2 { + t.Fatalf("items=%+v", items) + } + byCode := map[string]plannedRemoteItem{} + for _, item := range items { + byCode[item.Code] = item + } + if byCode["IC-2"].DetailID != 20 { + t.Fatalf("existing binding for IC-2 must stay on detail 20, got %+v", byCode["IC-2"]) + } + if byCode["IC-1"].DetailID != 21 { + t.Fatalf("missing IC-1 should be assigned to the blank detail 21, got %+v", byCode["IC-1"]) + } +} + type captureStarter struct { jobID string committed bool