chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user