diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index 88d7f41..dcd7ae1 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -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) { diff --git a/backend/internal/modules/adminfinance/disbursement.go b/backend/internal/modules/adminfinance/disbursement.go index 02c40bb..84ecaa2 100644 --- a/backend/internal/modules/adminfinance/disbursement.go +++ b/backend/internal/modules/adminfinance/disbursement.go @@ -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) diff --git a/backend/internal/modules/adminfinance/dto.go b/backend/internal/modules/adminfinance/dto.go index 26aff42..c6e005e 100644 --- a/backend/internal/modules/adminfinance/dto.go +++ b/backend/internal/modules/adminfinance/dto.go @@ -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"` } diff --git a/backend/internal/modules/adminfinance/handler.go b/backend/internal/modules/adminfinance/handler.go index 99ab947..5e90f84 100644 --- a/backend/internal/modules/adminfinance/handler.go +++ b/backend/internal/modules/adminfinance/handler.go @@ -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", "财务数据暂时不可用") } diff --git a/backend/internal/modules/adminfinance/operating_expense.go b/backend/internal/modules/adminfinance/operating_expense.go new file mode 100644 index 0000000..41e196c --- /dev/null +++ b/backend/internal/modules/adminfinance/operating_expense.go @@ -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 +} diff --git a/backend/internal/modules/adminfinance/operating_expense_test.go b/backend/internal/modules/adminfinance/operating_expense_test.go new file mode 100644 index 0000000..9481048 --- /dev/null +++ b/backend/internal/modules/adminfinance/operating_expense_test.go @@ -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) + } +} diff --git a/backend/internal/modules/adminfinance/service.go b/backend/internal/modules/adminfinance/service.go index cbd5f48..b54179d 100644 --- a/backend/internal/modules/adminfinance/service.go +++ b/backend/internal/modules/adminfinance/service.go @@ -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": diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a6914a5..3701623 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -680,6 +680,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { 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("/finance/operating-expenses", requirePerm("finance:operating_expense"), adminFinanceHandler.OperatingExpenses) + adminRoutes.POST("/finance/operating-expenses", requirePerm("finance:operating_expense"), adminFinanceHandler.CreateOperatingExpense) + adminRoutes.POST("/finance/operating-expenses/:id/void", requirePerm("finance:operating_expense"), adminFinanceHandler.VoidOperatingExpense) adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger) adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList) diff --git a/backend/migrations/000054_operating_expenses.sql b/backend/migrations/000054_operating_expenses.sql new file mode 100644 index 0000000..9cd2d48 --- /dev/null +++ b/backend/migrations/000054_operating_expenses.sql @@ -0,0 +1,44 @@ +-- +goose Up + +CREATE TABLE operating_expenses ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + expense_no VARCHAR(64) NOT NULL COMMENT '运营开支单号', + category VARCHAR(50) NOT NULL COMMENT '开支类别', + payee_name VARCHAR(100) NOT NULL COMMENT '收款方或单位名称', + amount_cent BIGINT NOT NULL COMMENT '开支金额(分)', + occurred_at DATETIME NOT NULL COMMENT '实际开支时间', + remark VARCHAR(500) NOT NULL COMMENT '开支用途备注', + voucher_url VARCHAR(500) NOT NULL DEFAULT '' COMMENT '开支凭证地址', + status VARCHAR(16) NOT NULL DEFAULT 'paid' COMMENT '状态: paid已记账/voided已作废', + created_by BIGINT UNSIGNED NOT NULL COMMENT '录入管理员ID', + voided_by BIGINT UNSIGNED NULL COMMENT '作废管理员ID', + voided_at DATETIME NULL COMMENT '作废时间', + void_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '作废原因', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_operating_expenses_no (expense_no), + KEY idx_operating_expenses_occurred (status, occurred_at), + KEY idx_operating_expenses_created (created_at), + KEY idx_operating_expenses_category (category) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='运营开支记账'; + +INSERT INTO permissions (code, name, resource, action) VALUES +('finance:operating_expense', '管理运营开支', 'finance', 'operating_expense') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code IN ('super_admin', 'finance') AND p.code = 'finance:operating_expense'; + +-- +goose Down + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'finance:operating_expense'; + +DELETE FROM permissions WHERE code = 'finance:operating_expense'; +DROP TABLE IF EXISTS operating_expenses; diff --git a/frontend/src/features/admin/api/adminFinance.ts b/frontend/src/features/admin/api/adminFinance.ts index af0ed3c..ea00a22 100644 --- a/frontend/src/features/admin/api/adminFinance.ts +++ b/frontend/src/features/admin/api/adminFinance.ts @@ -68,6 +68,8 @@ export interface FinanceDailyItem { withdrawal_paid_count: number manual_paid_amount_cent: number manual_paid_count: number + operating_expense_amount_cent: number + operating_expense_count: number disbursement_paid_amount_cent: number disbursement_paid_count: number } @@ -107,6 +109,11 @@ export interface FinanceDisbursementSummary { manual_paid_count: number } +export interface OperatingExpenseSummary { + amount_cent: number + count: number +} + export interface FinanceDisbursementListSummary { record_count: number paid_amount_cent: number @@ -207,9 +214,61 @@ export interface FinanceDashboard { pickup_summary: FinancePickupSummary mohong_summary: FinanceMohongSummary disbursement_summary: FinanceDisbursementSummary + operating_expense_summary: OperatingExpenseSummary generated_at: string } +export interface OperatingExpense { + id: number + expense_no: string + category: string + payee_name: string + amount_cent: number + occurred_at: string + remark: string + voucher_url: string + status: 'paid' | 'voided' + created_by: number + created_by_name: string + voided_by?: number + voided_by_name: string + voided_at?: string + void_reason: string + created_at: string +} + +export interface OperatingExpenseQuery extends FinanceDateQuery { + status?: string + category?: string + keyword?: string + date_type?: 'created' | 'occurred' + page?: number + page_size?: number +} + +export interface OperatingExpenseListSummary { + record_count: number + paid_amount_cent: number + paid_count: number +} + +export interface OperatingExpenseList { + items: OperatingExpense[] + total: number + page: number + page_size: number + summary: OperatingExpenseListSummary +} + +export interface CreateOperatingExpensePayload { + category: string + payee_name: string + amount_cent: number + occurred_at: string + remark: string + voucher_url?: string +} + export interface FinanceDetail { order_id: number order_no: string @@ -309,6 +368,10 @@ export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) { manual_paid_amount_cent: 0, manual_paid_count: 0, }, + operating_expense_summary: data.data?.operating_expense_summary ?? { + amount_cent: 0, + count: 0, + }, } } @@ -350,6 +413,37 @@ export async function fetchFinanceDisbursements(query: FinanceDisbursementQuery } } +export async function fetchOperatingExpenses(query: OperatingExpenseQuery = {}) { + const { data } = await apiClient.get>( + '/admin/finance/operating-expenses', + { params: cleanParams(query) } + ) + const result = data.data + return { + items: Array.isArray(result?.items) ? result.items : [], + total: Number(result?.total ?? 0), + page: Number(result?.page ?? query.page ?? 1), + page_size: Number(result?.page_size ?? query.page_size ?? 20), + summary: result?.summary ?? { record_count: 0, paid_amount_cent: 0, paid_count: 0 }, + } +} + +export async function createOperatingExpense(payload: CreateOperatingExpensePayload) { + const { data } = await apiClient.post>( + '/admin/finance/operating-expenses', + payload + ) + return data.data +} + +export async function voidOperatingExpense(id: number, reason: string) { + const { data } = await apiClient.post>( + `/admin/finance/operating-expenses/${id}/void`, + { reason } + ) + return data.data +} + export async function createManualDisbursement(payload: CreateManualDisbursementPayload) { const { data } = await apiClient.post>( '/admin/finance/manual-disbursements', diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue index 5838d95..0872599 100644 --- a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -204,6 +204,11 @@ function diffType(value: number) { {{ moneyCent(dashboard.disbursement_summary.manual_paid_amount_cent) }} {{ dashboard.disbursement_summary.manual_paid_count }} 笔有效记账 +
+ 运营开支 + {{ moneyCent(dashboard.operating_expense_summary.amount_cent) }} + {{ dashboard.operating_expense_summary.count }} 笔有效记账 +
当前待出款 {{ moneyCent(dashboard.disbursement_summary.pending_payment_amount_cent) }} @@ -286,6 +291,9 @@ function diffType(value: number) { + + +

@@ -331,6 +339,10 @@ function diffType(value: number) { border-left: 3px solid #0f766e; } +.operating-expense-card { + border-left: 3px solid #b45309; +} + .pending-card { border-left: 3px solid #d97706; } diff --git a/frontend/src/features/admin/views/AdminOperatingExpensesView.vue b/frontend/src/features/admin/views/AdminOperatingExpensesView.vue new file mode 100644 index 0000000..c6325f3 --- /dev/null +++ b/frontend/src/features/admin/views/AdminOperatingExpensesView.vue @@ -0,0 +1,411 @@ + + + + + diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 08e7a4a..9c5c1cd 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -169,6 +169,12 @@ const allNavGroups: NavGroup[] = [ icon: Money, permission: 'wallet:view', }, + { + label: '运营开支', + to: adminPath('finance/operating-expenses'), + icon: Money, + permission: 'finance:operating_expense', + }, { label: '资金流水', to: adminPath('wallet-ledger'), diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 0d3b7d1..3df265a 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -138,6 +138,12 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminFinanceDisbursementsView.vue'), meta: adminMeta, }, + { + path: adminPath('finance/operating-expenses'), + name: 'admin-finance-operating-expenses', + component: () => import('@/features/admin/views/AdminOperatingExpensesView.vue'), + meta: adminMeta, + }, { path: adminPath('wallet-ledger'), name: 'admin-wallet-ledger',