From 248eae01ee6c3bcf71fc113361b0de426ba57206 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 23 Sep 2026 16:37:56 +0800 Subject: [PATCH] feat(yeeke): switch returns admin list to item-level rows with shop filter (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change GET /api/admin/v1/yeeke-returns to return one row per yeeke_return_item joined with its parent package, with a placeholder row (hasItem:false) for packages that have no items. Add GET /yeeke-returns/shops (distinct sorted shop names) and GET /yeeke-returns/items/:itemId (item + parent package + sibling items) as static-prefixed routes registered before the /:packageId wildcard so they are not swallowed by it. Shop filter is now an exact match instead of a partial LIKE. Remove trackingNo/itemCount from the list projection while keeping them on package detail. Update the admin web list to item-level rows (reference image column, 店铺 dropdown sourced from /shops) and retitle the detail drawer to "退货商品详情", switching it to the item-detail endpoint for real rows and the existing package-detail endpoint for placeholder rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/access/purchaser.go | 2 + server/app/goauto/yeeke/list.go | 240 ++++++++++++++---- server/app/goauto/yeeke/list_test.go | 251 ++++++++++++++++--- server/app/goauto/yeeke/router.go | 4 + web/src/api/goauto/yeeke-returns.js | 8 + web/src/views/goauto/yeeke-returns/index.vue | 147 ++++++++--- 6 files changed, 536 insertions(+), 116 deletions(-) diff --git a/server/app/goauto/access/purchaser.go b/server/app/goauto/access/purchaser.go index 9adf33d..0fdb88d 100644 --- a/server/app/goauto/access/purchaser.go +++ b/server/app/goauto/access/purchaser.go @@ -125,6 +125,8 @@ var AdminAPIs = []APIPermission{ {"处理结果不明确任务", "/api/admin/v1/purchase-tasks/:taskId/resolve-unknown", "POST", true}, {"查看 yeeke 退货包裹列表", "/api/admin/v1/yeeke-returns", "GET", true}, + {"查看 yeeke 退货店铺列表", "/api/admin/v1/yeeke-returns/shops", "GET", true}, + {"查看 yeeke 退货商品详情", "/api/admin/v1/yeeke-returns/items/:itemId", "GET", true}, {"查看 yeeke 退货包裹详情", "/api/admin/v1/yeeke-returns/:packageId", "GET", true}, {"查看 yeeke 同步记录", "/api/admin/v1/yeeke-returns/sync-runs", "GET", true}, {"查看 yeeke 同步详情", "/api/admin/v1/yeeke-returns/sync-runs/:runId", "GET", true}, diff --git a/server/app/goauto/yeeke/list.go b/server/app/goauto/yeeke/list.go index 8f468cd..08f559e 100644 --- a/server/app/goauto/yeeke/list.go +++ b/server/app/goauto/yeeke/list.go @@ -3,6 +3,7 @@ package yeeke import ( "errors" "net/http" + "sort" "strconv" "strings" "time" @@ -51,6 +52,32 @@ type ReturnPackageDTO struct { Items []ReturnItemDTO `json:"items,omitempty"` } +// ReturnItemRowDTO is one row of the #337 item-level admin list: one row per +// yeeke_return_item, joined with its parent package. A package with no items +// still produces exactly one placeholder row (HasItem=false, item fields +// empty/zero) so it is never silently dropped from the list. +type ReturnItemRowDTO struct { + Key string `json:"key"` + PackageID uint64 `json:"packageId"` + ItemID uint64 `json:"itemId"` + OrderSN string `json:"orderSn"` + Image string `json:"image"` + ItemName string `json:"itemName"` + VariationName string `json:"variationName"` + ShopeeItemID string `json:"shopeeItemId"` + VariationID string `json:"variationId"` + Quantity int64 `json:"quantity"` + ShopName string `json:"shopName"` + WareHouse string `json:"wareHouse"` + WareCode string `json:"wareCode"` + ClaimStatus string `json:"claimStatus"` + StatusUnrecognized bool `json:"statusUnrecognized"` + ClaimTime *string `json:"claimTime"` + DestroyDeadLine *string `json:"destroyDeadLine"` + LastSyncedAt string `json:"lastSyncedAt"` + HasItem bool `json:"hasItem"` +} + func formatTimePtr(t *time.Time) *string { if t == nil { return nil @@ -77,6 +104,71 @@ func toItemDTO(i models.YeekeReturnItem) ReturnItemDTO { } } +// itemRow mirrors one row of the LEFT JOIN query in ListReturnItemRows: every +// package column plus every (nullable) item column. +type itemRow struct { + PackageID uint64 + OrderSN string + ShopName string + WareHouse string + WareCode string + ClaimStatus string + StatusUnrecognized bool + ClaimTime *time.Time + DestroyDeadLine *time.Time + PackageLastSynced time.Time + + ItemID *uint64 + ItemExternalItem *string + ItemVariationID *string + ItemName *string + ItemVariationName *string + ItemImage *string + ItemQuantity *int64 + ItemLastSynced *time.Time +} + +func toItemRowDTO(r itemRow) ReturnItemRowDTO { + dto := ReturnItemRowDTO{ + PackageID: r.PackageID, OrderSN: r.OrderSN, ShopName: r.ShopName, + WareHouse: r.WareHouse, WareCode: r.WareCode, ClaimStatus: r.ClaimStatus, + StatusUnrecognized: r.StatusUnrecognized, + ClaimTime: formatTimePtr(r.ClaimTime), + DestroyDeadLine: formatTimePtr(r.DestroyDeadLine), + LastSyncedAt: r.PackageLastSynced.UTC().Format("2006-01-02T15:04:05Z"), + } + if r.ItemID == nil || *r.ItemID == 0 { + dto.Key = "p" + strconv.FormatUint(r.PackageID, 10) + dto.HasItem = false + return dto + } + dto.Key = strconv.FormatUint(*r.ItemID, 10) + dto.ItemID = *r.ItemID + dto.HasItem = true + if r.ItemExternalItem != nil { + dto.ShopeeItemID = *r.ItemExternalItem + } + if r.ItemVariationID != nil { + dto.VariationID = *r.ItemVariationID + } + if r.ItemName != nil { + dto.ItemName = *r.ItemName + } + if r.ItemVariationName != nil { + dto.VariationName = *r.ItemVariationName + } + if r.ItemImage != nil { + dto.Image = *r.ItemImage + } + if r.ItemQuantity != nil { + dto.Quantity = *r.ItemQuantity + } + if r.ItemLastSynced != nil { + dto.LastSyncedAt = r.ItemLastSynced.UTC().Format("2006-01-02T15:04:05Z") + } + return dto +} + // ListReturnPackages returns a paginated, filtered list of return packages. // Filters: order sn, tracking no, shop (id or name, partial match), itemId / // variationId (matched against yeeke_return_item, requires a join), claim @@ -97,24 +189,27 @@ func (h Handler) ListReturnPackages(c *gin.Context) { return } - query := db.Model(&models.YeekeReturnPackage{}) + base := db.Table("yeeke_return_package p"). + Joins("LEFT JOIN yeeke_return_item i ON i.package_id = p.id") if v := strings.TrimSpace(c.Query("orderSn")); v != "" { - query = query.Where("order_sn LIKE ?", "%"+v+"%") + base = base.Where("p.order_sn LIKE ?", "%"+v+"%") } if v := strings.TrimSpace(c.Query("trackingNo")); v != "" { - query = query.Where("tracking_no LIKE ?", "%"+v+"%") + base = base.Where("p.tracking_no LIKE ?", "%"+v+"%") } + // #337 #1: 店铺 filter switched from free-text partial match to an exact + // match against p.shop_name, matching the dropdown surfaced by /shops. if v := strings.TrimSpace(c.Query("shop")); v != "" { - query = query.Where("shop_id LIKE ? OR shop_name LIKE ?", "%"+v+"%", "%"+v+"%") + base = base.Where("p.shop_name = ?", v) } claimStatus := strings.TrimSpace(c.Query("claimStatus")) switch claimStatus { case "": // no filter case "unknown": - query = query.Where("status_unrecognized = ?", true) + base = base.Where("p.status_unrecognized = ?", true) default: - query = query.Where("claim_status = ? AND status_unrecognized = ?", claimStatus, false) + base = base.Where("p.claim_status = ? AND p.status_unrecognized = ?", claimStatus, false) } if v := strings.TrimSpace(c.Query("createTimeFrom")); v != "" { t, perr := parseFilterTime(v) @@ -122,7 +217,7 @@ func (h Handler) ListReturnPackages(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "createTimeFrom 格式无效"}) return } - query = query.Where("create_time >= ?", t) + base = base.Where("p.create_time >= ?", t) } if v := strings.TrimSpace(c.Query("createTimeTo")); v != "" { t, perr := parseFilterTime(v) @@ -130,51 +225,39 @@ func (h Handler) ListReturnPackages(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "createTimeTo 格式无效"}) return } - query = query.Where("create_time <= ?", t) + base = base.Where("p.create_time <= ?", t) } - itemID := strings.TrimSpace(c.Query("itemId")) - variationID := strings.TrimSpace(c.Query("variationId")) - if itemID != "" || variationID != "" { - sub := db.Model(&models.YeekeReturnItem{}).Select("package_id") - if itemID != "" { - sub = sub.Where("item_id = ?", itemID) - } - if variationID != "" { - sub = sub.Where("variation_id = ?", variationID) - } - query = query.Where("id IN (?)", sub) + // itemId/variationId kept for backward compatibility (#337): now filter + // the item-level join directly instead of a package sub-select. + if v := strings.TrimSpace(c.Query("itemId")); v != "" { + base = base.Where("i.item_id = ?", v) + } + if v := strings.TrimSpace(c.Query("variationId")); v != "" { + base = base.Where("i.variation_id = ?", v) } var total int64 - if err := query.Count(&total).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) - return - } - var rows []models.YeekeReturnPackage - if err := query.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil { + if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) return } - items := make([]ReturnPackageDTO, 0, len(rows)) - if len(rows) > 0 { - ids := make([]uint64, 0, len(rows)) - for _, r := range rows { - ids = append(ids, r.ID) - } - type countRow struct { - PackageID uint64 - N int - } - var counts []countRow - db.Model(&models.YeekeReturnItem{}).Select("package_id, count(*) as n").Where("package_id IN ?", ids).Group("package_id").Scan(&counts) - countByID := make(map[uint64]int, len(counts)) - for _, cr := range counts { - countByID[cr.PackageID] = cr.N - } - for _, r := range rows { - items = append(items, toPackageDTO(r, countByID[r.ID])) - } + var rows []itemRow + q := base.Session(&gorm.Session{}).Select( + "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", + ).Order("p.create_time desc, i.id asc"). + Offset((page - 1) * pageSize).Limit(pageSize) + if err := q.Find(&rows).Error; err != 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)) } c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{ @@ -182,6 +265,75 @@ func (h Handler) ListReturnPackages(c *gin.Context) { }}) } +// ListReturnShops returns the distinct, non-empty shop names known from +// yeeke_return_package, sorted ascending, for the #337 shop-name dropdown +// filter. It must be registered before the /:packageId wildcard route so it +// is never swallowed by it. +func (h Handler) ListReturnShops(c *gin.Context) { + db, ok := h.db(c) + if !ok { + return + } + var shops []string + if err := db.Model(&models.YeekeReturnPackage{}). + Where("shop_name <> ''"). + Distinct("shop_name"). + Order("shop_name asc"). + Pluck("shop_name", &shops).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + sort.Strings(shops) + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"shops": shops}}) +} + +// ReturnItemDetail returns one item, its parent package, and the sibling +// items of the same package (excluding itself), for the #337 "退货商品详情" +// drawer. It must be registered before the /:packageId wildcard route so +// /yeeke-returns/items/:itemId is never swallowed by it. +func (h Handler) ReturnItemDetail(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("itemId"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "itemId 无效"}) + return + } + db, ok := h.db(c) + if !ok { + return + } + var item models.YeekeReturnItem + if err := db.First(&item, id).Error; 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 + } + var pkg models.YeekeReturnPackage + if err := db.First(&pkg, item.PackageID).Error; 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 + } + var siblingRows []models.YeekeReturnItem + if err := db.Where("package_id = ? AND id <> ?", item.PackageID, item.ID).Order("id asc").Find(&siblingRows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + siblings := make([]ReturnItemDTO, 0, len(siblingRows)) + for _, s := range siblingRows { + siblings = append(siblings, toItemDTO(s)) + } + pkgDTO := toPackageDTO(pkg, len(siblingRows)+1) + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{ + "item": toItemDTO(item), "package": pkgDTO, "siblings": siblings, + }}) +} + // ReturnPackageDetail returns one package with its items. func (h Handler) ReturnPackageDetail(c *gin.Context) { id, err := strconv.ParseUint(c.Param("packageId"), 10, 64) diff --git a/server/app/goauto/yeeke/list_test.go b/server/app/goauto/yeeke/list_test.go index 6e43bbf..b12b8d6 100644 --- a/server/app/goauto/yeeke/list_test.go +++ b/server/app/goauto/yeeke/list_test.go @@ -31,6 +31,13 @@ func setupListTestDB(t *testing.T) (*gin.Engine, func(req *http.Request) *httpte }).Error; err != nil { t.Fatal(err) } + // p3 has no items at all: must still produce exactly one placeholder row. + if err := db.Create(&models.YeekeReturnPackage{ + ExternalID: "p3", OrderSN: "ORDER-3", TrackingNo: "TRACK-3", ShopID: "s3", ShopName: "店铺三", + ClaimStatus: "pending", StatusUnrecognized: false, CreateTime: &older, LastSyncedAt: older, SyncStatus: "ok", + }).Error; err != nil { + t.Fatal(err) + } var pkg1, pkg2 models.YeekeReturnPackage db.Where("external_id = ?", "p1").First(&pkg1) db.Where("external_id = ?", "p2").First(&pkg2) @@ -47,6 +54,12 @@ func setupListTestDB(t *testing.T) (*gin.Engine, func(req *http.Request) *httpte }).Error; err != nil { t.Fatal(err) } + if err := db.Create(&models.YeekeReturnItem{ + PackageID: pkg2.ID, ExternalKey: "k3", ItemID: "item2", VariationID: "v3", ItemName: "商品2", VariationName: "默认", + Image: "https://cdn.example.com/c.jpg", Quantity: 3, LastSyncedAt: older, SyncStatus: "ok", + }).Error; err != nil { + t.Fatal(err) + } if err := db.Create(&models.YeekeSyncRun{ Status: "succeeded", Trigger: "manual", StartedAt: now, @@ -62,6 +75,8 @@ func setupListTestDB(t *testing.T) (*gin.Engine, func(req *http.Request) *httpte handler := Handler{DB: db} engine := gin.New() engine.GET("/api/admin/v1/yeeke-returns", handler.ListReturnPackages) + engine.GET("/api/admin/v1/yeeke-returns/shops", handler.ListReturnShops) + engine.GET("/api/admin/v1/yeeke-returns/items/:itemId", handler.ReturnItemDetail) engine.GET("/api/admin/v1/yeeke-returns/:packageId", handler.ReturnPackageDetail) engine.GET("/api/admin/v1/yeeke-returns/sync-runs", handler.ListSyncRuns) @@ -73,41 +88,93 @@ func setupListTestDB(t *testing.T) (*gin.Engine, func(req *http.Request) *httpte return engine, do } -func TestListReturnPackagesFiltersByOrderSn(t *testing.T) { +type itemRowListResp struct { + Data struct { + Items []ReturnItemRowDTO `json:"items"` + Total int64 `json:"total"` + } `json:"data"` +} + +func TestListReturnItemsFiltersByOrderSn(t *testing.T) { _, do := setupListTestDB(t) w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil)) if w.Code != http.StatusOK { t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) } - var resp struct { - Data struct { - Items []ReturnPackageDTO `json:"items"` - Total int64 `json:"total"` - } `json:"data"` + var resp itemRowListResp + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) } + if resp.Data.Total != 2 || len(resp.Data.Items) != 2 { + t.Fatalf("expected 2 item rows for a 2-item package, got: %+v", resp.Data) + } + for _, row := range resp.Data.Items { + if row.OrderSN != "ORDER-1" || !row.HasItem { + t.Fatalf("unexpected row: %+v", row) + } + } +} + +func TestListReturnItemsPlaceholderRowForItemlessPackage(t *testing.T) { + _, do := setupListTestDB(t) + w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-3", nil)) + var resp itemRowListResp if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if resp.Data.Total != 1 || len(resp.Data.Items) != 1 { - t.Fatalf("unexpected result: %+v", resp.Data) + t.Fatalf("expected exactly one placeholder row, got: %+v", resp.Data) } - if resp.Data.Items[0].OrderSN != "ORDER-1" { - t.Fatalf("unexpected order: %+v", resp.Data.Items[0]) + row := resp.Data.Items[0] + if row.HasItem { + t.Fatalf("expected hasItem=false for item-less package, got: %+v", row) } - if resp.Data.Items[0].ItemCount != 2 { - t.Fatalf("expected item count 2, got %d", resp.Data.Items[0].ItemCount) + if row.ItemID != 0 || row.ItemName != "" { + t.Fatalf("expected empty/zero item fields on placeholder row, got: %+v", row) } } -func TestListReturnPackagesUnknownClaimStatusFilter(t *testing.T) { +func TestListReturnItemsPaginationAndCountOverItemRows(t *testing.T) { + _, do := setupListTestDB(t) + // total rows across all packages: 2 (p1) + 1 (p2) + 1 placeholder (p3) = 4 + w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?pageSize=2&page=1", nil)) + var resp itemRowListResp + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Data.Total != 4 { + t.Fatalf("expected total=4 item-level rows, got %d", resp.Data.Total) + } + if len(resp.Data.Items) != 2 { + t.Fatalf("expected page size 2 applied to item-level rows, got %d", len(resp.Data.Items)) + } +} + +func TestListReturnItemsShopFilterIsExactMatch(t *testing.T) { + _, do := setupListTestDB(t) + w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?shop=%E5%BA%97", nil)) // partial "店" must not match + var resp itemRowListResp + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Data.Total != 0 { + t.Fatalf("expected no matches for a partial shop name (exact match required), got %d", resp.Data.Total) + } + + w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?shop=%E5%BA%97%E9%93%BA%E4%B8%80", nil)) // "店铺一" + var resp2 itemRowListResp + if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil { + t.Fatal(err) + } + if resp2.Data.Total != 2 { + t.Fatalf("expected exact shop match to find package p1's 2 item rows, got %d", resp2.Data.Total) + } +} + +func TestListReturnItemsUnknownClaimStatusFilter(t *testing.T) { _, do := setupListTestDB(t) w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?claimStatus=unknown", nil)) - var resp struct { - Data struct { - Items []ReturnPackageDTO `json:"items"` - Total int64 `json:"total"` - } `json:"data"` - } + var resp itemRowListResp if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } @@ -122,52 +189,162 @@ func TestListReturnPackagesUnknownClaimStatusFilter(t *testing.T) { } } -func TestListReturnPackagesFiltersByItemAndVariation(t *testing.T) { +func TestListReturnItemsFiltersByItemAndVariationBackwardCompat(t *testing.T) { _, do := setupListTestDB(t) w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?itemId=item1&variationId=v2", nil)) - var resp struct { - Data struct { - Items []ReturnPackageDTO `json:"items"` - Total int64 `json:"total"` - } `json:"data"` - } + var resp itemRowListResp if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } - if resp.Data.Total != 1 || resp.Data.Items[0].OrderSN != "ORDER-1" { + if resp.Data.Total != 1 || resp.Data.Items[0].OrderSN != "ORDER-1" || resp.Data.Items[0].VariationID != "v2" { t.Fatalf("unexpected result: %+v", resp.Data) } } -func TestListReturnPackagesFiltersByCreateTimeRange(t *testing.T) { +func TestListReturnItemsFiltersByCreateTimeRange(t *testing.T) { _, do := setupListTestDB(t) w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?createTimeFrom=2026-09-19&createTimeTo=2026-09-21", nil)) + var resp itemRowListResp + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Data.Total != 2 || resp.Data.Items[0].OrderSN != "ORDER-1" { + t.Fatalf("unexpected result: %+v", resp.Data) + } +} + +func TestListReturnShopsReturnsDistinctSortedNames(t *testing.T) { + _, do := setupListTestDB(t) + w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/shops", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } var resp struct { Data struct { - Items []ReturnPackageDTO `json:"items"` - Total int64 `json:"total"` + Shops []string `json:"shops"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } - if resp.Data.Total != 1 || resp.Data.Items[0].OrderSN != "ORDER-1" { - t.Fatalf("unexpected result: %+v", resp.Data) + want := []string{"店铺一", "店铺三", "店铺二"} + if len(resp.Data.Shops) != len(want) { + t.Fatalf("expected %d distinct shops, got %+v", len(want), resp.Data.Shops) + } + for i := range want { + if resp.Data.Shops[i] != want[i] { + t.Fatalf("expected sorted shops %+v, got %+v", want, resp.Data.Shops) + } + } +} + +func TestReturnItemDetailReturnsSiblingsExcludingSelf(t *testing.T) { + _, do := setupListTestDB(t) + listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil)) + var listResp itemRowListResp + if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil { + t.Fatal(err) + } + if len(listResp.Data.Items) != 2 { + t.Fatalf("expected 2 item rows to pick from, got %+v", listResp.Data.Items) + } + target := listResp.Data.Items[0] + + w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/"+itoa(target.ItemID), nil)) + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var resp struct { + Data struct { + Item ReturnItemDTO `json:"item"` + Package ReturnPackageDTO `json:"package"` + Siblings []ReturnItemDTO `json:"siblings"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Data.Item.ID != target.ItemID { + t.Fatalf("expected item id %d, got %d", target.ItemID, resp.Data.Item.ID) + } + if resp.Data.Package.OrderSN != "ORDER-1" { + t.Fatalf("expected parent package ORDER-1, got %+v", resp.Data.Package) + } + if len(resp.Data.Siblings) != 1 { + t.Fatalf("expected exactly 1 sibling (the other item in the same package), got %+v", resp.Data.Siblings) + } + if resp.Data.Siblings[0].ID == resp.Data.Item.ID { + t.Fatal("siblings must exclude the current item") + } +} + +func TestReturnItemDetailNotFound(t *testing.T) { + _, do := setupListTestDB(t) + w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/999999", nil)) + if w.Code != http.StatusNotFound { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestYeekeReturnsRoutesDoNotCollide(t *testing.T) { + // Routing test proving /yeeke-returns/shops and /yeeke-returns/items/:id + // are not swallowed by the /yeeke-returns/:packageId wildcard route. + _, do := setupListTestDB(t) + + w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/shops", nil)) + if w.Code != http.StatusOK { + t.Fatalf("/shops should hit ListReturnShops, got status=%d body=%s", w.Code, w.Body.String()) + } + var shopsResp struct { + Data struct { + Shops []string `json:"shops"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &shopsResp); err != nil { + t.Fatal(err) + } + if len(shopsResp.Data.Shops) == 0 { + t.Fatal("/shops must hit ListReturnShops, not ReturnPackageDetail (which would 400/404 on a non-numeric id)") + } + + listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil)) + var listResp itemRowListResp + if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil { + t.Fatal(err) + } + itemID := listResp.Data.Items[0].ItemID + + w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/"+itoa(itemID), nil)) + if w2.Code != http.StatusOK { + t.Fatalf("/items/:itemId should hit ReturnItemDetail, got status=%d body=%s", w2.Code, w2.Body.String()) + } + var itemResp struct { + Data struct { + Item ReturnItemDTO `json:"item"` + } `json:"data"` + } + if err := json.Unmarshal(w2.Body.Bytes(), &itemResp); err != nil { + t.Fatal(err) + } + if itemResp.Data.Item.ID != itemID { + t.Fatalf("/items/:itemId must hit ReturnItemDetail, got item=%+v", itemResp.Data.Item) + } + + // The plain numeric package-id route must still work unchanged. + w3 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/999999", nil)) + if w3.Code != http.StatusNotFound { + t.Fatalf("/:packageId route must still work for a numeric id, got status=%d", w3.Code) } } func TestReturnPackageDetailIncludesSeparateItemRows(t *testing.T) { _, do := setupListTestDB(t) listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil)) - var listResp struct { - Data struct { - Items []ReturnPackageDTO `json:"items"` - } `json:"data"` - } + var listResp itemRowListResp if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil { t.Fatal(err) } - id := listResp.Data.Items[0].ID + id := listResp.Data.Items[0].PackageID w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/"+itoa(id), nil)) if w.Code != http.StatusOK { diff --git a/server/app/goauto/yeeke/router.go b/server/app/goauto/yeeke/router.go index 838e924..fa0654e 100644 --- a/server/app/goauto/yeeke/router.go +++ b/server/app/goauto/yeeke/router.go @@ -18,6 +18,10 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { handler := Handler{} group := engine.Group("/api/admin/v1/yeeke-returns").Use(auth.MiddlewareFunc()) group.GET("", handler.ListReturnPackages) + // #337: static-prefixed routes must be registered before the + // /:packageId wildcard route below so they are never swallowed by it. + group.GET("/shops", handler.ListReturnShops) + group.GET("/items/:itemId", handler.ReturnItemDetail) group.GET("/:packageId", handler.ReturnPackageDetail) group.GET("/sync-runs", handler.ListSyncRuns) group.GET("/sync-runs/:runId", handler.SyncRunDetail) diff --git a/web/src/api/goauto/yeeke-returns.js b/web/src/api/goauto/yeeke-returns.js index 38be4c1..513b36f 100644 --- a/web/src/api/goauto/yeeke-returns.js +++ b/web/src/api/goauto/yeeke-returns.js @@ -8,6 +8,14 @@ export function getYeekeReturnPackage(packageId) { return request({ url: `/api/admin/v1/yeeke-returns/${packageId}`, method: 'get' }) } +export function listYeekeReturnShops(options = {}) { + return request({ url: '/api/admin/v1/yeeke-returns/shops', method: 'get', ...options }) +} + +export function getYeekeReturnItem(itemId) { + return request({ url: `/api/admin/v1/yeeke-returns/items/${itemId}`, method: 'get' }) +} + export function listYeekeSyncRuns(params, options = {}) { return request({ url: '/api/admin/v1/yeeke-returns/sync-runs', method: 'get', params, ...options }) } diff --git a/web/src/views/goauto/yeeke-returns/index.vue b/web/src/views/goauto/yeeke-returns/index.vue index 978d037..5be9c81 100644 --- a/web/src/views/goauto/yeeke-returns/index.vue +++ b/web/src/views/goauto/yeeke-returns/index.vue @@ -11,7 +11,11 @@ - + + + + + @@ -22,12 +26,38 @@ - + - - + + + + + + + + + + + + + - - - + - +
-