package adminfinance import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "time" "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/timeutil" "gorm.io/gorm" "gorm.io/gorm/clause" ) type manualDisbursementRecord struct { ID uint64 DisbursementNo string Category string CustomCategoryName string PayeeName string AmountCent int64 PaidAt time.Time Remark string VoucherURL string Status string CreatedBy uint64 VoidedBy *uint64 VoidedAt *time.Time VoidReason string CreatedAt time.Time UpdatedAt time.Time } func (manualDisbursementRecord) TableName() string { return "manual_disbursements" } func (r *Repository) CreateManualDisbursement( ctx context.Context, req CreateManualDisbursementRequest, adminID uint64, meta auditlog.Meta, ) (*ManualDisbursementDTO, error) { disbursementNo, err := newManualDisbursementNo() if err != nil { return nil, err } record := manualDisbursementRecord{ DisbursementNo: disbursementNo, Category: req.Category, CustomCategoryName: req.CustomCategoryName, PayeeName: req.PayeeName, AmountCent: req.AmountCent, PaidAt: req.PaidAt, 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: "manual_disbursement_create", BizType: "manual_disbursement", BizID: &id, Meta: meta, Detail: map[string]any{ "disbursement_no": disbursementNo, "category": req.Category, "custom_category_name": req.CustomCategoryName, "payee_name": req.PayeeName, "amount_cent": req.AmountCent, "paid_at": req.PaidAt, "has_voucher": req.VoucherURL != "", }, }) }) if err != nil { return nil, err } return r.findManualDisbursement(ctx, record.ID) } func (r *Repository) VoidManualDisbursement( ctx context.Context, id uint64, reason string, adminID uint64, meta auditlog.Meta, ) (*ManualDisbursementDTO, error) { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var record manualDisbursementRecord if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&record, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrManualDisbursementNotFound } return err } if record.Status != "paid" { return ErrManualDisbursementNotPaid } 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: "manual_disbursement_void", BizType: "manual_disbursement", BizID: &bizID, Meta: meta, Detail: map[string]any{ "disbursement_no": record.DisbursementNo, "amount_cent": record.AmountCent, "reason": reason, }, }) }) if err != nil { return nil, err } return r.findManualDisbursement(ctx, id) } func (r *Repository) findManualDisbursement(ctx context.Context, id uint64) (*ManualDisbursementDTO, error) { var row manualDisbursementDetailRow err := r.db.WithContext(ctx).Table("manual_disbursements AS md"). Select(`md.*, 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 = md.created_by"). Joins("LEFT JOIN admin_users AS voider ON voider.id = md.voided_by"). Where("md.id = ?", id). Take(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrManualDisbursementNotFound } if err != nil { return nil, err } return &ManualDisbursementDTO{ ID: row.ID, DisbursementNo: row.DisbursementNo, Category: row.Category, CustomCategoryName: row.CustomCategoryName, PayeeName: row.PayeeName, AmountCent: row.AmountCent, PaidAt: row.PaidAt, Remark: row.Remark, VoucherURL: row.VoucherURL, Status: row.Status, CreatedBy: row.CreatedBy, CreatedByName: row.CreatedByName, VoidedBy: row.VoidedBy, VoidedByName: row.VoidedByName, VoidedAt: row.VoidedAt, VoidReason: row.VoidReason, CreatedAt: row.CreatedAt, }, nil } type manualDisbursementDetailRow struct { ID uint64 DisbursementNo string Category string CustomCategoryName string PayeeName string AmountCent int64 PaidAt 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 newManualDisbursementNo() (string, error) { buf := make([]byte, 4) if _, err := rand.Read(buf); err != nil { return "", err } return fmt.Sprintf("OD%s%s", timeutil.ShanghaiNow().Format("20060102150405"), hex.EncodeToString(buf)), nil }