// Package employeecollection 收口员工代收款账单、核销申请与线下收款方式字典的写用例。 // 写用例在事务内保存业务事实与审计事实,读取由 internal/query 提供。 package employeecollection import ( "context" stdErrors "errors" "strconv" "github.com/jackc/pgx/v5/pgconn" "gorm.io/gorm" "gorm.io/gorm/clause" systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" ) // AuditWriter 接收员工代收款用例在业务事务内产生的配置审计事实。 type AuditWriter interface { WriteConfigChange(ctx context.Context, tx *gorm.DB, audit systemconfigapp.ChangeAudit) error } // PaymentMethodService 维护线下收款方式字典。 // 已启用的字典项由其稳定编码对外,被核销申请引用后只可停用,不允许物理删除或改编码。 type PaymentMethodService struct { db *gorm.DB audit AuditWriter } // NewPaymentMethodService 创建线下收款方式字典事务脚本。 func NewPaymentMethodService(db *gorm.DB, audit AuditWriter) *PaymentMethodService { return &PaymentMethodService{db: db, audit: audit} } // Create 创建线下收款方式,并在同一事务内写入配置审计。 func (s *PaymentMethodService) Create( ctx context.Context, request dto.CreateEmployeeCollectionPaymentMethodRequest, ) (*dto.EmployeeCollectionPaymentMethodResponse, 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, "线下收款方式维护能力尚未配置") } status := constants.EmployeeCollectionPaymentMethodStatusDisabled if request.Enabled != nil && *request.Enabled { status = constants.EmployeeCollectionPaymentMethodStatusEnabled } var sortOrder int64 if request.Sort != nil { sortOrder = *request.Sort } normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{ Code: request.Code, Name: request.Name, SortOrder: sortOrder, Status: status, Remark: request.Remark, }) if err != nil { return nil, err } paymentMethod := &model.EmployeeCollectionPaymentMethod{ Code: normalized.Code, Name: normalized.Name, SortOrder: normalized.SortOrder, Status: normalized.Status, Remark: normalized.Remark, BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID}, } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := ensurePaymentMethodCodeAvailable(ctx, tx, normalized.Code, 0); err != nil { return err } if err := tx.WithContext(ctx).Create(paymentMethod).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建线下收款方式失败") } return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{ OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodCreate, Description: "创建线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID), Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID), DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod), AfterData: paymentMethodAuditSnapshot(paymentMethod), Result: constants.AuditResultSuccess, }) }) if err != nil { return nil, mapPaymentMethodCodeConflict(err) } return toPaymentMethodResponse(paymentMethod), nil } // Update 修改线下收款方式的名称、排序、启停与备注,并在未被引用时允许修改稳定编码。 func (s *PaymentMethodService) Update( ctx context.Context, id uint, request dto.UpdateEmployeeCollectionPaymentMethodRequest, ) (*dto.EmployeeCollectionPaymentMethodResponse, 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.CodeEmployeeCollectionPaymentMethodNotFound) } var updated *model.EmployeeCollectionPaymentMethod err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { paymentMethod, err := lockPaymentMethod(ctx, tx, id) if err != nil { return err } before := *paymentMethod beforeData := paymentMethodAuditSnapshot(&before) if request.Code != nil { code := *request.Code normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{ Code: code, Name: paymentMethod.Name, SortOrder: paymentMethod.SortOrder, Status: paymentMethod.Status, Remark: paymentMethod.Remark, }) if err != nil { return err } if normalized.Code != paymentMethod.Code { referenced, err := countPaymentMethodReferences(ctx, tx, id) if err != nil { return err } if referenced > 0 { return errors.New(errors.CodeEmployeeCollectionPaymentMethodReferenced, "线下收款方式已被核销申请或代理充值申请引用,不能修改稳定编码") } if err := ensurePaymentMethodCodeAvailable(ctx, tx, normalized.Code, id); err != nil { return err } } paymentMethod.Code = normalized.Code } if request.Name != nil { paymentMethod.Name = *request.Name } if request.Sort != nil { paymentMethod.SortOrder = *request.Sort } if request.Enabled != nil { if *request.Enabled { paymentMethod.Status = constants.EmployeeCollectionPaymentMethodStatusEnabled } else { paymentMethod.Status = constants.EmployeeCollectionPaymentMethodStatusDisabled } } if request.Remark != nil { paymentMethod.Remark = *request.Remark } normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{ Code: paymentMethod.Code, Name: paymentMethod.Name, SortOrder: paymentMethod.SortOrder, Status: paymentMethod.Status, Remark: paymentMethod.Remark, }) if err != nil { return err } paymentMethod.Code = normalized.Code paymentMethod.Name = normalized.Name paymentMethod.SortOrder = normalized.SortOrder paymentMethod.Status = normalized.Status paymentMethod.Remark = normalized.Remark paymentMethod.Updater = operatorID if err := tx.WithContext(ctx).Save(paymentMethod).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新线下收款方式失败") } if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{ OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodUpdate, Description: "更新线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID), Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID), DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod), BeforeData: beforeData, AfterData: paymentMethodAuditSnapshot(paymentMethod), Result: constants.AuditResultSuccess, }); err != nil { return err } updated = paymentMethod return nil }) if err != nil { return nil, mapPaymentMethodCodeConflict(err) } return toPaymentMethodResponse(updated), nil } // Delete 物理删除未被任何核销申请引用的线下收款方式,并写入配置审计。 // 已被引用的字典项只允许停用,保证历史申请继续显示冻结名称。 func (s *PaymentMethodService) Delete(ctx context.Context, id uint) error { operatorID, err := requireSuperAdmin(ctx) if err != nil { return err } if s == nil || s.db == nil || s.audit == nil { return errors.New(errors.CodeServiceUnavailable, "线下收款方式维护能力尚未配置") } if id == 0 { return errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound) } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { paymentMethod, err := lockPaymentMethod(ctx, tx, id) if err != nil { return err } referenced, err := countPaymentMethodReferences(ctx, tx, id) if err != nil { return err } if referenced > 0 { return errors.New(errors.CodeEmployeeCollectionPaymentMethodReferenced) } beforeData := paymentMethodAuditSnapshot(paymentMethod) paymentMethod.Updater = operatorID if err := tx.WithContext(ctx).Save(paymentMethod).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新线下收款方式失败") } if err := tx.WithContext(ctx).Delete(paymentMethod).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "删除线下收款方式失败") } return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{ OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodDelete, Description: "删除线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID), Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID), DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod), BeforeData: beforeData, Result: constants.AuditResultSuccess, }) }) } // requireSuperAdmin 校验当前调用者是超级管理员,并返回其账号 ID。 // 字典维护不对外开放,未授权一律返回同一禁止访问错误。 func requireSuperAdmin(ctx context.Context) (uint, error) { if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { return 0, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } operatorID := middleware.GetUserIDFromContext(ctx) if operatorID == 0 { return 0, errors.New(errors.CodeUnauthorized) } return operatorID, nil } // lockPaymentMethod 以行锁读取线下收款方式,未找到返回稳定不存在错误。 func lockPaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) { var paymentMethod model.EmployeeCollectionPaymentMethod err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). First(&paymentMethod, id).Error if err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound) } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败") } return &paymentMethod, nil } // ensurePaymentMethodCodeAvailable 校验稳定编码在未删除记录中唯一,excludeID 用于更新自身。 func ensurePaymentMethodCodeAvailable(ctx context.Context, tx *gorm.DB, code string, excludeID uint) error { query := tx.WithContext(ctx).Model(&model.EmployeeCollectionPaymentMethod{}).Where("code = ?", code) if excludeID != 0 { query = query.Where("id <> ?", excludeID) } var count int64 if err := query.Count(&count).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "校验线下收款方式编码失败") } if count > 0 { return errors.New(errors.CodeEmployeeCollectionPaymentMethodCodeExists) } return nil } // countPaymentMethodReferences 统计引用该收款方式的核销申请与代理充值申请数量。 // 两类引用任一存在即禁止物理删除与改码,历史快照由各自记录冻结。 func countPaymentMethodReferences(ctx context.Context, tx *gorm.DB, id uint) (int64, error) { var applicationCount int64 if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}). Where("payment_method_id = ?", id).Count(&applicationCount).Error; err != nil { return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计线下收款方式引用失败") } if applicationCount > 0 { return applicationCount, nil } var rechargeCount int64 if err := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}). Where("offline_payment_method_id = ?", id).Count(&rechargeCount).Error; err != nil { return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计代理充值线下收款方式引用失败") } return rechargeCount, nil } // mapPaymentMethodCodeConflict 把稳定编码唯一索引冲突映射为稳定业务错误。 // 并发创建或改码时唯一索引是最终裁决,避免把约束冲突暴露成内部错误。 func mapPaymentMethodCodeConflict(err error) error { var pgErr *pgconn.PgError if stdErrors.As(err, &pgErr) && pgErr.Code == "23505" { return errors.New(errors.CodeEmployeeCollectionPaymentMethodCodeExists) } return err } // toPaymentMethodResponse 将字典项投影为对外响应。 func toPaymentMethodResponse(paymentMethod *model.EmployeeCollectionPaymentMethod) *dto.EmployeeCollectionPaymentMethodResponse { if paymentMethod == nil { return nil } return &dto.EmployeeCollectionPaymentMethodResponse{ ID: paymentMethod.ID, Code: paymentMethod.Code, Name: paymentMethod.Name, Enabled: paymentMethod.Status == constants.EmployeeCollectionPaymentMethodStatusEnabled, Sort: paymentMethod.SortOrder, Remark: paymentMethod.Remark, CreatedAt: paymentMethod.CreatedAt, UpdatedAt: paymentMethod.UpdatedAt, } } // paymentMethodAuditConfigKey 生成字典项的审计配置键。 func paymentMethodAuditConfigKey(id uint) string { return constants.EmployeeCollectionAuditConfigKeyPrefix + "." + strconv.FormatUint(uint64(id), 10) } // paymentMethodAuditResourceID 生成字典项审计资源标识。 func paymentMethodAuditResourceID(id uint) *string { value := strconv.FormatUint(uint64(id), 10) return &value } // paymentMethodAuditIdentity 生成字典项审计身份快照,不含任何凭证内容。 func paymentMethodAuditIdentity(paymentMethod *model.EmployeeCollectionPaymentMethod) map[string]any { return map[string]any{ "id": paymentMethod.ID, "code": paymentMethod.Code, "name": paymentMethod.Name, "status": paymentMethod.Status, "sort": paymentMethod.SortOrder, } } // paymentMethodAuditSnapshot 生成字典项审计前后值快照,不含任何凭证内容。 func paymentMethodAuditSnapshot(paymentMethod *model.EmployeeCollectionPaymentMethod) map[string]any { return map[string]any{ "id": paymentMethod.ID, "code": paymentMethod.Code, "name": paymentMethod.Name, "status": paymentMethod.Status, "sort": paymentMethod.SortOrder, "remark": paymentMethod.Remark, } }