diff --git a/backend/internal/model/pickup.go b/backend/internal/model/pickup.go index bbbbfd6..6443afd 100644 --- a/backend/internal/model/pickup.go +++ b/backend/internal/model/pickup.go @@ -1,25 +1,36 @@ package model -import "time" +import ( + "time" + + "gorm.io/datatypes" +) // AdminPickup 管理员线下提号记录。 // 独立于 rental_orders,不进入正常订单状态机与财务统计口径, // 完成时通过 wallet.AppendEntries 给卖家增加可用余额。 type AdminPickup struct { - ID uint64 `gorm:"primaryKey" json:"id"` - PickupNo string `gorm:"size:64;not null;uniqueIndex" json:"pickup_no"` - ListingID uint64 `gorm:"not null;index" json:"listing_id"` - 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"` - Platform string `gorm:"size:32;not null;default:''" json:"platform"` - 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"` + ID uint64 `gorm:"primaryKey" json:"id"` + PickupNo string `gorm:"size:64;not null;uniqueIndex" json:"pickup_no"` + ListingID uint64 `gorm:"not null;index" json:"listing_id"` + 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"` + Platform string `gorm:"size:32;not null;default:''" json:"platform"` + ListingPriceCent int64 `gorm:"not null;default:0" json:"listing_price_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"` + SellerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"seller_ratio"` + BuyerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"buyer_ratio"` + 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 { diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index c7adc4d..c696fa7 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -32,10 +32,11 @@ func (r *Repository) pickupSummary(ctx context.Context, query DashboardQuery) (* db := r.db.WithContext(ctx) var row struct { SettledAmountCent int64 + ProfitAmountCent int64 CompletedCount int64 } 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("completed_at >= ? AND completed_at <= ?", query.StartDate, query.EndDate). Scan(&row).Error; err != nil { @@ -47,6 +48,7 @@ func (r *Repository) pickupSummary(ctx context.Context, query DashboardQuery) (* } return &PickupSummaryDTO{ SettledAmountCent: row.SettledAmountCent, + ProfitAmountCent: row.ProfitAmountCent, CompletedCount: row.CompletedCount, InProgressCount: inProgress, }, nil diff --git a/backend/internal/modules/adminfinance/dto.go b/backend/internal/modules/adminfinance/dto.go index 98dce65..79053b0 100644 --- a/backend/internal/modules/adminfinance/dto.go +++ b/backend/internal/modules/adminfinance/dto.go @@ -20,15 +20,16 @@ type DetailQuery struct { } type DashboardDTO struct { - Summary FinanceSummaryDTO `json:"summary"` - DailyItems []FinanceDailyDTO `json:"daily_items"` - PickupSummary PickupSummaryDTO `json:"pickup_summary"` - GeneratedAt time.Time `json:"generated_at"` + Summary FinanceSummaryDTO `json:"summary"` + DailyItems []FinanceDailyDTO `json:"daily_items"` + PickupSummary PickupSummaryDTO `json:"pickup_summary"` + GeneratedAt time.Time `json:"generated_at"` } // PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。 type PickupSummaryDTO struct { SettledAmountCent int64 `json:"settled_amount_cent"` // 区间内已完成提号的结算金额合计 + ProfitAmountCent int64 `json:"profit_amount_cent"` // 区间内已完成提号的线下利润合计 CompletedCount int64 `json:"completed_count"` // 区间内已完成提号笔数 InProgressCount int64 `json:"in_progress_count"` // 当前提号中笔数(不按时间) } diff --git a/backend/internal/modules/pickup/dto.go b/backend/internal/modules/pickup/dto.go index dce3851..6d401cf 100644 --- a/backend/internal/modules/pickup/dto.go +++ b/backend/internal/modules/pickup/dto.go @@ -3,6 +3,8 @@ package pickup import ( "errors" "time" + + "gorm.io/datatypes" ) // 提号状态 @@ -18,39 +20,49 @@ var ( ErrPickupNotFound = errors.New("pickup not found") ErrPickupNotPickingUp = errors.New("pickup not in picking_up status") ErrInvalidAmount = errors.New("invalid amount") + ErrInvalidProfit = errors.New("invalid profit amount") ErrDuplicatePickup = errors.New("duplicate pickup in progress") ) type PickupDTO struct { - ID uint64 `json:"id"` - PickupNo string `json:"pickup_no"` - ListingID uint64 `json:"listing_id"` - ListingNo string `json:"listing_no"` - AccountID uint64 `json:"account_id"` - AccountTitle string `json:"account_title"` - ServerRegion string `json:"server_region"` - LoginPlatform string `json:"login_platform"` - OwnerID uint64 `json:"owner_id"` - OwnerPhone string `json:"owner_phone"` - AdminID uint64 `json:"admin_id"` - Platform string `json:"platform"` - 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"` + ID uint64 `json:"id"` + PickupNo string `json:"pickup_no"` + ListingID uint64 `json:"listing_id"` + ListingNo string `json:"listing_no"` + AccountID uint64 `json:"account_id"` + AccountTitle string `json:"account_title"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + OwnerID uint64 `json:"owner_id"` + OwnerPhone string `json:"owner_phone"` + AdminID uint64 `json:"admin_id"` + Platform string `json:"platform"` + ListingPriceCent int64 `json:"listing_price_cent"` + OwnerPriceCent int64 `json:"owner_price_cent"` + WebsiteProfitCent int64 `json:"website_profit_cent"` + ProfitAmountCent int64 `json:"profit_amount_cent"` + SellerRatio float64 `json:"seller_ratio"` + BuyerRatio float64 `json:"buyer_ratio"` + 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 { - ListingID uint64 `json:"listing_id" binding:"required"` - Platform string `json:"platform"` - Remark string `json:"remark"` + ListingID uint64 `json:"listing_id" binding:"required"` + Platform string `json:"platform"` + ProfitAmountCent int64 `json:"profit_amount_cent"` + Remark string `json:"remark"` } type CompleteRequest struct { SettleAmountCent int64 `json:"settle_amount_cent" binding:"required,min=1"` + ProfitAmountCent *int64 `json:"profit_amount_cent"` CompleteRemark string `json:"complete_remark"` } @@ -72,14 +84,19 @@ type SellerPickupQuery struct { } type AvailableListingDTO struct { - ID uint64 `json:"id"` - ListingNo string `json:"listing_no"` - AccountID uint64 `json:"account_id"` - AccountTitle string `json:"account_title"` - ServerRegion string `json:"server_region"` - LoginPlatform string `json:"login_platform"` - OwnerID uint64 `json:"owner_id"` - OwnerPhone string `json:"owner_phone"` + ID uint64 `json:"id"` + ListingNo string `json:"listing_no"` + AccountID uint64 `json:"account_id"` + AccountTitle string `json:"account_title"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + OwnerID uint64 `json:"owner_id"` + 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 { diff --git a/backend/internal/modules/pickup/handler.go b/backend/internal/modules/pickup/handler.go index 2d8c38b..72be943 100644 --- a/backend/internal/modules/pickup/handler.go +++ b/backend/internal/modules/pickup/handler.go @@ -154,6 +154,8 @@ func writePickupError(c *gin.Context, err error) { response.BadRequest(c, "提号订单状态不允许该操作") case errors.Is(err, ErrInvalidAmount): response.BadRequest(c, "结算金额不正确") + case errors.Is(err, ErrInvalidProfit): + response.BadRequest(c, "利润金额不正确") default: response.Error(c, http.StatusInternalServerError, "pickup_error", err.Error()) } diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index 251cd09..3a9a685 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -3,8 +3,10 @@ package pickup import ( "context" crand "crypto/rand" + "encoding/json" "errors" "fmt" + "math" "strconv" "strings" "time" @@ -16,6 +18,7 @@ import ( "hfb_sys/backend/internal/timeutil" "hfb_sys/backend/pkg/money" + "gorm.io/datatypes" "gorm.io/gorm" "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 { return err } + priceSnapshot := buildPickupPriceSnapshot(listing, account) + accountSnapshot, err := makePickupAccountSnapshot(account, listing) + if err != nil { + return err + } pickupNo, err := newPickupNo() if err != nil { return err } pickup := model.AdminPickup{ - PickupNo: pickupNo, - ListingID: listing.ID, - AccountID: listing.AccountID, - OwnerID: listing.OwnerID, - AdminID: adminID, - Platform: strings.TrimSpace(req.Platform), - Status: StatusPickingUp, - Remark: strings.TrimSpace(req.Remark), + PickupNo: pickupNo, + ListingID: listing.ID, + AccountID: listing.AccountID, + OwnerID: listing.OwnerID, + AdminID: adminID, + Platform: strings.TrimSpace(req.Platform), + ListingPriceCent: priceSnapshot.ListingPriceCent, + 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 { return err @@ -107,9 +122,10 @@ 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, + "pickup_no": pickupNo, + "listing_id": listing.ID, + "platform": pickup.Platform, + "profit_cent": pickup.ProfitAmountCent, }, }); err != nil { return err @@ -151,6 +167,9 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete now := time.Now() pickup.SettleAmountCent = req.SettleAmountCent + if req.ProfitAmountCent != nil { + pickup.ProfitAmountCent = *req.ProfitAmountCent + } pickup.Status = StatusCompleted pickup.CompleteRemark = strings.TrimSpace(req.CompleteRemark) pickup.CompletedAt = &now @@ -199,6 +218,7 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete Detail: map[string]any{ "pickup_no": pickup.PickupNo, "settle_amount_cent": req.SettleAmountCent, + "profit_amount_cent": pickup.ProfitAmountCent, }, }); err != nil { return err @@ -275,6 +295,11 @@ func (r *Repository) FindByID(ctx context.Context, id uint64) (*PickupDTO, error return nil, err } 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 } @@ -290,7 +315,7 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*Pa like := "%" + kw + "%" 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) { @@ -301,10 +326,10 @@ func (r *Repository) ListForSeller(ctx context.Context, ownerID uint64, query Se if status := strings.TrimSpace(query.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) var total int64 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)) 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 } @@ -327,7 +359,7 @@ func (r *Repository) ListAvailableListings(ctx context.Context, keyword string, return nil, ErrDependencyUnavailable } 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 users AS owner ON owner.id = l.owner_id"). 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 } 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 { 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 { @@ -367,28 +403,233 @@ type pickupRow struct { func (row pickupRow) toDTO() PickupDTO { return PickupDTO{ - ID: row.ID, - PickupNo: row.PickupNo, - ListingID: row.ListingID, - ListingNo: row.ListingNo, - AccountID: row.AccountID, - AccountTitle: row.AccountTitle, - ServerRegion: row.ServerRegion, - LoginPlatform: row.LoginPlatform, - OwnerID: row.OwnerID, - OwnerPhone: row.OwnerPhone, - AdminID: row.AdminID, - Platform: row.Platform, - SettleAmountCent: row.SettleAmountCent, - Status: row.Status, - Remark: row.Remark, - CompleteRemark: row.CompleteRemark, - CreatedAt: row.CreatedAt, - CompletedAt: row.CompletedAt, - CancelledAt: row.CancelledAt, + ID: row.ID, + PickupNo: row.PickupNo, + ListingID: row.ListingID, + ListingNo: row.ListingNo, + AccountID: row.AccountID, + AccountTitle: row.AccountTitle, + ServerRegion: row.ServerRegion, + LoginPlatform: row.LoginPlatform, + OwnerID: row.OwnerID, + OwnerPhone: row.OwnerPhone, + AdminID: row.AdminID, + Platform: row.Platform, + ListingPriceCent: row.ListingPriceCent, + OwnerPriceCent: row.OwnerPriceCent, + WebsiteProfitCent: row.WebsiteProfitCent, + ProfitAmountCent: row.ProfitAmountCent, + SellerRatio: row.SellerRatio, + BuyerRatio: row.BuyerRatio, + 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) { if page < 1 { page = 1 @@ -411,5 +652,5 @@ func newPickupNo() (string, error) { return "", err } 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 } diff --git a/backend/internal/modules/pickup/service.go b/backend/internal/modules/pickup/service.go index 205d35e..b122c8b 100644 --- a/backend/internal/modules/pickup/service.go +++ b/backend/internal/modules/pickup/service.go @@ -21,6 +21,9 @@ func (s *Service) Create(ctx context.Context, req CreateRequest, adminID uint64, if req.ListingID == 0 { return nil, ErrListingUnavailable } + if req.ProfitAmountCent < 0 { + return nil, ErrInvalidProfit + } 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 { return nil, ErrInvalidAmount } + if req.ProfitAmountCent != nil && *req.ProfitAmountCent < 0 { + return nil, ErrInvalidProfit + } return s.repo.Complete(ctx, pickupID, req, adminID, meta) } diff --git a/backend/migrations/000023_admin_pickup_profit_snapshot.sql b/backend/migrations/000023_admin_pickup_profit_snapshot.sql new file mode 100644 index 0000000..c9418aa --- /dev/null +++ b/backend/migrations/000023_admin_pickup_profit_snapshot.sql @@ -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; diff --git a/backend/migrations/000024_admin_pickup_account_snapshot.sql b/backend/migrations/000024_admin_pickup_account_snapshot.sql new file mode 100644 index 0000000..22b4562 --- /dev/null +++ b/backend/migrations/000024_admin_pickup_account_snapshot.sql @@ -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; diff --git a/frontend/src/features/admin/api/adminFinance.ts b/frontend/src/features/admin/api/adminFinance.ts index 8330671..275c9bb 100644 --- a/frontend/src/features/admin/api/adminFinance.ts +++ b/frontend/src/features/admin/api/adminFinance.ts @@ -46,6 +46,7 @@ export interface FinanceDailyItem { export interface FinancePickupSummary { settled_amount_cent: number + profit_amount_cent: number completed_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 : [], pickup_summary: data.data?.pickup_summary ?? { settled_amount_cent: 0, + profit_amount_cent: 0, completed_count: 0, in_progress_count: 0, }, diff --git a/frontend/src/features/admin/api/adminPickup.ts b/frontend/src/features/admin/api/adminPickup.ts index 9ef6091..b8e6220 100644 --- a/frontend/src/features/admin/api/adminPickup.ts +++ b/frontend/src/features/admin/api/adminPickup.ts @@ -14,6 +14,13 @@ export interface AdminPickup { owner_phone: string admin_id: number 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 settle_amount_cent: number status: string remark: string @@ -32,16 +39,23 @@ export interface AvailableListing { login_platform: string owner_id: number owner_phone: string + listing_price_cent: number + owner_price_cent: number + website_profit_cent: number + seller_ratio: number + buyer_ratio: number } export interface AdminPickupCreateRequest { listing_id: number platform?: string + profit_amount_cent?: number remark?: string } export interface AdminPickupCompleteRequest { settle_amount_cent: number + profit_amount_cent?: number 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>(`/admin/pickups/${id}`) + return data.data +} + export async function fetchAvailableListings(keyword: string, page = 1, page_size = 20) { const { data } = await apiClient.get>>( '/admin/pickups/available-listings', diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue index ef71160..f2e59c7 100644 --- a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -145,6 +145,11 @@ function rowDiffClass(row: FinanceDailyItem) { {{ moneyCent(dashboard.pickup_summary.settled_amount_cent) }} {{ dashboard.pickup_summary.completed_count }} 笔已完成 +
+ 线下提号利润 + {{ moneyCent(dashboard.pickup_summary.profit_amount_cent) }} + 电商平台手工录入利润 +
提号中 {{ dashboard.pickup_summary.in_progress_count }} diff --git a/frontend/src/features/admin/views/AdminPickupDetailView.vue b/frontend/src/features/admin/views/AdminPickupDetailView.vue new file mode 100644 index 0000000..42bdd54 --- /dev/null +++ b/frontend/src/features/admin/views/AdminPickupDetailView.vue @@ -0,0 +1,531 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminPickupView.vue b/frontend/src/features/admin/views/AdminPickupView.vue index 01c06d9..1fb3f54 100644 --- a/frontend/src/features/admin/views/AdminPickupView.vue +++ b/frontend/src/features/admin/views/AdminPickupView.vue @@ -1,5 +1,5 @@