feat(returnmatch): DB-backed batch match/confirm/cancel/remark API for #338

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-09-24 11:48:58 +08:00
co-authored by Claude Opus 5.5
parent 9bebf3930d
commit fbde80cc20
5 changed files with 809 additions and 0 deletions
+2
View File
@@ -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)
}
+215
View File
@@ -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
}
+21
View File
@@ -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)
}
+301
View File
@@ -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")
@@ -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)
}
}