feat(returnmatch): pure spec normalization and candidate selection for #338
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user