From 9d9adfbd5ef308f61be6d41df1b6e5d637746e43 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 9 Oct 2026 12:46:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(pickup):=20=E7=BA=BF=E4=B8=8B=E6=8F=90?= =?UTF-8?q?=E5=8F=B7=E6=94=AF=E6=8C=81=E6=89=A9=E5=B1=95=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=BA=97=E9=93=BA=E7=AE=A1=E7=90=86CRUD=E5=8F=8A=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 线下提号创建表单扩展:增加「操作人」、「第三方单号」、「下单金额」、「押金金额」字段,重构弹窗布局为紧凑双列/三列金额卡片排布,消除过度垂直滚动并默认预填操作人 - 店铺管理 CRUD:新增 pickup_shops 配置表与配套 API,支持在提号页面直接管理店铺(增删改查、启用/禁用、筛选),下拉选项自动优先加载活跃配置店铺并兼顾历史店铺 - 列表时间范围筛选:增加精确到秒的「日期+时间」时间范围选择器,支持快捷时段与后端时间范围过滤 - 生产环境数据核查:只读提取线上全部 61 项历史提号店铺名称与单量分布,完整整理沉淀至实施文档供后续映射规则参考 - 验证方式:执行并通过全套端到端代码门禁(scripts/check.sh:SQL约束、go vet、go build、MySQL真实测试库单元测试及覆盖率、vue大小检查、prettier、eslint、vue-tsc及vite构建) --- backend/internal/model/pickup.go | 4 + backend/internal/model/pickup_shop.go | 19 + backend/internal/modules/pickup/dto.go | 60 ++- backend/internal/modules/pickup/handler.go | 77 ++++ .../internal/modules/pickup/handler_parse.go | 34 ++ backend/internal/modules/pickup/repository.go | 289 ++++++++++++- .../modules/pickup/repository_test.go | 157 +++++++ backend/internal/modules/pickup/service.go | 28 ++ backend/internal/router/router.go | 4 + ...79_admin_pickup_extra_fields_and_shops.sql | 35 ++ docs/线下提号字段扩展与店铺管理实施方案.md | 153 +++++++ .../src/features/admin/api/adminPickup.ts | 69 +++ .../admin/components/PickupShopDialog.vue | 401 ++++++++++++++++++ .../admin/views/AdminPickupDetailView.vue | 32 ++ .../features/admin/views/AdminPickupView.css | 24 ++ .../features/admin/views/AdminPickupView.vue | 260 +++++++++--- 16 files changed, 1571 insertions(+), 75 deletions(-) create mode 100644 backend/internal/model/pickup_shop.go create mode 100644 backend/migrations/000079_admin_pickup_extra_fields_and_shops.sql create mode 100644 docs/线下提号字段扩展与店铺管理实施方案.md create mode 100644 frontend/src/features/admin/components/PickupShopDialog.vue diff --git a/backend/internal/model/pickup.go b/backend/internal/model/pickup.go index 5e4f16e..ed5c6d6 100644 --- a/backend/internal/model/pickup.go +++ b/backend/internal/model/pickup.go @@ -16,12 +16,16 @@ type AdminPickup struct { AccountID uint64 `gorm:"not null;index" json:"account_id"` OwnerID uint64 `gorm:"not null;index" json:"owner_id"` AdminID uint64 `gorm:"not null" json:"admin_id"` + Operator string `gorm:"size:64;not null;default:'';index" json:"operator"` Platform string `gorm:"size:32;not null;default:''" json:"platform"` + ThirdPartyOrderNo string `gorm:"size:128;not null;default:'';index" json:"third_party_order_no"` ShopName string `gorm:"size:100;not null;default:'';index" json:"shop_name"` AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"` SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"` SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"` ListingPriceCent int64 `gorm:"not null;default:0" json:"listing_price_cent"` + OrderAmountCent int64 `gorm:"not null;default:0" json:"order_amount_cent"` + DepositAmountCent int64 `gorm:"not null;default:0" json:"deposit_amount_cent"` OwnerPriceCent int64 `gorm:"not null;default:0" json:"owner_price_cent"` WebsiteProfitCent int64 `gorm:"not null;default:0" json:"website_profit_cent"` ProfitAmountCent int64 `gorm:"not null;default:0" json:"profit_amount_cent"` diff --git a/backend/internal/model/pickup_shop.go b/backend/internal/model/pickup_shop.go new file mode 100644 index 0000000..f34cee2 --- /dev/null +++ b/backend/internal/model/pickup_shop.go @@ -0,0 +1,19 @@ +package model + +import "time" + +// PickupShop 线下提号店铺配置模型。 +type PickupShop struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null;uniqueIndex" json:"name"` + Platform string `gorm:"size:32;not null;default:''" json:"platform"` + Status string `gorm:"size:20;not null;default:'active';index" json:"status"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + Remark string `gorm:"size:255;not null;default:''" json:"remark"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (PickupShop) TableName() string { + return "pickup_shops" +} diff --git a/backend/internal/modules/pickup/dto.go b/backend/internal/modules/pickup/dto.go index 6117c2d..d03fc26 100644 --- a/backend/internal/modules/pickup/dto.go +++ b/backend/internal/modules/pickup/dto.go @@ -25,6 +25,9 @@ const ( FinancialAdjustmentStatusSettled = "settled" FinancialAdjustmentStatusPending = "pending" FinancialAdjustmentStatusRecovery = "recovery_pending" + + PickupShopStatusActive = "active" + PickupShopStatusDisabled = "disabled" ) var ( @@ -39,6 +42,8 @@ var ( ErrFinancialAdjustmentInvalid = errors.New("financial adjustment invalid") ErrFinancialAdjustmentNotFound = errors.New("financial adjustment not found") ErrFinancialAdjustmentCannotSettle = errors.New("financial adjustment cannot be settled") + ErrPickupShopNotFound = errors.New("pickup shop not found") + ErrPickupShopNameExists = errors.New("pickup shop name already exists") ) type PickupDTO struct { @@ -53,12 +58,16 @@ type PickupDTO struct { OwnerID uint64 `json:"owner_id"` OwnerPhone string `json:"owner_phone"` AdminID uint64 `json:"admin_id"` + Operator string `json:"operator"` Platform string `json:"platform"` + ThirdPartyOrderNo string `json:"third_party_order_no"` ShopName string `json:"shop_name"` AccountSource string `json:"account_source"` SourceChannel string `json:"source_channel"` SettlementMode string `json:"settlement_mode"` ListingPriceCent int64 `json:"listing_price_cent"` + OrderAmountCent int64 `json:"order_amount_cent"` + DepositAmountCent int64 `json:"deposit_amount_cent"` OwnerPriceCent int64 `json:"owner_price_cent"` WebsiteProfitCent int64 `json:"website_profit_cent"` ProfitAmountCent int64 `json:"profit_amount_cent"` @@ -98,11 +107,15 @@ type FinancialAdjustmentDTO struct { } type CreateRequest struct { - ListingID uint64 `json:"listing_id" binding:"required"` - Platform string `json:"platform"` - ShopName string `json:"shop_name" binding:"max=100"` - ProfitAmountCent int64 `json:"profit_amount_cent"` - Remark string `json:"remark"` + ListingID uint64 `json:"listing_id" binding:"required"` + Operator string `json:"operator" binding:"max=64"` + Platform string `json:"platform"` + ThirdPartyOrderNo string `json:"third_party_order_no" binding:"max=128"` + ShopName string `json:"shop_name" binding:"max=100"` + OrderAmountCent int64 `json:"order_amount_cent" binding:"min=0"` + DepositAmountCent int64 `json:"deposit_amount_cent" binding:"min=0"` + ProfitAmountCent int64 `json:"profit_amount_cent"` + Remark string `json:"remark"` } type CompleteRequest struct { @@ -143,6 +156,43 @@ type AdminPickupQuery struct { ShopName string AccountSource string OfflineSettlementStatus string + Operator string + ThirdPartyOrderNo string + StartTime *time.Time + EndTime *time.Time +} + +type PickupShopDTO struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + Status string `json:"status"` + SortOrder int `json:"sort_order"` + Remark string `json:"remark"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreatePickupShopRequest struct { + Name string `json:"name" binding:"required,max=100"` + Platform string `json:"platform" binding:"max=32"` + Status string `json:"status" binding:"omitempty,oneof=active disabled"` + SortOrder int `json:"sort_order"` + Remark string `json:"remark" binding:"max=255"` +} + +type UpdatePickupShopRequest struct { + Name string `json:"name" binding:"required,max=100"` + Platform string `json:"platform" binding:"max=32"` + Status string `json:"status" binding:"required,oneof=active disabled"` + SortOrder int `json:"sort_order"` + Remark string `json:"remark" binding:"max=255"` +} + +type PickupShopQuery struct { + Keyword string + Platform string + Status string } type SellerPickupQuery struct { diff --git a/backend/internal/modules/pickup/handler.go b/backend/internal/modules/pickup/handler.go index 0d3a51b..7af1bd7 100644 --- a/backend/internal/modules/pickup/handler.go +++ b/backend/internal/modules/pickup/handler.go @@ -301,11 +301,88 @@ func writePickupError(c *gin.Context, err error) { response.Error(c, http.StatusNotFound, "pickup_financial_adjustment_not_found", "财务调整记录不存在") case errors.Is(err, ErrFinancialAdjustmentCannotSettle): response.BadRequest(c, "当前调整不可确认处理") + case errors.Is(err, ErrPickupShopNotFound): + response.Error(c, http.StatusNotFound, "pickup_shop_not_found", "店铺不存在") + case errors.Is(err, ErrPickupShopNameExists): + response.Error(c, http.StatusConflict, "pickup_shop_name_exists", "店铺名称已存在") default: response.Error(c, http.StatusInternalServerError, "pickup_error", err.Error()) } } +// ListShops 获取店铺配置列表(管理员) +func (h *Handler) ListShops(c *gin.Context) { + query := parsePickupShopQuery(c) + items, err := h.service.ListShops(c.Request.Context(), query) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, items) +} + +// CreateShop 创建店铺配置(管理员) +func (h *Handler) CreateShop(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + var req CreatePickupShopRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "店铺参数格式不正确") + return + } + item, err := h.service.CreateShop(c.Request.Context(), req, adminID, auditMeta(c)) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, item) +} + +// UpdateShop 更新店铺配置(管理员) +func (h *Handler) UpdateShop(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req UpdatePickupShopRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "店铺参数格式不正确") + return + } + item, err := h.service.UpdateShop(c.Request.Context(), id, req, adminID, auditMeta(c)) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, item) +} + +// DeleteShop 删除店铺配置(管理员) +func (h *Handler) DeleteShop(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + if err := h.service.DeleteShop(c.Request.Context(), id, adminID, auditMeta(c)); err != nil { + writePickupError(c, err) + return + } + response.OK(c, gin.H{"deleted": true}) +} + func auditMeta(c *gin.Context) auditlog.Meta { return auditlog.Meta{ IP: c.ClientIP(), diff --git a/backend/internal/modules/pickup/handler_parse.go b/backend/internal/modules/pickup/handler_parse.go index f39e66c..28a604a 100644 --- a/backend/internal/modules/pickup/handler_parse.go +++ b/backend/internal/modules/pickup/handler_parse.go @@ -2,6 +2,8 @@ package pickup import ( "strconv" + "strings" + "time" "hfb_sys/backend/internal/middleware" "hfb_sys/backend/pkg/response" @@ -51,6 +53,26 @@ func parsePagination(c *gin.Context) (int, int) { return page, pageSize } +func parseFlexibleTime(val string) *time.Time { + val = strings.TrimSpace(val) + if val == "" { + return nil + } + layouts := []string{ + "2006-01-02 15:04:05", + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02", + } + loc := time.Local + for _, layout := range layouts { + if t, err := time.ParseInLocation(layout, val, loc); err == nil { + return &t + } + } + return nil +} + func parseAdminQuery(c *gin.Context) AdminPickupQuery { page, pageSize := parsePagination(c) return AdminPickupQuery{ @@ -61,6 +83,18 @@ func parseAdminQuery(c *gin.Context) AdminPickupQuery { ShopName: c.Query("shop_name"), AccountSource: c.Query("account_source"), OfflineSettlementStatus: c.Query("offline_settlement_status"), + Operator: c.Query("operator"), + ThirdPartyOrderNo: c.Query("third_party_order_no"), + StartTime: parseFlexibleTime(c.Query("start_time")), + EndTime: parseFlexibleTime(c.Query("end_time")), + } +} + +func parsePickupShopQuery(c *gin.Context) PickupShopQuery { + return PickupShopQuery{ + Keyword: c.Query("keyword"), + Platform: c.Query("platform"), + Status: c.Query("status"), } } diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index 79b7d3e..0becdc0 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -87,12 +87,16 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint AccountID: listing.AccountID, OwnerID: listing.OwnerID, AdminID: adminID, + Operator: strings.TrimSpace(req.Operator), Platform: strings.TrimSpace(req.Platform), + ThirdPartyOrderNo: strings.TrimSpace(req.ThirdPartyOrderNo), ShopName: strings.TrimSpace(req.ShopName), AccountSource: accountSource, SourceChannel: sourceChannel, SettlementMode: pickupSettlementMode(listing.SettlementMode, accountSource), ListingPriceCent: priceSnapshot.ListingPriceCent, + OrderAmountCent: req.OrderAmountCent, + DepositAmountCent: req.DepositAmountCent, OwnerPriceCent: priceSnapshot.OwnerPriceCent, WebsiteProfitCent: priceSnapshot.WebsiteProfitCent, ProfitAmountCent: req.ProfitAmountCent, @@ -106,9 +110,15 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint return err } if err := appendPickupEvent(tx, pickup, "pickup_created", adminID, "管理员创建线下提号。", pickup.Remark, map[string]any{ - "listing_price_cent": pickup.ListingPriceCent, "profit_amount_cent": pickup.ProfitAmountCent, - "account_source": pickup.AccountSource, "source_channel": pickup.SourceChannel, - "settlement_mode": pickup.SettlementMode, + "listing_price_cent": pickup.ListingPriceCent, + "order_amount_cent": pickup.OrderAmountCent, + "deposit_amount_cent": pickup.DepositAmountCent, + "profit_amount_cent": pickup.ProfitAmountCent, + "account_source": pickup.AccountSource, + "source_channel": pickup.SourceChannel, + "settlement_mode": pickup.SettlementMode, + "operator": pickup.Operator, + "third_party_order_no": pickup.ThirdPartyOrderNo, }, map[string]any{}); err != nil { return err } @@ -157,13 +167,17 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint BizID: &bid, Meta: meta, Detail: map[string]any{ - "pickup_no": pickupNo, - "listing_id": listing.ID, - "platform": pickup.Platform, - "shop_name": pickup.ShopName, - "account_source": pickup.AccountSource, - "source_channel": pickup.SourceChannel, - "profit_cent": pickup.ProfitAmountCent, + "pickup_no": pickupNo, + "listing_id": listing.ID, + "platform": pickup.Platform, + "third_party_order_no": pickup.ThirdPartyOrderNo, + "shop_name": pickup.ShopName, + "operator": pickup.Operator, + "account_source": pickup.AccountSource, + "source_channel": pickup.SourceChannel, + "order_amount_cent": pickup.OrderAmountCent, + "deposit_amount_cent": pickup.DepositAmountCent, + "profit_cent": pickup.ProfitAmountCent, }, }); err != nil { return err @@ -735,6 +749,18 @@ func applyAdminPickupFilters(db *gorm.DB, query AdminPickupQuery) *gorm.DB { if offlineSettlementStatus := strings.TrimSpace(query.OfflineSettlementStatus); offlineSettlementStatus != "" { db = db.Where("p.offline_settlement_status = ?", offlineSettlementStatus) } + if operator := strings.TrimSpace(query.Operator); operator != "" { + db = db.Where("p.operator = ?", operator) + } + if thirdPartyOrderNo := strings.TrimSpace(query.ThirdPartyOrderNo); thirdPartyOrderNo != "" { + db = db.Where("p.third_party_order_no = ?", thirdPartyOrderNo) + } + if query.StartTime != nil { + db = db.Where("p.created_at >= ?", *query.StartTime) + } + if query.EndTime != nil { + db = db.Where("p.created_at <= ?", *query.EndTime) + } if kw := strings.TrimSpace(query.Keyword); kw != "" { upper := strings.ToUpper(kw) if len(kw) == 11 && kw[0] == '1' && isAllDigits(kw) { @@ -752,7 +778,9 @@ func applyAdminPickupFilters(db *gorm.DB, query AdminPickupQuery) *gorm.DB { } else { like := "%" + kw + "%" db = db.Where( - "p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ?", + "p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR p.third_party_order_no LIKE ? OR p.operator LIKE ?", + like, + like, like, like, like, @@ -822,21 +850,240 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*Pa return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } -// ListShopNames 返回历史提号中已使用的店铺名称,供后台选择和筛选。 +// ListShopNames 返回店铺配置与历史提号中已使用的店铺名称,供后台选择和筛选。 +// 优先返回 pickup_shops 中配置的 active 店铺(按权重排序),再补充历史提号中未在配置表但已有的店铺。 func (r *Repository) ListShopNames(ctx context.Context, platform string) ([]string, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } - db := r.db.WithContext(ctx).Model(&model.AdminPickup{}). - Where("shop_name <> ''") - if value := strings.TrimSpace(platform); value != "" { - db = db.Where("platform = ?", value) + + result := make([]string, 0) + seen := make(map[string]struct{}) + + // 1. 查询店铺配置表中的 active 店铺 + shopDB := r.db.WithContext(ctx).Model(&model.PickupShop{}).Where("status = ?", PickupShopStatusActive) + if val := strings.TrimSpace(platform); val != "" { + shopDB = shopDB.Where("platform = ? OR platform = ''", val) } - names := make([]string, 0) - if err := db.Distinct("shop_name").Order("shop_name ASC").Limit(200).Pluck("shop_name", &names).Error; err != nil { + var configuredNames []string + if err := shopDB.Order("sort_order DESC, name ASC").Pluck("name", &configuredNames).Error; err == nil { + for _, name := range configuredNames { + trimmed := strings.TrimSpace(name) + if trimmed != "" { + if _, ok := seen[trimmed]; !ok { + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + } + } + } + + // 2. 查询 admin_pickups 中的历史店铺作为补充 + historyDB := r.db.WithContext(ctx).Model(&model.AdminPickup{}).Where("shop_name <> ''") + if val := strings.TrimSpace(platform); val != "" { + historyDB = historyDB.Where("platform = ?", val) + } + var historyNames []string + if err := historyDB.Distinct("shop_name").Order("shop_name ASC").Limit(200).Pluck("shop_name", &historyNames).Error; err != nil { return nil, err } - return names, nil + for _, name := range historyNames { + trimmed := strings.TrimSpace(name) + if trimmed != "" { + if _, ok := seen[trimmed]; !ok { + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + } + } + + return result, nil +} + +// ListShops 查询店铺配置列表 +func (r *Repository) ListShops(ctx context.Context, query PickupShopQuery) ([]PickupShopDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + db := r.db.WithContext(ctx).Model(&model.PickupShop{}) + if kw := strings.TrimSpace(query.Keyword); kw != "" { + like := "%" + kw + "%" + db = db.Where("name LIKE ? OR remark LIKE ?", like, like) + } + if p := strings.TrimSpace(query.Platform); p != "" { + db = db.Where("platform = ?", p) + } + if s := strings.TrimSpace(query.Status); s != "" { + db = db.Where("status = ?", s) + } + var rows []model.PickupShop + if err := db.Order("sort_order DESC, id DESC").Find(&rows).Error; err != nil { + return nil, err + } + items := make([]PickupShopDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, PickupShopDTO{ + ID: row.ID, + Name: row.Name, + Platform: row.Platform, + Status: row.Status, + SortOrder: row.SortOrder, + Remark: row.Remark, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }) + } + return items, nil +} + +// CreateShop 创建店铺配置 +func (r *Repository) CreateShop(ctx context.Context, req CreatePickupShopRequest, adminID uint64, meta auditlog.Meta) (*PickupShopDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, ErrInvalidAmount + } + var count int64 + if err := r.db.WithContext(ctx).Model(&model.PickupShop{}).Where("name = ?", name).Count(&count).Error; err != nil { + return nil, err + } + if count > 0 { + return nil, ErrPickupShopNameExists + } + status := strings.TrimSpace(req.Status) + if status == "" { + status = PickupShopStatusActive + } + now := time.Now() + shop := model.PickupShop{ + Name: name, + Platform: strings.TrimSpace(req.Platform), + Status: status, + SortOrder: req.SortOrder, + Remark: strings.TrimSpace(req.Remark), + CreatedAt: now, + UpdatedAt: now, + } + if err := r.db.WithContext(ctx).Create(&shop).Error; err != nil { + return nil, err + } + bid := shop.ID + _ = auditlog.Append(r.db.WithContext(ctx), auditlog.Entry{ + ActorType: "admin", + ActorID: adminID, + Action: "pickup_shop_create", + BizType: "pickup_shop", + BizID: &bid, + Meta: meta, + Detail: map[string]any{ + "shop_id": shop.ID, + "name": shop.Name, + "platform": shop.Platform, + "status": shop.Status, + }, + }) + return &PickupShopDTO{ + ID: shop.ID, + Name: shop.Name, + Platform: shop.Platform, + Status: shop.Status, + SortOrder: shop.SortOrder, + Remark: shop.Remark, + CreatedAt: shop.CreatedAt, + UpdatedAt: shop.UpdatedAt, + }, nil +} + +// UpdateShop 更新店铺配置 +func (r *Repository) UpdateShop(ctx context.Context, id uint64, req UpdatePickupShopRequest, adminID uint64, meta auditlog.Meta) (*PickupShopDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, ErrInvalidAmount + } + var shop model.PickupShop + if err := r.db.WithContext(ctx).First(&shop, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrPickupShopNotFound + } + return nil, err + } + var count int64 + if err := r.db.WithContext(ctx).Model(&model.PickupShop{}).Where("name = ? AND id <> ?", name, id).Count(&count).Error; err != nil { + return nil, err + } + if count > 0 { + return nil, ErrPickupShopNameExists + } + shop.Name = name + shop.Platform = strings.TrimSpace(req.Platform) + shop.Status = strings.TrimSpace(req.Status) + shop.SortOrder = req.SortOrder + shop.Remark = strings.TrimSpace(req.Remark) + shop.UpdatedAt = time.Now() + if err := r.db.WithContext(ctx).Save(&shop).Error; err != nil { + return nil, err + } + bid := shop.ID + _ = auditlog.Append(r.db.WithContext(ctx), auditlog.Entry{ + ActorType: "admin", + ActorID: adminID, + Action: "pickup_shop_update", + BizType: "pickup_shop", + BizID: &bid, + Meta: meta, + Detail: map[string]any{ + "shop_id": shop.ID, + "name": shop.Name, + "platform": shop.Platform, + "status": shop.Status, + }, + }) + return &PickupShopDTO{ + ID: shop.ID, + Name: shop.Name, + Platform: shop.Platform, + Status: shop.Status, + SortOrder: shop.SortOrder, + Remark: shop.Remark, + CreatedAt: shop.CreatedAt, + UpdatedAt: shop.UpdatedAt, + }, nil +} + +// DeleteShop 删除店铺配置 +func (r *Repository) DeleteShop(ctx context.Context, id uint64, adminID uint64, meta auditlog.Meta) error { + if r == nil || r.db == nil { + return ErrDependencyUnavailable + } + var shop model.PickupShop + if err := r.db.WithContext(ctx).First(&shop, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrPickupShopNotFound + } + return err + } + if err := r.db.WithContext(ctx).Delete(&shop).Error; err != nil { + return err + } + bid := shop.ID + _ = auditlog.Append(r.db.WithContext(ctx), auditlog.Entry{ + ActorType: "admin", + ActorID: adminID, + Action: "pickup_shop_delete", + BizType: "pickup_shop", + BizID: &bid, + Meta: meta, + Detail: map[string]any{ + "shop_id": shop.ID, + "name": shop.Name, + }, + }) + return nil } func (r *Repository) ListForSeller(ctx context.Context, ownerID uint64, query SellerPickupQuery) (*PaginatedResult, error) { @@ -977,12 +1224,16 @@ func (row pickupRow) toDTO() PickupDTO { OwnerID: row.OwnerID, OwnerPhone: row.OwnerPhone, AdminID: row.AdminID, + Operator: row.Operator, Platform: row.Platform, + ThirdPartyOrderNo: row.ThirdPartyOrderNo, ShopName: row.ShopName, AccountSource: row.AccountSource, SourceChannel: row.SourceChannel, SettlementMode: row.SettlementMode, ListingPriceCent: row.ListingPriceCent, + OrderAmountCent: row.OrderAmountCent, + DepositAmountCent: row.DepositAmountCent, OwnerPriceCent: row.OwnerPriceCent, WebsiteProfitCent: row.WebsiteProfitCent, ProfitAmountCent: row.ProfitAmountCent, diff --git a/backend/internal/modules/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index 34221fc..980e886 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -5,6 +5,7 @@ import ( "errors" "strings" "testing" + "time" "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/database" @@ -621,6 +622,7 @@ func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) { &model.ListingUpload{}, &model.AdminPickup{}, &model.AdminPickupFinancialAdjustment{}, + &model.PickupShop{}, &model.WalletAccount{}, &model.WalletLedger{}, &model.AuditLog{}, @@ -761,3 +763,158 @@ func TestApplyAdminPickupFiltersSmartRouting(t *testing.T) { }) } } + +func TestRepositoryCreateExtraFieldsAndQueryTimeRange(t *testing.T) { + repo, db := newPickupTestRepo(t) + listing := seedAvailableListing(t, db, "LST-TEST-EXTRA-01", "测试商品带扩展字段", false) + + created, err := repo.Create(t.Context(), CreateRequest{ + ListingID: listing.ID, + Operator: "运营小张", + Platform: "淘宝", + ThirdPartyOrderNo: "TB-20261009-8888", + ShopName: "测试大锤专营", + OrderAmountCent: 12800, + DepositAmountCent: 5000, + ProfitAmountCent: 3000, + Remark: "测试提号备注", + }, 1, auditlog.Meta{}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + if created.Operator != "运营小张" { + t.Fatalf("Operator = %q, want %q", created.Operator, "运营小张") + } + if created.ThirdPartyOrderNo != "TB-20261009-8888" { + t.Fatalf("ThirdPartyOrderNo = %q, want %q", created.ThirdPartyOrderNo, "TB-20261009-8888") + } + assertInt64(t, "OrderAmountCent", created.OrderAmountCent, 12800) + assertInt64(t, "DepositAmountCent", created.DepositAmountCent, 5000) + + detail, err := repo.FindByID(t.Context(), created.ID) + if err != nil { + t.Fatalf("FindByID() error = %v", err) + } + if detail.Operator != "运营小张" || detail.ThirdPartyOrderNo != "TB-20261009-8888" { + t.Fatalf("FindByID fields mismatch: %+v", detail) + } + + // 测试时间范围筛选 + now := time.Now() + before1Hour := now.Add(-1 * time.Hour) + after1Hour := now.Add(1 * time.Hour) + before2Hour := now.Add(-2 * time.Hour) + + // 命中的时间范围 + hitRes, err := repo.ListAdmin(t.Context(), AdminPickupQuery{ + StartTime: &before1Hour, + EndTime: &after1Hour, + }) + if err != nil { + t.Fatalf("ListAdmin() with time range error = %v", err) + } + if hitRes.Total < 1 { + t.Fatalf("ListAdmin() in-range total = %d, want >= 1", hitRes.Total) + } + + // 未命中的时间范围(过去) + missRes, err := repo.ListAdmin(t.Context(), AdminPickupQuery{ + StartTime: &before2Hour, + EndTime: &before1Hour, + }) + if err != nil { + t.Fatalf("ListAdmin() with miss range error = %v", err) + } + if missRes.Total != 0 { + t.Fatalf("ListAdmin() out-of-range total = %d, want 0", missRes.Total) + } +} + +func TestRepositoryPickupShopCRUDAndOptions(t *testing.T) { + repo, _ := newPickupTestRepo(t) + + // 1. 创建店铺 + shop1, err := repo.CreateShop(t.Context(), CreatePickupShopRequest{ + Name: "旗舰一店", + Platform: "淘宝", + Status: "active", + SortOrder: 10, + Remark: "测试店铺一", + }, 1, auditlog.Meta{}) + if err != nil { + t.Fatalf("CreateShop() error = %v", err) + } + if shop1.ID == 0 || shop1.Name != "旗舰一店" { + t.Fatalf("CreateShop() result = %+v", shop1) + } + + // 2. 重名校验 + _, err = repo.CreateShop(t.Context(), CreatePickupShopRequest{ + Name: "旗舰一店", + }, 1, auditlog.Meta{}) + if !errors.Is(err, ErrPickupShopNameExists) { + t.Fatalf("CreateShop() duplicate error = %v, want ErrPickupShopNameExists", err) + } + + // 3. 创建第二个店铺 + shop2, err := repo.CreateShop(t.Context(), CreatePickupShopRequest{ + Name: "拼多多二店", + Platform: "拼多多", + Status: "active", + SortOrder: 20, + Remark: "测试店铺二", + }, 1, auditlog.Meta{}) + if err != nil { + t.Fatalf("CreateShop(2) error = %v", err) + } + + // 4. 查询店铺列表 + shops, err := repo.ListShops(t.Context(), PickupShopQuery{}) + if err != nil { + t.Fatalf("ListShops() error = %v", err) + } + if len(shops) != 2 { + t.Fatalf("ListShops() length = %d, want 2", len(shops)) + } + // 权重 20 的应该排在前面 + if shops[0].Name != "拼多多二店" { + t.Fatalf("ListShops() first = %s, want 拼多多二店", shops[0].Name) + } + + // 5. 更新店铺 + updated, err := repo.UpdateShop(t.Context(), shop1.ID, UpdatePickupShopRequest{ + Name: "旗舰一店改名", + Platform: "淘宝", + Status: "disabled", + SortOrder: 5, + Remark: "修改备注", + }, 1, auditlog.Meta{}) + if err != nil { + t.Fatalf("UpdateShop() error = %v", err) + } + if updated.Name != "旗舰一店改名" || updated.Status != "disabled" { + t.Fatalf("UpdateShop() result = %+v", updated) + } + + // 6. ListShopNames 选项列表(仅返回 active 店铺) + options, err := repo.ListShopNames(t.Context(), "") + if err != nil { + t.Fatalf("ListShopNames() error = %v", err) + } + if len(options) != 1 || options[0] != "拼多多二店" { + t.Fatalf("ListShopNames() options = %v, want [拼多多二店]", options) + } + + // 7. 删除店铺 + if err := repo.DeleteShop(t.Context(), shop2.ID, 1, auditlog.Meta{}); err != nil { + t.Fatalf("DeleteShop() error = %v", err) + } + shopsAfterDelete, err := repo.ListShops(t.Context(), PickupShopQuery{Status: "active"}) + if err != nil { + t.Fatalf("ListShops() after delete error = %v", err) + } + if len(shopsAfterDelete) != 0 { + t.Fatalf("active shops after delete length = %d, want 0", len(shopsAfterDelete)) + } +} diff --git a/backend/internal/modules/pickup/service.go b/backend/internal/modules/pickup/service.go index 45f6c40..985e9a7 100644 --- a/backend/internal/modules/pickup/service.go +++ b/backend/internal/modules/pickup/service.go @@ -148,3 +148,31 @@ func (s *Service) ListAvailableListings(ctx context.Context, query AvailableList } return s.repo.ListAvailableListings(ctx, query) } + +func (s *Service) ListShops(ctx context.Context, query PickupShopQuery) ([]PickupShopDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListShops(ctx, query) +} + +func (s *Service) CreateShop(ctx context.Context, req CreatePickupShopRequest, adminID uint64, meta auditlog.Meta) (*PickupShopDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.CreateShop(ctx, req, adminID, meta) +} + +func (s *Service) UpdateShop(ctx context.Context, id uint64, req UpdatePickupShopRequest, adminID uint64, meta auditlog.Meta) (*PickupShopDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.UpdateShop(ctx, id, req, adminID, meta) +} + +func (s *Service) DeleteShop(ctx context.Context, id uint64, adminID uint64, meta auditlog.Meta) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.DeleteShop(ctx, id, adminID, meta) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 09483ce..57def36 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -759,6 +759,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/pickups", requirePerm("order:pickup"), pickupHandler.Create) adminRoutes.GET("/pickups", requirePerm("order:pickup"), pickupHandler.List) adminRoutes.GET("/pickups/shop-options", requirePerm("order:pickup"), pickupHandler.ShopOptions) + adminRoutes.GET("/pickups/shops", requirePerm("order:pickup"), pickupHandler.ListShops) + adminRoutes.POST("/pickups/shops", requirePerm("order:pickup"), pickupHandler.CreateShop) + adminRoutes.PUT("/pickups/shops/:id", requirePerm("order:pickup"), pickupHandler.UpdateShop) + adminRoutes.DELETE("/pickups/shops/:id", requirePerm("order:pickup"), pickupHandler.DeleteShop) adminRoutes.GET("/pickups/available-listings", requirePerm("order:pickup"), pickupHandler.AvailableListings) adminRoutes.GET("/pickups/:id/financial-adjustments", requirePerm("order:pickup"), pickupHandler.ListFinancialAdjustments) adminRoutes.GET("/pickups/:id/process-events", requirePerm("order:pickup"), pickupHandler.ProcessEvents) diff --git a/backend/migrations/000079_admin_pickup_extra_fields_and_shops.sql b/backend/migrations/000079_admin_pickup_extra_fields_and_shops.sql new file mode 100644 index 0000000..b3ddb41 --- /dev/null +++ b/backend/migrations/000079_admin_pickup_extra_fields_and_shops.sql @@ -0,0 +1,35 @@ +-- +goose Up + +ALTER TABLE admin_pickups + ADD COLUMN operator VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作人' AFTER admin_id, + ADD COLUMN third_party_order_no VARCHAR(128) NOT NULL DEFAULT '' COMMENT '第三方订单号' AFTER platform, + ADD COLUMN order_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '下单金额(分)' AFTER listing_price_cent, + ADD COLUMN deposit_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '押金金额(分)' AFTER order_amount_cent, + ADD KEY idx_admin_pickups_third_party_order_no (third_party_order_no), + ADD KEY idx_admin_pickups_operator (operator); + +CREATE TABLE pickup_shops ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(100) NOT NULL COMMENT '店铺名称', + platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT '所属平台(如淘宝/拼多多/闲鱼/微信等)', + status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active启用/disabled禁用', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重', + remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注说明', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_pickup_shop_name (name), + KEY idx_pickup_shop_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='线下提号店铺配置表'; + +-- +goose Down + +DROP TABLE IF EXISTS pickup_shops; + +ALTER TABLE admin_pickups + DROP KEY idx_admin_pickups_third_party_order_no, + DROP KEY idx_admin_pickups_operator, + DROP COLUMN operator, + DROP COLUMN third_party_order_no, + DROP COLUMN order_amount_cent, + DROP COLUMN deposit_amount_cent; diff --git a/docs/线下提号字段扩展与店铺管理实施方案.md b/docs/线下提号字段扩展与店铺管理实施方案.md new file mode 100644 index 0000000..83e59ac --- /dev/null +++ b/docs/线下提号字段扩展与店铺管理实施方案.md @@ -0,0 +1,153 @@ +# 线下提号字段扩展与店铺管理实施方案 + +## 一、进度与证据分类登记 + +### 1. 已知(附证据与确信度) +- **远程生产环境只读统计(确信度 100%)**: + 通过 SSH 连接生产服务器(aliyun, 42.121.217.194),从线上 MySQL 执行只读聚合查询: + `SELECT DISTINCT shop_name, COUNT(*) as cnt FROM admin_pickups GROUP BY shop_name ORDER BY cnt DESC;` + 获取到了全部 61 种历史店铺名称及统计分布(详见后文清单)。其中高频店铺包括:糖浆(1049)、王大锤游戏(846)、王大锤数娱(604)、淘宝王大锤网游专营店(494)等,亦包含部分历史空值和脏数据。 +- **当前提号表结构(确信度 100%)**: + `admin_pickups` 现有字段包含 `admin_id`, `platform`, `shop_name`, `listing_price_cent`, `profit_amount_cent` 等,缺少 `operator`, `third_party_order_no`, `order_amount_cent`, `deposit_amount_cent`。 +- **当前店铺来源逻辑(确信度 100%)**: + 现有 `GET /api/v1/admin/pickups/shop-options` 直接对 `admin_pickups` 表进行 `Distinct("shop_name")` 查询,没有独立的店铺配置表。 +- **当前筛选条件(确信度 100%)**: + 现有列表仅支持关键词、状态、店铺、账号来源、线下结算状态筛选,缺少精确到“日期+时间”的时间范围筛选。 +- **SQL 防线与测试规范(确信度 100%)**: + `scripts/check-sql-guard.sh` 对 migration 追加、Goose Up/Down 规范以及单号唯一索引直达有严格断言。 + +### 2. 未知与假设(附当前假设与确信度) +- **店铺历史脏数据映射(确信度 100%)**: + 用户表示后续会提供具体的店铺映射规则;当前阶段先完成标准店铺表设计及 CRUD 管理功能,前端与接口在选择店铺时兼容已录入店铺和历史店铺。 +- **操作人录入习惯(确信度 95%)**: + 创建提号时,操作人字段默认自动预填当前登录管理员的用户名/昵称,同时支持管理员自由修改输入为客服名或经办人姓名。 + +--- + +## 二、生产环境历史店铺名称只读清单(共 61 项) + +| 序号 | 店铺名称 | 历史提号单量 | 备注/分类特征 | +| :--- | :--- | :--- | :--- | +| 1 | 糖浆 | 1049 | 主力店铺 | +| 2 | 王大锤游戏 | 846 | 主力店铺 | +| 3 | 王大锤数娱 | 604 | 主力店铺 | +| 4 | 淘宝王大锤网游专营店 | 494 | 淘宝渠道 | +| 5 | 咸鱼大锤商行小白 | 170 | 闲鱼渠道 | +| 6 | 拼多多王大锤数娱 | 152 | 拼多多渠道 | +| 7 | 淘宝王大锤网游 | 149 | 淘宝渠道 | +| 8 | 糖浆游戏 拼多多 | 143 | 拼多多渠道 | +| 9 | (空字符串) | 137 | 未填店铺 | +| 10 | 咸鱼羊小胖 | 114 | 闲鱼渠道 | +| 11 | 咸鱼滑翔伞 | 110 | 闲鱼渠道 | +| 12 | 咸鱼大锤商行 | 68 | 闲鱼渠道 | +| 13 | 王大锤游戏专营 | 36 | 主力店铺变体 | +| 14 | 咸鱼西红柿不错 | 35 | 闲鱼渠道 | +| 15 | 微信 | 33 | 微信私域 | +| 16 | 拼多多游戏 | 32 | 拼多多渠道 | +| 17 | 王大锤网游专营店 | 30 | 淘宝/天猫变体 | +| 18 | 淘宝艾优游戏 | 30 | 淘宝渠道 | +| 19 | 游戏 | 29 | 简写 | +| 20 | 淘宝三角洲行动租号商行 | 27 | 淘宝渠道 | +| 21 | 闲鱼羊小胖 | 23 | 闲鱼渠道 | +| 22 | 富友 | 18 | 渠道/平台 | +| 23 | 滑翔伞 | 17 | 简写 | +| 24 | 宜收宝 | 15 | 渠道/平台 | +| 25 | 淘宝大锤商行 | 15 | 淘宝渠道 | +| 26 | 西红柿不错 | 14 | 简写 | +| 27 | 三角洲行动租号商行 | 11 | 简写 | +| 28 | 大锤商行 | 9 | 简写 | +| 29 | 羊小胖 | 8 | 简写 | +| 30 | 数 | 7 | 简写脏数据 | +| 31 | 三角洲行动商行 | 6 | 简写 | +| 32 | 三角洲行动 | 6 | 简写 | +| 33 | 淘宝356166138小边 | 5 | 淘宝单店 | +| 34 | 艾优 | 4 | 简写 | +| 35 | 溟科 | 4 | 机构/分销 | +| 36 | 覃公子未付 | 4 | 临时备注型店铺名 | +| 37 | 11.25 7 | 3 | 日期数字型脏数据 | +| 38 | 艾优游戏 | 3 | 简写 | +| 39 | 小边 | 3 | 客服/分销 | +| 40 | 数娱 | 3 | 简写 | +| 41 | 淘宝王大锤商行 | 3 | 淘宝渠道 | +| 42 | 富友+宜收宝 | 2 | 复合渠道 | +| 43 | 淘宝易磊 | 2 | 淘宝单店 | +| 44 | 半野潮汐俱乐部 | 2 | 专营店 | +| 45 | 10.17W免押500 | 2 | 备注型脏数据 | +| 46 | 7.20 拼多多东 | 2 | 日期拼多多型 | +| 47 | 超帅 | 1 | 别名 | +| 48 | 0526🐠 | 1 | 特殊符号 | +| 49 | 西红柿 | 1 | 简写 | +| 50 | 行动 | 1 | 简写 | +| 51 | 淘宝980493223 | 1 | 淘宝店号 | +| 52 | .7.22 | 1 | 日期型 | +| 53 | 网游 | 1 | 简写 | +| 54 | Lost | 1 | 别名 | +| 55 | 七友 | 1 | 别名 | +| 56 | 大锤 | 1 | 简写 | +| 57 | 王大锤商行 | 1 | 简写 | +| 58 | 爱优游戏 | 1 | 同音别名 | +| 59 | 小白 | 1 | 客服名 | +| 60 | 深南群主正常拿号 | 1 | 渠道拿号说明 | +| 61 | 撞车 / 正在输入 | 各 1 | 临时测试/异常字符 | + +--- + +## 三、方案设计与改造范围 + +### 1. 数据库升级 (`000079_admin_pickup_extra_fields_and_shops.sql`) +1. 对 `admin_pickups` 表增加 4 个字段及必要索引: + - `operator VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作人(如客服/运营人员名字)'` + - `third_party_order_no VARCHAR(128) NOT NULL DEFAULT '' COMMENT '第三方订单号'` + - `order_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '下单金额(分)'` + - `deposit_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '押金金额(分)'` + - 索引:`idx_admin_pickups_third_party_order_no`, `idx_admin_pickups_operator` +2. 新增店铺配置表 `pickup_shops`: + - `id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY` + - `name VARCHAR(100) NOT NULL UNIQUE COMMENT '店铺名称'` + - `platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT '所属平台'` + - `status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/disabled'` + - `sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重'` + - `remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注'` + - `created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL` + - 索引:`uk_pickup_shop_name`, `idx_pickup_shop_status` + +### 2. 后端核心代码改造 +- `backend/internal/model/pickup.go`:结构体添加新增字段。 +- `backend/internal/model/pickup_shop.go`:创建店铺模型。 +- `backend/internal/modules/pickup/dto.go`: + - `PickupDTO` 扩充 `Operator`, `ThirdPartyOrderNo`, `OrderAmountCent`, `DepositAmountCent`; + - `CreateRequest` 扩充对应 4 个字段; + - `AdminPickupQuery` 增加 `StartTime`, `EndTime` 时间范围筛选; + - 增加 `PickupShopDTO`, `CreatePickupShopRequest`, `UpdatePickupShopRequest`, `PickupShopQuery` 等 DTO。 +- `backend/internal/modules/pickup/repository.go`: + - `Create` 写入新字段,并在事件与审计日志中留痕; + - `applyAdminPickupFilters` 增加 `p.created_at >= ?` 与 `p.created_at <= ?` 时间筛选;关键词搜索补充支持第三方单号与操作人; + - 增加店铺管理 CRUD:`ListShops`, `CreateShop`, `UpdateShop`, `DeleteShop`; + - `ListShopNames`:优先读取 `pickup_shops` 活跃店铺,并合并 `admin_pickups` 历史店铺去重返回。 +- `backend/internal/modules/pickup/handler.go` & `handler_parse.go`: + - 解析 query 参数中的 `start_time` 与 `end_time`(支持多种格式); + - 实现店铺增删改查处理函数。 +- `backend/internal/router/router.go`:注册店铺相关路由。 + +### 3. 前端界面重构与布局优化 +- **创建线下提号弹窗布局优化**: + - 第一行(双列):左侧「账号来源」,右侧「操作人」(默认当前登录用户,可手动修改)。 + - 第二行(全宽):「选择账号」(带远程搜索、来源确认与价格快照)。 + - 第三行(双列):左侧「第三方订单号」,右侧「交易渠道」。 + - 第四行(全宽):「店铺名称」(下拉选择已有店铺,右侧提供店铺管理快捷入口)。 + - 第五行(三列金额区块): + - 「下单金额(元)」 + - 「押金金额(元)」 + - 「线下利润(元)」 + - 第六行(全宽):「备注」。 + - 底部操作按钮及说明提示。 +- **店铺管理功能**: + - 线下提号页面顶部增加「店铺管理」入口; + - 弹出全功能店铺管理对话框,支持店铺列表、新增、编辑、启用/禁用、删除; + - 店铺变更后自动同步刷新外层提号的店铺下拉选择框。 +- **时间筛选(日期+时间)**: + - 筛选栏加入 `el-date-picker`(`type="datetimerange"`),格式精确到秒; + - 支持快捷查询与一键重置。 +- **提号列表与详情页字段展示**: + - 列表中增加操作人、第三方单号、下单金额、押金金额的展示; + - 详情页概览卡片同步展示新增字段。 diff --git a/frontend/src/features/admin/api/adminPickup.ts b/frontend/src/features/admin/api/adminPickup.ts index d64d3b5..9ffa13c 100644 --- a/frontend/src/features/admin/api/adminPickup.ts +++ b/frontend/src/features/admin/api/adminPickup.ts @@ -19,12 +19,16 @@ export interface AdminPickup { owner_id: number owner_phone: string admin_id: number + operator: string platform: string + third_party_order_no: string shop_name: string account_source: PickupAccountSource source_channel: string settlement_mode: PickupSettlementMode listing_price_cent: number + order_amount_cent: number + deposit_amount_cent: number owner_price_cent: number website_profit_cent: number profit_amount_cent: number @@ -108,8 +112,12 @@ export interface AvailableListing { export interface AdminPickupCreateRequest { listing_id: number + operator?: string platform?: string + third_party_order_no?: string shop_name?: string + order_amount_cent?: number + deposit_amount_cent?: number profit_amount_cent?: number remark?: string } @@ -147,6 +155,43 @@ export interface AdminPickupListQuery { shop_name?: string account_source?: PickupAccountSource | '' offline_settlement_status?: PickupOfflineSettlementStatus | '' + operator?: string + third_party_order_no?: string + start_time?: string + end_time?: string +} + +export interface PickupShop { + id: number + name: string + platform: string + status: 'active' | 'disabled' + sort_order: number + remark: string + created_at: string + updated_at: string +} + +export interface PickupShopCreateRequest { + name: string + platform?: string + status?: 'active' | 'disabled' + sort_order?: number + remark?: string +} + +export interface PickupShopUpdateRequest { + name: string + platform?: string + status: 'active' | 'disabled' + sort_order?: number + remark?: string +} + +export interface PickupShopQuery { + keyword?: string + platform?: string + status?: string } function cleanParams(query: object) { @@ -270,3 +315,27 @@ export async function cancelAdminPickup(id: number, reason: string) { ) return data.data } + +export async function fetchPickupShops(query: PickupShopQuery = {}) { + const { data } = await apiClient.get>('/admin/pickups/shops', { + params: cleanParams(query), + }) + return Array.isArray(data.data) ? data.data : [] +} + +export async function createPickupShop(req: PickupShopCreateRequest) { + const { data } = await apiClient.post>('/admin/pickups/shops', req) + return data.data +} + +export async function updatePickupShop(id: number, req: PickupShopUpdateRequest) { + const { data } = await apiClient.put>(`/admin/pickups/shops/${id}`, req) + return data.data +} + +export async function deletePickupShop(id: number) { + const { data } = await apiClient.delete>( + `/admin/pickups/shops/${id}` + ) + return data.data +} diff --git a/frontend/src/features/admin/components/PickupShopDialog.vue b/frontend/src/features/admin/components/PickupShopDialog.vue new file mode 100644 index 0000000..6e1f572 --- /dev/null +++ b/frontend/src/features/admin/components/PickupShopDialog.vue @@ -0,0 +1,401 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminPickupDetailView.vue b/frontend/src/features/admin/views/AdminPickupDetailView.vue index 159b46d..a68cfb0 100644 --- a/frontend/src/features/admin/views/AdminPickupDetailView.vue +++ b/frontend/src/features/admin/views/AdminPickupDetailView.vue @@ -97,6 +97,12 @@ const moneySplitRows = computed(() => { { label: '线下利润', amountCent: effectiveProfitCent(row), tone: 'profit' }, { label: '结算给号主', amountCent: effectiveSettleCent(row), tone: '' }, ] + if (row.order_amount_cent > 0) { + rows.push({ label: '下单金额', amountCent: row.order_amount_cent, tone: '' }) + } + if (row.deposit_amount_cent > 0) { + rows.push({ label: '押金金额', amountCent: row.deposit_amount_cent, tone: '' }) + } if (offlineTotalCent !== null) { rows.push({ label: '线下成交合计', amountCent: offlineTotalCent, tone: 'total' }) } @@ -454,10 +460,36 @@ function readSnapshotResources(summary: Record | null): Snapsho
交易渠道
{{ pickup.platform || '-' }}
+
+
第三方单号
+
{{ pickup.third_party_order_no || '-' }}
+
店铺名称
{{ pickup.shop_name || '-' }}
+
+
操作人
+
{{ pickup.operator || '-' }}
+
+
+
下单金额
+
+ {{ + pickup.order_amount_cent > 0 ? formatCentWithSymbol(pickup.order_amount_cent) : '-' + }} +
+
+
+
押金金额
+
+ {{ + pickup.deposit_amount_cent > 0 + ? formatCentWithSymbol(pickup.deposit_amount_cent) + : '-' + }} +
+
管理员ID
{{ pickup.admin_id }}
diff --git a/frontend/src/features/admin/views/AdminPickupView.css b/frontend/src/features/admin/views/AdminPickupView.css index 639fda6..c0f814d 100644 --- a/frontend/src/features/admin/views/AdminPickupView.css +++ b/frontend/src/features/admin/views/AdminPickupView.css @@ -118,3 +118,27 @@ flex-wrap: wrap; gap: 8px; } + +.amount-group-card { + background: var(--el-fill-color-light, #f8fafc); + border: 1px solid var(--el-border-color-lighter, #e2e8f0); + border-radius: 8px; + padding: 12px 14px 6px; + margin-top: 4px; +} + +.amount-group-title { + font-size: 13px; + font-weight: 600; + color: #334155; + margin-bottom: 8px; +} + +.amount-item :deep(.el-form-item__label) { + font-size: 12px; + padding-right: 4px; +} + +.create-pickup-form :deep(.el-form-item) { + margin-bottom: 16px; +} diff --git a/frontend/src/features/admin/views/AdminPickupView.vue b/frontend/src/features/admin/views/AdminPickupView.vue index 2ee53db..eb223a6 100644 --- a/frontend/src/features/admin/views/AdminPickupView.vue +++ b/frontend/src/features/admin/views/AdminPickupView.vue @@ -1,7 +1,7 @@