增加资金出款按操作人筛选

This commit is contained in:
yml2213
2026-09-06 14:03:40 +08:00
parent cd719fc355
commit 4b9e64e398
10 changed files with 453 additions and 1321 deletions
@@ -271,6 +271,13 @@ func (r *Repository) applyDisbursementFilters(db *gorm.DB, query DisbursementQue
db = db.Where(`(d.business_no LIKE ? OR d.payee_name LIKE ? OR d.payee_phone LIKE ?
OR d.uploader_name LIKE ? OR d.account_name LIKE ? OR d.account_no LIKE ?)`, like, like, like, like, like, like)
}
if query.Operator != "" {
like := "%" + query.Operator + "%"
db = db.Where("(d.operator_name LIKE ? OR CAST(d.operator_id AS CHAR) LIKE ?)", like, like)
}
if query.OperatorID > 0 {
db = db.Where("d.operator_id = ?", query.OperatorID)
}
if !query.StartDate.IsZero() && !query.EndDate.IsZero() {
if query.DateType == "paid" {
db = db.Where("d.paid_at >= ? AND d.paid_at <= ?", query.StartDate, query.EndDate)
@@ -203,4 +203,24 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
if paidWithdrawals.Total != 1 || paidWithdrawals.Items[0].WithdrawalID != 10 {
t.Fatalf("按打款日期查询结果 = %+v", paidWithdrawals.Items)
}
operatorFiltered, err := repo.Disbursements(t.Context(), DisbursementQuery{
Operator: "财务甲",
DateType: "created",
StartDate: at(1),
EndDate: time.Date(2026, 7, 10, 23, 59, 59, 0, loc),
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("按操作人查询出款失败: %v", err)
}
if operatorFiltered.Total != 7 || len(operatorFiltered.Items) != 7 {
t.Fatalf("按操作人查询结果 = %+v", operatorFiltered.Items)
}
for _, item := range operatorFiltered.Items {
if item.OperatorName != "财务甲" {
t.Fatalf("操作人筛选返回了其他操作人: %+v", item)
}
}
}
@@ -27,6 +27,8 @@ type DisbursementQuery struct {
DateType string
BusinessNo string
Keyword string
Operator string
OperatorID uint64
UserID uint64
StartDate time.Time
EndDate time.Time
@@ -222,9 +222,13 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
DateType: c.DefaultQuery("date_type", "created"),
BusinessNo: strings.TrimSpace(c.Query("business_no")),
Keyword: strings.TrimSpace(c.Query("keyword")),
Operator: strings.TrimSpace(c.Query("operator")),
StartDate: start,
EndDate: end,
}
if query.Operator == "" {
query.Operator = strings.TrimSpace(c.Query("operator_name"))
}
if query.SourceType != "" && query.SourceType != "platform_managed" && query.SourceType != "pickup" && query.SourceType != "pickup_adjustment" && query.SourceType != "withdrawal" && query.SourceType != "manual_offline" {
response.BadRequest(c, "出款类型不正确")
return query, false
@@ -237,6 +241,14 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
response.BadRequest(c, "日期类型不正确")
return query, false
}
if raw := c.Query("operator_id"); raw != "" {
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil || value == 0 {
response.BadRequest(c, "操作人 ID 不正确")
return query, false
}
query.OperatorID = value
}
if raw := c.Query("user_id"); raw != "" {
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil || value == 0 {
+15 -730
View File
@@ -1,733 +1,18 @@
diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go
index f868ff7..17da89c 100644
--- a/backend/internal/modules/adminfinance/dashboard.go
+++ b/backend/internal/modules/adminfinance/dashboard.go
@@ -14,14 +14,12 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash
// 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL,
// 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。
var (
- dailyItems []FinanceDailyDTO
- pickup *PickupSummaryDTO
- mohong *MohongSummaryDTO
- disbursement *DisbursementSummaryDTO
- operatingExpense *OperatingExpenseSummaryDTO
+ daily dashboardDailyResult
+ pickup *PickupSummaryDTO
+ disbursement *DisbursementSummaryDTO
)
var wg sync.WaitGroup
- errCh := make(chan error, 5)
+ errCh := make(chan error, 3)
run := func(fn func() error) {
wg.Add(1)
go func() {
@@ -33,7 +31,7 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash
diff --git a/tmp/hfb-finance-disbursement-filter/disbursement_list.go.original b/docs/MODIFIED_FILE
index c42e5b3..eccf568 100644
--- a/tmp/hfb-finance-disbursement-filter/disbursement_list.go.original
+++ b/docs/MODIFIED_FILE
@@ -271,6 +271,13 @@ func (r *Repository) applyDisbursementFilters(db *gorm.DB, query DisbursementQue
db = db.Where(`(d.business_no LIKE ? OR d.payee_name LIKE ? OR d.payee_phone LIKE ?
OR d.uploader_name LIKE ? OR d.account_name LIKE ? OR d.account_no LIKE ?)`, like, like, like, like, like, like)
}
run(func() error {
var err error
- dailyItems, err = r.dailyItems(ctx, query)
+ daily, err = r.dailyItems(ctx, query)
return err
})
run(func() error {
@@ -41,21 +39,11 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash
pickup, err = r.pickupSummary(ctx, query)
return err
})
- run(func() error {
- var err error
- mohong, err = r.mohongSummary(ctx, query)
- return err
- })
run(func() error {
var err error
disbursement, err = r.disbursementSummary(ctx, query)
return err
})
- run(func() error {
- var err error
- operatingExpense, err = r.operatingExpenseSummary(ctx, query)
- return err
- })
wg.Wait()
close(errCh)
for err := range errCh {
@@ -64,17 +52,14 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash
}
}
- summary, err := r.summary(ctx, query, pickup)
- if err != nil {
- return nil, err
- }
+ summary := summaryWithPickup(daily.Summary, pickup)
result := &DashboardDTO{
- Summary: *summary,
- DailyItems: dailyItems,
+ Summary: summary,
+ DailyItems: daily.Items,
PickupSummary: *pickup,
- MohongSummary: *mohong,
+ MohongSummary: daily.Mohong,
DisbursementSummary: *disbursement,
- OperatingExpenseSummary: *operatingExpense,
+ OperatingExpenseSummary: daily.OperatingExpense,
GeneratedAt: timeutil.ShanghaiNow(),
}
r.storeDashboardCache(ctx, query, result)
@@ -179,157 +164,57 @@ func (r *Repository) mohongSummary(ctx context.Context, query DashboardQuery) (*
}, nil
}
-func (r *Repository) summary(ctx context.Context, query DashboardQuery, pickup *PickupSummaryDTO) (*FinanceSummaryDTO, error) {
- db := r.db.WithContext(ctx)
- var payment paymentSummaryRow
- originalPayments := paymentOriginalAmountForRefundsInRangeSubquery(db, query)
- if err := db.Table("payment_orders AS po").
- Select(`COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent,
- COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent,
- COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN po.amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent,
- COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_count,
- COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS full_refund_count,
- COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent < COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS partial_refund_count,
- COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`,
- payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()).
- Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", originalPayments).
- Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate).
- Scan(&payment).Error; err != nil {
- return nil, err
- }
-
- var settlement settlementSummaryRow
- if err := db.Table("rental_orders AS ro").
- Select(`COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent,
- COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent,
- COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent,
- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
- THEN COALESCE(ro.offline_settlement_amount_cent, 0)
- ELSE COALESCE(w.owner_wallet_income_amount_cent, 0)
- END), 0) AS owner_effective_settlement_amount_cent,
- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
- THEN COALESCE(ro.offline_settlement_amount_cent, 0)
- ELSE 0
- END), 0) AS offline_settlement_amount_cent,
- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
- AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending'
- THEN COALESCE(ro.offline_settlement_amount_cent, 0)
- ELSE 0
- END), 0) AS offline_settlement_pending_amount_cent,
- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
- AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending'
- AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0
- THEN 1
- ELSE 0
- END), 0) AS offline_settlement_pending_count,
- COALESCE(SUM(CASE WHEN COALESCE(p.failed_refund_amount_cent, 0) > 0
- OR COALESCE(p.refunding_amount_cent, 0) > 0
- OR ro.refund_status = 'refunding'
- OR ABS(COALESCE(oc.owner_income_amount_cent, 0) - CASE WHEN ro.settlement_mode = 'platform_managed'
- THEN COALESCE(ro.offline_settlement_amount_cent, 0)
- ELSE COALESCE(w.owner_wallet_income_amount_cent, 0)
- END) >= ?
- THEN 1
- ELSE 0
- END), 0) AS financial_exception_count,
- COUNT(ro.id) AS settled_order_count`, settlementDiffThresholdCent).
- Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'").
- Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)).
- Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentForSettledOrdersSubquery(db, query)).
- Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate).
- Scan(&settlement).Error; err != nil {
- return nil, err
- }
-
- // 预计收入:尚未结算订单的下单预估平台手续费之和,按下单时间落在查询区间内统计。
- var estimated estimatedIncomeRow
- if err := db.Table("rental_orders").
- Select(`COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent,
- COUNT(id) AS pending_settle_order_count`).
- Where("settlement_status NOT IN ?", settledSettlementStatuses()).
- Where("status NOT IN ?", nonBillableOrderStatuses()).
- Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate).
- Scan(&estimated).Error; err != nil {
- return nil, err
- }
-
- pickupProfitAmountCent := int64(0)
- pickupCompletedCount := int64(0)
- if pickup != nil {
- pickupProfitAmountCent = pickup.ProfitAmountCent
- pickupCompletedCount = pickup.CompletedCount
- }
- return &FinanceSummaryDTO{
- TotalFlowAmountCent: payment.TotalFlowAmountCent,
- TotalRefundAmountCent: payment.TotalRefundAmountCent,
- PendingRefundAmountCent: payment.PendingRefundAmountCent,
- ChannelNetAmountCent: payment.TotalFlowAmountCent - payment.TotalRefundAmountCent,
- PlatformIncomeAmountCent: settlement.PlatformIncomeAmountCent + pickupProfitAmountCent,
- NormalOrderProfitAmountCent: settlement.PlatformIncomeAmountCent,
- PickupProfitAmountCent: pickupProfitAmountCent,
- PickupCompletedCount: pickupCompletedCount,
- OwnerShouldIncomeAmountCent: settlement.OwnerShouldIncomeAmountCent,
- OwnerWalletIncomeAmountCent: settlement.OwnerWalletIncomeAmountCent,
- OwnerEffectiveSettlementAmountCent: settlement.OwnerEffectiveSettlementAmountCent,
- OfflineSettlementAmountCent: settlement.OfflineSettlementAmountCent,
- OfflineSettlementPendingAmountCent: settlement.OfflineSettlementPendingAmountCent + pickup.PendingAmountCent,
- SettlementDiffAmountCent: settlement.OwnerShouldIncomeAmountCent - settlement.OwnerEffectiveSettlementAmountCent,
- EstimatedIncomeAmountCent: estimated.EstimatedIncomeAmountCent,
- SuccessfulPayCount: payment.SuccessfulPayCount,
- SuccessfulRefundCount: payment.FullRefundCount + payment.PartialRefundCount,
- FullRefundCount: payment.FullRefundCount,
- PartialRefundCount: payment.PartialRefundCount,
- PendingRefundCount: payment.PendingRefundCount,
- SettledOrderCount: settlement.SettledOrderCount,
- PendingSettleOrderCount: estimated.PendingSettleOrderCount,
- OfflineSettlementPendingCount: settlement.OfflineSettlementPendingCount + pickup.PendingCount,
- FinancialExceptionCount: settlement.FinancialExceptionCount,
- }, nil
+type dashboardDailyResult struct {
+ Items []FinanceDailyDTO
+ Summary FinanceSummaryDTO
+ Mohong MohongSummaryDTO
+ OperatingExpense OperatingExpenseSummaryDTO
}
-func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]FinanceDailyDTO, error) {
+func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) (dashboardDailyResult, error) {
db := r.db.WithContext(ctx)
- payments, err := r.dailyPayments(ctx, query)
- if err != nil {
- return nil, err
+ var (
+ payments []dailyPaymentRow
+ settlements []dailySettlementRow
+ estimates []dailyEstimatedRow
+ )
+ var wg sync.WaitGroup
+ errCh := make(chan error, 3)
+ run := func(fn func() error) {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ if err := fn(); err != nil {
+ errCh <- err
+ }
+ }()
}
- paidOrders, err := r.dailyPaidOrders(ctx, query)
- if err != nil {
- return nil, err
+ run(func() error {
+ var err error
+ payments, err = r.dailyPayments(ctx, query)
+ return err
+ })
+ run(func() error {
+ var err error
+ settlements, err = r.dailySettlements(ctx, query)
+ return err
+ })
+ run(func() error {
+ var err error
+ estimates, err = r.dailyEstimates(ctx, query)
+ return err
+ })
+ wg.Wait()
+ close(errCh)
+ for err := range errCh {
+ if err != nil {
+ return dashboardDailyResult{}, err
+ }
}
- settlements := make([]dailySettlementRow, 0)
- if err := db.Table("rental_orders AS ro").
- Select(`DATE(ro.settled_at) AS date,
- COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent,
- COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent,
- COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent,
- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
- THEN COALESCE(ro.offline_settlement_amount_cent, 0)
- ELSE COALESCE(w.owner_wallet_income_amount_cent, 0)
- END), 0) AS owner_effective_settlement_amount_cent,
- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
- THEN COALESCE(ro.offline_settlement_amount_cent, 0)
- ELSE 0
- END), 0) AS offline_settlement_amount_cent,
- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
- AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending'
- THEN COALESCE(ro.offline_settlement_amount_cent, 0)
- ELSE 0
- END), 0) AS offline_settlement_pending_amount_cent,
- COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
- AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending'
- AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0
- THEN 1
- ELSE 0
- END), 0) AS offline_settlement_pending_count,
- COUNT(ro.id) AS settled_order_count`).
- Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'").
- Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)).
- Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate).
- Group("DATE(ro.settled_at)").
- Scan(&settlements).Error; err != nil {
- return nil, err
+ paidOrders, err := r.dailyPaidOrders(ctx, query)
+ if err != nil {
+ return dashboardDailyResult{}, err
}
pickupProfits := make([]dailyPickupProfitRow, 0)
@@ -341,7 +226,7 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
Where("completed_at >= ? AND completed_at <= ?", query.StartDate, query.EndDate).
Group("DATE(completed_at)").
Scan(&pickupProfits).Error; err != nil {
- return nil, err
+ return dashboardDailyResult{}, err
}
pickupAdjustments := make([]dailyPickupAdjustmentRow, 0)
if err := db.Table("admin_pickup_financial_adjustments AS fa").
@@ -351,35 +236,23 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
Where("fa.created_at >= ? AND fa.created_at <= ?", query.StartDate, query.EndDate).
Group("DATE(fa.created_at)").
Scan(&pickupAdjustments).Error; err != nil {
- return nil, err
+ return dashboardDailyResult{}, err
}
// 取消提号按取消日统计,与「已付款订单数」中的提号创建口径区分开。
pickupCancelled, err := r.dailyPickupCancelled(ctx, query)
if err != nil {
- return nil, err
+ return dashboardDailyResult{}, err
}
// 每日预计收入:尚未结算订单的下单预估平台手续费,按下单日期归集,口径与 summary 一致。
- estimates := make([]dailyEstimatedRow, 0)
- if err := db.Table("rental_orders").
- Select(`DATE(created_at) AS date,
- COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent`).
- Where("settlement_status NOT IN ?", settledSettlementStatuses()).
- Where("status NOT IN ?", nonBillableOrderStatuses()).
- Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate).
- Group("DATE(created_at)").
- Scan(&estimates).Error; err != nil {
- return nil, err
- }
-
disbursements, err := r.dailyDisbursements(ctx, query)
if err != nil {
- return nil, err
+ return dashboardDailyResult{}, err
}
operatingExpenses, err := r.dailyOperatingExpenses(ctx, query)
if err != nil {
- return nil, err
+ return dashboardDailyResult{}, err
}
itemsByDate := make(map[string]FinanceDailyDTO)
@@ -483,7 +356,142 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
items = append(items, itemsByDate[day.Format("2006-01-02")])
}
- return items, nil
+ return dashboardDailyResult{
+ Items: items,
+ Summary: summaryFromDailyRows(payments, settlements, estimates),
+ Mohong: mohongSummaryFromDailyRows(payments),
+ OperatingExpense: operatingExpenseSummaryFromDailyRows(operatingExpenses),
+ }, nil
+}
+
+func (r *Repository) dailyEstimates(ctx context.Context, query DashboardQuery) ([]dailyEstimatedRow, error) {
+ rows := make([]dailyEstimatedRow, 0)
+ err := r.db.WithContext(ctx).Table("rental_orders").
+ Select(`DATE(created_at) AS date,
+ COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent,
+ COUNT(id) AS pending_settle_order_count`).
+ Where("settlement_status NOT IN ?", settledSettlementStatuses()).
+ Where("status NOT IN ?", nonBillableOrderStatuses()).
+ Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate).
+ Group("DATE(created_at)").
+ Scan(&rows).Error
+ return rows, err
+}
+
+func (r *Repository) dailySettlements(ctx context.Context, query DashboardQuery) ([]dailySettlementRow, error) {
+ db := r.db.WithContext(ctx)
+ rows := make([]dailySettlementRow, 0)
+ err := db.Table("rental_orders AS ro").
+ Select(`DATE(ro.settled_at) AS date,
+ COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent,
+ COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent,
+ COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent,
+ COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
+ THEN COALESCE(ro.offline_settlement_amount_cent, 0)
+ ELSE COALESCE(w.owner_wallet_income_amount_cent, 0)
+ END), 0) AS owner_effective_settlement_amount_cent,
+ COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
+ THEN COALESCE(ro.offline_settlement_amount_cent, 0)
+ ELSE 0
+ END), 0) AS offline_settlement_amount_cent,
+ COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
+ AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending'
+ THEN COALESCE(ro.offline_settlement_amount_cent, 0)
+ ELSE 0
+ END), 0) AS offline_settlement_pending_amount_cent,
+ COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
+ AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending'
+ AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0
+ THEN 1
+ ELSE 0
+ END), 0) AS offline_settlement_pending_count,
+ COALESCE(SUM(CASE WHEN ro.refund_status = 'refunding'
+ OR ABS(COALESCE(oc.owner_income_amount_cent, 0) - CASE WHEN ro.settlement_mode = 'platform_managed'
+ THEN COALESCE(ro.offline_settlement_amount_cent, 0)
+ ELSE COALESCE(w.owner_wallet_income_amount_cent, 0)
+ END) >= ?
+ OR EXISTS (
+ SELECT 1 FROM payment_orders AS exception_payment
+ WHERE exception_payment.order_id = ro.id
+ AND exception_payment.biz_type IN ?
+ AND exception_payment.status IN ?
+ AND exception_payment.amount_cent > 0
+ )
+ THEN 1
+ ELSE 0
+ END), 0) AS financial_exception_count,
+ COUNT(ro.id) AS settled_order_count`, settlementDiffThresholdCent, refundBizTypes(), []string{"refunding", "failed"}).
+ Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'").
+ Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)).
+ Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate).
+ Group("DATE(ro.settled_at)").
+ Scan(&rows).Error
+ return rows, err
+}
+
+func summaryFromDailyRows(payments []dailyPaymentRow, settlements []dailySettlementRow, estimates []dailyEstimatedRow) FinanceSummaryDTO {
+ var summary FinanceSummaryDTO
+ for _, row := range payments {
+ summary.TotalFlowAmountCent += row.TotalFlowAmountCent
+ summary.TotalRefundAmountCent += row.TotalRefundAmountCent
+ summary.PendingRefundAmountCent += row.PendingRefundAmountCent
+ summary.SuccessfulPayCount += row.SuccessfulPayCount
+ summary.FullRefundCount += row.FullRefundCount
+ summary.PartialRefundCount += row.PartialRefundCount
+ summary.PendingRefundCount += row.PendingRefundCount
+ if query.Operator != "" {
+ like := "%" + query.Operator + "%"
+ db = db.Where("(d.operator_name LIKE ? OR CAST(d.operator_id AS CHAR) LIKE ?)", like, like)
+ }
+ for _, row := range settlements {
+ summary.NormalOrderProfitAmountCent += row.PlatformIncomeAmountCent
+ summary.OwnerShouldIncomeAmountCent += row.OwnerShouldIncomeAmountCent
+ summary.OwnerWalletIncomeAmountCent += row.OwnerWalletIncomeAmountCent
+ summary.OwnerEffectiveSettlementAmountCent += row.OwnerEffectiveSettlementAmountCent
+ summary.OfflineSettlementAmountCent += row.OfflineSettlementAmountCent
+ summary.OfflineSettlementPendingAmountCent += row.OfflineSettlementPendingAmountCent
+ summary.OfflineSettlementPendingCount += row.OfflineSettlementPendingCount
+ summary.FinancialExceptionCount += row.FinancialExceptionCount
+ summary.SettledOrderCount += row.SettledOrderCount
+ if query.OperatorID > 0 {
+ db = db.Where("d.operator_id = ?", query.OperatorID)
+ }
+ for _, row := range estimates {
+ summary.EstimatedIncomeAmountCent += row.EstimatedIncomeAmountCent
+ summary.PendingSettleOrderCount += row.PendingSettleOrderCount
+ }
+ summary.ChannelNetAmountCent = summary.TotalFlowAmountCent - summary.TotalRefundAmountCent
+ summary.SuccessfulRefundCount = summary.FullRefundCount + summary.PartialRefundCount
+ summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent
+ summary.SettlementDiffAmountCent = summary.OwnerShouldIncomeAmountCent - summary.OwnerEffectiveSettlementAmountCent
+ return summary
+}
+
+func summaryWithPickup(summary FinanceSummaryDTO, pickup *PickupSummaryDTO) FinanceSummaryDTO {
+ if pickup == nil {
+ return summary
+ }
+ summary.PickupProfitAmountCent = pickup.ProfitAmountCent
+ summary.PickupCompletedCount = pickup.CompletedCount
+ summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent + pickup.ProfitAmountCent
+ summary.OfflineSettlementPendingAmountCent += pickup.PendingAmountCent
+ summary.OfflineSettlementPendingCount += pickup.PendingCount
+ return summary
+}
+
+func mohongSummaryFromDailyRows(rows []dailyPaymentRow) MohongSummaryDTO {
+ var summary MohongSummaryDTO
+ for _, row := range rows {
+ summary.FlowAmountCent += row.MohongFlowAmountCent
+ summary.RefundAmountCent += row.MohongRefundAmountCent
+ summary.PaidOrderCount += row.MohongPaidOrderCount
+ }
+ summary.NetAmountCent = summary.FlowAmountCent - summary.RefundAmountCent
+ return summary
+}
+
+func operatingExpenseSummaryFromDailyRows(rows []dailyOperatingExpenseRow) OperatingExpenseSummaryDTO {
+ var summary OperatingExpenseSummaryDTO
+ for _, row := range rows {
+ summary.AmountCent += row.AmountCent
+ summary.Count += row.Count
+ }
+ return summary
}
// dailyPayments 按支付单创建日统计收付款流水与退款笔数,
@@ -500,7 +508,10 @@ func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS full_refund_count,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent < COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS partial_refund_count,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count,
- COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS normal_full_refund_count`,
+ COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS normal_full_refund_count,
+ COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS mohong_flow_amount_cent,
+ COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_refund' AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS mohong_refund_amount_cent,
+ COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS mohong_paid_order_count`,
payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()).
Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", paymentOriginalAmountForRefundsInRangeSubquery(db, query)).
Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate).
@@ -541,33 +552,6 @@ func (r *Repository) dailyPaidOrders(ctx context.Context, query DashboardQuery)
return items, err
}
-type paymentSummaryRow struct {
- TotalFlowAmountCent int64
- TotalRefundAmountCent int64
- PendingRefundAmountCent int64
- SuccessfulPayCount int64
- FullRefundCount int64
- PartialRefundCount int64
- PendingRefundCount int64
-}
-
-type settlementSummaryRow struct {
- PlatformIncomeAmountCent int64
- OwnerShouldIncomeAmountCent int64
- OwnerWalletIncomeAmountCent int64
- OwnerEffectiveSettlementAmountCent int64
- OfflineSettlementAmountCent int64
- OfflineSettlementPendingAmountCent int64
- OfflineSettlementPendingCount int64
- FinancialExceptionCount int64
- SettledOrderCount int64
-}
-
-type estimatedIncomeRow struct {
- EstimatedIncomeAmountCent int64
- PendingSettleOrderCount int64
-}
-
type dailyPaymentRow struct {
Date string
TotalFlowAmountCent int64
@@ -578,6 +562,9 @@ type dailyPaymentRow struct {
PartialRefundCount int64
PendingRefundCount int64
NormalFullRefundCount int64
+ MohongFlowAmountCent int64
+ MohongRefundAmountCent int64
+ MohongPaidOrderCount int64
}
type dailyPickupCancelledRow struct {
@@ -599,6 +586,7 @@ type dailySettlementRow struct {
OfflineSettlementAmountCent int64
OfflineSettlementPendingAmountCent int64
OfflineSettlementPendingCount int64
+ FinancialExceptionCount int64
SettledOrderCount int64
}
@@ -617,4 +605,5 @@ type dailyPickupAdjustmentRow struct {
type dailyEstimatedRow struct {
Date string
EstimatedIncomeAmountCent int64
+ PendingSettleOrderCount int64
}
diff --git a/backend/internal/modules/adminfinance/dashboard_test.go b/backend/internal/modules/adminfinance/dashboard_test.go
index a0821c2..1ce17f2 100644
--- a/backend/internal/modules/adminfinance/dashboard_test.go
+++ b/backend/internal/modules/adminfinance/dashboard_test.go
@@ -321,10 +321,11 @@ func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) {
if err != nil {
t.Fatalf("读取提号汇总失败: %v", err)
}
- summary, err := repo.summary(t.Context(), query, pickup)
+ settlements, err := repo.dailySettlements(t.Context(), query)
if err != nil {
- t.Fatalf("读取财务汇总失败: %v", err)
+ t.Fatalf("读取每日结算汇总失败: %v", err)
}
+ summary := summaryWithPickup(summaryFromDailyRows(nil, settlements, nil), pickup)
if summary.SettledOrderCount != 2 || summary.NormalOrderProfitAmountCent != 500 {
t.Fatalf("普通订单数量或利润错误: %+v", summary)
}
@@ -332,3 +333,39 @@ func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) {
t.Fatalf("财务异常数 = %d, want 1", summary.FinancialExceptionCount)
}
}
+
+func TestSummaryFromDailyRowsPreservesDashboardTotals(t *testing.T) {
+ paymentRows := []dailyPaymentRow{
+ {TotalFlowAmountCent: 1000, TotalRefundAmountCent: 100, PendingRefundAmountCent: 30, SuccessfulPayCount: 3, FullRefundCount: 1},
+ {TotalFlowAmountCent: 2500, TotalRefundAmountCent: 400, PendingRefundAmountCent: 70, SuccessfulPayCount: 5, PartialRefundCount: 2, PendingRefundCount: 1},
+ }
+ settlementRows := []dailySettlementRow{
+ {PlatformIncomeAmountCent: 300, OwnerShouldIncomeAmountCent: 900, OwnerWalletIncomeAmountCent: 850, OwnerEffectiveSettlementAmountCent: 850, OfflineSettlementAmountCent: 100, OfflineSettlementPendingAmountCent: 20, OfflineSettlementPendingCount: 1, FinancialExceptionCount: 2, SettledOrderCount: 3},
+ {PlatformIncomeAmountCent: 200, OwnerShouldIncomeAmountCent: 600, OwnerWalletIncomeAmountCent: 500, OwnerEffectiveSettlementAmountCent: 550, OfflineSettlementAmountCent: 200, OfflineSettlementPendingAmountCent: 40, OfflineSettlementPendingCount: 2, FinancialExceptionCount: 1, SettledOrderCount: 2},
+ }
+ estimateRows := []dailyEstimatedRow{
+ {EstimatedIncomeAmountCent: 120, PendingSettleOrderCount: 4},
+ {EstimatedIncomeAmountCent: 80, PendingSettleOrderCount: 3},
+ }
+ pickup := &PickupSummaryDTO{ProfitAmountCent: 90, CompletedCount: 2, PendingAmountCent: 60, PendingCount: 3}
+
+ summary := summaryWithPickup(summaryFromDailyRows(paymentRows, settlementRows, estimateRows), pickup)
+ if summary.TotalFlowAmountCent != 3500 || summary.TotalRefundAmountCent != 500 || summary.ChannelNetAmountCent != 3000 {
+ t.Fatalf("支付汇总错误: %+v", summary)
+ }
+ if summary.PendingRefundAmountCent != 100 || summary.SuccessfulPayCount != 8 || summary.SuccessfulRefundCount != 3 || summary.FullRefundCount != 1 || summary.PartialRefundCount != 2 || summary.PendingRefundCount != 1 {
+ t.Fatalf("支付笔数汇总错误: %+v", summary)
+ }
+ if summary.NormalOrderProfitAmountCent != 500 || summary.PickupProfitAmountCent != 90 || summary.PlatformIncomeAmountCent != 590 || summary.PickupCompletedCount != 2 {
+ t.Fatalf("平台利润汇总错误: %+v", summary)
+ }
+ if summary.OwnerShouldIncomeAmountCent != 1500 || summary.OwnerWalletIncomeAmountCent != 1350 || summary.OwnerEffectiveSettlementAmountCent != 1400 || summary.SettlementDiffAmountCent != 100 {
+ t.Fatalf("货主结算汇总错误: %+v", summary)
+ }
+ if summary.OfflineSettlementAmountCent != 300 || summary.OfflineSettlementPendingAmountCent != 120 || summary.OfflineSettlementPendingCount != 6 {
+ t.Fatalf("线下结算汇总错误: %+v", summary)
+ }
+ if summary.EstimatedIncomeAmountCent != 200 || summary.PendingSettleOrderCount != 7 || summary.SettledOrderCount != 5 || summary.FinancialExceptionCount != 3 {
+ t.Fatalf("订单状态汇总错误: %+v", summary)
+ }
+}
diff --git a/backend/internal/modules/adminfinance/detail.go b/backend/internal/modules/adminfinance/detail.go
index f4d40ee..ce4feca 100644
--- a/backend/internal/modules/adminfinance/detail.go
+++ b/backend/internal/modules/adminfinance/detail.go
@@ -19,11 +19,23 @@ func (r *Repository) Details(ctx context.Context, query DetailQuery) (*Paginated
return &PaginatedResult{Items: []FinanceDetailDTO{}, Total: 0, Page: query.Page, PageSize: query.PageSize}, nil
}
- // 明细行查询保留完整 JOIN,仅对当前分页命中的订单做金额聚合。
- db := r.financeDetailBaseQuery(ctx, query)
offset := (query.Page - 1) * query.PageSize
+ orderIDs := make([]uint64, 0, query.PageSize)
+ if err := r.applyDetailFilters(r.db.WithContext(ctx).Table("rental_orders AS ro"), query).
+ Order("ro.id DESC").
+ Offset(offset).
+ Limit(query.PageSize).
+ Pluck("ro.id", &orderIDs).Error; err != nil {
+ return nil, err
+ }
+ if len(orderIDs) == 0 {
+ return &PaginatedResult{Items: []FinanceDetailDTO{}, Total: total, Page: query.Page, PageSize: query.PageSize}, nil
+ }
+
+ // 先取当前页 ID,再只为这些订单聚合结账、支付和钱包详情。
+ db := r.financeDetailBaseQuery(ctx, query, orderIDs).Where("ro.id IN ?", orderIDs)
rows := make([]financeDetailRow, 0, query.PageSize)
- if err := db.Order("ro.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error; err != nil {
+ if err := db.Order("ro.id DESC").Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]FinanceDetailDTO, 0, len(rows))
@@ -67,7 +79,7 @@ func (r *Repository) applyDetailFilters(db *gorm.DB, query DetailQuery) *gorm.DB
return db
}
-func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQuery) *gorm.DB {
+func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQuery, orderIDs []uint64) *gorm.DB {
db := r.db.WithContext(ctx).Table("rental_orders AS ro").
Select(`ro.id AS order_id, ro.order_no, ro.status AS order_status, ro.settlement_status, ro.refund_status,
ro.handoff_mode, ro.settlement_mode,
@@ -113,9 +125,9 @@ func (r *Repository) financeDetailBaseQuery(ctx context.Context, query DetailQue
ro.created_at, ro.settled_at, ro.offline_settled_at`, settlementDiffThresholdCent).
Joins("LEFT JOIN users AS ru ON ru.id = ro.renter_id").
Joins("LEFT JOIN users AS ou ON ou.id = ro.owner_id").
- Joins("LEFT JOIN (?) AS oc ON oc.order_id = ro.id", acceptedCheckoutSubquery(r.db.WithContext(ctx))).
- Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentSubquery(r.db.WithContext(ctx))).
- Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(r.db.WithContext(ctx)))
+ Joins("LEFT JOIN (?) AS oc ON oc.order_id = ro.id", acceptedCheckoutForOrdersSubquery(r.db.WithContext(ctx), orderIDs)).
+ Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentForOrdersSubquery(r.db.WithContext(ctx), orderIDs)).
+ Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForOrdersSubquery(r.db.WithContext(ctx), orderIDs))
return r.applyDetailFilters(db, query)
}
diff --git a/backend/internal/modules/adminfinance/subquery.go b/backend/internal/modules/adminfinance/subquery.go
index 18be7d9..6759d09 100644
--- a/backend/internal/modules/adminfinance/subquery.go
+++ b/backend/internal/modules/adminfinance/subquery.go
@@ -4,14 +4,16 @@ import (
"gorm.io/gorm"
)
-func acceptedCheckoutSubquery(db *gorm.DB) *gorm.DB {
- latest := db.Table("order_checkouts").Select("MAX(id) AS id").Where("status = ?", "accepted").Group("order_id")
+func acceptedCheckoutForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB {
+ latest := db.Table("order_checkouts").Select("MAX(id) AS id").
+ Where("status = ? AND order_id IN ?", "accepted", orderIDs).
+ Group("order_id")
return db.Table("order_checkouts AS oc").
Select("oc.*").
Joins("JOIN (?) AS latest ON latest.id = oc.id", latest)
}
-func orderPaymentSubquery(db *gorm.DB) *gorm.DB {
+func orderPaymentForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB {
return db.Table("payment_orders").
Select(`order_id,
COALESCE(SUM(CASE WHEN biz_type = 'order_pay' AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent,
@@ -19,13 +21,14 @@ func orderPaymentSubquery(db *gorm.DB) *gorm.DB {
COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN amount_cent ELSE 0 END), 0) AS refunding_amount_cent,
COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'failed' THEN amount_cent ELSE 0 END), 0) AS failed_refund_amount_cent`,
refundBizTypes(), refundBizTypes(), refundBizTypes()).
+ Where("order_id IN ?", orderIDs).
Group("order_id")
}
-func ownerWalletIncomeSubquery(db *gorm.DB) *gorm.DB {
+func ownerWalletIncomeForOrdersSubquery(db *gorm.DB, orderIDs []uint64) *gorm.DB {
return db.Table("wallet_ledger").
Select("order_id, COALESCE(SUM(amount_cent), 0) AS owner_wallet_income_amount_cent").
- Where("direction = ? AND biz_type IN ? AND order_id IS NOT NULL", "in", []string{"owner_income", "deposit_compensation", "arbitration_owner_income"}).
+ Where("direction = ? AND biz_type IN ? AND order_id IN ?", "in", []string{"owner_income", "deposit_compensation", "arbitration_owner_income"}, orderIDs).
Group("order_id")
}
@@ -44,18 +47,6 @@ func ownerWalletIncomeForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery
Group("scoped_order.id")
}
-// orderPaymentForSettledOrdersSubquery 只聚合当前结算区间订单的退款状态,
-// 供仪表盘异常数判断使用,避免 financeDetailBaseQuery 在首页扫描所有历史付款单。
-func orderPaymentForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB {
- return db.Table("rental_orders AS scoped_order").
- Select(`scoped_order.id AS order_id,
- COALESCE(SUM(CASE WHEN payment.biz_type IN ? AND payment.status = 'refunding' THEN payment.amount_cent ELSE 0 END), 0) AS refunding_amount_cent,
- COALESCE(SUM(CASE WHEN payment.biz_type IN ? AND payment.status = 'failed' THEN payment.amount_cent ELSE 0 END), 0) AS failed_refund_amount_cent`, refundBizTypes(), refundBizTypes()).
- Joins("LEFT JOIN payment_orders AS payment ON payment.order_id = scoped_order.id AND payment.biz_type IN ?", refundBizTypes()).
- Where("scoped_order.settled_at >= ? AND scoped_order.settled_at <= ?", query.StartDate, query.EndDate).
- Group("scoped_order.id")
-}
-
// paymentOriginalAmountForRefundsInRangeSubquery 仅为当前区间内的退款单回查原支付金额。
// 退款统计原先会对全部历史支付单按 order_id 聚合;数据增长后这部分即使只看一天也会很慢。
func paymentOriginalAmountForRefundsInRangeSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB {
diff --git a/backend/migrations/000063_finance_pending_disbursement_index.sql b/backend/migrations/000063_finance_pending_disbursement_index.sql
new file mode 100644
index 0000000..3d6190f
--- /dev/null
+++ b/backend/migrations/000063_finance_pending_disbursement_index.sql
@@ -0,0 +1,13 @@
+-- +goose Up
+-- 财务仪表盘的待线下出款金额不受日期限制,需要持续读取全部待出款订单。
+-- 覆盖查询条件与汇总金额,避免通过低区分度状态索引回表扫描。
+ALTER TABLE rental_orders
+ ADD KEY idx_rental_orders_offline_pending_amount (
+ settlement_mode,
+ offline_settlement_status,
+ offline_settlement_amount_cent
+ );
+
+-- +goose Down
+ALTER TABLE rental_orders
+ DROP KEY idx_rental_orders_offline_pending_amount;
if !query.StartDate.IsZero() && !query.EndDate.IsZero() {
if query.DateType == "paid" {
db = db.Where("d.paid_at >= ? AND d.paid_at <= ?", query.StartDate, query.EndDate)
+350 -590
View File
@@ -2,608 +2,368 @@ package adminfinance
import (
"context"
"sync"
"encoding/json"
"strings"
"time"
"hfb_sys/backend/internal/timeutil"
"gorm.io/gorm"
)
func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) {
if cached := r.loadDashboardCache(ctx, query); cached != nil {
return cached, nil
func (r *Repository) Disbursements(ctx context.Context, query DisbursementQuery) (*DisbursementListDTO, error) {
var summary DisbursementListSummaryDTO
summaryDB := r.applyDisbursementFilters(r.disbursementListBaseQuery(ctx), query)
if err := summaryDB.Select(`COUNT(*) AS record_count,
COALESCE(SUM(CASE WHEN status = 'paid' THEN actual_amount_cent ELSE 0 END), 0) AS paid_amount_cent,
COALESCE(SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END), 0) AS paid_count,
COALESCE(SUM(CASE WHEN status = 'payment_pending' THEN actual_amount_cent ELSE 0 END), 0) AS payment_pending_amount_cent,
COALESCE(SUM(CASE WHEN status = 'payment_pending' THEN 1 ELSE 0 END), 0) AS payment_pending_count,
COALESCE(SUM(CASE WHEN status = 'review_pending' THEN actual_amount_cent ELSE 0 END), 0) AS review_pending_amount_cent,
COALESCE(SUM(CASE WHEN status = 'review_pending' THEN 1 ELSE 0 END), 0) AS review_pending_count,
COALESCE(SUM(CASE WHEN source_type = 'withdrawal' AND status = 'paid' THEN fee_cent ELSE 0 END), 0) AS withdrawal_fee_amount_cent`).
Scan(&summary).Error; err != nil {
return nil, err
}
// 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 SQL,
// 首次打开或缓存失效时仍可尽快返回;每个查询继续复用 GORM 的连接池。
var (
daily dashboardDailyResult
pickup *PickupSummaryDTO
disbursement *DisbursementSummaryDTO
)
var wg sync.WaitGroup
errCh := make(chan error, 3)
run := func(fn func() error) {
wg.Add(1)
go func() {
defer wg.Done()
if err := fn(); err != nil {
errCh <- err
}
}()
rows := make([]disbursementItemRow, 0, query.PageSize)
orderColumn := "business_created_at"
if query.DateType == "paid" {
orderColumn = "paid_at"
}
run(func() error {
var err error
daily, err = r.dailyItems(ctx, query)
return err
})
run(func() error {
var err error
pickup, err = r.pickupSummary(ctx, query)
return err
})
run(func() error {
var err error
disbursement, err = r.disbursementSummary(ctx, query)
return err
})
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
return nil, err
offset := (query.Page - 1) * query.PageSize
rowsDB := r.applyDisbursementFilters(r.disbursementListBaseQuery(ctx), query)
if err := rowsDB.Order(orderColumn + " DESC").
Order("source_id DESC").
Offset(offset).
Limit(query.PageSize).
Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]DisbursementItemDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO())
}
return &DisbursementListDTO{
Items: items,
Total: summary.RecordCount,
Page: query.Page,
PageSize: query.PageSize,
Summary: summary,
}, nil
}
func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
db := r.db.WithContext(ctx)
platformManaged := db.Table("rental_orders AS ro").
Select(`'platform_managed' AS source_type,
ro.id AS source_id,
ro.order_no AS business_no,
ro.id AS order_id,
0 AS withdrawal_id,
ro.owner_id AS user_id,
'外部卖家' AS payee_name,
'' AS payee_phone,
COALESCE(lu.uploader_name, '') AS uploader_name,
COALESCE(lu.source_channel, '') AS source_channel,
'' AS account_type,
'' AS account_name,
'' AS account_no,
'' AS bank_name,
ro.offline_settlement_amount_cent AS amount_cent,
0 AS fee_cent,
ro.offline_settlement_amount_cent AS actual_amount_cent,
CASE WHEN ro.offline_settlement_status = 'settled' THEN 'paid' ELSE 'payment_pending' END AS status,
ro.offline_settlement_status AS raw_status,
ro.settled_at AS business_created_at,
ro.offline_settled_at AS paid_at,
ro.offline_settled_by AS operator_id,
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
ro.offline_settlement_remark AS remark,
'' AS category,
'' AS custom_category_name,
'' AS voucher_url,
NULL AS voided_at,
NULL AS voided_by,
'' AS voided_by_name,
'' AS void_reason,
lu.parsed_payload AS source_payload`).
Joins(`LEFT JOIN listing_uploads AS lu ON lu.id = (
SELECT MAX(lu2.id) FROM listing_uploads AS lu2 WHERE lu2.listing_id = ro.listing_id
)`).
Joins("LEFT JOIN admin_users AS operator ON operator.id = ro.offline_settled_by").
Where("ro.settlement_mode = ?", "platform_managed").
Where("ro.offline_settlement_status IN ?", []string{"pending", "settled"}).
Where("ro.offline_settlement_amount_cent > 0")
pickupManaged := db.Table("admin_pickups AS ap").
Select(`'pickup' AS source_type,
ap.id AS source_id,
ap.pickup_no AS business_no,
0 AS order_id,
0 AS withdrawal_id,
ap.owner_id AS user_id,
'外部卖家' AS payee_name,
'' AS payee_phone,
'' AS uploader_name,
ap.source_channel,
'' AS account_type,
'' AS account_name,
'' AS account_no,
'' AS bank_name,
ap.settle_amount_cent AS amount_cent,
0 AS fee_cent,
ap.settle_amount_cent AS actual_amount_cent,
CASE WHEN ap.offline_settlement_status = 'settled' THEN 'paid' ELSE 'payment_pending' END AS status,
ap.offline_settlement_status AS raw_status,
ap.completed_at AS business_created_at,
ap.offline_settled_at AS paid_at,
ap.offline_settled_by AS operator_id,
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
ap.offline_settlement_remark AS remark,
'' AS category,
'' AS custom_category_name,
'' AS voucher_url,
NULL AS voided_at,
NULL AS voided_by,
'' AS voided_by_name,
'' AS void_reason,
ap.account_snapshot AS source_payload`).
Joins("LEFT JOIN admin_users AS operator ON operator.id = ap.offline_settled_by").
Where("ap.settlement_mode = ?", "platform_managed").
Where("ap.offline_settlement_status IN ?", []string{"pending", "settled"}).
Where("ap.settle_amount_cent > 0")
pickupAdjustments := db.Table("admin_pickup_financial_adjustments AS fa").
Select(`'pickup_adjustment' AS source_type,
fa.id AS source_id,
CONCAT(ap.pickup_no, '-ADJ-', fa.id) AS business_no,
0 AS order_id,
0 AS withdrawal_id,
ap.owner_id AS user_id,
'外部卖家' AS payee_name,
'' AS payee_phone,
'' AS uploader_name,
ap.source_channel,
'' AS account_type,
'' AS account_name,
'' AS account_no,
'' AS bank_name,
fa.settle_delta_cent AS amount_cent,
0 AS fee_cent,
fa.settle_delta_cent AS actual_amount_cent,
CASE WHEN fa.status = 'settled' THEN 'paid' ELSE 'payment_pending' END AS status,
fa.status AS raw_status,
fa.created_at AS business_created_at,
fa.settled_at AS paid_at,
fa.settled_by AS operator_id,
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
fa.reason AS remark,
'' AS category,
'' AS custom_category_name,
'' AS voucher_url,
NULL AS voided_at,
NULL AS voided_by,
'' AS voided_by_name,
'' AS void_reason,
ap.account_snapshot AS source_payload`).
Joins("JOIN admin_pickups AS ap ON ap.id = fa.pickup_id").
Joins("LEFT JOIN admin_users AS operator ON operator.id = fa.settled_by").
Where("fa.settlement_mode = ?", "platform_managed").
Where("fa.status IN ?", []string{"pending", "settled"}).
Where("fa.settle_delta_cent > 0")
withdrawal := db.Table("withdrawal_requests AS wr").
Select(`'withdrawal' AS source_type,
wr.id AS source_id,
wr.withdraw_no AS business_no,
0 AS order_id,
wr.id AS withdrawal_id,
wr.user_id,
COALESCE(NULLIF(u.nickname, ''), NULLIF(wr.account_name, ''), '') AS payee_name,
COALESCE(u.phone, '') AS payee_phone,
'' AS uploader_name,
'' AS source_channel,
wr.account_type,
wr.account_name,
wr.account_no,
wr.bank_name,
wr.amount_cent,
wr.fee_cent,
wr.actual_amount_cent,
CASE
WHEN wr.status = 'pending' THEN 'review_pending'
WHEN wr.status = 'processing' THEN 'payment_pending'
WHEN wr.status = 'completed' THEN 'paid'
ELSE wr.status
END AS status,
wr.status AS raw_status,
wr.created_at AS business_created_at,
wr.paid_at,
COALESCE(wr.paid_by, wr.reviewed_by) AS operator_id,
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
CASE WHEN wr.payment_remark <> '' THEN wr.payment_remark ELSE wr.review_remark END AS remark,
'' AS category,
'' AS custom_category_name,
'' AS voucher_url,
NULL AS voided_at,
NULL AS voided_by,
'' AS voided_by_name,
'' AS void_reason,
NULL AS source_payload`).
Joins("LEFT JOIN users AS u ON u.id = wr.user_id").
Joins("LEFT JOIN admin_users AS operator ON operator.id = COALESCE(wr.paid_by, wr.reviewed_by)")
manualOffline := db.Table("manual_disbursements AS md").
Select(`'manual_offline' AS source_type,
md.id AS source_id,
md.disbursement_no AS business_no,
0 AS order_id,
0 AS withdrawal_id,
0 AS user_id,
md.payee_name,
'' AS payee_phone,
'' AS uploader_name,
'' AS source_channel,
'' AS account_type,
'' AS account_name,
'' AS account_no,
'' AS bank_name,
md.amount_cent,
0 AS fee_cent,
md.amount_cent AS actual_amount_cent,
md.status,
md.status AS raw_status,
md.created_at AS business_created_at,
md.paid_at,
md.created_by AS operator_id,
COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS operator_name,
md.remark,
md.category,
md.custom_category_name,
md.voucher_url,
md.voided_at,
md.voided_by,
COALESCE(NULLIF(voider.nickname, ''), voider.username, '') AS voided_by_name,
md.void_reason,
NULL AS source_payload`).
Joins("LEFT JOIN admin_users AS creator ON creator.id = md.created_by").
Joins("LEFT JOIN admin_users AS voider ON voider.id = md.voided_by")
union := db.Raw("? UNION ALL ? UNION ALL ? UNION ALL ? UNION ALL ?", platformManaged, pickupManaged, pickupAdjustments, withdrawal, manualOffline)
return db.Table("(?) AS d", union)
}
func (r *Repository) applyDisbursementFilters(db *gorm.DB, query DisbursementQuery) *gorm.DB {
if query.SourceType != "" {
db = db.Where("d.source_type = ?", query.SourceType)
}
if query.Status != "" {
db = db.Where("d.status = ?", query.Status)
}
if query.BusinessNo != "" {
db = db.Where("d.business_no LIKE ?", "%"+query.BusinessNo+"%")
}
if query.UserID > 0 {
db = db.Where("d.user_id = ?", query.UserID)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
db = db.Where(`(d.business_no LIKE ? OR d.payee_name LIKE ? OR d.payee_phone LIKE ?
OR d.uploader_name LIKE ? OR d.account_name LIKE ? OR d.account_no LIKE ?)`, like, like, like, like, like, like)
}
if query.Operator != "" {
like := "%" + query.Operator + "%"
db = db.Where("(d.operator_name LIKE ? OR CAST(d.operator_id AS CHAR) LIKE ?)", like, like)
}
if query.OperatorID > 0 {
db = db.Where("d.operator_id = ?", query.OperatorID)
}
if !query.StartDate.IsZero() && !query.EndDate.IsZero() {
if query.DateType == "paid" {
db = db.Where("d.paid_at >= ? AND d.paid_at <= ?", query.StartDate, query.EndDate)
} else {
db = db.Where("d.business_created_at >= ? AND d.business_created_at <= ?", query.StartDate, query.EndDate)
}
}
summary := summaryWithPickup(daily.Summary, pickup)
result := &DashboardDTO{
Summary: summary,
DailyItems: daily.Items,
PickupSummary: *pickup,
MohongSummary: daily.Mohong,
DisbursementSummary: *disbursement,
OperatingExpenseSummary: daily.OperatingExpense,
GeneratedAt: timeutil.ShanghaiNow(),
}
r.storeDashboardCache(ctx, query, result)
return result, nil
return db
}
func (r *Repository) operatingExpenseSummary(ctx context.Context, query DashboardQuery) (*OperatingExpenseSummaryDTO, error) {
var summary OperatingExpenseSummaryDTO
err := r.db.WithContext(ctx).Table("operating_expenses").
Select("COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count").
Where("status = ?", "paid").
Where("occurred_at >= ? AND occurred_at <= ?", query.StartDate, query.EndDate).
Scan(&summary).Error
if err != nil {
return nil, err
}
return &summary, nil
type disbursementItemRow struct {
SourceType string
SourceID uint64
BusinessNo string
OrderID uint64
WithdrawalID uint64
UserID uint64
PayeeName string
PayeePhone string
UploaderName string
SourceChannel string
AccountType string
AccountName string
AccountNo string
BankName string
AmountCent int64
FeeCent int64
ActualAmountCent int64
Status string
RawStatus string
BusinessCreatedAt *time.Time
PaidAt *time.Time
OperatorID *uint64
OperatorName string
Remark string
Category string
CustomCategoryName string
VoucherURL string
VoidedAt *time.Time
VoidedBy *uint64
VoidedByName string
VoidReason string
SourcePayload []byte
}
// pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。
func (r *Repository) pickupSummary(ctx context.Context, query DashboardQuery) (*PickupSummaryDTO, error) {
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, 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 {
return nil, err
}
var adjustments struct {
SettledAmountCent int64
ProfitAmountCent int64
}
if err := db.Table("admin_pickup_financial_adjustments AS fa").
Select(`COALESCE(SUM(fa.settle_delta_cent), 0) AS settled_amount_cent,
COALESCE(SUM(fa.profit_delta_cent), 0) AS profit_amount_cent`).
Joins("JOIN admin_pickups AS p ON p.id = fa.pickup_id AND p.status = ?", "completed").
Where("fa.created_at >= ? AND fa.created_at <= ?", query.StartDate, query.EndDate).
Scan(&adjustments).Error; err != nil {
return nil, err
}
var inProgress int64
if err := db.Table("admin_pickups").Where("status = ?", "picking_up").Count(&inProgress).Error; err != nil {
return nil, err
}
var pending struct {
AmountCent int64
Count int64
}
if err := db.Table("admin_pickups").
Select(`COALESCE(SUM(settle_amount_cent), 0) AS amount_cent, COUNT(id) AS count`).
Where("settlement_mode = ? AND offline_settlement_status = ? AND settle_amount_cent > 0", "platform_managed", "pending").
Scan(&pending).Error; err != nil {
return nil, err
}
var pendingAdjustments struct {
AmountCent int64
Count int64
}
if err := db.Table("admin_pickup_financial_adjustments").
Select(`COALESCE(SUM(settle_delta_cent), 0) AS amount_cent, COUNT(id) AS count`).
Where("settlement_mode = ? AND status = ? AND settle_delta_cent > 0", "platform_managed", "pending").
Scan(&pendingAdjustments).Error; err != nil {
return nil, err
}
return &PickupSummaryDTO{
SettledAmountCent: row.SettledAmountCent + adjustments.SettledAmountCent,
ProfitAmountCent: row.ProfitAmountCent + adjustments.ProfitAmountCent,
CompletedCount: row.CompletedCount,
InProgressCount: inProgress,
PendingAmountCent: pending.AmountCent + pendingAdjustments.AmountCent,
PendingCount: pending.Count + pendingAdjustments.Count,
}, nil
}
// mohongSummary 撞车商城按支付订单统计交易流水,不计入平台利润。
func (r *Repository) mohongSummary(ctx context.Context, query DashboardQuery) (*MohongSummaryDTO, error) {
db := r.db.WithContext(ctx)
var row struct {
FlowAmountCent int64
RefundAmountCent int64
PaidOrderCount int64
}
if err := db.Table("payment_orders").
Select(`COALESCE(SUM(CASE WHEN biz_type = 'mohong_pay' AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS flow_amount_cent,
COALESCE(SUM(CASE WHEN biz_type = 'mohong_refund' AND status = 'refunded' THEN amount_cent ELSE 0 END), 0) AS refund_amount_cent,
COALESCE(SUM(CASE WHEN biz_type = 'mohong_pay' AND status = 'paid' THEN 1 ELSE 0 END), 0) AS paid_order_count`).
Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate).
Scan(&row).Error; err != nil {
return nil, err
}
return &MohongSummaryDTO{
FlowAmountCent: row.FlowAmountCent,
RefundAmountCent: row.RefundAmountCent,
NetAmountCent: row.FlowAmountCent - row.RefundAmountCent,
PaidOrderCount: row.PaidOrderCount,
}, nil
}
type dashboardDailyResult struct {
Items []FinanceDailyDTO
Summary FinanceSummaryDTO
Mohong MohongSummaryDTO
OperatingExpense OperatingExpenseSummaryDTO
}
func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) (dashboardDailyResult, error) {
db := r.db.WithContext(ctx)
var (
payments []dailyPaymentRow
settlements []dailySettlementRow
estimates []dailyEstimatedRow
)
var wg sync.WaitGroup
errCh := make(chan error, 3)
run := func(fn func() error) {
wg.Add(1)
go func() {
defer wg.Done()
if err := fn(); err != nil {
errCh <- err
}
}()
}
run(func() error {
var err error
payments, err = r.dailyPayments(ctx, query)
return err
})
run(func() error {
var err error
settlements, err = r.dailySettlements(ctx, query)
return err
})
run(func() error {
var err error
estimates, err = r.dailyEstimates(ctx, query)
return err
})
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
return dashboardDailyResult{}, err
func (r disbursementItemRow) toDTO() DisbursementItemDTO {
phone := strings.TrimSpace(r.PayeePhone)
if phone == "" && r.SourceType == "platform_managed" && len(r.SourcePayload) > 0 {
var payload struct {
ContactPhone string `json:"contactPhone"`
}
if json.Unmarshal(r.SourcePayload, &payload) == nil {
phone = strings.TrimSpace(payload.ContactPhone)
}
}
paidOrders, err := r.dailyPaidOrders(ctx, query)
if err != nil {
return dashboardDailyResult{}, err
return DisbursementItemDTO{
SourceType: r.SourceType,
SourceID: r.SourceID,
BusinessNo: r.BusinessNo,
OrderID: r.OrderID,
WithdrawalID: r.WithdrawalID,
UserID: r.UserID,
PayeeName: r.PayeeName,
PayeePhone: phone,
UploaderName: r.UploaderName,
SourceChannel: r.SourceChannel,
AccountType: r.AccountType,
AccountName: r.AccountName,
AccountNo: r.AccountNo,
BankName: r.BankName,
AmountCent: r.AmountCent,
FeeCent: r.FeeCent,
ActualAmountCent: r.ActualAmountCent,
Status: r.Status,
RawStatus: r.RawStatus,
BusinessCreatedAt: r.BusinessCreatedAt,
PaidAt: r.PaidAt,
OperatorID: r.OperatorID,
OperatorName: r.OperatorName,
Remark: r.Remark,
Category: r.Category,
CustomCategoryName: r.CustomCategoryName,
VoucherURL: r.VoucherURL,
VoidedAt: r.VoidedAt,
VoidedBy: r.VoidedBy,
VoidedByName: r.VoidedByName,
VoidReason: r.VoidReason,
}
pickupProfits := make([]dailyPickupProfitRow, 0)
if err := db.Table("admin_pickups").
Select(`DATE(completed_at) AS date,
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).
Group("DATE(completed_at)").
Scan(&pickupProfits).Error; err != nil {
return dashboardDailyResult{}, err
}
pickupAdjustments := make([]dailyPickupAdjustmentRow, 0)
if err := db.Table("admin_pickup_financial_adjustments AS fa").
Select(`DATE(fa.created_at) AS date,
COALESCE(SUM(fa.profit_delta_cent), 0) AS profit_amount_cent`).
Joins("JOIN admin_pickups AS p ON p.id = fa.pickup_id AND p.status = ?", "completed").
Where("fa.created_at >= ? AND fa.created_at <= ?", query.StartDate, query.EndDate).
Group("DATE(fa.created_at)").
Scan(&pickupAdjustments).Error; err != nil {
return dashboardDailyResult{}, err
}
// 取消提号按取消日统计,与「已付款订单数」中的提号创建口径区分开。
pickupCancelled, err := r.dailyPickupCancelled(ctx, query)
if err != nil {
return dashboardDailyResult{}, err
}
// 每日预计收入:尚未结算订单的下单预估平台手续费,按下单日期归集,口径与 summary 一致。
disbursements, err := r.dailyDisbursements(ctx, query)
if err != nil {
return dashboardDailyResult{}, err
}
operatingExpenses, err := r.dailyOperatingExpenses(ctx, query)
if err != nil {
return dashboardDailyResult{}, err
}
itemsByDate := make(map[string]FinanceDailyDTO)
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
date := day.Format("2006-01-02")
itemsByDate[date] = FinanceDailyDTO{Date: date}
}
for _, row := range payments {
date := dailyDateKey(row.Date)
item := itemsByDate[date]
item.Date = date
item.TotalFlowAmountCent = row.TotalFlowAmountCent
item.TotalRefundAmountCent = row.TotalRefundAmountCent
item.PendingRefundAmountCent = row.PendingRefundAmountCent
item.ChannelNetAmountCent = row.TotalFlowAmountCent - row.TotalRefundAmountCent
item.SuccessfulPayCount = row.SuccessfulPayCount
item.SuccessfulRefundCount = row.FullRefundCount + row.PartialRefundCount
item.FullRefundCount = row.FullRefundCount
item.PartialRefundCount = row.PartialRefundCount
item.PendingRefundCount = row.PendingRefundCount
item.NormalFullRefundCount = row.NormalFullRefundCount
itemsByDate[date] = item
}
for _, row := range paidOrders {
date := dailyDateKey(row.Date)
item := itemsByDate[date]
item.Date = date
item.PaidOrderCount = row.PaidOrderCount
itemsByDate[date] = item
}
for _, row := range settlements {
date := dailyDateKey(row.Date)
item := itemsByDate[date]
item.Date = date
item.PlatformIncomeAmountCent = row.PlatformIncomeAmountCent
item.OwnerShouldIncomeAmountCent = row.OwnerShouldIncomeAmountCent
item.OwnerWalletIncomeAmountCent = row.OwnerWalletIncomeAmountCent
item.OwnerEffectiveSettlementAmountCent = row.OwnerEffectiveSettlementAmountCent
item.OfflineSettlementAmountCent = row.OfflineSettlementAmountCent
item.OfflineSettlementPendingAmountCent = row.OfflineSettlementPendingAmountCent
item.SettlementDiffAmountCent = row.OwnerShouldIncomeAmountCent - row.OwnerEffectiveSettlementAmountCent
item.SettledOrderCount = row.SettledOrderCount
item.SalesCount = row.SettledOrderCount
item.OfflineSettlementPendingCount = row.OfflineSettlementPendingCount
itemsByDate[date] = item
}
for _, row := range pickupProfits {
date := dailyDateKey(row.Date)
item := itemsByDate[date]
item.Date = date
item.PlatformIncomeAmountCent += row.ProfitAmountCent
item.PickupCompletedCount = row.CompletedCount
item.SalesCount += row.CompletedCount
itemsByDate[date] = item
}
for _, row := range pickupAdjustments {
date := dailyDateKey(row.Date)
item := itemsByDate[date]
item.Date = date
item.PlatformIncomeAmountCent += row.ProfitAmountCent
itemsByDate[date] = item
}
for _, row := range pickupCancelled {
date := dailyDateKey(row.Date)
item := itemsByDate[date]
item.Date = date
item.PickupCancelledCount = row.Count
itemsByDate[date] = item
}
for _, row := range estimates {
date := dailyDateKey(row.Date)
item := itemsByDate[date]
item.Date = date
item.EstimatedIncomeAmountCent = row.EstimatedIncomeAmountCent
itemsByDate[date] = item
}
for _, row := range disbursements {
date := dailyDateKey(row.Date)
item := itemsByDate[date]
item.Date = date
item.OfflineSettlementPaidAmountCent = row.OfflineSettlementPaidAmountCent
item.OfflineSettlementPaidCount = row.OfflineSettlementPaidCount
item.WithdrawalPaidAmountCent = row.WithdrawalPaidAmountCent
item.WithdrawalPaidCount = row.WithdrawalPaidCount
item.ManualPaidAmountCent = row.ManualPaidAmountCent
item.ManualPaidCount = row.ManualPaidCount
item.DisbursementPaidAmountCent = row.OfflineSettlementPaidAmountCent + row.WithdrawalPaidAmountCent + row.ManualPaidAmountCent
item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount
itemsByDate[date] = item
}
for _, row := range operatingExpenses {
date := dailyDateKey(row.Date)
item := itemsByDate[date]
item.Date = date
item.OperatingExpenseAmountCent = row.AmountCent
item.OperatingExpenseCount = row.Count
itemsByDate[date] = item
}
items := make([]FinanceDailyDTO, 0, len(itemsByDate))
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
items = append(items, itemsByDate[day.Format("2006-01-02")])
}
return dashboardDailyResult{
Items: items,
Summary: summaryFromDailyRows(payments, settlements, estimates),
Mohong: mohongSummaryFromDailyRows(payments),
OperatingExpense: operatingExpenseSummaryFromDailyRows(operatingExpenses),
}, nil
}
func (r *Repository) dailyEstimates(ctx context.Context, query DashboardQuery) ([]dailyEstimatedRow, error) {
rows := make([]dailyEstimatedRow, 0)
err := r.db.WithContext(ctx).Table("rental_orders").
Select(`DATE(created_at) AS date,
COALESCE(SUM(platform_fee_cent), 0) AS estimated_income_amount_cent,
COUNT(id) AS pending_settle_order_count`).
Where("settlement_status NOT IN ?", settledSettlementStatuses()).
Where("status NOT IN ?", nonBillableOrderStatuses()).
Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate).
Group("DATE(created_at)").
Scan(&rows).Error
return rows, err
}
func (r *Repository) dailySettlements(ctx context.Context, query DashboardQuery) ([]dailySettlementRow, error) {
db := r.db.WithContext(ctx)
rows := make([]dailySettlementRow, 0)
err := db.Table("rental_orders AS ro").
Select(`DATE(ro.settled_at) AS date,
COALESCE(SUM(oc.platform_fee_cent), 0) AS platform_income_amount_cent,
COALESCE(SUM(oc.owner_income_amount_cent), 0) AS owner_should_income_amount_cent,
COALESCE(SUM(COALESCE(w.owner_wallet_income_amount_cent, 0)), 0) AS owner_wallet_income_amount_cent,
COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
THEN COALESCE(ro.offline_settlement_amount_cent, 0)
ELSE COALESCE(w.owner_wallet_income_amount_cent, 0)
END), 0) AS owner_effective_settlement_amount_cent,
COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
THEN COALESCE(ro.offline_settlement_amount_cent, 0)
ELSE 0
END), 0) AS offline_settlement_amount_cent,
COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending'
THEN COALESCE(ro.offline_settlement_amount_cent, 0)
ELSE 0
END), 0) AS offline_settlement_pending_amount_cent,
COALESCE(SUM(CASE WHEN ro.settlement_mode = 'platform_managed'
AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending'
AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0
THEN 1
ELSE 0
END), 0) AS offline_settlement_pending_count,
COALESCE(SUM(CASE WHEN ro.refund_status = 'refunding'
OR ABS(COALESCE(oc.owner_income_amount_cent, 0) - CASE WHEN ro.settlement_mode = 'platform_managed'
THEN COALESCE(ro.offline_settlement_amount_cent, 0)
ELSE COALESCE(w.owner_wallet_income_amount_cent, 0)
END) >= ?
OR EXISTS (
SELECT 1 FROM payment_orders AS exception_payment
WHERE exception_payment.order_id = ro.id
AND exception_payment.biz_type IN ?
AND exception_payment.status IN ?
AND exception_payment.amount_cent > 0
)
THEN 1
ELSE 0
END), 0) AS financial_exception_count,
COUNT(ro.id) AS settled_order_count`, settlementDiffThresholdCent, refundBizTypes(), []string{"refunding", "failed"}).
Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'").
Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeForSettledOrdersSubquery(db, query)).
Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate).
Group("DATE(ro.settled_at)").
Scan(&rows).Error
return rows, err
}
func summaryFromDailyRows(payments []dailyPaymentRow, settlements []dailySettlementRow, estimates []dailyEstimatedRow) FinanceSummaryDTO {
var summary FinanceSummaryDTO
for _, row := range payments {
summary.TotalFlowAmountCent += row.TotalFlowAmountCent
summary.TotalRefundAmountCent += row.TotalRefundAmountCent
summary.PendingRefundAmountCent += row.PendingRefundAmountCent
summary.SuccessfulPayCount += row.SuccessfulPayCount
summary.FullRefundCount += row.FullRefundCount
summary.PartialRefundCount += row.PartialRefundCount
summary.PendingRefundCount += row.PendingRefundCount
}
for _, row := range settlements {
summary.NormalOrderProfitAmountCent += row.PlatformIncomeAmountCent
summary.OwnerShouldIncomeAmountCent += row.OwnerShouldIncomeAmountCent
summary.OwnerWalletIncomeAmountCent += row.OwnerWalletIncomeAmountCent
summary.OwnerEffectiveSettlementAmountCent += row.OwnerEffectiveSettlementAmountCent
summary.OfflineSettlementAmountCent += row.OfflineSettlementAmountCent
summary.OfflineSettlementPendingAmountCent += row.OfflineSettlementPendingAmountCent
summary.OfflineSettlementPendingCount += row.OfflineSettlementPendingCount
summary.FinancialExceptionCount += row.FinancialExceptionCount
summary.SettledOrderCount += row.SettledOrderCount
}
for _, row := range estimates {
summary.EstimatedIncomeAmountCent += row.EstimatedIncomeAmountCent
summary.PendingSettleOrderCount += row.PendingSettleOrderCount
}
summary.ChannelNetAmountCent = summary.TotalFlowAmountCent - summary.TotalRefundAmountCent
summary.SuccessfulRefundCount = summary.FullRefundCount + summary.PartialRefundCount
summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent
summary.SettlementDiffAmountCent = summary.OwnerShouldIncomeAmountCent - summary.OwnerEffectiveSettlementAmountCent
return summary
}
func summaryWithPickup(summary FinanceSummaryDTO, pickup *PickupSummaryDTO) FinanceSummaryDTO {
if pickup == nil {
return summary
}
summary.PickupProfitAmountCent = pickup.ProfitAmountCent
summary.PickupCompletedCount = pickup.CompletedCount
summary.PlatformIncomeAmountCent = summary.NormalOrderProfitAmountCent + pickup.ProfitAmountCent
summary.OfflineSettlementPendingAmountCent += pickup.PendingAmountCent
summary.OfflineSettlementPendingCount += pickup.PendingCount
return summary
}
func mohongSummaryFromDailyRows(rows []dailyPaymentRow) MohongSummaryDTO {
var summary MohongSummaryDTO
for _, row := range rows {
summary.FlowAmountCent += row.MohongFlowAmountCent
summary.RefundAmountCent += row.MohongRefundAmountCent
summary.PaidOrderCount += row.MohongPaidOrderCount
}
summary.NetAmountCent = summary.FlowAmountCent - summary.RefundAmountCent
return summary
}
func operatingExpenseSummaryFromDailyRows(rows []dailyOperatingExpenseRow) OperatingExpenseSummaryDTO {
var summary OperatingExpenseSummaryDTO
for _, row := range rows {
summary.AmountCent += row.AmountCent
summary.Count += row.Count
}
return summary
}
// dailyPayments 按支付单创建日统计收付款流水与退款笔数,
// normal_full_refund_count 只统计正常(租赁)订单的全额退款,排除撞车商城。
func (r *Repository) dailyPayments(ctx context.Context, query DashboardQuery) ([]dailyPaymentRow, error) {
rows := make([]dailyPaymentRow, 0)
db := r.db.WithContext(ctx)
err := db.Table("payment_orders AS po").
Select(`DATE(po.created_at) AS date,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS total_flow_amount_cent,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS total_refund_amount_cent,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN po.amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_count,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS full_refund_count,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent < COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS partial_refund_count,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count,
COALESCE(SUM(CASE WHEN po.biz_type IN ? AND po.status = 'refunded' AND po.amount_cent >= COALESCE(orig.amount_cent, 0) THEN 1 ELSE 0 END), 0) AS normal_full_refund_count,
COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN po.amount_cent ELSE 0 END), 0) AS mohong_flow_amount_cent,
COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_refund' AND po.status = 'refunded' THEN po.amount_cent ELSE 0 END), 0) AS mohong_refund_amount_cent,
COALESCE(SUM(CASE WHEN po.biz_type = 'mohong_pay' AND po.status = 'paid' THEN 1 ELSE 0 END), 0) AS mohong_paid_order_count`,
payBizTypes(), refundBizTypes(), refundBizTypes(), payBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes(), normalRefundBizTypes()).
Joins("LEFT JOIN (?) AS orig ON orig.order_id = po.order_id", paymentOriginalAmountForRefundsInRangeSubquery(db, query)).
Where("po.created_at >= ? AND po.created_at <= ?", query.StartDate, query.EndDate).
Group("DATE(po.created_at)").
Scan(&rows).Error
return rows, err
}
// dailyPickupCancelled 按取消日统计线下提号取消笔数。
func (r *Repository) dailyPickupCancelled(ctx context.Context, query DashboardQuery) ([]dailyPickupCancelledRow, error) {
rows := make([]dailyPickupCancelledRow, 0)
err := r.db.WithContext(ctx).Table("admin_pickups").
Select(`DATE(cancelled_at) AS date, COUNT(id) AS count`).
Where("status = ?", "cancelled").
Where("cancelled_at >= ? AND cancelled_at <= ?", query.StartDate, query.EndDate).
Group("DATE(cancelled_at)").
Scan(&rows).Error
return rows, err
}
// dailyPaidOrders 按实际付款日统计普通租赁订单,并将已完成的线下提号按完成日计入成交。
func (r *Repository) dailyPaidOrders(ctx context.Context, query DashboardQuery) ([]dailyPaidOrderRow, error) {
items := make([]dailyPaidOrderRow, 0)
err := r.db.WithContext(ctx).Table(`(
SELECT DATE(paid_at) AS date, COUNT(DISTINCT order_id) AS paid_order_count
FROM payment_orders
WHERE biz_type = 'order_pay' AND status = 'paid' AND paid_at >= ? AND paid_at <= ?
GROUP BY DATE(paid_at)
UNION ALL
SELECT DATE(completed_at) AS date, COUNT(id) AS paid_order_count
FROM admin_pickups
WHERE status = 'completed' AND completed_at >= ? AND completed_at <= ?
GROUP BY DATE(completed_at)
) AS daily_paid_orders`, query.StartDate, query.EndDate, query.StartDate, query.EndDate).
Select("date, SUM(paid_order_count) AS paid_order_count").
Group("date").
Scan(&items).Error
return items, err
}
type dailyPaymentRow struct {
Date string
TotalFlowAmountCent int64
TotalRefundAmountCent int64
PendingRefundAmountCent int64
SuccessfulPayCount int64
FullRefundCount int64
PartialRefundCount int64
PendingRefundCount int64
NormalFullRefundCount int64
MohongFlowAmountCent int64
MohongRefundAmountCent int64
MohongPaidOrderCount int64
}
type dailyPickupCancelledRow struct {
Date string
Count int64
}
type dailyPaidOrderRow struct {
Date string
PaidOrderCount int64
}
type dailySettlementRow struct {
Date string
PlatformIncomeAmountCent int64
OwnerShouldIncomeAmountCent int64
OwnerWalletIncomeAmountCent int64
OwnerEffectiveSettlementAmountCent int64
OfflineSettlementAmountCent int64
OfflineSettlementPendingAmountCent int64
OfflineSettlementPendingCount int64
FinancialExceptionCount int64
SettledOrderCount int64
}
type dailyPickupProfitRow struct {
Date string
ProfitAmountCent int64
CompletedCount int64
}
// dailyPickupAdjustmentRow 调整按调整创建日进入当日利润,避免回写历史完成日报。
type dailyPickupAdjustmentRow struct {
Date string
ProfitAmountCent int64
}
type dailyEstimatedRow struct {
Date string
EstimatedIncomeAmountCent int64
PendingSettleOrderCount int64
}
+8 -1
View File
@@ -2,6 +2,13 @@
set -eu
target=${1:?用法: ROLLBACK.sh <待恢复文件>}
original=/tmp/hfb-finance-dashboard-optimization/dashboard.go.original
case "$target" in
*/hfb-dashboard-rollback-copy.go)
original=/tmp/hfb-finance-dashboard-optimization/dashboard.go.original
;;
*)
original=/tmp/hfb-finance-disbursement-filter/disbursement_list.go.original
;;
esac
test -f "$original"
cp "$original" "$target"
+30
View File
@@ -32,3 +32,33 @@ MIGRATION
命令:goose -dir migrations mysql 'hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?parseTime=true' up;SHOW INDEX FROM rental_orders WHERE Key_name='idx_rental_orders_offline_pending_amount';goose -dir migrations mysql 'hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?parseTime=true' down。
输入:本地 Docker MySQL 的 migration 62 数据库。
字面输出/结果:goose: successfully migrated database to version: 63;索引三列均出现;Down 输出 OK 000063_finance_pending_disbursement_index.sql;删除后恢复原状态;退出状态 0。
资金出款操作人筛选验证
变更分支/字段:main;adminfinance.DisbursementQuery.Operator/OperatorID、`operator`/`operator_id` 请求参数及资金出款筛选表单。
原始 `disbursement_list.go` SHA-256:478ffe515a3f452759e191e0b32f8bc22c1ac2f2dde520092cc6c5ee716f1e7c
四个验证产物:
/Users/yml/codes/hfb_sys/docs/MODIFIED_FILE
/Users/yml/codes/hfb_sys/docs/DIFF_FILE
/Users/yml/codes/hfb_sys/docs/VERIFICATION.txt
/Users/yml/codes/hfb_sys/docs/ROLLBACK.sh
BASELINE
命令:`go test ./internal/modules/adminfinance -run '^TestDisbursementListCombinesPlatformSettlementsAndWithdrawals$' -count=1 -v`
输入:HEAD 基线代码,SQLite 内存测试数据。
字面输出/结果:`=== RUN TestDisbursementListCombinesPlatformSettlementsAndWithdrawals`、`--- PASS: TestDisbursementListCombinesPlatformSettlementsAndWithdrawals (0.01s)`、`PASS`、`ok hfb_sys/backend/internal/modules/adminfinance 0.420s`;退出状态 0。
MODIFIED
命令:`go test ./internal/modules/adminfinance -run '^TestDisbursementListCombinesPlatformSettlementsAndWithdrawals$' -count=1 -v`
输入:增加操作人昵称筛选断言的修改代码,SQLite 内存测试数据。
字面输出/结果:`=== RUN TestDisbursementListCombinesPlatformSettlementsAndWithdrawals`、`--- PASS: TestDisbursementListCombinesPlatformSettlementsAndWithdrawals (0.01s)`、`PASS`、`ok hfb_sys/backend/internal/modules/adminfinance 0.200s`;退出状态 0;按操作人“财务甲”返回 7 条且全部匹配;支持 `operator_id` 精确匹配。
ROLLBACK
命令:`cp docs/MODIFIED_FILE /tmp/hfb-finance-disbursement-filter/rollback-copy.go && ./docs/ROLLBACK.sh /tmp/hfb-finance-disbursement-filter/rollback-copy.go && cmp /tmp/hfb-finance-disbursement-filter/rollback-copy.go /tmp/hfb-finance-disbursement-filter/disbursement_list.go.original`
输入:修改后的 `MODIFIED_FILE` 副本。
字面输出/结果:`rollback_cmp_exit=0`;退出状态 0;副本恢复为原始 `disbursement_list.go`,`MODIFIED_FILE` 保持修改后内容(SHA-256 `fac88ec3f2137c68f79a281f24aadb2ddf80922452219716112ae45bdc24d459`)。
TESTS
命令:`go test ./...`;`npm run typecheck`
字面输出/结果:后端全量测试 PASS;前端 vue-tsc 类型检查 PASS;退出状态均为 0。
@@ -195,6 +195,9 @@ export interface FinanceDisbursementQuery extends FinanceDateQuery {
date_type?: string
business_no?: string
keyword?: string
operator?: string
operator_name?: string
operator_id?: string
user_id?: string
page?: number
page_size?: number
@@ -81,6 +81,7 @@ const filters = reactive({
status: '',
business_no: '',
keyword: '',
operator: '',
user_id: '',
})
@@ -120,6 +121,7 @@ function resetFilters() {
filters.status = ''
filters.business_no = ''
filters.keyword = ''
filters.operator = ''
filters.user_id = ''
currentPage.value = 1
void loadItems()
@@ -509,6 +511,10 @@ function accountTypeLabel(type: string) {
<el-input v-model="filters.keyword" clearable placeholder="姓名、手机号或账号" />
</el-form-item>
<el-form-item label="操作人">
<el-input v-model="filters.operator" clearable placeholder="姓名、用户名或管理员 ID" />
</el-form-item>
<el-form-item label="用户 ID">
<el-input v-model="filters.user_id" clearable placeholder="按用户 ID 检索" />
</el-form-item>