feat(yeeke): add match-status filter and occupying-SYB column to returns list (#338)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user