增加 运营开支
This commit is contained in:
@@ -27,16 +27,34 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
operatingExpense, err := r.operatingExpenseSummary(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &DashboardDTO{
|
return &DashboardDTO{
|
||||||
Summary: *summary,
|
Summary: *summary,
|
||||||
DailyItems: dailyItems,
|
DailyItems: dailyItems,
|
||||||
PickupSummary: *pickup,
|
PickupSummary: *pickup,
|
||||||
MohongSummary: *mohong,
|
MohongSummary: *mohong,
|
||||||
DisbursementSummary: *disbursement,
|
DisbursementSummary: *disbursement,
|
||||||
GeneratedAt: timeutil.ShanghaiNow(),
|
OperatingExpenseSummary: *operatingExpense,
|
||||||
|
GeneratedAt: timeutil.ShanghaiNow(),
|
||||||
}, nil
|
}, 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 表,不混入正常订单口径。
|
// pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。
|
||||||
func (r *Repository) pickupSummary(ctx context.Context, query DashboardQuery) (*PickupSummaryDTO, error) {
|
func (r *Repository) pickupSummary(ctx context.Context, query DashboardQuery) (*PickupSummaryDTO, error) {
|
||||||
db := r.db.WithContext(ctx)
|
db := r.db.WithContext(ctx)
|
||||||
@@ -303,6 +321,10 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
operatingExpenses, err := r.dailyOperatingExpenses(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
itemsByDate := make(map[string]FinanceDailyDTO)
|
itemsByDate := make(map[string]FinanceDailyDTO)
|
||||||
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
|
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
|
item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount
|
||||||
itemsByDate[date] = item
|
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))
|
items := make([]FinanceDailyDTO, 0, len(itemsByDate))
|
||||||
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
|
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
|
||||||
|
|||||||
@@ -2,6 +2,23 @@ package adminfinance
|
|||||||
|
|
||||||
import "context"
|
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 使用实际确认打款时间统计区间出款,同时返回不受日期限制的当前待办。
|
// disbursementSummary 使用实际确认打款时间统计区间出款,同时返回不受日期限制的当前待办。
|
||||||
func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQuery) (*DisbursementSummaryDTO, error) {
|
func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQuery) (*DisbursementSummaryDTO, error) {
|
||||||
db := r.db.WithContext(ctx)
|
db := r.db.WithContext(ctx)
|
||||||
|
|||||||
@@ -34,13 +34,25 @@ type DisbursementQuery struct {
|
|||||||
PageSize int
|
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 {
|
type DashboardDTO struct {
|
||||||
Summary FinanceSummaryDTO `json:"summary"`
|
Summary FinanceSummaryDTO `json:"summary"`
|
||||||
DailyItems []FinanceDailyDTO `json:"daily_items"`
|
DailyItems []FinanceDailyDTO `json:"daily_items"`
|
||||||
PickupSummary PickupSummaryDTO `json:"pickup_summary"`
|
PickupSummary PickupSummaryDTO `json:"pickup_summary"`
|
||||||
MohongSummary MohongSummaryDTO `json:"mohong_summary"`
|
MohongSummary MohongSummaryDTO `json:"mohong_summary"`
|
||||||
DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"`
|
DisbursementSummary DisbursementSummaryDTO `json:"disbursement_summary"`
|
||||||
GeneratedAt time.Time `json:"generated_at"`
|
OperatingExpenseSummary OperatingExpenseSummaryDTO `json:"operating_expense_summary"`
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。
|
// PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。
|
||||||
@@ -101,6 +113,25 @@ type DisbursementListSummaryDTO struct {
|
|||||||
WithdrawalFeeAmountCent int64 `json:"withdrawal_fee_amount_cent"`
|
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 {
|
type DisbursementItemDTO struct {
|
||||||
SourceType string `json:"source_type"`
|
SourceType string `json:"source_type"`
|
||||||
SourceID uint64 `json:"source_id"`
|
SourceID uint64 `json:"source_id"`
|
||||||
@@ -169,6 +200,38 @@ type ManualDisbursementDTO struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
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 {
|
type FinanceSummaryDTO struct {
|
||||||
TotalFlowAmountCent int64 `json:"total_flow_amount_cent"`
|
TotalFlowAmountCent int64 `json:"total_flow_amount_cent"`
|
||||||
TotalRefundAmountCent int64 `json:"total_refund_amount_cent"`
|
TotalRefundAmountCent int64 `json:"total_refund_amount_cent"`
|
||||||
@@ -233,6 +296,8 @@ type FinanceDailyDTO struct {
|
|||||||
WithdrawalPaidCount int64 `json:"withdrawal_paid_count"`
|
WithdrawalPaidCount int64 `json:"withdrawal_paid_count"`
|
||||||
ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"`
|
ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"`
|
||||||
ManualPaidCount int64 `json:"manual_paid_count"`
|
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"`
|
DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"`
|
||||||
DisbursementPaidCount int64 `json:"disbursement_paid_count"`
|
DisbursementPaidCount int64 `json:"disbursement_paid_count"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ func (h *Handler) Disbursements(c *gin.Context) {
|
|||||||
response.OK(c, result)
|
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) {
|
func (h *Handler) CreateManualDisbursement(c *gin.Context) {
|
||||||
adminID, ok := financeAdminID(c)
|
adminID, ok := financeAdminID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -105,6 +118,49 @@ func (h *Handler) VoidManualDisbursement(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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) {
|
func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) {
|
||||||
start, end, ok := parseDateRange(c, 6)
|
start, end, ok := parseDateRange(c, 6)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -196,6 +252,34 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
|
|||||||
return query, true
|
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) {
|
func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) {
|
||||||
loc := timeutil.ShanghaiLocation()
|
loc := timeutil.ShanghaiLocation()
|
||||||
today := timeutil.ShanghaiNow()
|
today := timeutil.ShanghaiNow()
|
||||||
@@ -238,6 +322,12 @@ func writeFinanceError(c *gin.Context, err error) {
|
|||||||
response.NotFound(c, "线下出款记录不存在")
|
response.NotFound(c, "线下出款记录不存在")
|
||||||
case errors.Is(err, ErrManualDisbursementNotPaid):
|
case errors.Is(err, ErrManualDisbursementNotPaid):
|
||||||
response.BadRequest(c, "该线下出款记录已作废")
|
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:
|
default:
|
||||||
response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用")
|
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")
|
ErrInvalidManualDisbursement = errors.New("invalid manual disbursement")
|
||||||
ErrManualDisbursementNotFound = errors.New("manual disbursement not found")
|
ErrManualDisbursementNotFound = errors.New("manual disbursement not found")
|
||||||
ErrManualDisbursementNotPaid = errors.New("manual disbursement is not paid")
|
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 {
|
type Service struct {
|
||||||
@@ -64,6 +67,22 @@ func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (*
|
|||||||
return s.repo.Disbursements(ctx, query)
|
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(
|
func (s *Service) CreateManualDisbursement(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req CreateManualDisbursementRequest,
|
req CreateManualDisbursementRequest,
|
||||||
@@ -106,6 +125,35 @@ func (s *Service) VoidManualDisbursement(
|
|||||||
return s.repo.VoidManualDisbursement(ctx, id, reason, adminID, meta)
|
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 {
|
func validManualDisbursementCategory(category string) bool {
|
||||||
switch category {
|
switch category {
|
||||||
case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "duoduo_deposit_refund", "other", "custom":
|
case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "duoduo_deposit_refund", "other", "custom":
|
||||||
|
|||||||
@@ -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.GET("/finance/disbursements", requirePerm("wallet:view"), adminFinanceHandler.Disbursements)
|
||||||
adminRoutes.POST("/finance/manual-disbursements", requirePerm("finance:manual_disbursement"), adminFinanceHandler.CreateManualDisbursement)
|
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.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("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger)
|
||||||
adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList)
|
adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -68,6 +68,8 @@ export interface FinanceDailyItem {
|
|||||||
withdrawal_paid_count: number
|
withdrawal_paid_count: number
|
||||||
manual_paid_amount_cent: number
|
manual_paid_amount_cent: number
|
||||||
manual_paid_count: number
|
manual_paid_count: number
|
||||||
|
operating_expense_amount_cent: number
|
||||||
|
operating_expense_count: number
|
||||||
disbursement_paid_amount_cent: number
|
disbursement_paid_amount_cent: number
|
||||||
disbursement_paid_count: number
|
disbursement_paid_count: number
|
||||||
}
|
}
|
||||||
@@ -107,6 +109,11 @@ export interface FinanceDisbursementSummary {
|
|||||||
manual_paid_count: number
|
manual_paid_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OperatingExpenseSummary {
|
||||||
|
amount_cent: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface FinanceDisbursementListSummary {
|
export interface FinanceDisbursementListSummary {
|
||||||
record_count: number
|
record_count: number
|
||||||
paid_amount_cent: number
|
paid_amount_cent: number
|
||||||
@@ -207,9 +214,61 @@ export interface FinanceDashboard {
|
|||||||
pickup_summary: FinancePickupSummary
|
pickup_summary: FinancePickupSummary
|
||||||
mohong_summary: FinanceMohongSummary
|
mohong_summary: FinanceMohongSummary
|
||||||
disbursement_summary: FinanceDisbursementSummary
|
disbursement_summary: FinanceDisbursementSummary
|
||||||
|
operating_expense_summary: OperatingExpenseSummary
|
||||||
generated_at: string
|
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 {
|
export interface FinanceDetail {
|
||||||
order_id: number
|
order_id: number
|
||||||
order_no: string
|
order_no: string
|
||||||
@@ -309,6 +368,10 @@ export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) {
|
|||||||
manual_paid_amount_cent: 0,
|
manual_paid_amount_cent: 0,
|
||||||
manual_paid_count: 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<ApiResponse<OperatingExpenseList>>(
|
||||||
|
'/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<ApiResponse<OperatingExpense>>(
|
||||||
|
'/admin/finance/operating-expenses',
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function voidOperatingExpense(id: number, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<OperatingExpense>>(
|
||||||
|
`/admin/finance/operating-expenses/${id}/void`,
|
||||||
|
{ reason }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function createManualDisbursement(payload: CreateManualDisbursementPayload) {
|
export async function createManualDisbursement(payload: CreateManualDisbursementPayload) {
|
||||||
const { data } = await apiClient.post<ApiResponse<ManualDisbursement>>(
|
const { data } = await apiClient.post<ApiResponse<ManualDisbursement>>(
|
||||||
'/admin/finance/manual-disbursements',
|
'/admin/finance/manual-disbursements',
|
||||||
|
|||||||
@@ -204,6 +204,11 @@ function diffType(value: number) {
|
|||||||
<strong>{{ moneyCent(dashboard.disbursement_summary.manual_paid_amount_cent) }}</strong>
|
<strong>{{ moneyCent(dashboard.disbursement_summary.manual_paid_amount_cent) }}</strong>
|
||||||
<small>{{ dashboard.disbursement_summary.manual_paid_count }} 笔有效记账</small>
|
<small>{{ dashboard.disbursement_summary.manual_paid_count }} 笔有效记账</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="metric-card operating-expense-card">
|
||||||
|
<span>运营开支</span>
|
||||||
|
<strong>{{ moneyCent(dashboard.operating_expense_summary.amount_cent) }}</strong>
|
||||||
|
<small>{{ dashboard.operating_expense_summary.count }} 笔有效记账</small>
|
||||||
|
</div>
|
||||||
<div class="metric-card pending-card">
|
<div class="metric-card pending-card">
|
||||||
<span>当前待出款</span>
|
<span>当前待出款</span>
|
||||||
<strong>{{ moneyCent(dashboard.disbursement_summary.pending_payment_amount_cent) }}</strong>
|
<strong>{{ moneyCent(dashboard.disbursement_summary.pending_payment_amount_cent) }}</strong>
|
||||||
@@ -286,6 +291,9 @@ function diffType(value: number) {
|
|||||||
<el-table-column label="实际出款" width="130">
|
<el-table-column label="实际出款" width="130">
|
||||||
<template #default="{ row }">{{ moneyCent(row.disbursement_paid_amount_cent) }}</template>
|
<template #default="{ row }">{{ moneyCent(row.disbursement_paid_amount_cent) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="运营开支" width="130">
|
||||||
|
<template #default="{ row }">{{ moneyCent(row.operating_expense_amount_cent) }}</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<p v-if="dashboard" class="generated-at">
|
<p v-if="dashboard" class="generated-at">
|
||||||
@@ -331,6 +339,10 @@ function diffType(value: number) {
|
|||||||
border-left: 3px solid #0f766e;
|
border-left: 3px solid #0f766e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.operating-expense-card {
|
||||||
|
border-left: 3px solid #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
.pending-card {
|
.pending-card {
|
||||||
border-left: 3px solid #d97706;
|
border-left: 3px solid #d97706;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CircleClose, Plus, Search, UploadFilled, View } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createOperatingExpense,
|
||||||
|
fetchOperatingExpenses,
|
||||||
|
voidOperatingExpense,
|
||||||
|
type OperatingExpense,
|
||||||
|
type OperatingExpenseListSummary,
|
||||||
|
} from '@/features/admin/api/adminFinance'
|
||||||
|
import AuthImage from '@/shared/components/business/AuthImage.vue'
|
||||||
|
import { uploadAdminFile } from '@/shared/api/files'
|
||||||
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
|
import { readError } from '@/shared/utils/error'
|
||||||
|
import { formatCentWithSymbol, yuanToCent } from '@/shared/utils/money'
|
||||||
|
import { formatDateTime, formatInputDate } from '@/shared/utils/time'
|
||||||
|
import AdminTablePagination from '../components/AdminTablePagination.vue'
|
||||||
|
|
||||||
|
const adminSession = useAdminSessionStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const createVisible = ref(false)
|
||||||
|
const createSaving = ref(false)
|
||||||
|
const voucherUploading = ref(false)
|
||||||
|
const actionID = ref(0)
|
||||||
|
const selectedExpense = ref<OperatingExpense | null>(null)
|
||||||
|
const items = ref<OperatingExpense[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const summary = ref<OperatingExpenseListSummary>(emptySummary())
|
||||||
|
const canManage = computed(() => adminSession.hasPermission('finance:operating_expense'))
|
||||||
|
const filters = reactive({
|
||||||
|
date_type: 'created' as 'created' | 'occurred',
|
||||||
|
start_date: defaultStartDate(),
|
||||||
|
end_date: formatInputDate(new Date()),
|
||||||
|
status: '',
|
||||||
|
category: '',
|
||||||
|
keyword: '',
|
||||||
|
})
|
||||||
|
const createForm = reactive({
|
||||||
|
category: '',
|
||||||
|
payee_name: '',
|
||||||
|
amount_yuan: 0,
|
||||||
|
occurred_at: new Date(),
|
||||||
|
remark: '',
|
||||||
|
voucher_url: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadItems)
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchOperatingExpenses({
|
||||||
|
...filters,
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: currentPageSize.value,
|
||||||
|
})
|
||||||
|
items.value = result.items
|
||||||
|
total.value = result.total
|
||||||
|
summary.value = result.summary
|
||||||
|
} catch (error) {
|
||||||
|
items.value = []
|
||||||
|
total.value = 0
|
||||||
|
summary.value = emptySummary()
|
||||||
|
ElMessage.error(readError(error, '运营开支记录加载失败'))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
currentPage.value = 1
|
||||||
|
void loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filters.date_type = 'created'
|
||||||
|
filters.start_date = defaultStartDate()
|
||||||
|
filters.end_date = formatInputDate(new Date())
|
||||||
|
filters.status = ''
|
||||||
|
filters.category = ''
|
||||||
|
filters.keyword = ''
|
||||||
|
currentPage.value = 1
|
||||||
|
void loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
createForm.category = ''
|
||||||
|
createForm.payee_name = ''
|
||||||
|
createForm.amount_yuan = 0
|
||||||
|
createForm.occurred_at = new Date()
|
||||||
|
createForm.remark = ''
|
||||||
|
createForm.voucher_url = ''
|
||||||
|
createVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadVoucher(options: { file: File }) {
|
||||||
|
if (!options.file.type.startsWith('image/')) {
|
||||||
|
ElMessage.warning('开支凭证仅支持图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
voucherUploading.value = true
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadAdminFile(options.file, 'operating-expense')
|
||||||
|
createForm.voucher_url = uploaded.url
|
||||||
|
ElMessage.success('凭证已上传')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '凭证上传失败'))
|
||||||
|
} finally {
|
||||||
|
voucherUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitExpense() {
|
||||||
|
const amountCent = yuanToCent(createForm.amount_yuan)
|
||||||
|
if (
|
||||||
|
!createForm.category.trim() ||
|
||||||
|
!createForm.payee_name.trim() ||
|
||||||
|
amountCent <= 0 ||
|
||||||
|
!createForm.occurred_at ||
|
||||||
|
!createForm.remark.trim()
|
||||||
|
) {
|
||||||
|
ElMessage.warning('请填写完整的开支信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createSaving.value = true
|
||||||
|
try {
|
||||||
|
await createOperatingExpense({
|
||||||
|
category: createForm.category.trim(),
|
||||||
|
payee_name: createForm.payee_name.trim(),
|
||||||
|
amount_cent: amountCent,
|
||||||
|
occurred_at: createForm.occurred_at.toISOString(),
|
||||||
|
remark: createForm.remark.trim(),
|
||||||
|
voucher_url: createForm.voucher_url || undefined,
|
||||||
|
})
|
||||||
|
createVisible.value = false
|
||||||
|
ElMessage.success('运营开支已记账')
|
||||||
|
await loadItems()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '运营开支记账失败'))
|
||||||
|
} finally {
|
||||||
|
createSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVoid(item: OperatingExpense) {
|
||||||
|
if (actionID.value) return
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt(
|
||||||
|
`确认作废开支单 ${item.expense_no}?作废后将从运营开支统计中排除。`,
|
||||||
|
'作废运营开支',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确认作废',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputPlaceholder: '请输入作废原因',
|
||||||
|
inputValidator: value => !!value.trim() || '请输入作废原因',
|
||||||
|
type: 'warning',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
actionID.value = item.id
|
||||||
|
await voidOperatingExpense(item.id, value.trim())
|
||||||
|
selectedExpense.value = null
|
||||||
|
ElMessage.success('运营开支已作废')
|
||||||
|
await loadItems()
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(readError(error, '运营开支作废失败'))
|
||||||
|
} finally {
|
||||||
|
actionID.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySummary(): OperatingExpenseListSummary {
|
||||||
|
return { record_count: 0, paid_amount_cent: 0, paid_count: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultStartDate() {
|
||||||
|
const date = new Date()
|
||||||
|
date.setDate(date.getDate() - 29)
|
||||||
|
return formatInputDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: number) {
|
||||||
|
return formatCentWithSymbol(Number(value || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string) {
|
||||||
|
return status === 'paid' ? '已记账' : '已作废'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusType(status: string) {
|
||||||
|
return status === 'paid' ? 'success' : 'danger'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page operating-expenses-page">
|
||||||
|
<div class="page-header-row">
|
||||||
|
<div class="page-header">
|
||||||
|
<p class="eyebrow">Operating Expenses</p>
|
||||||
|
<h1>运营开支</h1>
|
||||||
|
<p>独立记录日常运营费用,并在财务仪表盘按发生日期汇总。</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button v-if="canManage" type="success" :icon="Plus" @click="openCreate">
|
||||||
|
新增运营开支
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
|
<el-button type="primary" :icon="Search" :loading="loading" @click="search">查询</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-grid operating-metrics">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>筛选记录</span>
|
||||||
|
<strong>{{ summary.record_count }} 笔</strong>
|
||||||
|
<small>当前筛选条件</small>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card operating-metric">
|
||||||
|
<span>有效运营开支</span>
|
||||||
|
<strong>{{ money(summary.paid_amount_cent) }}</strong>
|
||||||
|
<small>{{ summary.paid_count }} 笔已记账开支</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form class="table-panel operating-filters" label-position="top">
|
||||||
|
<el-form-item label="日期类型">
|
||||||
|
<el-select v-model="filters.date_type" class="full-control">
|
||||||
|
<el-option label="录入日期" value="created" />
|
||||||
|
<el-option label="开支日期" value="occurred" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开始日期">
|
||||||
|
<el-date-picker v-model="filters.start_date" class="full-control" type="date" value-format="YYYY-MM-DD" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="结束日期">
|
||||||
|
<el-date-picker v-model="filters.end_date" class="full-control" type="date" value-format="YYYY-MM-DD" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="filters.status" clearable placeholder="全部状态" class="full-control">
|
||||||
|
<el-option label="已记账" value="paid" />
|
||||||
|
<el-option label="已作废" value="voided" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开支类别">
|
||||||
|
<el-input v-model="filters.category" clearable placeholder="按类别精确筛选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="filters.keyword" clearable placeholder="单号、收款方或用途" @keyup.enter="search" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" class="table-panel" :data="items">
|
||||||
|
<el-table-column label="开支单号" min-width="190">
|
||||||
|
<template #default="{ row }">{{ row.expense_no }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="category" label="开支类别" min-width="130" />
|
||||||
|
<el-table-column label="收款方" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<strong>{{ row.payee_name }}</strong>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="开支金额" min-width="135" align="right">
|
||||||
|
<template #default="{ row }"><strong>{{ money(row.amount_cent) }}</strong></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="开支时间" min-width="175">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.occurred_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="105" align="center">
|
||||||
|
<template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="录入人" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.created_by_name || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="备注" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.remark }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="145" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" :icon="View" @click="selectedExpense = row">详情</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'paid' && canManage"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:icon="CircleClose"
|
||||||
|
:loading="actionID === row.id"
|
||||||
|
@click="handleVoid(row)"
|
||||||
|
>
|
||||||
|
作废
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<AdminTablePagination
|
||||||
|
v-if="total > 0"
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:loading="loading"
|
||||||
|
@page-change="loadItems"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-dialog v-model="createVisible" title="新增运营开支" width="560px" :close-on-click-modal="false">
|
||||||
|
<el-form class="expense-form" label-position="top" @submit.prevent="submitExpense">
|
||||||
|
<el-form-item label="开支类别" required>
|
||||||
|
<el-input v-model="createForm.category" maxlength="50" placeholder="例如:推广投放、办公采购" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="收款方" required>
|
||||||
|
<el-input v-model="createForm.payee_name" maxlength="100" placeholder="姓名或单位名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开支金额" required>
|
||||||
|
<el-input-number v-model="createForm.amount_yuan" class="full-control" :min="0.01" :precision="2" :step="100" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开支时间" required>
|
||||||
|
<el-date-picker v-model="createForm.occurred_at" class="full-control" type="datetime" placeholder="选择开支时间" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="expense-form-wide" label="开支用途" required>
|
||||||
|
<el-input v-model="createForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="填写开支用途" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="expense-form-wide" label="开支凭证(选填)">
|
||||||
|
<div v-if="createForm.voucher_url" class="voucher-preview">
|
||||||
|
<AuthImage :source="createForm.voucher_url" :admin="true" fit="cover" :preview-src-list="[createForm.voucher_url]" :image-style="{ width: '104px', height: '104px', borderRadius: '6px' }" />
|
||||||
|
<el-button class="voucher-remove" circle plain type="danger" :icon="CircleClose" title="移除凭证" @click="createForm.voucher_url = ''" />
|
||||||
|
</div>
|
||||||
|
<el-upload v-else :show-file-list="false" :http-request="uploadVoucher" accept="image/jpeg,image/png,image/webp">
|
||||||
|
<el-button :icon="UploadFilled" :loading="voucherUploading">上传凭证</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button :disabled="createSaving" @click="createVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="createSaving" :disabled="voucherUploading" @click="submitExpense">确认记账</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog :model-value="!!selectedExpense" title="运营开支详情" width="620px" @update:model-value="selectedExpense = null">
|
||||||
|
<el-descriptions v-if="selectedExpense" :column="2" border>
|
||||||
|
<el-descriptions-item label="开支单号">{{ selectedExpense.expense_no }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态"><el-tag :type="statusType(selectedExpense.status)">{{ statusLabel(selectedExpense.status) }}</el-tag></el-descriptions-item>
|
||||||
|
<el-descriptions-item label="开支类别">{{ selectedExpense.category }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="收款方">{{ selectedExpense.payee_name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="开支金额"><strong>{{ money(selectedExpense.amount_cent) }}</strong></el-descriptions-item>
|
||||||
|
<el-descriptions-item label="开支时间">{{ formatDateTime(selectedExpense.occurred_at) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="录入人">{{ selectedExpense.created_by_name || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="录入时间">{{ formatDateTime(selectedExpense.created_at) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="开支用途" :span="2">{{ selectedExpense.remark }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="selectedExpense.voucher_url" label="开支凭证" :span="2">
|
||||||
|
<AuthImage :source="selectedExpense.voucher_url" :admin="true" fit="cover" :preview-src-list="[selectedExpense.voucher_url]" :image-style="{ width: '140px', height: '140px', borderRadius: '6px' }" />
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="selectedExpense.status === 'voided'" label="作废信息" :span="2">
|
||||||
|
{{ selectedExpense.voided_by_name || '-' }} · {{ formatDateTime(selectedExpense.voided_at) }} · {{ selectedExpense.void_reason || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="selectedExpense = null">关闭</el-button>
|
||||||
|
<el-button v-if="selectedExpense?.status === 'paid' && canManage" type="danger" :icon="CircleClose" :loading="actionID === selectedExpense.id" @click="handleVoid(selectedExpense)">作废记录</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.operating-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(170px, 1fr));
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operating-metric {
|
||||||
|
border-left: 3px solid #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operating-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(130px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-form-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucher-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucher-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
right: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.operating-filters {
|
||||||
|
grid-template-columns: repeat(3, minmax(140px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -169,6 +169,12 @@ const allNavGroups: NavGroup[] = [
|
|||||||
icon: Money,
|
icon: Money,
|
||||||
permission: 'wallet:view',
|
permission: 'wallet:view',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '运营开支',
|
||||||
|
to: adminPath('finance/operating-expenses'),
|
||||||
|
icon: Money,
|
||||||
|
permission: 'finance:operating_expense',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '资金流水',
|
label: '资金流水',
|
||||||
to: adminPath('wallet-ledger'),
|
to: adminPath('wallet-ledger'),
|
||||||
|
|||||||
@@ -138,6 +138,12 @@ export const adminRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/features/admin/views/AdminFinanceDisbursementsView.vue'),
|
component: () => import('@/features/admin/views/AdminFinanceDisbursementsView.vue'),
|
||||||
meta: adminMeta,
|
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'),
|
path: adminPath('wallet-ledger'),
|
||||||
name: 'admin-wallet-ledger',
|
name: 'admin-wallet-ledger',
|
||||||
|
|||||||
Reference in New Issue
Block a user