Files
hfb_sys/backend/internal/modules/adminfinance/disbursement.go
T
yml2213 cbd5412315 test: 后端测试从 SQLite 全面切换到 MySQL,与生产环境对齐
测试库不再使用 SQLite 内存库,改为每个测试包独立的 MySQL 库,
表结构由 backend/migrations 的真实迁移建立。

基础设施
- 新增 internal/database/testdb.go:NewTestDB() 保持原签名(28 处调用零改动),
  内部连接 MySQL 测试库,首次调用重建库并执行全部真实迁移,之后每次调用清空全表
- 库名 = 测试库基础名 + 包目录名,同包共享、跨包隔离,使 go test ./... 可并发执行
  (共用一库时各包会互相 DROP/CREATE 导致 database exists)
- 新增 NewDryRunDB():MySQL 方言 + DryRun,供断言 SQL 形态的 5 处测试使用
- 新增 AutoMigrateIfMissing():只对不存在的表 AutoMigrate,
  避免改动迁移定义的索引与约束
- 新增 scripts/prepare-test-db.sh:建库与通配授权,可重复执行
- 33 处 sqlite.Open 全部替换,26 个测试文件改用 MySQL
- 删除 46 处手写 SQLite 方言 DDL(AUTOINCREMENT / INTEGER PRIMARY KEY / TEXT),
  表统一由真实迁移建立
- go.mod 移除 gorm.io/driver/sqlite 与 mattn/go-sqlite3
- 删除生产代码里 distribution/repository.go 的 sqlite 方言分支(strftime)

对齐环境后暴露并修复的真实问题
- DATE(col) 在 MySQL 返回 DATE 类型,使 Date string 字段变成 RFC3339 时间串,
  大盘日期会带出时间部分;28 处改为 DATE_FORMAT(col, '%Y-%m-%d')
- 系统自动创建分销结算批次时 created_by 写入 0,违反指向 admin_users 的外键,
  生产会直接插入失败;改为写 NULL(新增 adminRef helper)
- MySQL DATETIME 仅到秒,静默截断 Go 侧写入的时间,
  导致租用时长计算与结算时点回读比对偏差;
  rental_orders 的 9 个业务时间列提到 DATETIME(6)
- 测试数据补齐严格模式要求的 NOT NULL 列、修正超出列长的取值、补齐外键父记录

迁移
- 000065 的 ALTER TABLE rental_orders 一并完成业务时间列精度调整,
  feature 侧不再单开迁移文件

脚本与文档
- check.sh / check-backend-coverage.sh 去掉 -p 1 串行与 SQLite 相关注释
- 更新 docs/Repository层测试补充总结.md 的测试运行说明
2026-09-10 14:34:01 +08:00

277 lines
11 KiB
Go

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
}