From 3516fb7edc347d73a0e61fc7969abde9ab8a80fd Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 11:48:33 +0800 Subject: [PATCH 01/12] feat(db): add return_match table for #338 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New model models.ReturnMatch backing the SYB↔yeeke return matching feature: matched/confirmed/cancelled status, nullable ActiveSYBProductID/ActiveYeekeReturnItemID columns (same pattern as YeekeSyncRun.ActiveSlot) each carrying a unique index so only one active match can occupy either side at a time; cancel clears both to free the slot for a rematch. Registered in migrations.MigratedModels(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/migrations/migrate.go | 1 + server/app/goauto/models/return_match.go | 71 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 server/app/goauto/models/return_match.go diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index e9aeb32..e3fdde8 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -79,6 +79,7 @@ func MigratedModels() []any { &models.PDDProductReplacement{}, &models.PDDProductReplacementItem{}, &models.PDDProductReplacementWorkerLease{}, + &models.ReturnMatch{}, } } diff --git a/server/app/goauto/models/return_match.go b/server/app/goauto/models/return_match.go new file mode 100644 index 0000000..baab66c --- /dev/null +++ b/server/app/goauto/models/return_match.go @@ -0,0 +1,71 @@ +package models + +import "time" + +// #338: ReturnMatch links one syb_product row to one yeeke_return_item row so +// the return can be reused instead of buying the item again on PDD. A match +// is "active" (still blocking purchase creation and occupying the return) +// while Status is matched or confirmed; cancelling clears both Active* +// columns so the unique index frees up both sides for a future rematch +// (issue #338, business rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许). +// +// ActiveSYBProductID/ActiveYeekeReturnItemID follow the same nullable-unique- +// column pattern already used by YeekeSyncRun.ActiveSlot in this codebase: +// MySQL unique indexes allow any number of NULL rows, so only the currently +// active match for a given side occupies the slot, and a concurrent second +// insert for the same side fails the unique constraint instead of double- +// booking it. +const ( + ReturnMatchStatusMatched = "matched" + ReturnMatchStatusConfirmed = "confirmed" + ReturnMatchStatusCancelled = "cancelled" +) + +type ReturnMatch struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + + SYBProductID uint64 `json:"sybProductId" gorm:"not null;index"` + YeekeReturnItemID uint64 `json:"yeekeReturnItemId" gorm:"not null;index"` + + // ActiveSYBProductID/ActiveYeekeReturnItemID mirror the ID columns above + // while Status is matched/confirmed, and are cleared to NULL on cancel. + ActiveSYBProductID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_syb"` + ActiveYeekeReturnItemID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_return"` + + Status string `json:"status" gorm:"size:16;not null;index;check:ck_return_match_status,status IN ('matched','confirmed','cancelled')"` + + // Snapshots of the normalized spec text on both sides at match time, kept + // for audit and for the compare screen even if the source rows change + // later. + SYBSpecText string `json:"sybSpecText" gorm:"size:255;not null;default:''"` + YeekeSpecText string `json:"yeekeSpecText" gorm:"size:255;not null;default:''"` + NormalizedKey string `json:"normalizedKey" gorm:"size:255;not null;default:'';index"` + + // PreviousProcessStage records what the SYB product's computed process + // stage was immediately before this match, so cancel can report/restore + // context; the actual restore is a pure recomputation (no stage is + // stored on syb_product), this is for audit/display only. + PreviousProcessStage string `json:"previousProcessStage" gorm:"size:32;not null;default:''"` + + DestroyDeadlineSnapshot *time.Time `json:"destroyDeadlineSnapshot,omitempty"` + + Remark string `json:"remark" gorm:"size:500;not null;default:''"` + + MatchedBy string `json:"matchedBy" gorm:"size:64;not null;default:''"` + MatchedAt time.Time `json:"matchedAt"` + ConfirmedBy string `json:"confirmedBy,omitempty" gorm:"size:64;not null;default:''"` + ConfirmedAt *time.Time `json:"confirmedAt,omitempty"` + CancelledBy string `json:"cancelledBy,omitempty" gorm:"size:64;not null;default:''"` + CancelledAt *time.Time `json:"cancelledAt,omitempty"` + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (ReturnMatch) TableName() string { return "return_match" } + +// IsActive reports whether the match currently blocks purchase creation and +// occupies its return item. +func (m ReturnMatch) IsActive() bool { + return m.Status == ReturnMatchStatusMatched || m.Status == ReturnMatchStatusConfirmed +} 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 02/12] 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) + } +} From fbde80cc201215905680a47e9defeee61f713c47 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 11:48:58 +0800 Subject: [PATCH 03/12] feat(returnmatch): DB-backed batch match/confirm/cancel/remark API for #338 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service (app/goauto/returnmatch/service.go) wires the pure matching functions to the database: - BatchMatch: manual-only trigger (no scheduler, not called from yeeke sync or SYB import) for ticked SYB product rows. Filters to the participating process stages (待人工处理 excluded per the confirmed rule), loads the available return pool (no active match, non-nil future destroy deadline) via purchase.ProcessStages +a join query, runs SelectMatches, and inserts one return_match row per outcome. A unique-constraint violation on insert (lost race) is reported per-row as a conflict skip, never fails the whole batch. - Confirm/Cancel: state transitions with row locking; Cancel clears both Active* columns so the same pair can be rematched later. - Remark, List (by SYB product id / return item id / status) and Detail for both admin pages' filter/column needs. Handler + router (app/goauto/returnmatch/{handler,router}.go) expose POST /api/admin/v1/return-matches/batch-match, GET .../return-matches, GET .../return-matches/:id, POST .../:id/{confirm,cancel,remark}. Write actions require admin/purchaser (same requireCanPurchase gate already used by yeeke.Handler.TriggerSync); list/detail are read-only for any authenticated user. Registered in app/admin/router/init_router.go. Tests cover end-to-end batch match, expired-deadline exclusion, multi-colour cross-pairing through the DB path, 待人工处理 exclusion, confirm-then-cancel restoring availability and rematch-ability, and a concurrent-insert test asserting exactly one winner against the unique constraint. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/admin/router/init_router.go | 2 + server/app/goauto/returnmatch/handler.go | 215 +++++++++++++ server/app/goauto/returnmatch/router.go | 21 ++ server/app/goauto/returnmatch/service.go | 301 ++++++++++++++++++ server/app/goauto/returnmatch/service_test.go | 270 ++++++++++++++++ 5 files changed, 809 insertions(+) create mode 100644 server/app/goauto/returnmatch/handler.go create mode 100644 server/app/goauto/returnmatch/router.go create mode 100644 server/app/goauto/returnmatch/service.go create mode 100644 server/app/goauto/returnmatch/service_test.go diff --git a/server/app/admin/router/init_router.go b/server/app/admin/router/init_router.go index 762a037..4c44f62 100644 --- a/server/app/admin/router/init_router.go +++ b/server/app/admin/router/init_router.go @@ -14,6 +14,7 @@ import ( goautopurchase "go-admin/app/goauto/purchase" goautopurchaserule "go-admin/app/goauto/purchaserule" goautoreplacement "go-admin/app/goauto/replacement" + goautoreturnmatch "go-admin/app/goauto/returnmatch" goautorule "go-admin/app/goauto/rule" goautoshopeeproduct "go-admin/app/goauto/shopeeproduct" goautosybimport "go-admin/app/goauto/sybimport" @@ -71,4 +72,5 @@ func InitRouter() { goautosybshop.InitRouter(r, authMiddleware) goautosybproductfilter.InitRouter(r, authMiddleware) goautoyeeke.InitRouter(r, authMiddleware) + goautoreturnmatch.InitRouter(r, authMiddleware) } diff --git a/server/app/goauto/returnmatch/handler.go b/server/app/goauto/returnmatch/handler.go new file mode 100644 index 0000000..e5d5759 --- /dev/null +++ b/server/app/goauto/returnmatch/handler.go @@ -0,0 +1,215 @@ +package returnmatch + +import ( + "errors" + "net/http" + "strconv" + + "go-admin/app/goauto/models" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/sdk/pkg" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "gorm.io/gorm" +) + +// Handler exposes the #338 return-matching admin surface: manual batch +// match, list/filter (shared by the SYB product page and the yeeke returns +// page), detail, confirm, cancel and remark. Everything here only reads or +// writes GoAuto's own database — no yeeke or SYB write call is made. +type Handler struct { + DB *gorm.DB +} + +func (h Handler) db(c *gin.Context) (*gorm.DB, bool) { + db := h.DB + var err error + if db == nil { + db, err = pkg.GetOrm(c) + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return nil, false + } + return db, true +} + +func operatorFromContext(c *gin.Context) (string, string) { + claims := jwt.ExtractClaims(c) + role, _ := claims["rolekey"].(string) + username, _ := claims["username"].(string) + if username == "" { + username, _ = claims["userName"].(string) + } + return role, username +} + +// requireCanPurchase mirrors the admin/purchaser write gate this codebase +// already uses for other manual-trigger actions (yeeke.Handler.TriggerSync, +// sybimport.Handler.Import): trigger match, confirm, cancel and remark are +// writes and require it; the two list/detail read endpoints do not. +func requireCanPurchase(c *gin.Context) bool { + role, _ := operatorFromContext(c) + if role != "admin" && role != "purchaser" { + c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作退货匹配"}) + return false + } + return true +} + +type batchMatchBody struct { + SYBProductIDs []uint64 `json:"sybProductIds"` +} + +// BatchMatch is the「匹配退货」button: manual-only trigger for ticked rows. +func (h Handler) BatchMatch(c *gin.Context) { + if !requireCanPurchase(c) { + return + } + db, ok := h.db(c) + if !ok { + return + } + var body batchMatchBody + if err := c.ShouldBindJSON(&body); err != nil || len(body.SYBProductIDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "sybProductIds 不能为空"}) + return + } + _, operator := operatorFromContext(c) + resp, err := NewService(db).BatchMatch(c.Request.Context(), BatchMatchRequest{SYBProductIDs: body.SYBProductIDs, Operator: operator}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": resp}) +} + +// List backs both the SYB product page's匹配列/筛选 and the yeeke returns +// page's占用状态筛选/列 (issue #338 pages section). +func (h Handler) List(c *gin.Context) { + db, ok := h.db(c) + if !ok { + return + } + filter := ListFilter{Status: c.Query("status")} + if raw := c.QueryArray("sybProductId"); len(raw) > 0 { + filter.SYBProductIDs = parseUint64List(raw) + } + if raw := c.QueryArray("yeekeReturnItemId"); len(raw) > 0 { + filter.YeekeReturnItemIDs = parseUint64List(raw) + } + rows, err := NewService(db).List(c.Request.Context(), filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"items": rows}}) +} + +func (h Handler) Detail(c *gin.Context) { + db, ok := h.db(c) + if !ok { + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"}) + return + } + match, err := NewService(db).Detail(c.Request.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}}) +} + +func (h Handler) Confirm(c *gin.Context) { + h.transition(c, func(s *Service, ctx *gin.Context, id uint64, operator string) (models.ReturnMatch, error) { + return s.Confirm(ctx.Request.Context(), id, operator) + }) +} + +func (h Handler) Cancel(c *gin.Context) { + h.transition(c, func(s *Service, ctx *gin.Context, id uint64, operator string) (models.ReturnMatch, error) { + return s.Cancel(ctx.Request.Context(), id, operator) + }) +} + +func (h Handler) transition(c *gin.Context, fn func(*Service, *gin.Context, uint64, string) (models.ReturnMatch, error)) { + if !requireCanPurchase(c) { + return + } + db, ok := h.db(c) + if !ok { + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"}) + return + } + _, operator := operatorFromContext(c) + match, err := fn(NewService(db), c, id, operator) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"}) + return + } + if errors.Is(err, errStateConflict) { + c.JSON(http.StatusConflict, gin.H{"code": "STATE_CONFLICT", "message": "匹配状态已变化,请刷新后重试"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}}) +} + +type remarkBody struct { + Remark string `json:"remark"` +} + +func (h Handler) Remark(c *gin.Context) { + if !requireCanPurchase(c) { + return + } + db, ok := h.db(c) + if !ok { + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"}) + return + } + var body remarkBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "请求体无效"}) + return + } + match, err := NewService(db).Remark(c.Request.Context(), id, body.Remark) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}}) +} + +func parseUint64List(raw []string) []uint64 { + out := make([]uint64, 0, len(raw)) + for _, v := range raw { + if id, err := strconv.ParseUint(v, 10, 64); err == nil { + out = append(out, id) + } + } + return out +} diff --git a/server/app/goauto/returnmatch/router.go b/server/app/goauto/returnmatch/router.go new file mode 100644 index 0000000..5f84255 --- /dev/null +++ b/server/app/goauto/returnmatch/router.go @@ -0,0 +1,21 @@ +package returnmatch + +import ( + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +// InitRouter mounts the #338 return-matching admin surface. List/detail are +// readable by any authenticated admin user; the write actions (batch match, +// confirm, cancel, remark) additionally require admin/purchaser via +// requireCanPurchase, same gate as yeeke.Handler.TriggerSync. +func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { + handler := Handler{} + group := engine.Group("/api/admin/v1/return-matches").Use(auth.MiddlewareFunc()) + group.GET("", handler.List) + group.GET("/:id", handler.Detail) + group.POST("/batch-match", handler.BatchMatch) + group.POST("/:id/confirm", handler.Confirm) + group.POST("/:id/cancel", handler.Cancel) + group.POST("/:id/remark", handler.Remark) +} diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go new file mode 100644 index 0000000..4e7b298 --- /dev/null +++ b/server/app/goauto/returnmatch/service.go @@ -0,0 +1,301 @@ +package returnmatch + +import ( + "context" + "errors" + "strings" + "time" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/purchase" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func clauseLockUpdate() clause.Locking { return clause.Locking{Strength: "UPDATE"} } + +// participatingStages is issue #338 rule 1's "待采购范围": 待人工处理 is +// explicitly excluded (already matched, per the confirmed rule), as are the +// stages that mean a task is already in flight or a match already exists. +var participatingStages = map[string]bool{ + purchase.ProcessStagePDDUnlinked: true, + purchase.ProcessStagePDDPending: true, + purchase.ProcessStagePDDCollecting: true, + purchase.ProcessStagePDDCollectionFail: true, + purchase.ProcessStageColorMapping: true, + purchase.ProcessStagePurchaseReady: true, +} + +type Service struct { + DB *gorm.DB + Now func() time.Time +} + +func NewService(db *gorm.DB) *Service { + return &Service{DB: db, Now: time.Now} +} + +// BatchMatchRequest/Result mirror the prototype's batch result dialog +// (screen 2): per-SYB-product outcome plus a reason bucket count. +type BatchMatchRequest struct { + SYBProductIDs []uint64 + Operator string +} + +type BatchMatchItem struct { + SYBProductID uint64 `json:"sybProductId"` + Matched bool `json:"matched"` + ReasonCode string `json:"reasonCode,omitempty"` + Reason string `json:"reason,omitempty"` + MatchID uint64 `json:"matchId,omitempty"` +} + +type BatchMatchResponse struct { + Items []BatchMatchItem `json:"items"` + ProcessedCount int `json:"processedCount"` + MatchedCount int `json:"matchedCount"` + SkippedCount int `json:"skippedCount"` +} + +const ( + ReasonStageIneligible = "stage_ineligible" + ReasonNoCandidate = "no_candidate" + ReasonConflict = "conflict" +) + +// BatchMatch implements issue #338's manual "匹配退货" trigger. It is only +// ever called from the batch-match button (ticked rows) — no scheduler, no +// yeeke-sync/SYB-import hook calls this (rule: 手动触发, 无定时任务). +func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) { + resp := BatchMatchResponse{Items: make([]BatchMatchItem, 0, len(req.SYBProductIDs))} + if len(req.SYBProductIDs) == 0 { + return resp, nil + } + + var sybProducts []models.SYBProduct + if err := s.DB.WithContext(ctx).Where("id IN ?", req.SYBProductIDs).Find(&sybProducts).Error; err != nil { + return resp, err + } + sybByID := make(map[uint64]models.SYBProduct, len(sybProducts)) + for _, p := range sybProducts { + sybByID[p.ID] = p + } + + stages, err := purchase.NewService(s.DB).ProcessStages(ctx, req.SYBProductIDs) + if err != nil { + return resp, err + } + + now := s.Now() + eligible := make([]SYBCandidate, 0, len(req.SYBProductIDs)) + skipped := make(map[uint64]string) + for _, id := range req.SYBProductIDs { + syb, ok := sybByID[id] + if !ok { + skipped[id] = "SYB 商品不存在或已删除" + continue + } + stage := stages[id] + if !participatingStages[stage.Stage] { + skipped[id] = "该处理阶段不参与匹配:" + stage.Label + continue + } + eligible = append(eligible, SYBCandidate{ + SYBProductID: syb.ID, ShopeeItemID: syb.ShopeeItemID, + TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, CreatedAt: syb.CreatedAt, + }) + } + // Rule 4: process in SYB created_at DESC order. + sortSYBCandidatesDesc(eligible) + + returns, err := s.availableReturnPool(ctx) + if err != nil { + return resp, err + } + + outcomes := SelectMatches(eligible, returns, now) + outcomeBySYB := make(map[uint64]MatchOutcome, len(outcomes)) + for _, o := range outcomes { + outcomeBySYB[o.SYBProductID] = o + } + + for _, id := range req.SYBProductIDs { + if reason, isSkip := skipped[id]; isSkip { + resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonStageIneligible, Reason: reason}) + resp.SkippedCount++ + continue + } + outcome, ok := outcomeBySYB[id] + if !ok || !outcome.Matched { + resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonNoCandidate, Reason: "没有满足条件的退货商品"}) + resp.SkippedCount++ + continue + } + match, insertErr := s.insertMatch(ctx, sybByID[id], outcome, req.Operator) + if insertErr != nil { + if isUniqueConstraintErr(insertErr) { + // Rule 7 / acceptance item 9: a concurrent insert lost the + // race for either side — skip this row and report it, never + // fail the whole batch. + resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonConflict, Reason: "并发匹配冲突,该商品或退货商品已被占用"}) + resp.SkippedCount++ + continue + } + return resp, insertErr + } + resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: true, MatchID: match.ID}) + resp.MatchedCount++ + } + resp.ProcessedCount = len(resp.Items) + return resp, nil +} + +func sortSYBCandidatesDesc(items []SYBCandidate) { + for i := 1; i < len(items); i++ { + for j := i; j > 0 && items[j].CreatedAt.After(items[j-1].CreatedAt); j-- { + items[j], items[j-1] = items[j-1], items[j] + } + } +} + +// availableReturnPool loads every yeeke return item with no active match and +// a destroy deadline (rule 2: empty deadline is unavailable, handled by the +// NULL exclusion below; "later than now" is enforced by SelectMatches). +func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, error) { + var rows []struct { + ID uint64 + ItemID string + VariationName string + DestroyDeadLine *time.Time + } + err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i"). + Select("i.id AS id, i.item_id AS item_id, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line"). + Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id"). + Joins("LEFT JOIN return_match AS m ON m.active_yeeke_return_item_id = i.id"). + Where("m.id IS NULL"). + Find(&rows).Error + if err != nil { + return nil, err + } + out := make([]ReturnCandidate, 0, len(rows)) + for _, r := range rows { + if r.DestroyDeadLine == nil { + continue + } + out = append(out, ReturnCandidate{ReturnItemID: r.ID, ItemID: r.ItemID, VariationName: r.VariationName, DestroyDeadline: *r.DestroyDeadLine}) + } + return out, nil +} + +func (s *Service) insertMatch(ctx context.Context, syb models.SYBProduct, outcome MatchOutcome, operator string) (models.ReturnMatch, error) { + sybID := syb.ID + returnID := outcome.ReturnItemID + now := s.Now() + match := models.ReturnMatch{ + SYBProductID: sybID, YeekeReturnItemID: returnID, + ActiveSYBProductID: &sybID, ActiveYeekeReturnItemID: &returnID, + Status: models.ReturnMatchStatusMatched, + SYBSpecText: SYBSpecText(syb.TargetColor, syb.TargetSize), + NormalizedKey: outcome.NormalizedKey, + MatchedBy: operator, MatchedAt: now, + DestroyDeadlineSnapshot: &outcome.DestroyDeadline, + } + if err := s.DB.WithContext(ctx).Create(&match).Error; err != nil { + return models.ReturnMatch{}, err + } + return match, nil +} + +func isUniqueConstraintErr(err error) bool { + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unique") || strings.Contains(msg, "duplicate") +} + +// Confirm/Cancel/Remark implement the human-review half of issue #338. + +func (s *Service) Confirm(ctx context.Context, matchID uint64, operator string) (models.ReturnMatch, error) { + var match models.ReturnMatch + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clauseLockUpdate()).First(&match, matchID).Error; err != nil { + return err + } + if match.Status != models.ReturnMatchStatusMatched { + return errStateConflict + } + now := s.Now() + match.Status = models.ReturnMatchStatusConfirmed + match.ConfirmedBy = operator + match.ConfirmedAt = &now + return tx.Save(&match).Error + }) + return match, err +} + +// Cancel restores the SYB product to purchasable (by clearing +// ActiveSYBProductID) and returns the return item to the eligible pool (by +// clearing ActiveYeekeReturnItemID), from either matched or confirmed state, +// per issue #338 rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许. +func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) (models.ReturnMatch, error) { + var match models.ReturnMatch + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clauseLockUpdate()).First(&match, matchID).Error; err != nil { + return err + } + if match.Status == models.ReturnMatchStatusCancelled { + return errStateConflict + } + now := s.Now() + match.Status = models.ReturnMatchStatusCancelled + match.ActiveSYBProductID = nil + match.ActiveYeekeReturnItemID = nil + match.CancelledBy = operator + match.CancelledAt = &now + return tx.Save(&match).Error + }) + return match, err +} + +func (s *Service) Remark(ctx context.Context, matchID uint64, remark string) (models.ReturnMatch, error) { + var match models.ReturnMatch + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.First(&match, matchID).Error; err != nil { + return err + } + match.Remark = remark + return tx.Save(&match).Error + }) + return match, err +} + +// List supports both admin pages' filter/column needs: by SYB product ids, +// by yeeke return item ids, or by status. +type ListFilter struct { + SYBProductIDs []uint64 + YeekeReturnItemIDs []uint64 + Status string +} + +func (s *Service) List(ctx context.Context, filter ListFilter) ([]models.ReturnMatch, error) { + q := s.DB.WithContext(ctx).Model(&models.ReturnMatch{}) + if len(filter.SYBProductIDs) > 0 { + q = q.Where("syb_product_id IN ?", filter.SYBProductIDs) + } + if len(filter.YeekeReturnItemIDs) > 0 { + q = q.Where("yeeke_return_item_id IN ?", filter.YeekeReturnItemIDs) + } + if filter.Status != "" { + q = q.Where("status = ?", filter.Status) + } + var rows []models.ReturnMatch + err := q.Order("id DESC").Find(&rows).Error + return rows, err +} + +func (s *Service) Detail(ctx context.Context, matchID uint64) (models.ReturnMatch, error) { + var match models.ReturnMatch + err := s.DB.WithContext(ctx).First(&match, matchID).Error + return match, err +} + +var errStateConflict = errors.New("return match state conflict") diff --git a/server/app/goauto/returnmatch/service_test.go b/server/app/goauto/returnmatch/service_test.go new file mode 100644 index 0000000..7b2898e --- /dev/null +++ b/server/app/goauto/returnmatch/service_test.go @@ -0,0 +1,270 @@ +package returnmatch + +import ( + "context" + "fmt" + "strings" + "sync" + "testing" + "time" + + "go-admin/app/goauto/migrations" + "go-admin/app/goauto/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func testDB(t *testing.T) *gorm.DB { + t.Helper() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + if err = migrations.Migrate(db); err != nil { + t.Fatal(err) + } + return db +} + +// shopeeLinkForItem memoizes one shopee_product row per (db, item id) test +// case so multiple SYB rows for the same order/item share the link, same as +// real data, while still landing on the PDD-unlinked stage (which +// participates in matching per issue #338 rule 1) without needing the full +// PDD/price fixture the purchase package's own tests use. +func shopeeLinkForItem(t *testing.T, db *gorm.DB, itemID string) uint64 { + t.Helper() + var existing models.ShopeeProduct + if err := db.Where("shopee_item_id = ?", itemID).First(&existing).Error; err == nil { + return existing.ID + } + sp := models.ShopeeProduct{ShopeeItemID: itemID, Title: "蝦皮商品", ShopName: "测试店", SpecsJSON: `[]`, Currency: "CNY"} + if err := db.Create(&sp).Error; err != nil { + t.Fatal(err) + } + return sp.ID +} + +func seedSYB(t *testing.T, db *gorm.DB, orderCode string, detailID uint64, color, size string, createdAt time.Time) models.SYBProduct { + t.Helper() + shopeeID := shopeeLinkForItem(t, db, "100") + syb := models.SYBProduct{ + OrderCode: orderCode, DetailID: detailID, StockID: detailID, ShopeeItemID: "100", ShopeeProductID: &shopeeID, + TargetColor: color, TargetSize: size, Quantity: 1, UnitPriceCent: 1000, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`, + } + if err := db.Create(&syb).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("created_at", createdAt).Error; err != nil { + t.Fatal(err) + } + syb.CreatedAt = createdAt + return syb +} + +func seedReturn(t *testing.T, db *gorm.DB, variationName string, deadline *time.Time) models.YeekeReturnItem { + t.Helper() + pkg := models.YeekeReturnPackage{ + ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1", + DestroyDeadLine: deadline, LastSyncedAt: time.Now(), + } + if err := db.Create(&pkg).Error; err != nil { + t.Fatal(err) + } + item := models.YeekeReturnItem{ + PackageID: pkg.ID, ExternalKey: "key-" + variationName + fmt.Sprint(time.Now().UnixNano()), + ItemID: "100", VariationName: variationName, LastSyncedAt: time.Now(), + } + if err := db.Create(&item).Error; err != nil { + t.Fatal(err) + } + return item +} + +func TestBatchMatch_EndToEnd(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 1 || len(resp.Items) != 1 || !resp.Items[0].Matched { + t.Fatalf("expected one match: %+v", resp) + } + + var match models.ReturnMatch + if err := db.First(&match, resp.Items[0].MatchID).Error; err != nil { + t.Fatal(err) + } + if match.Status != models.ReturnMatchStatusMatched || match.ActiveSYBProductID == nil || *match.ActiveSYBProductID != syb.ID { + t.Fatalf("unexpected match row: %+v", match) + } +} + +func TestBatchMatch_ExpiredDeadlineNotMatched(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + + past := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + seedReturn(t, db, "白色,L", &past) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 0 || resp.SkippedCount != 1 { + t.Fatalf("expected skip due to expired deadline: %+v", resp) + } +} + +func TestBatchMatch_MultiColourSameOrderCrossPairing(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + + white := seedSYB(t, db, "SYB-1", 1, "白色", "2XL", time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)) + purple := seedSYB(t, db, "SYB-1", 2, "紫色", "M", time.Date(2026, 9, 1, 11, 0, 0, 0, time.UTC)) + whiteReturn := seedReturn(t, db, "白色,2XL【建議65-75公斤】", &deadline) + purpleReturn := seedReturn(t, db, "紫色,M【建議43-53公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{white.ID, purple.ID}}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 2 { + t.Fatalf("expected both to match: %+v", resp) + } + var matches []models.ReturnMatch + if err := db.Find(&matches).Error; err != nil { + t.Fatal(err) + } + got := map[uint64]uint64{} + for _, m := range matches { + got[m.SYBProductID] = m.YeekeReturnItemID + } + if got[white.ID] != whiteReturn.ID { + t.Fatalf("white SYB product should pair with white return, got %+v", got) + } + if got[purple.ID] != purpleReturn.ID { + t.Fatalf("purple SYB product should pair with purple return, got %+v", got) + } +} + +func TestBatchMatch_ManualActionStageDoesNotParticipate(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + + // A parse-failed SYB product computes to 待人工处理, which the confirmed + // rule says must NOT participate even though a candidate return exists. + syb := models.SYBProduct{OrderCode: "SYB-1", DetailID: 1, StockID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", Quantity: 1, UnitPriceCent: 1000, ParseStatus: models.SYBParseStatusFailed, RawJSON: `{}`} + if err := db.Create(&syb).Error; err != nil { + t.Fatal(err) + } + seedReturn(t, db, "白色,L", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 0 || resp.Items[0].ReasonCode != ReasonStageIneligible { + t.Fatalf("expected 待人工处理 to be excluded from matching: %+v", resp) + } +} + +func TestConfirmThenCancel_RestoresAvailability(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + seedReturn(t, db, "白色,L", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}) + if err != nil || resp.MatchedCount != 1 { + t.Fatalf("setup match failed: %v %+v", err, resp) + } + matchID := resp.Items[0].MatchID + + if _, err := s.Confirm(context.Background(), matchID, "op1"); err != nil { + t.Fatal(err) + } + var confirmed models.ReturnMatch + db.First(&confirmed, matchID) + if confirmed.Status != models.ReturnMatchStatusConfirmed { + t.Fatalf("expected confirmed: %+v", confirmed) + } + + if _, err := s.Cancel(context.Background(), matchID, "op2"); err != nil { + t.Fatal(err) + } + var cancelled models.ReturnMatch + db.First(&cancelled, matchID) + if cancelled.Status != models.ReturnMatchStatusCancelled || cancelled.ActiveSYBProductID != nil || cancelled.ActiveYeekeReturnItemID != nil { + t.Fatalf("expected cancel to clear active columns: %+v", cancelled) + } + + // Rematch must be able to pick the same pair again (issue #338 rule 7). + resp2, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}) + if err != nil { + t.Fatal(err) + } + if resp2.MatchedCount != 1 { + t.Fatalf("expected rematch after cancel to succeed: %+v", resp2) + } +} + +// TestBatchMatch_ConcurrentInsertSkipsConflictRow exercises the DB unique +// constraint directly: two goroutines racing to insert an active match for +// the exact same SYB product must have exactly one winner, and the loser +// must not error out the whole call (acceptance item 9). +func TestBatchMatch_ConcurrentInsertSkipsConflictRow(t *testing.T) { + db := testDB(t) + sybID := uint64(1) + returnAID := uint64(101) + returnBID := uint64(102) + + var wg sync.WaitGroup + results := make([]error, 2) + for i, retID := range []uint64{returnAID, returnBID} { + wg.Add(1) + go func(i int, retID uint64) { + defer wg.Done() + match := models.ReturnMatch{ + SYBProductID: sybID, YeekeReturnItemID: retID, + ActiveSYBProductID: &sybID, ActiveYeekeReturnItemID: &retID, + Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now(), + } + results[i] = db.Create(&match).Error + }(i, retID) + } + wg.Wait() + + successCount := 0 + conflictCount := 0 + for _, err := range results { + if err == nil { + successCount++ + } else if isUniqueConstraintErr(err) { + conflictCount++ + } else { + t.Fatalf("unexpected error: %v", err) + } + } + if successCount != 1 || conflictCount != 1 { + t.Fatalf("expected exactly one winner and one unique-constraint conflict, got success=%d conflict=%d results=%v", successCount, conflictCount, results) + } +} From dec99cb81d511890ea0d3040753c52bb2e4d9b29 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 11:49:10 +0800 Subject: [PATCH 04/12] fix(purchase): block new purchase tasks for actively return-matched SYB products (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rejectIfActiveReturnMatch (app/goauto/purchase/return_match_block.go) is called from Service.create right after the SYB row is locked: a SYB product with an active (matched or confirmed) return_match row is rejected with CodeReturnMatched. Since BatchCreate already calls Service.Create per row and treats a create error as a per-row skip, this single insertion point covers both single and batch creation — batch creation skips only the blocked rows and reports CodeReturnMatched, it does not fail the whole batch. A query error here is treated as internal() and never silently allows creation. Process stage computation (process_stage.go) gains two new stages, ProcessStageReturnPending (退货待确认) and ProcessStageReturnUsed (已用退货), sourced from a new bounded dataset.activeReturnMatchBySYB query in loadBatchPreviewDataset (batch.go); with zero return_match rows this query returns nothing and every other stage branch is unchanged (updated the batch preview bounded-query-count assertion in batch_test.go from 8 to 9 to reflect the new, still-bounded query). Regression coverage (return_match_block_test.go): single create rejected for matched and for confirmed match, cancelled match does not block, batch create creates the clean row and skips only the matched row with CodeReturnMatched, and a zero-return-match baseline still succeeds unchanged (acceptance item 11). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/purchase/batch.go | 18 +++ server/app/goauto/purchase/batch_test.go | 9 +- server/app/goauto/purchase/process_stage.go | 21 ++- .../app/goauto/purchase/return_match_block.go | 30 ++++ .../purchase/return_match_block_test.go | 145 ++++++++++++++++++ server/app/goauto/purchase/service.go | 3 + server/app/goauto/purchase/types.go | 7 +- 7 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 server/app/goauto/purchase/return_match_block.go create mode 100644 server/app/goauto/purchase/return_match_block_test.go diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 0ff0243..67f631d 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -142,6 +142,11 @@ type batchPreviewDataset struct { // 见 #289 与 sybimport.RawSpecHalves。 collapsedColorByShopee map[uint64]map[string]bool collapsedSizeByShopee map[uint64]map[string]bool + // activeReturnMatchBySYB is #338's return-match stage input: a non-zero + // entry means the SYB product currently has an active (matched or + // confirmed) return_match row, which forces the 退货待确认/已用退货 + // stages regardless of what the rest of the pipeline would compute. + activeReturnMatchBySYB map[uint64]models.ReturnMatch } // loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries. @@ -159,6 +164,7 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba skuCombinationsByPDD: make(map[uint64][]pddSKUCombination), collapsedColorByShopee: make(map[uint64]map[string]bool), collapsedSizeByShopee: make(map[uint64]map[string]bool), + activeReturnMatchBySYB: make(map[uint64]models.ReturnMatch), } var sybProducts []models.SYBProduct if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil { @@ -248,6 +254,18 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba dataset.latestTaskBySYB[*task.SYBProductID] = task } } + // #338: load active return matches for these SYB products. This query + // only ever ADDS a stage override on top of the pre-#338 computation; an + // empty result set (the common case for products never matched) leaves + // every other branch of processStageFromDataset byte-for-byte unchanged + // (issue #338 acceptance item 11 regression). + var activeMatches []models.ReturnMatch + if err := s.DB.WithContext(ctx).Where("syb_product_id IN ? AND active_syb_product_id IS NOT NULL", ids).Find(&activeMatches).Error; err != nil { + return dataset, err + } + for _, m := range activeMatches { + dataset.activeReturnMatchBySYB[m.SYBProductID] = m + } return dataset, nil } diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go index 3be3c96..64e65ad 100644 --- a/server/app/goauto/purchase/batch_test.go +++ b/server/app/goauto/purchase/batch_test.go @@ -311,10 +311,11 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) { t.Fatalf("read-only preview called AI matcher %d times", matcher.calls) } // #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) + // 因此从 7 变为 8;#338 再新增一次有界批量查询(按 SYB 商品ID拉活跃 + // 退货匹配),因此从 8 变为 9。这条断言守的是“不得出现 N+1”,不是具体 + // 数字;只有新增的查询确实有界时才允许上调。 + if queries != 9 { + t.Fatalf("batch preview used %d queries, want 9 bounded queries including collection eligibility, current purchase rule, collapsed spec keys and #338 active return matches", 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) diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go index ca70dc0..bcd668f 100644 --- a/server/app/goauto/purchase/process_stage.go +++ b/server/app/goauto/purchase/process_stage.go @@ -21,7 +21,13 @@ const ( ProcessStageTaskCreated = "task_created" ProcessStagePurchaseSucceeded = "purchase_succeeded" ProcessStageOrderReview = "order_review" - processActionOpenPDDLink = "open_pdd_link" + // ProcessStageReturnPending/ProcessStageReturnUsed are the two new stages + // added by issue #338: a SYB product with an active return_match blocks + // purchase creation regardless of what stage it would otherwise compute + // to (see rejectIfActiveReturnMatch and processStageFromDataset below). + ProcessStageReturnPending = "return_pending" + ProcessStageReturnUsed = "return_used" + processActionOpenPDDLink = "open_pdd_link" ) var processStageLabels = map[string]string{ @@ -35,6 +41,8 @@ var processStageLabels = map[string]string{ ProcessStageTaskCreated: "已创建任务", ProcessStagePurchaseSucceeded: "采购成功", ProcessStageOrderReview: "待人工核对", + ProcessStageReturnPending: "退货待确认", + ProcessStageReturnUsed: "已用退货", } type ProcessStageResult struct { @@ -132,6 +140,17 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat } } + // #338: an active return match overrides the normal computation below — + // it only ever fires for a SYB product that has one, so with zero + // matches (the pre-#338 default) this branch is a no-op and every stage + // below is unchanged (acceptance item 11 regression). + if match, ok := dataset.activeReturnMatchBySYB[id]; ok { + if match.Status == models.ReturnMatchStatusConfirmed { + return stage(ProcessStageReturnUsed, "已用退货冲抵,无需采购", "open_return_match") + } + return stage(ProcessStageReturnPending, "已匹配退货待人工确认,暂不能创建采购任务", "open_return_match") + } + syb, ok := dataset.sybByID[id] if !ok { return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh") diff --git a/server/app/goauto/purchase/return_match_block.go b/server/app/goauto/purchase/return_match_block.go new file mode 100644 index 0000000..812375a --- /dev/null +++ b/server/app/goauto/purchase/return_match_block.go @@ -0,0 +1,30 @@ +package purchase + +import ( + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +// rejectIfActiveReturnMatch implements issue #338's purchase-creation block: +// a SYB product with an active (matched or confirmed) return_match row must +// not get a NEW purchase task created for it (single or batch — BatchCreate +// calls s.Create per row, so this single check point covers both paths and +// batch creation simply skips the row via the normal createErr handling, +// issue #338 acceptance item 5). Resuming/reparsing an existing task is a +// different code path and is not affected. +// +// On any query error this returns the error (never nil) so a DB failure can +// never silently allow a purchase for an already-matched product (issue +// #338 acceptance item 11). +func rejectIfActiveReturnMatch(tx *gorm.DB, sybProductID uint64) error { + var match models.ReturnMatch + err := tx.Where("active_syb_product_id = ?", sybProductID).First(&match).Error + if err == nil { + return fail(CodeReturnMatched, "该商品已匹配退货,需先在退货匹配中取消才能创建采购任务") + } + if err == gorm.ErrRecordNotFound { + return nil + } + return internal(err) +} diff --git a/server/app/goauto/purchase/return_match_block_test.go b/server/app/goauto/purchase/return_match_block_test.go new file mode 100644 index 0000000..5ea3e70 --- /dev/null +++ b/server/app/goauto/purchase/return_match_block_test.go @@ -0,0 +1,145 @@ +package purchase + +import ( + "context" + "testing" + "time" + + "go-admin/app/goauto/models" + + "github.com/google/uuid" +) + +// #338 acceptance item 5/6/11: a SYB product with an active return match +// must be rejected for NEW single-task creation, batch creation must skip +// only that row (not fail the whole batch), and with zero matches (the +// default) behavior must be byte-for-byte unchanged. + +func TestCreate_RejectsWhenActiveReturnMatch(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + + match := models.ReturnMatch{ + SYBProductID: f.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + + _, err := createLive(t, s, f) + if err == nil { + t.Fatalf("expected rejection, got success") + } + if se, ok := asServiceError(err); !ok || se.Code != CodeReturnMatched { + t.Fatalf("expected CodeReturnMatched, got %v", err) + } +} + +func TestCreate_ConfirmedReturnMatchAlsoRejects(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + + match := models.ReturnMatch{ + SYBProductID: f.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusConfirmed, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + _, err := createLive(t, s, f) + if err == nil { + t.Fatalf("expected rejection for confirmed match too") + } + if se, ok := asServiceError(err); !ok || se.Code != CodeReturnMatched { + t.Fatalf("expected CodeReturnMatched, got %v", err) + } +} + +func TestCreate_CancelledReturnMatchDoesNotBlock(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + + match := models.ReturnMatch{ + SYBProductID: f.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: nil, Status: models.ReturnMatchStatusCancelled, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + if _, err := createLive(t, s, f); err != nil { + t.Fatalf("cancelled match must not block creation: %v", err) + } +} + +// TestBatchCreate_SkipsOnlyBlockedRowNotWholeBatch is the acceptance-item-5 +// "batch creation must skip just those rows" regression: one blocked SYB +// product and one clean one in the same batch call. +func TestBatchCreate_SkipsOnlyBlockedRowNotWholeBatch(t *testing.T) { + db := testDB(t) + s := testService(db) + f1 := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f1.pdd.ID) + + // second, independent eligible SYB product sharing the same shopee/pdd + // chain shape but its own row. + syb2 := models.SYBProduct{OrderCode: "SYB-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ImageURL: "", ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`} + if err := db.Create(&syb2).Error; err != nil { + t.Fatal(err) + } + + match := models.ReturnMatch{ + SYBProductID: f1.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: &f1.syb.ID, Status: models.ReturnMatchStatusMatched, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + + resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{ + RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID, + }) + if err != nil { + t.Fatalf("batch call itself must not fail: %v", err) + } + if resp.CreatedCount != 1 || resp.FailedCount != 1 { + t.Fatalf("expected 1 created + 1 failed, got created=%d failed=%d items=%+v", resp.CreatedCount, resp.FailedCount, resp.Items) + } + for _, item := range resp.Items { + if item.SYBProductID == f1.syb.ID { + if item.Created { + t.Fatalf("matched row must not be created: %+v", item) + } + if item.ReasonCode != CodeReturnMatched { + t.Fatalf("expected CodeReturnMatched reason, got %+v", item) + } + } + if item.SYBProductID == syb2.ID && !item.Created { + t.Fatalf("clean row must still be created: %+v", item) + } + } +} + +// TestCreate_NoReturnMatchTableRowsUnaffected is the acceptance-item-11 +// regression: with zero return_match rows anywhere, creation behaves exactly +// as it did before #338. +func TestCreate_NoReturnMatchTableRowsUnaffected(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + if _, err := createLive(t, s, f); err != nil { + t.Fatalf("zero-match baseline must succeed unchanged: %v", err) + } +} + +func asServiceError(err error) (*ServiceError, bool) { + se, ok := err.(*ServiceError) + return se, ok +} diff --git a/server/app/goauto/purchase/service.go b/server/app/goauto/purchase/service.go index 7b4ffa9..fb25908 100644 --- a/server/app/goauto/purchase/service.go +++ b/server/app/goauto/purchase/service.go @@ -178,6 +178,9 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, *req.SYBProductID).Error; err != nil { return notFound(err, "顺云宝商品不存在") } + if err := rejectIfActiveReturnMatch(tx, syb.ID); err != nil { + return err + } if syb.ShopeeProductID == nil { return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品") } diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index 2a5f780..332e71e 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -36,7 +36,12 @@ const ( CodeOrderTimeMissing = "PURCHASE_ORDER_TIME_MISSING" CodeOrderTimeInvalid = "PURCHASE_ORDER_TIME_INVALID" CodeOrderUnpaidMissing = "PURCHASE_ORDER_UNPAID_EVIDENCE_MISSING" - CodeInternal = "INTERNAL_ERROR" + // CodeReturnMatched is returned when a SYB product has an active (matched + // or confirmed) return match: issue #338 blocks creating NEW purchase + // tasks for such a product until the match is cancelled. Resuming or + // reparsing an existing task is not affected. + CodeReturnMatched = "PURCHASE_RETURN_MATCHED" + CodeInternal = "INTERNAL_ERROR" ) type ServiceError struct { From fc99be0392a1d153d5ce4e864bf822f55c17b888 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 11:56:14 +0800 Subject: [PATCH 05/12] fix(returnmatch): dedicated log table, race-safe matching, spec/remark fixes (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on the #338 backend: 1. New table return_match_log (models/return_match_log.go, registered in migrations.MigratedModels()): match_id/action/operator/detail/ created_at. sys_opera_log is a generic per-HTTP-call framework log, not queryable per match and not carrying operator/detail in a stable shape, so match/confirm/cancel/remark each write their own log row in the SAME transaction as the state change. Detail now returns the logs (newest first). 2. Race between matching and purchase creation: BatchMatch's outer screening pass (stage check outside any lock) is now followed by matchOneWithLock, which takes the same clause.Locking{Strength: "UPDATE"} lock on syb_product that purchase.Service.create takes, re-computes the stage inside that transaction via purchase.NewService(tx).ProcessStages, and returns errStageNoLongerEligible (surfaced as reasonCode stage_ineligible) if the product is no longer in a participating stage instead of inserting a stale match. TestMatchOneWithLock_SkipsWhenStageNoLongerParticipatesUnderLock covers the skip path. 3. matchOneWithLock now fills YeekeSpecText (raw variation_name) and PreviousProcessStage (the stage code at match time) on the inserted row. Remark takes an operator (for its log row) and rejects input over 500 runes with errRemarkTooLong instead of truncating (varchar(500) is a character-count limit in MySQL, so the check is utf8.RuneCountInString, not len()). 4. returnmatch.SYBSpecText now joins only non-empty color/size parts, so a single-dimension spec (e.g. color-only) no longer produces a stray leading/trailing comma ("黑色" instead of "黑色,"); matchKey additionally trims leading/trailing commas from both normalized sides via the new trimCommas() helper. New tests cover color-only and size-only matching through SelectMatches plus SYBSpecText/ trimCommas directly. 5. Detail (service.go) now returns MatchDetail: SYBDetailView (order code, shopee item id, shop, title, target color/size, quantity, image, current computed stage+label), YeekeDetailView (return order sn, item id, variation id, shop, item name, variation name, quantity, image, destroy deadline), both sides' normalized spec text, the match row itself, and the operation logs — everything the prototype's compare screen (screen 3) needs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/migrations/migrate.go | 1 + server/app/goauto/models/return_match_log.go | 28 +++ server/app/goauto/returnmatch/candidate.go | 4 +- .../app/goauto/returnmatch/candidate_test.go | 20 ++ server/app/goauto/returnmatch/handler.go | 11 +- server/app/goauto/returnmatch/normalize.go | 23 +- .../app/goauto/returnmatch/normalize_test.go | 27 +++ server/app/goauto/returnmatch/service.go | 207 +++++++++++++++--- server/app/goauto/returnmatch/service_test.go | 168 ++++++++++++++ 9 files changed, 457 insertions(+), 32 deletions(-) create mode 100644 server/app/goauto/models/return_match_log.go diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index e3fdde8..93cfaec 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -80,6 +80,7 @@ func MigratedModels() []any { &models.PDDProductReplacementItem{}, &models.PDDProductReplacementWorkerLease{}, &models.ReturnMatch{}, + &models.ReturnMatchLog{}, } } diff --git a/server/app/goauto/models/return_match_log.go b/server/app/goauto/models/return_match_log.go new file mode 100644 index 0000000..4a99aad --- /dev/null +++ b/server/app/goauto/models/return_match_log.go @@ -0,0 +1,28 @@ +package models + +import "time" + +// ReturnMatchLog is #338's dedicated operation log for the return-matching +// feature. The framework's generic sys_opera_log records raw HTTP +// request/response per call and is not queryable per match id nor does it +// carry a stable action/operator/detail shape, so this table is written +// explicitly, in the same transaction as the action it records (match / +// confirm / cancel / remark). +const ( + ReturnMatchLogActionMatched = "matched" + ReturnMatchLogActionConfirmed = "confirmed" + ReturnMatchLogActionCancelled = "cancelled" + ReturnMatchLogActionRemark = "remark" +) + +type ReturnMatchLog struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + MatchID uint64 `json:"matchId" gorm:"not null;index"` + Action string `json:"action" gorm:"size:16;not null;index;check:ck_return_match_log_action,action IN ('matched','confirmed','cancelled','remark')"` + Operator string `json:"operator" gorm:"size:64;not null;default:''"` + Detail string `json:"detail" gorm:"size:1000;not null;default:''"` + + CreatedAt time.Time `json:"createdAt"` +} + +func (ReturnMatchLog) TableName() string { return "return_match_log" } diff --git a/server/app/goauto/returnmatch/candidate.go b/server/app/goauto/returnmatch/candidate.go index c360147..3cbe66c 100644 --- a/server/app/goauto/returnmatch/candidate.go +++ b/server/app/goauto/returnmatch/candidate.go @@ -70,7 +70,7 @@ func SelectMatches(products []SYBCandidate, returns []ReturnCandidate, now time. if !r.DestroyDeadline.After(now) { continue } - key := matchKey(r.ItemID, Normalize(r.VariationName)) + key := matchKey(r.ItemID, trimCommas(Normalize(r.VariationName))) buckets[key] = append(buckets[key], r) } for key := range buckets { @@ -84,7 +84,7 @@ func SelectMatches(products []SYBCandidate, returns []ReturnCandidate, now time. 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))) + key := matchKey(p.ShopeeItemID, trimCommas(Normalize(SYBSpecText(p.TargetColor, p.TargetSize)))) var picked *ReturnCandidate for i := range buckets[key] { cand := buckets[key][i] diff --git a/server/app/goauto/returnmatch/candidate_test.go b/server/app/goauto/returnmatch/candidate_test.go index 03f13a8..be7fce4 100644 --- a/server/app/goauto/returnmatch/candidate_test.go +++ b/server/app/goauto/returnmatch/candidate_test.go @@ -118,6 +118,26 @@ func TestSelectMatches_QuantityIgnored(t *testing.T) { } } +func TestSelectMatches_ColorOnlySYBSpecMatchesPlainYeekeText(t *testing.T) { + now := t1(0) + products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "黑色", TargetSize: "", CreatedAt: t1(-1)}} + returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "黑色", DestroyDeadline: t1(10)}} + out := SelectMatches(products, returns, now) + if !out[0].Matched || out[0].ReturnItemID != 900 { + t.Fatalf("color-only spec should still match: %+v", out[0]) + } +} + +func TestSelectMatches_SizeOnlySYBSpecMatchesPlainYeekeText(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(10)}} + out := SelectMatches(products, returns, now) + if !out[0].Matched || out[0].ReturnItemID != 900 { + t.Fatalf("size-only spec should still match: %+v", out[0]) + } +} + func TestSelectMatches_DifferentItemIDNeverMatches(t *testing.T) { now := t1(0) products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}} diff --git a/server/app/goauto/returnmatch/handler.go b/server/app/goauto/returnmatch/handler.go index e5d5759..8931ccb 100644 --- a/server/app/goauto/returnmatch/handler.go +++ b/server/app/goauto/returnmatch/handler.go @@ -116,7 +116,7 @@ func (h Handler) Detail(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"}) return } - match, err := NewService(db).Detail(c.Request.Context(), id) + detail, err := NewService(db).Detail(c.Request.Context(), id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"}) @@ -125,7 +125,7 @@ func (h Handler) Detail(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) return } - c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}}) + c.JSON(http.StatusOK, gin.H{"code": 200, "data": detail}) } func (h Handler) Confirm(c *gin.Context) { @@ -192,12 +192,17 @@ func (h Handler) Remark(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "请求体无效"}) return } - match, err := NewService(db).Remark(c.Request.Context(), id, body.Remark) + _, operator := operatorFromContext(c) + match, err := NewService(db).Remark(c.Request.Context(), id, operator, body.Remark) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"}) return } + if errors.Is(err, errRemarkTooLong) { + c.JSON(http.StatusBadRequest, gin.H{"code": "REMARK_TOO_LONG", "message": "备注不能超过 500 字"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) return } diff --git a/server/app/goauto/returnmatch/normalize.go b/server/app/goauto/returnmatch/normalize.go index daa8645..0343c9a 100644 --- a/server/app/goauto/returnmatch/normalize.go +++ b/server/app/goauto/returnmatch/normalize.go @@ -94,7 +94,26 @@ func isSpace(r rune) bool { } // SYBSpecText builds the SYB-side comparable spec text from the parser's -// target_color/target_size fields, per issue #338's data design. +// target_color/target_size fields, per issue #338's data design. Only +// non-empty parts are joined so a single-dimension spec (color-only or +// size-only) doesn't pick up a stray leading/trailing comma that would +// prevent it from normalizing equal to the yeeke side's single-value +// variation_name (e.g. SYBSpecText("黑色", "") must be "黑色", not "黑色,"). func SYBSpecText(targetColor, targetSize string) string { - return targetColor + "," + targetSize + parts := make([]string, 0, 2) + if targetColor != "" { + parts = append(parts, targetColor) + } + if targetSize != "" { + parts = append(parts, targetSize) + } + return strings.Join(parts, ",") +} + +// trimCommas removes leading/trailing commas left over after normalization +// (e.g. a source value that itself started or ended with a comma). Used only +// when building the matching key, so exact-text comparisons/tests elsewhere +// are unaffected. +func trimCommas(s string) string { + return strings.Trim(s, ",") } diff --git a/server/app/goauto/returnmatch/normalize_test.go b/server/app/goauto/returnmatch/normalize_test.go index e188d53..8a9a798 100644 --- a/server/app/goauto/returnmatch/normalize_test.go +++ b/server/app/goauto/returnmatch/normalize_test.go @@ -49,3 +49,30 @@ func TestSYBSpecText(t *testing.T) { t.Fatalf("got %q", got) } } + +func TestSYBSpecText_SingleDimension(t *testing.T) { + if got := SYBSpecText("黑色", ""); got != "黑色" { + t.Fatalf("color-only: got %q, want %q (no trailing comma)", got, "黑色") + } + if got := SYBSpecText("", "L"); got != "L" { + t.Fatalf("size-only: got %q, want %q (no leading comma)", got, "L") + } + if got := SYBSpecText("", ""); got != "" { + t.Fatalf("both empty: got %q, want empty", got) + } +} + +func TestTrimCommas(t *testing.T) { + cases := map[string]string{ + "黑色,": "黑色", + ",黑色": "黑色", + ",黑色,": "黑色", + "黑色": "黑色", + "": "", + } + for in, want := range cases { + if got := trimCommas(in); got != want { + t.Fatalf("trimCommas(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go index 4e7b298..271823d 100644 --- a/server/app/goauto/returnmatch/service.go +++ b/server/app/goauto/returnmatch/service.go @@ -3,8 +3,10 @@ package returnmatch import ( "context" "errors" + "fmt" "strings" "time" + "unicode/utf8" "go-admin/app/goauto/models" "go-admin/app/goauto/purchase" @@ -132,8 +134,19 @@ func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchM resp.SkippedCount++ continue } - match, insertErr := s.insertMatch(ctx, sybByID[id], outcome, req.Operator) + match, insertErr := s.matchOneWithLock(ctx, id, outcome, req.Operator) if insertErr != nil { + if errors.Is(insertErr, errStageNoLongerEligible) { + // #338 review fix: the stage was re-checked under the same + // FOR UPDATE lock purchase.create takes, right before insert. + // A purchase task could have been created for this row + // between the outer screening pass above and this point; + // when that happens the product is no longer in a + // participating stage and this row is skipped, not matched. + resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonStageIneligible, Reason: "处理阶段已变化,不再参与匹配"}) + resp.SkippedCount++ + continue + } if isUniqueConstraintErr(insertErr) { // Rule 7 / acceptance item 9: a concurrent insert lost the // race for either side — skip this row and report it, never @@ -188,23 +201,66 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e return out, nil } -func (s *Service) insertMatch(ctx context.Context, syb models.SYBProduct, outcome MatchOutcome, operator string) (models.ReturnMatch, error) { - sybID := syb.ID - returnID := outcome.ReturnItemID - now := s.Now() - match := models.ReturnMatch{ - SYBProductID: sybID, YeekeReturnItemID: returnID, - ActiveSYBProductID: &sybID, ActiveYeekeReturnItemID: &returnID, - Status: models.ReturnMatchStatusMatched, - SYBSpecText: SYBSpecText(syb.TargetColor, syb.TargetSize), - NormalizedKey: outcome.NormalizedKey, - MatchedBy: operator, MatchedAt: now, - DestroyDeadlineSnapshot: &outcome.DestroyDeadline, - } - if err := s.DB.WithContext(ctx).Create(&match).Error; err != nil { - return models.ReturnMatch{}, err - } - return match, nil +// errStageNoLongerEligible is returned by matchOneWithLock when the SYB +// product's process stage, re-checked under lock immediately before insert, +// is no longer in participatingStages — e.g. a purchase task was created for +// it between BatchMatch's outer screening pass and this point (#338 review +// fix: race between matching and purchase creation). +var errStageNoLongerEligible = errors.New("syb product stage no longer participates in matching") + +// matchOneWithLock takes the SAME row lock purchase.Service.create takes on +// syb_product (clause.Locking{Strength: "UPDATE"}) and re-computes the +// process stage inside that transaction via purchase.NewService(tx) before +// inserting the match, so a purchase task creation racing with this batch +// match can never both succeed: whichever gets the row lock first commits, +// and the other sees the now-current state (an active return_match row, or +// a task_created/order_review/purchase_succeeded stage) and is rejected/ +// skipped instead of double-committing an inconsistent state. +func (s *Service) matchOneWithLock(ctx context.Context, sybID uint64, outcome MatchOutcome, operator string) (models.ReturnMatch, error) { + var match models.ReturnMatch + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var syb models.SYBProduct + if err := tx.Clauses(clauseLockUpdate()).First(&syb, sybID).Error; err != nil { + return err + } + stages, err := purchase.NewService(tx).ProcessStages(ctx, []uint64{sybID}) + if err != nil { + return err + } + stage := stages[sybID] + if !participatingStages[stage.Stage] { + return errStageNoLongerEligible + } + var returnItem models.YeekeReturnItem + if err := tx.First(&returnItem, outcome.ReturnItemID).Error; err != nil { + return err + } + sybIDCopy := syb.ID + returnIDCopy := outcome.ReturnItemID + deadline := outcome.DestroyDeadline + now := s.Now() + match = models.ReturnMatch{ + SYBProductID: sybIDCopy, YeekeReturnItemID: returnIDCopy, + ActiveSYBProductID: &sybIDCopy, ActiveYeekeReturnItemID: &returnIDCopy, + Status: models.ReturnMatchStatusMatched, + SYBSpecText: SYBSpecText(syb.TargetColor, syb.TargetSize), + YeekeSpecText: returnItem.VariationName, + NormalizedKey: outcome.NormalizedKey, + PreviousProcessStage: stage.Stage, + MatchedBy: operator, + MatchedAt: now, + DestroyDeadlineSnapshot: &deadline, + } + if err := tx.Create(&match).Error; err != nil { + return err + } + logRow := models.ReturnMatchLog{ + MatchID: match.ID, Action: models.ReturnMatchLogActionMatched, Operator: operator, + Detail: fmt.Sprintf("匹配退货商品 #%d(%s),此前处理阶段:%s", returnIDCopy, returnItem.VariationName, stage.Label), + } + return tx.Create(&logRow).Error + }) + return match, err } func isUniqueConstraintErr(err error) bool { @@ -227,7 +283,10 @@ func (s *Service) Confirm(ctx context.Context, matchID uint64, operator string) match.Status = models.ReturnMatchStatusConfirmed match.ConfirmedBy = operator match.ConfirmedAt = &now - return tx.Save(&match).Error + if err := tx.Save(&match).Error; err != nil { + return err + } + return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionConfirmed, Operator: operator, Detail: "确认匹配,商品状态变为已用退货"}).Error }) return match, err } @@ -251,19 +310,35 @@ func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) ( match.ActiveYeekeReturnItemID = nil match.CancelledBy = operator match.CancelledAt = &now - return tx.Save(&match).Error + if err := tx.Save(&match).Error; err != nil { + return err + } + return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionCancelled, Operator: operator, Detail: "取消匹配,SYB 商品恢复可采购,退货商品回到可用池"}).Error }) return match, err } -func (s *Service) Remark(ctx context.Context, matchID uint64, remark string) (models.ReturnMatch, error) { +// errRemarkTooLong is returned instead of silently truncating; remark is a +// varchar(500) column and MySQL VARCHAR length is a character count, so this +// checks runes, not bytes. +var errRemarkTooLong = errors.New("remark exceeds 500 characters") + +const maxRemarkLength = 500 + +func (s *Service) Remark(ctx context.Context, matchID uint64, operator, remark string) (models.ReturnMatch, error) { + if utf8.RuneCountInString(remark) > maxRemarkLength { + return models.ReturnMatch{}, errRemarkTooLong + } var match models.ReturnMatch err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.First(&match, matchID).Error; err != nil { return err } match.Remark = remark - return tx.Save(&match).Error + if err := tx.Save(&match).Error; err != nil { + return err + } + return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionRemark, Operator: operator, Detail: remark}).Error }) return match, err } @@ -292,10 +367,92 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]models.ReturnM return rows, err } -func (s *Service) Detail(ctx context.Context, matchID uint64) (models.ReturnMatch, error) { +// SYBDetailView/YeekeDetailView/MatchDetail back the prototype's compare +// screen (screen 3): both sides' fields and images side by side, normalized +// spec text for both, and the operation log. +type SYBDetailView struct { + SYBProductID uint64 `json:"sybProductId"` + OrderCode string `json:"orderCode"` + ShopeeItemID string `json:"shopeeItemId"` + ShopName string `json:"shopName"` + ProductTitle string `json:"productTitle"` + TargetColor string `json:"targetColor"` + TargetSize string `json:"targetSize"` + Quantity int64 `json:"quantity"` + ImageURL string `json:"imageUrl,omitempty"` + CurrentStage string `json:"currentStage"` + CurrentStageLabel string `json:"currentStageLabel"` +} + +type YeekeDetailView struct { + YeekeReturnItemID uint64 `json:"yeekeReturnItemId"` + OrderSN string `json:"orderSn"` + ItemID string `json:"itemId"` + VariationID string `json:"variationId"` + ShopName string `json:"shopName"` + ItemName string `json:"itemName"` + VariationName string `json:"variationName"` + Quantity int64 `json:"quantity"` + Image string `json:"image,omitempty"` + DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"` +} + +type MatchDetail struct { + Match models.ReturnMatch `json:"match"` + SYB *SYBDetailView `json:"syb,omitempty"` + Yeeke *YeekeDetailView `json:"yeeke,omitempty"` + NormalizedSYBSpec string `json:"normalizedSybSpec"` + NormalizedYeekeSpec string `json:"normalizedYeekeSpec"` + Logs []models.ReturnMatchLog `json:"logs"` +} + +func (s *Service) Detail(ctx context.Context, matchID uint64) (MatchDetail, error) { var match models.ReturnMatch - err := s.DB.WithContext(ctx).First(&match, matchID).Error - return match, err + if err := s.DB.WithContext(ctx).First(&match, matchID).Error; err != nil { + return MatchDetail{}, err + } + detail := MatchDetail{Match: match} + + var syb models.SYBProduct + if err := s.DB.WithContext(ctx).First(&syb, match.SYBProductID).Error; err == nil { + stages, stageErr := purchase.NewService(s.DB).ProcessStages(ctx, []uint64{syb.ID}) + var stage purchase.ProcessStageResult + if stageErr == nil { + stage = stages[syb.ID] + } + detail.SYB = &SYBDetailView{ + SYBProductID: syb.ID, OrderCode: syb.OrderCode, ShopeeItemID: syb.ShopeeItemID, + ShopName: syb.ShopName, ProductTitle: syb.ProductTitle, + TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Quantity: syb.Quantity, + ImageURL: syb.ImageURL, CurrentStage: stage.Stage, CurrentStageLabel: stage.Label, + } + detail.NormalizedSYBSpec = trimCommas(Normalize(SYBSpecText(syb.TargetColor, syb.TargetSize))) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return detail, err + } + + var item models.YeekeReturnItem + if err := s.DB.WithContext(ctx).First(&item, match.YeekeReturnItemID).Error; err == nil { + var pkg models.YeekeReturnPackage + if pkgErr := s.DB.WithContext(ctx).First(&pkg, item.PackageID).Error; pkgErr != nil && !errors.Is(pkgErr, gorm.ErrRecordNotFound) { + return detail, pkgErr + } + detail.Yeeke = &YeekeDetailView{ + YeekeReturnItemID: item.ID, OrderSN: pkg.OrderSN, ItemID: item.ItemID, VariationID: item.VariationID, + ShopName: pkg.ShopName, ItemName: item.ItemName, VariationName: item.VariationName, + Quantity: item.Quantity, Image: item.Image, DestroyDeadline: pkg.DestroyDeadLine, + } + detail.NormalizedYeekeSpec = trimCommas(Normalize(item.VariationName)) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return detail, err + } + + var logs []models.ReturnMatchLog + if err := s.DB.WithContext(ctx).Where("match_id = ?", matchID).Order("id DESC").Find(&logs).Error; err != nil { + return detail, err + } + detail.Logs = logs + return detail, nil } var errStateConflict = errors.New("return match state conflict") diff --git a/server/app/goauto/returnmatch/service_test.go b/server/app/goauto/returnmatch/service_test.go index 7b2898e..d830fd5 100644 --- a/server/app/goauto/returnmatch/service_test.go +++ b/server/app/goauto/returnmatch/service_test.go @@ -2,6 +2,7 @@ package returnmatch import ( "context" + "errors" "fmt" "strings" "sync" @@ -227,6 +228,173 @@ func TestConfirmThenCancel_RestoresAvailability(t *testing.T) { } } +// TestBatchMatch_SkipsWhenStageRacesToTaskCreatedUnderLock is the #338 +// review fix-2 regression: a purchase task gets created for the SYB product +// (moving its stage to task_created, which does not participate) AFTER +// BatchMatch's outer screening pass already judged it eligible but BEFORE +// matchOneWithLock's own re-check runs. The row must be skipped with +// stage_ineligible, not matched. +func TestMatchOneWithLock_SkipsWhenStageNoLongerParticipatesUnderLock(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + ret := seedReturn(t, db, "白色,L", &deadline) + + // Simulate the exact race the review flagged: BatchMatch's outer + // screening pass already computed `outcome` while the product was still + // participating (pdd_unlinked). Before matchOneWithLock's own re-check + // runs (which takes the same row lock purchase.create takes and + // recomputes the stage inside that transaction), something else moves + // the product out of a participating stage — here simulated directly by + // flipping it to a parse-failed/manual_action state, same effect as a + // purchase task having been created for it in the meantime. + if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("parse_status", models.SYBParseStatusFailed).Error; err != nil { + t.Fatal(err) + } + outcome := MatchOutcome{SYBProductID: syb.ID, Matched: true, ReturnItemID: ret.ID, NormalizedKey: "100|白色,l"} + + _, err := s.matchOneWithLock(context.Background(), syb.ID, outcome, "tester") + if !errors.Is(err, errStageNoLongerEligible) { + t.Fatalf("expected errStageNoLongerEligible, got %v", err) + } + var count int64 + db.Model(&models.ReturnMatch{}).Where("syb_product_id = ?", syb.ID).Count(&count) + if count != 0 { + t.Fatalf("no return_match row should have been created when the stage race is caught: count=%d", count) + } +} + +func TestBatchMatch_WritesLogRowInSameTransaction(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"}) + if err != nil || resp.MatchedCount != 1 { + t.Fatalf("setup match failed: %v %+v", err, resp) + } + var logs []models.ReturnMatchLog + if err := db.Where("match_id = ?", resp.Items[0].MatchID).Find(&logs).Error; err != nil { + t.Fatal(err) + } + if len(logs) != 1 || logs[0].Action != models.ReturnMatchLogActionMatched || logs[0].Operator != "tester" { + t.Fatalf("expected one matched log row: %+v", logs) + } +} + +func TestConfirmCancelRemark_EachWritesOwnLogRow(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + seedReturn(t, db, "白色,L", &deadline) + resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "matcher"}) + matchID := resp.Items[0].MatchID + + if _, err := s.Remark(context.Background(), matchID, "reviewer", "看起来没问题"); err != nil { + t.Fatal(err) + } + if _, err := s.Confirm(context.Background(), matchID, "reviewer"); err != nil { + t.Fatal(err) + } + if _, err := s.Cancel(context.Background(), matchID, "reviewer2"); err != nil { + t.Fatal(err) + } + + var logs []models.ReturnMatchLog + if err := db.Where("match_id = ?", matchID).Order("id ASC").Find(&logs).Error; err != nil { + t.Fatal(err) + } + wantActions := []string{models.ReturnMatchLogActionMatched, models.ReturnMatchLogActionRemark, models.ReturnMatchLogActionConfirmed, models.ReturnMatchLogActionCancelled} + if len(logs) != len(wantActions) { + t.Fatalf("expected %d log rows, got %d: %+v", len(wantActions), len(logs), logs) + } + for i, action := range wantActions { + if logs[i].Action != action { + t.Fatalf("log[%d].Action = %q, want %q (%+v)", i, logs[i].Action, action, logs) + } + } + if logs[1].Detail != "看起来没问题" { + t.Fatalf("remark log must carry the remark text: %+v", logs[1]) + } +} + +func TestRemark_RejectsOverLongInputInsteadOfTruncating(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + seedReturn(t, db, "白色,L", &deadline) + resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}) + matchID := resp.Items[0].MatchID + + tooLong := strings.Repeat("字", 501) + if _, err := s.Remark(context.Background(), matchID, "op", tooLong); !errors.Is(err, errRemarkTooLong) { + t.Fatalf("expected errRemarkTooLong, got %v", err) + } + var match models.ReturnMatch + db.First(&match, matchID) + if match.Remark != "" { + t.Fatalf("rejected remark must not be saved (partially or fully): %q", match.Remark) + } + + exactly500 := strings.Repeat("字", 500) + if _, err := s.Remark(context.Background(), matchID, "op", exactly500); err != nil { + t.Fatalf("exactly 500 runes must be accepted: %v", err) + } +} + +func TestDetail_ReturnsBothSidesNormalizedSpecsAndLogs(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "matcher"}) + if err != nil || resp.MatchedCount != 1 { + t.Fatalf("setup failed: %v %+v", err, resp) + } + matchID := resp.Items[0].MatchID + if _, err := s.Remark(context.Background(), matchID, "op", "备注文字"); err != nil { + t.Fatal(err) + } + + detail, err := s.Detail(context.Background(), matchID) + if err != nil { + t.Fatal(err) + } + if detail.SYB == nil || detail.SYB.SYBProductID != syb.ID || detail.SYB.OrderCode != "SYB-1" { + t.Fatalf("missing/wrong SYB detail: %+v", detail.SYB) + } + if detail.Yeeke == nil || detail.Yeeke.YeekeReturnItemID != ret.ID || detail.Yeeke.OrderSN != "ORD1" { + t.Fatalf("missing/wrong yeeke detail: %+v", detail.Yeeke) + } + if detail.Yeeke.DestroyDeadline == nil || !detail.Yeeke.DestroyDeadline.Equal(deadline) { + t.Fatalf("expected destroy deadline to be carried through: %+v", detail.Yeeke) + } + if detail.NormalizedSYBSpec == "" || detail.NormalizedSYBSpec != detail.NormalizedYeekeSpec { + t.Fatalf("expected both sides to normalize equal: syb=%q yeeke=%q", detail.NormalizedSYBSpec, detail.NormalizedYeekeSpec) + } + if len(detail.Logs) != 2 { + t.Fatalf("expected 2 log rows (matched + remark), got %+v", detail.Logs) + } + if detail.Logs[0].Action != models.ReturnMatchLogActionRemark { + t.Fatalf("expected newest-first ordering (remark first): %+v", detail.Logs) + } + if detail.Match.YeekeSpecText == "" || detail.Match.PreviousProcessStage == "" { + t.Fatalf("insertMatch must fill YeekeSpecText and PreviousProcessStage: %+v", detail.Match) + } +} + // TestBatchMatch_ConcurrentInsertSkipsConflictRow exercises the DB unique // constraint directly: two goroutines racing to insert an active match for // the exact same SYB product must have exactly one winner, and the loser From 92c42ba1fe277a1ef344757c2c0d8c20bd2fb112 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 11:58:24 +0800 Subject: [PATCH 06/12] feat(yeeke): add match-status filter and occupying-SYB column to returns list (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListReturnPackages (app/goauto/yeeke/list.go) gains a single bounded LEFT JOIN onto return_match (active match only) and syb_product, plus a matchStatus query param (unmatched/matched/confirmed — invalid values are rejected, not ignored). ReturnItemRowDTO now carries matchId/matchStatus/occupyingSybProductId/occupyingSybOrderCode/ occupyingSybStage(+Label). The occupying SYB product's current stage label is filled from one extra bounded purchase.NewService(db). ProcessStages call over the page's distinct occupying SYB ids — not a per-row lookup — so the yeeke returns page's new 匹配状态 filter and 被占用 SYB 订单商品 column need no N+1 query. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/yeeke/list.go | 93 ++++++++++++++++- server/app/goauto/yeeke/list_match_test.go | 113 +++++++++++++++++++++ 2 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 server/app/goauto/yeeke/list_match_test.go diff --git a/server/app/goauto/yeeke/list.go b/server/app/goauto/yeeke/list.go index 08f559e..5070036 100644 --- a/server/app/goauto/yeeke/list.go +++ b/server/app/goauto/yeeke/list.go @@ -9,6 +9,7 @@ import ( "time" "go-admin/app/goauto/models" + "go-admin/app/goauto/purchase" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -76,8 +77,23 @@ type ReturnItemRowDTO struct { DestroyDeadLine *string `json:"destroyDeadLine"` LastSyncedAt string `json:"lastSyncedAt"` HasItem bool `json:"hasItem"` + + // #338: match status/占用信息, joined from return_match (active match + // only). MatchStatus is "unmatched" when there is no active match. + MatchID uint64 `json:"matchId,omitempty"` + MatchStatus string `json:"matchStatus"` + OccupyingSYBProductID uint64 `json:"occupyingSybProductId,omitempty"` + OccupyingSYBOrderCode string `json:"occupyingSybOrderCode,omitempty"` + OccupyingSYBStage string `json:"occupyingSybStage,omitempty"` + OccupyingSYBStageLabel string `json:"occupyingSybStageLabel,omitempty"` } +const ( + ReturnMatchFilterUnmatched = "unmatched" + ReturnMatchFilterMatched = "matched" + ReturnMatchFilterConfirmed = "confirmed" +) + func formatTimePtr(t *time.Time) *string { if t == nil { return nil @@ -126,6 +142,12 @@ type itemRow struct { ItemImage *string ItemQuantity *int64 ItemLastSynced *time.Time + + // #338 match columns, from the LEFT JOIN onto return_match/syb_product. + MatchID *uint64 `gorm:"column:match_id"` + MatchStatus *string `gorm:"column:match_status"` + OccupyingSYBProductID *uint64 `gorm:"column:occupying_syb_product_id"` + OccupyingSYBOrderCode *string `gorm:"column:occupying_syb_order_code"` } func toItemRowDTO(r itemRow) ReturnItemRowDTO { @@ -166,6 +188,24 @@ func toItemRowDTO(r itemRow) ReturnItemRowDTO { if r.ItemLastSynced != nil { dto.LastSyncedAt = r.ItemLastSynced.UTC().Format("2006-01-02T15:04:05Z") } + dto.MatchStatus = ReturnMatchFilterUnmatched + if r.MatchID != nil { + dto.MatchID = *r.MatchID + if r.MatchStatus != nil { + switch *r.MatchStatus { + case models.ReturnMatchStatusConfirmed: + dto.MatchStatus = ReturnMatchFilterConfirmed + default: + dto.MatchStatus = ReturnMatchFilterMatched + } + } + if r.OccupyingSYBProductID != nil { + dto.OccupyingSYBProductID = *r.OccupyingSYBProductID + } + if r.OccupyingSYBOrderCode != nil { + dto.OccupyingSYBOrderCode = *r.OccupyingSYBOrderCode + } + } return dto } @@ -190,7 +230,12 @@ func (h Handler) ListReturnPackages(c *gin.Context) { } base := db.Table("yeeke_return_package p"). - Joins("LEFT JOIN yeeke_return_item i ON i.package_id = p.id") + Joins("LEFT JOIN yeeke_return_item i ON i.package_id = p.id"). + // #338: single bounded LEFT JOIN onto the active match (if any) and + // the SYB product it occupies, so 匹配状态 filtering/column never + // needs a per-row lookup. + Joins("LEFT JOIN return_match m ON m.active_yeeke_return_item_id = i.id"). + Joins("LEFT JOIN syb_product sp ON sp.id = m.syb_product_id") if v := strings.TrimSpace(c.Query("orderSn")); v != "" { base = base.Where("p.order_sn LIKE ?", "%"+v+"%") } @@ -235,6 +280,19 @@ func (h Handler) ListReturnPackages(c *gin.Context) { if v := strings.TrimSpace(c.Query("variationId")); v != "" { base = base.Where("i.variation_id = ?", v) } + switch matchStatus := strings.TrimSpace(c.Query("matchStatus")); matchStatus { + case "": + // no filter + case ReturnMatchFilterUnmatched: + base = base.Where("m.id IS NULL") + case ReturnMatchFilterMatched: + base = base.Where("m.status = ?", models.ReturnMatchStatusMatched) + case ReturnMatchFilterConfirmed: + base = base.Where("m.status = ?", models.ReturnMatchStatusConfirmed) + default: + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "matchStatus 无效"}) + return + } var total int64 if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil { @@ -247,7 +305,8 @@ func (h Handler) ListReturnPackages(c *gin.Context) { "p.id as package_id, p.order_sn, p.shop_name, p.ware_house, p.ware_code, p.claim_status, " + "p.status_unrecognized, p.claim_time, p.destroy_dead_line as destroy_dead_line, p.last_synced_at as package_last_synced, " + "i.id as item_id, i.item_id as item_external_item, i.variation_id as item_variation_id, i.item_name as item_name, " + - "i.variation_name as item_variation_name, i.image as item_image, i.quantity as item_quantity, i.last_synced_at as item_last_synced", + "i.variation_name as item_variation_name, i.image as item_image, i.quantity as item_quantity, i.last_synced_at as item_last_synced, " + + "m.id as match_id, m.status as match_status, sp.id as occupying_syb_product_id, sp.order_code as occupying_syb_order_code", ).Order("p.create_time desc, i.id asc"). Offset((page - 1) * pageSize).Limit(pageSize) if err := q.Find(&rows).Error; err != nil { @@ -255,9 +314,37 @@ func (h Handler) ListReturnPackages(c *gin.Context) { return } + // #338: one bounded ProcessStages call for the distinct occupying SYB + // product ids on this page (never per-row), so the 「被占用 SYB 订单 + // 商品」 column can also show its current stage. + sybIDs := make([]uint64, 0, len(rows)) + seenSYB := make(map[uint64]bool, len(rows)) + for _, r := range rows { + if r.OccupyingSYBProductID != nil && !seenSYB[*r.OccupyingSYBProductID] { + seenSYB[*r.OccupyingSYBProductID] = true + sybIDs = append(sybIDs, *r.OccupyingSYBProductID) + } + } + var stages map[uint64]purchase.ProcessStageResult + if len(sybIDs) > 0 { + var stageErr error + stages, stageErr = purchase.NewService(db).ProcessStages(c.Request.Context(), sybIDs) + if stageErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + } + items := make([]ReturnItemRowDTO, 0, len(rows)) for _, r := range rows { - items = append(items, toItemRowDTO(r)) + dto := toItemRowDTO(r) + if r.OccupyingSYBProductID != nil { + if stage, ok := stages[*r.OccupyingSYBProductID]; ok { + dto.OccupyingSYBStage = stage.Stage + dto.OccupyingSYBStageLabel = stage.Label + } + } + items = append(items, dto) } c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{ diff --git a/server/app/goauto/yeeke/list_match_test.go b/server/app/goauto/yeeke/list_match_test.go new file mode 100644 index 0000000..1be7eee --- /dev/null +++ b/server/app/goauto/yeeke/list_match_test.go @@ -0,0 +1,113 @@ +package yeeke + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "go-admin/app/goauto/models" + + "github.com/gin-gonic/gin" +) + +// TestListReturnItems_MatchStatusFilterAndOccupyingColumn is the #338 web-UI +// support test: the yeeke returns list must be able to filter by match +// status and show which SYB order product occupies a matched return, using +// the single bounded LEFT JOIN added to ListReturnPackages (no N+1). +func TestListReturnItems_MatchStatusFilterAndOccupyingColumn(t *testing.T) { + gin.SetMode(gin.TestMode) + db := testDB(t) + now := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) + deadline := now.Add(30 * 24 * time.Hour) + + pkg := models.YeekeReturnPackage{ExternalID: "pkg-m1", OrderSN: "ORD-M1", TrackingNo: "T1", DestroyDeadLine: &deadline, LastSyncedAt: now, CreateTime: &now} + if err := db.Create(&pkg).Error; err != nil { + t.Fatal(err) + } + matchedItem := models.YeekeReturnItem{PackageID: pkg.ID, ExternalKey: "k-matched", ItemID: "100", VariationName: "白色,L", LastSyncedAt: now} + unmatchedItem := models.YeekeReturnItem{PackageID: pkg.ID, ExternalKey: "k-unmatched", ItemID: "100", VariationName: "黑色,M", LastSyncedAt: now} + if err := db.Create(&matchedItem).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&unmatchedItem).Error; err != nil { + t.Fatal(err) + } + + syb := models.SYBProduct{OrderCode: "SYB-OCC-1", DetailID: 1, StockID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", Quantity: 1, UnitPriceCent: 100, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}"} + if err := db.Create(&syb).Error; err != nil { + t.Fatal(err) + } + match := models.ReturnMatch{ + SYBProductID: syb.ID, YeekeReturnItemID: matchedItem.ID, + ActiveSYBProductID: &syb.ID, ActiveYeekeReturnItemID: &matchedItem.ID, + Status: models.ReturnMatchStatusMatched, MatchedAt: now, + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + + handler := Handler{DB: db} + engine := gin.New() + engine.GET("/api/admin/v1/yeeke-returns", handler.ListReturnPackages) + do := func(req *http.Request) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + return w + } + + // No filter: both rows present, matched row carries occupying info. + w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns", nil)) + var resp itemRowListResp + decodeJSON(t, w.Body.Bytes(), &resp) + if resp.Data.Total != 2 { + t.Fatalf("expected 2 rows, got %+v", resp.Data) + } + byKey := map[string]ReturnItemRowDTO{} + for _, item := range resp.Data.Items { + byKey[item.Key] = item + } + matchedDTO := byKey[strconv.FormatUint(matchedItem.ID, 10)] + if matchedDTO.MatchStatus != ReturnMatchFilterMatched { + t.Fatalf("expected matched status: %+v", matchedDTO) + } + if matchedDTO.OccupyingSYBOrderCode != "SYB-OCC-1" || matchedDTO.OccupyingSYBProductID != syb.ID { + t.Fatalf("expected occupying SYB info: %+v", matchedDTO) + } + if matchedDTO.OccupyingSYBStageLabel == "" { + t.Fatalf("expected occupying SYB stage label to be filled: %+v", matchedDTO) + } + unmatchedDTO := byKey[strconv.FormatUint(unmatchedItem.ID, 10)] + if unmatchedDTO.MatchStatus != ReturnMatchFilterUnmatched || unmatchedDTO.OccupyingSYBProductID != 0 { + t.Fatalf("expected unmatched status with no occupying info: %+v", unmatchedDTO) + } + + // matchStatus=unmatched filters down to just the unmatched row. + w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=unmatched", nil)) + decodeJSON(t, w.Body.Bytes(), &resp) + if resp.Data.Total != 1 || len(resp.Data.Items) != 1 || resp.Data.Items[0].Key != strconv.FormatUint(unmatchedItem.ID, 10) { + t.Fatalf("expected only the unmatched row: %+v", resp.Data) + } + + // matchStatus=matched filters down to just the matched row. + w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=matched", nil)) + decodeJSON(t, w.Body.Bytes(), &resp) + if resp.Data.Total != 1 || len(resp.Data.Items) != 1 || resp.Data.Items[0].Key != strconv.FormatUint(matchedItem.ID, 10) { + t.Fatalf("expected only the matched row: %+v", resp.Data) + } + + // Invalid value is rejected, not silently ignored. + w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=bogus", nil)) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid matchStatus, got %d", w.Code) + } +} + +func decodeJSON(t *testing.T, body []byte, v any) { + t.Helper() + if err := json.Unmarshal(body, v); err != nil { + t.Fatalf("decode response: %v (%s)", err, body) + } +} From f2c8307c17e5985b92c98a144181d46a2c3da6db Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 12:03:10 +0800 Subject: [PATCH 07/12] feat(web): return-matching UI on SYB products and yeeke returns pages (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SYB 订单商品页 (web/src/views/goauto/syb-products/index.vue): - 「匹配退货」按钮,作用于勾选行中处于参与匹配阶段的商品(与服务端 returnmatch.participatingStages 同一口径),canPurchase 门禁,忙碌时 loading,无候选时禁用。 - 批量结果对话框:成功匹配/无候选/阶段不参与跳过/并发冲突跳过四类计数 + 逐行原因表格。 - 处理阶段筛选与展示新增「退货待确认」「已用退货」两个选项/标签(复用 sybimport 现有的、基于 purchase.ProcessStages 的阶段筛选机制,无需 额外服务端改动——process_stage.go 上一次提交已让这两个阶段参与其中)。 - 新增「匹配到的退货商品」列(缩略图/退货订单号/规格/销毁截止),逐行 「查看对比」「备注」「取消匹配」(后两者仅 canPurchase 可见)。 - 已有效匹配的行不可勾选(isReturnMatchCandidate 排除 return_pending/ return_used 两个阶段)。 - 匹配详情对话框:SYB/yeeke 双方字段与归一化规格文字并排对比、备注 编辑、操作日志列表、确认(仅 matched 状态可用)/取消匹配(matched 或 confirmed 可用)按钮,取消前均有二次确认。 退货商品页 (web/src/views/goauto/yeeke-returns/index.vue): - 新增「匹配状态」筛选(全部/未匹配/退货待确认/已用退货),对所有用户 可见(纯只读筛选,不需要 canPurchase)。 - 新增「被占用 SYB 订单商品」列(订单号 + 当前阶段,点击跳转 SYB 商品 页)。 New file web/src/api/goauto/return-matches.js: listReturnMatches/ getReturnMatch/batchMatchReturns/confirmReturnMatch/cancelReturnMatch/ remarkReturnMatch. Backend support for the match column: returnmatch.Service.List now returns ListItem (match + orderSn/image/variationName/destroyDeadline) via one bounded join over the page's yeeke_return_item ids, so the SYB product page's match column never needs a second request per row (service.go, service_test.go: TestList_EnrichesWithYeekeReturnDisplayFields). 采购创建拦截的 UI 呈现沿用现有机制:ProcessStageReturnPending/ ProcessStageReturnUsed 让 isPurchaseCandidate(row) 天然为 false(与其余 非 purchase_ready 阶段的既有行为一致),「采购」按钮不显示,处理阶段列 展示服务端返回的拦截原因文案;未额外改动采购创建流程/UI。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/returnmatch/service.go | 66 +++++- server/app/goauto/returnmatch/service_test.go | 22 ++ web/src/api/goauto/return-matches.js | 25 ++ web/src/views/goauto/syb-products/index.vue | 220 +++++++++++++++++- web/src/views/goauto/yeeke-returns/index.vue | 30 ++- 5 files changed, 353 insertions(+), 10 deletions(-) create mode 100644 web/src/api/goauto/return-matches.js diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go index 271823d..ecbc906 100644 --- a/server/app/goauto/returnmatch/service.go +++ b/server/app/goauto/returnmatch/service.go @@ -351,7 +351,18 @@ type ListFilter struct { Status string } -func (s *Service) List(ctx context.Context, filter ListFilter) ([]models.ReturnMatch, error) { +// ListItem is one row of List's result: the match itself plus a few display +// fields the SYB product page's match column needs (thumbnail, return order +// sn, spec) so it never has to issue a second request per row. +type ListItem struct { + models.ReturnMatch + OrderSN string `json:"orderSn,omitempty"` + Image string `json:"image,omitempty"` + VariationName string `json:"variationName,omitempty"` + DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"` +} + +func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, error) { q := s.DB.WithContext(ctx).Model(&models.ReturnMatch{}) if len(filter.SYBProductIDs) > 0 { q = q.Where("syb_product_id IN ?", filter.SYBProductIDs) @@ -363,8 +374,57 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]models.ReturnM q = q.Where("status = ?", filter.Status) } var rows []models.ReturnMatch - err := q.Order("id DESC").Find(&rows).Error - return rows, err + if err := q.Order("id DESC").Find(&rows).Error; err != nil { + return nil, err + } + items := make([]ListItem, len(rows)) + returnIDs := make([]uint64, 0, len(rows)) + for i, r := range rows { + items[i] = ListItem{ReturnMatch: r} + returnIDs = append(returnIDs, r.YeekeReturnItemID) + } + if len(returnIDs) == 0 { + return items, nil + } + // One bounded join query for every return item referenced on this page — + // never a per-row lookup. + var joined []struct { + ID uint64 + OrderSN string + Image string + VariationName string + DestroyDeadLine *time.Time + } + if err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i"). + Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line"). + Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id"). + Where("i.id IN ?", returnIDs). + Find(&joined).Error; err != nil { + return nil, err + } + byID := make(map[uint64]struct { + OrderSN string + Image string + VariationName string + DestroyDeadLine *time.Time + }, len(joined)) + for _, j := range joined { + byID[j.ID] = struct { + OrderSN string + Image string + VariationName string + DestroyDeadLine *time.Time + }{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine} + } + for i := range items { + if extra, ok := byID[items[i].YeekeReturnItemID]; ok { + items[i].OrderSN = extra.OrderSN + items[i].Image = extra.Image + items[i].VariationName = extra.VariationName + items[i].DestroyDeadline = extra.DestroyDeadLine + } + } + return items, nil } // SYBDetailView/YeekeDetailView/MatchDetail back the prototype's compare diff --git a/server/app/goauto/returnmatch/service_test.go b/server/app/goauto/returnmatch/service_test.go index d830fd5..00de4ca 100644 --- a/server/app/goauto/returnmatch/service_test.go +++ b/server/app/goauto/returnmatch/service_test.go @@ -436,3 +436,25 @@ func TestBatchMatch_ConcurrentInsertSkipsConflictRow(t *testing.T) { t.Fatalf("expected exactly one winner and one unique-constraint conflict, got success=%d conflict=%d results=%v", successCount, conflictCount, results) } } + +func TestList_EnrichesWithYeekeReturnDisplayFields(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}) + if err != nil || resp.MatchedCount != 1 { + t.Fatalf("setup failed: %v %+v", err, resp) + } + + items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}}) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].OrderSN != "ORD1" || items[0].DestroyDeadline == nil { + t.Fatalf("expected enriched list item: %+v", items) + } +} diff --git a/web/src/api/goauto/return-matches.js b/web/src/api/goauto/return-matches.js new file mode 100644 index 0000000..792b9dc --- /dev/null +++ b/web/src/api/goauto/return-matches.js @@ -0,0 +1,25 @@ +import request from '@/utils/request' + +export function listReturnMatches(params, options = {}) { + return request({ url: '/api/admin/v1/return-matches', method: 'get', params, ...options }) +} + +export function getReturnMatch(matchId, options = {}) { + return request({ url: `/api/admin/v1/return-matches/${matchId}`, method: 'get', ...options }) +} + +export function batchMatchReturns(data) { + return request({ url: '/api/admin/v1/return-matches/batch-match', method: 'post', data }) +} + +export function confirmReturnMatch(matchId) { + return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post' }) +} + +export function cancelReturnMatch(matchId) { + return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post' }) +} + +export function remarkReturnMatch(matchId, data) { + return request({ url: `/api/admin/v1/return-matches/${matchId}/remark`, method: 'post', data }) +} diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index a05daf9..af537fa 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -12,7 +12,7 @@ - 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }} + 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }} @@ -51,6 +51,26 @@
{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}
+ + +
@@ -162,6 +182,80 @@ + + +
+ 成功匹配 {{ returnMatchBatchResult.matchedCount }} 条 + 无候选 {{ returnMatchBatchResult.noCandidateCount }} 条 + 阶段不参与跳过 {{ returnMatchBatchResult.stageIneligibleCount }} 条 + 并发冲突跳过 {{ returnMatchBatchResult.conflictCount }} 条 +
+ + + + + + + +
+ + + +
+ +
+ +
+
@@ -227,6 +321,7 @@ import { listDevices } from '@/api/goauto/devices' import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks' import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } from '@/api/goauto/collection-tasks' import { listCollectionRules } from '@/api/goauto/collection-rules' +import { listReturnMatches, batchMatchReturns, getReturnMatch, confirmReturnMatch, cancelReturnMatch, remarkReturnMatch } from '@/api/goauto/return-matches' import { createRequestId } from '@/utils/request-id' import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference' import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue' @@ -255,6 +350,11 @@ export default { collectionBatchData: { ruleId: null, deviceId: null }, collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] }, query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }, + returnMatchByProductId: {}, + returnMatchLoading: false, + returnMatchBatchLoading: false, + returnMatchBatchResult: { open: false, items: [], matchedCount: 0, noCandidateCount: 0, stageIneligibleCount: 0, conflictCount: 0 }, + matchDetail: { open: false, loading: false, saving: false, id: null, data: null, remarkText: '' }, detail: { open: false, loading: false, item: null }, correct: { open: false, saving: false, productId: null, targetColor: '', targetSize: '' }, shopeeDetail: { open: false, productId: null, targetColor: '', action: '' }, @@ -265,7 +365,8 @@ export default { purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) }, purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length }, canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') }, - processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }] }, + processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] }, + returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) }, aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) }, aiMatchButtonReason() { if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格' @@ -313,6 +414,7 @@ export default { this.total = r.data.total this.loading = false void this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation) + void this.loadReturnMatches(this.products.map(item => item.id), generation) } catch (error) { if (generation !== this.loadGeneration) return if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) { @@ -357,13 +459,19 @@ export default { // 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品 // 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。 isImageSearchCandidate(row) { return Boolean(row.shopeeProductId && row.imageUrl) }, + // #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。 + isReturnMatchCandidate(row) { + const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready']) + return participating.has(this.purchaseReady(row).processStage) + }, + isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' }, // `[必须]` 可勾选不等于可采购。放宽的只有勾选:purchaseCandidates / collectionCandidates - // / aiMatchCandidates 仍各自用自己的判据过滤,采购门禁不受影响。 - isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) }, + // / aiMatchCandidates / returnMatch 仍各自用自己的判据过滤,采购门禁不受影响。 + isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) }, rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) }, purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } }, purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` }, - processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }}[stage] || { label: '待人工处理', type: 'warning' } }, + processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } }, purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' }, async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) { if (generation !== this.loadGeneration) return @@ -428,6 +536,107 @@ export default { }, formatConfidence(value) { return Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—' }, + // ---------------- 退货匹配 (#338) ---------------- + formatMatchDeadline(value) { return value ? new Date(value).toLocaleString() : '—' }, + matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status }, + async loadReturnMatches(ids, generation = this.loadGeneration) { + this.returnMatchByProductId = {} + if (!ids.length) return + this.returnMatchLoading = true + try { + const r = await listReturnMatches({ sybProductId: ids }, { suppressErrorMessage: true }) + if (generation !== this.loadGeneration) return + const map = {} + for (const item of (r.data?.items || [])) { + if (item.status === 'cancelled') continue + if (!map[item.sybProductId]) map[item.sybProductId] = item + } + this.returnMatchByProductId = map + } catch { + // 展示失败不影响主列表;用户可重新查询重试。 + } finally { + if (generation === this.loadGeneration) this.returnMatchLoading = false + } + }, + matchReasonMeta(code) { + return { + stage_ineligible: '阶段不参与', + no_candidate: '无候选', + conflict: '并发冲突' + }[code] || code + }, + async runBatchMatchReturns() { + const ids = this.returnMatchCandidateIds + if (!ids.length || this.returnMatchBatchLoading) return + this.returnMatchBatchLoading = true + try { + const r = await batchMatchReturns({ sybProductIds: ids }) + const items = r.data.items || [] + this.returnMatchBatchResult = { + open: true, items, + matchedCount: r.data.matchedCount || 0, + noCandidateCount: items.filter(i => i.reasonCode === 'no_candidate').length, + stageIneligibleCount: items.filter(i => i.reasonCode === 'stage_ineligible').length, + conflictCount: items.filter(i => i.reasonCode === 'conflict').length + } + await this.load() + } finally { + this.returnMatchBatchLoading = false + } + }, + async openMatchDetail(matchId, focusRemark = false) { + this.matchDetail = { open: true, loading: true, saving: false, id: matchId, data: null, remarkText: '' } + try { + const r = await getReturnMatch(matchId) + this.matchDetail.data = r.data + this.matchDetail.remarkText = r.data.match.remark || '' + } finally { + this.matchDetail.loading = false + } + if (focusRemark) this.$nextTick(() => this.$refs.matchRemarkInput?.focus()) + }, + async refreshMatchDetail() { + const r = await getReturnMatch(this.matchDetail.id) + this.matchDetail.data = r.data + this.matchDetail.remarkText = r.data.match.remark || '' + await this.load() + }, + async saveMatchRemark() { + this.matchDetail.saving = true + try { + await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText }) + ElMessage.success('已保存备注') + await this.refreshMatchDetail() + } finally { + this.matchDetail.saving = false + } + }, + async confirmMatchDetail() { + try { + await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' }) + } catch { return } + await confirmReturnMatch(this.matchDetail.id) + ElMessage.success('已确认匹配') + await this.refreshMatchDetail() + }, + async cancelMatchDetail() { + try { + await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' }) + } catch { return } + await cancelReturnMatch(this.matchDetail.id) + ElMessage.success('已取消匹配') + this.matchDetail.open = false + await this.load() + }, + async quickCancelMatch(matchId) { + try { + await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' }) + } catch { return } + await cancelReturnMatch(matchId) + ElMessage.success('已取消匹配') + await this.load() + }, + // ---------------- 批量创建采购任务 ---------------- async openSinglePurchase(row) { this.$refs.productTable?.clearSelection() @@ -625,6 +834,7 @@ export default { .missing{color:#b91c1c} .quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0} .thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px} +.return-match-cell{display:flex;gap:8px;align-items:flex-start} .link{color:#1677ff;cursor:pointer} .shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .notice{margin-bottom:16px} diff --git a/web/src/views/goauto/yeeke-returns/index.vue b/web/src/views/goauto/yeeke-returns/index.vue index 5edf6d8..bb6cb4d 100644 --- a/web/src/views/goauto/yeeke-returns/index.vue +++ b/web/src/views/goauto/yeeke-returns/index.vue @@ -22,6 +22,11 @@ + + + + + 查询重置 @@ -64,6 +69,18 @@ + + + + + + @@ -139,8 +156,15 @@ export default { loading: false, loadError: '', items: [], total: 0, createTimeRange: null, summary: { lastSuccessAt: '', lastRun: null }, summaryError: '', shopOptions: [], - query: { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' }, + query: { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '', matchStatus: '' }, detail: { open: false, loading: false, placeholder: false, item: null, pkg: null, siblings: [] }, + // #338: 匹配状态是只读信息筛选,未勾选/无写操作,按钮门禁规则里 + // 「纯只读筛选可以照常展示」的部分——不需要 canPurchase 判断。 + matchStatusOptions: [ + { label: '未匹配', value: 'unmatched' }, + { label: '退货待确认', value: 'matched' }, + { label: '已用退货', value: 'confirmed' } + ], // yeeke's only confirmed claim-status value is the raw string "1" // (HAR evidence, #336 requirement doc). Any other value the sync sees // is preserved verbatim and flagged status_unrecognized — never @@ -159,6 +183,8 @@ export default { // unmapped value we have not seen yet — show it as-is rather than a // bare digit or a guessed Chinese label. claimStatusMeta(status) { return status === '1' ? { label: '已认领', type: 'success' } : { label: status ? `其他:${status}` : '—', type: 'info' } }, + matchStatusMeta(status) { return { unmatched: { label: '未匹配', type: 'info' }, matched: { label: '退货待确认', type: 'warning' }, confirmed: { label: '已用退货', type: 'success' }}[status] || { label: status || '—', type: 'info' } }, + openSybProduct(sybProductId) { this.$router.push({ path: '/syb-products/index', query: { sybProductId } }) }, statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } }, formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) }, async load() { @@ -202,7 +228,7 @@ export default { }, reset() { this.createTimeRange = null - this.query = { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' } + this.query = { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '', matchStatus: '' } this.load() }, async openDetail(row) { From 20a8144be0f39f47d3ba91732ec69e5f31ce20a6 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 14:03:06 +0800 Subject: [PATCH 08/12] fix(db): add migration version that creates return_match tables (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering the models in MigratedModels alone does nothing on databases whose earlier versions are already recorded in sys_migration; the migrate command's post-check rejected the run with "迁移后仍缺少表". Add the 1789800700000 version so existing databases create return_match and return_match_log. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- .../1789800700000_return_match.go | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 server/cmd/migrate/migration/version-local/1789800700000_return_match.go diff --git a/server/cmd/migrate/migration/version-local/1789800700000_return_match.go b/server/cmd/migrate/migration/version-local/1789800700000_return_match.go new file mode 100644 index 0000000..3a092f4 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789800700000_return_match.go @@ -0,0 +1,25 @@ +package version_local + +import ( + "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +// #338: creates return_match and return_match_log (registered in +// migrations.MigratedModels) on databases whose earlier versions are already +// recorded in sys_migration. +func init() { + _, f, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMatch) +} +func migrateReturnMatch(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := migrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} From 1e944b9d2eee0622c6f39a5e1ee484dab0281fe2 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 14:22:06 +0800 Subject: [PATCH 09/12] feat(returnmatch): record every batch-match submission and its outcomes (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each click of 「匹配退货」 now writes a return_match_batch row: operator, time, submitted/matched/skipped counts and the per-SYB-product result (in submission order), plus the error if the batch aborted. Recording happens after the per-row transactions commit, so a logging failure is logged server-side instead of failing an already-applied match. GET /return-matches/batches and /return-matches/batches/:batchId expose the records. Migration version 1789800800000 creates the table. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/migrations/migrate.go | 1 + .../app/goauto/models/return_match_batch.go | 25 ++++++ .../goauto/returnmatch/batch_record_test.go | 80 +++++++++++++++++ server/app/goauto/returnmatch/handler.go | 39 ++++++++ server/app/goauto/returnmatch/router.go | 3 + server/app/goauto/returnmatch/service.go | 89 +++++++++++++++++++ .../1789800800000_return_match_batch.go | 25 ++++++ 7 files changed, 262 insertions(+) create mode 100644 server/app/goauto/models/return_match_batch.go create mode 100644 server/app/goauto/returnmatch/batch_record_test.go create mode 100644 server/cmd/migrate/migration/version-local/1789800800000_return_match_batch.go diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 93cfaec..443a094 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -81,6 +81,7 @@ func MigratedModels() []any { &models.PDDProductReplacementWorkerLease{}, &models.ReturnMatch{}, &models.ReturnMatchLog{}, + &models.ReturnMatchBatch{}, } } diff --git a/server/app/goauto/models/return_match_batch.go b/server/app/goauto/models/return_match_batch.go new file mode 100644 index 0000000..e0ea492 --- /dev/null +++ b/server/app/goauto/models/return_match_batch.go @@ -0,0 +1,25 @@ +package models + +import "time" + +// ReturnMatchBatch records one click of 「匹配退货」 (#338): who submitted it, +// which SYB products were submitted and the per-product outcome, so a later +// question like "why did only N of my selection match" can be answered from +// data instead of reconstructing the page selection. ItemsJSON holds the +// []BatchMatchItem result array (sybProductId/matched/reasonCode/reason/ +// matchId) in submission order. +type ReturnMatchBatch struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Operator string `json:"operator" gorm:"size:64;not null;default:'';index"` + SubmittedCount int `json:"submittedCount" gorm:"not null;default:0"` + MatchedCount int `json:"matchedCount" gorm:"not null;default:0"` + SkippedCount int `json:"skippedCount" gorm:"not null;default:0"` + ItemsJSON string `json:"-" gorm:"type:longtext;not null"` + // Error is non-empty when the batch aborted part-way; ItemsJSON then + // holds only the rows processed before the failure. + Error string `json:"error" gorm:"size:500;not null;default:''"` + + CreatedAt time.Time `json:"createdAt" gorm:"index"` +} + +func (ReturnMatchBatch) TableName() string { return "return_match_batch" } diff --git a/server/app/goauto/returnmatch/batch_record_test.go b/server/app/goauto/returnmatch/batch_record_test.go new file mode 100644 index 0000000..72cafc8 --- /dev/null +++ b/server/app/goauto/returnmatch/batch_record_test.go @@ -0,0 +1,80 @@ +package returnmatch + +import ( + "context" + "testing" + "time" + + "go-admin/app/goauto/models" +) + +// #338: every 「匹配退货」 click is recorded with the submitted SYB products in +// order and each one's outcome, so "why did only some of my selection match" +// can be answered from data. +func TestBatchMatch_RecordsSubmittedProductsAndOutcomes(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + hit := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC)) + miss := seedSYB(t, db, "SYB-2", 2, "黑色", "XL", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + seedReturn(t, db, "白色,L", &deadline) + missingID := uint64(999999) + + submitted := []uint64{miss.ID, hit.ID, missingID} + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: submitted, Operator: "caigou1"}) + if err != nil { + t.Fatal(err) + } + + batches, err := s.ListBatches(context.Background(), 10, true) + if err != nil { + t.Fatal(err) + } + if len(batches) != 1 { + t.Fatalf("want exactly one batch record, got %d", len(batches)) + } + b := batches[0] + if b.Operator != "caigou1" || b.SubmittedCount != 3 || b.MatchedCount != 1 || b.SkippedCount != 2 || b.Error != "" { + t.Fatalf("unexpected batch header: %+v", b.ReturnMatchBatch) + } + if len(b.Items) != len(submitted) { + t.Fatalf("want %d items, got %+v", len(submitted), b.Items) + } + for i, id := range submitted { + if b.Items[i].SYBProductID != id { + t.Fatalf("item %d: want submitted order id %d, got %d", i, id, b.Items[i].SYBProductID) + } + } + if b.Items[0].ReasonCode != ReasonNoCandidate || !b.Items[1].Matched || b.Items[1].MatchID != resp.Items[1].MatchID || b.Items[2].ReasonCode != ReasonStageIneligible { + t.Fatalf("unexpected recorded outcomes: %+v", b.Items) + } + + got, err := s.GetBatch(context.Background(), b.ID) + if err != nil { + t.Fatal(err) + } + if len(got.Items) != 3 { + t.Fatalf("GetBatch items: %+v", got.Items) + } + + // The list without items stays small. + light, err := s.ListBatches(context.Background(), 10, false) + if err != nil || len(light) != 1 || light[0].Items != nil { + t.Fatalf("list without items: %+v err=%v", light, err) + } +} + +// An empty submission is rejected before matching and leaves no record. +func TestBatchMatch_EmptySubmissionNotRecorded(t *testing.T) { + db := testDB(t) + if _, err := NewService(db).BatchMatch(context.Background(), BatchMatchRequest{Operator: "x"}); err != nil { + t.Fatal(err) + } + var n int64 + db.Model(&models.ReturnMatchBatch{}).Count(&n) + if n != 0 { + t.Fatalf("empty submission recorded %d batches", n) + } +} diff --git a/server/app/goauto/returnmatch/handler.go b/server/app/goauto/returnmatch/handler.go index 8931ccb..1968c7e 100644 --- a/server/app/goauto/returnmatch/handler.go +++ b/server/app/goauto/returnmatch/handler.go @@ -218,3 +218,42 @@ func parseUint64List(raw []string) []uint64 { } return out } + +// ListBatches returns recent 「匹配退货」 batch records (who, when, submitted +// and matched counts); pass withItems=1 to include each row's outcome. +func (h Handler) ListBatches(c *gin.Context) { + db, ok := h.db(c) + if !ok { + return + } + limit, _ := strconv.Atoi(c.Query("limit")) + rows, err := NewService(db).ListBatches(c.Request.Context(), limit, c.Query("withItems") == "1") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"items": rows}}) +} + +// GetBatch returns one batch record with every submitted SYB product's outcome. +func (h Handler) GetBatch(c *gin.Context) { + db, ok := h.db(c) + if !ok { + return + } + id, err := strconv.ParseUint(c.Param("batchId"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "batchId 无效"}) + return + } + rec, err := NewService(db).GetBatch(c.Request.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "批量匹配记录不存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": rec}) +} diff --git a/server/app/goauto/returnmatch/router.go b/server/app/goauto/returnmatch/router.go index 5f84255..899fc72 100644 --- a/server/app/goauto/returnmatch/router.go +++ b/server/app/goauto/returnmatch/router.go @@ -13,6 +13,9 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { handler := Handler{} group := engine.Group("/api/admin/v1/return-matches").Use(auth.MiddlewareFunc()) group.GET("", handler.List) + // Static batch routes are registered before /:id so they never parse as a match id. + group.GET("/batches", handler.ListBatches) + group.GET("/batches/:batchId", handler.GetBatch) group.GET("/:id", handler.Detail) group.POST("/batch-match", handler.BatchMatch) group.POST("/:id/confirm", handler.Confirm) diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go index ecbc906..d12a1be 100644 --- a/server/app/goauto/returnmatch/service.go +++ b/server/app/goauto/returnmatch/service.go @@ -2,8 +2,10 @@ package returnmatch import ( "context" + "encoding/json" "errors" "fmt" + "log" "strings" "time" "unicode/utf8" @@ -70,6 +72,93 @@ const ( // ever called from the batch-match button (ticked rows) — no scheduler, no // yeeke-sync/SYB-import hook calls this (rule: 手动触发, 无定时任务). func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) { + resp, err := s.batchMatch(ctx, req) + if len(req.SYBProductIDs) > 0 { + // The batch record is written after the per-row transactions have + // committed, so a failure here must not turn an already-applied + // match into an error response; it is logged instead. + if logErr := s.recordBatch(ctx, req, resp, err); logErr != nil { + log.Printf("return match batch record failed: operator=%s submitted=%d: %v", req.Operator, len(req.SYBProductIDs), logErr) + } + } + return resp, err +} + +// recordBatch stores one ReturnMatchBatch row describing this click of +// 「匹配退货」: submitted SYB products in order and each one's outcome. +func (s *Service) recordBatch(ctx context.Context, req BatchMatchRequest, resp BatchMatchResponse, batchErr error) error { + items := resp.Items + if items == nil { + items = []BatchMatchItem{} + } + raw, err := json.Marshal(items) + if err != nil { + return err + } + row := models.ReturnMatchBatch{ + Operator: req.Operator, + SubmittedCount: len(req.SYBProductIDs), + MatchedCount: resp.MatchedCount, + SkippedCount: resp.SkippedCount, + ItemsJSON: string(raw), + } + if batchErr != nil { + row.Error = truncateRunes(batchErr.Error(), 500) + } + return s.DB.WithContext(ctx).Create(&row).Error +} + +func truncateRunes(v string, max int) string { + r := []rune(v) + if len(r) <= max { + return v + } + return string(r[:max]) +} + +// BatchRecord is the API view of one ReturnMatchBatch row. +type BatchRecord struct { + models.ReturnMatchBatch + Items []BatchMatchItem `json:"items,omitempty"` +} + +// ListBatches returns the most recent batch records, newest first; items are +// only expanded when withItems is true to keep the list response small. +func (s *Service) ListBatches(ctx context.Context, limit int, withItems bool) ([]BatchRecord, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + var rows []models.ReturnMatchBatch + if err := s.DB.WithContext(ctx).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + out := make([]BatchRecord, 0, len(rows)) + for _, row := range rows { + rec := BatchRecord{ReturnMatchBatch: row} + if withItems { + if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil { + return nil, err + } + } + out = append(out, rec) + } + return out, nil +} + +// GetBatch returns one batch record with its items expanded. +func (s *Service) GetBatch(ctx context.Context, id uint64) (BatchRecord, error) { + var row models.ReturnMatchBatch + if err := s.DB.WithContext(ctx).First(&row, id).Error; err != nil { + return BatchRecord{}, err + } + rec := BatchRecord{ReturnMatchBatch: row} + if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil { + return BatchRecord{}, err + } + return rec, nil +} + +func (s *Service) batchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) { resp := BatchMatchResponse{Items: make([]BatchMatchItem, 0, len(req.SYBProductIDs))} if len(req.SYBProductIDs) == 0 { return resp, nil diff --git a/server/cmd/migrate/migration/version-local/1789800800000_return_match_batch.go b/server/cmd/migrate/migration/version-local/1789800800000_return_match_batch.go new file mode 100644 index 0000000..91f3824 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789800800000_return_match_batch.go @@ -0,0 +1,25 @@ +package version_local + +import ( + "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +// #338: creates return_match_batch (registered in +// migrations.MigratedModels) on databases whose earlier versions are already +// recorded in sys_migration. +func init() { + _, f, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMatchBatch) +} +func migrateReturnMatchBatch(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := migrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} From ff6e87649cabbec1ea05568a0ef1ff5a5aace380 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 15:57:38 +0800 Subject: [PATCH 10/12] feat(returnmatch): mark yeeke returns unavailable when a complete sync drops them (#338) A COMPLETE yeeke sync run (natural page-walk end, not stopped by a duplicate-fingerprint break or MaxPages exhaustion) that also wrote zero record failures now flips any yeeke_return_package/yeeke_return_item still "ok" from before the run to sync_status="missing" with missing_since stamped, in one transaction. Rows are never deleted. A 20% safety valve skips marking (and records why in error_message) when the candidate count would be too large; a reappearing record recovers to "ok" with missing_since cleared. returnmatch.availableReturnPool now excludes missing items/packages from matching, without auto-cancelling any existing active match; List()/Detail() surface syncStatus/missingSince so the SYB products match column, its compare dialog, and the yeeke returns list can warn or label rows as unavailable. Adds migration 1789800900000_return_missing (AutoMigrate alone does not reach existing databases). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/models/yeeke.go | 22 +- server/app/goauto/returnmatch/missing_test.go | 158 +++++++ server/app/goauto/returnmatch/service.go | 61 ++- server/app/goauto/yeeke/list.go | 46 ++- server/app/goauto/yeeke/sync.go | 88 +++- server/app/goauto/yeeke/sync_missing_test.go | 391 ++++++++++++++++++ .../1789800900000_return_missing.go | 28 ++ web/src/views/goauto/syb-products/index.vue | 3 + web/src/views/goauto/yeeke-returns/index.vue | 6 + 9 files changed, 773 insertions(+), 30 deletions(-) create mode 100644 server/app/goauto/returnmatch/missing_test.go create mode 100644 server/app/goauto/yeeke/sync_missing_test.go create mode 100644 server/cmd/migrate/migration/version-local/1789800900000_return_missing.go diff --git a/server/app/goauto/models/yeeke.go b/server/app/goauto/models/yeeke.go index a158287..d763055 100644 --- a/server/app/goauto/models/yeeke.go +++ b/server/app/goauto/models/yeeke.go @@ -38,9 +38,14 @@ type YeekeReturnPackage struct { UpdateTime *time.Time DestroyDeadLine *time.Time LastSyncedAt time.Time `gorm:"not null;index"` - SyncStatus string `gorm:"size:32;not null;default:'ok'"` - CreatedAt time.Time - UpdatedAt time.Time + // SyncStatus is "ok" while the package still appears in a COMPLETE yeeke + // sync; #338 sets it to "missing" (with MissingSince stamped) once a + // completed sync no longer sees it, so return matching stops using it. + // It is never deleted or marked "已销毁" — only flagged unavailable. + SyncStatus string `gorm:"size:32;not null;default:'ok';index"` + MissingSince *time.Time + CreatedAt time.Time + UpdatedAt time.Time } func (YeekeReturnPackage) TableName() string { return "yeeke_return_package" } @@ -56,9 +61,14 @@ type YeekeReturnItem struct { Image string `gorm:"type:text;not null"` Quantity int64 `gorm:"not null;default:0"` LastSyncedAt time.Time `gorm:"not null;index"` - SyncStatus string `gorm:"size:32;not null;default:'ok'"` - CreatedAt time.Time - UpdatedAt time.Time + // SyncStatus/MissingSince mirror YeekeReturnPackage's fields (#338): once + // a COMPLETE yeeke sync no longer sees this item it is flagged "missing" + // so returnmatch.availableReturnPool stops offering it, without ever + // deleting the row. + SyncStatus string `gorm:"size:32;not null;default:'ok';index"` + MissingSince *time.Time + CreatedAt time.Time + UpdatedAt time.Time } func (YeekeReturnItem) TableName() string { return "yeeke_return_item" } diff --git a/server/app/goauto/returnmatch/missing_test.go b/server/app/goauto/returnmatch/missing_test.go new file mode 100644 index 0000000..fcb3fd8 --- /dev/null +++ b/server/app/goauto/returnmatch/missing_test.go @@ -0,0 +1,158 @@ +package returnmatch + +import ( + "context" + "testing" + "time" + + "go-admin/app/goauto/models" +) + +// TestBatchMatch_MissingItemNotMatched: a return item whose sync_status is +// "missing" (#338: dropped from a COMPLETE yeeke sync) must never be offered +// to matching, even though it otherwise satisfies every other rule (an +// eligible SYB candidate, a future destroy deadline, no active match). +func TestBatchMatch_MissingItemNotMatched(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + now := time.Now().UTC() + if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID). + Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; err != nil { + t.Fatal(err) + } + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 0 || resp.SkippedCount != 1 { + t.Fatalf("expected the missing item to be skipped, not matched: %+v", resp) + } + if resp.Items[0].ReasonCode != ReasonNoCandidate { + t.Fatalf("expected no_candidate (the only candidate is missing): %+v", resp.Items[0]) + } +} + +// TestBatchMatch_MissingPackageNotMatched: same as above but the ITEM itself +// is still "ok" while its PACKAGE is "missing" — availableReturnPool must +// exclude it too, since #338's rule is "an item, or an item whose package, +// is no longer ok". +func TestBatchMatch_MissingPackageNotMatched(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + now := time.Now().UTC() + if err := db.Model(&models.YeekeReturnPackage{}).Where("id = ?", ret.PackageID). + Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; err != nil { + t.Fatal(err) + } + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 0 || resp.SkippedCount != 1 { + t.Fatalf("expected the item to be skipped because its package is missing: %+v", resp) + } +} + +// TestActiveMatchSurvivesItemGoingMissing: issue #338's rule 6 — an existing +// active match is NOT auto-cancelled when its return item later becomes +// missing. Detail() must still report it (SYB side unaffected) and surface +// the yeeke side's syncStatus="missing" so the UI can show the warning. +func TestActiveMatchSurvivesItemGoingMissing(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 1 { + t.Fatalf("expected a match before the item goes missing: %+v", resp) + } + matchID := resp.Items[0].MatchID + + // The return item now drops out of a COMPLETE yeeke sync. + missingSince := time.Now().UTC() + if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID). + Updates(map[string]any{"sync_status": "missing", "missing_since": missingSince}).Error; err != nil { + t.Fatal(err) + } + + var match models.ReturnMatch + if err := db.First(&match, matchID).Error; err != nil { + t.Fatal(err) + } + if match.Status != models.ReturnMatchStatusMatched || match.ActiveYeekeReturnItemID == nil { + t.Fatalf("existing match must stay active when its return item goes missing, got %+v", match) + } + + detail, err := s.Detail(context.Background(), matchID) + if err != nil { + t.Fatal(err) + } + if detail.Yeeke == nil { + t.Fatal("Detail must still resolve the yeeke side (the row was never deleted)") + } + if detail.Yeeke.SyncStatus != "missing" || detail.Yeeke.MissingSince == nil { + t.Fatalf("Detail must report the return item's syncStatus=missing, got %+v", detail.Yeeke) + } + if detail.SYB == nil || detail.SYB.SYBProductID != syb.ID { + t.Fatalf("SYB side must be unaffected by the return item going missing, got %+v", detail.SYB) + } +} + +// TestList_ReportsMissingReturnSyncStatus: the SYB products match column and +// the compare dialog read List()'s syncStatus/missingSince fields (#338); a +// match whose return item is missing must carry them through. +func TestList_ReportsMissingReturnSyncStatus(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 1 { + t.Fatalf("expected a match: %+v", resp) + } + + missingSince := time.Now().UTC() + if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID). + Updates(map[string]any{"sync_status": "missing", "missing_since": missingSince}).Error; err != nil { + t.Fatal(err) + } + + items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}}) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("expected one list row, got %d", len(items)) + } + if items[0].SyncStatus != "missing" || items[0].MissingSince == nil { + t.Fatalf("List() must surface the missing return item's status, got %+v", items[0]) + } +} diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go index d12a1be..f20adec 100644 --- a/server/app/goauto/returnmatch/service.go +++ b/server/app/goauto/returnmatch/service.go @@ -264,6 +264,9 @@ func sortSYBCandidatesDesc(items []SYBCandidate) { // availableReturnPool loads every yeeke return item with no active match and // a destroy deadline (rule 2: empty deadline is unavailable, handled by the // NULL exclusion below; "later than now" is enforced by SelectMatches). +// #338 scope addition: an item, or an item whose package, is no longer +// sync_status="ok" (i.e. a COMPLETE yeeke sync stopped seeing it) is +// excluded here so matching never offers a return yeeke has dropped. func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, error) { var rows []struct { ID uint64 @@ -275,7 +278,7 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e Select("i.id AS id, i.item_id AS item_id, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line"). Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id"). Joins("LEFT JOIN return_match AS m ON m.active_yeeke_return_item_id = i.id"). - Where("m.id IS NULL"). + Where("m.id IS NULL AND i.sync_status = ? AND p.sync_status = ?", "ok", "ok"). Find(&rows).Error if err != nil { return nil, err @@ -449,6 +452,12 @@ type ListItem struct { Image string `json:"image,omitempty"` VariationName string `json:"variationName,omitempty"` DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"` + // SyncStatus/MissingSince (#338) surface the yeeke return item's own + // current availability ("ok"/"missing") so the SYB products match column + // and the compare dialog can warn even on an existing, already-matched + // pair whose return later dropped out of a COMPLETE yeeke sync. + SyncStatus string `json:"syncStatus,omitempty"` + MissingSince *time.Time `json:"missingSince,omitempty"` } func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, error) { @@ -478,32 +487,42 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro // One bounded join query for every return item referenced on this page — // never a per-row lookup. var joined []struct { - ID uint64 - OrderSN string - Image string - VariationName string - DestroyDeadLine *time.Time + ID uint64 + OrderSN string + Image string + VariationName string + DestroyDeadLine *time.Time + SyncStatus string + MissingSince *time.Time + PackageSyncStat string `gorm:"column:package_sync_status"` + PackageMissingAt *time.Time `gorm:"column:package_missing_since"` } if err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i"). - Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line"). + Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line, "+ + "i.sync_status AS sync_status, i.missing_since AS missing_since, p.sync_status AS package_sync_status, p.missing_since AS package_missing_since"). Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id"). Where("i.id IN ?", returnIDs). Find(&joined).Error; err != nil { return nil, err } - byID := make(map[uint64]struct { + type extraFields struct { OrderSN string Image string VariationName string DestroyDeadLine *time.Time - }, len(joined)) + SyncStatus string + MissingSince *time.Time + } + byID := make(map[uint64]extraFields, len(joined)) for _, j := range joined { - byID[j.ID] = struct { - OrderSN string - Image string - VariationName string - DestroyDeadLine *time.Time - }{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine} + // A missing package makes its items unavailable too (#338), even + // if the item row itself is still "ok" — surface the package's + // missing_since in that case since it is the more accurate reason. + syncStatus, missingSince := j.SyncStatus, j.MissingSince + if j.PackageSyncStat != "ok" { + syncStatus, missingSince = j.PackageSyncStat, j.PackageMissingAt + } + byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine, syncStatus, missingSince} } for i := range items { if extra, ok := byID[items[i].YeekeReturnItemID]; ok { @@ -511,6 +530,8 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro items[i].Image = extra.Image items[i].VariationName = extra.VariationName items[i].DestroyDeadline = extra.DestroyDeadLine + items[i].SyncStatus = extra.SyncStatus + items[i].MissingSince = extra.MissingSince } } return items, nil @@ -544,6 +565,11 @@ type YeekeDetailView struct { Quantity int64 `json:"quantity"` Image string `json:"image,omitempty"` DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"` + // SyncStatus/MissingSince (#338): "missing" when either the item or its + // package fell out of a COMPLETE yeeke sync, so the compare dialog can + // show the warning even on a match made before the item went missing. + SyncStatus string `json:"syncStatus"` + MissingSince *time.Time `json:"missingSince,omitempty"` } type MatchDetail struct { @@ -586,10 +612,15 @@ func (s *Service) Detail(ctx context.Context, matchID uint64) (MatchDetail, erro if pkgErr := s.DB.WithContext(ctx).First(&pkg, item.PackageID).Error; pkgErr != nil && !errors.Is(pkgErr, gorm.ErrRecordNotFound) { return detail, pkgErr } + syncStatus, missingSince := item.SyncStatus, item.MissingSince + if pkg.SyncStatus != "" && pkg.SyncStatus != "ok" { + syncStatus, missingSince = pkg.SyncStatus, pkg.MissingSince + } detail.Yeeke = &YeekeDetailView{ YeekeReturnItemID: item.ID, OrderSN: pkg.OrderSN, ItemID: item.ItemID, VariationID: item.VariationID, ShopName: pkg.ShopName, ItemName: item.ItemName, VariationName: item.VariationName, Quantity: item.Quantity, Image: item.Image, DestroyDeadline: pkg.DestroyDeadLine, + SyncStatus: syncStatus, MissingSince: missingSince, } detail.NormalizedYeekeSpec = trimCommas(Normalize(item.VariationName)) } else if !errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/server/app/goauto/yeeke/list.go b/server/app/goauto/yeeke/list.go index 5070036..1499cce 100644 --- a/server/app/goauto/yeeke/list.go +++ b/server/app/goauto/yeeke/list.go @@ -20,13 +20,15 @@ import ( // image field is always the external yeeke URL (never downloaded/proxied, // per #337 non-goal). type ReturnItemDTO struct { - ID uint64 `json:"id"` - ItemID string `json:"itemId"` - VariationID string `json:"variationId"` - ItemName string `json:"itemName"` - VariationName string `json:"variationName"` - Image string `json:"image"` - Quantity int64 `json:"quantity"` + ID uint64 `json:"id"` + ItemID string `json:"itemId"` + VariationID string `json:"variationId"` + ItemName string `json:"itemName"` + VariationName string `json:"variationName"` + Image string `json:"image"` + Quantity int64 `json:"quantity"` + SyncStatus string `json:"syncStatus"` + MissingSince *string `json:"missingSince,omitempty"` } // ReturnPackageDTO is the read-only shape of one return package returned to @@ -50,6 +52,7 @@ type ReturnPackageDTO struct { DestroyDeadLine *string `json:"destroyDeadLine"` LastSyncedAt string `json:"lastSyncedAt"` SyncStatus string `json:"syncStatus"` + MissingSince *string `json:"missingSince,omitempty"` Items []ReturnItemDTO `json:"items,omitempty"` } @@ -78,6 +81,12 @@ type ReturnItemRowDTO struct { LastSyncedAt string `json:"lastSyncedAt"` HasItem bool `json:"hasItem"` + // SyncStatus/MissingSince (#338 scope addition): "missing" when this + // item, or its package, has fallen out of a COMPLETE yeeke sync — the + // row is never dropped from this list, only flagged unavailable. + SyncStatus string `json:"syncStatus,omitempty"` + MissingSince *string `json:"missingSince,omitempty"` + // #338: match status/占用信息, joined from return_match (active match // only). MatchStatus is "unmatched" when there is no active match. MatchID uint64 `json:"matchId,omitempty"` @@ -110,6 +119,7 @@ func toPackageDTO(p models.YeekeReturnPackage, itemCount int) ReturnPackageDTO { ClaimTime: formatTimePtr(p.ClaimTime), CreateTime: formatTimePtr(p.CreateTime), UpdateTime: formatTimePtr(p.UpdateTime), DestroyDeadLine: formatTimePtr(p.DestroyDeadLine), LastSyncedAt: p.LastSyncedAt.UTC().Format("2006-01-02T15:04:05Z"), SyncStatus: p.SyncStatus, + MissingSince: formatTimePtr(p.MissingSince), } } @@ -117,6 +127,7 @@ func toItemDTO(i models.YeekeReturnItem) ReturnItemDTO { return ReturnItemDTO{ ID: i.ID, ItemID: i.ItemID, VariationID: i.VariationID, ItemName: i.ItemName, VariationName: i.VariationName, Image: i.Image, Quantity: i.Quantity, + SyncStatus: i.SyncStatus, MissingSince: formatTimePtr(i.MissingSince), } } @@ -143,6 +154,14 @@ type itemRow struct { ItemQuantity *int64 ItemLastSynced *time.Time + // #338 scope addition: item's own and its package's sync_status/ + // missing_since, so the row can be flagged unavailable regardless of + // which side dropped out of a COMPLETE yeeke sync. + ItemSyncStatus *string `gorm:"column:item_sync_status"` + ItemMissingSince *time.Time `gorm:"column:item_missing_since"` + PackageSyncStatus string `gorm:"column:package_sync_status"` + PackageMissingSince *time.Time `gorm:"column:package_missing_since"` + // #338 match columns, from the LEFT JOIN onto return_match/syb_product. MatchID *uint64 `gorm:"column:match_id"` MatchStatus *string `gorm:"column:match_status"` @@ -188,6 +207,17 @@ func toItemRowDTO(r itemRow) ReturnItemRowDTO { if r.ItemLastSynced != nil { dto.LastSyncedAt = r.ItemLastSynced.UTC().Format("2006-01-02T15:04:05Z") } + // #338: the package being "missing" makes the item unavailable too, + // even if the item row itself is still "ok" — its reason/timestamp wins. + syncStatus, missingSince := "ok", r.ItemMissingSince + if r.ItemSyncStatus != nil { + syncStatus = *r.ItemSyncStatus + } + if r.PackageSyncStatus != "" && r.PackageSyncStatus != "ok" { + syncStatus, missingSince = r.PackageSyncStatus, r.PackageMissingSince + } + dto.SyncStatus = syncStatus + dto.MissingSince = formatTimePtr(missingSince) dto.MatchStatus = ReturnMatchFilterUnmatched if r.MatchID != nil { dto.MatchID = *r.MatchID @@ -306,6 +336,8 @@ func (h Handler) ListReturnPackages(c *gin.Context) { "p.status_unrecognized, p.claim_time, p.destroy_dead_line as destroy_dead_line, p.last_synced_at as package_last_synced, " + "i.id as item_id, i.item_id as item_external_item, i.variation_id as item_variation_id, i.item_name as item_name, " + "i.variation_name as item_variation_name, i.image as item_image, i.quantity as item_quantity, i.last_synced_at as item_last_synced, " + + "i.sync_status as item_sync_status, i.missing_since as item_missing_since, " + + "p.sync_status as package_sync_status, p.missing_since as package_missing_since, " + "m.id as match_id, m.status as match_status, sp.id as occupying_syb_product_id, sp.order_code as occupying_syb_order_code", ).Order("p.create_time desc, i.id asc"). Offset((page - 1) * pageSize).Limit(pageSize) diff --git a/server/app/goauto/yeeke/sync.go b/server/app/goauto/yeeke/sync.go index 7d419ca..38cec1a 100644 --- a/server/app/goauto/yeeke/sync.go +++ b/server/app/goauto/yeeke/sync.go @@ -40,6 +40,13 @@ type Report struct { RunID uint64 TotalPages, Read, Created, Updated, Skipped, Failed int Status string + // MissingMarked is the number of yeeke_return_item rows flipped from + // "ok" to "missing" by this run's completion (#338). It is only ever + // non-zero on a run whose page walk finished naturally, wrote no + // failures, and stayed under the 20% safety-valve threshold; it is not + // persisted as a run column, only reported here and folded into + // error_message when the safety valve skips marking. + MissingMarked int } // knownClaimStatuses lists the status values the sync code currently @@ -185,6 +192,13 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro }() seen := map[string]bool{} var firstWriteErr error + // complete tracks whether the page walk ended NATURALLY (empty page, + // short page, or reaching p.Pages) as opposed to the duplicate- + // fingerprint break or MaxPages exhaustion (#338): only a naturally + // complete run is trusted to mark absent items/packages "missing" below, + // since a duplicate/MaxPages stop means the walk never actually finished + // seeing everything yeeke currently has. + complete := false for page := 1; page <= s.cfg.MaxPages; page++ { var p yeekeclient.ReturnPage var e error @@ -211,6 +225,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro } rep.TotalPages = page if len(p.Records) == 0 { + complete = true break } finger := pageFingerprint(p) @@ -238,9 +253,11 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro } } if len(p.Records) < s.cfg.PageSize { + complete = true break } if p.Pages > 0 && page >= p.Pages { + complete = true break } } @@ -253,9 +270,73 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro runErr = errors.New(errMsg) } } + // #338: only a naturally complete run with zero write failures is + // trusted to mark items/packages the sync no longer sees as "missing". + if complete && rep.Failed == 0 && rep.Status == "succeeded" { + marked, valveReason, mErr := s.markMissing(ctx, r) + switch { + case mErr != nil: + errMsg = truncateRunError(fmt.Sprintf("标记 yeeke 退货不可用失败:%v", mErr)) + case valveReason != "": + errMsg = truncateRunError(valveReason) + default: + rep.MissingMarked = marked + } + } return rep, nil } +// markMissing implements #338's completion-triggered availability flip: any +// yeeke_return_item/yeeke_return_package row still marked sync_status="ok" +// but whose last_synced_at predates this run's start was not touched by +// this (complete, failure-free) run's upsert calls, meaning yeeke no longer +// reports it. Both tables are flipped to "missing" with missing_since +// stamped, in one transaction, so a reader never observes the item flipped +// without its package (or vice versa). Rows are never deleted. +// +// Safety valve: if the number of items that would be marked exceeds 20% of +// the items currently "ok", nothing is marked and valveReason explains why +// (the run itself still finishes as "succeeded" — this is a caution, not a +// sync failure). +func (s *Service) markMissing(ctx context.Context, r *models.YeekeSyncRun) (marked int, valveReason string, err error) { + var totalOkItems int64 + if e := s.db.WithContext(ctx).Model(&models.YeekeReturnItem{}).Where("sync_status = ?", "ok").Count(&totalOkItems).Error; e != nil { + return 0, "", e + } + var candidateItems int64 + if e := s.db.WithContext(ctx).Model(&models.YeekeReturnItem{}). + Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt). + Count(&candidateItems).Error; e != nil { + return 0, "", e + } + if candidateItems == 0 { + return 0, "", nil + } + // candidateItems/totalOkItems > 20% <=> candidateItems*5 > totalOkItems + // (integer-only, avoids float rounding). + if totalOkItems > 0 && candidateItems*5 > totalOkItems { + return 0, fmt.Sprintf( + "未标记 yeeke 退货不可用:待标记 %d 条超过当前可用退货商品 %d 条的 20%% 安全阈值,需人工核查后再处理", + candidateItems, totalOkItems, + ), nil + } + now := time.Now().UTC() + txErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if e := tx.Model(&models.YeekeReturnItem{}). + Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt). + Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; e != nil { + return e + } + return tx.Model(&models.YeekeReturnPackage{}). + Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt). + Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error + }) + if txErr != nil { + return 0, "", txErr + } + return int(candidateItems), "", nil +} + // truncateRunError keeps error_message inside the column's size limit. It // never includes request bodies or headers, so it cannot leak a captcha, // token or credential: every error path above passes only Go error text from @@ -304,7 +385,10 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool return e } } else { - vals := map[string]any{"order_sn": fields.OrderSN, "tracking_no": fields.TrackingNo, "shop_id": fields.ShopID, "shop_name": fields.ShopName, "ware_code": fields.WareCode, "ware_house": fields.WareHouse, "ware_name": fields.WareName, "claim_status": fields.ClaimStatus, "status_unrecognized": fields.StatusUnrecognized, "claim_time": fields.ClaimTime, "create_time": fields.CreateTime, "update_time": fields.UpdateTime, "destroy_dead_line": fields.DestroyDeadLine, "last_synced_at": now, "sync_status": "ok"} + // #338: missing_since is reset to NULL whenever a package + // reappears in a sync so it recovers cleanly, whatever its prior + // sync_status was. + vals := map[string]any{"order_sn": fields.OrderSN, "tracking_no": fields.TrackingNo, "shop_id": fields.ShopID, "shop_name": fields.ShopName, "ware_code": fields.WareCode, "ware_house": fields.WareHouse, "ware_name": fields.WareName, "claim_status": fields.ClaimStatus, "status_unrecognized": fields.StatusUnrecognized, "claim_time": fields.ClaimTime, "create_time": fields.CreateTime, "update_time": fields.UpdateTime, "destroy_dead_line": fields.DestroyDeadLine, "last_synced_at": now, "sync_status": "ok", "missing_since": (*time.Time)(nil)} if e = tx.Model(&row).Updates(vals).Error; e != nil { return e } @@ -327,7 +411,7 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool case ie != nil: return ie default: - iv := map[string]any{"item_id": item.ItemID, "variation_id": item.VariationID, "item_name": item.ItemName, "variation_name": item.VariationName, "image": item.Image, "quantity": item.Quantity, "last_synced_at": now, "sync_status": "ok"} + iv := map[string]any{"item_id": item.ItemID, "variation_id": item.VariationID, "item_name": item.ItemName, "variation_name": item.VariationName, "image": item.Image, "quantity": item.Quantity, "last_synced_at": now, "sync_status": "ok", "missing_since": (*time.Time)(nil)} if e = tx.Model(&existing).Updates(iv).Error; e != nil { return e } diff --git a/server/app/goauto/yeeke/sync_missing_test.go b/server/app/goauto/yeeke/sync_missing_test.go new file mode 100644 index 0000000..2e95322 --- /dev/null +++ b/server/app/goauto/yeeke/sync_missing_test.go @@ -0,0 +1,391 @@ +package yeeke + +import ( + "context" + "fmt" + "go-admin/app/goauto/models" + "go-admin/app/goauto/yeekeclient" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// fivePackagesFirstReport builds a first-sync page of 5 distinct packages +// (p1..p5), so a follow-up run dropping exactly one of them (1/5 = 20%, +// the safety-valve boundary, which is not ">20%") still marks it missing. +func fivePackagesFirstReport() []string { + recs := make([]string, 0, 5) + for i := 1; i <= 5; i++ { + id := fmt.Sprintf("p%d", i) + recs = append(recs, record(id, "i", "v"+id, 1)) + } + return recs +} + +// TestCompleteRunMarksAbsentItemsAndPackagesMissing: p1 exists from an +// earlier sync of 5 packages; a later, naturally complete run only reports +// the other 4 (p2..p5). p1's item and package must both flip to +// sync_status="missing" with missing_since set; p2..p5 must stay "ok". +func TestCompleteRunMarksAbsentItemsAndPackagesMissing(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 10}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("first sync: %v", err) + } + srv1.Close() + + time.Sleep(1100 * time.Millisecond) // ensure StartedAt of run 2 is strictly later + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p2", "i", "vp2", 1), record("p3", "i", "vp3", 1), record("p4", "i", "vp4", 1), record("p5", "i", "vp5", 1)}, 4, 1)) + })) + defer srv2.Close() + s.client, _ = yeekeclient.New(srv2.URL) + rep, err := s.Sync(context.Background(), "manual") + if err != nil { + t.Fatalf("second sync: %v", err) + } + if rep.Status != "succeeded" { + t.Fatalf("rep=%+v", rep) + } + if rep.MissingMarked != 1 { + t.Fatalf("MissingMarked=%d, want 1", rep.MissingMarked) + } + + var p1 models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil { + t.Fatal(e) + } + if p1.SyncStatus != "missing" || p1.MissingSince == nil { + t.Fatalf("p1 package=%+v, want sync_status=missing with missing_since set", p1) + } + var i1 models.YeekeReturnItem + if e := db.Where("package_id = ?", p1.ID).First(&i1).Error; e != nil { + t.Fatal(e) + } + if i1.SyncStatus != "missing" || i1.MissingSince == nil { + t.Fatalf("p1 item=%+v, want sync_status=missing with missing_since set", i1) + } + + var p2 models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil { + t.Fatal(e) + } + if p2.SyncStatus != "ok" || p2.MissingSince != nil { + t.Fatalf("p2 package=%+v, want sync_status=ok with no missing_since", p2) + } +} + +// TestFailedPageStopsMarking: page 2 always fails, so the run ends "failed". +// Nothing must be marked missing even though p1 (from an earlier run) is +// absent from this run's (incomplete) output. +func TestFailedPageStopsMarking(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 1, Retry: 0}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("first sync: %v", err) + } + srv1.Close() + + var calls int32 + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n == 1 { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 2, 2)) + return + } + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv2.Close() + s.client, _ = yeekeclient.New(srv2.URL) + if _, err := s.Sync(context.Background(), "manual"); err == nil { + t.Fatal("expected the second run (failed page 2) to error") + } + + var p1 models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil { + t.Fatal(e) + } + if p1.SyncStatus != "ok" || p1.MissingSince != nil { + t.Fatalf("p1 must stay ok after a failed page, got %+v", p1) + } +} + +// TestCtxCancelStopsMarking: the context is cancelled mid-walk. The run ends +// with an error and must not mark anything missing. +func TestCtxCancelStopsMarking(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 1, Retry: 0}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("first sync: %v", err) + } + srv1.Close() + + ctx, cancel := context.WithCancel(context.Background()) + var calls int32 + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n == 1 { + cancel() + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 2, 2)) + return + } + http.Error(w, "should not be reached", http.StatusInternalServerError) + })) + defer srv2.Close() + s.client, _ = yeekeclient.New(srv2.URL) + if _, err := s.Sync(ctx, "manual"); err == nil { + t.Fatal("expected a context-cancellation error") + } + + var p1 models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil { + t.Fatal(e) + } + if p1.SyncStatus != "ok" || p1.MissingSince != nil { + t.Fatalf("p1 must stay ok after a context-cancelled run, got %+v", p1) + } +} + +// TestDuplicateFingerprintStopsMarking: the walk ends via the +// duplicate-page break, not a natural stop, so nothing must be marked even +// though only p1 (not p2 from an earlier sync) is ever reported. +func TestDuplicateFingerprintStopsMarking(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 1}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 1, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("first sync (seeds p2): %v", err) + } + srv1.Close() + + // Second run: page 1 returns p1, but the server (mis)reports pages=5 and + // then serves the exact same page again, triggering the duplicate break + // before ever reaching a natural stop. + body := page([]string{record("p1", "i", "v1", 1)}, 10, 5) + var calls int32 + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, body) + })) + defer srv2.Close() + s.client, _ = yeekeclient.New(srv2.URL) + rep, err := s.Sync(context.Background(), "manual") + if err != nil { + t.Fatalf("second sync: %v", err) + } + if rep.MissingMarked != 0 { + t.Fatalf("MissingMarked=%d, want 0 (duplicate-fingerprint stop is not complete)", rep.MissingMarked) + } + + var p2 models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil { + t.Fatal(e) + } + if p2.SyncStatus != "ok" || p2.MissingSince != nil { + t.Fatalf("p2 must stay ok after a duplicate-fingerprint stop, got %+v", p2) + } +} + +// TestMaxPagesExhaustionStopsMarking: MaxPages is reached without any +// natural stop condition being hit, so nothing must be marked. +func TestMaxPagesExhaustionStopsMarking(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 1}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 1, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("first sync (seeds p2): %v", err) + } + srv1.Close() + + // Second run: every page returns a distinct full page (never short, + // never empty, pages always reported far beyond MaxPages), so the walk + // only stops because MaxPages is exhausted. + var n int32 + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + k := atomic.AddInt32(&n, 1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record(fmt.Sprintf("p1-%d", k), "i", "v1", 1)}, 1000, 1000)) + })) + defer srv2.Close() + s.client, _ = yeekeclient.New(srv2.URL) + s.cfg.MaxPages = 2 + rep, err := s.Sync(context.Background(), "manual") + if err != nil { + t.Fatalf("second sync: %v", err) + } + if rep.MissingMarked != 0 { + t.Fatalf("MissingMarked=%d, want 0 (MaxPages exhaustion is not complete)", rep.MissingMarked) + } + + var p2 models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil { + t.Fatal(e) + } + if p2.SyncStatus != "ok" || p2.MissingSince != nil { + t.Fatalf("p2 must stay ok after MaxPages exhaustion, got %+v", p2) + } +} + +// TestSafetyValveSkipsMarkingWhenOverThreshold: 3 packages are "ok"; a +// complete follow-up run only reports 1 of them (2 of 3 = 67% would be +// marked, well over the 20% threshold). Nothing must be marked, and the +// run's error_message must explain why. +func TestSafetyValveSkipsMarkingWhenOverThreshold(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 10}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1), record("p2", "i", "v2", 1), record("p3", "i", "v3", 1)}, 3, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("first sync: %v", err) + } + srv1.Close() + + time.Sleep(1100 * time.Millisecond) + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1)) + })) + defer srv2.Close() + s.client, _ = yeekeclient.New(srv2.URL) + rep, err := s.Sync(context.Background(), "manual") + if err != nil { + t.Fatalf("second sync: %v", err) + } + if rep.Status != "succeeded" { + t.Fatalf("safety valve must not fail the run, got status=%q", rep.Status) + } + if rep.MissingMarked != 0 { + t.Fatalf("MissingMarked=%d, want 0 (over the 20%% safety valve)", rep.MissingMarked) + } + var run models.YeekeSyncRun + if e := db.First(&run, rep.RunID).Error; e != nil { + t.Fatal(e) + } + if !strings.Contains(run.ErrorMessage, "20%") { + t.Fatalf("run.ErrorMessage=%q, want an explanation mentioning the 20%% safety valve", run.ErrorMessage) + } + + for _, ext := range []string{"p2", "p3"} { + var p models.YeekeReturnPackage + if e := db.Where("external_id = ?", ext).First(&p).Error; e != nil { + t.Fatal(e) + } + if p.SyncStatus != "ok" || p.MissingSince != nil { + t.Fatalf("%s must stay ok when the safety valve trips, got %+v", ext, p) + } + } +} + +// TestReappearingRecordRecoversFromMissing: a package/item marked missing by +// an earlier complete run reappears in a later sync and must recover to +// sync_status="ok" with missing_since cleared. +func TestReappearingRecordRecoversFromMissing(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 10}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("run 1: %v", err) + } + srv1.Close() + + time.Sleep(1100 * time.Millisecond) + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p2", "i", "vp2", 1), record("p3", "i", "vp3", 1), record("p4", "i", "vp4", 1), record("p5", "i", "vp5", 1)}, 4, 1)) + })) + s.client, _ = yeekeclient.New(srv2.URL) + rep2, err := s.Sync(context.Background(), "manual") + if err != nil { + t.Fatalf("run 2: %v", err) + } + if rep2.MissingMarked != 1 { + t.Fatalf("run2 MissingMarked=%d, want 1", rep2.MissingMarked) + } + srv2.Close() + + var p1 models.YeekeReturnPackage + db.Where("external_id = ?", "p1").First(&p1) + if p1.SyncStatus != "missing" || p1.MissingSince == nil { + t.Fatalf("p1 must be missing before recovery, got %+v", p1) + } + + srv3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1)) + })) + defer srv3.Close() + s.client, _ = yeekeclient.New(srv3.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("run 3 (recovery): %v", err) + } + + // A fresh variable is used here (not the p1 declared above): GORM's Scan + // does not reset an already non-nil pointer field to nil when the new + // row's column is NULL, so reusing the earlier struct would misreport a + // stale MissingSince even though the row itself recovered correctly. + var recovered models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p1").First(&recovered).Error; e != nil { + t.Fatal(e) + } + if recovered.SyncStatus != "ok" || recovered.MissingSince != nil { + t.Fatalf("p1 must recover to ok with missing_since cleared, got %+v", recovered) + } + var i1 models.YeekeReturnItem + if e := db.Where("package_id = ?", recovered.ID).First(&i1).Error; e != nil { + t.Fatal(e) + } + if i1.SyncStatus != "ok" || i1.MissingSince != nil { + t.Fatalf("p1's item must recover to ok with missing_since cleared, got %+v", i1) + } +} diff --git a/server/cmd/migrate/migration/version-local/1789800900000_return_missing.go b/server/cmd/migrate/migration/version-local/1789800900000_return_missing.go new file mode 100644 index 0000000..3be2cbc --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789800900000_return_missing.go @@ -0,0 +1,28 @@ +package version_local + +import ( + "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +// #338 scope addition: adds the nullable missing_since column to +// yeeke_return_package and yeeke_return_item (models.YeekeReturnPackage / +// models.YeekeReturnItem, registered in migrations.MigratedModels) on +// databases whose earlier versions are already recorded in sys_migration. +// AutoMigrate alone never reaches an existing database without a version +// file like this one being run by the migrate command. +func init() { + _, f, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMissing) +} +func migrateReturnMissing(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := migrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index af537fa..e9496bf 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -62,6 +62,7 @@
销毁截止:{{ formatMatchDeadline(returnMatchByProductId[row.id].destroyDeadline) }}
+

