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) + } +}