增加 运营开支

This commit is contained in:
yml2213
2026-08-20 16:06:05 +08:00
parent 0b235d078d
commit 80e329a604
14 changed files with 1189 additions and 12 deletions
@@ -27,16 +27,34 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash
if err != nil {
return nil, err
}
operatingExpense, err := r.operatingExpenseSummary(ctx, query)
if err != nil {
return nil, err
}
return &DashboardDTO{
Summary: *summary,
DailyItems: dailyItems,
PickupSummary: *pickup,
MohongSummary: *mohong,
DisbursementSummary: *disbursement,
GeneratedAt: timeutil.ShanghaiNow(),
Summary: *summary,
DailyItems: dailyItems,
PickupSummary: *pickup,
MohongSummary: *mohong,
DisbursementSummary: *disbursement,
OperatingExpenseSummary: *operatingExpense,
GeneratedAt: timeutil.ShanghaiNow(),
}, 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)
@@ -303,6 +321,10 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
if err != nil {
return nil, err
}
operatingExpenses, err := r.dailyOperatingExpenses(ctx, query)
if err != nil {
return nil, err
}
itemsByDate := make(map[string]FinanceDailyDTO)
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
@@ -392,6 +414,14 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
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) {
@@ -2,6 +2,23 @@ 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(occurred_at) 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(occurred_at)").
Scan(&rows).Error
return rows, err
}
// disbursementSummary 使用实际确认打款时间统计区间出款,同时返回不受日期限制的当前待办。
func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQuery) (*DisbursementSummaryDTO, error) {
db := r.db.WithContext(ctx)
+71 -6
View File
@@ -34,13 +34,25 @@ type DisbursementQuery struct {
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"`
GeneratedAt time.Time `json:"generated_at"`
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 表。
@@ -101,6 +113,25 @@ type DisbursementListSummaryDTO struct {
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"`
@@ -169,6 +200,38 @@ type ManualDisbursementDTO struct {
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"`
@@ -233,6 +296,8 @@ type FinanceDailyDTO struct {
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"`
}
@@ -62,6 +62,19 @@ func (h *Handler) Disbursements(c *gin.Context) {
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 {
@@ -105,6 +118,49 @@ func (h *Handler) VoidManualDisbursement(c *gin.Context) {
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 {
@@ -196,6 +252,34 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
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 parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) {
loc := timeutil.ShanghaiLocation()
today := timeutil.ShanghaiNow()
@@ -238,6 +322,12 @@ func writeFinanceError(c *gin.Context, err error) {
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", "财务数据暂时不可用")
}
@@ -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,117 @@
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 TestOperatingExpenseCreateListAndVoid(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)
}
for _, statement := range []string{
`CREATE TABLE operating_expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT, expense_no TEXT NOT NULL UNIQUE, category TEXT NOT NULL,
payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL, occurred_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
)`,
} {
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, '运营甲', '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, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("打开测试数据库失败: %v", err)
}
if err := db.Exec(`CREATE TABLE operating_expenses (
id INTEGER PRIMARY KEY, amount_cent INTEGER NOT NULL, occurred_at DATETIME NOT NULL, status TEXT NOT NULL
)`).Error; err != nil {
t.Fatalf("创建运营开支表失败: %v", err)
}
loc := timeutil.ShanghaiLocation()
inRange := time.Date(2026, 8, 20, 10, 0, 0, 0, loc)
if err := db.Exec(`INSERT INTO operating_expenses (id, amount_cent, occurred_at, status) VALUES
(1, 12000, ?, 'paid'), (2, 8000, ?, 'voided'), (3, 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)
}
}
@@ -15,6 +15,9 @@ var (
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 {
@@ -64,6 +67,22 @@ func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (*
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,
@@ -106,6 +125,35 @@ func (s *Service) VoidManualDisbursement(
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":