优化提号详情
This commit is contained in:
@@ -1,25 +1,36 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
// AdminPickup 管理员线下提号记录。
|
// AdminPickup 管理员线下提号记录。
|
||||||
// 独立于 rental_orders,不进入正常订单状态机与财务统计口径,
|
// 独立于 rental_orders,不进入正常订单状态机与财务统计口径,
|
||||||
// 完成时通过 wallet.AppendEntries 给卖家增加可用余额。
|
// 完成时通过 wallet.AppendEntries 给卖家增加可用余额。
|
||||||
type AdminPickup struct {
|
type AdminPickup struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
PickupNo string `gorm:"size:64;not null;uniqueIndex" json:"pickup_no"`
|
PickupNo string `gorm:"size:64;not null;uniqueIndex" json:"pickup_no"`
|
||||||
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
|
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
|
||||||
AccountID uint64 `gorm:"not null;index" json:"account_id"`
|
AccountID uint64 `gorm:"not null;index" json:"account_id"`
|
||||||
OwnerID uint64 `gorm:"not null;index" json:"owner_id"`
|
OwnerID uint64 `gorm:"not null;index" json:"owner_id"`
|
||||||
AdminID uint64 `gorm:"not null" json:"admin_id"`
|
AdminID uint64 `gorm:"not null" json:"admin_id"`
|
||||||
Platform string `gorm:"size:32;not null;default:''" json:"platform"`
|
Platform string `gorm:"size:32;not null;default:''" json:"platform"`
|
||||||
SettleAmountCent int64 `gorm:"not null;default:0" json:"settle_amount_cent"`
|
ListingPriceCent int64 `gorm:"not null;default:0" json:"listing_price_cent"`
|
||||||
Status string `gorm:"size:20;not null;default:'picking_up';index" json:"status"`
|
OwnerPriceCent int64 `gorm:"not null;default:0" json:"owner_price_cent"`
|
||||||
Remark string `gorm:"size:255;not null;default:''" json:"remark"`
|
WebsiteProfitCent int64 `gorm:"not null;default:0" json:"website_profit_cent"`
|
||||||
CompleteRemark string `gorm:"size:255;not null;default:''" json:"complete_remark"`
|
ProfitAmountCent int64 `gorm:"not null;default:0" json:"profit_amount_cent"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
SellerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"seller_ratio"`
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
BuyerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"buyer_ratio"`
|
||||||
CancelledAt *time.Time `json:"cancelled_at,omitempty"`
|
AccountSnapshot datatypes.JSON `json:"account_snapshot,omitempty"`
|
||||||
|
SettleAmountCent int64 `gorm:"not null;default:0" json:"settle_amount_cent"`
|
||||||
|
Status string `gorm:"size:20;not null;default:'picking_up';index" json:"status"`
|
||||||
|
Remark string `gorm:"size:255;not null;default:''" json:"remark"`
|
||||||
|
CompleteRemark string `gorm:"size:255;not null;default:''" json:"complete_remark"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
CancelledAt *time.Time `json:"cancelled_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (AdminPickup) TableName() string {
|
func (AdminPickup) TableName() string {
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ func (r *Repository) pickupSummary(ctx context.Context, query DashboardQuery) (*
|
|||||||
db := r.db.WithContext(ctx)
|
db := r.db.WithContext(ctx)
|
||||||
var row struct {
|
var row struct {
|
||||||
SettledAmountCent int64
|
SettledAmountCent int64
|
||||||
|
ProfitAmountCent int64
|
||||||
CompletedCount int64
|
CompletedCount int64
|
||||||
}
|
}
|
||||||
if err := db.Table("admin_pickups").
|
if err := db.Table("admin_pickups").
|
||||||
Select(`COALESCE(SUM(settle_amount_cent), 0) AS settled_amount_cent, COUNT(id) AS completed_count`).
|
Select(`COALESCE(SUM(settle_amount_cent), 0) AS settled_amount_cent, COALESCE(SUM(profit_amount_cent), 0) AS profit_amount_cent, COUNT(id) AS completed_count`).
|
||||||
Where("status = ?", "completed").
|
Where("status = ?", "completed").
|
||||||
Where("completed_at >= ? AND completed_at <= ?", query.StartDate, query.EndDate).
|
Where("completed_at >= ? AND completed_at <= ?", query.StartDate, query.EndDate).
|
||||||
Scan(&row).Error; err != nil {
|
Scan(&row).Error; err != nil {
|
||||||
@@ -47,6 +48,7 @@ func (r *Repository) pickupSummary(ctx context.Context, query DashboardQuery) (*
|
|||||||
}
|
}
|
||||||
return &PickupSummaryDTO{
|
return &PickupSummaryDTO{
|
||||||
SettledAmountCent: row.SettledAmountCent,
|
SettledAmountCent: row.SettledAmountCent,
|
||||||
|
ProfitAmountCent: row.ProfitAmountCent,
|
||||||
CompletedCount: row.CompletedCount,
|
CompletedCount: row.CompletedCount,
|
||||||
InProgressCount: inProgress,
|
InProgressCount: inProgress,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -20,15 +20,16 @@ type DetailQuery struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DashboardDTO struct {
|
type DashboardDTO struct {
|
||||||
Summary FinanceSummaryDTO `json:"summary"`
|
Summary FinanceSummaryDTO `json:"summary"`
|
||||||
DailyItems []FinanceDailyDTO `json:"daily_items"`
|
DailyItems []FinanceDailyDTO `json:"daily_items"`
|
||||||
PickupSummary PickupSummaryDTO `json:"pickup_summary"`
|
PickupSummary PickupSummaryDTO `json:"pickup_summary"`
|
||||||
GeneratedAt time.Time `json:"generated_at"`
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。
|
// PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。
|
||||||
type PickupSummaryDTO struct {
|
type PickupSummaryDTO struct {
|
||||||
SettledAmountCent int64 `json:"settled_amount_cent"` // 区间内已完成提号的结算金额合计
|
SettledAmountCent int64 `json:"settled_amount_cent"` // 区间内已完成提号的结算金额合计
|
||||||
|
ProfitAmountCent int64 `json:"profit_amount_cent"` // 区间内已完成提号的线下利润合计
|
||||||
CompletedCount int64 `json:"completed_count"` // 区间内已完成提号笔数
|
CompletedCount int64 `json:"completed_count"` // 区间内已完成提号笔数
|
||||||
InProgressCount int64 `json:"in_progress_count"` // 当前提号中笔数(不按时间)
|
InProgressCount int64 `json:"in_progress_count"` // 当前提号中笔数(不按时间)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package pickup
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 提号状态
|
// 提号状态
|
||||||
@@ -18,39 +20,49 @@ var (
|
|||||||
ErrPickupNotFound = errors.New("pickup not found")
|
ErrPickupNotFound = errors.New("pickup not found")
|
||||||
ErrPickupNotPickingUp = errors.New("pickup not in picking_up status")
|
ErrPickupNotPickingUp = errors.New("pickup not in picking_up status")
|
||||||
ErrInvalidAmount = errors.New("invalid amount")
|
ErrInvalidAmount = errors.New("invalid amount")
|
||||||
|
ErrInvalidProfit = errors.New("invalid profit amount")
|
||||||
ErrDuplicatePickup = errors.New("duplicate pickup in progress")
|
ErrDuplicatePickup = errors.New("duplicate pickup in progress")
|
||||||
)
|
)
|
||||||
|
|
||||||
type PickupDTO struct {
|
type PickupDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
PickupNo string `json:"pickup_no"`
|
PickupNo string `json:"pickup_no"`
|
||||||
ListingID uint64 `json:"listing_id"`
|
ListingID uint64 `json:"listing_id"`
|
||||||
ListingNo string `json:"listing_no"`
|
ListingNo string `json:"listing_no"`
|
||||||
AccountID uint64 `json:"account_id"`
|
AccountID uint64 `json:"account_id"`
|
||||||
AccountTitle string `json:"account_title"`
|
AccountTitle string `json:"account_title"`
|
||||||
ServerRegion string `json:"server_region"`
|
ServerRegion string `json:"server_region"`
|
||||||
LoginPlatform string `json:"login_platform"`
|
LoginPlatform string `json:"login_platform"`
|
||||||
OwnerID uint64 `json:"owner_id"`
|
OwnerID uint64 `json:"owner_id"`
|
||||||
OwnerPhone string `json:"owner_phone"`
|
OwnerPhone string `json:"owner_phone"`
|
||||||
AdminID uint64 `json:"admin_id"`
|
AdminID uint64 `json:"admin_id"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
SettleAmountCent int64 `json:"settle_amount_cent"`
|
ListingPriceCent int64 `json:"listing_price_cent"`
|
||||||
Status string `json:"status"`
|
OwnerPriceCent int64 `json:"owner_price_cent"`
|
||||||
Remark string `json:"remark"`
|
WebsiteProfitCent int64 `json:"website_profit_cent"`
|
||||||
CompleteRemark string `json:"complete_remark"`
|
ProfitAmountCent int64 `json:"profit_amount_cent"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
SellerRatio float64 `json:"seller_ratio"`
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
BuyerRatio float64 `json:"buyer_ratio"`
|
||||||
CancelledAt *time.Time `json:"cancelled_at,omitempty"`
|
AccountSnapshot datatypes.JSON `json:"account_snapshot,omitempty"`
|
||||||
|
SettleAmountCent int64 `json:"settle_amount_cent"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
CompleteRemark string `json:"complete_remark"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
CancelledAt *time.Time `json:"cancelled_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateRequest struct {
|
type CreateRequest struct {
|
||||||
ListingID uint64 `json:"listing_id" binding:"required"`
|
ListingID uint64 `json:"listing_id" binding:"required"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
Remark string `json:"remark"`
|
ProfitAmountCent int64 `json:"profit_amount_cent"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CompleteRequest struct {
|
type CompleteRequest struct {
|
||||||
SettleAmountCent int64 `json:"settle_amount_cent" binding:"required,min=1"`
|
SettleAmountCent int64 `json:"settle_amount_cent" binding:"required,min=1"`
|
||||||
|
ProfitAmountCent *int64 `json:"profit_amount_cent"`
|
||||||
CompleteRemark string `json:"complete_remark"`
|
CompleteRemark string `json:"complete_remark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,14 +84,19 @@ type SellerPickupQuery struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AvailableListingDTO struct {
|
type AvailableListingDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
ListingNo string `json:"listing_no"`
|
ListingNo string `json:"listing_no"`
|
||||||
AccountID uint64 `json:"account_id"`
|
AccountID uint64 `json:"account_id"`
|
||||||
AccountTitle string `json:"account_title"`
|
AccountTitle string `json:"account_title"`
|
||||||
ServerRegion string `json:"server_region"`
|
ServerRegion string `json:"server_region"`
|
||||||
LoginPlatform string `json:"login_platform"`
|
LoginPlatform string `json:"login_platform"`
|
||||||
OwnerID uint64 `json:"owner_id"`
|
OwnerID uint64 `json:"owner_id"`
|
||||||
OwnerPhone string `json:"owner_phone"`
|
OwnerPhone string `json:"owner_phone"`
|
||||||
|
ListingPriceCent int64 `json:"listing_price_cent"`
|
||||||
|
OwnerPriceCent int64 `json:"owner_price_cent"`
|
||||||
|
WebsiteProfitCent int64 `json:"website_profit_cent"`
|
||||||
|
SellerRatio float64 `json:"seller_ratio"`
|
||||||
|
BuyerRatio float64 `json:"buyer_ratio"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PaginatedResult struct {
|
type PaginatedResult struct {
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ func writePickupError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "提号订单状态不允许该操作")
|
response.BadRequest(c, "提号订单状态不允许该操作")
|
||||||
case errors.Is(err, ErrInvalidAmount):
|
case errors.Is(err, ErrInvalidAmount):
|
||||||
response.BadRequest(c, "结算金额不正确")
|
response.BadRequest(c, "结算金额不正确")
|
||||||
|
case errors.Is(err, ErrInvalidProfit):
|
||||||
|
response.BadRequest(c, "利润金额不正确")
|
||||||
default:
|
default:
|
||||||
response.Error(c, http.StatusInternalServerError, "pickup_error", err.Error())
|
response.Error(c, http.StatusInternalServerError, "pickup_error", err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package pickup
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
crand "crypto/rand"
|
crand "crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,6 +18,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/timeutil"
|
"hfb_sys/backend/internal/timeutil"
|
||||||
"hfb_sys/backend/pkg/money"
|
"hfb_sys/backend/pkg/money"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
@@ -62,20 +65,32 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint
|
|||||||
if err := tx.First(&account, listing.AccountID).Error; err != nil {
|
if err := tx.First(&account, listing.AccountID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
priceSnapshot := buildPickupPriceSnapshot(listing, account)
|
||||||
|
accountSnapshot, err := makePickupAccountSnapshot(account, listing)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
pickupNo, err := newPickupNo()
|
pickupNo, err := newPickupNo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pickup := model.AdminPickup{
|
pickup := model.AdminPickup{
|
||||||
PickupNo: pickupNo,
|
PickupNo: pickupNo,
|
||||||
ListingID: listing.ID,
|
ListingID: listing.ID,
|
||||||
AccountID: listing.AccountID,
|
AccountID: listing.AccountID,
|
||||||
OwnerID: listing.OwnerID,
|
OwnerID: listing.OwnerID,
|
||||||
AdminID: adminID,
|
AdminID: adminID,
|
||||||
Platform: strings.TrimSpace(req.Platform),
|
Platform: strings.TrimSpace(req.Platform),
|
||||||
Status: StatusPickingUp,
|
ListingPriceCent: priceSnapshot.ListingPriceCent,
|
||||||
Remark: strings.TrimSpace(req.Remark),
|
OwnerPriceCent: priceSnapshot.OwnerPriceCent,
|
||||||
|
WebsiteProfitCent: priceSnapshot.WebsiteProfitCent,
|
||||||
|
ProfitAmountCent: req.ProfitAmountCent,
|
||||||
|
SellerRatio: priceSnapshot.SellerRatio,
|
||||||
|
BuyerRatio: priceSnapshot.BuyerRatio,
|
||||||
|
AccountSnapshot: accountSnapshot,
|
||||||
|
Status: StatusPickingUp,
|
||||||
|
Remark: strings.TrimSpace(req.Remark),
|
||||||
}
|
}
|
||||||
if err := tx.Create(&pickup).Error; err != nil {
|
if err := tx.Create(&pickup).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -107,9 +122,10 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint
|
|||||||
BizID: &bid,
|
BizID: &bid,
|
||||||
Meta: meta,
|
Meta: meta,
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"pickup_no": pickupNo,
|
"pickup_no": pickupNo,
|
||||||
"listing_id": listing.ID,
|
"listing_id": listing.ID,
|
||||||
"platform": pickup.Platform,
|
"platform": pickup.Platform,
|
||||||
|
"profit_cent": pickup.ProfitAmountCent,
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -151,6 +167,9 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
pickup.SettleAmountCent = req.SettleAmountCent
|
pickup.SettleAmountCent = req.SettleAmountCent
|
||||||
|
if req.ProfitAmountCent != nil {
|
||||||
|
pickup.ProfitAmountCent = *req.ProfitAmountCent
|
||||||
|
}
|
||||||
pickup.Status = StatusCompleted
|
pickup.Status = StatusCompleted
|
||||||
pickup.CompleteRemark = strings.TrimSpace(req.CompleteRemark)
|
pickup.CompleteRemark = strings.TrimSpace(req.CompleteRemark)
|
||||||
pickup.CompletedAt = &now
|
pickup.CompletedAt = &now
|
||||||
@@ -199,6 +218,7 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete
|
|||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"pickup_no": pickup.PickupNo,
|
"pickup_no": pickup.PickupNo,
|
||||||
"settle_amount_cent": req.SettleAmountCent,
|
"settle_amount_cent": req.SettleAmountCent,
|
||||||
|
"profit_amount_cent": pickup.ProfitAmountCent,
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -275,6 +295,11 @@ func (r *Repository) FindByID(ctx context.Context, id uint64) (*PickupDTO, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dto := row.toDTO()
|
dto := row.toDTO()
|
||||||
|
if isEmptyPickupAccountSnapshot(dto.AccountSnapshot) {
|
||||||
|
if snapshot, err := r.fallbackAccountSnapshot(ctx, row.ListingID, row.AccountID); err == nil {
|
||||||
|
dto.AccountSnapshot = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +315,7 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*Pa
|
|||||||
like := "%" + kw + "%"
|
like := "%" + kw + "%"
|
||||||
db = db.Where("p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ?", like, like, like)
|
db = db.Where("p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ?", like, like, like)
|
||||||
}
|
}
|
||||||
return r.paginatePickups(db, query.Page, query.PageSize)
|
return r.paginatePickups(db, query.Page, query.PageSize, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListForSeller(ctx context.Context, ownerID uint64, query SellerPickupQuery) (*PaginatedResult, error) {
|
func (r *Repository) ListForSeller(ctx context.Context, ownerID uint64, query SellerPickupQuery) (*PaginatedResult, error) {
|
||||||
@@ -301,10 +326,10 @@ func (r *Repository) ListForSeller(ctx context.Context, ownerID uint64, query Se
|
|||||||
if status := strings.TrimSpace(query.Status); status != "" {
|
if status := strings.TrimSpace(query.Status); status != "" {
|
||||||
db = db.Where("p.status = ?", status)
|
db = db.Where("p.status = ?", status)
|
||||||
}
|
}
|
||||||
return r.paginatePickups(db, query.Page, query.PageSize)
|
return r.paginatePickups(db, query.Page, query.PageSize, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int) (*PaginatedResult, error) {
|
func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int, sellerView bool) (*PaginatedResult, error) {
|
||||||
page, pageSize = normalizePaging(page, pageSize)
|
page, pageSize = normalizePaging(page, pageSize)
|
||||||
var total int64
|
var total int64
|
||||||
if err := db.Count(&total).Error; err != nil {
|
if err := db.Count(&total).Error; err != nil {
|
||||||
@@ -316,7 +341,14 @@ func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int) (*Paginate
|
|||||||
}
|
}
|
||||||
items := make([]PickupDTO, 0, len(rows))
|
items := make([]PickupDTO, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
items = append(items, row.toDTO())
|
item := row.toDTO()
|
||||||
|
item.AccountSnapshot = nil
|
||||||
|
if sellerView {
|
||||||
|
item.WebsiteProfitCent = 0
|
||||||
|
item.ProfitAmountCent = 0
|
||||||
|
item.BuyerRatio = 0
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
@@ -327,7 +359,7 @@ func (r *Repository) ListAvailableListings(ctx context.Context, keyword string,
|
|||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
db := r.db.WithContext(ctx).Table("rental_listings AS l").
|
db := r.db.WithContext(ctx).Table("rental_listings AS l").
|
||||||
Select("l.id, l.listing_no, l.account_id, a.title AS account_title, a.server_region, a.login_platform, l.owner_id, owner.phone AS owner_phone").
|
Select("l.id, l.listing_no, l.account_id, l.owner_id, l.price_cent AS listing_price_cent, a.title AS account_title, a.server_region, a.login_platform, a.haf_coin_amount, a.asset_summary, owner.phone AS owner_phone").
|
||||||
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
||||||
Joins("JOIN users AS owner ON owner.id = l.owner_id").
|
Joins("JOIN users AS owner ON owner.id = l.owner_id").
|
||||||
Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false)
|
Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false)
|
||||||
@@ -341,11 +373,15 @@ func (r *Repository) ListAvailableListings(ctx context.Context, keyword string,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
page, pageSize = normalizePaging(page, pageSize)
|
page, pageSize = normalizePaging(page, pageSize)
|
||||||
var rows []AvailableListingDTO
|
var rows []availableListingRow
|
||||||
if err := db.Order("l.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
|
if err := db.Order("l.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &AvailableListingsResult{Items: rows, Total: total, Page: page, PageSize: pageSize}, nil
|
items := make([]AvailableListingDTO, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, row.toDTO())
|
||||||
|
}
|
||||||
|
return &AvailableListingsResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
||||||
@@ -367,28 +403,233 @@ type pickupRow struct {
|
|||||||
|
|
||||||
func (row pickupRow) toDTO() PickupDTO {
|
func (row pickupRow) toDTO() PickupDTO {
|
||||||
return PickupDTO{
|
return PickupDTO{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
PickupNo: row.PickupNo,
|
PickupNo: row.PickupNo,
|
||||||
ListingID: row.ListingID,
|
ListingID: row.ListingID,
|
||||||
ListingNo: row.ListingNo,
|
ListingNo: row.ListingNo,
|
||||||
AccountID: row.AccountID,
|
AccountID: row.AccountID,
|
||||||
AccountTitle: row.AccountTitle,
|
AccountTitle: row.AccountTitle,
|
||||||
ServerRegion: row.ServerRegion,
|
ServerRegion: row.ServerRegion,
|
||||||
LoginPlatform: row.LoginPlatform,
|
LoginPlatform: row.LoginPlatform,
|
||||||
OwnerID: row.OwnerID,
|
OwnerID: row.OwnerID,
|
||||||
OwnerPhone: row.OwnerPhone,
|
OwnerPhone: row.OwnerPhone,
|
||||||
AdminID: row.AdminID,
|
AdminID: row.AdminID,
|
||||||
Platform: row.Platform,
|
Platform: row.Platform,
|
||||||
SettleAmountCent: row.SettleAmountCent,
|
ListingPriceCent: row.ListingPriceCent,
|
||||||
Status: row.Status,
|
OwnerPriceCent: row.OwnerPriceCent,
|
||||||
Remark: row.Remark,
|
WebsiteProfitCent: row.WebsiteProfitCent,
|
||||||
CompleteRemark: row.CompleteRemark,
|
ProfitAmountCent: row.ProfitAmountCent,
|
||||||
CreatedAt: row.CreatedAt,
|
SellerRatio: row.SellerRatio,
|
||||||
CompletedAt: row.CompletedAt,
|
BuyerRatio: row.BuyerRatio,
|
||||||
CancelledAt: row.CancelledAt,
|
AccountSnapshot: row.AccountSnapshot,
|
||||||
|
SettleAmountCent: row.SettleAmountCent,
|
||||||
|
Status: row.Status,
|
||||||
|
Remark: row.Remark,
|
||||||
|
CompleteRemark: row.CompleteRemark,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
CompletedAt: row.CompletedAt,
|
||||||
|
CancelledAt: row.CancelledAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type availableListingRow struct {
|
||||||
|
ID uint64
|
||||||
|
ListingNo string
|
||||||
|
AccountID uint64
|
||||||
|
AccountTitle string
|
||||||
|
ServerRegion string
|
||||||
|
LoginPlatform string
|
||||||
|
OwnerID uint64
|
||||||
|
OwnerPhone string
|
||||||
|
ListingPriceCent int64
|
||||||
|
HafCoinAmount int64
|
||||||
|
AssetSummary datatypes.JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
func (row availableListingRow) toDTO() AvailableListingDTO {
|
||||||
|
snapshot := buildPickupPriceSnapshot(
|
||||||
|
model.RentalListing{PriceCent: row.ListingPriceCent},
|
||||||
|
model.GameAccount{HafCoinAmount: row.HafCoinAmount, AssetSummary: row.AssetSummary},
|
||||||
|
)
|
||||||
|
return AvailableListingDTO{
|
||||||
|
ID: row.ID,
|
||||||
|
ListingNo: row.ListingNo,
|
||||||
|
AccountID: row.AccountID,
|
||||||
|
AccountTitle: row.AccountTitle,
|
||||||
|
ServerRegion: row.ServerRegion,
|
||||||
|
LoginPlatform: row.LoginPlatform,
|
||||||
|
OwnerID: row.OwnerID,
|
||||||
|
OwnerPhone: row.OwnerPhone,
|
||||||
|
ListingPriceCent: snapshot.ListingPriceCent,
|
||||||
|
OwnerPriceCent: snapshot.OwnerPriceCent,
|
||||||
|
WebsiteProfitCent: snapshot.WebsiteProfitCent,
|
||||||
|
SellerRatio: snapshot.SellerRatio,
|
||||||
|
BuyerRatio: snapshot.BuyerRatio,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makePickupAccountSnapshot(account model.GameAccount, listing model.RentalListing) (datatypes.JSON, error) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"listing_id": listing.ID,
|
||||||
|
"listing_no": listing.ListingNo,
|
||||||
|
"account_id": account.ID,
|
||||||
|
"title": account.Title,
|
||||||
|
"game_name": account.GameName,
|
||||||
|
"server_region": account.ServerRegion,
|
||||||
|
"login_platform": account.LoginPlatform,
|
||||||
|
"rank_level": account.RankLevel,
|
||||||
|
"haf_coin_amount": account.HafCoinAmount,
|
||||||
|
"asset_summary": account.AssetSummary,
|
||||||
|
"season_tags": account.SeasonTags,
|
||||||
|
"screenshot_urls": account.ScreenshotURLS,
|
||||||
|
"snapshot_version": 1,
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
return datatypes.JSON(raw), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) fallbackAccountSnapshot(ctx context.Context, listingID uint64, accountID uint64) (datatypes.JSON, error) {
|
||||||
|
var listing model.RentalListing
|
||||||
|
if err := r.db.WithContext(ctx).First(&listing, listingID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var account model.GameAccount
|
||||||
|
if err := r.db.WithContext(ctx).First(&account, accountID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return makePickupAccountSnapshot(account, listing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmptyPickupAccountSnapshot(raw datatypes.JSON) bool {
|
||||||
|
trimmed := strings.TrimSpace(string(raw))
|
||||||
|
return trimmed == "" || trimmed == "null" || trimmed == "{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
type pickupPriceSnapshot struct {
|
||||||
|
ListingPriceCent int64
|
||||||
|
OwnerPriceCent int64
|
||||||
|
WebsiteProfitCent int64
|
||||||
|
SellerRatio float64
|
||||||
|
BuyerRatio float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPickupPriceSnapshot(listing model.RentalListing, account model.GameAccount) pickupPriceSnapshot {
|
||||||
|
summary := decodePickupAssetSummary(account.AssetSummary)
|
||||||
|
breakdown := pickupPriceBreakdown(summary)
|
||||||
|
listingPriceCent := maxPickupCent(listing.PriceCent, 0)
|
||||||
|
ownerPriceCent := yuanToPickupCent(readPickupNumber(breakdown["seller_total_price"]))
|
||||||
|
if ownerPriceCent <= 0 || (listingPriceCent > 0 && ownerPriceCent > listingPriceCent) {
|
||||||
|
ownerPriceCent = listingPriceCent
|
||||||
|
}
|
||||||
|
websiteProfitCent := yuanToPickupCent(readPickupNumber(breakdown["platform_markup_amount"]))
|
||||||
|
if websiteProfitCent <= 0 {
|
||||||
|
websiteProfitCent = listingPriceCent - ownerPriceCent
|
||||||
|
}
|
||||||
|
if websiteProfitCent < 0 {
|
||||||
|
websiteProfitCent = 0
|
||||||
|
}
|
||||||
|
sellerRatio := readPickupNumber(breakdown["seller_ratio"])
|
||||||
|
if sellerRatio <= 0 {
|
||||||
|
sellerRatio = ratioFromCoin(account.HafCoinAmount, readPickupNumber(breakdown["seller_coin_base_price"]))
|
||||||
|
}
|
||||||
|
buyerRatio := readPickupNumber(breakdown["buyer_ratio"])
|
||||||
|
if buyerRatio <= 0 {
|
||||||
|
buyerRatio = readPickupNumber(summary["publish_ratio"])
|
||||||
|
}
|
||||||
|
if buyerRatio <= 0 {
|
||||||
|
buyerRatio = ratioFromCoin(account.HafCoinAmount, readPickupNumber(breakdown["buyer_coin_base_price"]))
|
||||||
|
}
|
||||||
|
return pickupPriceSnapshot{
|
||||||
|
ListingPriceCent: listingPriceCent,
|
||||||
|
OwnerPriceCent: ownerPriceCent,
|
||||||
|
WebsiteProfitCent: websiteProfitCent,
|
||||||
|
SellerRatio: roundPickupRatio(sellerRatio),
|
||||||
|
BuyerRatio: roundPickupRatio(buyerRatio),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodePickupAssetSummary(raw datatypes.JSON) map[string]any {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
var summary map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &summary); err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickupPriceBreakdown(summary map[string]any) map[string]any {
|
||||||
|
if summary == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
if breakdown, ok := summary["price_breakdown"].(map[string]any); ok {
|
||||||
|
return breakdown
|
||||||
|
}
|
||||||
|
if raw, ok := summary["price_breakdown"].(string); ok && strings.TrimSpace(raw) != "" {
|
||||||
|
var breakdown map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &breakdown); err == nil {
|
||||||
|
return breakdown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPickupNumber(value any) float64 {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case float64:
|
||||||
|
return typed
|
||||||
|
case float32:
|
||||||
|
return float64(typed)
|
||||||
|
case int:
|
||||||
|
return float64(typed)
|
||||||
|
case int64:
|
||||||
|
return float64(typed)
|
||||||
|
case json.Number:
|
||||||
|
number, err := typed.Float64()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return number
|
||||||
|
case string:
|
||||||
|
number, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return number
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func yuanToPickupCent(value float64) int64 {
|
||||||
|
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64(math.Round(value * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ratioFromCoin(coinAmount int64, basePrice float64) float64 {
|
||||||
|
if coinAmount <= 0 || basePrice <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(coinAmount) / 10000 / basePrice
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundPickupRatio(value float64) float64 {
|
||||||
|
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return math.Round(value*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxPickupCent(a, b int64) int64 {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
func normalizePaging(page, pageSize int) (int, int) {
|
func normalizePaging(page, pageSize int) (int, int) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
@@ -411,5 +652,5 @@ func newPickupNo() (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
randomNum := (int(buf[0])<<8 | int(buf[1])) % 1000
|
randomNum := (int(buf[0])<<8 | int(buf[1])) % 1000
|
||||||
return "PK" + timeStr + strconv.Itoa(1000+randomNum)[1:], nil
|
return "PK" + timeStr + strconv.Itoa(1000 + randomNum)[1:], nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ func (s *Service) Create(ctx context.Context, req CreateRequest, adminID uint64,
|
|||||||
if req.ListingID == 0 {
|
if req.ListingID == 0 {
|
||||||
return nil, ErrListingUnavailable
|
return nil, ErrListingUnavailable
|
||||||
}
|
}
|
||||||
|
if req.ProfitAmountCent < 0 {
|
||||||
|
return nil, ErrInvalidProfit
|
||||||
|
}
|
||||||
return s.repo.Create(ctx, req, adminID, meta)
|
return s.repo.Create(ctx, req, adminID, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +34,9 @@ func (s *Service) Complete(ctx context.Context, pickupID uint64, req CompleteReq
|
|||||||
if pickupID == 0 || req.SettleAmountCent <= 0 {
|
if pickupID == 0 || req.SettleAmountCent <= 0 {
|
||||||
return nil, ErrInvalidAmount
|
return nil, ErrInvalidAmount
|
||||||
}
|
}
|
||||||
|
if req.ProfitAmountCent != nil && *req.ProfitAmountCent < 0 {
|
||||||
|
return nil, ErrInvalidProfit
|
||||||
|
}
|
||||||
return s.repo.Complete(ctx, pickupID, req, adminID, meta)
|
return s.repo.Complete(ctx, pickupID, req, adminID, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
ALTER TABLE admin_pickups
|
||||||
|
ADD COLUMN listing_price_cent BIGINT NOT NULL DEFAULT 0 COMMENT '提号时网站售价(分)' AFTER platform,
|
||||||
|
ADD COLUMN owner_price_cent BIGINT NOT NULL DEFAULT 0 COMMENT '提号时号主上架价(分)' AFTER listing_price_cent,
|
||||||
|
ADD COLUMN website_profit_cent BIGINT NOT NULL DEFAULT 0 COMMENT '提号时网站价格内平台加价(分)' AFTER owner_price_cent,
|
||||||
|
ADD COLUMN profit_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '线下平台利润(分),管理员手工录入' AFTER website_profit_cent,
|
||||||
|
ADD COLUMN seller_ratio DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '提号时号主回收比例' AFTER profit_amount_cent,
|
||||||
|
ADD COLUMN buyer_ratio DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '提号时网站售卖比例' AFTER seller_ratio;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE admin_pickups
|
||||||
|
DROP COLUMN buyer_ratio,
|
||||||
|
DROP COLUMN seller_ratio,
|
||||||
|
DROP COLUMN profit_amount_cent,
|
||||||
|
DROP COLUMN website_profit_cent,
|
||||||
|
DROP COLUMN owner_price_cent,
|
||||||
|
DROP COLUMN listing_price_cent;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
ALTER TABLE admin_pickups
|
||||||
|
ADD COLUMN account_snapshot JSON NULL COMMENT '提号时账号快照' AFTER buyer_ratio;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE admin_pickups
|
||||||
|
DROP COLUMN account_snapshot;
|
||||||
@@ -46,6 +46,7 @@ export interface FinanceDailyItem {
|
|||||||
|
|
||||||
export interface FinancePickupSummary {
|
export interface FinancePickupSummary {
|
||||||
settled_amount_cent: number
|
settled_amount_cent: number
|
||||||
|
profit_amount_cent: number
|
||||||
completed_count: number
|
completed_count: number
|
||||||
in_progress_count: number
|
in_progress_count: number
|
||||||
}
|
}
|
||||||
@@ -118,6 +119,7 @@ export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) {
|
|||||||
daily_items: Array.isArray(data.data?.daily_items) ? data.data.daily_items : [],
|
daily_items: Array.isArray(data.data?.daily_items) ? data.data.daily_items : [],
|
||||||
pickup_summary: data.data?.pickup_summary ?? {
|
pickup_summary: data.data?.pickup_summary ?? {
|
||||||
settled_amount_cent: 0,
|
settled_amount_cent: 0,
|
||||||
|
profit_amount_cent: 0,
|
||||||
completed_count: 0,
|
completed_count: 0,
|
||||||
in_progress_count: 0,
|
in_progress_count: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export interface AdminPickup {
|
|||||||
owner_phone: string
|
owner_phone: string
|
||||||
admin_id: number
|
admin_id: number
|
||||||
platform: string
|
platform: string
|
||||||
|
listing_price_cent: number
|
||||||
|
owner_price_cent: number
|
||||||
|
website_profit_cent: number
|
||||||
|
profit_amount_cent: number
|
||||||
|
seller_ratio: number
|
||||||
|
buyer_ratio: number
|
||||||
|
account_snapshot?: Record<string, unknown>
|
||||||
settle_amount_cent: number
|
settle_amount_cent: number
|
||||||
status: string
|
status: string
|
||||||
remark: string
|
remark: string
|
||||||
@@ -32,16 +39,23 @@ export interface AvailableListing {
|
|||||||
login_platform: string
|
login_platform: string
|
||||||
owner_id: number
|
owner_id: number
|
||||||
owner_phone: string
|
owner_phone: string
|
||||||
|
listing_price_cent: number
|
||||||
|
owner_price_cent: number
|
||||||
|
website_profit_cent: number
|
||||||
|
seller_ratio: number
|
||||||
|
buyer_ratio: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminPickupCreateRequest {
|
export interface AdminPickupCreateRequest {
|
||||||
listing_id: number
|
listing_id: number
|
||||||
platform?: string
|
platform?: string
|
||||||
|
profit_amount_cent?: number
|
||||||
remark?: string
|
remark?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminPickupCompleteRequest {
|
export interface AdminPickupCompleteRequest {
|
||||||
settle_amount_cent: number
|
settle_amount_cent: number
|
||||||
|
profit_amount_cent?: number
|
||||||
complete_remark?: string
|
complete_remark?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +86,11 @@ export async function fetchAdminPickups(query: AdminPickupListQuery = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminPickup(id: string | number) {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<AdminPickup>>(`/admin/pickups/${id}`)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAvailableListings(keyword: string, page = 1, page_size = 20) {
|
export async function fetchAvailableListings(keyword: string, page = 1, page_size = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AvailableListing>>>(
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AvailableListing>>>(
|
||||||
'/admin/pickups/available-listings',
|
'/admin/pickups/available-listings',
|
||||||
|
|||||||
@@ -145,6 +145,11 @@ function rowDiffClass(row: FinanceDailyItem) {
|
|||||||
<strong>{{ moneyCent(dashboard.pickup_summary.settled_amount_cent) }}</strong>
|
<strong>{{ moneyCent(dashboard.pickup_summary.settled_amount_cent) }}</strong>
|
||||||
<small>{{ dashboard.pickup_summary.completed_count }} 笔已完成</small>
|
<small>{{ dashboard.pickup_summary.completed_count }} 笔已完成</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="metric-card pickup-card">
|
||||||
|
<span>线下提号利润</span>
|
||||||
|
<strong>{{ moneyCent(dashboard.pickup_summary.profit_amount_cent) }}</strong>
|
||||||
|
<small>电商平台手工录入利润</small>
|
||||||
|
</div>
|
||||||
<div class="metric-card pickup-card">
|
<div class="metric-card pickup-card">
|
||||||
<span>提号中</span>
|
<span>提号中</span>
|
||||||
<strong>{{ dashboard.pickup_summary.in_progress_count }}</strong>
|
<strong>{{ dashboard.pickup_summary.in_progress_count }}</strong>
|
||||||
|
|||||||
@@ -0,0 +1,531 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { fetchAdminPickup, type AdminPickup } from '@/features/admin/api/adminPickup'
|
||||||
|
import { quantity, readNumber, readUnitPrice } from '@/features/orders/composables/useOrderSnapshot'
|
||||||
|
import { adminPath } from '@/shared/utils/adminPath'
|
||||||
|
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
||||||
|
import { formatCentWithSymbol, formatMoneyWithSymbol } from '@/shared/utils/money'
|
||||||
|
import { pickupStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
|
|
||||||
|
interface SnapshotResource {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
price: string
|
||||||
|
mode: string
|
||||||
|
quantity: number
|
||||||
|
amount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const loading = ref(false)
|
||||||
|
const pickup = ref<AdminPickup | null>(null)
|
||||||
|
|
||||||
|
const listingCode = computed(() =>
|
||||||
|
pickup.value ? formatListingNo(pickup.value.listing_no, pickup.value.listing_id) : '-'
|
||||||
|
)
|
||||||
|
const snapshot = computed(() => normalizeRecord(pickup.value?.account_snapshot))
|
||||||
|
const assetSummary = computed(() => normalizeRecord(snapshot.value?.asset_summary))
|
||||||
|
const priceBreakdown = computed(() => normalizeRecord(assetSummary.value?.price_breakdown))
|
||||||
|
const snapshotText = computed(() =>
|
||||||
|
snapshot.value ? JSON.stringify(snapshot.value, null, 2) : '暂无账号快照'
|
||||||
|
)
|
||||||
|
const snapshotHafCoinM = computed(() =>
|
||||||
|
snapshot.value ? quantity(readNumber(snapshot.value.haf_coin_amount) / 1000000) : '-'
|
||||||
|
)
|
||||||
|
const snapshotSeasonTags = computed(() => {
|
||||||
|
const tags = snapshot.value?.season_tags
|
||||||
|
return Array.isArray(tags) ? tags.map(item => String(item)).filter(Boolean) : []
|
||||||
|
})
|
||||||
|
const snapshotResources = computed(() => readSnapshotResources(assetSummary.value))
|
||||||
|
const accountSnapshotItems = computed(() => {
|
||||||
|
const row = snapshot.value
|
||||||
|
if (!row) return []
|
||||||
|
return [
|
||||||
|
{ label: '账号ID', value: displayValue(row.account_id) },
|
||||||
|
{ label: '账号标题', value: displayValue(row.title) },
|
||||||
|
{ label: '游戏名称', value: displayValue(row.game_name) },
|
||||||
|
{ label: '所在区服', value: displayValue(row.server_region) },
|
||||||
|
{ label: '登录平台', value: displayValue(row.login_platform) },
|
||||||
|
{ label: '段位等级', value: displayValue(row.rank_level) },
|
||||||
|
].filter(item => item.value !== '-')
|
||||||
|
})
|
||||||
|
const moneySplitRows = computed(() => {
|
||||||
|
const row = pickup.value
|
||||||
|
if (!row) return []
|
||||||
|
const ownerCoinBasePriceCent = readBreakdownCent('seller_coin_base_price')
|
||||||
|
const ownerLossPriceCent =
|
||||||
|
readBreakdownCent('consumable_price') ?? fallbackOwnerLossCent(ownerCoinBasePriceCent)
|
||||||
|
const offlineTotalCent =
|
||||||
|
row.settle_amount_cent > 0 ? row.settle_amount_cent + row.profit_amount_cent : null
|
||||||
|
const rows = [
|
||||||
|
{ label: '网站售价', amountCent: row.listing_price_cent, tone: '' },
|
||||||
|
{ label: '号主纯币价格', amountCent: ownerCoinBasePriceCent, tone: '' },
|
||||||
|
{ label: '号主损耗', amountCent: ownerLossPriceCent, tone: '' },
|
||||||
|
{ label: '号主价合计', amountCent: row.owner_price_cent, tone: 'subtotal' },
|
||||||
|
{ label: '网站加价', amountCent: row.website_profit_cent, tone: '' },
|
||||||
|
{ label: '线下利润', amountCent: row.profit_amount_cent, tone: 'profit' },
|
||||||
|
{ label: '结算给号主', amountCent: row.settle_amount_cent, tone: '' },
|
||||||
|
]
|
||||||
|
if (offlineTotalCent !== null) {
|
||||||
|
rows.push({ label: '线下成交合计', amountCent: offlineTotalCent, tone: 'total' })
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
const breakdownRows = computed(() => {
|
||||||
|
const row = priceBreakdown.value
|
||||||
|
if (!row) return []
|
||||||
|
return [
|
||||||
|
{ label: '回收比例', value: ratioText(readNumber(row.seller_ratio)) },
|
||||||
|
{ label: '售卖比例', value: ratioText(readNumber(row.buyer_ratio)) },
|
||||||
|
{ label: '参考比例', value: ratioText(readNumber(row.seller_reference_ratio)) },
|
||||||
|
{ label: '加速出号比例', value: ratioText(readNumber(row.accelerated_sale_ratio)) },
|
||||||
|
{ label: '定价规则', value: displayValue(row.platform_rule_type) },
|
||||||
|
{ label: '调价原因', value: displayValue(row.admin_adjust_reason) },
|
||||||
|
{ label: '调价时间', value: displayValue(row.admin_adjusted_at) },
|
||||||
|
].filter(item => item.value !== '-')
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadPickup)
|
||||||
|
|
||||||
|
async function loadPickup() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
pickup.value = await fetchAdminPickup(String(route.params.id))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusType(status: string) {
|
||||||
|
if (status === 'picking_up') return 'warning'
|
||||||
|
if (status === 'completed') return 'success'
|
||||||
|
if (status === 'cancelled') return 'info'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioText(value: number) {
|
||||||
|
const ratio = Number(value || 0)
|
||||||
|
if (ratio <= 0) return '-'
|
||||||
|
const rounded = Math.round(ratio * 100) / 100
|
||||||
|
return `1:${Number.isInteger(rounded) ? rounded : rounded.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayValue(value: unknown) {
|
||||||
|
if (value === undefined || value === null || value === '') return '-'
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBreakdownCent(key: string) {
|
||||||
|
const value = readNumber(priceBreakdown.value?.[key])
|
||||||
|
return value > 0 ? Math.round(value * 100) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackOwnerLossCent(ownerCoinBasePriceCent: number | null) {
|
||||||
|
const row = pickup.value
|
||||||
|
if (!row || ownerCoinBasePriceCent === null) return null
|
||||||
|
return Math.max(row.owner_price_cent - ownerCoinBasePriceCent, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNullableCent(value: number | null | undefined) {
|
||||||
|
if (value === null || value === undefined) return '-'
|
||||||
|
return formatCentWithSymbol(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value === 'object' && value !== null) return value as Record<string, unknown>
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value)
|
||||||
|
return typeof parsed === 'object' && parsed !== null ? parsed : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSnapshotResources(summary: Record<string, unknown> | null): SnapshotResource[] {
|
||||||
|
const resources = summary?.resources
|
||||||
|
if (!Array.isArray(resources)) return []
|
||||||
|
return resources
|
||||||
|
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
||||||
|
.map(item => {
|
||||||
|
const key = String(item.key || item.label || '')
|
||||||
|
const label = String(item.label || key || '额外消耗品')
|
||||||
|
const price = String(item.price || '')
|
||||||
|
const mode = String(item.mode || '收费')
|
||||||
|
const count = readNumber(item.quantity)
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
price,
|
||||||
|
mode,
|
||||||
|
quantity: count,
|
||||||
|
amount: mode === '收费' ? count * readUnitPrice(price) : 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(item => item.key && item.quantity > 0)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page" v-loading="loading">
|
||||||
|
<div v-if="pickup" class="page-header-row">
|
||||||
|
<div class="page-header">
|
||||||
|
<p class="eyebrow">{{ pickup.pickup_no }}</p>
|
||||||
|
<h1>提号详情</h1>
|
||||||
|
<p>
|
||||||
|
商品编号 {{ listingCode }} · {{ pickup.account_title }} · {{ pickup.server_region }} /
|
||||||
|
{{ pickup.login_platform }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<RouterLink :to="adminPath('pickup')">
|
||||||
|
<el-button>返回列表</el-button>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pickup" class="metric-grid pickup-metrics">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>提号状态</span>
|
||||||
|
<strong>{{ pickupStatusLabel(pickup.status) }}</strong>
|
||||||
|
<small>创建 {{ formatDateTime(pickup.created_at) }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>线下利润</span>
|
||||||
|
<strong>{{ formatCentWithSymbol(pickup.profit_amount_cent) }}</strong>
|
||||||
|
<small>财务统计使用该金额</small>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>结算金额</span>
|
||||||
|
<strong>{{ formatCentWithSymbol(pickup.settle_amount_cent) }}</strong>
|
||||||
|
<small>{{ pickup.completed_at ? formatDateTime(pickup.completed_at) : '待结算' }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>比例</span>
|
||||||
|
<strong>{{ ratioText(pickup.seller_ratio) }} / {{ ratioText(pickup.buyer_ratio) }}</strong>
|
||||||
|
<small>回收 / 售卖</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pickup" class="pickup-detail-grid">
|
||||||
|
<section class="dashboard-panel detail-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>提号概览</h2>
|
||||||
|
<el-tag :type="statusType(pickup.status)" effect="light">
|
||||||
|
{{ pickupStatusLabel(pickup.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<div>
|
||||||
|
<dt>提号编号</dt>
|
||||||
|
<dd>{{ pickup.pickup_no }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>上架编号</dt>
|
||||||
|
<dd>{{ listingCode }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>账号ID</dt>
|
||||||
|
<dd>{{ pickup.account_id }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>号主</dt>
|
||||||
|
<dd>{{ pickup.owner_phone || pickup.owner_id }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>交易渠道</dt>
|
||||||
|
<dd>{{ pickup.platform || '-' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>管理员ID</dt>
|
||||||
|
<dd>{{ pickup.admin_id }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>创建时间</dt>
|
||||||
|
<dd>{{ formatDateTime(pickup.created_at) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>完成时间</dt>
|
||||||
|
<dd>{{ formatDateTime(pickup.completed_at, '未完成') }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-panel detail-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>资金拆分</h2>
|
||||||
|
<span class="panel-subtitle">提号创建时固化</span>
|
||||||
|
</div>
|
||||||
|
<div class="money-list">
|
||||||
|
<div
|
||||||
|
v-for="row in moneySplitRows"
|
||||||
|
:key="row.label"
|
||||||
|
class="money-row"
|
||||||
|
:class="`is-${row.tone || 'normal'}`"
|
||||||
|
>
|
||||||
|
<span>{{ row.label }}</span>
|
||||||
|
<strong>{{ formatNullableCent(row.amountCent) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-panel detail-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>备注</h2>
|
||||||
|
</div>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<div class="wide">
|
||||||
|
<dt>创建备注</dt>
|
||||||
|
<dd>{{ pickup.remark || '-' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="wide">
|
||||||
|
<dt>完成备注</dt>
|
||||||
|
<dd>{{ pickup.complete_remark || '-' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="breakdownRows.length" class="dashboard-panel detail-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>价格拆分</h2>
|
||||||
|
<span class="panel-subtitle">来自账号快照</span>
|
||||||
|
</div>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<div v-for="row in breakdownRows" :key="row.label">
|
||||||
|
<dt>{{ row.label }}</dt>
|
||||||
|
<dd>{{ row.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-panel detail-panel pickup-wide-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>账号快照</h2>
|
||||||
|
<span class="panel-subtitle">哈夫币 {{ snapshotHafCoinM }}M</span>
|
||||||
|
</div>
|
||||||
|
<dl v-if="accountSnapshotItems.length" class="detail-list snapshot-detail-list">
|
||||||
|
<div v-for="item in accountSnapshotItems" :key="item.label">
|
||||||
|
<dt>{{ item.label }}</dt>
|
||||||
|
<dd>{{ item.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div v-if="snapshotSeasonTags.length" class="snapshot-tags">
|
||||||
|
<el-tag v-for="tag in snapshotSeasonTags" :key="tag" size="small" effect="plain">
|
||||||
|
{{ tag }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="snapshotResources.length" class="snapshot-resources">
|
||||||
|
<div v-for="resource in snapshotResources" :key="resource.key" class="resource-item">
|
||||||
|
<strong>{{ resource.label }}</strong>
|
||||||
|
<span>
|
||||||
|
{{ quantity(resource.quantity) }} · {{ resource.price || resource.mode }}
|
||||||
|
<template v-if="resource.amount > 0">
|
||||||
|
· {{ formatMoneyWithSymbol(resource.amount) }}</template
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="snapshot-json">{{ snapshotText }}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pickup-metrics {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickup-metrics .metric-card strong {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #8f9bba;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickup-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr);
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickup-wide-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-subtitle {
|
||||||
|
color: #8f9bba;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px 18px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list div {
|
||||||
|
min-width: 0;
|
||||||
|
border-top: 1px solid #f0f2f5;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list .wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list dt {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #8f9bba;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #1b2559;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.money-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.money-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-top: 1px solid #f0f2f5;
|
||||||
|
padding-top: 9px;
|
||||||
|
color: #52616f;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.money-row strong {
|
||||||
|
color: #1b2559;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.money-row.is-subtotal {
|
||||||
|
border-top-color: #d8e2f0;
|
||||||
|
color: #1b2559;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.money-row.is-profit strong {
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.money-row.is-total {
|
||||||
|
margin-top: 4px;
|
||||||
|
border-top-color: #bfdbfe;
|
||||||
|
color: #1b2559;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.money-row.is-total strong {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-detail-list {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-resources {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfcff;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-item strong {
|
||||||
|
color: #1b2559;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-item span {
|
||||||
|
color: #52616f;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-json {
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 360px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #dbeafe;
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.pickup-detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickup-wide-panel {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
@@ -11,9 +11,10 @@ import {
|
|||||||
type AdminPickup,
|
type AdminPickup,
|
||||||
type AvailableListing,
|
type AvailableListing,
|
||||||
} from '@/features/admin/api/adminPickup'
|
} from '@/features/admin/api/adminPickup'
|
||||||
import { formatCentWithSymbol } from '@/shared/utils/money'
|
import { centToYuan, formatCentWithSymbol, yuanToCent } from '@/shared/utils/money'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
import { pickupStatusLabel } from '@/shared/utils/statusLabels'
|
import { pickupStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
|
import { adminPath } from '@/shared/utils/adminPath'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const items = ref<AdminPickup[]>([])
|
const items = ref<AdminPickup[]>([])
|
||||||
@@ -27,12 +28,16 @@ const activePickupId = ref(0)
|
|||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
listing_id: null as number | null,
|
listing_id: null as number | null,
|
||||||
platform: '',
|
platform: '',
|
||||||
|
profit_amount: 0,
|
||||||
remark: '',
|
remark: '',
|
||||||
})
|
})
|
||||||
const completeForm = reactive({ settle_amount: 0, complete_remark: '' })
|
const completeForm = reactive({ settle_amount: 0, profit_amount: 0, complete_remark: '' })
|
||||||
|
|
||||||
const listingOptions = ref<AvailableListing[]>([])
|
const listingOptions = ref<AvailableListing[]>([])
|
||||||
const listingLoading = ref(false)
|
const listingLoading = ref(false)
|
||||||
|
const selectedListing = computed(
|
||||||
|
() => listingOptions.value.find(item => item.id === createForm.listing_id) || null
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(loadList)
|
onMounted(loadList)
|
||||||
|
|
||||||
@@ -64,6 +69,7 @@ function handleSizeChange(s: number) {
|
|||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
createForm.listing_id = null
|
createForm.listing_id = null
|
||||||
createForm.platform = ''
|
createForm.platform = ''
|
||||||
|
createForm.profit_amount = 0
|
||||||
createForm.remark = ''
|
createForm.remark = ''
|
||||||
listingOptions.value = []
|
listingOptions.value = []
|
||||||
createDialogVisible.value = true
|
createDialogVisible.value = true
|
||||||
@@ -88,11 +94,16 @@ async function handleCreate() {
|
|||||||
ElMessage.warning('请选择要提号的账号')
|
ElMessage.warning('请选择要提号的账号')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (createForm.profit_amount < 0) {
|
||||||
|
ElMessage.warning('线下利润不能为负数')
|
||||||
|
return
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await createAdminPickup({
|
await createAdminPickup({
|
||||||
listing_id: createForm.listing_id,
|
listing_id: createForm.listing_id,
|
||||||
platform: createForm.platform,
|
platform: createForm.platform,
|
||||||
|
profit_amount_cent: yuanToCent(createForm.profit_amount),
|
||||||
remark: createForm.remark,
|
remark: createForm.remark,
|
||||||
})
|
})
|
||||||
ElMessage.success('提号订单已创建')
|
ElMessage.success('提号订单已创建')
|
||||||
@@ -105,9 +116,10 @@ async function handleCreate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCompleteDialog(id: number) {
|
function openCompleteDialog(row: AdminPickup) {
|
||||||
activePickupId.value = id
|
activePickupId.value = row.id
|
||||||
completeForm.settle_amount = 0
|
completeForm.settle_amount = 0
|
||||||
|
completeForm.profit_amount = centToYuan(row.profit_amount_cent)
|
||||||
completeForm.complete_remark = ''
|
completeForm.complete_remark = ''
|
||||||
completeDialogVisible.value = true
|
completeDialogVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -117,10 +129,15 @@ async function handleComplete() {
|
|||||||
ElMessage.warning('请输入结算金额')
|
ElMessage.warning('请输入结算金额')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (completeForm.profit_amount < 0) {
|
||||||
|
ElMessage.warning('线下利润不能为负数')
|
||||||
|
return
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await completeAdminPickup(activePickupId.value, {
|
await completeAdminPickup(activePickupId.value, {
|
||||||
settle_amount_cent: Math.round(completeForm.settle_amount * 100),
|
settle_amount_cent: yuanToCent(completeForm.settle_amount),
|
||||||
|
profit_amount_cent: yuanToCent(completeForm.profit_amount),
|
||||||
complete_remark: completeForm.complete_remark,
|
complete_remark: completeForm.complete_remark,
|
||||||
})
|
})
|
||||||
ElMessage.success('提号已完成,已给卖家结算')
|
ElMessage.success('提号已完成,已给卖家结算')
|
||||||
@@ -169,6 +186,13 @@ function errorMessage(e: unknown) {
|
|||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ratioText(value: number) {
|
||||||
|
const ratio = Number(value || 0)
|
||||||
|
if (ratio <= 0) return '-'
|
||||||
|
const rounded = Math.round(ratio * 100) / 100
|
||||||
|
return `1:${Number.isInteger(rounded) ? rounded : rounded.toFixed(2)}`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -205,7 +229,9 @@ function errorMessage(e: unknown) {
|
|||||||
<el-table-column prop="pickup_no" label="提号编号" width="190" />
|
<el-table-column prop="pickup_no" label="提号编号" width="190" />
|
||||||
<el-table-column prop="account_title" label="账号" min-width="180" />
|
<el-table-column prop="account_title" label="账号" min-width="180" />
|
||||||
<el-table-column label="区服/平台" width="150">
|
<el-table-column label="区服/平台" width="150">
|
||||||
<template #default="{ row }">{{ row.server_region || '-' }} / {{ row.login_platform || '-' }}</template>
|
<template #default="{ row }"
|
||||||
|
>{{ row.server_region || '-' }} / {{ row.login_platform || '-' }}</template
|
||||||
|
>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="上架编号" width="120">
|
<el-table-column label="上架编号" width="120">
|
||||||
<template #default="{ row }">{{ row.listing_no }}</template>
|
<template #default="{ row }">{{ row.listing_no }}</template>
|
||||||
@@ -218,7 +244,9 @@ function errorMessage(e: unknown) {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="结算金额" width="130">
|
<el-table-column label="结算金额" width="130">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.status === 'completed'">{{ formatCentWithSymbol(row.settle_amount_cent) }}</span>
|
<span v-if="row.status === 'completed'">{{
|
||||||
|
formatCentWithSymbol(row.settle_amount_cent)
|
||||||
|
}}</span>
|
||||||
<span v-else class="text-muted">待结算</span>
|
<span v-else class="text-muted">待结算</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -232,26 +260,30 @@ function errorMessage(e: unknown) {
|
|||||||
<el-table-column label="创建时间" width="170">
|
<el-table-column label="创建时间" width="170">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-table-column label="操作" width="220" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<div class="action-buttons">
|
||||||
v-if="row.status === 'picking_up'"
|
<RouterLink :to="adminPath(`pickup/${row.id}`)">
|
||||||
type="primary"
|
<el-button size="small">详情</el-button>
|
||||||
size="small"
|
</RouterLink>
|
||||||
@click="openCompleteDialog(row.id)"
|
<el-button
|
||||||
>
|
v-if="row.status === 'picking_up'"
|
||||||
完成结算
|
type="primary"
|
||||||
</el-button>
|
size="small"
|
||||||
<el-button
|
@click="openCompleteDialog(row)"
|
||||||
v-if="row.status === 'picking_up'"
|
>
|
||||||
type="danger"
|
完成结算
|
||||||
size="small"
|
</el-button>
|
||||||
plain
|
<el-button
|
||||||
@click="handleCancel(row.id)"
|
v-if="row.status === 'picking_up'"
|
||||||
>
|
type="danger"
|
||||||
取消
|
size="small"
|
||||||
</el-button>
|
plain
|
||||||
<span v-if="row.status !== 'picking_up'" class="text-muted">—</span>
|
@click="handleCancel(row.id)"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -285,15 +317,35 @@ function errorMessage(e: unknown) {
|
|||||||
<el-option
|
<el-option
|
||||||
v-for="item in listingOptions"
|
v-for="item in listingOptions"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:label="`${item.listing_no} · ${item.account_title}`"
|
:label="`${item.listing_no} · ${item.account_title} · ${formatCentWithSymbol(item.owner_price_cent)} / ${formatCentWithSymbol(item.listing_price_cent)}`"
|
||||||
:value="item.id"
|
:value="item.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<div class="form-hint">仅显示已发布、已审核、未在交易中的账号</div>
|
<div class="form-hint">仅显示已发布、已审核、未在交易中的账号</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="selectedListing" label="价格快照">
|
||||||
|
<div class="price-snapshot">
|
||||||
|
<span>号主价 {{ formatCentWithSymbol(selectedListing.owner_price_cent) }}</span>
|
||||||
|
<span>网站售价 {{ formatCentWithSymbol(selectedListing.listing_price_cent) }}</span>
|
||||||
|
<span>网站加价 {{ formatCentWithSymbol(selectedListing.website_profit_cent) }}</span>
|
||||||
|
<span>回收 {{ ratioText(selectedListing.seller_ratio) }}</span>
|
||||||
|
<span>售卖 {{ ratioText(selectedListing.buyer_ratio) }}</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="交易渠道">
|
<el-form-item label="交易渠道">
|
||||||
<el-input v-model="createForm.platform" placeholder="如 微信 / QQ / 闲鱼" />
|
<el-input v-model="createForm.platform" placeholder="如 微信 / QQ / 闲鱼" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="线下利润(元)">
|
||||||
|
<el-input-number
|
||||||
|
v-model="createForm.profit_amount"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
<div class="form-hint">电商平台实际成交利润,财务统计按该金额汇总</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="备注">
|
<el-form-item label="备注">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="createForm.remark"
|
v-model="createForm.remark"
|
||||||
@@ -326,6 +378,17 @@ function errorMessage(e: unknown) {
|
|||||||
/>
|
/>
|
||||||
<div class="form-hint">给卖家钱包增加的可用余额</div>
|
<div class="form-hint">给卖家钱包增加的可用余额</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="线下利润(元)">
|
||||||
|
<el-input-number
|
||||||
|
v-model="completeForm.profit_amount"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
<div class="form-hint">可在结算时修正为最终利润,财务统计使用该值</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="完成备注">
|
<el-form-item label="完成备注">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="completeForm.complete_remark"
|
v-model="completeForm.complete_remark"
|
||||||
@@ -335,7 +398,7 @@ function errorMessage(e: unknown) {
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-alert type="warning" :closable="false">
|
<el-alert type="warning" :closable="false">
|
||||||
完成后订单置为"已完成",卖家钱包立即入账,账号下架不可再上架。操作不可撤销。
|
完成后提号记录置为"已完成",卖家钱包立即入账,账号下架不可再上架。操作不可撤销。
|
||||||
</el-alert>
|
</el-alert>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -355,4 +418,17 @@ function errorMessage(e: unknown) {
|
|||||||
.text-muted {
|
.text-muted {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
.price-snapshot {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ function statusType(status: string) {
|
|||||||
if (status === 'cancelled') return 'info'
|
if (status === 'cancelled') return 'info'
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ratioText(value: number) {
|
||||||
|
const ratio = Number(value || 0)
|
||||||
|
if (ratio <= 0) return '-'
|
||||||
|
const rounded = Math.round(ratio * 100) / 100
|
||||||
|
return `1:${Number.isInteger(rounded) ? rounded : rounded.toFixed(2)}`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -70,11 +77,19 @@ function statusType(status: string) {
|
|||||||
<el-table-column prop="pickup_no" label="提号编号" width="190" />
|
<el-table-column prop="pickup_no" label="提号编号" width="190" />
|
||||||
<el-table-column prop="account_title" label="账号" min-width="180" />
|
<el-table-column prop="account_title" label="账号" min-width="180" />
|
||||||
<el-table-column label="区服/平台" width="150">
|
<el-table-column label="区服/平台" width="150">
|
||||||
<template #default="{ row }">{{ row.server_region || '-' }} / {{ row.login_platform || '-' }}</template>
|
<template #default="{ row }"
|
||||||
|
>{{ row.server_region || '-' }} / {{ row.login_platform || '-' }}</template
|
||||||
|
>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="交易渠道" width="110">
|
<el-table-column label="交易渠道" width="110">
|
||||||
<template #default="{ row }">{{ row.platform || '-' }}</template>
|
<template #default="{ row }">{{ row.platform || '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="号主价" width="120">
|
||||||
|
<template #default="{ row }">{{ formatCentWithSymbol(row.owner_price_cent) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="回收比例" width="110">
|
||||||
|
<template #default="{ row }">{{ ratioText(row.seller_ratio) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="结算金额" width="140">
|
<el-table-column label="结算金额" width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.status === 'completed'" class="amount-in">
|
<span v-if="row.status === 'completed'" class="amount-in">
|
||||||
@@ -100,7 +115,9 @@ function statusType(status: string) {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="备注" min-width="160">
|
<el-table-column label="备注" min-width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.status === 'completed' && row.complete_remark">{{ row.complete_remark }}</span>
|
<span v-if="row.status === 'completed' && row.complete_remark">{{
|
||||||
|
row.complete_remark
|
||||||
|
}}</span>
|
||||||
<span v-else-if="row.remark">{{ row.remark }}</span>
|
<span v-else-if="row.remark">{{ row.remark }}</span>
|
||||||
<span v-else class="text-muted">—</span>
|
<span v-else class="text-muted">—</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export const adminRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/features/admin/views/AdminPickupView.vue'),
|
component: () => import('@/features/admin/views/AdminPickupView.vue'),
|
||||||
meta: adminMeta,
|
meta: adminMeta,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: adminPath('pickup/:id'),
|
||||||
|
name: 'admin-pickup-detail',
|
||||||
|
component: () => import('@/features/admin/views/AdminPickupDetailView.vue'),
|
||||||
|
meta: adminMeta,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: adminPath('orders/:id'),
|
path: adminPath('orders/:id'),
|
||||||
name: 'admin-order-detail',
|
name: 'admin-order-detail',
|
||||||
|
|||||||
Reference in New Issue
Block a user