新增其他线下出款记账

This commit is contained in:
yml2213
2026-07-26 17:13:08 +08:00
parent dbe0cda2c6
commit 379536e5c5
16 changed files with 1125 additions and 35 deletions
@@ -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/")
}
+1 -1
View File
@@ -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"