From 9bebf3930d6b0883b840e92e56f5f1c58fc54fe0 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 11:48:44 +0800 Subject: [PATCH] feat(returnmatch): pure spec normalization and candidate selection for #338 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Package returnmatch holds only DB-free, unit-tested logic so the matching rules can be verified directly: - Normalize() strips 【】()()[] brackets and their content, strips whitespace, converts fullwidth ASCII/space to halfwidth and lowercases (issue #338 normalization rule), backtested against the local real-pair samples quoted in the issue. - SelectMatches() implements rules 2-6: caller-ordered (SYB created_at DESC) processing, deadline-must-be-after-now filtering, earliest- deadline-first selection among same-key candidates, and same-run occupied-return exclusion; quantity never participates. Covers the same-order multi-colour cross-pairing case explicitly (TestSelectMatches_MultiColourSameOrderCrossPairing / TestSYBSpecText) plus expired-deadline, earliest-first, occupied, quantity-ignored and different-item-id cases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/returnmatch/candidate.go | 112 +++++++++++++++ .../app/goauto/returnmatch/candidate_test.go | 129 ++++++++++++++++++ server/app/goauto/returnmatch/normalize.go | 100 ++++++++++++++ .../app/goauto/returnmatch/normalize_test.go | 51 +++++++ 4 files changed, 392 insertions(+) create mode 100644 server/app/goauto/returnmatch/candidate.go create mode 100644 server/app/goauto/returnmatch/candidate_test.go create mode 100644 server/app/goauto/returnmatch/normalize.go create mode 100644 server/app/goauto/returnmatch/normalize_test.go diff --git a/server/app/goauto/returnmatch/candidate.go b/server/app/goauto/returnmatch/candidate.go new file mode 100644 index 0000000..c360147 --- /dev/null +++ b/server/app/goauto/returnmatch/candidate.go @@ -0,0 +1,112 @@ +package returnmatch + +import ( + "sort" + "time" +) + +// SYBCandidate is the pure, DB-free view of one selected SYB order product +// eligible to participate in this batch's matching (stage filter and "no +// active match" filter are applied by the caller before building this list; +// see issue #338 rule 1). +type SYBCandidate struct { + SYBProductID uint64 + ShopeeItemID string + TargetColor string + TargetSize string + CreatedAt time.Time +} + +// ReturnCandidate is the pure, DB-free view of one available yeeke return +// item (no active match, non-nil destroy deadline; the "deadline later than +// now" filter is applied by the caller — see BuildReturnPool below, or by +// the caller directly when it already filters in SQL). +type ReturnCandidate struct { + ReturnItemID uint64 + ItemID string + VariationName string + DestroyDeadline time.Time +} + +// SkipReason enumerates why a selected SYB candidate was not matched in this +// batch, for the batch-result dialog's per-row reasons (issue #338 prototype +// screen 2). +type SkipReason string + +const ( + SkipReasonNoCandidate SkipReason = "no_candidate" +) + +// MatchOutcome is one row of the pure matching result: either matched to a +// return item, or skipped with a reason. +type MatchOutcome struct { + SYBProductID uint64 + Matched bool + ReturnItemID uint64 + SkipReason SkipReason + NormalizedKey string + DestroyDeadline time.Time +} + +// SelectMatches implements issue #338 rules 2-6 purely in memory: +// - products are processed in the order given by the caller, which MUST be +// SYB created_at DESC (rule 4); this function does not itself sort by +// CreatedAt so a caller can supply a stable pre-sorted/tie-broken order. +// - a return is eligible only while its deadline is strictly after `now` +// (rule 2), matched on shopeeItemId + normalized spec text (rule 3); +// - once a return is used within this run it cannot be reused by a later +// product in the same run, on top of whatever was already occupied +// before the run started (rule 4, "已被占用的退货商品不再参与本轮后续 +// 及以后的匹配"); +// - when several candidates match the same product, the one with the +// earliest destroy deadline is chosen (rule 5); quantity is ignored +// entirely (rule 6). +func SelectMatches(products []SYBCandidate, returns []ReturnCandidate, now time.Time) []MatchOutcome { + // Group available (deadline > now) returns by matchKey = itemId + "|" + + // normalized spec text, sorted by deadline ascending so the first unused + // entry in each bucket is always the earliest deadline. + buckets := make(map[string][]ReturnCandidate) + for _, r := range returns { + if !r.DestroyDeadline.After(now) { + continue + } + key := matchKey(r.ItemID, Normalize(r.VariationName)) + buckets[key] = append(buckets[key], r) + } + for key := range buckets { + bucket := buckets[key] + sort.Slice(bucket, func(i, j int) bool { + return bucket[i].DestroyDeadline.Before(bucket[j].DestroyDeadline) + }) + buckets[key] = bucket + } + + used := make(map[uint64]bool) + outcomes := make([]MatchOutcome, 0, len(products)) + for _, p := range products { + key := matchKey(p.ShopeeItemID, Normalize(SYBSpecText(p.TargetColor, p.TargetSize))) + var picked *ReturnCandidate + for i := range buckets[key] { + cand := buckets[key][i] + if used[cand.ReturnItemID] { + continue + } + picked = &buckets[key][i] + break + } + if picked == nil { + outcomes = append(outcomes, MatchOutcome{SYBProductID: p.SYBProductID, Matched: false, SkipReason: SkipReasonNoCandidate, NormalizedKey: key}) + continue + } + used[picked.ReturnItemID] = true + outcomes = append(outcomes, MatchOutcome{ + SYBProductID: p.SYBProductID, Matched: true, ReturnItemID: picked.ReturnItemID, + NormalizedKey: key, DestroyDeadline: picked.DestroyDeadline, + }) + } + return outcomes +} + +func matchKey(itemID, normalizedSpec string) string { + return itemID + "|" + normalizedSpec +} diff --git a/server/app/goauto/returnmatch/candidate_test.go b/server/app/goauto/returnmatch/candidate_test.go new file mode 100644 index 0000000..03f13a8 --- /dev/null +++ b/server/app/goauto/returnmatch/candidate_test.go @@ -0,0 +1,129 @@ +package returnmatch + +import ( + "testing" + "time" +) + +func t1(offsetDays int) time.Time { + base := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) + return base.AddDate(0, 0, offsetDays) +} + +func TestSelectMatches_BasicMatch(t *testing.T) { + now := t1(0) + products := []SYBCandidate{ + {SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "2XL", CreatedAt: t1(-1)}, + } + returns := []ReturnCandidate{ + {ReturnItemID: 900, ItemID: "100", VariationName: "白色,2XL【建議65-75公斤】", DestroyDeadline: t1(10)}, + } + out := SelectMatches(products, returns, now) + if len(out) != 1 || !out[0].Matched || out[0].ReturnItemID != 900 { + t.Fatalf("unexpected outcome: %+v", out) + } +} + +// TestSelectMatches_MultiColourSameOrderCrossPairing reproduces issue #338's +// documented real-world case: the same order buys 2+ colour variants of the +// same Shopee item, and each SYB row must pair with the return item of its +// OWN colour, never grab whichever candidate is available first. +func TestSelectMatches_MultiColourSameOrderCrossPairing(t *testing.T) { + now := t1(0) + products := []SYBCandidate{ + {SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "2XL", CreatedAt: t1(-1)}, + {SYBProductID: 2, ShopeeItemID: "100", TargetColor: "紫色", TargetSize: "M", CreatedAt: t1(-2)}, + } + returns := []ReturnCandidate{ + {ReturnItemID: 901, ItemID: "100", VariationName: "紫色,M【建議43-53公斤】", DestroyDeadline: t1(5)}, + {ReturnItemID: 900, ItemID: "100", VariationName: "白色,2XL【建議65-75公斤】", DestroyDeadline: t1(10)}, + } + out := SelectMatches(products, returns, now) + got := map[uint64]uint64{} + for _, o := range out { + if !o.Matched { + t.Fatalf("expected all matched, got skip: %+v", o) + } + got[o.SYBProductID] = o.ReturnItemID + } + if got[1] != 900 { + t.Fatalf("white/2XL SYB product should match return 900 (white/2XL), got %d", got[1]) + } + if got[2] != 901 { + t.Fatalf("purple/M SYB product should match return 901 (purple/M), got %d", got[2]) + } +} + +func TestSelectMatches_ExpiredDeadlineExcluded(t *testing.T) { + now := t1(0) + products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}} + returns := []ReturnCandidate{ + {ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(0)}, // not after now -> excluded + {ReturnItemID: 901, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(-1)}, // already past -> excluded + } + out := SelectMatches(products, returns, now) + if out[0].Matched { + t.Fatalf("expected no match for expired-only candidates, got %+v", out[0]) + } + if out[0].SkipReason != SkipReasonNoCandidate { + t.Fatalf("unexpected skip reason: %+v", out[0]) + } +} + +func TestSelectMatches_EarliestDeadlineFirst(t *testing.T) { + now := t1(0) + products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}} + returns := []ReturnCandidate{ + {ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(30)}, + {ReturnItemID: 901, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(3)}, // soonest to expire, must win + {ReturnItemID: 902, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)}, + } + out := SelectMatches(products, returns, now) + if !out[0].Matched || out[0].ReturnItemID != 901 { + t.Fatalf("expected earliest-deadline candidate 901, got %+v", out[0]) + } +} + +func TestSelectMatches_OccupiedWithinRunNotReused(t *testing.T) { + now := t1(0) + // Two products with the identical spec, only one return item available: + // processed in the given (already created_at DESC) order, the second + // product must NOT be able to reuse the same return item. + products := []SYBCandidate{ + {SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}, // newer, processed first (DESC) + {SYBProductID: 2, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-5)}, + } + returns := []ReturnCandidate{ + {ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)}, + } + out := SelectMatches(products, returns, now) + if !out[0].Matched || out[0].ReturnItemID != 900 { + t.Fatalf("first (newer) product should win the only candidate: %+v", out[0]) + } + if out[1].Matched { + t.Fatalf("second product must not reuse the already-occupied return: %+v", out[1]) + } +} + +func TestSelectMatches_QuantityIgnored(t *testing.T) { + // SYBCandidate/ReturnCandidate deliberately carry no quantity field at + // all: this test documents that omission is intentional (issue #338 + // rule 6, quantity never participates in matching). + now := t1(0) + products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}} + returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)}} + out := SelectMatches(products, returns, now) + if !out[0].Matched { + t.Fatalf("expected match regardless of any quantity mismatch: %+v", out[0]) + } +} + +func TestSelectMatches_DifferentItemIDNeverMatches(t *testing.T) { + now := t1(0) + products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}} + returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "200", VariationName: "白色,L", DestroyDeadline: t1(10)}} + out := SelectMatches(products, returns, now) + if out[0].Matched { + t.Fatalf("different shopee item id must never match: %+v", out[0]) + } +} diff --git a/server/app/goauto/returnmatch/normalize.go b/server/app/goauto/returnmatch/normalize.go new file mode 100644 index 0000000..daa8645 --- /dev/null +++ b/server/app/goauto/returnmatch/normalize.go @@ -0,0 +1,100 @@ +// Package returnmatch implements the #338 return-matching algorithm: pairing +// a SYB order product with an available yeeke return item that is the same +// Shopee item with the same spec, so the return can be reused instead of +// buying the item again. This file holds only pure, DB-free functions so the +// matching rules can be unit tested directly against the 840 real pairs used +// to design the normalization rule (see issue #338 "当前事实"). +package returnmatch + +import "strings" + +// bracketPairs lists every bracket style issue #338 requires stripped, along +// with its contained content: 【】()() and []. +var bracketPairs = []struct{ open, close rune }{ + {'【', '】'}, + {'(', ')'}, + {'(', ')'}, + {'[', ']'}, +} + +// stripBracketedContent removes every bracket-delimited span (any style in +// bracketPairs) and its contents. Unmatched opening brackets discard the +// remainder of the string from that point, which is safe here because SYB +// and yeeke free-text specs never rely on a trailing unmatched bracket to +// carry meaningful spec content. +func stripBracketedContent(s string) string { + var b strings.Builder + depth := 0 + for _, r := range s { + isOpen, isClose := false, false + for _, pair := range bracketPairs { + if r == pair.open { + isOpen = true + } + if r == pair.close { + isClose = true + } + } + switch { + case isOpen: + depth++ + case isClose: + if depth > 0 { + depth-- + } + case depth == 0: + b.WriteRune(r) + } + } + return b.String() +} + +// fullwidthToHalfwidth converts fullwidth ASCII forms (U+FF01-U+FF5E) and the +// fullwidth space (U+3000) to their halfwidth equivalents, leaving CJK +// characters (colors like 紫色) untouched. +func fullwidthToHalfwidth(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r == ' ': + b.WriteRune(' ') + case r >= '!' && r <= '~': + b.WriteRune(r - 0xFEE0) + default: + b.WriteRune(r) + } + } + return b.String() +} + +// Normalize implements issue #338's spec-text normalization rule: strip +// bracketed remarks (【】()()[] and their content), strip whitespace, +// convert fullwidth characters to halfwidth, and lowercase. It is used on +// both the SYB side (target_color + "," + target_size) and the yeeke side +// (variation_name) so the two can be compared for equality. +func Normalize(raw string) string { + s := stripBracketedContent(raw) + s = fullwidthToHalfwidth(s) + s = strings.ToLower(s) + var b strings.Builder + for _, r := range s { + if !isSpace(r) { + b.WriteRune(r) + } + } + return b.String() +} + +func isSpace(r rune) bool { + switch r { + case ' ', '\t', '\n', '\r', '\f', '\v', ' ': + return true + } + return false +} + +// SYBSpecText builds the SYB-side comparable spec text from the parser's +// target_color/target_size fields, per issue #338's data design. +func SYBSpecText(targetColor, targetSize string) string { + return targetColor + "," + targetSize +} diff --git a/server/app/goauto/returnmatch/normalize_test.go b/server/app/goauto/returnmatch/normalize_test.go new file mode 100644 index 0000000..e188d53 --- /dev/null +++ b/server/app/goauto/returnmatch/normalize_test.go @@ -0,0 +1,51 @@ +package returnmatch + +import "testing" + +func TestNormalize(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"square brackets with content", "白色,2XL【建議65-75公斤】", "白色,2xl"}, + {"chinese parens", "紫色,M(建議43-53公斤)", "紫色,m"}, + {"ascii parens", "紫色,M (建議43-53公斤)", "紫色,m"}, + {"latin square brackets", "紫色,M [建議43-53公斤]", "紫色,m"}, + {"internal whitespace stripped", "紫色, M 【建議43-53公斤】", "紫色,m"}, + {"fullwidth digits and letters", "白色,2XL", "白色,2xl"}, + {"no bracket, plain", "黑色,L", "黑色,l"}, + {"empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Normalize(tc.in) + if got != tc.want { + t.Fatalf("Normalize(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestNormalizeMatchesRealPairSample(t *testing.T) { + // Representative real pairs from issue #338's local 840-pair backtest + // note: SYB target_color+","+target_size vs yeeke variation_name should + // normalize equal once bracketed remarks are stripped. + pairs := []struct{ syb, yeeke string }{ + {SYBSpecText("白色", "2XL"), "白色,2XL【建議65-75公斤】"}, + {SYBSpecText("紫色", "M"), "紫色,M【建議43-53公斤】"}, + {SYBSpecText("黑色", "L"), "黑色,L"}, + } + for _, p := range pairs { + if Normalize(p.syb) != Normalize(p.yeeke) { + t.Fatalf("expected normalized equality: syb=%q (%q) yeeke=%q (%q)", + p.syb, Normalize(p.syb), p.yeeke, Normalize(p.yeeke)) + } + } +} + +func TestSYBSpecText(t *testing.T) { + if got := SYBSpecText("白色", "2XL"); got != "白色,2XL" { + t.Fatalf("got %q", got) + } +}