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