新增其他线下出款记账
This commit is contained in:
@@ -290,8 +290,10 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
|
||||
item.OfflineSettlementPaidCount = row.OfflineSettlementPaidCount
|
||||
item.WithdrawalPaidAmountCent = row.WithdrawalPaidAmountCent
|
||||
item.WithdrawalPaidCount = row.WithdrawalPaidCount
|
||||
item.DisbursementPaidAmountCent = row.OfflineSettlementPaidAmountCent + row.WithdrawalPaidAmountCent
|
||||
item.DisbursementPaidCount = row.OfflineSettlementPaidCount + 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
|
||||
}
|
||||
|
||||
|
||||
@@ -71,9 +71,21 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
|
||||
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 + withdrawalPaid.ActualAmountCent,
|
||||
PaidCount: offlinePaid.Count + withdrawalPaid.Count,
|
||||
PaidAmountCent: offlinePaid.AmountCent + withdrawalPaid.ActualAmountCent + manualPaid.AmountCent,
|
||||
PaidCount: offlinePaid.Count + withdrawalPaid.Count + manualPaid.Count,
|
||||
PendingPaymentAmountCent: offlinePending.AmountCent + withdrawalPending.ActualAmountCent,
|
||||
PendingPaymentCount: offlinePending.Count + withdrawalPending.Count,
|
||||
OfflineSettlementPaidAmountCent: offlinePaid.AmountCent,
|
||||
@@ -88,6 +100,8 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
|
||||
WithdrawalPendingCount: withdrawalPending.Count,
|
||||
WithdrawalReviewAmountCent: withdrawalReview.ActualAmountCent,
|
||||
WithdrawalReviewCount: withdrawalReview.Count,
|
||||
ManualPaidAmountCent: manualPaid.AmountCent,
|
||||
ManualPaidCount: manualPaid.Count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -118,7 +132,19 @@ func (r *Repository) dailyDisbursements(ctx context.Context, query DashboardQuer
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rowsByDate := make(map[string]dailyDisbursementRow, len(offlineRows)+len(withdrawalRows))
|
||||
manualRows := make([]dailyDisbursementSourceRow, 0)
|
||||
if err := db.Table("manual_disbursements").
|
||||
Select(`DATE(paid_at) 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(paid_at)").
|
||||
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]
|
||||
@@ -135,6 +161,14 @@ func (r *Repository) dailyDisbursements(ctx context.Context, query DashboardQuer
|
||||
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) {
|
||||
@@ -157,4 +191,6 @@ type dailyDisbursementRow struct {
|
||||
OfflineSettlementPaidCount int64
|
||||
WithdrawalPaidAmountCent int64
|
||||
WithdrawalPaidCount int64
|
||||
ManualPaidAmountCent int64
|
||||
ManualPaidCount int64
|
||||
}
|
||||
|
||||
@@ -79,6 +79,12 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
|
||||
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 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
|
||||
@@ -118,11 +124,52 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
|
||||
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 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)")
|
||||
|
||||
union := db.Raw("? UNION ALL ?", platformManaged, withdrawal)
|
||||
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.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 ?", platformManaged, withdrawal, manualOffline)
|
||||
return db.Table("(?) AS d", union)
|
||||
}
|
||||
|
||||
@@ -179,6 +226,12 @@ type disbursementItemRow struct {
|
||||
OperatorID *uint64
|
||||
OperatorName string
|
||||
Remark string
|
||||
Category string
|
||||
VoucherURL string
|
||||
VoidedAt *time.Time
|
||||
VoidedBy *uint64
|
||||
VoidedByName string
|
||||
VoidReason string
|
||||
SourcePayload []byte
|
||||
}
|
||||
|
||||
@@ -217,5 +270,11 @@ func (r disbursementItemRow) toDTO() DisbursementItemDTO {
|
||||
OperatorID: r.OperatorID,
|
||||
OperatorName: r.OperatorName,
|
||||
Remark: r.Remark,
|
||||
Category: r.Category,
|
||||
VoucherURL: r.VoucherURL,
|
||||
VoidedAt: r.VoidedAt,
|
||||
VoidedBy: r.VoidedBy,
|
||||
VoidedByName: r.VoidedByName,
|
||||
VoidReason: r.VoidReason,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
||||
)`,
|
||||
`CREATE TABLE users (id INTEGER PRIMARY KEY, nickname TEXT, phone TEXT)`,
|
||||
`CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`,
|
||||
`CREATE TABLE manual_disbursements (
|
||||
id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, payee_name TEXT,
|
||||
amount_cent INTEGER, paid_at DATETIME, remark TEXT, voucher_url TEXT,
|
||||
status TEXT, created_by INTEGER, voided_by INTEGER, voided_at DATETIME,
|
||||
void_reason TEXT, created_at DATETIME
|
||||
)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
@@ -50,7 +56,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO admin_users (id, nickname, username) VALUES
|
||||
(7, '财务甲', 'finance_a')`).Error; err != nil {
|
||||
(7, '财务甲', 'finance_a'), (8, '财务乙', 'finance_b')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO listing_uploads
|
||||
@@ -80,6 +86,15 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
||||
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, payee_name, amount_cent, paid_at, remark,
|
||||
voucher_url, status, created_by, voided_by, voided_at, void_reason, created_at)
|
||||
VALUES
|
||||
(20, 'OD202607010001', 'user_compensation', '李四', 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{
|
||||
@@ -93,11 +108,11 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("Disbursements() error = %v", err)
|
||||
}
|
||||
if result.Total != 5 || len(result.Items) != 5 {
|
||||
t.Fatalf("记录数 = %d/%d, want 5/5", result.Total, len(result.Items))
|
||||
if result.Total != 7 || len(result.Items) != 7 {
|
||||
t.Fatalf("记录数 = %d/%d, want 7/7", result.Total, len(result.Items))
|
||||
}
|
||||
if result.Summary.PaidAmountCent != 21900 || result.Summary.PaidCount != 2 {
|
||||
t.Fatalf("已出款 = %d/%d, want 21900/2", result.Summary.PaidAmountCent, result.Summary.PaidCount)
|
||||
if result.Summary.PaidAmountCent != 24400 || result.Summary.PaidCount != 3 {
|
||||
t.Fatalf("已出款 = %d/%d, want 24400/3", 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)
|
||||
@@ -120,6 +135,17 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
||||
t.Fatalf("平台收款人/上传人 = %s/%s/%s", platformItem.PayeeName, platformItem.PayeePhone, platformItem.UploaderName)
|
||||
}
|
||||
|
||||
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 != "user_compensation" || manualItem.OperatorName != "财务甲" {
|
||||
t.Fatalf("其他线下出款信息不正确: %+v", manualItem)
|
||||
}
|
||||
|
||||
paidWithdrawals, err := repo.Disbursements(t.Context(), DisbursementQuery{
|
||||
SourceType: "withdrawal",
|
||||
Status: "paid",
|
||||
|
||||
@@ -35,10 +35,19 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("创建提现表失败: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE manual_disbursements (
|
||||
id INTEGER PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
amount_cent INTEGER NOT NULL,
|
||||
paid_at DATETIME NOT NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("创建其他线下出款表失败: %v", err)
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -54,6 +63,16 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
||||
t.Fatalf("写入订单失败: %v", err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]any{
|
||||
{1, "paid", 2500, inRangeManual},
|
||||
{2, "paid", 1000, outOfRange},
|
||||
{3, "voided", 6000, inRangeManual},
|
||||
} {
|
||||
if err := db.Exec(`INSERT INTO manual_disbursements
|
||||
(id, status, amount_cent, paid_at) VALUES (?, ?, ?, ?)`, args...).Error; err != nil {
|
||||
t.Fatalf("写入其他线下出款失败: %v", err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]any{
|
||||
{1, "completed", 10000, 100, 9900, inRangeWithdrawal},
|
||||
{2, "completed", 5000, 0, 5000, outOfRange},
|
||||
@@ -77,8 +96,8 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("disbursementSummary() error = %v", err)
|
||||
}
|
||||
if summary.PaidAmountCent != 21900 || summary.PaidCount != 2 {
|
||||
t.Fatalf("实际出款 = %d/%d, want 21900/2", summary.PaidAmountCent, summary.PaidCount)
|
||||
if summary.PaidAmountCent != 24400 || summary.PaidCount != 3 {
|
||||
t.Fatalf("实际出款 = %d/%d, want 24400/3", summary.PaidAmountCent, summary.PaidCount)
|
||||
}
|
||||
if summary.OfflineSettlementPaidAmountCent != 12000 || summary.OfflineSettlementPaidCount != 1 {
|
||||
t.Fatalf("代管打款 = %d/%d, want 12000/1", summary.OfflineSettlementPaidAmountCent, summary.OfflineSettlementPaidCount)
|
||||
@@ -86,6 +105,9 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
||||
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 != 12800 || summary.PendingPaymentCount != 2 {
|
||||
t.Fatalf("待出款 = %d/%d, want 12800/2", summary.PendingPaymentAmountCent, summary.PendingPaymentCount)
|
||||
}
|
||||
@@ -97,8 +119,8 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("dailyDisbursements() error = %v", err)
|
||||
}
|
||||
if len(daily) != 2 {
|
||||
t.Fatalf("每日出款行数 = %d, want 2", len(daily))
|
||||
if len(daily) != 3 {
|
||||
t.Fatalf("每日出款行数 = %d, want 3", len(daily))
|
||||
}
|
||||
if daily[0].Date != "2026-07-01" || daily[0].OfflineSettlementPaidAmountCent != 12000 {
|
||||
t.Fatalf("第一日代管出款 = %+v", daily[0])
|
||||
@@ -106,4 +128,7 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("第三日其他线下出款 = %+v", daily[2])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ type DisbursementSummaryDTO struct {
|
||||
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 {
|
||||
@@ -115,6 +117,44 @@ type DisbursementItemDTO struct {
|
||||
OperatorID *uint64 `json:"operator_id,omitempty"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
Remark string `json:"remark"`
|
||||
Category string `json:"category"`
|
||||
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"`
|
||||
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"`
|
||||
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 FinanceSummaryDTO struct {
|
||||
@@ -166,6 +206,8 @@ type FinanceDailyDTO struct {
|
||||
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"`
|
||||
DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"`
|
||||
DisbursementPaidCount int64 `json:"disbursement_paid_count"`
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/auditlog"
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
@@ -60,6 +62,49 @@ func (h *Handler) Disbursements(c *gin.Context) {
|
||||
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 parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) {
|
||||
start, end, ok := parseDateRange(c, 6)
|
||||
if !ok {
|
||||
@@ -124,11 +169,11 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
|
||||
StartDate: start,
|
||||
EndDate: end,
|
||||
}
|
||||
if query.SourceType != "" && query.SourceType != "platform_managed" && query.SourceType != "withdrawal" {
|
||||
if query.SourceType != "" && query.SourceType != "platform_managed" && 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" {
|
||||
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
|
||||
}
|
||||
@@ -186,7 +231,30 @@ func writeFinanceError(c *gin.Context, err error) {
|
||||
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, "该线下出款记录已作废")
|
||||
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,200 @@
|
||||
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
|
||||
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,
|
||||
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,
|
||||
"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,
|
||||
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
|
||||
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,96 @@
|
||||
package adminfinance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/auditlog"
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestManualDisbursementCreateAndVoid(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
statements := []string{
|
||||
`CREATE TABLE manual_disbursements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, disbursement_no TEXT NOT NULL UNIQUE,
|
||||
category TEXT NOT NULL, payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL,
|
||||
paid_at DATETIME NOT NULL, remark TEXT NOT NULL, voucher_url TEXT NOT NULL,
|
||||
status TEXT NOT NULL, created_by INTEGER NOT NULL, voided_by INTEGER,
|
||||
voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`,
|
||||
`CREATE TABLE audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_type TEXT, actor_id INTEGER,
|
||||
action TEXT, biz_type TEXT, biz_id INTEGER, ip TEXT, user_agent TEXT,
|
||||
detail BLOB, created_at DATETIME
|
||||
)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatalf("创建测试表失败: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO admin_users (id, nickname, username) 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: "operating_expense",
|
||||
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 != "财务甲" {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,19 @@ package adminfinance
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"hfb_sys/backend/internal/auditlog"
|
||||
)
|
||||
|
||||
var ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
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")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
@@ -53,3 +63,66 @@ func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (*
|
||||
}
|
||||
return s.repo.Disbursements(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.PayeeName = strings.TrimSpace(req.PayeeName)
|
||||
req.Remark = strings.TrimSpace(req.Remark)
|
||||
req.VoucherURL = strings.TrimSpace(req.VoucherURL)
|
||||
if !validManualDisbursementCategory(req.Category) ||
|
||||
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 validManualDisbursementCategory(category string) bool {
|
||||
switch category {
|
||||
case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "other":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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/")
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func fileURLForScene(scene string, key string) string {
|
||||
func normalizeScene(scene string) string {
|
||||
scene = strings.TrimSpace(strings.ToLower(scene))
|
||||
switch scene {
|
||||
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle":
|
||||
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle", "manual-disbursement":
|
||||
return scene
|
||||
default:
|
||||
return "misc"
|
||||
|
||||
@@ -639,6 +639,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.GET("/finance/dashboard", requirePerm("wallet:view"), adminFinanceHandler.Dashboard)
|
||||
adminRoutes.GET("/finance/details", requirePerm("wallet:view"), adminFinanceHandler.Details)
|
||||
adminRoutes.GET("/finance/disbursements", requirePerm("wallet:view"), adminFinanceHandler.Disbursements)
|
||||
adminRoutes.POST("/finance/manual-disbursements", requirePerm("finance:manual_disbursement"), adminFinanceHandler.CreateManualDisbursement)
|
||||
adminRoutes.POST("/finance/manual-disbursements/:id/void", requirePerm("finance:manual_disbursement"), adminFinanceHandler.VoidManualDisbursement)
|
||||
adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger)
|
||||
adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user