chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
@@ -0,0 +1,633 @@
package adminfinance
import (
"context"
"errors"
"sync"
ordermodule "hfb_sys/backend/internal/modules/order"
"hfb_sys/backend/internal/timeutil"
)
func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) {
if cached := r.loadDashboardCache(ctx, query); cached != nil {
return cached, nil
}
key := r.dashboardCacheKey(query)
val, err, _ := r.sfGroup.Do(key, func() (any, error) {
// 在 singleflight 内部二次检查缓存,避免排队等待的并发请求重复执行大聚合
if cached := r.loadDashboardCache(ctx, query); cached != nil {
return cached, nil
}
return r.calculateDashboard(ctx, query)
})
if err != nil {
return nil, err
}
res, ok := val.(*DashboardDTO)
if !ok {
return nil, errors.New("invalid dashboard result type")
}
return res, nil
}
func (r *Repository) calculateDashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) {
// 各板块的读取互不依赖。并行执行能避免单个请求串行等待二十余条统计 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
}
}()
}
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
}
}
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
}
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
}
// 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
}
}
paidOrders, err := r.dailyPaidOrders(ctx, query)
if err != nil {
return dashboardDailyResult{}, err
}
pickupProfits := make([]dailyPickupProfitRow, 0)
if err := db.Table("admin_pickups").
Select(`DATE_FORMAT(completed_at, '%Y-%m-%d') 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_FORMAT(completed_at, '%Y-%m-%d')").
Scan(&pickupProfits).Error; err != nil {
return dashboardDailyResult{}, err
}
pickupAdjustments := make([]dailyPickupAdjustmentRow, 0)
if err := db.Table("admin_pickup_financial_adjustments AS fa").
Select(`DATE_FORMAT(fa.created_at, '%Y-%m-%d') 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_FORMAT(fa.created_at, '%Y-%m-%d')").
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_FORMAT(created_at, '%Y-%m-%d') 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_FORMAT(created_at, '%Y-%m-%d')").
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_FORMAT(ro.settled_at, '%Y-%m-%d') 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"}).
// 统计口径:只算"正常结账确认"的订单。仲裁结案的结账单终态是 settled
// (由 dispute 模块写入),不纳入本指标,此处保持既有口径不变。
Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = ?", ordermodule.CheckoutStatusAccepted).
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_FORMAT(ro.settled_at, '%Y-%m-%d')").
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_FORMAT(po.created_at, '%Y-%m-%d') 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_FORMAT(po.created_at, '%Y-%m-%d')").
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_FORMAT(cancelled_at, '%Y-%m-%d') AS date, COUNT(id) AS count`).
Where("status = ?", "cancelled").
Where("cancelled_at >= ? AND cancelled_at <= ?", query.StartDate, query.EndDate).
Group("DATE_FORMAT(cancelled_at, '%Y-%m-%d')").
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_FORMAT(paid_at, '%Y-%m-%d') 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_FORMAT(paid_at, '%Y-%m-%d')
UNION ALL
SELECT DATE_FORMAT(completed_at, '%Y-%m-%d') AS date, COUNT(id) AS paid_order_count
FROM admin_pickups
WHERE status = 'completed' AND completed_at >= ? AND completed_at <= ?
GROUP BY DATE_FORMAT(completed_at, '%Y-%m-%d')
) 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
}
@@ -0,0 +1,41 @@
package adminfinance
import (
"context"
"encoding/json"
"fmt"
"time"
)
const dashboardCacheTTL = 5 * time.Minute
func (r *Repository) dashboardCacheKey(query DashboardQuery) string {
return fmt.Sprintf("admin-finance:dashboard:v1:%d:%d", query.StartDate.Unix(), query.EndDate.Unix())
}
func (r *Repository) loadDashboardCache(ctx context.Context, query DashboardQuery) *DashboardDTO {
if r.redis == nil {
return nil
}
raw, err := r.redis.Get(ctx, r.dashboardCacheKey(query)).Bytes()
if err != nil {
return nil
}
var value DashboardDTO
if err := json.Unmarshal(raw, &value); err != nil {
return nil
}
return &value
}
func (r *Repository) storeDashboardCache(ctx context.Context, query DashboardQuery, value *DashboardDTO) {
if r.redis == nil || value == nil {
return
}
raw, err := json.Marshal(value)
if err != nil {
return
}
// 缓存不可用时静默降级到实时统计,不能影响财务页面可用性。
_ = r.redis.Set(ctx, r.dashboardCacheKey(query), raw, dashboardCacheTTL).Err()
}
@@ -0,0 +1,318 @@
package adminfinance
import (
"sync"
"sync/atomic"
"testing"
"time"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/timeutil"
)
func TestDailyPaidOrdersUsePaidAtAndIncludeCreatedPickups(t *testing.T) {
db := database.NewTestDB()
loc := timeutil.ShanghaiLocation()
day := func(value int) time.Time { return time.Date(2026, 8, value, 10, 0, 0, 0, loc) }
rows := [][]any{
{1, "PAY-1", 101, "ORD-101", 11, "mockpay", "T-101", "order_pay", "paid", day(2)},
{2, "PAY-2", 101, "ORD-101", 11, "mockpay", "T-102", "order_pay", "paid", day(2)},
{3, "PAY-3", 102, "ORD-102", 12, "mockpay", "T-103", "order_pay", "paid", day(3)},
{4, "PAY-4", 201, "ORD-201", 21, "mockpay", "T-201", "mohong_pay", "paid", day(2)},
{5, "PAY-5", 103, "ORD-103", 13, "mockpay", "T-104", "order_pay", "created", nil},
{6, "PAY-6", 104, "ORD-104", 14, "mockpay", "T-105", "order_pay", "paid", day(1)},
}
for _, row := range rows {
if err := db.Exec(`INSERT INTO payment_orders
(id, payment_no, order_id, order_no, user_id, provider, third_order_id, biz_type, status, paid_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row...).Error; err != nil {
t.Fatalf("写入支付单失败: %v", err)
}
}
for _, row := range [][]any{
{1, "PK-1", 1, 1, 1, 1, "completed", day(2), day(2)},
{2, "PK-2", 2, 2, 2, 1, "picking_up", day(2), nil},
{3, "PK-3", 3, 3, 3, 1, "completed", day(3), day(3)},
{4, "PK-4", 4, 4, 4, 1, "cancelled", day(1), nil},
} {
if err := db.Exec(`INSERT INTO admin_pickups
(id, pickup_no, listing_id, account_id, owner_id, admin_id, status, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, row...).Error; err != nil {
t.Fatalf("写入提号记录失败: %v", err)
}
}
repo := NewRepository(db)
query := DashboardQuery{
StartDate: time.Date(2026, 8, 2, 0, 0, 0, 0, loc),
EndDate: time.Date(2026, 8, 3, 23, 59, 59, 0, loc),
}
result, err := repo.dailyPaidOrders(t.Context(), query)
if err != nil {
t.Fatalf("读取每日已付款订单失败: %v", err)
}
if len(result) != 2 {
t.Fatalf("每日已付款记录数 = %d, want 2", len(result))
}
counts := make(map[string]int64, len(result))
for _, row := range result {
counts[row.Date] = row.PaidOrderCount
}
if counts["2026-08-02"] != 2 || counts["2026-08-03"] != 2 {
t.Fatalf("每日已付款订单 = %#v, want 8月2日为2、8月3日为2", counts)
}
}
func TestDailyPaymentsNormalFullRefundExcludesMohong(t *testing.T) {
db := database.NewTestDB()
loc := timeutil.ShanghaiLocation()
day := func(value int) time.Time { return time.Date(2026, 8, value, 10, 0, 0, 0, loc) }
rows := [][]any{
// 正常订单全额退款:计入 full_refund_count 与 normal_full_refund_count
{1, "PAY-1", 101, "ORD-101", 11, "mockpay", "T-101", "rent_refund", "refunded", 5000, day(2)},
{2, "PAY-2", 101, "ORD-101", 11, "mockpay", "T-102", "order_pay", "paid", 5000, day(1)},
// 正常订单部分退款:不计入全额
{3, "PAY-3", 102, "ORD-102", 12, "mockpay", "T-103", "rent_refund", "refunded", 2000, day(2)},
{4, "PAY-4", 102, "ORD-102", 12, "mockpay", "T-104", "order_pay", "paid", 5000, day(1)},
// 撞车商城全额退款:只计入 full_refund_count,不计入 normal_full_refund_count
{5, "PAY-5", 201, "ORD-201", 21, "mockpay", "T-201", "mohong_refund", "refunded", 3000, day(2)},
{6, "PAY-6", 201, "ORD-201", 21, "mockpay", "T-202", "mohong_pay", "paid", 3000, day(1)},
}
for _, row := range rows {
if err := db.Exec(`INSERT INTO payment_orders
(id, payment_no, order_id, order_no, user_id, provider, third_order_id, biz_type, status, amount_cent, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row...).Error; err != nil {
t.Fatalf("写入支付单失败: %v", err)
}
}
repo := NewRepository(db)
query := DashboardQuery{
StartDate: time.Date(2026, 8, 2, 0, 0, 0, 0, loc),
EndDate: time.Date(2026, 8, 2, 23, 59, 59, 0, loc),
}
result, err := repo.dailyPayments(t.Context(), query)
if err != nil {
t.Fatalf("读取每日收付款失败: %v", err)
}
if len(result) != 1 {
t.Fatalf("每日收付款记录数 = %d, want 1", len(result))
}
row := result[0]
if row.FullRefundCount != 2 {
t.Fatalf("full_refund_count = %d, want 2(正常1笔 + 撞车1笔)", row.FullRefundCount)
}
if row.NormalFullRefundCount != 1 {
t.Fatalf("normal_full_refund_count = %d, want 1(仅正常订单全额退款)", row.NormalFullRefundCount)
}
if row.PartialRefundCount != 1 {
t.Fatalf("partial_refund_count = %d, want 1", row.PartialRefundCount)
}
}
func TestDailyPickupCancelledByCancelledAt(t *testing.T) {
db := database.NewTestDB()
loc := timeutil.ShanghaiLocation()
day := func(value int) time.Time { return time.Date(2026, 8, value, 15, 0, 0, 0, loc) }
for _, row := range [][]any{
{1, "PK-1", 1, 1, 1, 1, "cancelled", day(1), day(2)},
{2, "PK-2", 2, 2, 2, 1, "cancelled", day(1), day(2)},
{3, "PK-3", 3, 3, 3, 1, "cancelled", day(1), day(3)},
{4, "PK-4", 4, 4, 4, 1, "cancelled", day(1), nil}, // 取消时间缺失的不计
{5, "PK-5", 5, 5, 5, 1, "completed", day(1), day(2)},
} {
if err := db.Exec(`INSERT INTO admin_pickups
(id, pickup_no, listing_id, account_id, owner_id, admin_id, status, created_at, cancelled_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, row...).Error; err != nil {
t.Fatalf("写入提号记录失败: %v", err)
}
}
repo := NewRepository(db)
query := DashboardQuery{
StartDate: time.Date(2026, 8, 2, 0, 0, 0, 0, loc),
EndDate: time.Date(2026, 8, 2, 23, 59, 59, 0, loc),
}
result, err := repo.dailyPickupCancelled(t.Context(), query)
if err != nil {
t.Fatalf("读取每日取消提号失败: %v", err)
}
if len(result) != 1 {
t.Fatalf("每日取消提号记录数 = %d, want 1", len(result))
}
if result[0].Count != 2 {
t.Fatalf("8月2日取消提号数 = %d, want 2", result[0].Count)
}
}
func TestPickupSummaryKeepsOriginalAndAdjustmentOnTheirOwnDates(t *testing.T) {
db := database.NewTestDB()
loc := timeutil.ShanghaiLocation()
completedAt := time.Date(2026, 8, 2, 10, 0, 0, 0, loc)
adjustedAt := time.Date(2026, 8, 4, 10, 0, 0, 0, loc)
if err := db.Exec(`INSERT INTO admin_pickups
(id, pickup_no, listing_id, account_id, owner_id, admin_id, status, settle_amount_cent, profit_amount_cent,
created_at, completed_at)
VALUES (1, 'PK-1', 1, 1, 1, 1, 'completed', 12000, 1000, ?, ?)`, adjustedAt, completedAt).Error; err != nil {
t.Fatalf("写入提号失败: %v", err)
}
if err := db.Exec(`INSERT INTO admin_pickup_financial_adjustments
(id, pickup_id, settlement_mode, status, reason, created_by, settle_delta_cent, profit_delta_cent, created_at)
VALUES (1, 1, 'owner_wallet', 'settled', '测试调整', 1, 1500, 500, ?)`, adjustedAt).Error; err != nil {
t.Fatalf("写入调整失败: %v", err)
}
repo := NewRepository(db)
dayQuery := func(day int) DashboardQuery {
return DashboardQuery{
StartDate: time.Date(2026, 8, day, 0, 0, 0, 0, loc),
EndDate: time.Date(2026, 8, day, 23, 59, 59, 0, loc),
}
}
original, err := repo.pickupSummary(t.Context(), dayQuery(2))
if err != nil {
t.Fatalf("查询完成日提号汇总失败: %v", err)
}
if original.SettledAmountCent != 12000 || original.ProfitAmountCent != 1000 || original.CompletedCount != 1 {
t.Fatalf("完成日汇总 = %+v", original)
}
adjusted, err := repo.pickupSummary(t.Context(), dayQuery(4))
if err != nil {
t.Fatalf("查询调整日提号汇总失败: %v", err)
}
if adjusted.SettledAmountCent != 1500 || adjusted.ProfitAmountCent != 500 || adjusted.CompletedCount != 0 {
t.Fatalf("调整日汇总 = %+v", adjusted)
}
}
func TestSummaryReturnsNormalOrderCountAndScopedExceptionCount(t *testing.T) {
db := database.NewTestDB()
loc := timeutil.ShanghaiLocation()
settledAt := time.Date(2026, 8, 2, 10, 0, 0, 0, loc)
// ORD-S3 的结账单终态是 settled(仲裁结案),不纳入"正常结账确认"口径。
for _, row := range [][]any{
{1, "ORD-S1", 1, 1, 1, 1, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 300},
{2, "ORD-S2", 2, 2, 2, 2, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 200},
{3, "ORD-S3", 3, 3, 3, 3, settledAt, "owner_wallet", 0, "none", "none", "settled", "completed", settledAt, 0},
} {
if err := db.Exec(`INSERT INTO rental_orders
(id, order_no, listing_id, account_id, owner_id, renter_id,
settled_at, settlement_mode, offline_settlement_amount_cent, offline_settlement_status,
refund_status, settlement_status, status, created_at, platform_fee_cent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row...).Error; err != nil {
t.Fatalf("写入订单失败: %v", err)
}
}
for _, row := range [][]any{
{1, 1, 1, "accepted", 300, 700},
{2, 2, 1, "accepted", 200, 600},
{3, 3, 1, "settled", 0, 0},
} {
if err := db.Exec(`INSERT INTO order_checkouts
(id, order_id, initiated_by, status, platform_fee_cent, owner_income_amount_cent)
VALUES (?, ?, ?, ?, ?, ?)`, row...).Error; err != nil {
t.Fatalf("写入结账单失败: %v", err)
}
}
for _, row := range [][]any{
{1, "WL-1", 1, 1, "in", "available", "owner_income", "WL-1", 700},
{2, "WL-2", 2, 2, "in", "available", "owner_income", "WL-2", 500},
} {
if err := db.Exec(`INSERT INTO wallet_ledger
(id, ledger_no, order_id, user_id, direction, balance_type, biz_type, biz_no, amount_cent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, row...).Error; err != nil {
t.Fatalf("写入钱包流水失败: %v", err)
}
}
query := DashboardQuery{
StartDate: time.Date(2026, 8, 2, 0, 0, 0, 0, loc),
EndDate: time.Date(2026, 8, 2, 23, 59, 59, 0, loc),
}
repo := NewRepository(db)
pickup, err := repo.pickupSummary(t.Context(), query)
if err != nil {
t.Fatalf("读取提号汇总失败: %v", err)
}
settlements, err := repo.dailySettlements(t.Context(), query)
if err != nil {
t.Fatalf("读取每日结算汇总失败: %v", err)
}
summary := summaryWithPickup(summaryFromDailyRows(nil, settlements, nil), pickup)
// ORD-S3 的结账单终态为 settled(仲裁结案),按既有口径不计入"已结算订单数"。
if summary.SettledOrderCount != 2 || summary.NormalOrderProfitAmountCent != 500 {
t.Fatalf("普通订单数量或利润错误: %+v", summary)
}
if summary.FinancialExceptionCount != 1 {
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)
}
}
func TestDashboardCacheKeyAndSingleflight(t *testing.T) {
repo := NewRepository(nil)
q := DashboardQuery{
StartDate: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC),
EndDate: time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC),
}
key := repo.dashboardCacheKey(q)
if key == "" {
t.Fatal("cache key should not be empty")
}
var callCount atomic.Int32
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _, _ = repo.sfGroup.Do(key, func() (any, error) {
callCount.Add(1)
time.Sleep(20 * time.Millisecond)
return &DashboardDTO{}, nil
})
}()
}
wg.Wait()
if count := callCount.Load(); count != 1 {
t.Fatalf("singleflight 并发合并失败: got %d, want 1", count)
}
}
@@ -0,0 +1,133 @@
package adminfinance
import (
"context"
"gorm.io/gorm"
)
func (r *Repository) Details(ctx context.Context, query DetailQuery) (*PaginatedResult, error) {
// COUNT 只扫描 rental_orders 主表(过滤条件全部落在 ro.* 上,可命中
// idx_rental_orders_settled_at_id / created_at_id 索引),不再套完整 5-JOIN
// 子查询,避免大数据量翻页时 COUNT 拖慢。
countDB := r.applyDetailFilters(r.db.WithContext(ctx).Table("rental_orders AS ro"), query)
var total int64
if err := countDB.Count(&total).Error; err != nil {
return nil, err
}
if total == 0 {
return &PaginatedResult{Items: []FinanceDetailDTO{}, Total: 0, Page: query.Page, PageSize: query.PageSize}, nil
}
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").Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]FinanceDetailDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO())
}
return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil
}
// applyDetailFilters 把仅依赖 rental_orders 主表字段的过滤条件挂到查询上,
// COUNT 与明细行查询共用,保证两者口径一致。
func (r *Repository) applyDetailFilters(db *gorm.DB, query DetailQuery) *gorm.DB {
if query.OrderNo != "" {
db = db.Where("ro.order_no = ?", query.OrderNo)
}
if query.UserID > 0 {
db = db.Where("(ro.renter_id = ? OR ro.owner_id = ?)", query.UserID, query.UserID)
}
if query.OrderStatus != "" {
db = db.Where("ro.status = ?", query.OrderStatus)
}
if query.SettlementStatus != "" {
db = db.Where("ro.settlement_status = ?", query.SettlementStatus)
}
if query.SettlementMode != "" {
db = db.Where("ro.settlement_mode = ?", query.SettlementMode)
}
if query.OfflineSettlementStatus != "" {
db = db.Where("COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = ?", query.OfflineSettlementStatus)
}
if !query.StartDate.IsZero() && !query.EndDate.IsZero() {
switch query.DateType {
case "created":
db = db.Where("ro.created_at >= ? AND ro.created_at <= ?", query.StartDate, query.EndDate)
case "offline_settled":
db = db.Where("ro.offline_settled_at >= ? AND ro.offline_settled_at <= ?", query.StartDate, query.EndDate)
default:
db = db.Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate)
}
}
return 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,
COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') AS offline_settlement_status,
COALESCE(ro.offline_settlement_amount_cent, 0) AS offline_settlement_amount_cent,
ro.renter_id, COALESCE(ru.phone, '') AS renter_phone, COALESCE(ru.nickname, '') AS renter_nickname,
ro.owner_id, COALESCE(ou.phone, '') AS owner_phone, COALESCE(ou.nickname, '') AS owner_nickname,
ro.rent_amount_cent AS order_rent_amount_cent, ro.deposit_amount_cent AS order_deposit_amount_cent,
COALESCE(oc.rent_amount_cent, 0) AS checkout_rent_amount_cent,
COALESCE(oc.renter_refund_amount_cent, 0) AS checkout_renter_refund_cent,
COALESCE(oc.owner_income_amount_cent, 0) AS checkout_owner_income_cent,
COALESCE(oc.platform_fee_cent, 0) AS checkout_platform_fee_cent,
COALESCE(w.owner_wallet_income_amount_cent, 0) AS owner_wallet_income_amount_cent,
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 AS owner_effective_settlement_amount_cent,
COALESCE(p.paid_amount_cent, 0) AS paid_amount_cent,
COALESCE(p.refunded_amount_cent, 0) AS refunded_amount_cent,
COALESCE(p.refunding_amount_cent, 0) AS refunding_amount_cent,
COALESCE(p.failed_refund_amount_cent, 0) AS failed_refund_amount_cent,
COALESCE(p.paid_amount_cent, 0) - COALESCE(p.refunded_amount_cent, 0) AS channel_net_amount_cent,
COALESCE(oc.platform_fee_cent, 0) + (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) AS platform_net_amount_cent,
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 AS settlement_diff_amount_cent,
CASE
WHEN COALESCE(p.failed_refund_amount_cent, 0) > 0 THEN 'refund_failed'
WHEN COALESCE(p.refunding_amount_cent, 0) > 0 OR ro.refund_status = 'refunding' THEN 'refund_pending'
WHEN 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 'settlement_diff'
WHEN ro.settlement_mode = 'platform_managed'
AND COALESCE(ro.offline_settlement_amount_cent, 0) > 0
AND COALESCE(NULLIF(ro.offline_settlement_status, ''), 'none') = 'pending' THEN 'offline_settlement_pending'
ELSE 'normal'
END AS finance_status,
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", 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)
}
@@ -0,0 +1,276 @@
package adminfinance
import "context"
type dailyOperatingExpenseRow struct {
Date string
AmountCent int64
Count int64
}
func (r *Repository) dailyOperatingExpenses(ctx context.Context, query DashboardQuery) ([]dailyOperatingExpenseRow, error) {
rows := make([]dailyOperatingExpenseRow, 0)
err := r.db.WithContext(ctx).Table("operating_expenses").
Select(`DATE_FORMAT(occurred_at, '%Y-%m-%d') AS date, 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).
Group("DATE_FORMAT(occurred_at, '%Y-%m-%d')").
Scan(&rows).Error
return rows, err
}
// disbursementSummary 使用实际确认打款时间统计区间出款,同时返回不受日期限制的当前待办。
func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQuery) (*DisbursementSummaryDTO, error) {
db := r.db.WithContext(ctx)
var offlinePaid struct {
AmountCent int64
Count int64
}
if err := db.Table("rental_orders").
Select(`COALESCE(SUM(offline_settlement_amount_cent), 0) AS amount_cent, COUNT(id) AS count`).
Where("settlement_mode = ?", "platform_managed").
Where("offline_settlement_status = ?", "settled").
Where("offline_settled_at >= ? AND offline_settled_at <= ?", query.StartDate, query.EndDate).
Scan(&offlinePaid).Error; err != nil {
return nil, err
}
var pickupPaid 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 = ?", "platform_managed").
Where("offline_settlement_status = ?", "settled").
Where("offline_settled_at >= ? AND offline_settled_at <= ?", query.StartDate, query.EndDate).
Scan(&pickupPaid).Error; err != nil {
return nil, err
}
var pickupAdjustmentPaid 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 = ?", "platform_managed").
Where("status = ?", "settled").
Where("settle_delta_cent > 0").
Where("settled_at >= ? AND settled_at <= ?", query.StartDate, query.EndDate).
Scan(&pickupAdjustmentPaid).Error; err != nil {
return nil, err
}
var offlinePending struct {
AmountCent int64
Count int64
}
if err := db.Table("rental_orders").
Select(`COALESCE(SUM(offline_settlement_amount_cent), 0) AS amount_cent, COUNT(id) AS count`).
Where("settlement_mode = ?", "platform_managed").
Where("offline_settlement_status = ?", "pending").
Where("offline_settlement_amount_cent > 0").
Scan(&offlinePending).Error; err != nil {
return nil, err
}
var pickupPending 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 = ?", "platform_managed").
Where("offline_settlement_status = ?", "pending").
Where("settle_amount_cent > 0").
Scan(&pickupPending).Error; err != nil {
return nil, err
}
var pickupAdjustmentPending 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 = ?", "platform_managed").
Where("status = ?", "pending").
Where("settle_delta_cent > 0").
Scan(&pickupAdjustmentPending).Error; err != nil {
return nil, err
}
// 付款、处理中、待审核三类提现在一次条件聚合中完成,避免同一张表重复扫描。
var withdrawal struct {
PaidAmountCent int64
PaidFeeAmountCent int64
PaidActualAmountCent int64
PaidCount int64
PendingAmountCent int64
PendingCount int64
ReviewAmountCent int64
ReviewCount int64
}
if err := db.Table("withdrawal_requests").Select(`
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent,
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN fee_cent ELSE 0 END), 0) AS paid_fee_amount_cent,
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN actual_amount_cent ELSE 0 END), 0) AS paid_actual_amount_cent,
COALESCE(SUM(CASE WHEN status = 'completed' AND paid_at >= ? AND paid_at <= ? THEN 1 ELSE 0 END), 0) AS paid_count,
COALESCE(SUM(CASE WHEN status = 'processing' THEN actual_amount_cent ELSE 0 END), 0) AS pending_amount_cent,
COALESCE(SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END), 0) AS pending_count,
COALESCE(SUM(CASE WHEN status = 'pending' THEN actual_amount_cent ELSE 0 END), 0) AS review_amount_cent,
COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS review_count`,
query.StartDate, query.EndDate, query.StartDate, query.EndDate,
query.StartDate, query.EndDate, query.StartDate, query.EndDate).
Scan(&withdrawal).Error; err != nil {
return nil, err
}
var manualPaid struct {
AmountCent int64
Count int64
}
if err := db.Table("manual_disbursements").
Select(`COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count`).
Where("status = ?", "paid").
Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate).
Scan(&manualPaid).Error; err != nil {
return nil, err
}
return &DisbursementSummaryDTO{
PaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent + withdrawal.PaidActualAmountCent + manualPaid.AmountCent,
PaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count + withdrawal.PaidCount + manualPaid.Count,
PendingPaymentAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent + withdrawal.PendingAmountCent,
PendingPaymentCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count + withdrawal.PendingCount,
OfflineSettlementPaidAmountCent: offlinePaid.AmountCent + pickupPaid.AmountCent + pickupAdjustmentPaid.AmountCent,
OfflineSettlementPaidCount: offlinePaid.Count + pickupPaid.Count + pickupAdjustmentPaid.Count,
OfflineSettlementPendingAmountCent: offlinePending.AmountCent + pickupPending.AmountCent + pickupAdjustmentPending.AmountCent,
OfflineSettlementPendingCount: offlinePending.Count + pickupPending.Count + pickupAdjustmentPending.Count,
WithdrawalAmountCent: withdrawal.PaidAmountCent,
WithdrawalFeeAmountCent: withdrawal.PaidFeeAmountCent,
WithdrawalPaidAmountCent: withdrawal.PaidActualAmountCent,
WithdrawalPaidCount: withdrawal.PaidCount,
WithdrawalPendingAmountCent: withdrawal.PendingAmountCent,
WithdrawalPendingCount: withdrawal.PendingCount,
WithdrawalReviewAmountCent: withdrawal.ReviewAmountCent,
WithdrawalReviewCount: withdrawal.ReviewCount,
ManualPaidAmountCent: manualPaid.AmountCent,
ManualPaidCount: manualPaid.Count,
}, nil
}
func (r *Repository) dailyDisbursements(ctx context.Context, query DashboardQuery) ([]dailyDisbursementRow, error) {
db := r.db.WithContext(ctx)
offlineRows := make([]dailyDisbursementSourceRow, 0)
if err := db.Table("rental_orders").
Select(`DATE_FORMAT(offline_settled_at, '%Y-%m-%d') AS date,
COALESCE(SUM(offline_settlement_amount_cent), 0) AS amount_cent,
COUNT(id) AS count`).
Where("settlement_mode = ?", "platform_managed").
Where("offline_settlement_status = ?", "settled").
Where("offline_settled_at >= ? AND offline_settled_at <= ?", query.StartDate, query.EndDate).
Group("DATE_FORMAT(offline_settled_at, '%Y-%m-%d')").
Scan(&offlineRows).Error; err != nil {
return nil, err
}
pickupOfflineRows := make([]dailyDisbursementSourceRow, 0)
if err := db.Table("admin_pickups").
Select(`DATE_FORMAT(offline_settled_at, '%Y-%m-%d') AS date,
COALESCE(SUM(settle_amount_cent), 0) AS amount_cent,
COUNT(id) AS count`).
Where("settlement_mode = ?", "platform_managed").
Where("offline_settlement_status = ?", "settled").
Where("offline_settled_at >= ? AND offline_settled_at <= ?", query.StartDate, query.EndDate).
Group("DATE_FORMAT(offline_settled_at, '%Y-%m-%d')").
Scan(&pickupOfflineRows).Error; err != nil {
return nil, err
}
pickupAdjustmentRows := make([]dailyDisbursementSourceRow, 0)
if err := db.Table("admin_pickup_financial_adjustments").
Select(`DATE_FORMAT(settled_at, '%Y-%m-%d') AS date,
COALESCE(SUM(settle_delta_cent), 0) AS amount_cent,
COUNT(id) AS count`).
Where("settlement_mode = ?", "platform_managed").
Where("status = ?", "settled").
Where("settle_delta_cent > 0").
Where("settled_at >= ? AND settled_at <= ?", query.StartDate, query.EndDate).
Group("DATE_FORMAT(settled_at, '%Y-%m-%d')").
Scan(&pickupAdjustmentRows).Error; err != nil {
return nil, err
}
offlineRows = append(offlineRows, pickupOfflineRows...)
offlineRows = append(offlineRows, pickupAdjustmentRows...)
withdrawalRows := make([]dailyDisbursementSourceRow, 0)
if err := db.Table("withdrawal_requests").
Select(`DATE_FORMAT(paid_at, '%Y-%m-%d') AS date,
COALESCE(SUM(actual_amount_cent), 0) AS amount_cent,
COUNT(id) AS count`).
Where("status = ?", "completed").
Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate).
Group("DATE_FORMAT(paid_at, '%Y-%m-%d')").
Scan(&withdrawalRows).Error; err != nil {
return nil, err
}
manualRows := make([]dailyDisbursementSourceRow, 0)
if err := db.Table("manual_disbursements").
Select(`DATE_FORMAT(paid_at, '%Y-%m-%d') AS date,
COALESCE(SUM(amount_cent), 0) AS amount_cent,
COUNT(id) AS count`).
Where("status = ?", "paid").
Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate).
Group("DATE_FORMAT(paid_at, '%Y-%m-%d')").
Scan(&manualRows).Error; err != nil {
return nil, err
}
rowsByDate := make(map[string]dailyDisbursementRow, len(offlineRows)+len(withdrawalRows)+len(manualRows))
for _, row := range offlineRows {
date := dailyDateKey(row.Date)
item := rowsByDate[date]
item.Date = date
item.OfflineSettlementPaidAmountCent += row.AmountCent
item.OfflineSettlementPaidCount += row.Count
rowsByDate[date] = item
}
for _, row := range withdrawalRows {
date := dailyDateKey(row.Date)
item := rowsByDate[date]
item.Date = date
item.WithdrawalPaidAmountCent = row.AmountCent
item.WithdrawalPaidCount = row.Count
rowsByDate[date] = item
}
for _, row := range manualRows {
date := dailyDateKey(row.Date)
item := rowsByDate[date]
item.Date = date
item.ManualPaidAmountCent = row.AmountCent
item.ManualPaidCount = row.Count
rowsByDate[date] = item
}
rows := make([]dailyDisbursementRow, 0, len(rowsByDate))
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
if row, ok := rowsByDate[day.Format("2006-01-02")]; ok {
rows = append(rows, row)
}
}
return rows, nil
}
type dailyDisbursementSourceRow struct {
Date string
AmountCent int64
Count int64
}
type dailyDisbursementRow struct {
Date string
OfflineSettlementPaidAmountCent int64
OfflineSettlementPaidCount int64
WithdrawalPaidAmountCent int64
WithdrawalPaidCount int64
ManualPaidAmountCent int64
ManualPaidCount int64
}
@@ -0,0 +1,369 @@
package adminfinance
import (
"context"
"encoding/json"
"strings"
"time"
"gorm.io/gorm"
)
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
}
rows := make([]disbursementItemRow, 0, query.PageSize)
orderColumn := "business_created_at"
if query.DateType == "paid" {
orderColumn = "paid_at"
}
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)
}
}
return db
}
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
}
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)
}
}
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,
}
}
@@ -0,0 +1,179 @@
package adminfinance
import (
"testing"
"time"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/timeutil"
)
func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T) {
db := database.NewTestDB()
loc := timeutil.ShanghaiLocation()
at := func(day int) time.Time { return time.Date(2026, 7, day, 10, 0, 0, 0, loc) }
if err := db.Exec(`INSERT INTO users (id, nickname, phone, password_hash) VALUES
(1, '平台客服', 'admin:1', ''), (2, '提现用户', '13900000000', '')`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO admin_users (id, nickname, username, password_hash) VALUES
(7, '财务甲', 'finance_a', ''), (8, '财务乙', 'finance_b', '')`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO listing_uploads
(id, listing_id, uploader_name, source_channel, parsed_payload)
VALUES
(1, 101, '旧上传人', 'external_api', '{}'),
(2, 101, '上传客服甲', 'external_api', ?)`, `{"contactPhone":"13800000000"}`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO rental_orders
(id, order_no, listing_id, account_id, owner_id, renter_id, settlement_mode, offline_settlement_status,
offline_settlement_amount_cent, offline_settlement_remark, settled_at, offline_settled_at, offline_settled_by)
VALUES
(1, 'R202607010001', 101, 101, 1, 2, 'platform_managed', 'settled', 12000, '支付宝转账', ?, ?, 7),
(2, 'R202607010002', 102, 102, 1, 2, 'platform_managed', 'pending', 8000, '', ?, NULL, NULL)`,
at(2), at(3), at(4)).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO admin_pickups
(id, pickup_no, listing_id, account_id, owner_id, admin_id, source_channel, settlement_mode, settle_amount_cent,
offline_settlement_status, offline_settlement_remark, offline_settled_by, offline_settled_at, completed_at,
created_at, account_snapshot)
VALUES (30, 'P202607010001', 101, 101, 1, 7, '线下录入', 'platform_managed', 4000, 'settled', '提号结算', 7, ?, ?, ?, '{}')`,
at(4), at(3), at(2)).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO admin_pickup_financial_adjustments
(id, pickup_id, settle_delta_cent, settlement_mode, status, reason, created_by, settled_by, settled_at, created_at)
VALUES (31, 30, 500, 'platform_managed', 'settled', '补差价', 7, 7, ?, ?)`, at(5), at(5)).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO withdrawal_requests
(id, withdraw_no, user_id, account_type, account_name, account_no, bank_name,
amount_cent, fee_cent, actual_amount_cent, status, created_at, paid_at,
paid_by, reviewed_by, payment_remark, review_remark)
VALUES
(10, 'W202607010001', 2, 'bank', '张三', '****1234', '测试银行', 10000, 100, 9900, 'completed', ?, ?, 7, 7, '银行转账', ''),
(11, 'W202607010002', 2, 'alipay', '张三', '138****0000', '', 5100, 100, 5000, 'pending', ?, NULL, NULL, NULL, '', ''),
(12, 'W202607010003', 2, 'wechat', '张三', 'wx***01', '', 3000, 0, 3000, 'processing', ?, NULL, NULL, 7, '', '已审核')`,
at(5), at(6), at(7), at(8)).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO manual_disbursements
(id, disbursement_no, category, custom_category_name, payee_name, amount_cent, paid_at, remark,
voucher_url, status, created_by, voided_by, voided_at, void_reason, created_at)
VALUES
(20, 'OD202607010001', 'custom', '临时活动支出', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?),
(21, 'OD202607010002', 'other', '', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`,
at(6), at(5), at(7), at(8), at(5)).Error; err != nil {
t.Fatal(err)
}
repo := NewRepository(db)
query := DisbursementQuery{
DateType: "created",
StartDate: at(1),
EndDate: time.Date(2026, 7, 10, 23, 59, 59, 0, loc),
Page: 1,
PageSize: 20,
}
result, err := repo.Disbursements(t.Context(), query)
if err != nil {
t.Fatalf("Disbursements() error = %v", err)
}
if result.Total != 9 || len(result.Items) != 9 {
t.Fatalf("记录数 = %d/%d, want 9/9", result.Total, len(result.Items))
}
if result.Summary.PaidAmountCent != 28900 || result.Summary.PaidCount != 5 {
t.Fatalf("已出款 = %d/%d, want 28900/5", result.Summary.PaidAmountCent, result.Summary.PaidCount)
}
if result.Summary.PaymentPendingAmountCent != 11000 || result.Summary.PaymentPendingCount != 2 {
t.Fatalf("待打款 = %d/%d, want 11000/2", result.Summary.PaymentPendingAmountCent, result.Summary.PaymentPendingCount)
}
if result.Summary.ReviewPendingAmountCent != 5000 || result.Summary.ReviewPendingCount != 1 {
t.Fatalf("待审核 = %d/%d, want 5000/1", result.Summary.ReviewPendingAmountCent, result.Summary.ReviewPendingCount)
}
var platformItem *DisbursementItemDTO
for index := range result.Items {
if result.Items[index].BusinessNo == "R202607010001" {
platformItem = &result.Items[index]
break
}
}
if platformItem == nil {
t.Fatal("未查询到平台代管出款")
}
if platformItem.PayeeName != "外部卖家" || platformItem.PayeePhone != "13800000000" || platformItem.UploaderName != "上传客服甲" {
t.Fatalf("平台收款人/上传人 = %s/%s/%s", platformItem.PayeeName, platformItem.PayeePhone, platformItem.UploaderName)
}
var pickupItem *DisbursementItemDTO
for index := range result.Items {
if result.Items[index].BusinessNo == "P202607010001" {
pickupItem = &result.Items[index]
break
}
}
if pickupItem == nil || pickupItem.SourceType != "pickup" || pickupItem.ActualAmountCent != 4000 {
t.Fatalf("线下提号出款信息不正确: %+v", pickupItem)
}
var adjustmentItem *DisbursementItemDTO
for index := range result.Items {
if result.Items[index].SourceType == "pickup_adjustment" {
adjustmentItem = &result.Items[index]
break
}
}
if adjustmentItem == nil || adjustmentItem.BusinessNo != "P202607010001-ADJ-31" || adjustmentItem.ActualAmountCent != 500 {
t.Fatalf("提号调整出款信息不正确: %+v", adjustmentItem)
}
var manualItem *DisbursementItemDTO
for index := range result.Items {
if result.Items[index].BusinessNo == "OD202607010001" {
manualItem = &result.Items[index]
break
}
}
if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "custom" || manualItem.CustomCategoryName != "临时活动支出" || manualItem.OperatorName != "财务甲" {
t.Fatalf("其他线下出款信息不正确: %+v", manualItem)
}
paidWithdrawals, err := repo.Disbursements(t.Context(), DisbursementQuery{
SourceType: "withdrawal",
Status: "paid",
DateType: "paid",
StartDate: at(6),
EndDate: time.Date(2026, 7, 6, 23, 59, 59, 0, loc),
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("按打款日期查询提现失败: %v", err)
}
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)
}
}
}
@@ -0,0 +1,129 @@
package adminfinance
import (
"testing"
"time"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/timeutil"
)
func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
db := database.NewTestDB()
loc := timeutil.ShanghaiLocation()
inRangeOffline := time.Date(2026, 7, 1, 10, 0, 0, 0, loc)
inRangeWithdrawal := time.Date(2026, 7, 2, 11, 0, 0, 0, loc)
inRangeManual := time.Date(2026, 7, 3, 12, 0, 0, 0, loc)
outOfRange := time.Date(2026, 6, 30, 23, 59, 0, 0, loc)
for _, args := range [][]any{
{1, "ORD-1", 1, 1, 1, 1, "platform_managed", "settled", 12000, inRangeOffline},
{2, "ORD-2", 2, 2, 2, 2, "platform_managed", "settled", 3000, outOfRange},
{3, "ORD-3", 3, 3, 3, 3, "platform_managed", "pending", 8000, nil},
{4, "ORD-4", 4, 4, 4, 4, "platform_managed", "pending", 0, nil},
{5, "ORD-5", 5, 5, 5, 5, "owner_wallet", "pending", 9000, nil},
} {
if err := db.Exec(`INSERT INTO rental_orders
(id, order_no, listing_id, account_id, owner_id, renter_id,
settlement_mode, offline_settlement_status, offline_settlement_amount_cent, offline_settled_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, args...).Error; err != nil {
t.Fatalf("写入订单失败: %v", err)
}
}
for _, args := range [][]any{
{1, "PKR-1", 1, 1, 1, 1, "platform_managed", "settled", 4000, inRangeOffline, inRangeOffline},
{2, "PKR-2", 2, 2, 2, 2, "platform_managed", "pending", 2500, nil, inRangeOffline},
} {
if err := db.Exec(`INSERT INTO admin_pickups
(id, pickup_no, listing_id, account_id, owner_id, admin_id, settlement_mode,
offline_settlement_status, settle_amount_cent, offline_settled_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, args...).Error; err != nil {
t.Fatalf("写入提号失败: %v", err)
}
}
for _, args := range [][]any{
{1, 1, "platform_managed", "settled", "补差价", 1, 500, inRangeManual, inRangeManual},
{2, 2, "platform_managed", "pending", "补差价", 1, 300, nil, inRangeManual},
{3, 3, "platform_managed", "recovery_pending", "补差价", 1, -200, nil, inRangeManual},
} {
if err := db.Exec(`INSERT INTO admin_pickup_financial_adjustments
(id, pickup_id, settlement_mode, status, reason, created_by, settle_delta_cent, settled_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, args...).Error; err != nil {
t.Fatalf("写入提号调整失败: %v", err)
}
}
for _, args := range [][]any{
{1, "MD-1", "推广投放", "测试收款方", "测试备注", 1, "paid", 2500, inRangeManual},
{2, "MD-2", "推广投放", "测试收款方", "测试备注", 1, "paid", 1000, outOfRange},
{3, "MD-3", "推广投放", "测试收款方", "测试备注", 1, "voided", 6000, inRangeManual},
} {
if err := db.Exec(`INSERT INTO manual_disbursements
(id, disbursement_no, category, payee_name, remark, created_by, status, amount_cent, paid_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, args...).Error; err != nil {
t.Fatalf("写入其他线下出款失败: %v", err)
}
}
for _, args := range [][]any{
{1, "WD-1", 1, "alipay", "测试户名", "test@example.com", "completed", 10000, 100, 9900, inRangeWithdrawal},
{2, "WD-2", 1, "alipay", "测试户名", "test@example.com", "completed", 5000, 0, 5000, outOfRange},
{3, "WD-3", 1, "alipay", "测试户名", "test@example.com", "processing", 5000, 200, 4800, nil},
{4, "WD-4", 1, "alipay", "测试户名", "test@example.com", "pending", 2100, 100, 2000, nil},
{5, "WD-5", 1, "alipay", "测试户名", "test@example.com", "rejected", 6000, 100, 5900, nil},
} {
if err := db.Exec(`INSERT INTO withdrawal_requests
(id, withdraw_no, user_id, account_type, account_name, account_no,
status, amount_cent, fee_cent, actual_amount_cent, paid_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, args...).Error; err != nil {
t.Fatalf("写入提现单失败: %v", err)
}
}
query := DashboardQuery{
StartDate: time.Date(2026, 7, 1, 0, 0, 0, 0, loc),
EndDate: time.Date(2026, 7, 3, 23, 59, 59, 0, loc),
}
repo := NewRepository(db)
summary, err := repo.disbursementSummary(t.Context(), query)
if err != nil {
t.Fatalf("disbursementSummary() error = %v", err)
}
if summary.PaidAmountCent != 28900 || summary.PaidCount != 5 {
t.Fatalf("实际出款 = %d/%d, want 28900/5", summary.PaidAmountCent, summary.PaidCount)
}
if summary.OfflineSettlementPaidAmountCent != 16500 || summary.OfflineSettlementPaidCount != 3 {
t.Fatalf("代管打款 = %d/%d, want 16500/3", summary.OfflineSettlementPaidAmountCent, summary.OfflineSettlementPaidCount)
}
if summary.WithdrawalAmountCent != 10000 || summary.WithdrawalFeeAmountCent != 100 || summary.WithdrawalPaidAmountCent != 9900 {
t.Fatalf("提现申请/手续费/实付 = %d/%d/%d, want 10000/100/9900", summary.WithdrawalAmountCent, summary.WithdrawalFeeAmountCent, summary.WithdrawalPaidAmountCent)
}
if summary.ManualPaidAmountCent != 2500 || summary.ManualPaidCount != 1 {
t.Fatalf("其他线下出款 = %d/%d, want 2500/1", summary.ManualPaidAmountCent, summary.ManualPaidCount)
}
if summary.PendingPaymentAmountCent != 15600 || summary.PendingPaymentCount != 4 {
t.Fatalf("待出款 = %d/%d, want 15600/4", summary.PendingPaymentAmountCent, summary.PendingPaymentCount)
}
if summary.OfflineSettlementPendingAmountCent != 10800 || summary.OfflineSettlementPendingCount != 3 {
t.Fatalf("线下结算待出款 = %d/%d, want 10800/3", summary.OfflineSettlementPendingAmountCent, summary.OfflineSettlementPendingCount)
}
if summary.WithdrawalReviewAmountCent != 2000 || summary.WithdrawalReviewCount != 1 {
t.Fatalf("待审核提现 = %d/%d, want 2000/1", summary.WithdrawalReviewAmountCent, summary.WithdrawalReviewCount)
}
daily, err := repo.dailyDisbursements(t.Context(), query)
if err != nil {
t.Fatalf("dailyDisbursements() error = %v", err)
}
if len(daily) != 3 {
t.Fatalf("每日出款行数 = %d, want 3", len(daily))
}
if daily[0].Date != "2026-07-01" || daily[0].OfflineSettlementPaidAmountCent != 16000 {
t.Fatalf("第一日代管出款 = %+v", daily[0])
}
if daily[1].Date != "2026-07-02" || daily[1].WithdrawalPaidAmountCent != 9900 {
t.Fatalf("第二日提现出款 = %+v", daily[1])
}
if daily[2].Date != "2026-07-03" || daily[2].ManualPaidAmountCent != 2500 || daily[2].OfflineSettlementPaidAmountCent != 500 {
t.Fatalf("第三日其他线下出款 = %+v", daily[2])
}
}
@@ -0,0 +1,351 @@
package adminfinance
import "time"
type DashboardQuery struct {
StartDate time.Time
EndDate time.Time
}
type DetailQuery struct {
OrderNo string
UserID uint64
OrderStatus string
SettlementStatus string
SettlementMode string
OfflineSettlementStatus string
DateType string
StartDate time.Time
EndDate time.Time
Page int
PageSize int
}
type DisbursementQuery struct {
SourceType string
Status string
DateType string
BusinessNo string
Keyword string
Operator string
OperatorID uint64
UserID uint64
StartDate time.Time
EndDate time.Time
Page int
PageSize int
}
type OperatingExpenseQuery struct {
Status string
Category string
Keyword string
DateType string
StartDate time.Time
EndDate time.Time
Page int
PageSize int
}
type DashboardDTO struct {
Summary FinanceSummaryDTO `json:"summary"`
DailyItems []FinanceDailyDTO `json:"daily_items"`
PickupSummary PickupSummaryDTO `json:"pickup_summary"`
MohongSummary MohongSummaryDTO `json:"mohong_summary"`
DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"`
OperatingExpenseSummary OperatingExpenseSummaryDTO `json:"operating_expense_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"` // 当前提号中笔数(不按时间)
PendingAmountCent int64 `json:"pending_amount_cent"` // 当前平台代管提号待结算金额(含正向调整)
PendingCount int64 `json:"pending_count"` // 当前平台代管提号待结算笔数(含正向调整)
}
// MohongSummaryDTO 撞车商城的交易流水,单独展示,不计入平台利润。
type MohongSummaryDTO struct {
FlowAmountCent int64 `json:"flow_amount_cent"`
RefundAmountCent int64 `json:"refund_amount_cent"`
NetAmountCent int64 `json:"net_amount_cent"`
PaidOrderCount int64 `json:"paid_order_count"`
}
// DisbursementSummaryDTO 统计平台实际线下出款及当前待处理金额。
// 已打款按确认打款时间归入查询区间,待处理不受日期范围限制,反映当前资金待办。
type DisbursementSummaryDTO struct {
PaidAmountCent int64 `json:"paid_amount_cent"`
PaidCount int64 `json:"paid_count"`
PendingPaymentAmountCent int64 `json:"pending_payment_amount_cent"`
PendingPaymentCount int64 `json:"pending_payment_count"`
OfflineSettlementPaidAmountCent int64 `json:"offline_settlement_paid_amount_cent"`
OfflineSettlementPaidCount int64 `json:"offline_settlement_paid_count"`
OfflineSettlementPendingAmountCent int64 `json:"offline_settlement_pending_amount_cent"`
OfflineSettlementPendingCount int64 `json:"offline_settlement_pending_count"`
WithdrawalAmountCent int64 `json:"withdrawal_amount_cent"`
WithdrawalFeeAmountCent int64 `json:"withdrawal_fee_amount_cent"`
WithdrawalPaidAmountCent int64 `json:"withdrawal_paid_amount_cent"`
WithdrawalPaidCount int64 `json:"withdrawal_paid_count"`
WithdrawalPendingAmountCent int64 `json:"withdrawal_pending_amount_cent"`
WithdrawalPendingCount int64 `json:"withdrawal_pending_count"`
WithdrawalReviewAmountCent int64 `json:"withdrawal_review_amount_cent"`
WithdrawalReviewCount int64 `json:"withdrawal_review_count"`
ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"`
ManualPaidCount int64 `json:"manual_paid_count"`
}
type DisbursementListDTO struct {
Items []DisbursementItemDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Summary DisbursementListSummaryDTO `json:"summary"`
}
type DisbursementListSummaryDTO struct {
RecordCount int64 `json:"record_count"`
PaidAmountCent int64 `json:"paid_amount_cent"`
PaidCount int64 `json:"paid_count"`
PaymentPendingAmountCent int64 `json:"payment_pending_amount_cent"`
PaymentPendingCount int64 `json:"payment_pending_count"`
ReviewPendingAmountCent int64 `json:"review_pending_amount_cent"`
ReviewPendingCount int64 `json:"review_pending_count"`
WithdrawalFeeAmountCent int64 `json:"withdrawal_fee_amount_cent"`
}
type OperatingExpenseSummaryDTO struct {
AmountCent int64 `json:"amount_cent"`
Count int64 `json:"count"`
}
type OperatingExpenseListSummaryDTO struct {
RecordCount int64 `json:"record_count"`
PaidAmountCent int64 `json:"paid_amount_cent"`
PaidCount int64 `json:"paid_count"`
}
type OperatingExpenseListDTO struct {
Items []OperatingExpenseDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Summary OperatingExpenseListSummaryDTO `json:"summary"`
}
type DisbursementItemDTO struct {
SourceType string `json:"source_type"`
SourceID uint64 `json:"source_id"`
BusinessNo string `json:"business_no"`
OrderID uint64 `json:"order_id,omitempty"`
WithdrawalID uint64 `json:"withdrawal_id,omitempty"`
UserID uint64 `json:"user_id"`
PayeeName string `json:"payee_name"`
PayeePhone string `json:"payee_phone"`
UploaderName string `json:"uploader_name"`
SourceChannel string `json:"source_channel"`
AccountType string `json:"account_type"`
AccountName string `json:"account_name"`
AccountNo string `json:"account_no"`
BankName string `json:"bank_name"`
AmountCent int64 `json:"amount_cent"`
FeeCent int64 `json:"fee_cent"`
ActualAmountCent int64 `json:"actual_amount_cent"`
Status string `json:"status"`
RawStatus string `json:"raw_status"`
BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"`
PaidAt *time.Time `json:"paid_at,omitempty"`
OperatorID *uint64 `json:"operator_id,omitempty"`
OperatorName string `json:"operator_name"`
Remark string `json:"remark"`
Category string `json:"category"`
CustomCategoryName string `json:"custom_category_name"`
VoucherURL string `json:"voucher_url"`
VoidedAt *time.Time `json:"voided_at,omitempty"`
VoidedBy *uint64 `json:"voided_by,omitempty"`
VoidedByName string `json:"voided_by_name"`
VoidReason string `json:"void_reason"`
}
type CreateManualDisbursementRequest struct {
Category string `json:"category"`
CustomCategoryName string `json:"custom_category_name"`
PayeeName string `json:"payee_name"`
AmountCent int64 `json:"amount_cent"`
PaidAt time.Time `json:"paid_at"`
Remark string `json:"remark"`
VoucherURL string `json:"voucher_url"`
}
type VoidManualDisbursementRequest struct {
Reason string `json:"reason"`
}
type ManualDisbursementDTO struct {
ID uint64 `json:"id"`
DisbursementNo string `json:"disbursement_no"`
Category string `json:"category"`
CustomCategoryName string `json:"custom_category_name"`
PayeeName string `json:"payee_name"`
AmountCent int64 `json:"amount_cent"`
PaidAt time.Time `json:"paid_at"`
Remark string `json:"remark"`
VoucherURL string `json:"voucher_url"`
Status string `json:"status"`
CreatedBy uint64 `json:"created_by"`
CreatedByName string `json:"created_by_name"`
VoidedBy *uint64 `json:"voided_by,omitempty"`
VoidedByName string `json:"voided_by_name"`
VoidedAt *time.Time `json:"voided_at,omitempty"`
VoidReason string `json:"void_reason"`
CreatedAt time.Time `json:"created_at"`
}
type CreateOperatingExpenseRequest struct {
Category string `json:"category"`
PayeeName string `json:"payee_name"`
AmountCent int64 `json:"amount_cent"`
OccurredAt time.Time `json:"occurred_at"`
Remark string `json:"remark"`
VoucherURL string `json:"voucher_url"`
}
type VoidOperatingExpenseRequest struct {
Reason string `json:"reason"`
}
type OperatingExpenseDTO struct {
ID uint64 `json:"id"`
ExpenseNo string `json:"expense_no"`
Category string `json:"category"`
PayeeName string `json:"payee_name"`
AmountCent int64 `json:"amount_cent"`
OccurredAt time.Time `json:"occurred_at"`
Remark string `json:"remark"`
VoucherURL string `json:"voucher_url"`
Status string `json:"status"`
CreatedBy uint64 `json:"created_by"`
CreatedByName string `json:"created_by_name"`
VoidedBy *uint64 `json:"voided_by,omitempty"`
VoidedByName string `json:"voided_by_name"`
VoidedAt *time.Time `json:"voided_at,omitempty"`
VoidReason string `json:"void_reason"`
CreatedAt time.Time `json:"created_at"`
}
type FinanceSummaryDTO struct {
TotalFlowAmountCent int64 `json:"total_flow_amount_cent"`
TotalRefundAmountCent int64 `json:"total_refund_amount_cent"`
PendingRefundAmountCent int64 `json:"pending_refund_amount_cent"`
ChannelNetAmountCent int64 `json:"channel_net_amount_cent"`
// PlatformIncomeAmountCent 为正常订单最终结账利润与已完成线下提号利润之和。
// 保留原 JSON 字段名,避免影响已接入的管理端客户端。
PlatformIncomeAmountCent int64 `json:"platform_income_amount_cent"`
NormalOrderProfitAmountCent int64 `json:"normal_order_profit_amount_cent"`
PickupProfitAmountCent int64 `json:"pickup_profit_amount_cent"`
// PickupCompletedCount 线下提号利润对应的已完成提号笔数。
PickupCompletedCount int64 `json:"pickup_completed_count"`
OwnerShouldIncomeAmountCent int64 `json:"owner_should_income_amount_cent"`
OwnerWalletIncomeAmountCent int64 `json:"owner_wallet_income_amount_cent"`
OwnerEffectiveSettlementAmountCent int64 `json:"owner_effective_settlement_amount_cent"`
OfflineSettlementAmountCent int64 `json:"offline_settlement_amount_cent"`
OfflineSettlementPendingAmountCent int64 `json:"offline_settlement_pending_amount_cent"`
SettlementDiffAmountCent int64 `json:"settlement_diff_amount_cent"`
EstimatedIncomeAmountCent int64 `json:"estimated_income_amount_cent"`
SuccessfulPayCount int64 `json:"successful_pay_count"`
SuccessfulRefundCount int64 `json:"successful_refund_count"`
FullRefundCount int64 `json:"full_refund_count"`
PartialRefundCount int64 `json:"partial_refund_count"`
PendingRefundCount int64 `json:"pending_refund_count"`
SettledOrderCount int64 `json:"settled_order_count"`
PendingSettleOrderCount int64 `json:"pending_settle_order_count"`
OfflineSettlementPendingCount int64 `json:"offline_settlement_pending_count"`
FinancialExceptionCount int64 `json:"financial_exception_count"`
}
type FinanceDailyDTO struct {
Date string `json:"date"`
TotalFlowAmountCent int64 `json:"total_flow_amount_cent"`
TotalRefundAmountCent int64 `json:"total_refund_amount_cent"`
PendingRefundAmountCent int64 `json:"pending_refund_amount_cent"`
ChannelNetAmountCent int64 `json:"channel_net_amount_cent"`
PlatformIncomeAmountCent int64 `json:"platform_income_amount_cent"`
OwnerShouldIncomeAmountCent int64 `json:"owner_should_income_amount_cent"`
OwnerWalletIncomeAmountCent int64 `json:"owner_wallet_income_amount_cent"`
OwnerEffectiveSettlementAmountCent int64 `json:"owner_effective_settlement_amount_cent"`
OfflineSettlementAmountCent int64 `json:"offline_settlement_amount_cent"`
OfflineSettlementPendingAmountCent int64 `json:"offline_settlement_pending_amount_cent"`
SettlementDiffAmountCent int64 `json:"settlement_diff_amount_cent"`
EstimatedIncomeAmountCent int64 `json:"estimated_income_amount_cent"`
SuccessfulPayCount int64 `json:"successful_pay_count"`
SuccessfulRefundCount int64 `json:"successful_refund_count"`
FullRefundCount int64 `json:"full_refund_count"`
PartialRefundCount int64 `json:"partial_refund_count"`
PendingRefundCount int64 `json:"pending_refund_count"`
SettledOrderCount int64 `json:"settled_order_count"`
PickupCompletedCount int64 `json:"pickup_completed_count"`
// NormalFullRefundCount 正常(租赁)订单已退全款笔数(不含撞车商城退款)。
NormalFullRefundCount int64 `json:"normal_full_refund_count"`
// PickupCancelledCount 线下提号取消笔数(按取消日归集)。
PickupCancelledCount int64 `json:"pickup_cancelled_count"`
SalesCount int64 `json:"sales_count"`
PaidOrderCount int64 `json:"paid_order_count"`
OfflineSettlementPendingCount int64 `json:"offline_settlement_pending_count"`
OfflineSettlementPaidAmountCent int64 `json:"offline_settlement_paid_amount_cent"`
OfflineSettlementPaidCount int64 `json:"offline_settlement_paid_count"`
WithdrawalPaidAmountCent int64 `json:"withdrawal_paid_amount_cent"`
WithdrawalPaidCount int64 `json:"withdrawal_paid_count"`
ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"`
ManualPaidCount int64 `json:"manual_paid_count"`
OperatingExpenseAmountCent int64 `json:"operating_expense_amount_cent"`
OperatingExpenseCount int64 `json:"operating_expense_count"`
DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"`
DisbursementPaidCount int64 `json:"disbursement_paid_count"`
}
type PaginatedResult struct {
Items interface{} `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type FinanceDetailDTO struct {
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
OrderStatus string `json:"order_status"`
SettlementStatus string `json:"settlement_status"`
RefundStatus string `json:"refund_status"`
HandoffMode string `json:"handoff_mode"`
SettlementMode string `json:"settlement_mode"`
OfflineSettlementStatus string `json:"offline_settlement_status"`
OfflineSettlementAmountCent int64 `json:"offline_settlement_amount_cent"`
RenterID uint64 `json:"renter_id"`
RenterPhone string `json:"renter_phone"`
RenterNickname string `json:"renter_nickname"`
OwnerID uint64 `json:"owner_id"`
OwnerPhone string `json:"owner_phone"`
OwnerNickname string `json:"owner_nickname"`
OrderRentAmountCent int64 `json:"order_rent_amount_cent"`
OrderDepositAmountCent int64 `json:"order_deposit_amount_cent"`
CheckoutRentAmountCent int64 `json:"checkout_rent_amount_cent"`
CheckoutRenterRefundCent int64 `json:"checkout_renter_refund_cent"`
CheckoutOwnerIncomeCent int64 `json:"checkout_owner_income_cent"`
CheckoutPlatformFeeCent int64 `json:"checkout_platform_fee_cent"`
OwnerWalletIncomeAmountCent int64 `json:"owner_wallet_income_amount_cent"`
OwnerEffectiveSettlementAmountCent int64 `json:"owner_effective_settlement_amount_cent"`
PaidAmountCent int64 `json:"paid_amount_cent"`
RefundedAmountCent int64 `json:"refunded_amount_cent"`
RefundingAmountCent int64 `json:"refunding_amount_cent"`
FailedRefundAmountCent int64 `json:"failed_refund_amount_cent"`
ChannelNetAmountCent int64 `json:"channel_net_amount_cent"`
PlatformNetAmountCent int64 `json:"platform_net_amount_cent"`
SettlementDiffAmountCent int64 `json:"settlement_diff_amount_cent"`
FinanceStatus string `json:"finance_status"`
CreatedAt time.Time `json:"created_at"`
SettledAt *time.Time `json:"settled_at,omitempty"`
OfflineSettledAt *time.Time `json:"offline_settled_at,omitempty"`
}
@@ -0,0 +1,428 @@
package adminfinance
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/timeutil"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) Dashboard(c *gin.Context) {
query, ok := parseDashboardQuery(c)
if !ok {
return
}
item, err := h.service.Dashboard(c.Request.Context(), query)
if err != nil {
writeFinanceError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) Details(c *gin.Context) {
query, ok := parseDetailQuery(c)
if !ok {
return
}
result, err := h.service.Details(c.Request.Context(), query)
if err != nil {
writeFinanceError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) Disbursements(c *gin.Context) {
query, ok := parseDisbursementQuery(c)
if !ok {
return
}
result, err := h.service.Disbursements(c.Request.Context(), query)
if err != nil {
writeFinanceError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) OperatingExpenses(c *gin.Context) {
query, ok := parseOperatingExpenseQuery(c)
if !ok {
return
}
result, err := h.service.OperatingExpenses(c.Request.Context(), query)
if err != nil {
writeFinanceError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) CreateManualDisbursement(c *gin.Context) {
adminID, ok := financeAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req CreateManualDisbursementRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "线下出款信息不正确")
return
}
item, err := h.service.CreateManualDisbursement(c.Request.Context(), req, adminID, financeAuditMeta(c))
if err != nil {
writeFinanceError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) VoidManualDisbursement(c *gin.Context) {
adminID, ok := financeAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "线下出款记录 ID 不正确")
return
}
var req VoidManualDisbursementRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请填写作废原因")
return
}
item, err := h.service.VoidManualDisbursement(c.Request.Context(), id, req.Reason, adminID, financeAuditMeta(c))
if err != nil {
writeFinanceError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) CreateOperatingExpense(c *gin.Context) {
adminID, ok := financeAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req CreateOperatingExpenseRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "运营开支信息不正确")
return
}
item, err := h.service.CreateOperatingExpense(c.Request.Context(), req, adminID, financeAuditMeta(c))
if err != nil {
writeFinanceError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) VoidOperatingExpense(c *gin.Context) {
adminID, ok := financeAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "运营开支记录 ID 不正确")
return
}
var req VoidOperatingExpenseRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请填写作废原因")
return
}
item, err := h.service.VoidOperatingExpense(c.Request.Context(), id, req.Reason, adminID, financeAuditMeta(c))
if err != nil {
writeFinanceError(c, err)
return
}
response.OK(c, item)
}
func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) {
start, end, ok := parseDateRange(c, 6)
if !ok {
return DashboardQuery{}, false
}
return DashboardQuery{StartDate: start, EndDate: end}, true
}
func parseDetailQuery(c *gin.Context) (DetailQuery, bool) {
start, end, ok := parseDateRange(c, 29)
if !ok {
return DetailQuery{}, false
}
query := DetailQuery{
OrderNo: c.Query("order_no"),
OrderStatus: c.Query("order_status"),
SettlementStatus: c.Query("settlement_status"),
SettlementMode: c.Query("settlement_mode"),
OfflineSettlementStatus: c.Query("offline_settlement_status"),
DateType: c.DefaultQuery("date_type", "settled"),
StartDate: start,
EndDate: end,
}
if query.DateType != "created" && query.DateType != "settled" && query.DateType != "offline_settled" {
response.BadRequest(c, "日期类型不正确")
return query, false
}
if query.SettlementMode != "" && query.SettlementMode != "owner_wallet" && query.SettlementMode != "platform_managed" {
response.BadRequest(c, "结算模式不正确")
return query, false
}
if query.OfflineSettlementStatus != "" && query.OfflineSettlementStatus != "none" && query.OfflineSettlementStatus != "pending" && query.OfflineSettlementStatus != "settled" {
response.BadRequest(c, "线下结算状态不正确")
return query, false
}
if raw := c.Query("user_id"); raw != "" {
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil || value == 0 {
response.BadRequest(c, "用户 ID 不正确")
return query, false
}
query.UserID = value
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query.Page = page
query.PageSize = pageSize
return query, true
}
func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
start, end, ok := parseDateRange(c, 29)
if !ok {
return DisbursementQuery{}, false
}
query := DisbursementQuery{
SourceType: strings.TrimSpace(c.Query("source_type")),
Status: strings.TrimSpace(c.Query("status")),
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
}
if query.Status != "" && query.Status != "review_pending" && query.Status != "payment_pending" && query.Status != "paid" && query.Status != "rejected" && query.Status != "cancelled" && query.Status != "voided" {
response.BadRequest(c, "出款状态不正确")
return query, false
}
if query.DateType != "created" && query.DateType != "paid" {
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 {
response.BadRequest(c, "用户 ID 不正确")
return query, false
}
query.UserID = value
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query.Page = page
query.PageSize = pageSize
return query, true
}
func parseOperatingExpenseQuery(c *gin.Context) (OperatingExpenseQuery, bool) {
start, end, ok := parseDateRange(c, 29)
if !ok {
return OperatingExpenseQuery{}, false
}
query := OperatingExpenseQuery{
Status: strings.TrimSpace(c.Query("status")),
Category: strings.TrimSpace(c.Query("category")),
Keyword: strings.TrimSpace(c.Query("keyword")),
DateType: c.DefaultQuery("date_type", "created"),
StartDate: start,
EndDate: end,
}
if query.Status != "" && query.Status != "paid" && query.Status != "voided" {
response.BadRequest(c, "运营开支状态不正确")
return query, false
}
if query.DateType != "created" && query.DateType != "occurred" {
response.BadRequest(c, "日期类型不正确")
return query, false
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query.Page = page
query.PageSize = pageSize
return query, true
}
func parseFlexibleDateTime(raw string, loc *time.Location, isEnd bool) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, errors.New("empty time string")
}
formats := []string{
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006-01-02T15:04:05",
"2006-01-02T15:04",
time.RFC3339,
"2006-01-02",
}
for _, fmtStr := range formats {
if t, err := time.ParseInLocation(fmtStr, raw, loc); err == nil {
switch fmtStr {
case "2006-01-02":
if isEnd {
return t.AddDate(0, 0, 1).Add(-time.Nanosecond), nil
}
return t, nil
case "2006-01-02 15:04", "2006-01-02T15:04":
if isEnd {
return t.Add(time.Minute).Add(-time.Nanosecond), nil
}
return t, nil
case "2006-01-02 15:04:05", "2006-01-02T15:04:05":
if isEnd {
return t.Add(time.Second - time.Nanosecond), nil
}
return t, nil
default:
return t, nil
}
}
}
return time.Time{}, fmt.Errorf("invalid time format: %s", raw)
}
func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) {
loc := timeutil.ShanghaiLocation()
today := timeutil.ShanghaiNow()
defaultStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -defaultLookbackDays)
defaultEnd := time.Date(today.Year(), today.Month(), today.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), loc)
start := defaultStart
end := defaultEnd
rawStart := strings.TrimSpace(c.Query("start_time"))
usingStartTime := rawStart != ""
if rawStart == "" {
rawStart = strings.TrimSpace(c.Query("start_date"))
}
if rawStart != "" {
parsed, err := parseFlexibleDateTime(rawStart, loc, false)
if err != nil {
if usingStartTime {
response.BadRequest(c, "开始时间不正确")
} else {
response.BadRequest(c, "开始日期不正确")
}
return start, end, false
}
start = parsed
}
rawEnd := strings.TrimSpace(c.Query("end_time"))
usingEndTime := rawEnd != ""
if rawEnd == "" {
rawEnd = strings.TrimSpace(c.Query("end_date"))
}
if rawEnd != "" {
parsed, err := parseFlexibleDateTime(rawEnd, loc, true)
if err != nil {
if usingEndTime {
response.BadRequest(c, "结束时间不正确")
} else {
response.BadRequest(c, "结束日期不正确")
}
return start, end, false
}
end = parsed
}
if start.After(end) {
if usingStartTime || usingEndTime {
response.BadRequest(c, "开始时间不能晚于结束时间")
} else {
response.BadRequest(c, "开始日期不能晚于结束日期")
}
return start, end, false
}
return start, end, true
}
func writeFinanceError(c *gin.Context, err error) {
response.RecordError(c, err)
switch {
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接")
case errors.Is(err, ErrInvalidManualDisbursement):
response.BadRequest(c, "线下出款信息不正确")
case errors.Is(err, ErrManualDisbursementNotFound):
response.NotFound(c, "线下出款记录不存在")
case errors.Is(err, ErrManualDisbursementNotPaid):
response.BadRequest(c, "该线下出款记录已作废")
case errors.Is(err, ErrInvalidOperatingExpense):
response.BadRequest(c, "运营开支信息不正确")
case errors.Is(err, ErrOperatingExpenseNotFound):
response.NotFound(c, "运营开支记录不存在")
case errors.Is(err, ErrOperatingExpenseNotPaid):
response.BadRequest(c, "该运营开支记录已作废")
default:
response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用")
}
}
func financeAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
return 0, false
}
adminID, ok := value.(uint64)
return adminID, ok
}
func financeAuditMeta(c *gin.Context) auditlog.Meta {
return auditlog.Meta{
IP: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
RequestID: middleware.GetRequestID(c),
}
}
@@ -0,0 +1,142 @@
package adminfinance
import (
"net/http"
"net/http/httptest"
"testing"
"hfb_sys/backend/internal/timeutil"
"github.com/gin-gonic/gin"
)
func TestParseFlexibleDateTime(t *testing.T) {
loc := timeutil.ShanghaiLocation()
// 1. Test YYYY-MM-DD
start, err := parseFlexibleDateTime("2026-09-01", loc, false)
if err != nil {
t.Fatalf("unexpected error for date start: %v", err)
}
if start.Year() != 2026 || start.Month() != 9 || start.Day() != 1 || start.Hour() != 0 || start.Minute() != 0 || start.Second() != 0 {
t.Fatalf("unexpected start date: %v", start)
}
end, err := parseFlexibleDateTime("2026-09-01", loc, true)
if err != nil {
t.Fatalf("unexpected error for date end: %v", err)
}
if end.Year() != 2026 || end.Month() != 9 || end.Day() != 1 || end.Hour() != 23 || end.Minute() != 59 || end.Second() != 59 {
t.Fatalf("unexpected end date: %v", end)
}
// 2. Test YYYY-MM-DD HH:mm
startMinute, err := parseFlexibleDateTime("2026-09-01 14:30", loc, false)
if err != nil {
t.Fatalf("unexpected error for minute start: %v", err)
}
if startMinute.Hour() != 14 || startMinute.Minute() != 30 || startMinute.Second() != 0 {
t.Fatalf("unexpected start minute: %v", startMinute)
}
endMinute, err := parseFlexibleDateTime("2026-09-01 14:30", loc, true)
if err != nil {
t.Fatalf("unexpected error for minute end: %v", err)
}
if endMinute.Hour() != 14 || endMinute.Minute() != 30 || endMinute.Second() != 59 {
t.Fatalf("unexpected end minute: %v", endMinute)
}
// 3. Test YYYY-MM-DD HH:mm:ss
startSec, err := parseFlexibleDateTime("2026-09-01 14:30:15", loc, false)
if err != nil {
t.Fatalf("unexpected error for second start: %v", err)
}
if startSec.Hour() != 14 || startSec.Minute() != 30 || startSec.Second() != 15 {
t.Fatalf("unexpected start sec: %v", startSec)
}
endSec, err := parseFlexibleDateTime("2026-09-01 14:30:15", loc, true)
if err != nil {
t.Fatalf("unexpected error for second end: %v", err)
}
if endSec.Hour() != 14 || endSec.Minute() != 30 || endSec.Second() != 15 {
t.Fatalf("unexpected end sec: %v", endSec)
}
// 4. Test invalid
if _, err := parseFlexibleDateTime("invalid-date", loc, false); err == nil {
t.Fatalf("expected error for invalid date")
}
}
func TestParseDateRangeGin(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
query string
wantStatus int
wantOk bool
}{
{
name: "default range",
query: "",
wantStatus: http.StatusOK,
wantOk: true,
},
{
name: "date range",
query: "?start_date=2026-09-01&end_date=2026-09-07",
wantStatus: http.StatusOK,
wantOk: true,
},
{
name: "datetime range with minute",
query: "?start_date=2026-09-01%2009:30&end_date=2026-09-07%2018:45",
wantStatus: http.StatusOK,
wantOk: true,
},
{
name: "datetime range with second",
query: "?start_date=2026-09-01%2009:30:00&end_date=2026-09-07%2018:45:59",
wantStatus: http.StatusOK,
wantOk: true,
},
{
name: "start_time and end_time params",
query: "?start_time=2026-09-01%2009:30:00&end_time=2026-09-07%2018:45:59",
wantStatus: http.StatusOK,
wantOk: true,
},
{
name: "start after end",
query: "?start_date=2026-09-07&end_date=2026-09-01",
wantStatus: http.StatusBadRequest,
wantOk: false,
},
{
name: "invalid date",
query: "?start_date=invalid",
wantStatus: http.StatusBadRequest,
wantOk: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/test"+tc.query, nil)
c.Request = req
_, _, ok := parseDateRange(c, 29)
if ok != tc.wantOk {
t.Fatalf("parseDateRange() ok = %v, wantOk = %v", ok, tc.wantOk)
}
if !ok && w.Code != tc.wantStatus {
t.Fatalf("HTTP status = %d, wantStatus = %d", w.Code, tc.wantStatus)
}
})
}
}
@@ -0,0 +1,84 @@
package adminfinance
import (
"strings"
"time"
"hfb_sys/backend/internal/timeutil"
)
// settlementDiffThresholdCent 是判定"结算差异"的最小绝对金额(分)。
// 号主应得与实际入账的差额绝对值达到此阈值,才视为财务异常。
// SQL、presenter、前端展示统一引用此口径,避免多处硬编码不一致。
const settlementDiffThresholdCent = 5
const (
financeStatusNormal = "normal"
financeStatusRefundPending = "refund_pending"
financeStatusRefundFailed = "refund_failed"
financeStatusSettlementDiff = "settlement_diff"
financeStatusOfflineSettlementPending = "offline_settlement_pending"
)
func payBizTypes() []string {
return []string{"order_pay", "mohong_pay"}
}
func refundBizTypes() []string {
return []string{
"cancel_refund",
"admin_close_refund",
"admin_refund",
"checkout_refund",
"deposit_refund",
"rent_refund",
"arbitration_refund",
"mohong_refund",
}
}
// normalRefundBizTypes 正常(租赁)订单退款类型,不含撞车商城退款。
func normalRefundBizTypes() []string {
return []string{
"cancel_refund",
"admin_close_refund",
"admin_refund",
"checkout_refund",
"deposit_refund",
"rent_refund",
"arbitration_refund",
}
}
// settledSettlementStatuses 是视为"已完成结算"的结算状态集合。
// 预计收入只统计尚未结算的订单,这些状态需从预计口径中排除。
func settledSettlementStatuses() []string {
return []string{"settled", "closed", "arbitrated"}
}
// nonBillableOrderStatuses 是不产生平台收入的订单状态集合(待支付、已取消、已关闭)。
// 预计收入排除这些状态,只保留进行中/待结算且未来会产生手续费的订单。
func nonBillableOrderStatuses() []string {
return []string{"pending_payment", "cancelled", "closed"}
}
func dayStart(value time.Time) time.Time {
loc := timeutil.ShanghaiLocation()
local := value.In(loc)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc)
}
func dailyDateKey(value string) string {
value = strings.TrimSpace(value)
if len(value) >= len("2006-01-02") {
return value[:len("2006-01-02")]
}
return value
}
func absCent(value int64) int64 {
if value < 0 {
return -value
}
return value
}
@@ -0,0 +1,59 @@
package adminfinance
import "testing"
func TestDailyDateKey(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "date only", in: "2026-06-15", want: "2026-06-15"},
{name: "datetime", in: "2026-06-15 00:00:00", want: "2026-06-15"},
{name: "rfc3339", in: "2026-06-15T00:00:00+08:00", want: "2026-06-15"},
{name: "trim spaces", in: " 2026-06-15T00:00:00Z ", want: "2026-06-15"},
{name: "short value", in: "2026-06", want: "2026-06"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := dailyDateKey(tt.in); got != tt.want {
t.Fatalf("dailyDateKey(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestPayBizTypesIncludeMohongPay(t *testing.T) {
values := map[string]bool{}
for _, item := range payBizTypes() {
values[item] = true
}
for _, want := range []string{"order_pay", "mohong_pay"} {
if !values[want] {
t.Fatalf("payBizTypes missing %s", want)
}
}
}
func TestRefundBizTypesIncludeMohongRefund(t *testing.T) {
values := map[string]bool{}
for _, item := range refundBizTypes() {
values[item] = true
}
if !values["mohong_refund"] {
t.Fatal("refundBizTypes missing mohong_refund")
}
}
func TestSettledSettlementStatusesIncludeArbitrated(t *testing.T) {
values := map[string]bool{}
for _, item := range settledSettlementStatuses() {
values[item] = true
}
for _, want := range []string{"settled", "closed", "arbitrated"} {
if !values[want] {
t.Fatalf("settledSettlementStatuses missing %s", want)
}
}
}
@@ -0,0 +1,205 @@
package adminfinance
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/timeutil"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type manualDisbursementRecord struct {
ID uint64
DisbursementNo string
Category string
CustomCategoryName string
PayeeName string
AmountCent int64
PaidAt time.Time
Remark string
VoucherURL string
Status string
CreatedBy uint64
VoidedBy *uint64
VoidedAt *time.Time
VoidReason string
CreatedAt time.Time
UpdatedAt time.Time
}
func (manualDisbursementRecord) TableName() string {
return "manual_disbursements"
}
func (r *Repository) CreateManualDisbursement(
ctx context.Context,
req CreateManualDisbursementRequest,
adminID uint64,
meta auditlog.Meta,
) (*ManualDisbursementDTO, error) {
disbursementNo, err := newManualDisbursementNo()
if err != nil {
return nil, err
}
record := manualDisbursementRecord{
DisbursementNo: disbursementNo,
Category: req.Category,
CustomCategoryName: req.CustomCategoryName,
PayeeName: req.PayeeName,
AmountCent: req.AmountCent,
PaidAt: req.PaidAt,
Remark: req.Remark,
VoucherURL: req.VoucherURL,
Status: "paid",
CreatedBy: adminID,
}
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&record).Error; err != nil {
return err
}
id := record.ID
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin",
ActorID: adminID,
Action: "manual_disbursement_create",
BizType: "manual_disbursement",
BizID: &id,
Meta: meta,
Detail: map[string]any{
"disbursement_no": disbursementNo,
"category": req.Category,
"custom_category_name": req.CustomCategoryName,
"payee_name": req.PayeeName,
"amount_cent": req.AmountCent,
"paid_at": req.PaidAt,
"has_voucher": req.VoucherURL != "",
},
})
})
if err != nil {
return nil, err
}
return r.findManualDisbursement(ctx, record.ID)
}
func (r *Repository) VoidManualDisbursement(
ctx context.Context,
id uint64,
reason string,
adminID uint64,
meta auditlog.Meta,
) (*ManualDisbursementDTO, error) {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var record manualDisbursementRecord
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&record, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrManualDisbursementNotFound
}
return err
}
if record.Status != "paid" {
return ErrManualDisbursementNotPaid
}
now := timeutil.ShanghaiNow()
if err := tx.Model(&record).Updates(map[string]any{
"status": "voided",
"voided_by": adminID,
"voided_at": now,
"void_reason": reason,
"updated_at": now,
}).Error; err != nil {
return err
}
bizID := record.ID
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin",
ActorID: adminID,
Action: "manual_disbursement_void",
BizType: "manual_disbursement",
BizID: &bizID,
Meta: meta,
Detail: map[string]any{
"disbursement_no": record.DisbursementNo,
"amount_cent": record.AmountCent,
"reason": reason,
},
})
})
if err != nil {
return nil, err
}
return r.findManualDisbursement(ctx, id)
}
func (r *Repository) findManualDisbursement(ctx context.Context, id uint64) (*ManualDisbursementDTO, error) {
var row manualDisbursementDetailRow
err := r.db.WithContext(ctx).Table("manual_disbursements AS md").
Select(`md.*,
COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS created_by_name,
COALESCE(NULLIF(voider.nickname, ''), voider.username, '') AS voided_by_name`).
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").
Where("md.id = ?", id).
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrManualDisbursementNotFound
}
if err != nil {
return nil, err
}
return &ManualDisbursementDTO{
ID: row.ID,
DisbursementNo: row.DisbursementNo,
Category: row.Category,
CustomCategoryName: row.CustomCategoryName,
PayeeName: row.PayeeName,
AmountCent: row.AmountCent,
PaidAt: row.PaidAt,
Remark: row.Remark,
VoucherURL: row.VoucherURL,
Status: row.Status,
CreatedBy: row.CreatedBy,
CreatedByName: row.CreatedByName,
VoidedBy: row.VoidedBy,
VoidedByName: row.VoidedByName,
VoidedAt: row.VoidedAt,
VoidReason: row.VoidReason,
CreatedAt: row.CreatedAt,
}, nil
}
type manualDisbursementDetailRow struct {
ID uint64
DisbursementNo string
Category string
CustomCategoryName string
PayeeName string
AmountCent int64
PaidAt time.Time
Remark string
VoucherURL string
Status string
CreatedBy uint64
CreatedByName string
VoidedBy *uint64
VoidedByName string
VoidedAt *time.Time
VoidReason string
CreatedAt time.Time
}
func newManualDisbursementNo() (string, error) {
buf := make([]byte, 4)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return fmt.Sprintf("OD%s%s", timeutil.ShanghaiNow().Format("20060102150405"), hex.EncodeToString(buf)), nil
}
@@ -0,0 +1,77 @@
package adminfinance
import (
"errors"
"testing"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/timeutil"
)
func TestManualDisbursementCreateAndVoid(t *testing.T) {
db := database.NewTestDB()
if err := db.Exec(`INSERT INTO admin_users (id, nickname, username, password_hash) VALUES
(7, '财务甲', 'finance_a', ''), (8, '财务乙', 'finance_b', '')`).Error; err != nil {
t.Fatal(err)
}
repo := NewRepository(db)
paidAt := time.Date(2026, 7, 20, 9, 30, 0, 0, timeutil.ShanghaiLocation())
created, err := repo.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "custom",
CustomCategoryName: "临时活动支出",
PayeeName: "测试供应商",
AmountCent: 8800,
PaidAt: paidAt,
Remark: "测试运营支出",
VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp",
}, 7, auditlog.Meta{RequestID: "req-create"})
if err != nil {
t.Fatalf("创建其他线下出款失败: %v", err)
}
if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" || created.CustomCategoryName != "临时活动支出" {
t.Fatalf("创建结果不正确: %+v", created)
}
voided, err := repo.VoidManualDisbursement(t.Context(), created.ID, "录入金额有误", 8, auditlog.Meta{RequestID: "req-void"})
if err != nil {
t.Fatalf("作废其他线下出款失败: %v", err)
}
if voided.Status != "voided" || voided.VoidedByName != "财务乙" || voided.VoidReason != "录入金额有误" || voided.VoidedAt == nil {
t.Fatalf("作废结果不正确: %+v", voided)
}
if _, err := repo.VoidManualDisbursement(t.Context(), created.ID, "重复作废", 8, auditlog.Meta{}); !errors.Is(err, ErrManualDisbursementNotPaid) {
t.Fatalf("重复作废错误 = %v, want ErrManualDisbursementNotPaid", err)
}
var auditCount int64
if err := db.Table("audit_logs").Where("biz_type = ?", "manual_disbursement").Count(&auditCount).Error; err != nil {
t.Fatal(err)
}
if auditCount != 2 {
t.Fatalf("审计日志数量 = %d, want 2", auditCount)
}
}
func TestManualDisbursementValidation(t *testing.T) {
if !validManualDisbursementCategory("duoduo_deposit_refund") {
t.Fatal("多多退押金分类应为有效分类")
}
if !validManualDisbursementCustomCategoryName("custom", "临时活动支出") || validManualDisbursementCustomCategoryName("custom", "") {
t.Fatal("自定义分类名称校验不正确")
}
service := NewService(&Repository{})
_, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "unknown",
PayeeName: "测试收款人",
AmountCent: 100,
PaidAt: timeutil.ShanghaiNow(),
Remark: "测试",
}, 1, auditlog.Meta{})
if !errors.Is(err, ErrInvalidManualDisbursement) {
t.Fatalf("无效分类错误 = %v, want ErrInvalidManualDisbursement", err)
}
}
@@ -0,0 +1,234 @@
package adminfinance
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/timeutil"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type operatingExpenseRecord struct {
ID uint64
ExpenseNo string
Category string
PayeeName string
AmountCent int64
OccurredAt time.Time
Remark string
VoucherURL string
Status string
CreatedBy uint64
VoidedBy *uint64
VoidedAt *time.Time
VoidReason string
CreatedAt time.Time
UpdatedAt time.Time
}
func (operatingExpenseRecord) TableName() string {
return "operating_expenses"
}
func (r *Repository) CreateOperatingExpense(ctx context.Context, req CreateOperatingExpenseRequest, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
expenseNo, err := newOperatingExpenseNo()
if err != nil {
return nil, err
}
record := operatingExpenseRecord{
ExpenseNo: expenseNo,
Category: req.Category,
PayeeName: req.PayeeName,
AmountCent: req.AmountCent,
OccurredAt: req.OccurredAt,
Remark: req.Remark,
VoucherURL: req.VoucherURL,
Status: "paid",
CreatedBy: adminID,
}
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&record).Error; err != nil {
return err
}
id := record.ID
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin",
ActorID: adminID,
Action: "operating_expense_create",
BizType: "operating_expense",
BizID: &id,
Meta: meta,
Detail: map[string]any{
"expense_no": expenseNo,
"category": req.Category,
"payee_name": req.PayeeName,
"amount_cent": req.AmountCent,
"occurred_at": req.OccurredAt,
"has_voucher": req.VoucherURL != "",
},
})
})
if err != nil {
return nil, err
}
return r.findOperatingExpense(ctx, record.ID)
}
func (r *Repository) VoidOperatingExpense(ctx context.Context, id uint64, reason string, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var record operatingExpenseRecord
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&record, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrOperatingExpenseNotFound
}
return err
}
if record.Status != "paid" {
return ErrOperatingExpenseNotPaid
}
now := timeutil.ShanghaiNow()
if err := tx.Model(&record).Updates(map[string]any{
"status": "voided",
"voided_by": adminID,
"voided_at": now,
"void_reason": reason,
"updated_at": now,
}).Error; err != nil {
return err
}
bizID := record.ID
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin",
ActorID: adminID,
Action: "operating_expense_void",
BizType: "operating_expense",
BizID: &bizID,
Meta: meta,
Detail: map[string]any{
"expense_no": record.ExpenseNo,
"amount_cent": record.AmountCent,
"reason": reason,
},
})
})
if err != nil {
return nil, err
}
return r.findOperatingExpense(ctx, id)
}
func (r *Repository) OperatingExpenses(ctx context.Context, query OperatingExpenseQuery) (*OperatingExpenseListDTO, error) {
var summary OperatingExpenseListSummaryDTO
if err := r.applyOperatingExpenseFilters(r.db.WithContext(ctx).Table("operating_expenses AS oe"), query).
Select(`COUNT(*) AS record_count,
COALESCE(SUM(CASE WHEN status = 'paid' THEN 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`).
Scan(&summary).Error; err != nil {
return nil, err
}
rows := make([]operatingExpenseDetailRow, 0, query.PageSize)
orderColumn := "oe.created_at"
if query.DateType == "occurred" {
orderColumn = "oe.occurred_at"
}
if err := r.applyOperatingExpenseFilters(r.operatingExpenseDetailQuery(ctx), query).
Order(orderColumn + " DESC").
Order("oe.id DESC").
Offset((query.Page - 1) * query.PageSize).
Limit(query.PageSize).
Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]OperatingExpenseDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO())
}
return &OperatingExpenseListDTO{Items: items, Total: summary.RecordCount, Page: query.Page, PageSize: query.PageSize, Summary: summary}, nil
}
func (r *Repository) findOperatingExpense(ctx context.Context, id uint64) (*OperatingExpenseDTO, error) {
var row operatingExpenseDetailRow
err := r.operatingExpenseDetailQuery(ctx).Where("oe.id = ?", id).Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrOperatingExpenseNotFound
}
if err != nil {
return nil, err
}
item := row.toDTO()
return &item, nil
}
func (r *Repository) operatingExpenseDetailQuery(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).Table("operating_expenses AS oe").
Select(`oe.*, COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS created_by_name,
COALESCE(NULLIF(voider.nickname, ''), voider.username, '') AS voided_by_name`).
Joins("LEFT JOIN admin_users AS creator ON creator.id = oe.created_by").
Joins("LEFT JOIN admin_users AS voider ON voider.id = oe.voided_by")
}
func (r *Repository) applyOperatingExpenseFilters(db *gorm.DB, query OperatingExpenseQuery) *gorm.DB {
if query.Status != "" {
db = db.Where("oe.status = ?", query.Status)
}
if query.Category != "" {
db = db.Where("oe.category = ?", query.Category)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
db = db.Where("oe.expense_no LIKE ? OR oe.payee_name LIKE ? OR oe.remark LIKE ?", like, like, like)
}
if !query.StartDate.IsZero() && !query.EndDate.IsZero() {
column := "oe.created_at"
if query.DateType == "occurred" {
column = "oe.occurred_at"
}
db = db.Where(column+" >= ? AND "+column+" <= ?", query.StartDate, query.EndDate)
}
return db
}
type operatingExpenseDetailRow struct {
ID uint64
ExpenseNo string
Category string
PayeeName string
AmountCent int64
OccurredAt time.Time
Remark string
VoucherURL string
Status string
CreatedBy uint64
CreatedByName string
VoidedBy *uint64
VoidedByName string
VoidedAt *time.Time
VoidReason string
CreatedAt time.Time
}
func (r operatingExpenseDetailRow) toDTO() OperatingExpenseDTO {
return OperatingExpenseDTO{
ID: r.ID, ExpenseNo: r.ExpenseNo, Category: r.Category, PayeeName: r.PayeeName,
AmountCent: r.AmountCent, OccurredAt: r.OccurredAt, Remark: r.Remark, VoucherURL: r.VoucherURL,
Status: r.Status, CreatedBy: r.CreatedBy, CreatedByName: r.CreatedByName, VoidedBy: r.VoidedBy,
VoidedByName: r.VoidedByName, VoidedAt: r.VoidedAt, VoidReason: r.VoidReason, CreatedAt: r.CreatedAt,
}
}
func newOperatingExpenseNo() (string, error) {
buf := make([]byte, 4)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return fmt.Sprintf("OE%s%s", timeutil.ShanghaiNow().Format("20060102150405"), strings.ToUpper(hex.EncodeToString(buf))), nil
}
@@ -0,0 +1,90 @@
package adminfinance
import (
"errors"
"testing"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/timeutil"
)
func TestOperatingExpenseCreateListAndVoid(t *testing.T) {
db := database.NewTestDB()
if err := db.Exec(`INSERT INTO admin_users (id, nickname, username, password_hash) VALUES (7, '运营甲', 'ops_a', ''), (8, '运营乙', 'ops_b', '')`).Error; err != nil {
t.Fatal(err)
}
repo := NewRepository(db)
occurredAt := time.Date(2026, 8, 20, 10, 30, 0, 0, timeutil.ShanghaiLocation())
created, err := repo.CreateOperatingExpense(t.Context(), CreateOperatingExpenseRequest{
Category: "推广投放",
PayeeName: "测试媒体",
AmountCent: 12800,
OccurredAt: occurredAt,
Remark: "八月推广费用",
VoucherURL: "/api/files/object?key=operating-expense%2Fvoucher.webp",
}, 7, auditlog.Meta{RequestID: "req-create"})
if err != nil {
t.Fatalf("创建运营开支失败: %v", err)
}
if created.ID == 0 || created.ExpenseNo == "" || created.Status != "paid" || created.CreatedByName != "运营甲" {
t.Fatalf("创建结果不正确: %+v", created)
}
list, err := repo.OperatingExpenses(t.Context(), OperatingExpenseQuery{
DateType: "occurred", StartDate: occurredAt.Add(-time.Hour), EndDate: occurredAt.Add(time.Hour), Page: 1, PageSize: 20,
})
if err != nil {
t.Fatalf("查询运营开支失败: %v", err)
}
if list.Total != 1 || list.Summary.PaidAmountCent != 12800 || list.Items[0].Category != "推广投放" {
t.Fatalf("运营开支列表不正确: %+v", list)
}
voided, err := repo.VoidOperatingExpense(t.Context(), created.ID, "重复录入", 8, auditlog.Meta{RequestID: "req-void"})
if err != nil {
t.Fatalf("作废运营开支失败: %v", err)
}
if voided.Status != "voided" || voided.VoidedByName != "运营乙" || voided.VoidReason != "重复录入" {
t.Fatalf("作废结果不正确: %+v", voided)
}
if _, err := repo.VoidOperatingExpense(t.Context(), created.ID, "重复作废", 8, auditlog.Meta{}); !errors.Is(err, ErrOperatingExpenseNotPaid) {
t.Fatalf("重复作废错误 = %v, want ErrOperatingExpenseNotPaid", err)
}
}
func TestOperatingExpenseDashboardSummaryExcludesVoided(t *testing.T) {
db := database.NewTestDB()
loc := timeutil.ShanghaiLocation()
inRange := time.Date(2026, 8, 20, 10, 0, 0, 0, loc)
if err := db.Exec(`INSERT INTO operating_expenses
(id, expense_no, category, payee_name, remark, created_by, amount_cent, occurred_at, status) VALUES
(1, 'OE-1', '推广投放', '测试收款方', '测试备注', 1, 12000, ?, 'paid'),
(2, 'OE-2', '推广投放', '测试收款方', '测试备注', 1, 8000, ?, 'voided'),
(3, 'OE-3', '推广投放', '测试收款方', '测试备注', 1, 5000, ?, 'paid')`,
inRange, inRange, inRange.AddDate(0, 0, -1)).Error; err != nil {
t.Fatal(err)
}
summary, err := NewRepository(db).operatingExpenseSummary(t.Context(), DashboardQuery{
StartDate: time.Date(2026, 8, 20, 0, 0, 0, 0, loc),
EndDate: time.Date(2026, 8, 20, 23, 59, 59, 0, loc),
})
if err != nil {
t.Fatalf("运营开支仪表盘统计失败: %v", err)
}
if summary.AmountCent != 12000 || summary.Count != 1 {
t.Fatalf("运营开支统计 = %+v, want 12000/1", summary)
}
daily, err := NewRepository(db).dailyOperatingExpenses(t.Context(), DashboardQuery{
StartDate: time.Date(2026, 8, 20, 0, 0, 0, 0, loc),
EndDate: time.Date(2026, 8, 20, 23, 59, 59, 0, loc),
})
if err != nil {
t.Fatalf("每日运营开支统计失败: %v", err)
}
if len(daily) != 1 || daily[0].Date != "2026-08-20" || daily[0].AmountCent != 12000 || daily[0].Count != 1 {
t.Fatalf("每日运营开支统计 = %+v, want 2026-08-20/12000/1", daily)
}
}
@@ -0,0 +1,89 @@
package adminfinance
import (
"time"
)
type financeDetailRow struct {
OrderID uint64
OrderNo string
OrderStatus string
SettlementStatus string
RefundStatus string
HandoffMode string
SettlementMode string
OfflineSettlementStatus string
OfflineSettlementAmountCent int64
RenterID uint64
RenterPhone string
RenterNickname string
OwnerID uint64
OwnerPhone string
OwnerNickname string
OrderRentAmountCent int64
OrderDepositAmountCent int64
CheckoutRentAmountCent int64
CheckoutRenterRefundCent int64
CheckoutOwnerIncomeCent int64
CheckoutPlatformFeeCent int64
OwnerWalletIncomeAmountCent int64
OwnerEffectiveSettlementAmountCent int64
PaidAmountCent int64
RefundedAmountCent int64
RefundingAmountCent int64
FailedRefundAmountCent int64
ChannelNetAmountCent int64
PlatformNetAmountCent int64
SettlementDiffAmountCent int64
FinanceStatus string
CreatedAt time.Time
SettledAt *time.Time
OfflineSettledAt *time.Time
}
func (r financeDetailRow) toDTO() FinanceDetailDTO {
diff := r.SettlementDiffAmountCent
status := r.FinanceStatus
if status == "" {
status = financeStatusNormal
}
if absCent(diff) < settlementDiffThresholdCent && status == financeStatusSettlementDiff {
status = financeStatusNormal
}
return FinanceDetailDTO{
OrderID: r.OrderID,
OrderNo: r.OrderNo,
OrderStatus: r.OrderStatus,
SettlementStatus: r.SettlementStatus,
RefundStatus: r.RefundStatus,
HandoffMode: r.HandoffMode,
SettlementMode: r.SettlementMode,
OfflineSettlementStatus: r.OfflineSettlementStatus,
OfflineSettlementAmountCent: r.OfflineSettlementAmountCent,
RenterID: r.RenterID,
RenterPhone: r.RenterPhone,
RenterNickname: r.RenterNickname,
OwnerID: r.OwnerID,
OwnerPhone: r.OwnerPhone,
OwnerNickname: r.OwnerNickname,
OrderRentAmountCent: r.OrderRentAmountCent,
OrderDepositAmountCent: r.OrderDepositAmountCent,
CheckoutRentAmountCent: r.CheckoutRentAmountCent,
CheckoutRenterRefundCent: r.CheckoutRenterRefundCent,
CheckoutOwnerIncomeCent: r.CheckoutOwnerIncomeCent,
CheckoutPlatformFeeCent: r.CheckoutPlatformFeeCent,
OwnerWalletIncomeAmountCent: r.OwnerWalletIncomeAmountCent,
OwnerEffectiveSettlementAmountCent: r.OwnerEffectiveSettlementAmountCent,
PaidAmountCent: r.PaidAmountCent,
RefundedAmountCent: r.RefundedAmountCent,
RefundingAmountCent: r.RefundingAmountCent,
FailedRefundAmountCent: r.FailedRefundAmountCent,
ChannelNetAmountCent: r.ChannelNetAmountCent,
PlatformNetAmountCent: r.PlatformNetAmountCent,
SettlementDiffAmountCent: diff,
FinanceStatus: status,
CreatedAt: r.CreatedAt,
SettledAt: r.SettledAt,
OfflineSettledAt: r.OfflineSettledAt,
}
}
@@ -0,0 +1,21 @@
package adminfinance
import (
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
"gorm.io/gorm"
)
type Repository struct {
db *gorm.DB
redis *redis.Client
sfGroup singleflight.Group
}
func NewRepository(db *gorm.DB, redisClient ...*redis.Client) *Repository {
repo := &Repository{db: db}
if len(redisClient) > 0 {
repo.redis = redisClient[0]
}
return repo
}
@@ -0,0 +1,185 @@
package adminfinance
import (
"context"
"errors"
"net/url"
"strings"
"unicode/utf8"
"hfb_sys/backend/internal/auditlog"
)
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidManualDisbursement = errors.New("invalid manual disbursement")
ErrManualDisbursementNotFound = errors.New("manual disbursement not found")
ErrManualDisbursementNotPaid = errors.New("manual disbursement is not paid")
ErrInvalidOperatingExpense = errors.New("invalid operating expense")
ErrOperatingExpenseNotFound = errors.New("operating expense not found")
ErrOperatingExpenseNotPaid = errors.New("operating expense is not paid")
)
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.Dashboard(ctx, query)
}
func (s *Service) Details(ctx context.Context, query DetailQuery) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return s.repo.Details(ctx, query)
}
func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (*DisbursementListDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return s.repo.Disbursements(ctx, query)
}
func (s *Service) OperatingExpenses(ctx context.Context, query OperatingExpenseQuery) (*OperatingExpenseListDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return s.repo.OperatingExpenses(ctx, query)
}
func (s *Service) CreateManualDisbursement(
ctx context.Context,
req CreateManualDisbursementRequest,
adminID uint64,
meta auditlog.Meta,
) (*ManualDisbursementDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
req.Category = strings.TrimSpace(req.Category)
req.CustomCategoryName = strings.TrimSpace(req.CustomCategoryName)
req.PayeeName = strings.TrimSpace(req.PayeeName)
req.Remark = strings.TrimSpace(req.Remark)
req.VoucherURL = strings.TrimSpace(req.VoucherURL)
if !validManualDisbursementCategory(req.Category) ||
!validManualDisbursementCustomCategoryName(req.Category, req.CustomCategoryName) ||
utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 ||
req.AmountCent <= 0 || req.PaidAt.IsZero() ||
utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 ||
!validManualVoucherURL(req.VoucherURL) {
return nil, ErrInvalidManualDisbursement
}
return s.repo.CreateManualDisbursement(ctx, req, adminID, meta)
}
func (s *Service) VoidManualDisbursement(
ctx context.Context,
id uint64,
reason string,
adminID uint64,
meta auditlog.Meta,
) (*ManualDisbursementDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
reason = strings.TrimSpace(reason)
if id == 0 || utf8.RuneCountInString(reason) < 1 || utf8.RuneCountInString(reason) > 255 {
return nil, ErrInvalidManualDisbursement
}
return s.repo.VoidManualDisbursement(ctx, id, reason, adminID, meta)
}
func (s *Service) CreateOperatingExpense(ctx context.Context, req CreateOperatingExpenseRequest, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
req.Category = strings.TrimSpace(req.Category)
req.PayeeName = strings.TrimSpace(req.PayeeName)
req.Remark = strings.TrimSpace(req.Remark)
req.VoucherURL = strings.TrimSpace(req.VoucherURL)
if utf8.RuneCountInString(req.Category) < 1 || utf8.RuneCountInString(req.Category) > 50 ||
utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 ||
req.AmountCent <= 0 || req.OccurredAt.IsZero() ||
utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 ||
!validManualVoucherURL(req.VoucherURL) {
return nil, ErrInvalidOperatingExpense
}
return s.repo.CreateOperatingExpense(ctx, req, adminID, meta)
}
func (s *Service) VoidOperatingExpense(ctx context.Context, id uint64, reason string, adminID uint64, meta auditlog.Meta) (*OperatingExpenseDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
reason = strings.TrimSpace(reason)
if id == 0 || utf8.RuneCountInString(reason) < 1 || utf8.RuneCountInString(reason) > 255 {
return nil, ErrInvalidOperatingExpense
}
return s.repo.VoidOperatingExpense(ctx, id, reason, adminID, meta)
}
func validManualDisbursementCategory(category string) bool {
switch category {
case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "duoduo_deposit_refund", "other", "custom":
return true
default:
return false
}
}
func validManualDisbursementCustomCategoryName(category, name string) bool {
if category != "custom" {
return name == ""
}
return utf8.RuneCountInString(name) >= 1 && utf8.RuneCountInString(name) <= 50
}
func validManualVoucherURL(raw string) bool {
if raw == "" {
return true
}
if len(raw) > 500 {
return false
}
parsed, err := url.Parse(raw)
if err != nil || parsed.IsAbs() || parsed.Path != "/api/files/object" {
return false
}
return strings.HasPrefix(parsed.Query().Get("key"), "manual-disbursement/")
}
@@ -0,0 +1,62 @@
package adminfinance
import (
"gorm.io/gorm"
)
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 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,
COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunded' THEN amount_cent ELSE 0 END), 0) AS refunded_amount_cent,
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 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 IN ?", "in", []string{"owner_income", "deposit_compensation", "arbitration_owner_income"}, orderIDs).
Group("order_id")
}
// ownerWalletIncomeForSettledOrdersSubquery 只为当前仪表盘区间内已结算订单汇总钱包入账。
// 原先的全表分组会随钱包流水积累持续变慢;此处先通过 settled_at 缩小订单集合,
// 再使用 wallet_ledger.order_id 索引关联对应流水。
func ownerWalletIncomeForSettledOrdersSubquery(db *gorm.DB, query DashboardQuery) *gorm.DB {
return db.Table("rental_orders AS scoped_order").
Select(`scoped_order.id AS order_id,
COALESCE(SUM(wallet.amount_cent), 0) AS owner_wallet_income_amount_cent`).
Joins(`LEFT JOIN wallet_ledger AS wallet
ON wallet.order_id = scoped_order.id
AND wallet.direction = ?
AND wallet.biz_type IN ?`, "in", []string{"owner_income", "deposit_compensation", "arbitration_owner_income"}).
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 {
refundOrders := db.Table("payment_orders AS refund").
Select("DISTINCT refund.order_id").
Where("refund.biz_type IN ? AND refund.status = ?", refundBizTypes(), "refunded").
Where("refund.created_at >= ? AND refund.created_at <= ?", query.StartDate, query.EndDate)
return db.Table("payment_orders AS paid").
Select("paid.order_id, MAX(paid.amount_cent) AS amount_cent").
Joins("JOIN (?) AS refund_order ON refund_order.order_id = paid.order_id", refundOrders).
Where("paid.biz_type IN ? AND paid.status = ?", payBizTypes(), "paid").
Group("paid.order_id")
}