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