package employeecollection import ( "context" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // BillCloseService 关闭员工代收款账单。 // 仅超级管理员可关闭,且只允许关闭仍待核销或部分核销、且不存在审批中分摊的账单。 type BillCloseService struct { db *gorm.DB audit BillAuditWriter } // NewBillCloseService 创建账单关闭事务脚本。 func NewBillCloseService(db *gorm.DB, audit BillAuditWriter) *BillCloseService { return &BillCloseService{db: db, audit: audit} } // Close 关闭账单:作废未核销余额、保留已核销金额,并在同一事务内写关闭审计。 // 并发关闭通过行锁加 expected-status 条件更新兜底,状态已变化时返回冲突。 func (s *BillCloseService) Close(ctx context.Context, id uint, reason string) (*model.EmployeeCollectionBill, error) { operatorID, err := requireSuperAdmin(ctx) if err != nil { return nil, err } if s == nil || s.db == nil || s.audit == nil { return nil, errors.New(errors.CodeServiceUnavailable, "账单关闭能力尚未配置") } if id == 0 { return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound) } closeReason := strings.TrimSpace(reason) var closed *model.EmployeeCollectionBill err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { bill, err := lockBill(ctx, tx, id) if err != nil { return err } var pendingAllocations int64 if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAllocation{}). Where("bill_id = ? AND status = ?", id, constants.EmployeeCollectionAllocationStatusPending). Count(&pendingAllocations).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "统计账单审批中分摊失败") } if err := employeecollectiondomain.ValidateBillClose(employeecollectiondomain.BillCloseInput{ Status: bill.Status, PendingAllocations: pendingAllocations, Reason: closeReason, }); err != nil { return err } before := *bill closedAt := time.Now().UTC() result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}). Where("id = ? AND status = ?", bill.ID, bill.Status). Updates(map[string]any{ "status": constants.EmployeeCollectionBillStatusClosed, "closed_reason": closeReason, "closed_at": closedAt, "updater": operatorID, }) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "关闭员工代收款账单失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "账单状态已变化,请刷新后重试") } bill.Status = constants.EmployeeCollectionBillStatusClosed bill.ClosedReason = closeReason bill.ClosedAt = &closedAt bill.Updater = operatorID closeEventID, err := composeAuditEventID("employee_collection", "bill", uintText(bill.ID), "close") if err != nil { return err } if err := s.audit.WriteEmployeeCollectionBill(ctx, tx, BillAudit{ EventID: closeEventID, ActionCode: constants.AuditActionEmployeeCollectionBillClosed, Summary: "关闭员工代收款账单", Bill: bill, BeforeData: map[string]any{ "status": before.Status, "closed_reason": before.ClosedReason, }, AfterData: map[string]any{ "status": bill.Status, "closed_reason": bill.ClosedReason, }, CorrelationID: bill.SourceNo, }); err != nil { return err } closed = bill return nil }) if err != nil { return nil, err } return closed, nil } // lockBill 以行锁读取账单,未找到返回稳定不存在错误。 func lockBill(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionBill, error) { var bill model.EmployeeCollectionBill if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). First(&bill, id).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound) } return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定员工代收款账单失败") } return &bill, nil }