feat(yeeke): switch returns admin list to item-level rows with shop filter (#337)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
@@ -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},
|
||||
|
||||
+196
-44
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderSn" clearable placeholder="订单号" style="width:150px" /></el-form-item>
|
||||
<el-form-item label="店铺"><el-input v-model="query.shop" clearable placeholder="店铺ID或名称" style="width:130px" /></el-form-item>
|
||||
<el-form-item label="店铺">
|
||||
<el-select v-model="query.shop" clearable filterable placeholder="全部店铺" style="width:150px">
|
||||
<el-option v-for="name in shopOptions" :key="name" :label="name" :value="name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="认领状态">
|
||||
<el-select v-model="query.claimStatus" clearable placeholder="全部" style="width:130px">
|
||||
<el-option v-for="item in claimStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
@@ -22,12 +26,38 @@
|
||||
</el-form>
|
||||
|
||||
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="notice"><template #default><el-button link type="primary" @click="load">重新加载</el-button></template></el-alert>
|
||||
<el-table v-loading="loading" :data="items" border stripe empty-text="暂无退货包裹">
|
||||
<el-table v-loading="loading" :data="items" border stripe row-key="key" empty-text="暂无退货商品">
|
||||
<el-table-column label="订单号" prop="orderSn" min-width="130" />
|
||||
<el-table-column label="退货物流单号" prop="trackingNo" min-width="140" />
|
||||
<el-table-column label="店铺" min-width="120"><template #default="{ row }">{{ row.shopName || row.shopId || '—' }}</template></el-table-column>
|
||||
<el-table-column label="商品参考图" width="76">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.hasItem && row.image" :src="row.image" fit="cover" class="thumb" :preview-src-list="[row.image]" preview-teleported />
|
||||
<div v-else-if="row.hasItem" class="thumb placeholder">无图</div>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品标题/规格" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.hasItem">
|
||||
{{ row.itemName || '—' }}
|
||||
<div class="muted">{{ row.variationName || '—' }}</div>
|
||||
</template>
|
||||
<span v-else class="muted">(无商品明细)</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品ID/规格ID" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.hasItem">
|
||||
{{ row.shopeeItemId || '—' }}
|
||||
<div class="muted">{{ row.variationId || '—' }}</div>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" width="70">
|
||||
<template #default="{ row }">{{ row.hasItem ? row.quantity : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="店铺" min-width="120"><template #default="{ row }">{{ row.shopName || '—' }}</template></el-table-column>
|
||||
<el-table-column label="仓库/库位" min-width="140"><template #default="{ row }">{{ row.wareHouse || row.wareCode || '—' }}</template></el-table-column>
|
||||
<el-table-column label="商品数" prop="itemCount" width="80" />
|
||||
<el-table-column label="认领状态" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.statusUnrecognized" type="danger" effect="plain">未知:{{ row.claimStatus || '—' }}</el-tag>
|
||||
@@ -35,38 +65,63 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="认领时间" min-width="150"><template #default="{ row }">{{ formatTime(row.claimTime) }}</template></el-table-column>
|
||||
<el-table-column label="创建时间" min-width="150"><template #default="{ row }">{{ formatTime(row.createTime) }}</template></el-table-column>
|
||||
<el-table-column label="销毁截止" min-width="150"><template #default="{ row }">{{ formatTime(row.destroyDeadLine) }}</template></el-table-column>
|
||||
<el-table-column label="最近同步" min-width="150"><template #default="{ row }">{{ formatTime(row.lastSyncedAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button></template></el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row)">详情</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<el-drawer v-model="detail.open" title="退货包裹详情" size="720px">
|
||||
<el-drawer v-model="detail.open" title="退货商品详情" size="720px">
|
||||
<div v-loading="detail.loading" class="drawer-body">
|
||||
<template v-if="detail.item">
|
||||
<template v-if="detail.placeholder && detail.pkg">
|
||||
<el-alert title="该包裹无商品明细" type="info" show-icon :closable="false" class="notice" />
|
||||
<h3 class="section-title">所属退货包裹</h3>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="订单号">{{ detail.item.orderSn }}</el-descriptions-item>
|
||||
<el-descriptions-item label="退货物流单号">{{ detail.item.trackingNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺">{{ detail.item.shopName || detail.item.shopId || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="仓库/库位">{{ detail.item.wareHouse || detail.item.wareCode || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单号">{{ detail.pkg.orderSn }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺">{{ detail.pkg.shopName || detail.pkg.shopId || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="仓库/库位">{{ detail.pkg.wareHouse || detail.pkg.wareCode || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="认领状态">
|
||||
<el-tag v-if="detail.item.statusUnrecognized" type="danger" effect="plain">未知:{{ detail.item.claimStatus || '—' }}</el-tag>
|
||||
<el-tag v-else :type="claimStatusMeta(detail.item.claimStatus).type">{{ claimStatusMeta(detail.item.claimStatus).label }}</el-tag>
|
||||
<el-tag v-if="detail.pkg.statusUnrecognized" type="danger" effect="plain">未知:{{ detail.pkg.claimStatus || '—' }}</el-tag>
|
||||
<el-tag v-else :type="claimStatusMeta(detail.pkg.claimStatus).type">{{ claimStatusMeta(detail.pkg.claimStatus).label }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="认领时间">{{ formatTime(detail.item.claimTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(detail.item.createTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="销毁截止">{{ formatTime(detail.item.destroyDeadLine) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近同步">{{ formatTime(detail.item.lastSyncedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="认领时间">{{ formatTime(detail.pkg.claimTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(detail.pkg.createTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTime(detail.pkg.updateTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="销毁截止">{{ formatTime(detail.pkg.destroyDeadLine) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近同步">{{ formatTime(detail.pkg.lastSyncedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3 class="section-title">包裹内商品明细</h3>
|
||||
<el-table :data="detail.item.items" border size="small" empty-text="暂无商品明细">
|
||||
<el-table-column label="图片" width="64"><template #default="{ row }"><el-image v-if="row.image" :src="row.image" fit="cover" class="thumb" :preview-src-list="[row.image]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column>
|
||||
<el-table-column label="商品ID" prop="itemId" min-width="100" />
|
||||
<el-table-column label="规格ID" prop="variationId" min-width="100" />
|
||||
</template>
|
||||
<template v-else-if="detail.item">
|
||||
<div class="item-hero">
|
||||
<el-image v-if="detail.item.image" :src="detail.item.image" fit="cover" class="thumb-lg" :preview-src-list="[detail.item.image]" preview-teleported />
|
||||
<div v-else class="thumb-lg placeholder">无图</div>
|
||||
<div class="item-hero-info">
|
||||
<div class="item-title">{{ detail.item.itemName || '—' }}</div>
|
||||
<div class="muted">{{ detail.item.variationName || '—' }}</div>
|
||||
<div class="muted">商品ID:{{ detail.item.itemId || '—' }} / 规格ID:{{ detail.item.variationId || '—' }}</div>
|
||||
<div class="muted">退货数量:{{ detail.item.quantity }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="section-title">所属退货包裹</h3>
|
||||
<el-descriptions v-if="detail.pkg" :column="2" border size="small">
|
||||
<el-descriptions-item label="订单号">{{ detail.pkg.orderSn }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺">{{ detail.pkg.shopName || detail.pkg.shopId || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="仓库/库位">{{ detail.pkg.wareHouse || detail.pkg.wareCode || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="认领状态">
|
||||
<el-tag v-if="detail.pkg.statusUnrecognized" type="danger" effect="plain">未知:{{ detail.pkg.claimStatus || '—' }}</el-tag>
|
||||
<el-tag v-else :type="claimStatusMeta(detail.pkg.claimStatus).type">{{ claimStatusMeta(detail.pkg.claimStatus).label }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="认领时间">{{ formatTime(detail.pkg.claimTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(detail.pkg.createTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTime(detail.pkg.updateTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="销毁截止">{{ formatTime(detail.pkg.destroyDeadLine) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近同步">{{ formatTime(detail.pkg.lastSyncedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3 class="section-title">同包裹其他商品</h3>
|
||||
<el-table :data="detail.siblings" border size="small" empty-text="无其他商品">
|
||||
<el-table-column label="标题/规格" min-width="220"><template #default="{ row }">{{ row.itemName || '—' }}<div class="muted">{{ row.variationName || '—' }}</div></template></el-table-column>
|
||||
<el-table-column label="退货数量" prop="quantity" width="90" />
|
||||
<el-table-column label="数量" prop="quantity" width="80" />
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
@@ -77,7 +132,7 @@
|
||||
|
||||
<script>
|
||||
import { Search, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { listYeekeReturnPackages, getYeekeReturnPackage, listYeekeSyncRuns } from '@/api/goauto/yeeke-returns'
|
||||
import { listYeekeReturnPackages, getYeekeReturnPackage, getYeekeReturnItem, listYeekeReturnShops, listYeekeSyncRuns } from '@/api/goauto/yeeke-returns'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoYeekeReturns',
|
||||
@@ -86,8 +141,9 @@ export default {
|
||||
return {
|
||||
loading: false, loadError: '', items: [], total: 0, createTimeRange: null,
|
||||
summary: { lastSuccessAt: '', lastRun: null }, summaryError: '',
|
||||
shopOptions: [],
|
||||
query: { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' },
|
||||
detail: { open: false, loading: false, item: null },
|
||||
detail: { open: false, loading: false, placeholder: false, item: null, pkg: null, siblings: [] },
|
||||
claimStatusOptions: [
|
||||
{ label: '未知(原样展示)', value: 'unknown' },
|
||||
{ label: '待认领', value: 'pending' },
|
||||
@@ -96,7 +152,7 @@ export default {
|
||||
]
|
||||
}
|
||||
},
|
||||
created() { this.load(); this.loadSummary() },
|
||||
created() { this.load(); this.loadSummary(); this.loadShops() },
|
||||
methods: {
|
||||
claimStatusMeta(status) { return { pending: { label: '待认领', type: 'info' }, claimed: { label: '已认领', type: 'success' }, rejected: { label: '已拒绝', type: 'danger' }}[status] || { label: status || '—', type: 'info' } },
|
||||
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
|
||||
@@ -109,19 +165,28 @@ export default {
|
||||
this.items = r.data.items
|
||||
this.total = r.data.total
|
||||
} catch (error) {
|
||||
this.loadError = error?.response?.data?.message || error?.message || '退货包裹加载失败'
|
||||
this.loadError = error?.response?.data?.message || error?.message || '退货商品加载失败'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async loadShops() {
|
||||
try {
|
||||
const r = await listYeekeReturnShops({ suppressNetworkError: true, suppressErrorMessage: true })
|
||||
this.shopOptions = r.data.shops || []
|
||||
} catch (error) {
|
||||
// 店铺下拉加载失败时不阻塞列表,筛选项保持为空。
|
||||
this.shopOptions = []
|
||||
}
|
||||
},
|
||||
async loadSummary() {
|
||||
this.summaryError = ''
|
||||
try {
|
||||
const r = await listYeekeSyncRuns({ page: 1, pageSize: 1 }, { suppressNetworkError: true, suppressErrorMessage: true })
|
||||
this.summary = { lastSuccessAt: r.data.lastSuccessAt, lastRun: r.data.items[0] || null }
|
||||
} catch (error) {
|
||||
// 同步失败时仍显示上次已加载的包裹列表,只在摘要条提示。
|
||||
this.summaryError = '同步状态摘要加载失败,仍显示已有退货包裹数据'
|
||||
// 同步失败时仍显示上次已加载的商品列表,只在摘要条提示。
|
||||
this.summaryError = '同步状态摘要加载失败,仍显示已有退货商品数据'
|
||||
}
|
||||
},
|
||||
search() {
|
||||
@@ -136,11 +201,18 @@ export default {
|
||||
this.query = { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' }
|
||||
this.load()
|
||||
},
|
||||
async openDetail(id) {
|
||||
this.detail = { open: true, loading: true, item: null }
|
||||
async openDetail(row) {
|
||||
this.detail = { open: true, loading: true, placeholder: !row.hasItem, item: null, pkg: null, siblings: [] }
|
||||
try {
|
||||
const r = await getYeekeReturnPackage(id)
|
||||
this.detail.item = r.data.item
|
||||
if (!row.hasItem) {
|
||||
const r = await getYeekeReturnPackage(row.packageId)
|
||||
this.detail.pkg = r.data.item
|
||||
} else {
|
||||
const r = await getYeekeReturnItem(row.itemId)
|
||||
this.detail.item = r.data.item
|
||||
this.detail.pkg = r.data.package
|
||||
this.detail.siblings = r.data.siblings || []
|
||||
}
|
||||
} finally {
|
||||
this.detail.loading = false
|
||||
}
|
||||
@@ -162,6 +234,11 @@ export default {
|
||||
.section-title{margin:24px 0 12px}
|
||||
.thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}
|
||||
.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
|
||||
.thumb-lg{width:96px;height:96px;border-radius:6px;object-fit:cover;flex-shrink:0}
|
||||
.thumb-lg.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:12px}
|
||||
.item-hero{display:flex;gap:16px}
|
||||
.item-hero-info{flex:1;min-width:0}
|
||||
.item-title{font-size:15px;font-weight:600;color:#1f2937;margin-bottom:4px}
|
||||
.muted{font-size:12px;color:#909399}
|
||||
@media(max-width:800px){.page-heading{flex-direction:column}}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user