feat(dashboard): 增加极品专区与保险成交统计

- 仪表盘新增今日极品专区、保险8%、保险4%成交数量
- 展示近7日每日成交趋势和明细
- 按已支付订单口径统计,补充回归测试
This commit is contained in:
yml2213
2026-11-06 19:09:35 +08:00
parent ee08e343f4
commit 87d252bbee
6 changed files with 382 additions and 0 deletions
@@ -6,6 +6,7 @@ type DashboardDTO struct {
Metrics MetricsDTO `json:"metrics"`
Pending PendingDTO `json:"pending"`
ListingDaily ListingDailyOverviewDTO `json:"listing_daily"`
TradingDaily TradingDailyOverviewDTO `json:"trading_daily"`
ManualEntryToday []ManualEntryAgentStatsDTO `json:"manual_entry_today"`
RecentOrders []RecentOrderDTO `json:"recent_orders"`
RecentDisputes []RecentDisputeDTO `json:"recent_disputes"`
@@ -57,6 +58,25 @@ type ListingUploaderDayStatsDTO struct {
UploadCount int64 `json:"upload_count"`
}
// TradingDailyOverviewDTO 经营成交统计:今日指标 + 近 7 日趋势。
// 统计口径:已支付订单(status 不在 pending_payment/cancelled/closed),按创建日归属北京时间自然日。
type TradingDailyOverviewDTO struct {
Today TradingDayStatsDTO `json:"today"`
Trend []TradingDayStatsDTO `json:"trend"`
Days int `json:"days"`
Timezone string `json:"timezone"`
}
type TradingDayStatsDTO struct {
Date string `json:"date"` // YYYY-MM-DD(上海时区)
// PremiumOrderCount 极品专区成交订单数。
PremiumOrderCount int64 `json:"premium_order_count"`
// Insurance8OrderCount 选择保险 8% 的成交订单数。
Insurance8OrderCount int64 `json:"insurance_8_order_count"`
// Insurance4OrderCount 选择保险 4% 的成交订单数。
Insurance4OrderCount int64 `json:"insurance_4_order_count"`
}
// ManualEntryAgentStatsDTO 今日客服手动录单统计(按客服聚合)。
type ManualEntryAgentStatsDTO struct {
AdminID uint64 `json:"admin_id"`
@@ -14,8 +14,11 @@ import (
const (
listingDailyTrendDays = 7
tradingDailyTrendDays = 7
listingChannelWebsite = "站内发布"
listingChannelExternalUnknown = "未填写"
// 成交口径:排除待支付、取消、关闭,与推广归因的成交统计保持一致。
tradingOrderPaidCondition = "status NOT IN ('pending_payment', 'cancelled', 'closed')"
)
type Repository struct {
@@ -78,6 +81,10 @@ func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) {
if err != nil {
return nil, err
}
tradingDaily, err := r.tradingDailyOverview(ctx, now, tradingDailyTrendDays)
if err != nil {
return nil, err
}
manualEntryToday, err := r.manualEntryToday(ctx, now)
if err != nil {
return nil, err
@@ -94,6 +101,7 @@ func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) {
Metrics: metrics,
Pending: pending,
ListingDaily: listingDaily,
TradingDaily: tradingDaily,
ManualEntryToday: manualEntryToday,
RecentOrders: recentOrders,
RecentDisputes: recentDisputes,
@@ -353,6 +361,76 @@ func isTradeLeaveEvent(eventType, source string) bool {
}
}
// tradingDailyOverview 统计经营成交数据:极品专区成交、保险 8%/4% 成交。
// 订单在下单时已快照 is_premium / compensation_plan / insurance_fee_bps,
// 因此按订单统计不受商品后续修改影响。
func (r *Repository) tradingDailyOverview(ctx context.Context, now time.Time, days int) (TradingDailyOverviewDTO, error) {
if days <= 0 {
days = tradingDailyTrendDays
}
loc := timeutil.ShanghaiLocation()
now = now.In(loc)
end := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1)
start := end.AddDate(0, 0, -days)
type row struct {
CreatedAt time.Time
IsPremium bool
CompensationPlan string
InsuranceFeeBps int
}
rows := make([]row, 0)
// 读取区间内订单后在 Go 侧按上海时区归日,避免依赖 MySQL 会话时区。
if err := r.db.WithContext(ctx).Table("rental_orders").
Select("created_at, is_premium, compensation_plan, insurance_fee_bps").
Where(tradingOrderPaidCondition).
Where("created_at >= ? AND created_at < ?", start, end).
Scan(&rows).Error; err != nil {
return TradingDailyOverviewDTO{}, err
}
byDay := make(map[string]TradingDayStatsDTO, days)
for _, item := range rows {
key := item.CreatedAt.In(loc).Format("2006-01-02")
stats := byDay[key]
stats.Date = key
if item.IsPremium {
stats.PremiumOrderCount++
}
if item.CompensationPlan == "insurance" {
switch item.InsuranceFeeBps {
case 800:
stats.Insurance8OrderCount++
case 400:
stats.Insurance4OrderCount++
}
}
byDay[key] = stats
}
trend := make([]TradingDayStatsDTO, 0, days)
for i := days - 1; i >= 0; i-- {
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -i)
key := day.Format("2006-01-02")
item := TradingDayStatsDTO{Date: key}
if v, ok := byDay[key]; ok {
item = v
}
trend = append(trend, item)
}
today := TradingDayStatsDTO{Date: now.Format("2006-01-02")}
if len(trend) > 0 {
today = trend[len(trend)-1]
}
return TradingDailyOverviewDTO{
Today: today,
Trend: trend,
Days: days,
Timezone: "Asia/Shanghai",
}, nil
}
// manualEntryToday 统计今日(北京时间自然日)各客服的手动录单数量。
// 录单表是后来新增的功能表,若迁移尚未执行(表不存在)则降级为空统计,
// 避免连带整个仪表盘不可用。
@@ -106,3 +106,53 @@ func TestListingDailyOverviewGroupsExternalUploadsByUploader(t *testing.T) {
t.Fatalf("uploader trend = %#v, want 3 rows", overview.UploaderTrend)
}
}
func TestTradingDailyOverviewCountsPremiumAndInsurance(t *testing.T) {
db := database.NewTestDB()
if err := database.AutoMigrateIfMissing(db, &model.RentalOrder{}); err != nil {
t.Fatalf("AutoMigrate() error = %v", err)
}
loc := timeutil.ShanghaiLocation()
now := time.Date(2026, 7, 25, 12, 0, 0, 0, loc)
orders := []model.RentalOrder{
// 今日:极品 + 保险8%,成交
{OrderNo: "T1", ListingID: 1, AccountID: 1, OwnerID: 1, RenterID: 1, Status: "pending_handoff", IsPremium: true, CompensationPlan: "insurance", InsuranceFeeBps: 800, CreatedAt: now},
// 今日:极品 + 保险4%,成交
{OrderNo: "T2", ListingID: 2, AccountID: 2, OwnerID: 2, RenterID: 2, Status: "renting", IsPremium: true, CompensationPlan: "insurance", InsuranceFeeBps: 400, CreatedAt: now.Add(time.Hour)},
// 今日:非极品 + 保险4%,成交
{OrderNo: "T3", ListingID: 3, AccountID: 3, OwnerID: 3, RenterID: 3, Status: "completed", CompensationPlan: "insurance", InsuranceFeeBps: 400, CreatedAt: now.Add(2 * time.Hour)},
// 今日:待支付,不计入成交
{OrderNo: "T4", ListingID: 4, AccountID: 4, OwnerID: 4, RenterID: 4, Status: "pending_payment", IsPremium: true, CompensationPlan: "insurance", InsuranceFeeBps: 400, CreatedAt: now.Add(3 * time.Hour)},
// 今日:已取消,不计入成交
{OrderNo: "T5", ListingID: 5, AccountID: 5, OwnerID: 5, RenterID: 5, Status: "cancelled", IsPremium: true, CompensationPlan: "insurance", InsuranceFeeBps: 800, CreatedAt: now.Add(4 * time.Hour)},
// 昨日:极品 + 保险8%,成交
{OrderNo: "T6", ListingID: 6, AccountID: 6, OwnerID: 6, RenterID: 6, Status: "completed", IsPremium: true, CompensationPlan: "insurance", InsuranceFeeBps: 800, CreatedAt: now.AddDate(0, 0, -1)},
// 前日:普通赔付,不计入保险
{OrderNo: "T7", ListingID: 7, AccountID: 7, OwnerID: 7, RenterID: 7, Status: "completed", CompensationPlan: "normal", CreatedAt: now.AddDate(0, 0, -2)},
}
if err := db.Create(&orders).Error; err != nil {
t.Fatalf("create orders error = %v", err)
}
overview, err := NewRepository(db).tradingDailyOverview(t.Context(), now, 7)
if err != nil {
t.Fatalf("tradingDailyOverview() error = %v", err)
}
if overview.Days != 7 || len(overview.Trend) != 7 {
t.Fatalf("trend = %#v, want 7 days", overview.Trend)
}
if overview.Today.Date != "2026-07-25" {
t.Fatalf("today date = %q, want 2026-07-25", overview.Today.Date)
}
if overview.Today.PremiumOrderCount != 2 ||
overview.Today.Insurance8OrderCount != 1 ||
overview.Today.Insurance4OrderCount != 2 {
t.Fatalf("today stats = %#v, want premium=2 insurance8=1 insurance4=2", overview.Today)
}
yesterday := overview.Trend[len(overview.Trend)-2]
if yesterday.PremiumOrderCount != 1 || yesterday.Insurance8OrderCount != 1 || yesterday.Insurance4OrderCount != 0 {
t.Fatalf("yesterday stats = %#v, want premium=1 insurance8=1 insurance4=0", yesterday)
}
}