yeeke 退货商品

+ {{ matchDetail.data.yeeke.orderSn }} {{ matchDetail.data.yeeke.itemId }} @@ -838,6 +840,7 @@ export default { .link{color:#1677ff;cursor:pointer} .shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .notice{margin-bottom:16px} +.return-missing-alert{margin:6px 0} .compact-notice{margin-bottom:12px} .purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.warning-text{color:#b45309}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle} .spec-match-summary{gap:8px 24px} diff --git a/web/src/views/goauto/yeeke-returns/index.vue b/web/src/views/goauto/yeeke-returns/index.vue index bb6cb4d..fe2871c 100644 --- a/web/src/views/goauto/yeeke-returns/index.vue +++ b/web/src/views/goauto/yeeke-returns/index.vue @@ -66,6 +66,12 @@ {{ claimStatusMeta(row.claimStatus).label }} + + + From 5ac8e4c8fd0a6f558c2563a58558b7017946c085 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 16:14:30 +0800 Subject: [PATCH 11/12] feat(yeeke): show missing-marked and recovered counts per sync run (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist how many return items each sync run flipped to "missing" and how many came back to "ok" (yeeke_sync_run.missing_marked_count / recovered_count, migration 1789801000000), return them from the sync-runs API and add 「标记不可用」「恢复可用」 columns to the sync-runs page. When the 20% safety valve skips marking the count stays 0 and the reason remains in error_message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/models/yeeke.go | 39 ++++++++++--------- server/app/goauto/yeeke/handler.go | 31 ++++++++------- server/app/goauto/yeeke/sync.go | 29 +++++++++----- server/app/goauto/yeeke/sync_missing_test.go | 17 +++++++- ...801000000_yeeke_sync_run_missing_counts.go | 25 ++++++++++++ .../views/goauto/yeeke-sync-runs/index.vue | 2 + 6 files changed, 101 insertions(+), 42 deletions(-) create mode 100644 server/cmd/migrate/migration/version-local/1789801000000_yeeke_sync_run_missing_counts.go diff --git a/server/app/goauto/models/yeeke.go b/server/app/goauto/models/yeeke.go index d763055..51e94fb 100644 --- a/server/app/goauto/models/yeeke.go +++ b/server/app/goauto/models/yeeke.go @@ -74,24 +74,27 @@ type YeekeReturnItem struct { func (YeekeReturnItem) TableName() string { return "yeeke_return_item" } type YeekeSyncRun struct { - ID uint64 `gorm:"primaryKey;autoIncrement"` - Status string `gorm:"size:32;not null;index"` - Trigger string `gorm:"size:32;not null;index"` - TotalPages int `gorm:"not null;default:0"` - ReadCount int `gorm:"not null;default:0"` - CreatedCount int `gorm:"not null;default:0"` - UpdatedCount int `gorm:"not null;default:0"` - SkippedCount int `gorm:"not null;default:0"` - FailedCount int `gorm:"not null;default:0"` - ErrorMessage string `gorm:"size:1000;not null;default:''"` - StartedAt time.Time `gorm:"not null"` - FinishedAt *time.Time - LastSuccessAt *time.Time - ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"` - LeaseOwner string `gorm:"size:128;not null;default:''"` - LeaseExpiresAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time + ID uint64 `gorm:"primaryKey;autoIncrement"` + Status string `gorm:"size:32;not null;index"` + Trigger string `gorm:"size:32;not null;index"` + TotalPages int `gorm:"not null;default:0"` + ReadCount int `gorm:"not null;default:0"` + CreatedCount int `gorm:"not null;default:0"` + UpdatedCount int `gorm:"not null;default:0"` + SkippedCount int `gorm:"not null;default:0"` + FailedCount int `gorm:"not null;default:0"` + // #338: items flipped to "missing" / back to "ok" by this run. + MissingMarkedCount int `gorm:"not null;default:0"` + RecoveredCount int `gorm:"not null;default:0"` + ErrorMessage string `gorm:"size:1000;not null;default:''"` + StartedAt time.Time `gorm:"not null"` + FinishedAt *time.Time + LastSuccessAt *time.Time + ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"` + LeaseOwner string `gorm:"size:128;not null;default:''"` + LeaseExpiresAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time } func (YeekeSyncRun) TableName() string { return "yeeke_sync_run" } diff --git a/server/app/goauto/yeeke/handler.go b/server/app/goauto/yeeke/handler.go index 1417aa0..e419b5a 100644 --- a/server/app/goauto/yeeke/handler.go +++ b/server/app/goauto/yeeke/handler.go @@ -43,26 +43,29 @@ func (h Handler) db(c *gin.Context) (*gorm.DB, bool) { // only the summary fields already computed by the sync run itself; it never // carries yeeke_session (token/cookies) or a raw page response. type SyncRunDTO struct { - ID uint64 `json:"id"` - Status string `json:"status"` - Trigger string `json:"trigger"` - TotalPages int `json:"totalPages"` - ReadCount int `json:"readCount"` - CreatedCount int `json:"createdCount"` - UpdatedCount int `json:"updatedCount"` - SkippedCount int `json:"skippedCount"` - FailedCount int `json:"failedCount"` - ErrorMessage string `json:"errorMessage"` - StartedAt string `json:"startedAt"` - FinishedAt *string `json:"finishedAt"` - LastSuccessAt *string `json:"lastSuccessAt"` + ID uint64 `json:"id"` + Status string `json:"status"` + Trigger string `json:"trigger"` + TotalPages int `json:"totalPages"` + ReadCount int `json:"readCount"` + CreatedCount int `json:"createdCount"` + UpdatedCount int `json:"updatedCount"` + SkippedCount int `json:"skippedCount"` + FailedCount int `json:"failedCount"` + MissingMarkedCount int `json:"missingMarkedCount"` + RecoveredCount int `json:"recoveredCount"` + ErrorMessage string `json:"errorMessage"` + StartedAt string `json:"startedAt"` + FinishedAt *string `json:"finishedAt"` + LastSuccessAt *string `json:"lastSuccessAt"` } func toDTO(r models.YeekeSyncRun) SyncRunDTO { dto := SyncRunDTO{ ID: r.ID, Status: r.Status, Trigger: r.Trigger, TotalPages: r.TotalPages, ReadCount: r.ReadCount, CreatedCount: r.CreatedCount, UpdatedCount: r.UpdatedCount, - SkippedCount: r.SkippedCount, FailedCount: r.FailedCount, ErrorMessage: r.ErrorMessage, + SkippedCount: r.SkippedCount, FailedCount: r.FailedCount, + MissingMarkedCount: r.MissingMarkedCount, RecoveredCount: r.RecoveredCount, ErrorMessage: r.ErrorMessage, StartedAt: r.StartedAt.UTC().Format("2006-01-02T15:04:05Z"), } if r.FinishedAt != nil { diff --git a/server/app/goauto/yeeke/sync.go b/server/app/goauto/yeeke/sync.go index 38cec1a..ef68630 100644 --- a/server/app/goauto/yeeke/sync.go +++ b/server/app/goauto/yeeke/sync.go @@ -43,10 +43,14 @@ type Report struct { // MissingMarked is the number of yeeke_return_item rows flipped from // "ok" to "missing" by this run's completion (#338). It is only ever // non-zero on a run whose page walk finished naturally, wrote no - // failures, and stayed under the 20% safety-valve threshold; it is not - // persisted as a run column, only reported here and folded into - // error_message when the safety valve skips marking. + // failures, and stayed under the 20% safety-valve threshold. Persisted + // as yeeke_sync_run.missing_marked_count; when the safety valve skips + // marking it stays 0 and the reason goes to error_message. MissingMarked int + // Recovered is the number of yeeke_return_item rows that were "missing" + // before this run and reappeared in it, flipping back to "ok" (#338). + // Persisted as yeeke_sync_run.recovered_count. + Recovered int } // knownClaimStatuses lists the status values the sync code currently @@ -184,7 +188,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro var runErr error defer func() { now := time.Now().UTC() - updates := map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now} + updates := map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "missing_marked_count": rep.MissingMarked, "recovered_count": rep.Recovered, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now} if rep.Status == "succeeded" { updates["last_success_at"] = now } @@ -235,7 +239,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro } seen[finger] = true for _, x := range p.Records { - created, updated, err := s.upsert(ctx, x) + created, updated, recovered, err := s.upsert(ctx, x) if err != nil { rep.Failed++ if firstWriteErr == nil { @@ -244,6 +248,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro continue } rep.Read++ + rep.Recovered += recovered if created { rep.Created++ } else if updated { @@ -354,7 +359,10 @@ func pageFingerprint(p yeekeclient.ReturnPage) string { h := sha256.Sum256(b) return hex.EncodeToString(h[:]) } -func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool, bool, error) { + +// upsert writes one package and its items. recovered counts items that were +// marked "missing" before and reappeared in this run (#338). +func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (created bool, updated bool, recovered int, err error) { now := time.Now().UTC() key := packageKey(p) status := external(p.Status) @@ -363,7 +371,7 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool // behind without its items. Rows are always inserted fully populated — // MySQL strict mode (NO_ZERO_DATE) rejects the zero last_synced_at an // empty placeholder insert would carry, which failed every record. - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var row models.YeekeReturnPackage e := tx.Where("external_id = ?", key).First(&row).Error isNew = errors.Is(e, gorm.ErrRecordNotFound) @@ -411,6 +419,9 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool case ie != nil: return ie default: + if existing.SyncStatus == "missing" { + recovered++ + } iv := map[string]any{"item_id": item.ItemID, "variation_id": item.VariationID, "item_name": item.ItemName, "variation_name": item.VariationName, "image": item.Image, "quantity": item.Quantity, "last_synced_at": now, "sync_status": "ok", "missing_since": (*time.Time)(nil)} if e = tx.Model(&existing).Updates(iv).Error; e != nil { return e @@ -420,7 +431,7 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool return nil }) if err != nil { - return false, false, err + return false, false, 0, err } - return isNew, !isNew, nil + return isNew, !isNew, recovered, nil } diff --git a/server/app/goauto/yeeke/sync_missing_test.go b/server/app/goauto/yeeke/sync_missing_test.go index 2e95322..0e80fc8 100644 --- a/server/app/goauto/yeeke/sync_missing_test.go +++ b/server/app/goauto/yeeke/sync_missing_test.go @@ -366,9 +366,24 @@ func TestReappearingRecordRecoversFromMissing(t *testing.T) { })) defer srv3.Close() s.client, _ = yeekeclient.New(srv3.URL) - if _, err := s.Sync(context.Background(), "manual"); err != nil { + rep3, err := s.Sync(context.Background(), "manual") + if err != nil { t.Fatalf("run 3 (recovery): %v", err) } + if rep3.Recovered != 1 || rep3.MissingMarked != 0 { + t.Fatalf("run3 Recovered=%d MissingMarked=%d, want 1 and 0", rep3.Recovered, rep3.MissingMarked) + } + // Both counters are persisted on the run rows shown by the sync-runs page. + var run2, run3 models.YeekeSyncRun + if e := db.First(&run2, rep2.RunID).Error; e != nil { + t.Fatal(e) + } + if e := db.First(&run3, rep3.RunID).Error; e != nil { + t.Fatal(e) + } + if run2.MissingMarkedCount != 1 || run2.RecoveredCount != 0 || run3.MissingMarkedCount != 0 || run3.RecoveredCount != 1 { + t.Fatalf("persisted counts run2=(%d,%d) run3=(%d,%d), want (1,0) and (0,1)", run2.MissingMarkedCount, run2.RecoveredCount, run3.MissingMarkedCount, run3.RecoveredCount) + } // A fresh variable is used here (not the p1 declared above): GORM's Scan // does not reset an already non-nil pointer field to nil when the new diff --git a/server/cmd/migrate/migration/version-local/1789801000000_yeeke_sync_run_missing_counts.go b/server/cmd/migrate/migration/version-local/1789801000000_yeeke_sync_run_missing_counts.go new file mode 100644 index 0000000..c0e8a43 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801000000_yeeke_sync_run_missing_counts.go @@ -0,0 +1,25 @@ +package version_local + +import ( + "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +// #338: adds yeeke_sync_run.missing_marked_count and recovered_count (registered in +// migrations.MigratedModels) on databases whose earlier versions are already +// recorded in sys_migration. +func init() { + _, f, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(f), migrateYeekeSyncRunMissingCounts) +} +func migrateYeekeSyncRunMissingCounts(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := migrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/web/src/views/goauto/yeeke-sync-runs/index.vue b/web/src/views/goauto/yeeke-sync-runs/index.vue index 3b96eb3..1084c04 100644 --- a/web/src/views/goauto/yeeke-sync-runs/index.vue +++ b/web/src/views/goauto/yeeke-sync-runs/index.vue @@ -41,6 +41,8 @@ + +
From b72fa105a80dfa789f1e451d8a8224ca087915bc Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 16:27:38 +0800 Subject: [PATCH 12/12] feat(web): flag matched returns past their destroy deadline (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A match whose return has passed its destroy deadline keeps blocking purchase (user decision: remind, don't auto-cancel). Show 「退货已过销毁截止」 in the SYB products match column and compare dialog, and 「已过销毁截止」 under the occupying SYB product on the yeeke returns page. Uses the live package deadline, so a resync that moves the deadline updates the flag. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- web/src/views/goauto/syb-products/index.vue | 4 ++++ web/src/views/goauto/yeeke-returns/index.vue | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index e9496bf..ca4037c 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -63,6 +63,7 @@ +