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 }