增加 运营开支
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user