diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 8736a69..5f6329f 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -30,6 +30,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 handlers.WeCom = admin.NewWeComHandler(nil, nil) handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil) + handlers.EmployeeCollection = admin.NewEmployeeCollectionHandler(nil, nil, nil) handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 5ad70a6..5151176 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -39,6 +39,7 @@ func generateAdminDocs(outputPath string) error { // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 handlers.WeCom = admin.NewWeComHandler(nil, nil) handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil) + handlers.EmployeeCollection = admin.NewEmployeeCollectionHandler(nil, nil, nil) handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index eeefce3..11798ab 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -18,6 +18,7 @@ import ( approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval" auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet" @@ -379,6 +380,10 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con refundService.SetNotificationOutbox(outbox.NewRepository()) refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient)) refundService.SetLifecycleAudit(auditWriter) + // 员工代收款退款冲销与建账共用同一审计 Writer,接入点仅在企微退款成功事务内。 + refundService.SetEmployeeCollectionRefundOffset( + employeecollectionApp.NewRefundOffsetService(auditWriter), + ) if err := runtime.outboxConsumers.Register(commissionDelivery.EventRefundCommissionDeduct, commissionDelivery.NewRefundConsumer(refundService.ProcessCommissionDeduction, refundService.ProcessAssetPostProcessing)); err != nil { appLogger.Fatal("注册退款佣金回扣 Outbox 消费者失败", zap.Error(err)) } @@ -392,8 +397,14 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con decisionDispatcher := approvalApp.NewDecisionDispatcher( approvalInfra.NewDecisionDeliveryStore(runtime.db), map[string]approvalApp.BusinessDecisionHandler{ - constants.ApprovalBusinessTypeOfflineRecharge: agentrechargeApp.NewApprovalDecisionHandler(runtime.db, walletPosting, runtime.workerResult.Services.RechargeAudit), - constants.ApprovalBusinessTypeRefund: refundService, + constants.ApprovalBusinessTypeOfflineRecharge: agentrechargeApp.NewApprovalDecisionHandler( + runtime.db, walletPosting, runtime.workerResult.Services.RechargeAudit, + employeecollectionApp.NewBillCreationService(auditWriter), + ), + constants.ApprovalBusinessTypeRefund: refundService, + constants.ApprovalBusinessTypeEmployeeCollection: employeecollectionApp.NewApprovalDecisionHandler( + runtime.db, auditWriter, + ), }, owner, appLogger, diff --git a/internal/application/agentrecharge/approval_decision.go b/internal/application/agentrecharge/approval_decision.go index 615ba75..402343a 100644 --- a/internal/application/agentrecharge/approval_decision.go +++ b/internal/application/agentrecharge/approval_decision.go @@ -8,6 +8,7 @@ import ( "gorm.io/gorm/clause" approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/auditcontext" @@ -17,14 +18,20 @@ import ( // ApprovalDecisionHandler 将渠道无关审批终态应用到员工线下代充值业务。 type ApprovalDecisionHandler struct { - db *gorm.DB - posting *walletapp.PostingService - audit RechargeAuditWriter + db *gorm.DB + posting *walletapp.PostingService + audit RechargeAuditWriter + billCreation *employeecollectionapp.BillCreationService } // NewApprovalDecisionHandler 创建员工线下代充值审批终态消费者。 -func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService, audit RechargeAuditWriter) *ApprovalDecisionHandler { - return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit} +func NewApprovalDecisionHandler( + db *gorm.DB, + posting *walletapp.PostingService, + audit RechargeAuditWriter, + billCreation *employeecollectionapp.BillCreationService, +) *ApprovalDecisionHandler { + return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit, billCreation: billCreation} } // Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态不修改钱包。 @@ -32,6 +39,9 @@ func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp. if h == nil || h.db == nil || h.posting == nil || h.audit == nil { return errors.New(errors.CodeInternalError, "员工线下代充值审批终态能力未配置") } + if h.billCreation == nil { + return errors.New(errors.CodeInternalError, "员工代收款建账能力未配置") + } if event.BusinessType != constants.ApprovalBusinessTypeOfflineRecharge || event.BusinessID == 0 || event.InstanceID == 0 { return errors.New(errors.CodeInvalidParam, "员工线下代充值审批终态参数无效") } @@ -99,6 +109,10 @@ func (h *ApprovalDecisionHandler) applyApproved( if err != nil { return err } + // 员工代收款建账:锚点为“平台账号发起的线下充值入账成功”,按来源唯一键 recharge:{id} 幂等。 + if _, err := h.billCreation.CreateFromRechargeInTx(ctx, tx, record); err != nil { + return err + } if record.Status == constants.RechargeStatusCompleted && posting.AlreadyApplied { return nil } diff --git a/internal/application/employeecollection/application.go b/internal/application/employeecollection/application.go new file mode 100644 index 0000000..07bcb2e --- /dev/null +++ b/internal/application/employeecollection/application.go @@ -0,0 +1,671 @@ +package employeecollection + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/bytedance/sonic" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + 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" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// ApplicationAllocationCommand 描述核销申请中单张账单的本次分摊。 +type ApplicationAllocationCommand struct { + BillID uint + Amount int64 +} + +// SubmitApplicationCommand 描述创建或重提核销申请的稳定输入。 +type SubmitApplicationCommand struct { + PaymentMethodID uint + PaidAmount int64 + PayerName string + PaidAt time.Time + ExternalTransactionNo string + PaymentVoucherKeys []string + Remark string + ActingReason string + Allocations []ApplicationAllocationCommand +} + +// ApplicationSubmitResult 返回已原子保存的申请、审批尝试记录与分摊。 +type ApplicationSubmitResult struct { + Application *model.EmployeeCollectionApplication + Attempt *model.EmployeeCollectionApplicationAttempt + Allocations []*model.EmployeeCollectionApplicationAllocation + Bills []*model.EmployeeCollectionBill + InstanceID uint + InstanceStatus int +} + +// ApplicationService 创建与重提核销申请。 +// 申请、审批尝试记录、审批实例与账单预占在同一事务完成;任一校验失败都不留下半成品事实。 +type ApplicationService struct { + db *gorm.DB + approval approvalapp.Port + audit ApplicationAuditWriter +} + +// NewApplicationService 创建核销申请用例。 +func NewApplicationService(db *gorm.DB, approval approvalapp.Port, audit ApplicationAuditWriter) *ApplicationService { + return &ApplicationService{db: db, approval: approval, audit: audit} +} + +// Create 为本人可见账单创建核销申请;超级管理员可为账单欠款人代办并必须填写代办原因。 +func (s *ApplicationService) Create(ctx context.Context, command SubmitApplicationCommand) (*ApplicationSubmitResult, error) { + if err := s.ensureReady(); err != nil { + return nil, err + } + caller, err := currentApplicationCaller(ctx) + if err != nil { + return nil, err + } + return s.submit(ctx, caller, 0, command) +} + +// Resubmit 修改并重提已驳回的核销申请,新增审批尝试记录与新的企业微信审批实例。 +func (s *ApplicationService) Resubmit(ctx context.Context, applicationID uint, command SubmitApplicationCommand) (*ApplicationSubmitResult, error) { + if err := s.ensureReady(); err != nil { + return nil, err + } + caller, err := currentApplicationCaller(ctx) + if err != nil { + return nil, err + } + if applicationID == 0 { + return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound) + } + return s.submit(ctx, caller, applicationID, command) +} + +// ensureReady 校验用例依赖完整,缺失时失败关闭,避免绕过企业微信终审。 +func (s *ApplicationService) ensureReady() error { + if s == nil || s.db == nil || s.approval == nil || s.audit == nil { + return errors.New(errors.CodeServiceUnavailable, "核销申请能力尚未配置") + } + return nil +} + +// applicationCaller 是发起核销申请的真实操作者。 +type applicationCaller struct { + AccountID uint + AccountName string + IsAdmin bool +} + +// currentApplicationCaller 从上下文取当前操作者,未认证时拒绝。 +func currentApplicationCaller(ctx context.Context) (applicationCaller, error) { + accountID := middleware.GetUserIDFromContext(ctx) + if accountID == 0 { + return applicationCaller{}, errors.New(errors.CodeUnauthorized) + } + return applicationCaller{ + AccountID: accountID, + AccountName: middleware.GetUsernameFromContext(ctx), + IsAdmin: middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin, + }, nil +} + +// submit 在同一事务内完成校验、加锁、写申请、写审批尝试记录、创建审批实例与账单预占。 +// 加锁次序全仓统一为「申请行 → 账单行(ID 升序)」,与审批终态消费者保持一致,避免死锁。 +func (s *ApplicationService) submit( + ctx context.Context, + caller applicationCaller, + applicationID uint, + command SubmitApplicationCommand, +) (*ApplicationSubmitResult, error) { + if command.PaymentMethodID == 0 { + return nil, errors.New(errors.CodeInvalidParam, "核销申请必须选择线下收款方式") + } + allocationCommands, err := sortedAllocationCommands(command.Allocations) + if err != nil { + return nil, err + } + correlationID := "employee_collection:application:" + uuid.NewString() + preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{ + BusinessType: constants.ApprovalBusinessTypeEmployeeCollection, + SubmitterAccountID: caller.AccountID, CorrelationID: correlationID, + }) + if err != nil { + return nil, err + } + var result *ApplicationSubmitResult + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing *model.EmployeeCollectionApplication + if applicationID != 0 { + existing, err = lockApplication(ctx, tx, applicationID) + if err != nil { + return err + } + if err := employeecollectiondomain.ValidateApplicationResubmit(existing.Status); err != nil { + return err + } + if existing.ApplicantAccountID != caller.AccountID && !caller.IsAdmin { + return errors.New(errors.CodeEmployeeCollectionApplicationNotFound) + } + } + bills, err := lockBillsInAscendingOrder(ctx, tx, allocationCommands) + if err != nil { + return err + } + paymentMethod, err := loadEnabledPaymentMethod(ctx, tx, command.PaymentMethodID) + if err != nil { + return err + } + applicantAccountID, err := resolveApplicantAccountID(caller, existing, bills) + if err != nil { + return err + } + acting := applicantAccountID != caller.AccountID + normalized, err := employeecollectiondomain.NormalizeApplicationInput(employeecollectiondomain.ApplicationInput{ + PaidAmount: command.PaidAmount, PayerName: command.PayerName, PaidAt: command.PaidAt, + ExternalTransactionNo: command.ExternalTransactionNo, Remark: command.Remark, + ActingReason: command.ActingReason, PaymentVoucherKeys: command.PaymentVoucherKeys, + }, acting) + if err != nil { + return err + } + candidates := make([]employeecollectiondomain.AllocationCandidate, 0, len(allocationCommands)) + for _, item := range allocationCommands { + bill := bills[item.BillID] + candidates = append(candidates, employeecollectiondomain.AllocationCandidate{ + BillID: item.BillID, BillStatus: bill.Status, Amount: item.Amount, + Available: billAmounts(bill).Available(), + }) + } + if err := employeecollectiondomain.ValidateAllocations(normalized.PaidAmount, candidates); err != nil { + return err + } + + application, beforeData, err := prepareApplication( + ctx, tx, caller, existing, applicantAccountID, paymentMethod, normalized) + if err != nil { + return err + } + attemptNo, err := nextAttemptNo(ctx, tx, application.ID) + if err != nil { + return err + } + attempt, err := buildAttempt(ctx, tx, application.ID, attemptNo, caller, paymentMethod, normalized, bills, allocationCommands) + if err != nil { + return err + } + submitterSnapshot, requestSnapshot, err := approvalSnapshots(application.ID, caller, paymentMethod, normalized, bills, allocationCommands) + if err != nil { + return err + } + reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{ + Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeEmployeeCollection, + BusinessID: attempt.ID, SubmitterAccountID: caller.AccountID, + SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot, + CorrelationID: correlationID, + }) + if err != nil { + return err + } + if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil { + return err + } + if err := updateApplicationLatest(ctx, tx, application, attempt, reference.InstanceID); err != nil { + return err + } + allocations, reservedBills, err := createAllocations(ctx, tx, application.ID, attempt.ID, caller, bills, allocationCommands) + if err != nil { + return err + } + submitEventID, err := composeAuditEventID( + "employee_collection", "application", uintText(application.ID), "attempt", intText(attempt.AttemptNo), "submit") + if err != nil { + return err + } + if err := s.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{ + EventID: submitEventID, + ActionCode: constants.AuditActionEmployeeCollectionApplicationSubmitted, + Summary: submitSummary(existing != nil), + Application: application, Attempt: attempt, Allocations: allocations, Bills: reservedBills, + BeforeData: beforeData, AfterData: applicationAuditSnapshot(application), + CorrelationID: correlationID, + }); err != nil { + return err + } + result = &ApplicationSubmitResult{ + Application: application, Attempt: attempt, Allocations: allocations, + Bills: reservedBills, InstanceID: reference.InstanceID, InstanceStatus: reference.Status, + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +// submitSummary 区分首次提交与重提的审计摘要。 +func submitSummary(resubmit bool) string { + if resubmit { + return "重提员工代收款核销申请" + } + return "提交员工代收款核销申请" +} + +// sortedAllocationCommands 校验分摊入参基本形态并按账单 ID 升序返回,保证锁序唯一。 +func sortedAllocationCommands(items []ApplicationAllocationCommand) ([]ApplicationAllocationCommand, error) { + if len(items) == 0 { + return nil, errors.New(errors.CodeInvalidParam, "核销申请至少需要一个账单分摊") + } + if len(items) > constants.EmployeeCollectionAllocationMaxCount { + return nil, errors.New(errors.CodeInvalidParam, "核销申请账单分摊数量超出限制") + } + seen := make(map[uint]struct{}, len(items)) + for _, item := range items { + if item.BillID == 0 { + return nil, errors.New(errors.CodeInvalidParam, "账单分摊缺少目标账单") + } + if item.Amount <= 0 { + return nil, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid) + } + if _, exists := seen[item.BillID]; exists { + return nil, errors.New(errors.CodeInvalidParam, "同一账单不能重复分摊") + } + seen[item.BillID] = struct{}{} + } + sorted := append([]ApplicationAllocationCommand(nil), items...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].BillID < sorted[j].BillID }) + return sorted, nil +} + +// lockApplication 以行锁读取核销申请,未找到返回稳定不存在错误。 +func lockApplication(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionApplication, error) { + var application model.EmployeeCollectionApplication + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + First(&application, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound) + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定核销申请失败") + } + return &application, nil +} + +// lockBillsInAscendingOrder 按账单 ID 升序逐行加锁并返回账单事实。 +// 单条 `WHERE id IN (...) ORDER BY id FOR UPDATE` 在 PostgreSQL 中先取行加锁再排序, +// 无法保证加锁次序;因此对每个账单各发一条只锁一行的语句,由调用方保证 ID 升序且不重复。 +func lockBillsInAscendingOrder( + ctx context.Context, + tx *gorm.DB, + items []ApplicationAllocationCommand, +) (map[uint]*model.EmployeeCollectionBill, error) { + bills := make(map[uint]*model.EmployeeCollectionBill, len(items)) + for _, item := range items { + var bill model.EmployeeCollectionBill + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + First(&bill, item.BillID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound) + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定员工代收款账单失败") + } + bills[item.BillID] = &bill + } + return bills, nil +} + +// loadEnabledPaymentMethod 读取启用中的线下收款方式字典项作为冻结来源。 +func loadEnabledPaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) { + var paymentMethod model.EmployeeCollectionPaymentMethod + if err := tx.WithContext(ctx).First(&paymentMethod, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound) + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败") + } + if paymentMethod.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled { + return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled) + } + return &paymentMethod, nil +} + +// resolveApplicantAccountID 依据所选账单确定申请人。 +// 新建:非超级管理员只能选择本人欠款账单;超级管理员代办时全部账单必须属于同一欠款人。 +// 重提:被选账单必须仍属于原申请人,申请人的其他越权访问与不存在返回同一错误。 +func resolveApplicantAccountID( + caller applicationCaller, + existing *model.EmployeeCollectionApplication, + bills map[uint]*model.EmployeeCollectionBill, +) (uint, error) { + if existing != nil { + applicant := existing.ApplicantAccountID + for _, bill := range bills { + if bill.DebtorAccountID != applicant { + return 0, errors.New(errors.CodeEmployeeCollectionBillNotFound) + } + } + return applicant, nil + } + applicant := uint(0) + for _, bill := range bills { + if !caller.IsAdmin && bill.DebtorAccountID != caller.AccountID { + return 0, errors.New(errors.CodeEmployeeCollectionBillNotFound) + } + if applicant == 0 { + applicant = bill.DebtorAccountID + continue + } + if applicant != bill.DebtorAccountID { + return 0, errors.New(errors.CodeInvalidParam, "代办核销申请时全部账单必须属于同一欠款人") + } + } + return applicant, nil +} + +// billAmounts 将账单持久化事实映射为领域金额事实。 +func billAmounts(bill *model.EmployeeCollectionBill) employeecollectiondomain.BillAmounts { + return employeecollectiondomain.BillAmounts{ + Receivable: bill.ReceivableAmount, Received: bill.ReceivedAmount, Reserved: bill.ReservedAmount, + Closed: bill.Status == constants.EmployeeCollectionBillStatusClosed, + } +} + +// nextAttemptNo 返回该申请的下一条审批尝试序号;申请行已加锁,序号在同一事务内唯一。 +func nextAttemptNo(ctx context.Context, tx *gorm.DB, applicationID uint) (int, error) { + var row struct { + MaxAttemptNo int + } + if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAttempt{}). + Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no"). + Where("application_id = ?", applicationID).Scan(&row).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询核销审批尝试序号失败") + } + return row.MaxAttemptNo + 1, nil +} + +// prepareApplication 新建或就地更新核销申请,返回申请事实与变更前快照。 +// 重提使用 expected-status 条件更新,状态已变化时返回冲突。 +func prepareApplication( + ctx context.Context, + tx *gorm.DB, + caller applicationCaller, + existing *model.EmployeeCollectionApplication, + applicantAccountID uint, + paymentMethod *model.EmployeeCollectionPaymentMethod, + normalized employeecollectiondomain.NormalizedApplicationInput, +) (*model.EmployeeCollectionApplication, map[string]any, error) { + actingOperatorID := uint(0) + if applicantAccountID != caller.AccountID { + actingOperatorID = caller.AccountID + } + if existing == nil { + application := &model.EmployeeCollectionApplication{ + ApplicantAccountID: applicantAccountID, ActingOperatorID: actingOperatorID, + ActingReason: normalized.ActingReason, + PaymentMethodID: paymentMethod.ID, PaymentMethodCode: paymentMethod.Code, + PaymentMethodName: paymentMethod.Name, PaidAmount: normalized.PaidAmount, + PayerName: normalized.PayerName, PaidAt: normalized.PaidAt, + ExternalTransactionNo: normalized.ExternalTransactionNo, + PaymentVoucherKeys: model.StringJSONBArray(normalized.PaymentVoucherKeys), + Remark: normalized.Remark, + Status: constants.EmployeeCollectionApplicationStatusPending, + Creator: caller.AccountID, Updater: caller.AccountID, + } + if err := tx.WithContext(ctx).Create(application).Error; err != nil { + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "创建核销申请失败") + } + return application, nil, nil + } + beforeData := applicationAuditSnapshot(existing) + expectedStatus := existing.Status + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}). + Where("id = ? AND status = ?", existing.ID, expectedStatus). + Updates(map[string]any{ + "acting_operator_id": actingOperatorID, + "acting_reason": normalized.ActingReason, + "payment_method_id": paymentMethod.ID, + "payment_method_code": paymentMethod.Code, + "payment_method_name": paymentMethod.Name, + "paid_amount": normalized.PaidAmount, + "payer_name": normalized.PayerName, + "paid_at": normalized.PaidAt, + "external_transaction_no": normalized.ExternalTransactionNo, + "payment_voucher_keys": model.StringJSONBArray(normalized.PaymentVoucherKeys), + "remark": normalized.Remark, + "status": constants.EmployeeCollectionApplicationStatusPending, + "decided_at": nil, + "terminal_reason": "", + "updater": caller.AccountID, + }) + if result.Error != nil { + return nil, nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请失败") + } + if result.RowsAffected != 1 { + return nil, nil, errors.New(errors.CodeConflict, "核销申请状态已变化,请刷新后重试") + } + existing.ActingOperatorID = actingOperatorID + existing.ActingReason = normalized.ActingReason + existing.PaymentMethodID = paymentMethod.ID + existing.PaymentMethodCode = paymentMethod.Code + existing.PaymentMethodName = paymentMethod.Name + existing.PaidAmount = normalized.PaidAmount + existing.PayerName = normalized.PayerName + existing.PaidAt = normalized.PaidAt + existing.ExternalTransactionNo = normalized.ExternalTransactionNo + existing.PaymentVoucherKeys = model.StringJSONBArray(normalized.PaymentVoucherKeys) + existing.Remark = normalized.Remark + existing.Status = constants.EmployeeCollectionApplicationStatusPending + existing.DecidedAt = nil + existing.TerminalReason = "" + existing.Updater = caller.AccountID + return existing, beforeData, nil +} + +// buildAttempt 新增一条不可变审批尝试记录,冻结当次收款方式、外部付款、附件与账单分摊快照。 +func buildAttempt( + ctx context.Context, + tx *gorm.DB, + applicationID uint, + attemptNo int, + caller applicationCaller, + paymentMethod *model.EmployeeCollectionPaymentMethod, + normalized employeecollectiondomain.NormalizedApplicationInput, + bills map[uint]*model.EmployeeCollectionBill, + items []ApplicationAllocationCommand, +) (*model.EmployeeCollectionApplicationAttempt, error) { + snapshot, err := allocationSnapshot(bills, items) + if err != nil { + return nil, err + } + attempt := &model.EmployeeCollectionApplicationAttempt{ + ApplicationID: applicationID, AttemptNo: attemptNo, + PaymentMethodID: paymentMethod.ID, PaymentMethodCode: paymentMethod.Code, PaymentMethodName: paymentMethod.Name, + PaidAmount: normalized.PaidAmount, PayerName: normalized.PayerName, PaidAt: normalized.PaidAt, + ExternalTransactionNo: normalized.ExternalTransactionNo, + PaymentVoucherKeys: model.StringJSONBArray(normalized.PaymentVoucherKeys), + Remark: normalized.Remark, SubmittedByAccountID: caller.AccountID, + ActingReason: normalized.ActingReason, AllocationSnapshot: snapshot, + } + if err := tx.WithContext(ctx).Create(attempt).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建核销审批尝试记录失败") + } + return attempt, nil +} + +// allocationSnapshot 生成账单分摊快照,只保存账单摘要与金额,不含付款凭证内容。 +func allocationSnapshot( + bills map[uint]*model.EmployeeCollectionBill, + items []ApplicationAllocationCommand, +) ([]byte, error) { + entries := make([]map[string]any, 0, len(items)) + for _, item := range items { + bill := bills[item.BillID] + entries = append(entries, map[string]any{ + "bill_id": bill.ID, "source_type": bill.SourceType, "source_no": bill.SourceNo, + "bill_status": bill.Status, "receivable_amount": bill.ReceivableAmount, + "received_amount": bill.ReceivedAmount, "reserved_amount": bill.ReservedAmount, + "amount": item.Amount, + }) + } + payload, err := sonic.Marshal(entries) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "序列化账单分摊快照失败") + } + return payload, nil +} + +// approvalSnapshots 生成通用审批的提交人快照与企业微信表单业务快照。 +// 表单快照必须包含审批人核验所需的付款信息,因此保留经人工确认的完整外部流水号。 +func approvalSnapshots( + applicationID uint, + caller applicationCaller, + paymentMethod *model.EmployeeCollectionPaymentMethod, + normalized employeecollectiondomain.NormalizedApplicationInput, + bills map[uint]*model.EmployeeCollectionBill, + items []ApplicationAllocationCommand, +) ([]byte, []byte, error) { + submitterSnapshot, err := sonic.Marshal(map[string]any{ + "account_id": caller.AccountID, "account_name": caller.AccountName, + }) + if err != nil { + return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码核销申请提交人快照失败") + } + requestSnapshot, err := sonic.Marshal(map[string]any{ + constants.ApprovalFieldCollectionApplicationID: applicationID, + constants.ApprovalFieldCollectionPaymentMethod: paymentMethod.Name, + constants.ApprovalFieldCollectionPaidAmount: formatAmountYuan(normalized.PaidAmount), + constants.ApprovalFieldCollectionPaidAmountCent: normalized.PaidAmount, + constants.ApprovalFieldCollectionPayerName: normalized.PayerName, + constants.ApprovalFieldCollectionPaidAt: normalized.PaidAt.Format(time.RFC3339), + constants.ApprovalFieldCollectionExternalTransactionNo: normalized.ExternalTransactionNo, + constants.ApprovalFieldPaymentVoucherKey: normalized.PaymentVoucherKeys, + constants.ApprovalFieldRemark: normalized.Remark, + constants.ApprovalFieldSubmitterID: caller.AccountID, + constants.ApprovalFieldSubmitterName: caller.AccountName, + constants.ApprovalFieldCollectionBillCount: len(items), + constants.ApprovalFieldCollectionBillSummary: allocationSummary(bills, items), + }) + if err != nil { + return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码核销审批业务快照失败") + } + return submitterSnapshot, requestSnapshot, nil +} + +// allocationSummary 生成给审批人阅读的账单分摊摘要。 +func allocationSummary(bills map[uint]*model.EmployeeCollectionBill, items []ApplicationAllocationCommand) string { + parts := make([]string, 0, len(items)) + for _, item := range items { + bill := bills[item.BillID] + parts = append(parts, fmt.Sprintf("账单%d(%s)应收%s 本次分摊%s", + bill.ID, bill.SourceNo, formatAmountYuan(bill.ReceivableAmount), formatAmountYuan(item.Amount))) + } + return strings.Join(parts, ";") +} + +// formatAmountYuan 将分金额格式化为元字符串,仅用于展示与审批表单。 +func formatAmountYuan(amount int64) string { + return fmt.Sprintf("%d.%02d", amount/100, amount%100) +} + +// attachAttemptInstance 把审批实例 ID 回写到本次审批尝试记录,写入一次后不可修改。 +func attachAttemptInstance(ctx context.Context, tx *gorm.DB, attempt *model.EmployeeCollectionApplicationAttempt, instanceID uint) error { + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAttempt{}). + Where("id = ? AND approval_instance_id IS NULL", attempt.ID). + Update("approval_instance_id", instanceID) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联核销审批实例失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "核销审批实例关联已变化") + } + attempt.ApprovalInstanceID = &instanceID + return nil +} + +// updateApplicationLatest 更新申请的最新审批尝试与审批实例引用,仅用于展示。 +func updateApplicationLatest( + ctx context.Context, + tx *gorm.DB, + application *model.EmployeeCollectionApplication, + attempt *model.EmployeeCollectionApplicationAttempt, + instanceID uint, +) error { + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}). + Where("id = ?", application.ID). + Updates(map[string]any{ + "latest_attempt_id": attempt.ID, "latest_approval_instance_id": instanceID, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请最新审批实例失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "核销申请最新审批实例更新已变化") + } + application.LatestAttemptID = attempt.ID + application.LatestApprovalInstanceID = instanceID + return nil +} + +// createAllocations 写入分摊行并按账单 ID 升序预占余额。 +// 预占使用条件更新并要求 RowsAffected 为 1,避免并发申请超额占用同一账单。 +func createAllocations( + ctx context.Context, + tx *gorm.DB, + applicationID uint, + attemptID uint, + caller applicationCaller, + bills map[uint]*model.EmployeeCollectionBill, + items []ApplicationAllocationCommand, +) ([]*model.EmployeeCollectionApplicationAllocation, []*model.EmployeeCollectionBill, error) { + allocations := make([]*model.EmployeeCollectionApplicationAllocation, 0, len(items)) + reservedBills := make([]*model.EmployeeCollectionBill, 0, len(items)) + for _, item := range items { + bill := bills[item.BillID] + allocation := &model.EmployeeCollectionApplicationAllocation{ + ApplicationID: applicationID, AttemptID: attemptID, BillID: bill.ID, + Amount: item.Amount, Status: constants.EmployeeCollectionAllocationStatusPending, + Creator: caller.AccountID, Updater: caller.AccountID, + } + if err := tx.WithContext(ctx).Create(allocation).Error; err != nil { + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "写入核销分摊失败") + } + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}). + Where("id = ? AND reserved_amount + ? <= receivable_amount - received_amount", bill.ID, item.Amount). + Updates(map[string]any{ + "reserved_amount": gorm.Expr("reserved_amount + ?", item.Amount), + "updater": caller.AccountID, + }) + if result.Error != nil { + return nil, nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "预占账单可核销余额失败") + } + if result.RowsAffected != 1 { + return nil, nil, errors.New(errors.CodeEmployeeCollectionAllocationExceeded) + } + bill.ReservedAmount += item.Amount + allocations = append(allocations, allocation) + reservedBills = append(reservedBills, bill) + } + return allocations, reservedBills, nil +} + +// applicationAuditSnapshot 生成申请审计快照,外部交易流水号按脱敏值记录。 +func applicationAuditSnapshot(application *model.EmployeeCollectionApplication) map[string]any { + return map[string]any{ + "id": application.ID, "applicant_account_id": application.ApplicantAccountID, + "acting_operator_id": application.ActingOperatorID, + "payment_method_id": application.PaymentMethodID, "payment_method_code": application.PaymentMethodCode, + "paid_amount": application.PaidAmount, "payer_name": application.PayerName, + "external_transaction_no_masked": employeecollectiondomain.MaskExternalTransactionNo(application.ExternalTransactionNo), + "voucher_count": len(application.PaymentVoucherKeys), + "status": application.Status, + "latest_attempt_id": application.LatestAttemptID, + "latest_approval_instance_id": application.LatestApprovalInstanceID, + } +} diff --git a/internal/application/employeecollection/application_audit.go b/internal/application/employeecollection/application_audit.go new file mode 100644 index 0000000..d47a565 --- /dev/null +++ b/internal/application/employeecollection/application_audit.go @@ -0,0 +1,37 @@ +package employeecollection + +import ( + "context" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" +) + +// ApplicationAudit 描述核销申请、审批尝试记录与受影响账单的事实变化。 +type ApplicationAudit struct { + // EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。 + EventID string + // ActionCode 是已注册的核销申请审计动作码。 + ActionCode string + // Summary 是给人工阅读的中文摘要。 + Summary string + // Application 是本次动作后的核销申请事实。 + Application *model.EmployeeCollectionApplication + // Attempt 是本次动作对应的审批尝试记录。 + Attempt *model.EmployeeCollectionApplicationAttempt + // Allocations 是本次动作涉及的分摊事实。 + Allocations []*model.EmployeeCollectionApplicationAllocation + // Bills 是本次动作影响的员工代收款账单事实。 + Bills []*model.EmployeeCollectionBill + // BeforeData 与 AfterData 是脱敏前后的字段快照,不得包含付款凭证内容。 + BeforeData map[string]any + AfterData map[string]any + // CorrelationID 是申请链路标识。 + CorrelationID string +} + +// ApplicationAuditWriter 在员工代收款核销事务内追加统一 Audit Event。 +type ApplicationAuditWriter interface { + WriteEmployeeCollectionApplication(ctx context.Context, tx *gorm.DB, change ApplicationAudit) error +} diff --git a/internal/application/employeecollection/approval_decision.go b/internal/application/employeecollection/approval_decision.go new file mode 100644 index 0000000..af3566d --- /dev/null +++ b/internal/application/employeecollection/approval_decision.go @@ -0,0 +1,501 @@ +package employeecollection + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ApprovalDecisionHandler 将渠道无关企业微信审批终态应用到员工代收款核销申请。 +// 通过才增加账单已核销金额,驳回才释放预占;重复、乱序或延迟回调都不重复入账。 +type ApprovalDecisionHandler struct { + db *gorm.DB + audit ApplicationAuditWriter +} + +// NewApprovalDecisionHandler 创建员工代收款核销审批终态消费者。 +func NewApprovalDecisionHandler(db *gorm.DB, audit ApplicationAuditWriter) *ApprovalDecisionHandler { + return &ApprovalDecisionHandler{db: db, audit: audit} +} + +// Handle 幂等消费标准审批终态。 +// 业务标识为审批尝试记录主键:先锁定尝试记录并校验审批实例一致,再按申请与账单 ID 升序加锁。 +func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { + if h == nil || h.db == nil || h.audit == nil { + return errors.New(errors.CodeInternalError, "员工代收款核销审批终态能力未配置") + } + if event.BusinessType != constants.ApprovalBusinessTypeEmployeeCollection || event.BusinessID == 0 || event.InstanceID == 0 { + return errors.New(errors.CodeInvalidParam, "员工代收款核销审批终态参数无效") + } + ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID}) + return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var attempt model.EmployeeCollectionApplicationAttempt + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + First(&attempt, event.BusinessID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "核销审批尝试记录不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "锁定核销审批尝试记录失败") + } + if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != event.InstanceID { + return errors.New(errors.CodeConflict, "核销审批尝试记录关联的审批实例不一致") + } + application, err := lockApplication(ctx, tx, attempt.ApplicationID) + if err != nil { + return err + } + if application.LatestAttemptID != attempt.ID { + // 已被更新尝试取代的历史尝试终态不再改变申请事实。 + return nil + } + switch event.Decision { + case constants.ApprovalDecisionApproved: + return h.applyApproved(ctx, tx, application, &attempt, event) + case constants.ApprovalDecisionRejected: + return h.applyClosed(ctx, tx, application, &attempt, event, + constants.EmployeeCollectionApplicationStatusRejected, "企业微信审批已驳回") + case constants.ApprovalDecisionCancelled: + return h.applyClosed(ctx, tx, application, &attempt, event, + constants.EmployeeCollectionApplicationStatusRevoked, "企业微信审批已撤销") + case constants.ApprovalDecisionDeleted: + return h.applyClosed(ctx, tx, application, &attempt, event, + constants.EmployeeCollectionApplicationStatusRevoked, "企业微信审批已删除") + case constants.ApprovalDecisionRevokedAfterApproved: + return h.applyRevoked(ctx, tx, application, &attempt, event) + default: + return errors.New(errors.CodeInvalidParam, "不支持的核销申请审批终态") + } + }) +} + +// applyApproved 将本次尝试的全部预占分摊转入已核销并重算账单状态。 +// 仅当申请仍处于审批中时推进,重复或乱序回调不重复增加已核销金额。 +func (h *ApprovalDecisionHandler) applyApproved( + ctx context.Context, + tx *gorm.DB, + application *model.EmployeeCollectionApplication, + attempt *model.EmployeeCollectionApplicationAttempt, + event approvalapp.TerminalDecisionEvent, +) error { + if application.Status != constants.EmployeeCollectionApplicationStatusPending { + return nil + } + allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID) + if err != nil { + return err + } + if _, err := lockBillsInAscendingOrder(ctx, tx, allocationCommandsOf(allocations)); err != nil { + return err + } + now := time.Now().UTC() + for _, allocation := range allocations { + if err := approveAllocation(ctx, tx, allocation, now); err != nil { + return err + } + if err := settleBillReservation(ctx, tx, allocation); err != nil { + return err + } + } + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}). + Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusPending). + Updates(map[string]any{ + "status": constants.EmployeeCollectionApplicationStatusApproved, + "decided_at": now, "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销申请已通过失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "核销申请状态已变化") + } + before := application.Status + application.Status = constants.EmployeeCollectionApplicationStatusApproved + application.DecidedAt = &now + application.Updater = 0 + bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations)) + if err != nil { + return err + } + approvedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, "approved") + if err != nil { + return err + } + return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{ + EventID: approvedEventID, + ActionCode: constants.AuditActionEmployeeCollectionApplicationApproved, + Summary: "企业微信审批通过,核销分摊转入已核销", + Application: application, Attempt: attempt, Allocations: allocations, Bills: bills, + BeforeData: map[string]any{"status": before}, + AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID, + }) +} + +// applyClosed 处理最终驳回与渠道撤销、删除:释放全部预占并把申请置为对应终态。 +func (h *ApprovalDecisionHandler) applyClosed( + ctx context.Context, + tx *gorm.DB, + application *model.EmployeeCollectionApplication, + attempt *model.EmployeeCollectionApplicationAttempt, + event approvalapp.TerminalDecisionEvent, + targetStatus int, + reason string, +) error { + if application.Status != constants.EmployeeCollectionApplicationStatusPending { + return nil + } + allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID) + if err != nil { + return err + } + if _, err := lockBillsInAscendingOrder(ctx, tx, allocationCommandsOf(allocations)); err != nil { + return err + } + now := time.Now().UTC() + for _, allocation := range allocations { + if err := releaseAllocation(ctx, tx, allocation, now); err != nil { + return err + } + if err := releaseBillReservation(ctx, tx, allocation); err != nil { + return err + } + } + terminalReason := "" + if targetStatus == constants.EmployeeCollectionApplicationStatusRevoked { + terminalReason = reason + } + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}). + Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusPending). + Updates(map[string]any{ + "status": targetStatus, "decided_at": now, + "terminal_reason": terminalReason, "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请终态失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "核销申请状态已变化") + } + before := application.Status + application.Status = targetStatus + application.DecidedAt = &now + application.TerminalReason = terminalReason + application.Updater = 0 + bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations)) + if err != nil { + return err + } + closedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, decisionEventSuffix(event.Decision)) + if err != nil { + return err + } + return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{ + EventID: closedEventID, + ActionCode: constants.AuditActionEmployeeCollectionApplicationRejected, + Summary: "企业微信审批未通过,核销申请预占已释放", + Application: application, Attempt: attempt, Allocations: allocations, Bills: bills, + BeforeData: map[string]any{"status": before}, + AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID, + }) +} + +// applyRevoked 处理通过后撤销。 +// 申请已通过:不回滚已核销金额,只转异常终态并禁止自动重提。 +// 申请仍在审批中(渠道乱序投递):释放全部审批中预占并转异常终态,避免预占永久占用账单。 +func (h *ApprovalDecisionHandler) applyRevoked( + ctx context.Context, + tx *gorm.DB, + application *model.EmployeeCollectionApplication, + attempt *model.EmployeeCollectionApplicationAttempt, + event approvalapp.TerminalDecisionEvent, +) error { + switch application.Status { + case constants.EmployeeCollectionApplicationStatusApproved: + return h.revokeApproved(ctx, tx, application, attempt, event) + case constants.EmployeeCollectionApplicationStatusPending: + return h.revokePending(ctx, tx, application, attempt, event) + default: + // 已驳回、已撤销等终态不再改变事实。 + return nil + } +} + +// revokeApproved 在已通过态撤销:保留已核销金额,仅转异常终态。 +func (h *ApprovalDecisionHandler) revokeApproved( + ctx context.Context, + tx *gorm.DB, + application *model.EmployeeCollectionApplication, + attempt *model.EmployeeCollectionApplicationAttempt, + event approvalapp.TerminalDecisionEvent, +) error { + now := time.Now().UTC() + terminalReason := "企业微信通过后撤销" + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}). + Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusApproved). + Updates(map[string]any{ + "status": constants.EmployeeCollectionApplicationStatusRevoked, + "decided_at": now, "terminal_reason": terminalReason, "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销申请通过后撤销失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "核销申请状态已变化") + } + before := application.Status + application.Status = constants.EmployeeCollectionApplicationStatusRevoked + application.DecidedAt = &now + application.TerminalReason = terminalReason + application.Updater = 0 + allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID) + if err != nil { + return err + } + bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations)) + if err != nil { + return err + } + revokedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, "revoked") + if err != nil { + return err + } + return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{ + EventID: revokedEventID, + ActionCode: constants.AuditActionEmployeeCollectionApplicationRevoked, + Summary: "企业微信通过后撤销,已核销金额不回滚", + Application: application, Attempt: attempt, Allocations: allocations, Bills: bills, + BeforeData: map[string]any{"status": before}, + AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID, + }) +} + +// revokePending 在审批中态撤销:释放全部审批中预占并转异常终态。 +func (h *ApprovalDecisionHandler) revokePending( + ctx context.Context, + tx *gorm.DB, + application *model.EmployeeCollectionApplication, + attempt *model.EmployeeCollectionApplicationAttempt, + event approvalapp.TerminalDecisionEvent, +) error { + allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID) + if err != nil { + return err + } + if _, err := lockBillsInAscendingOrder(ctx, tx, allocationCommandsOf(allocations)); err != nil { + return err + } + now := time.Now().UTC() + for _, allocation := range allocations { + if err := releaseAllocation(ctx, tx, allocation, now); err != nil { + return err + } + if err := releaseBillReservation(ctx, tx, allocation); err != nil { + return err + } + } + terminalReason := "企业微信通过后在本地审批中状态被撤销" + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}). + Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusPending). + Updates(map[string]any{ + "status": constants.EmployeeCollectionApplicationStatusRevoked, + "decided_at": now, "terminal_reason": terminalReason, "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销申请撤销失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "核销申请状态已变化") + } + before := application.Status + application.Status = constants.EmployeeCollectionApplicationStatusRevoked + application.DecidedAt = &now + application.TerminalReason = terminalReason + application.Updater = 0 + bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations)) + if err != nil { + return err + } + revokedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, "revoked") + if err != nil { + return err + } + return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{ + EventID: revokedEventID, + ActionCode: constants.AuditActionEmployeeCollectionApplicationRevoked, + Summary: "企业微信通过后撤销,申请仍在审批中,已释放预占", + Application: application, Attempt: attempt, Allocations: allocations, Bills: bills, + BeforeData: map[string]any{"status": before}, + AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID, + }) +} + +// loadAttemptAllocations 按账单 ID 升序读取本次尝试的分摊,保证后续加锁与写入次序唯一。 +func loadAttemptAllocations(ctx context.Context, tx *gorm.DB, attemptID uint) ([]*model.EmployeeCollectionApplicationAllocation, error) { + var allocations []model.EmployeeCollectionApplicationAllocation + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("attempt_id = ?", attemptID).Order("bill_id ASC, id ASC").Find(&allocations).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销审批尝试分摊失败") + } + if len(allocations) == 0 { + return nil, errors.New(errors.CodeConflict, "核销审批尝试记录缺少分摊事实") + } + result := make([]*model.EmployeeCollectionApplicationAllocation, 0, len(allocations)) + for index := range allocations { + result = append(result, &allocations[index]) + } + return result, nil +} + +// allocationCommandsOf 提取分摊涉及的账单与金额,用于复用升序加锁函数。 +func allocationCommandsOf(allocations []*model.EmployeeCollectionApplicationAllocation) []ApplicationAllocationCommand { + items := make([]ApplicationAllocationCommand, 0, len(allocations)) + for _, allocation := range allocations { + items = append(items, ApplicationAllocationCommand{BillID: allocation.BillID, Amount: allocation.Amount}) + } + return items +} + +// approveAllocation 把分摊从审批中预占条件更新为已通过;重复处理时返回冲突。 +func approveAllocation( + ctx context.Context, + tx *gorm.DB, + allocation *model.EmployeeCollectionApplicationAllocation, + now time.Time, +) error { + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAllocation{}). + Where("id = ? AND status = ?", allocation.ID, constants.EmployeeCollectionAllocationStatusPending). + Updates(map[string]any{ + "status": constants.EmployeeCollectionAllocationStatusApproved, "released_at": now, "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销分摊已通过失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "核销分摊状态已变化") + } + allocation.Status = constants.EmployeeCollectionAllocationStatusApproved + allocation.ReleasedAt = &now + return nil +} + +// releaseAllocation 把分摊从审批中预占条件更新为已释放;重复处理时返回冲突。 +func releaseAllocation( + ctx context.Context, + tx *gorm.DB, + allocation *model.EmployeeCollectionApplicationAllocation, + now time.Time, +) error { + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAllocation{}). + Where("id = ? AND status = ?", allocation.ID, constants.EmployeeCollectionAllocationStatusPending). + Updates(map[string]any{ + "status": constants.EmployeeCollectionAllocationStatusReleased, "released_at": now, "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "释放核销分摊预占失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "核销分摊状态已变化") + } + allocation.Status = constants.EmployeeCollectionAllocationStatusReleased + allocation.ReleasedAt = &now + return nil +} + +// settleBillReservation 将账单预占转为已核销并重算账单状态。 +// 条件更新要求账单预占不小于分摊金额,并检查 RowsAffected,避免并发下重复入账。 +func settleBillReservation( + ctx context.Context, + tx *gorm.DB, + allocation *model.EmployeeCollectionApplicationAllocation, +) error { + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}). + Where("id = ? AND reserved_amount >= ?", allocation.BillID, allocation.Amount). + Updates(map[string]any{ + "received_amount": gorm.Expr("received_amount + ?", allocation.Amount), + "reserved_amount": gorm.Expr("reserved_amount - ?", allocation.Amount), + "status": gorm.Expr( + "CASE WHEN received_amount + ? >= receivable_amount THEN ?::smallint WHEN received_amount + ? > 0 THEN ?::smallint ELSE ?::smallint END", + allocation.Amount, constants.EmployeeCollectionBillStatusSettled, + allocation.Amount, constants.EmployeeCollectionBillStatusPartial, + constants.EmployeeCollectionBillStatusPending, + ), + "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "账单预占转入已核销失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "账单预占已变化,核销未入账") + } + return nil +} + +// releaseBillReservation 释放账单预占金额,条件更新并检查 RowsAffected。 +func releaseBillReservation( + ctx context.Context, + tx *gorm.DB, + allocation *model.EmployeeCollectionApplicationAllocation, +) error { + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}). + Where("id = ? AND reserved_amount >= ?", allocation.BillID, allocation.Amount). + Updates(map[string]any{ + "reserved_amount": gorm.Expr("reserved_amount - ?", allocation.Amount), + "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "释放账单预占失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "账单预占已变化") + } + return nil +} + +// reloadBills 重新读取受影响账单,保证审计快照反映终态金额。 +// 账单行已在同一事务内持有排他锁,这里只做一次按 ID 升序的普通读取。 +func reloadBills( + ctx context.Context, + tx *gorm.DB, + items []ApplicationAllocationCommand, +) ([]*model.EmployeeCollectionBill, error) { + billIDs := make([]uint, 0, len(items)) + for _, item := range items { + billIDs = append(billIDs, item.BillID) + } + var bills []model.EmployeeCollectionBill + if err := tx.WithContext(ctx).Where("id IN ?", billIDs).Order("id ASC").Find(&bills).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询受影响员工代收款账单失败") + } + result := make([]*model.EmployeeCollectionBill, 0, len(bills)) + for index := range bills { + result = append(result, &bills[index]) + } + return result, nil +} + +// decisionEventID 生成审批终态审计事件的稳定标识,并约束在审计列宽内。 +func decisionEventID(applicationID uint, attemptNo int, suffix string) (string, error) { + return composeAuditEventID( + "employee_collection", "application", uintText(applicationID), "attempt", intText(attemptNo), suffix) +} + +// decisionEventSuffix 把渠道决策映射为审计事件后缀。 +func decisionEventSuffix(decision string) string { + switch decision { + case constants.ApprovalDecisionRejected: + return "rejected" + case constants.ApprovalDecisionCancelled: + return "cancelled" + case constants.ApprovalDecisionDeleted: + return "deleted" + default: + return "closed" + } +} diff --git a/internal/application/employeecollection/audit_event.go b/internal/application/employeecollection/audit_event.go new file mode 100644 index 0000000..3ff2755 --- /dev/null +++ b/internal/application/employeecollection/audit_event.go @@ -0,0 +1,31 @@ +package employeecollection + +import ( + "strconv" + "strings" + + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// auditEventMaxLength 是统一审计事件标识的列宽上限,与 tb_audit_event.event_id 保持一致。 +const auditEventMaxLength = 64 + +// composeAuditEventID 以冒号连接业务标识片段,生成确定性的审计事件标识。 +// 超出审计列宽时返回稳定错误,避免写入时分段截断或事务被数据库拒绝。 +func composeAuditEventID(parts ...string) (string, error) { + eventID := strings.Join(parts, ":") + if len(eventID) > auditEventMaxLength { + return "", errors.New(errors.CodeInternalError, "审计事件标识超出长度限制") + } + return eventID, nil +} + +// uintText 将主键转为审计标识片段。 +func uintText(value uint) string { + return strconv.FormatUint(uint64(value), 10) +} + +// intText 将序号转为审计标识片段。 +func intText(value int) string { + return strconv.Itoa(value) +} diff --git a/internal/application/employeecollection/bill_close.go b/internal/application/employeecollection/bill_close.go new file mode 100644 index 0000000..aed3442 --- /dev/null +++ b/internal/application/employeecollection/bill_close.go @@ -0,0 +1,118 @@ +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 +} diff --git a/internal/application/employeecollection/bill_creation.go b/internal/application/employeecollection/bill_creation.go new file mode 100644 index 0000000..a2793b8 --- /dev/null +++ b/internal/application/employeecollection/bill_creation.go @@ -0,0 +1,233 @@ +package employeecollection + +import ( + "context" + + "github.com/bytedance/sonic" + "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" +) + +// BillAudit 描述员工代收款账单事实的实际变化。 +type BillAudit struct { + // EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。 + EventID string + // ActionCode 是已注册的账单审计动作码。 + ActionCode string + // Summary 是给人工阅读的中文摘要。 + Summary string + // Bill 是本次动作后的账单事实。 + Bill *model.EmployeeCollectionBill + // BeforeData 与 AfterData 是脱敏前后的字段快照。 + BeforeData map[string]any + AfterData map[string]any + // CorrelationID 是来源业务链路标识。 + CorrelationID string +} + +// BillAuditWriter 在员工代收款业务事务内追加统一 Audit Event。 +type BillAuditWriter interface { + WriteEmployeeCollectionBill(ctx context.Context, tx *gorm.DB, change BillAudit) error +} + +// BillCreationService 在来源成功事务内按来源唯一键幂等创建员工代收款账单。 +// 建账只由来源成功事务携带的来源主键触发,不存在扫描历史订单或充值补建的路径。 +type BillCreationService struct { + audit BillAuditWriter +} + +// NewBillCreationService 创建员工代收款建账用例。 +func NewBillCreationService(audit BillAuditWriter) *BillCreationService { + return &BillCreationService{audit: audit} +} + +// CreateFromOrderInTx 在后台线下套餐订单激活事务内建账。 +// 判据见 employeecollectiondomain.ShouldCreateBillForOrder;不满足判据时返回 (nil, nil)。 +// 重复订单事务、重放或重试都命中 source_key 唯一约束并返回既有账单,不使订单事务失败。 +func (s *BillCreationService) CreateFromOrderInTx( + ctx context.Context, + tx *gorm.DB, + order *model.Order, + hasGiftPackage bool, +) (*model.EmployeeCollectionBill, error) { + if s == nil || tx == nil || s.audit == nil { + return nil, errors.New(errors.CodeInternalError, "员工代收款建账用例未完整配置") + } + if order == nil || order.ID == 0 { + return nil, errors.New(errors.CodeInvalidParam, "员工代收款建账缺少来源订单") + } + if !employeecollectiondomain.ShouldCreateBillForOrder( + employeecollectiondomain.OrderBillSubjectFromOrder(order, hasGiftPackage)) { + return nil, nil + } + if order.OperatorAccountID == nil || *order.OperatorAccountID == 0 { + return nil, errors.New(errors.CodeInternalError, "线下套餐订单缺少欠款人账号") + } + debtorAccountID := *order.OperatorAccountID + debtorSnapshot, err := marshalSnapshot(map[string]any{ + "account_id": debtorAccountID, "account_name": order.OperatorAccountName, + "account_type": order.OperatorAccountType, + }) + if err != nil { + return nil, err + } + // shop_id 与 seller_shop_id 同时写入:店铺筛选统一读 shop_id,seller_shop_id 保留兼容口径。 + customerSnapshot, err := marshalSnapshot(map[string]any{ + "buyer_type": order.BuyerType, "buyer_id": order.BuyerID, + "buyer_nickname": order.BuyerNickname, + "shop_id": order.SellerShopID, "seller_shop_id": order.SellerShopID, + "asset_identifier": order.AssetIdentifier, + }) + if err != nil { + return nil, err + } + sourceKey := employeecollectiondomain.OrderSourceKey(order.ID) + bill := &model.EmployeeCollectionBill{ + SourceType: constants.EmployeeCollectionSourceTypeOrder, + SourceID: order.ID, + SourceKey: sourceKey, + SourceNo: order.OrderNo, + DebtorAccountID: debtorAccountID, + DebtorSnapshot: debtorSnapshot, + CustomerSnapshot: customerSnapshot, + ReceivableAmount: *order.ActualPaidAmount, + Status: constants.EmployeeCollectionBillStatusPending, + Creator: debtorAccountID, + Updater: debtorAccountID, + } + return s.persistInTx(ctx, tx, bill, + []string{"employee_collection", "bill", "order", uintText(order.ID), "create"}, + "后台线下套餐订单创建员工代收款账单", order.OrderNo) +} + +// CreateFromRechargeInTx 在代理线下充值入账事务内建账。 +// 判据见 employeecollectiondomain.ShouldCreateBillForRecharge;欠款人为发起充值的后台账号。 +// 覆盖企业微信终审通过入账与后台人工确认入账两条入口,重复入账不重复建账。 +func (s *BillCreationService) CreateFromRechargeInTx( + ctx context.Context, + tx *gorm.DB, + record *model.AgentRechargeRecord, +) (*model.EmployeeCollectionBill, error) { + if s == nil || tx == nil || s.audit == nil { + return nil, errors.New(errors.CodeInternalError, "员工代收款建账用例未完整配置") + } + if record == nil || record.ID == 0 { + return nil, errors.New(errors.CodeInvalidParam, "员工代收款建账缺少来源充值记录") + } + if !employeecollectiondomain.ShouldCreateBillForRecharge(employeecollectiondomain.RechargeBillSubject{ + PaymentMethod: record.PaymentMethod, Amount: record.Amount, + }) { + return nil, nil + } + if record.UserID == 0 { + return nil, errors.New(errors.CodeInternalError, "线下充值记录缺少发起账号") + } + debtorName, err := rechargeAccountName(ctx, tx, record.UserID) + if err != nil { + return nil, err + } + debtorSnapshot, err := marshalSnapshot(map[string]any{ + "account_id": record.UserID, "account_name": debtorName, + "account_type": model.OperatorAccountTypePlatform, + }) + if err != nil { + return nil, err + } + customerSnapshot, err := marshalSnapshot(map[string]any{ + "shop_id": record.ShopID, "agent_wallet_id": record.AgentWalletID, + "payment_method": record.PaymentMethod, "recharge_no": record.RechargeNo, + }) + if err != nil { + return nil, err + } + sourceKey := employeecollectiondomain.RechargeSourceKey(record.ID) + bill := &model.EmployeeCollectionBill{ + SourceType: constants.EmployeeCollectionSourceTypeRecharge, + SourceID: record.ID, + SourceKey: sourceKey, + SourceNo: record.RechargeNo, + DebtorAccountID: record.UserID, + DebtorSnapshot: debtorSnapshot, + CustomerSnapshot: customerSnapshot, + ReceivableAmount: record.Amount, + Status: constants.EmployeeCollectionBillStatusPending, + Creator: record.UserID, + Updater: record.UserID, + } + return s.persistInTx(ctx, tx, bill, + []string{"employee_collection", "bill", "recharge", uintText(record.ID), "create"}, + "代理线下充值入账创建员工代收款账单", record.RechargeNo) +} + +// persistInTx 以来源唯一键幂等写入账单:已存在同一来源账单时返回既有事实且不重复审计。 +func (s *BillCreationService) persistInTx( + ctx context.Context, + tx *gorm.DB, + bill *model.EmployeeCollectionBill, + eventIDParts []string, + summary string, + correlationID string, +) (*model.EmployeeCollectionBill, error) { + eventID, err := composeAuditEventID(eventIDParts...) + if err != nil { + return nil, err + } + result := tx.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "source_key"}}, + DoNothing: true, + }).Create(bill) + if result.Error != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "创建员工代收款账单失败") + } + if result.RowsAffected == 0 { + var existing model.EmployeeCollectionBill + if err := tx.WithContext(ctx).Where("source_key = ?", bill.SourceKey).First(&existing).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取既有员工代收款账单失败") + } + return &existing, nil + } + if err := s.audit.WriteEmployeeCollectionBill(ctx, tx, BillAudit{ + EventID: eventID, ActionCode: constants.AuditActionEmployeeCollectionBillCreated, Summary: summary, + Bill: bill, AfterData: billAuditSnapshot(bill), CorrelationID: correlationID, + }); err != nil { + return nil, err + } + return bill, nil +} + +// marshalSnapshot 将只读业务快照序列化为 jsonb,快照不得包含付款凭证或外部交易敏感内容。 +func marshalSnapshot(snapshot map[string]any) ([]byte, error) { + payload, err := sonic.Marshal(snapshot) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "序列化员工代收款账单快照失败") + } + return payload, nil +} + +// rechargeAccountName 读取充值发起账号名称用于欠款人快照;账号已被删除时留空名称。 +func rechargeAccountName(ctx context.Context, tx *gorm.DB, accountID uint) (string, error) { + var account model.Account + if err := tx.WithContext(ctx).Unscoped().First(&account, accountID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return "", nil + } + return "", errors.Wrap(errors.CodeDatabaseError, err, "查询线下充值发起账号失败") + } + return account.Username, nil +} + +// billAuditSnapshot 生成账单审计快照,只包含 ID、来源、金额与状态,不含凭证内容。 +func billAuditSnapshot(bill *model.EmployeeCollectionBill) map[string]any { + return map[string]any{ + "id": bill.ID, "source_type": bill.SourceType, "source_id": bill.SourceID, + "source_key": bill.SourceKey, "source_no": bill.SourceNo, + "debtor_account_id": bill.DebtorAccountID, "receivable_amount": bill.ReceivableAmount, + "received_amount": bill.ReceivedAmount, "reserved_amount": bill.ReservedAmount, + "status": bill.Status, + } +} diff --git a/internal/application/employeecollection/payment_method.go b/internal/application/employeecollection/payment_method.go new file mode 100644 index 0000000..0229f4c --- /dev/null +++ b/internal/application/employeecollection/payment_method.go @@ -0,0 +1,337 @@ +// 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 count int64 + if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}). + Where("payment_method_id = ?", id).Count(&count).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计线下收款方式引用失败") + } + return count, 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, + } +} diff --git a/internal/application/employeecollection/refund_offset.go b/internal/application/employeecollection/refund_offset.go new file mode 100644 index 0000000..9cd7ce0 --- /dev/null +++ b/internal/application/employeecollection/refund_offset.go @@ -0,0 +1,161 @@ +package employeecollection + +import ( + "context" + "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" +) + +// RefundOffsetSource 是来源订单退款成功的事实快照。 +type RefundOffsetSource struct { + // RefundID 表示本次退款申请 ID。 + RefundID uint + // OrderID 表示退款关联的来源订单 ID。 + OrderID uint + // RefundAmount 表示本次退款成功金额(分),与退款入账使用的金额为同一实参。 + RefundAmount int64 +} + +// RefundOffsetService 在既有退款成功事务内冲销或提示员工代收款账单。 +// 只处理来源为后台线下套餐订单的账单,其他订单直接跳过,不阻断退款链路。 +type RefundOffsetService struct { + audit BillAuditWriter +} + +// NewRefundOffsetService 创建退款冲销用例。 +func NewRefundOffsetService(audit BillAuditWriter) *RefundOffsetService { + return &RefundOffsetService{audit: audit} +} + +// ApplyInTx 在既有退款成功事务内按来源唯一键 order:{id} 查找账单并幂等写入冲销事实。 +// 同一退款对同一账单至多一条关联:重复投递时关联写入影响 0 行,不再冲减、不再写审计、 +// 也不依赖退款事务的 changed 标志。 +func (s *RefundOffsetService) ApplyInTx(ctx context.Context, tx *gorm.DB, source RefundOffsetSource) error { + if s == nil || tx == nil || s.audit == nil { + return errors.New(errors.CodeInternalError, "员工代收款退款冲销能力未配置") + } + if source.RefundID == 0 || source.OrderID == 0 || source.RefundAmount <= 0 { + return errors.New(errors.CodeInvalidParam, "员工代收款退款冲销参数无效") + } + var bill model.EmployeeCollectionBill + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("source_key = ?", employeecollectiondomain.OrderSourceKey(source.OrderID)). + First(&bill).Error; err != nil { + if err == gorm.ErrRecordNotFound { + // 来源订单未产生员工代收款账单,跳过而不阻断退款。 + return nil + } + return errors.Wrap(errors.CodeDatabaseError, err, "锁定来源订单员工代收款账单失败") + } + decision, err := employeecollectiondomain.DecideRefundOffset(billAmounts(&bill), source.RefundAmount) + if err != nil { + return err + } + record := &model.EmployeeCollectionBillRefund{ + BillID: bill.ID, RefundID: source.RefundID, SourceOrderID: source.OrderID, + RefundAmount: source.RefundAmount, BillReceivableAmount: bill.ReceivableAmount, + Outcome: decision.Outcome, ReducedAmount: decision.ReducedAmount, + } + result := tx.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "bill_id"}, {Name: "refund_id"}}, + DoNothing: true, + }).Create(record) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入员工代收款退款冲销关联失败") + } + if result.RowsAffected == 0 { + // 同一退款已冲销过同一账单,保留既有事实。 + return nil + } + before := bill + if err := applyRefundOutcome(ctx, tx, &bill, decision); err != nil { + return err + } + offsetEventID, err := composeAuditEventID( + "employee_collection", "bill", "order", uintText(source.OrderID), "refund", uintText(source.RefundID)) + if err != nil { + return err + } + return s.audit.WriteEmployeeCollectionBill(ctx, tx, BillAudit{ + EventID: offsetEventID, + ActionCode: constants.AuditActionEmployeeCollectionBillRefundOffseted, + Summary: constants.GetEmployeeCollectionRefundOutcomeName(decision.Outcome), + Bill: &bill, + BeforeData: map[string]any{ + "receivable_amount": before.ReceivableAmount, "received_amount": before.ReceivedAmount, + "reserved_amount": before.ReservedAmount, "status": before.Status, + }, + AfterData: map[string]any{ + "receivable_amount": bill.ReceivableAmount, "received_amount": bill.ReceivedAmount, + "reserved_amount": bill.ReservedAmount, "status": bill.Status, + "refund_id": source.RefundID, "refund_amount": source.RefundAmount, "outcome": decision.Outcome, + }, + CorrelationID: bill.SourceNo, + }) +} + +// applyRefundOutcome 按判定结果修改账单:全额退款关闭、部分冲减应收,提示结果不修改金额与状态。 +// 关闭与冲减都使用 expected-status 条件更新并检查 RowsAffected,避免并发覆盖。 +func applyRefundOutcome( + ctx context.Context, + tx *gorm.DB, + bill *model.EmployeeCollectionBill, + decision employeecollectiondomain.RefundOffsetDecision, +) error { + expectedStatus := bill.Status + switch decision.Outcome { + case constants.EmployeeCollectionRefundOutcomeHintOnly: + return nil + case constants.EmployeeCollectionRefundOutcomeClosedFull: + closedAt := time.Now().UTC() + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}). + Where("id = ? AND status = ?", bill.ID, expectedStatus). + Updates(map[string]any{ + "status": constants.EmployeeCollectionBillStatusClosed, + "closed_reason": "来源订单全额退款", + "closed_at": closedAt, + "updater": 0, + }) + 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 = "来源订单全额退款" + bill.ClosedAt = &closedAt + return nil + case constants.EmployeeCollectionRefundOutcomeReduced: + amounts, err := billAmounts(bill).ReduceReceivable(decision.ReducedAmount) + if err != nil { + return err + } + nextStatus := amounts.DerivedStatus() + result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}). + Where("id = ? AND status = ?", bill.ID, expectedStatus). + Updates(map[string]any{ + "receivable_amount": amounts.Receivable, + "status": nextStatus, + "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "冲减来源订单退款账单应收失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "员工代收款账单状态已变化,退款冲减未完成") + } + bill.ReceivableAmount = amounts.Receivable + bill.Status = nextStatus + return nil + default: + return errors.New(errors.CodeInternalError, "不支持的退款冲销处理结果") + } +} diff --git a/internal/application/wecom/scene.go b/internal/application/wecom/scene.go index eb9ed91..33d8440 100644 --- a/internal/application/wecom/scene.go +++ b/internal/application/wecom/scene.go @@ -333,6 +333,22 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse, {Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"}, {Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"}, }, true + case constants.ApprovalBusinessTypeEmployeeCollection: + return []dto.WeComBusinessFieldResponse{ + {Code: constants.ApprovalFieldCollectionApplicationID, Name: "核销申请 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "员工代收款核销申请的系统 ID"}, + {Code: constants.ApprovalFieldCollectionPaymentMethod, Name: "线下收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次外部付款使用的线下收款方式名称快照"}, + {Code: constants.ApprovalFieldCollectionPaidAmount, Name: "付款金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的人工确认付款金额"}, + {Code: constants.ApprovalFieldCollectionPaidAmountCent, Name: "付款金额(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的人工确认付款金额整数"}, + {Code: constants.ApprovalFieldCollectionPayerName, Name: "付款方", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次外部付款的付款方名称"}, + {Code: constants.ApprovalFieldCollectionPaidAt, Name: "付款时间", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次外部付款时间,RFC3339 格式"}, + {Code: constants.ApprovalFieldCollectionExternalTransactionNo, Name: "外部交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "人工确认的第三方交易流水号,用于审批人核验"}, + {Code: constants.ApprovalFieldPaymentVoucherKey, Name: "付款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的付款凭证列表"}, + {Code: constants.ApprovalFieldRemark, Name: "备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "申请人填写的核销备注"}, + {Code: constants.ApprovalFieldCollectionBillCount, Name: "分摊账单数量", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本次核销分摊的账单数量"}, + {Code: constants.ApprovalFieldCollectionBillSummary, Name: "账单分摊摘要", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次各账单应收金额与分摊金额摘要"}, + {Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"}, + {Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"}, + }, true default: return nil, false } @@ -353,14 +369,27 @@ func normalizeSceneMapping(mapping []dto.WeComControlMappingItem) []dto.WeComCon } func validApprovalBusinessType(businessType string) bool { - return businessType == constants.ApprovalBusinessTypeRefund || businessType == constants.ApprovalBusinessTypeOfflineRecharge + switch businessType { + case constants.ApprovalBusinessTypeRefund, + constants.ApprovalBusinessTypeOfflineRecharge, + constants.ApprovalBusinessTypeEmployeeCollection: + return true + default: + return false + } } func approvalBusinessTypeName(businessType string) string { - if businessType == constants.ApprovalBusinessTypeRefund { + switch businessType { + case constants.ApprovalBusinessTypeRefund: return "退款审批" + case constants.ApprovalBusinessTypeOfflineRecharge: + return "员工线下代充值审批" + case constants.ApprovalBusinessTypeEmployeeCollection: + return "员工代收款核销审批" + default: + return "未知审批业务类型" } - return "员工线下代充值审批" } func sceneAuditSnapshot(scene *model.WeComApprovalScene) map[string]any { diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 4c1a6d1..aded6ab 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -1,6 +1,7 @@ package bootstrap import ( + employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" roleApp "github.com/break/junhong_cmp_fiber/internal/application/role" @@ -22,6 +23,7 @@ import ( agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge" assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" auditQuery "github.com/break/junhong_cmp_fiber/internal/query/audit" + employeecollectionQuery "github.com/break/junhong_cmp_fiber/internal/query/employeecollection" exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange" integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration" notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification" @@ -287,6 +289,17 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { }(), WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig), PaymentMerchant: admin.NewPaymentMerchantHandler(merchantPaymentApp.NewManagementService(deps.DB, systemConfigAudit)), + EmployeeCollection: func() *admin.EmployeeCollectionHandler { + handler := admin.NewEmployeeCollectionHandler( + employeecollectionApp.NewPaymentMethodService(deps.DB, svc.AccessAudit), + employeecollectionApp.NewBillCloseService(deps.DB, svc.AccessAudit), + employeecollectionApp.NewApplicationService(deps.DB, svc.Approval, svc.AccessAudit), + ) + handler.SetPaymentMethodQuery(employeecollectionQuery.NewPaymentMethodQuery(deps.DB)) + handler.SetBillQuery(employeecollectionQuery.NewBillQuery(deps.DB)) + handler.SetApplicationQuery(employeecollectionQuery.NewApplicationQuery(deps.DB)) + return handler + }(), AgentRecharge: func() *admin.AgentRechargeHandler { handler := admin.NewAgentRechargeHandler(svc.AgentRecharge, validate) handler.SetOnlineCreationService(svc.AgentRechargeOnline) diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 2e3a6a9..5da1d89 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -8,6 +8,7 @@ import ( agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" @@ -265,6 +266,9 @@ func initServices(s *stores, deps *Dependencies) *services { packageSeriesService := packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package) packageSeriesService.SetAccessAudit(deps.DB, auditWriter) orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone) + // 员工代收款建账用例在订单、充值入账与退款冲销的事务内复用同一实例。 + employeeCollectionBillCreation := employeecollectionApp.NewBillCreationService(auditWriter) + orderService.SetEmployeeCollectionBillCreation(employeeCollectionBillCreation) orderService.SetResumeCallback(stopResumeService) orderService.SetLifecycleAudit(auditWriter) orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB)) @@ -352,6 +356,10 @@ func initServices(s *stores, deps *Dependencies) *services { agentrechargeApp.NewOfflineCreationService(deps.DB, approvalCreationService, auditWriter), ) agentRechargeService.SetRechargeAudit(auditWriter) + agentRechargeService.SetEmployeeCollectionBillCreation(employeeCollectionBillCreation) + refundService.SetEmployeeCollectionRefundOffset( + employeecollectionApp.NewRefundOffsetService(auditWriter), + ) refundService.SetRefundApprovalCreationService( refundapprovalApp.NewCreationService(deps.DB, approvalCreationService, auditWriter), ) diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index bbb2bf2..9f686fe 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -69,6 +69,7 @@ type Handlers struct { AssetWallet *admin.AssetWalletHandler WechatConfig *admin.WechatConfigHandler PaymentMerchant *admin.PaymentMerchantHandler + EmployeeCollection *admin.EmployeeCollectionHandler AgentRecharge *admin.AgentRechargeHandler Refund *admin.RefundHandler OrderPackageInvalidate *admin.OrderPackageInvalidateHandler diff --git a/internal/domain/employeecollection/allocation.go b/internal/domain/employeecollection/allocation.go new file mode 100644 index 0000000..575af5c --- /dev/null +++ b/internal/domain/employeecollection/allocation.go @@ -0,0 +1,58 @@ +package employeecollection + +import ( + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// AllocationCandidate 是一笔待校验的核销申请账单分摊候选。 +type AllocationCandidate struct { + // BillID 表示目标账单ID。 + BillID uint + // BillStatus 表示目标账单当前持久化状态。 + BillStatus int + // Amount 表示本次分摊金额(分)。 + Amount int64 + // Available 表示目标账单当前可核销余额(分),已扣除已通过分摊与其他审批中预占。 + Available int64 +} + +// ValidateAllocations 校验核销申请的账单分摊集合。 +// 规则:付款金额为正、分摊数量在允许区间、账单不重复、账单已关闭时拒绝、 +// 单笔分摊为正且不超过该账单可核销余额、分摊总额不超过本次付款金额。 +func ValidateAllocations(paidAmount int64, candidates []AllocationCandidate) error { + if paidAmount <= 0 { + return errors.New(errors.CodeInvalidParam, "付款金额必须大于零") + } + if len(candidates) == 0 { + return errors.New(errors.CodeInvalidParam, "核销申请至少需要一个账单分摊") + } + if len(candidates) > constants.EmployeeCollectionAllocationMaxCount { + return errors.New(errors.CodeInvalidParam, "核销申请账单分摊数量超出限制") + } + seen := make(map[uint]struct{}, len(candidates)) + var total int64 + for _, candidate := range candidates { + if candidate.BillID == 0 { + return errors.New(errors.CodeInvalidParam, "账单分摊缺少目标账单") + } + if _, exists := seen[candidate.BillID]; exists { + return errors.New(errors.CodeInvalidParam, "同一账单不能重复分摊") + } + seen[candidate.BillID] = struct{}{} + if candidate.BillStatus == constants.EmployeeCollectionBillStatusClosed { + return errors.New(errors.CodeEmployeeCollectionBillClosed) + } + if candidate.Amount <= 0 { + return errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid) + } + if candidate.Amount > candidate.Available { + return errors.New(errors.CodeEmployeeCollectionAllocationExceeded) + } + total += candidate.Amount + } + if total > paidAmount { + return errors.New(errors.CodeEmployeeCollectionPaidAmountExceeded) + } + return nil +} diff --git a/internal/domain/employeecollection/application.go b/internal/domain/employeecollection/application.go new file mode 100644 index 0000000..a1a5550 --- /dev/null +++ b/internal/domain/employeecollection/application.go @@ -0,0 +1,110 @@ +package employeecollection + +import ( + "strings" + "time" + "unicode/utf8" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ApplicationInput 是核销申请提交的领域输入;账单分摊由 ValidateAllocations 单独校验。 +type ApplicationInput struct { + // PaidAmount 表示人工确认的付款金额(分)。 + PaidAmount int64 + // PayerName 表示付款方名称。 + PayerName string + // PaidAt 表示付款时间。 + PaidAt time.Time + // ExternalTransactionNo 表示人工确认的外部交易流水号。 + ExternalTransactionNo string + // Remark 表示申请备注。 + Remark string + // ActingReason 表示代办原因,仅代办提交时必填。 + ActingReason string + // PaymentVoucherKeys 表示支付凭证对象存储键列表。 + PaymentVoucherKeys []string +} + +// NormalizedApplicationInput 是通过校验并去空格后的核销申请事实。 +type NormalizedApplicationInput struct { + PaidAmount int64 + PayerName string + PaidAt time.Time + ExternalTransactionNo string + Remark string + ActingReason string + PaymentVoucherKeys []string +} + +// NormalizeApplicationInput 校验并规范化核销申请输入。 +// acting 表示本次是否由超级管理员为他人代办:代办必须填写原因,本人办理不得填写原因。 +func NormalizeApplicationInput(input ApplicationInput, acting bool) (NormalizedApplicationInput, error) { + if input.PaidAmount <= 0 { + return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "付款金额必须大于零") + } + if input.PaidAt.IsZero() { + return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "付款时间必填") + } + payerName := strings.TrimSpace(input.PayerName) + if payerName == "" { + return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "付款方名称必填") + } + if utf8.RuneCountInString(payerName) > constants.EmployeeCollectionPayerNameMaxLength { + return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "付款方名称长度超出限制") + } + externalTransactionNo := strings.TrimSpace(input.ExternalTransactionNo) + if externalTransactionNo == "" { + return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "外部交易流水号必填") + } + if utf8.RuneCountInString(externalTransactionNo) > constants.EmployeeCollectionExternalTransactionNoMaxLength { + return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "外部交易流水号长度超出限制") + } + remark := strings.TrimSpace(input.Remark) + if utf8.RuneCountInString(remark) > constants.EmployeeCollectionRemarkMaxLength { + return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "核销申请备注长度超出限制") + } + actingReason := strings.TrimSpace(input.ActingReason) + if utf8.RuneCountInString(actingReason) > constants.EmployeeCollectionRemarkMaxLength { + return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "代办原因长度超出限制") + } + if acting && actingReason == "" { + return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "超级管理员代办核销申请必须填写代办原因") + } + if !acting && actingReason != "" { + return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "本人办理核销申请不能填写代办原因") + } + vouchers, err := NormalizePaymentVouchers(input.PaymentVoucherKeys) + if err != nil { + return NormalizedApplicationInput{}, err + } + return NormalizedApplicationInput{ + PaidAmount: input.PaidAmount, PayerName: payerName, + PaidAt: input.PaidAt.UTC(), ExternalTransactionNo: externalTransactionNo, + Remark: remark, ActingReason: actingReason, PaymentVoucherKeys: vouchers, + }, nil +} + +// ValidateApplicationResubmit 校验申请当前状态允许修改并重提。 +// 只有企业微信最终驳回的申请可以修改重提;已通过、审批中与异常终态一律拒绝。 +func ValidateApplicationResubmit(status int) error { + if status == constants.EmployeeCollectionApplicationStatusRejected { + return nil + } + return errors.New(errors.CodeEmployeeCollectionApplicationStatusInvalid) +} + +// MaskExternalTransactionNo 生成外部交易流水号的脱敏展示,用于审计与日志,不保留完整流水。 +// 长度不超过 8 时整体掩码,否则保留首尾各 4 位。 +func MaskExternalTransactionNo(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + runes := []rune(trimmed) + if len(runes) <= 8 { + return "****" + } + return string(runes[:4]) + "****" + string(runes[len(runes)-4:]) +} diff --git a/internal/domain/employeecollection/approval_opinion.go b/internal/domain/employeecollection/approval_opinion.go new file mode 100644 index 0000000..12d4b4e --- /dev/null +++ b/internal/domain/employeecollection/approval_opinion.go @@ -0,0 +1,51 @@ +package employeecollection + +import ( + "strings" + + "github.com/bytedance/sonic" +) + +// ExtractApprovalOpinion 从通用审批实例的终态决策快照中提取审批意见文本。 +// 快照来自渠道审批详情(`info` 对象),审批意见位于 `comments[].comment_content`; +// 兼容 `content` 与 `text` 两种等价键。取最后一条非空意见作为最终审批意见。 +// 无法解析或没有意见时返回空字符串:意见缺失不影响申请与账单事实。 +func ExtractApprovalOpinion(snapshot []byte) string { + if len(snapshot) == 0 { + return "" + } + var payload map[string]any + if err := sonic.Unmarshal(snapshot, &payload); err != nil { + return "" + } + opinion := "" + if raw, ok := payload["comments"].([]any); ok { + for _, item := range raw { + comment, ok := item.(map[string]any) + if !ok { + continue + } + if content := commentText(comment); content != "" { + opinion = content + } + } + } + if opinion != "" { + return opinion + } + return commentText(payload) +} + +// commentText 按优先顺序读取审批意见文本。 +func commentText(container map[string]any) string { + for _, key := range []string{"comment_content", "content", "text"} { + value, ok := container[key].(string) + if !ok { + continue + } + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/domain/employeecollection/bill.go b/internal/domain/employeecollection/bill.go new file mode 100644 index 0000000..262e488 --- /dev/null +++ b/internal/domain/employeecollection/bill.go @@ -0,0 +1,173 @@ +// Package employeecollection 收口员工代收款账单的金额、状态与预占不变量。 +// 只依赖标准库、领域常量和稳定错误,不依赖传输、持久化或外部 SDK。 +package employeecollection + +import ( + "strings" + "unicode/utf8" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// BillAmounts 描述一张员工代收款账单的应收、已核销、审批中预占与关闭事实。 +type BillAmounts struct { + // Receivable 表示应收金额(分),来源成功事务判定后不允许为负。 + Receivable int64 + // Received 表示企业微信最终通过后累计的已核销金额(分)。 + Received int64 + // Reserved 表示审批中分摊预占的金额(分)。 + Reserved int64 + // Closed 表示账单是否已关闭;已关闭账单的可核销余额为 0。 + Closed bool +} + +// NewBillAmounts 依据来源应收金额构造初始账单金额事实。 +// 应收金额必须大于零,避免零元账单立即成为已核销。 +func NewBillAmounts(receivable int64) (BillAmounts, error) { + amounts := BillAmounts{Receivable: receivable} + if err := amounts.Validate(); err != nil { + return BillAmounts{}, err + } + return amounts, nil +} + +// Validate 校验账单金额不变量:应收为正,已核销与预占非负且合计不超过应收。 +func (a BillAmounts) Validate() error { + if a.Receivable <= 0 { + return errors.New(errors.CodeInvalidParam, "账单应收金额必须大于零") + } + if a.Received < 0 || a.Reserved < 0 { + return errors.New(errors.CodeInvalidParam, "账单已核销与预占金额不能为负") + } + if a.Received+a.Reserved > a.Receivable { + return errors.New(errors.CodeInvalidParam, "账单已核销与预占金额合计不能超过应收金额") + } + return nil +} + +// Available 返回账单当前可被新分摊占用的金额;已关闭账单始终返回 0。 +func (a BillAmounts) Available() int64 { + if a.Closed { + return 0 + } + available := a.Receivable - a.Received - a.Reserved + if available < 0 { + return 0 + } + return available +} + +// DerivedStatus 依据金额推导未关闭账单的核销状态。 +// 调用方必须自行区分已关闭账单,关闭状态不可由金额推导。 +func (a BillAmounts) DerivedStatus() int { + switch { + case a.Received <= 0: + return constants.EmployeeCollectionBillStatusPending + case a.Received >= a.Receivable: + return constants.EmployeeCollectionBillStatusSettled + default: + return constants.EmployeeCollectionBillStatusPartial + } +} + +// Reserve 在审批中预占指定金额,返回预占后的新金额事实。 +// 分摊金额必须大于零且不超过当前可核销余额。 +func (a BillAmounts) Reserve(amount int64) (BillAmounts, error) { + if amount <= 0 { + return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid) + } + if err := a.ensureSettleable(); err != nil { + return BillAmounts{}, err + } + if amount > a.Available() { + return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationExceeded) + } + a.Reserved += amount + return a, nil +} + +// Release 释放指定金额的审批中预占,返回释放后的新金额事实。 +func (a BillAmounts) Release(amount int64) (BillAmounts, error) { + if amount <= 0 { + return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid) + } + if amount > a.Reserved { + return BillAmounts{}, errors.New(errors.CodeInternalError, "释放的预占金额超过账单当前预占") + } + a.Reserved -= amount + return a, nil +} + +// Approve 将指定金额从审批中预占转入已核销,返回通过后的新金额事实。 +func (a BillAmounts) Approve(amount int64) (BillAmounts, error) { + if amount <= 0 { + return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid) + } + if amount > a.Reserved { + return BillAmounts{}, errors.New(errors.CodeInternalError, "通过的分摊金额超过账单当前预占") + } + a.Reserved -= amount + a.Received += amount + return a, nil +} + +// ReduceReceivable 按来源订单退款金额冲减应收,仅在账单不存在任何已通过或审批中分摊时允许。 +func (a BillAmounts) ReduceReceivable(amount int64) (BillAmounts, error) { + if amount <= 0 { + return BillAmounts{}, errors.New(errors.CodeInvalidParam, "冲减金额必须大于零") + } + if a.Received > 0 || a.Reserved > 0 { + return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionBillNotSettleable, "账单存在分摊,不能冲减应收") + } + if amount >= a.Receivable { + return BillAmounts{}, errors.New(errors.CodeInvalidParam, "冲减金额必须小于账单应收金额") + } + a.Receivable -= amount + return a, nil +} + +// ensureSettleable 校验账单允许产生新的审批中分摊。 +func (a BillAmounts) ensureSettleable() error { + if a.Closed { + return errors.New(errors.CodeEmployeeCollectionBillClosed) + } + if a.DerivedStatus() == constants.EmployeeCollectionBillStatusSettled { + return errors.New(errors.CodeEmployeeCollectionBillNotSettleable) + } + return nil +} + +// BillCloseInput 描述关闭一张账单前的事实。 +type BillCloseInput struct { + // Status 表示账单当前持久化状态。 + Status int + // PendingAllocations 表示账单上仍处于审批中(预占)的分摊数量。 + PendingAllocations int64 + // Reason 表示关闭原因,必填。 + Reason string +} + +// ValidateBillClose 校验关闭账单的前置条件。 +// 已关闭账单返回账单已关闭,已核销账单不允许关闭,存在审批中分摊时拒绝关闭,关闭原因必填。 +func ValidateBillClose(input BillCloseInput) error { + if input.Status == constants.EmployeeCollectionBillStatusClosed { + return errors.New(errors.CodeEmployeeCollectionBillClosed) + } + if input.Status == constants.EmployeeCollectionBillStatusSettled { + return errors.New(errors.CodeEmployeeCollectionBillNotSettleable, "已核销账单没有未核销余额,不能关闭") + } + if input.Status != constants.EmployeeCollectionBillStatusPending && + input.Status != constants.EmployeeCollectionBillStatusPartial { + return errors.New(errors.CodeConflict, "账单当前状态不允许关闭") + } + if input.PendingAllocations > 0 { + return errors.New(errors.CodeEmployeeCollectionApplicationPending) + } + if trimmed := strings.TrimSpace(input.Reason); trimmed == "" { + return errors.New(errors.CodeInvalidParam, "关闭原因必填") + } else if utf8.RuneCountInString(trimmed) > constants.EmployeeCollectionRemarkMaxLength { + return errors.New(errors.CodeInvalidParam, "关闭原因长度超出限制") + } + return nil +} diff --git a/internal/domain/employeecollection/order_bill.go b/internal/domain/employeecollection/order_bill.go new file mode 100644 index 0000000..054a847 --- /dev/null +++ b/internal/domain/employeecollection/order_bill.go @@ -0,0 +1,56 @@ +package employeecollection + +import ( + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// OrderBillSubject 是判定后台线下套餐订单是否建账所需的来源事实。 +// 四个条件必须同时成立,判定只由 ShouldCreateBillForOrder 一处实现。 +type OrderBillSubject struct { + // PaymentMethod 表示订单支付方式快照。 + PaymentMethod string + // OperatorAccountType 表示实际操作账号类型快照。 + OperatorAccountType string + // ActualPaidAmount 表示订单实际支付金额(分),空表示来源未产生实收金额。 + ActualPaidAmount *int64 + // HasGiftPackage 表示订单是否包含赠送套餐。 + HasGiftPackage bool +} + +// OrderBillSubjectFromOrder 从已冻结的订单事实提取建账判据输入。 +// 建账与创建时付款凭证放宽必须使用同一份输入,避免出现两套口径。 +func OrderBillSubjectFromOrder(order *model.Order, hasGiftPackage bool) OrderBillSubject { + if order == nil { + return OrderBillSubject{} + } + return OrderBillSubject{ + PaymentMethod: order.PaymentMethod, + OperatorAccountType: order.OperatorAccountType, + ActualPaidAmount: order.ActualPaidAmount, + HasGiftPackage: hasGiftPackage, + } +} + +// ShouldCreateBillForOrder 判定后台线下套餐订单是否触发员工代收款建账。 +// 判据:支付方式为线下、实际操作账号为平台账号、订单不含赠送套餐、实收金额大于零。 +func ShouldCreateBillForOrder(subject OrderBillSubject) bool { + return subject.PaymentMethod == model.PaymentMethodOffline && + subject.OperatorAccountType == model.OperatorAccountTypePlatform && + !subject.HasGiftPackage && + subject.ActualPaidAmount != nil && *subject.ActualPaidAmount > 0 +} + +// RechargeBillSubject 是判定代理线下充值入账是否建账所需的来源事实。 +type RechargeBillSubject struct { + // PaymentMethod 表示充值记录支付方式。 + PaymentMethod string + // Amount 表示充值记录金额(分)。 + Amount int64 +} + +// ShouldCreateBillForRecharge 判定代理线下充值入账是否触发员工代收款建账。 +// 判据:支付方式为线下且入账金额大于零;零金额无法形成正的应收金额。 +func ShouldCreateBillForRecharge(subject RechargeBillSubject) bool { + return subject.PaymentMethod == constants.RechargeMethodOffline && subject.Amount > 0 +} diff --git a/internal/domain/employeecollection/payment_method.go b/internal/domain/employeecollection/payment_method.go new file mode 100644 index 0000000..a81bdfe --- /dev/null +++ b/internal/domain/employeecollection/payment_method.go @@ -0,0 +1,69 @@ +package employeecollection + +import ( + "strings" + "unicode" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// PaymentMethodInput 是线下收款方式字典写入的领域输入。 +type PaymentMethodInput struct { + // Code 表示稳定编码,创建后仅可在未被引用时修改。 + Code string + // Name 表示收款方式名称。 + Name string + // SortOrder 表示排序值,必须非负。 + SortOrder int64 + // Status 表示启停状态,取值见 constants.EmployeeCollectionPaymentMethodStatus*。 + Status int + // Remark 表示备注。 + Remark string +} + +// NormalizedPaymentMethodInput 是通过校验并去空格后的字典写入事实。 +type NormalizedPaymentMethodInput struct { + Code string + Name string + SortOrder int64 + Status int + Remark string +} + +// NormalizePaymentMethodInput 校验并规范化线下收款方式字典写入输入。 +// 规则:编码 1 至 64 字符且不含空白或控制字符、名称 1 至 100 字符、 +// 排序值非负、状态仅允许启用或停用、备注不超过 500 字符。 +func NormalizePaymentMethodInput(input PaymentMethodInput) (NormalizedPaymentMethodInput, error) { + code := strings.TrimSpace(input.Code) + if code == "" { + return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式编码必填") + } + if len([]rune(code)) > constants.EmployeeCollectionCodeMaxLength { + return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式编码长度超出限制") + } + if strings.IndexFunc(code, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 { + return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式编码不能包含空白或控制字符") + } + name := strings.TrimSpace(input.Name) + if name == "" { + return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式名称必填") + } + if len([]rune(name)) > constants.EmployeeCollectionNameMaxLength { + return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式名称长度超出限制") + } + if input.SortOrder < 0 { + return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式排序值不能为负") + } + if input.Status != constants.EmployeeCollectionPaymentMethodStatusDisabled && + input.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled { + return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式状态仅支持停用或启用") + } + remark := strings.TrimSpace(input.Remark) + if len([]rune(remark)) > constants.EmployeeCollectionRemarkMaxLength { + return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式备注长度超出限制") + } + return NormalizedPaymentMethodInput{ + Code: code, Name: name, SortOrder: input.SortOrder, Status: input.Status, Remark: remark, + }, nil +} diff --git a/internal/domain/employeecollection/refund_offset.go b/internal/domain/employeecollection/refund_offset.go new file mode 100644 index 0000000..8baa455 --- /dev/null +++ b/internal/domain/employeecollection/refund_offset.go @@ -0,0 +1,37 @@ +package employeecollection + +import ( + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// RefundOffsetDecision 是来源订单退款成功对账单的处理判定结果。 +type RefundOffsetDecision struct { + // Outcome 取值见 constants.EmployeeCollectionRefundOutcome*。 + Outcome string + // ReducedAmount 是本次实际冲减的应收金额(分),仅 reduced 时大于零。 + ReducedAmount int64 +} + +// DecideRefundOffset 判定来源订单本次退款成功金额对账单的处理方式。 +// 判定顺序:已关闭账单与存在已通过或审批中分摊的账单只写退款关联提示, +// 其余按退款成功金额与账单应收比较,等于或超过应收时关闭账单,小于应收时按退款金额冲减。 +// 已通过分摊体现为 received_amount > 0,审批中分摊体现为 reserved_amount > 0; +// 这两个金额只由本能力的条件更新维护,因此与「存在已通过或审批中分摊」等价。 +func DecideRefundOffset(bill BillAmounts, refundAmount int64) (RefundOffsetDecision, error) { + if refundAmount <= 0 { + return RefundOffsetDecision{}, errors.New(errors.CodeInvalidParam, "退款成功金额必须大于零") + } + if err := bill.Validate(); err != nil { + return RefundOffsetDecision{}, err + } + if bill.Closed || bill.Received > 0 || bill.Reserved > 0 { + return RefundOffsetDecision{Outcome: constants.EmployeeCollectionRefundOutcomeHintOnly}, nil + } + if refundAmount >= bill.Receivable { + return RefundOffsetDecision{Outcome: constants.EmployeeCollectionRefundOutcomeClosedFull}, nil + } + return RefundOffsetDecision{ + Outcome: constants.EmployeeCollectionRefundOutcomeReduced, ReducedAmount: refundAmount, + }, nil +} diff --git a/internal/domain/employeecollection/source.go b/internal/domain/employeecollection/source.go new file mode 100644 index 0000000..5c4569d --- /dev/null +++ b/internal/domain/employeecollection/source.go @@ -0,0 +1,14 @@ +package employeecollection + +import "strconv" + +// OrderSourceKey 返回后台线下套餐订单来源的账单唯一键。 +// 该键是 tb_employee_collection_bill.source_key 的持久化契约,同一来源至多一张账单。 +func OrderSourceKey(orderID uint) string { + return "order:" + strconv.FormatUint(uint64(orderID), 10) +} + +// RechargeSourceKey 返回代理线下充值来源的账单唯一键。 +func RechargeSourceKey(rechargeID uint) string { + return "recharge:" + strconv.FormatUint(uint64(rechargeID), 10) +} diff --git a/internal/domain/employeecollection/voucher.go b/internal/domain/employeecollection/voucher.go new file mode 100644 index 0000000..02371a3 --- /dev/null +++ b/internal/domain/employeecollection/voucher.go @@ -0,0 +1,35 @@ +package employeecollection + +import ( + "strings" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// NormalizePaymentVouchers 校验并规范化支付凭证对象键列表。 +// 规则:数量必须在 1 至 5 个之间、每个键去空格后非空且不超过长度上限、不允许重复。 +// 只接受对象存储键引用,不接受内联内容,避免敏感付款材料进入业务事实。 +func NormalizePaymentVouchers(keys []string) ([]string, error) { + if len(keys) < constants.EmployeeCollectionVoucherMinCount || + len(keys) > constants.EmployeeCollectionVoucherMaxCount { + return nil, errors.New(errors.CodeEmployeeCollectionVoucherInvalid) + } + normalized := make([]string, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return nil, errors.New(errors.CodeEmployeeCollectionVoucherInvalid) + } + if len([]rune(trimmed)) > constants.EmployeeCollectionVoucherKeyMaxLength { + return nil, errors.New(errors.CodeEmployeeCollectionVoucherInvalid) + } + if _, exists := seen[trimmed]; exists { + return nil, errors.New(errors.CodeEmployeeCollectionVoucherInvalid) + } + seen[trimmed] = struct{}{} + normalized = append(normalized, trimmed) + } + return normalized, nil +} diff --git a/internal/handler/admin/employee_collection.go b/internal/handler/admin/employee_collection.go new file mode 100644 index 0000000..e3fbd2b --- /dev/null +++ b/internal/handler/admin/employee_collection.go @@ -0,0 +1,272 @@ +package admin + +import ( + "strings" + "time" + + "github.com/gofiber/fiber/v2" + + employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + employeecollectionquery "github.com/break/junhong_cmp_fiber/internal/query/employeecollection" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// EmployeeCollectionHandler 处理员工代收款账单、核销申请与线下收款方式字典请求。 +// 边界只做绑定、路径参数校验与统一响应,状态、金额与权限不变量由应用用例判断。 +type EmployeeCollectionHandler struct { + paymentMethod *employeecollectionapp.PaymentMethodService + paymentMethodQuery *employeecollectionquery.PaymentMethodQuery + billClose *employeecollectionapp.BillCloseService + billQuery *employeecollectionquery.BillQuery + application *employeecollectionapp.ApplicationService + applicationQuery *employeecollectionquery.ApplicationQuery +} + +// NewEmployeeCollectionHandler 创建员工代收款处理器。 +func NewEmployeeCollectionHandler( + service *employeecollectionapp.PaymentMethodService, + billClose *employeecollectionapp.BillCloseService, + application *employeecollectionapp.ApplicationService, +) *EmployeeCollectionHandler { + return &EmployeeCollectionHandler{paymentMethod: service, billClose: billClose, application: application} +} + +// SetApplicationQuery 注入核销申请只读投影。 +func (h *EmployeeCollectionHandler) SetApplicationQuery(query *employeecollectionquery.ApplicationQuery) { + h.applicationQuery = query +} + +// SetPaymentMethodQuery 注入线下收款方式字典只读投影。 +func (h *EmployeeCollectionHandler) SetPaymentMethodQuery(query *employeecollectionquery.PaymentMethodQuery) { + h.paymentMethodQuery = query +} + +// SetBillQuery 注入员工代收款账单只读投影。 +func (h *EmployeeCollectionHandler) SetBillQuery(query *employeecollectionquery.BillQuery) { + h.billQuery = query +} + +// CreatePaymentMethod 创建线下收款方式。 +// POST /api/admin/employee-collection-payment-methods +func (h *EmployeeCollectionHandler) CreatePaymentMethod(c *fiber.Ctx) error { + var request dto.CreateEmployeeCollectionPaymentMethodRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + result, err := h.paymentMethod.Create(c.UserContext(), request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// UpdatePaymentMethod 更新线下收款方式,支持修改名称、排序、启停、备注与未被引用时的稳定编码。 +// PUT /api/admin/employee-collection-payment-methods/:id +func (h *EmployeeCollectionHandler) UpdatePaymentMethod(c *fiber.Ctx) error { + id, err := pathID(c) + if err != nil { + return err + } + var request dto.UpdateEmployeeCollectionPaymentMethodRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + result, err := h.paymentMethod.Update(c.UserContext(), id, request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// DeletePaymentMethod 删除未被核销申请引用的线下收款方式。 +// DELETE /api/admin/employee-collection-payment-methods/:id +func (h *EmployeeCollectionHandler) DeletePaymentMethod(c *fiber.Ctx) error { + id, err := pathID(c) + if err != nil { + return err + } + if err := h.paymentMethod.Delete(c.UserContext(), id); err != nil { + return err + } + return response.Success(c, nil) +} + +// ListPaymentMethods 分页查询线下收款方式。 +// GET /api/admin/employee-collection-payment-methods +func (h *EmployeeCollectionHandler) ListPaymentMethods(c *fiber.Ctx) error { + var request dto.EmployeeCollectionPaymentMethodListRequest + if err := c.QueryParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + result, err := h.paymentMethodQuery.List(c.UserContext(), request) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) +} + +// ListBills 分页查询员工代收款账单。 +// GET /api/admin/employee-collection-bills +func (h *EmployeeCollectionHandler) ListBills(c *fiber.Ctx) error { + var request dto.EmployeeCollectionBillListRequest + if err := c.QueryParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + result, err := h.billQuery.List(c.UserContext(), request) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) +} + +// StatisticsBills 汇总员工代收款账单金额与待处理数量。 +// GET /api/admin/employee-collection-bills/statistics +func (h *EmployeeCollectionHandler) StatisticsBills(c *fiber.Ctx) error { + var request dto.EmployeeCollectionBillStatisticsRequest + if err := c.QueryParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + result, err := h.billQuery.Statistics(c.UserContext(), request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// GetBill 查询员工代收款账单详情。 +// GET /api/admin/employee-collection-bills/:id +func (h *EmployeeCollectionHandler) GetBill(c *fiber.Ctx) error { + id, err := pathID(c) + if err != nil { + return err + } + result, err := h.billQuery.Detail(c.UserContext(), id) + if err != nil { + return err + } + return response.Success(c, result) +} + +// CloseBill 关闭员工代收款账单,仅超级管理员可操作。 +// POST /api/admin/employee-collection-bills/:id/close +func (h *EmployeeCollectionHandler) CloseBill(c *fiber.Ctx) error { + id, err := pathID(c) + if err != nil { + return err + } + var request dto.CloseEmployeeCollectionBillRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + bill, err := h.billClose.Close(c.UserContext(), id, request.Reason) + if err != nil { + return err + } + result, err := employeecollectionquery.ProjectBill(bill) + if err != nil { + return err + } + return response.Success(c, result) +} + +// CreateApplication 为本人可见账单创建核销申请。 +// POST /api/admin/employee-collection-applications +func (h *EmployeeCollectionHandler) CreateApplication(c *fiber.Ctx) error { + request, err := bindApplicationRequest(c) + if err != nil { + return err + } + result, err := h.application.Create(c.UserContext(), request) + if err != nil { + return err + } + projected, err := employeecollectionquery.ProjectApplicationSubmit(employeecollectionquery.ApplicationSubmitProjection{ + Application: result.Application, Attempt: result.Attempt, Allocations: result.Allocations, + Bills: result.Bills, InstanceID: result.InstanceID, InstanceStatus: result.InstanceStatus, + }) + if err != nil { + return err + } + return response.Success(c, projected) +} + +// ResubmitApplication 修改并重提已驳回的核销申请。 +// PUT /api/admin/employee-collection-applications/:id +func (h *EmployeeCollectionHandler) ResubmitApplication(c *fiber.Ctx) error { + id, err := pathID(c) + if err != nil { + return err + } + request, err := bindApplicationRequest(c) + if err != nil { + return err + } + result, err := h.application.Resubmit(c.UserContext(), id, request) + if err != nil { + return err + } + projected, err := employeecollectionquery.ProjectApplicationSubmit(employeecollectionquery.ApplicationSubmitProjection{ + Application: result.Application, Attempt: result.Attempt, Allocations: result.Allocations, + Bills: result.Bills, InstanceID: result.InstanceID, InstanceStatus: result.InstanceStatus, + }) + if err != nil { + return err + } + return response.Success(c, projected) +} + +// ListApplications 分页查询核销申请。 +// GET /api/admin/employee-collection-applications +func (h *EmployeeCollectionHandler) ListApplications(c *fiber.Ctx) error { + var request dto.EmployeeCollectionApplicationListRequest + if err := c.QueryParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + result, err := h.applicationQuery.List(c.UserContext(), request) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) +} + +// GetApplication 查询核销申请详情,含分摊与全部审批尝试历史。 +// GET /api/admin/employee-collection-applications/:id +func (h *EmployeeCollectionHandler) GetApplication(c *fiber.Ctx) error { + id, err := pathID(c) + if err != nil { + return err + } + result, err := h.applicationQuery.Detail(c.UserContext(), id) + if err != nil { + return err + } + return response.Success(c, result) +} + +// bindApplicationRequest 绑定并转换创建或重提核销申请请求。 +// 付款时间按带时区的 RFC3339 解析;其余边界与业务校验由应用用例统一判断。 +func bindApplicationRequest(c *fiber.Ctx) (employeecollectionapp.SubmitApplicationCommand, error) { + var request dto.SubmitEmployeeCollectionApplicationRequest + if err := c.BodyParser(&request); err != nil { + return employeecollectionapp.SubmitApplicationCommand{}, errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + paidAt, err := time.Parse(time.RFC3339, strings.TrimSpace(request.PaidAt)) + if err != nil { + return employeecollectionapp.SubmitApplicationCommand{}, errors.New(errors.CodeInvalidParam, "付款时间必须为带时区的 RFC3339 格式") + } + allocations := make([]employeecollectionapp.ApplicationAllocationCommand, 0, len(request.Allocations)) + for _, item := range request.Allocations { + allocations = append(allocations, employeecollectionapp.ApplicationAllocationCommand{ + BillID: item.BillID, Amount: item.Amount, + }) + } + return employeecollectionapp.SubmitApplicationCommand{ + PaymentMethodID: request.PaymentMethodID, PaidAmount: request.PaidAmount, + PayerName: request.PayerName, PaidAt: paidAt, + ExternalTransactionNo: request.ExternalTransactionNo, + PaymentVoucherKeys: request.PaymentVoucherKeys, Remark: request.Remark, + ActingReason: request.ActingReason, Allocations: allocations, + }, nil +} diff --git a/internal/infrastructure/audit/approval.go b/internal/infrastructure/audit/approval.go index 0dcda57..e672839 100644 --- a/internal/infrastructure/audit/approval.go +++ b/internal/infrastructure/audit/approval.go @@ -115,6 +115,20 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str "approval_instance_id": instanceID, "status": recharge.Status, }, }, nil + case constants.ApprovalBusinessTypeEmployeeCollection: + var attempt model.EmployeeCollectionApplicationAttempt + if err := tx.WithContext(ctx).First(&attempt, businessID).Error; err != nil { + return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联核销审批尝试记录失败") + } + return ResourceInput{ + Type: constants.AuditResourceEmployeeCollectionAttempt, ID: &id, + Key: id, DisplayName: "审批尝试 " + id, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness, + IdentitySnapshot: map[string]any{ + "id": attempt.ID, "application_id": attempt.ApplicationID, "attempt_no": attempt.AttemptNo, + "paid_amount": attempt.PaidAmount, "approval_instance_id": instanceID, + }, + }, nil default: return ResourceInput{}, errors.New(errors.CodeInvalidParam, "审批业务类型尚未注册审计资源") } diff --git a/internal/infrastructure/audit/employee_collection.go b/internal/infrastructure/audit/employee_collection.go new file mode 100644 index 0000000..a206197 --- /dev/null +++ b/internal/infrastructure/audit/employee_collection.go @@ -0,0 +1,41 @@ +package audit + +import ( + "context" + "strconv" + + "gorm.io/gorm" + + employeecollection "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// WriteEmployeeCollectionBill 将员工代收款账单事实变化写入统一 Audit Event。 +// 操作者与入口来自调用方审计上下文:订单建账为后台账号入口,充值入账为 Worker 或渠道回调入口。 +func (w *Writer) WriteEmployeeCollectionBill(ctx context.Context, tx *gorm.DB, change employeecollection.BillAudit) error { + if change.Bill == nil || change.Bill.ID == 0 || change.Bill.SourceKey == "" { + return errors.New(errors.CodeInvalidParam, "员工代收款账单审计资源不完整") + } + bill := change.Bill + resourceID := strconv.FormatUint(uint64(bill.ID), 10) + return w.Append(ctx, tx, AppendInput{ + EventID: change.EventID, ActionCode: change.ActionCode, Summary: change.Summary, + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + CorrelationID: change.CorrelationID, + Resources: []ResourceInput{{ + Type: constants.AuditResourceEmployeeCollectionBill, ID: &resourceID, + Key: bill.SourceKey, DisplayName: bill.SourceNo, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCollectionBill, + IdentitySnapshot: map[string]any{ + "id": bill.ID, "source_type": bill.SourceType, "source_id": bill.SourceID, + "source_key": bill.SourceKey, "source_no": bill.SourceNo, + "debtor_account_id": bill.DebtorAccountID, "receivable_amount": bill.ReceivableAmount, + "received_amount": bill.ReceivedAmount, "reserved_amount": bill.ReservedAmount, + "status": bill.Status, + }, + BeforeData: change.BeforeData, AfterData: change.AfterData, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary, + }}, + }) +} diff --git a/internal/infrastructure/audit/employee_collection_application.go b/internal/infrastructure/audit/employee_collection_application.go new file mode 100644 index 0000000..e717716 --- /dev/null +++ b/internal/infrastructure/audit/employee_collection_application.go @@ -0,0 +1,85 @@ +package audit + +import ( + "context" + "strconv" + + "gorm.io/gorm" + + employeecollection "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" + employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// WriteEmployeeCollectionApplication 将核销申请、审批尝试记录与受影响账单写入统一 Audit Event。 +// 申请与尝试的冻结快照含付款凭证对象键,审计只记录数量与外部流水号脱敏值,不复制敏感付款内容。 +func (w *Writer) WriteEmployeeCollectionApplication(ctx context.Context, tx *gorm.DB, change employeecollection.ApplicationAudit) error { + if change.Application == nil || change.Application.ID == 0 { + return errors.New(errors.CodeInvalidParam, "核销申请审计资源不完整") + } + application := change.Application + applicationID := strconv.FormatUint(uint64(application.ID), 10) + resources := []ResourceInput{{ + Type: constants.AuditResourceEmployeeCollectionApplication, ID: &applicationID, + Key: applicationID, DisplayName: "核销申请 " + applicationID, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCollectionApplication, + IdentitySnapshot: map[string]any{ + "id": application.ID, "applicant_account_id": application.ApplicantAccountID, + "acting_operator_id": application.ActingOperatorID, + "payment_method_id": application.PaymentMethodID, "paid_amount": application.PaidAmount, + "status": application.Status, + "latest_approval_instance_id": application.LatestApprovalInstanceID, + }, + BeforeData: change.BeforeData, AfterData: change.AfterData, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary, + }} + if change.Attempt != nil && change.Attempt.ID != 0 { + attempt := change.Attempt + attemptID := strconv.FormatUint(uint64(attempt.ID), 10) + instanceID := uint(0) + if attempt.ApprovalInstanceID != nil { + instanceID = *attempt.ApprovalInstanceID + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceEmployeeCollectionAttempt, ID: &attemptID, + Key: attemptID, DisplayName: "审批尝试 " + attemptID, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCollectionAttempt, + IdentitySnapshot: map[string]any{ + "id": attempt.ID, "application_id": attempt.ApplicationID, "attempt_no": attempt.AttemptNo, + "paid_amount": attempt.PaidAmount, "approval_instance_id": instanceID, + }, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary, + }) + } + for _, bill := range change.Bills { + if bill == nil || bill.ID == 0 { + continue + } + billID := strconv.FormatUint(uint64(bill.ID), 10) + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceEmployeeCollectionBill, ID: &billID, + Key: bill.SourceKey, DisplayName: bill.SourceNo, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleCollectionBillAffected, + IdentitySnapshot: map[string]any{ + "id": bill.ID, "source_type": bill.SourceType, "source_id": bill.SourceID, + "source_key": bill.SourceKey, "debtor_account_id": bill.DebtorAccountID, "status": bill.Status, + }, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary, + }) + } + return w.Append(ctx, tx, AppendInput{ + EventID: change.EventID, ActionCode: change.ActionCode, Summary: change.Summary, + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + CorrelationID: change.CorrelationID, + Metadata: map[string]any{ + "payment_method_code": application.PaymentMethodCode, + "payer_name": application.PayerName, + "external_transaction_no_masked": employeecollectiondomain.MaskExternalTransactionNo( + application.ExternalTransactionNo), + "voucher_count": len(application.PaymentVoucherKeys), + "bill_count": len(change.Bills), + }, + Resources: resources, + }) +} diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index 2b0aeab..6f2a439 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -179,6 +179,16 @@ func NewRegistry() *Registry { wecomDefaultCreatorSaved := connectionConfigAction(constants.AuditActionWeComDefaultCreatorSaved, "保存企业微信默认审批发起人", constants.AuditResourceWeComApplication, constants.AuditRiskHigh) wecomMembersSynced := connectionConfigAction(constants.AuditActionWeComMembersSynced, "同步企业微信应用可见成员", constants.AuditResourceWeComApplication, constants.AuditRiskNormal) wecomApprovalSceneSaved := connectionConfigAction(constants.AuditActionWeComApprovalSceneSaved, "保存企业微信审批场景配置", constants.AuditResourceWeComApprovalScene, constants.AuditRiskHigh) + employeeCollectionPaymentMethodCreated := connectionConfigAction(constants.AuditActionEmployeeCollectionPaymentMethodCreated, "创建线下收款方式", constants.AuditResourceEmployeeCollectionPaymentMethod, constants.AuditRiskNormal) + employeeCollectionPaymentMethodUpdated := connectionConfigAction(constants.AuditActionEmployeeCollectionPaymentMethodUpdated, "更新线下收款方式", constants.AuditResourceEmployeeCollectionPaymentMethod, constants.AuditRiskNormal) + employeeCollectionPaymentMethodDeleted := connectionConfigAction(constants.AuditActionEmployeeCollectionPaymentMethodDeleted, "删除线下收款方式", constants.AuditResourceEmployeeCollectionPaymentMethod, constants.AuditRiskHigh) + employeeCollectionBillCreated := employeeCollectionAction(constants.AuditActionEmployeeCollectionBillCreated, "创建员工代收款账单", constants.AuditResourceEmployeeCollectionBill) + employeeCollectionBillClosed := employeeCollectionAction(constants.AuditActionEmployeeCollectionBillClosed, "关闭员工代收款账单", constants.AuditResourceEmployeeCollectionBill) + employeeCollectionApplicationSubmitted := employeeCollectionAction(constants.AuditActionEmployeeCollectionApplicationSubmitted, "提交员工代收款核销申请", constants.AuditResourceEmployeeCollectionApplication) + employeeCollectionApplicationApproved := employeeCollectionAction(constants.AuditActionEmployeeCollectionApplicationApproved, "员工代收款核销申请企业微信通过", constants.AuditResourceEmployeeCollectionApplication) + employeeCollectionApplicationRejected := employeeCollectionAction(constants.AuditActionEmployeeCollectionApplicationRejected, "员工代收款核销申请企业微信驳回或撤销", constants.AuditResourceEmployeeCollectionApplication) + employeeCollectionApplicationRevoked := employeeCollectionAction(constants.AuditActionEmployeeCollectionApplicationRevoked, "员工代收款核销申请通过后撤销", constants.AuditResourceEmployeeCollectionApplication) + employeeCollectionBillRefundOffseted := employeeCollectionAction(constants.AuditActionEmployeeCollectionBillRefundOffseted, "来源订单退款冲销员工代收款账单", constants.AuditResourceEmployeeCollectionBill) outboxReplayed := outboxRecoveryAction( constants.AuditActionOutboxReplayed, "人工重放 Outbox 事件", @@ -349,245 +359,258 @@ func NewRegistry() *Registry { withdrawalRejected := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请") return &Registry{ actionsByOperation: map[string]ActionDefinition{ - constants.AuditOperationSystemConfigUpdate: systemConfigUpdated, - constants.AuditOperationPaymentConfigCreate: paymentConfigCreated, - constants.AuditOperationPaymentConfigUpdate: paymentConfigUpdated, - constants.AuditOperationPaymentConfigDelete: paymentConfigDeleted, - constants.AuditOperationPaymentConfigActivate: paymentConfigActivated, - constants.AuditOperationPaymentConfigDeactivate: paymentConfigDeactivated, - constants.AuditOperationCarrierCreate: carrierCreated, - constants.AuditOperationCarrierUpdate: carrierUpdated, - constants.AuditOperationCarrierDelete: carrierDeleted, - constants.AuditOperationCarrierStatusUpdate: carrierStatusUpdated, - constants.AuditOperationWeComApplicationSave: wecomApplicationSaved, - constants.AuditOperationWeComDefaultCreatorSave: wecomDefaultCreatorSaved, - constants.AuditOperationWeComMembersSync: wecomMembersSynced, - constants.AuditOperationWeComApprovalSceneSave: wecomApprovalSceneSaved, - constants.AuditOperationOutboxReplay: outboxReplayed, - constants.AuditOperationOutboxReleaseExpiredLease: outboxExpiredLeaseReleased, + constants.AuditOperationSystemConfigUpdate: systemConfigUpdated, + constants.AuditOperationPaymentConfigCreate: paymentConfigCreated, + constants.AuditOperationPaymentConfigUpdate: paymentConfigUpdated, + constants.AuditOperationPaymentConfigDelete: paymentConfigDeleted, + constants.AuditOperationPaymentConfigActivate: paymentConfigActivated, + constants.AuditOperationPaymentConfigDeactivate: paymentConfigDeactivated, + constants.AuditOperationCarrierCreate: carrierCreated, + constants.AuditOperationCarrierUpdate: carrierUpdated, + constants.AuditOperationCarrierDelete: carrierDeleted, + constants.AuditOperationCarrierStatusUpdate: carrierStatusUpdated, + constants.AuditOperationWeComApplicationSave: wecomApplicationSaved, + constants.AuditOperationWeComDefaultCreatorSave: wecomDefaultCreatorSaved, + constants.AuditOperationWeComMembersSync: wecomMembersSynced, + constants.AuditOperationWeComApprovalSceneSave: wecomApprovalSceneSaved, + constants.AuditOperationEmployeeCollectionPaymentMethodCreate: employeeCollectionPaymentMethodCreated, + constants.AuditOperationEmployeeCollectionPaymentMethodUpdate: employeeCollectionPaymentMethodUpdated, + constants.AuditOperationEmployeeCollectionPaymentMethodDelete: employeeCollectionPaymentMethodDeleted, + constants.AuditOperationOutboxReplay: outboxReplayed, + constants.AuditOperationOutboxReleaseExpiredLease: outboxExpiredLeaseReleased, }, actionsByCode: map[string]ActionDefinition{ - constants.AuditActionAccountCreated: accountCreated, - constants.AuditActionAccountUpdated: accountUpdated, - constants.AuditActionAccountDeleted: accountDeleted, - constants.AuditActionAccountPasswordReset: accountPasswordReset, - constants.AuditActionAccountPasswordChanged: accountPasswordChanged, - constants.AuditActionAccountWeComBound: accountWeComBound, - constants.AuditActionAuthLogin: authLogin, - constants.AuditActionAuthLogout: authLogout, - constants.AuditActionAuthTokenRefreshed: authTokenRefreshed, - constants.AuditActionAccountRolesAssigned: accountRolesAssigned, - constants.AuditActionAccountRoleRemoved: accountRoleRemoved, - constants.AuditActionShopRolesAssigned: shopRolesAssigned, - constants.AuditActionShopRoleDeleted: shopRoleDeleted, - constants.AuditActionShopCreated: shopCreated, - constants.AuditActionShopUpdated: shopUpdated, - constants.AuditActionShopEnabled: shopEnabled, - constants.AuditActionShopDisabled: shopDisabled, - constants.AuditActionShopDeleted: shopDeleted, - constants.AuditActionShopBusinessOwnerUpdated: shopBusinessOwnerUpdated, - constants.AuditActionShopClientLoginLimitUpdated: shopClientLoginLimitUpdated, - constants.AuditActionEnterpriseCreated: enterpriseCreated, - constants.AuditActionEnterpriseUpdated: enterpriseUpdated, - constants.AuditActionEnterpriseStatusUpdated: enterpriseStatusUpdated, - constants.AuditActionEnterprisePasswordUpdated: enterprisePasswordUpdated, - constants.AuditActionEnterpriseCardsAllocated: enterpriseCardsAllocated, - constants.AuditActionEnterpriseCardsRecalled: enterpriseCardsRecalled, - constants.AuditActionEnterpriseCardRemarkUpdated: enterpriseCardRemarkUpdated, - constants.AuditActionEnterpriseDevicesAllocated: enterpriseDevicesAllocated, - constants.AuditActionEnterpriseDevicesRecalled: enterpriseDevicesRecalled, - constants.AuditActionPersonalCustomerProfileUpdated: personalProfileUpdated, - constants.AuditActionPersonalCustomerPhoneBound: personalPhoneBound, - constants.AuditActionPersonalCustomerPhoneChanged: personalPhoneChanged, - constants.AuditActionPersonalCustomerWechatIdentityUpdated: personalWechatIdentityUpdated, - constants.AuditActionPersonalCustomerAssetBound: personalAssetBound, - constants.AuditActionPersonalCustomerAssetUnbound: personalAssetUnbound, - constants.AuditActionPersonalCustomerAssetBindingMigrated: personalAssetBindingMigrated, - constants.AuditActionIotCardCreated: iotCardCreated, - constants.AuditActionIotCardDeleted: iotCardDeleted, - constants.AuditActionIotCardDeactivated: iotCardDeactivated, - constants.AuditActionIotCardPollingStatusUpdated: iotCardPollingStatusUpdated, - constants.AuditActionIotCardPollingStatusBatchUpdated: iotCardPollingStatusBatchUpdated, - constants.AuditActionIotCardBatchDeleted: iotCardBatchDeleted, - constants.AuditActionIotCardAllocationBatch: iotCardAllocationBatch, - constants.AuditActionIotCardAllocated: iotCardAllocated, - constants.AuditActionIotCardRecallBatch: iotCardRecallBatch, - constants.AuditActionIotCardRecalled: iotCardRecalled, - constants.AuditActionIotCardSeriesBindingBatch: iotCardSeriesBindingBatch, - constants.AuditActionIotCardSeriesBound: iotCardSeriesBound, - constants.AuditActionIotCardSpeedTierSet: iotCardSpeedTierSet, - constants.AuditActionIotCardRealnamePolicyBatchUpdated: iotCardRealnamePolicyBatchUpdated, - constants.AuditActionIotCardRealnamePolicyUpdated: iotCardRealnamePolicyUpdated, - constants.AuditActionIotCardRealnameStatusUpdated: iotCardRealnameStatusUpdated, - constants.AuditActionIotCardRealnameCallbackSynced: iotCardRealnameCallbackSynced, - constants.AuditActionIotCardManualRefreshed: iotCardManualRefreshed, - constants.AuditActionIotCardPersonalRefreshed: iotCardPersonalRefreshed, - constants.AuditActionIotCardWorkerRealnameSynced: iotCardWorkerRealnameSynced, - constants.AuditActionIotCardWorkerTrafficSynced: iotCardWorkerTrafficSynced, - constants.AuditActionIotCardWorkerNetworkSynced: iotCardWorkerNetworkSynced, - constants.AuditActionIotCardManualStopped: iotCardManualStopped, - constants.AuditActionIotCardManualStarted: iotCardManualStarted, - constants.AuditActionIotCardAutoStopped: iotCardAutoStopped, - constants.AuditActionIotCardAutoStarted: iotCardAutoStarted, - constants.AuditActionIotCardOpenAPIStarted: iotCardOpenAPIStarted, - constants.AuditActionIotCardAutoStopReasonUpdated: iotCardAutoStopReasonUpdated, - constants.AuditActionDeviceCreated: deviceCreated, - constants.AuditActionDeviceDeleted: deviceDeleted, - constants.AuditActionDeviceDeactivated: deviceDeactivated, - constants.AuditActionDevicePollingStatusUpdated: devicePollingStatusUpdated, - constants.AuditActionDeviceAllocationBatch: deviceAllocationBatch, - constants.AuditActionDeviceAllocated: deviceAllocated, - constants.AuditActionDeviceRecallBatch: deviceRecallBatch, - constants.AuditActionDeviceRecalled: deviceRecalled, - constants.AuditActionDeviceSeriesBindingBatch: deviceSeriesBindingBatch, - constants.AuditActionDeviceSeriesBound: deviceSeriesBound, - constants.AuditActionDeviceRealnamePolicyBatchUpdated: deviceRealnamePolicyBatchUpdated, - constants.AuditActionDeviceRealnamePolicyUpdated: deviceRealnamePolicyUpdated, - constants.AuditActionDeviceStopped: deviceStopped, - constants.AuditActionDeviceStarted: deviceStarted, - constants.AuditActionDeviceWiFiSet: deviceWiFiSet, - constants.AuditActionDeviceSwitchModeSet: deviceSwitchModeSet, - constants.AuditActionDeviceRebooted: deviceRebooted, - constants.AuditActionDeviceReset: deviceReset, - constants.AuditActionDeviceCardBound: deviceCardBound, - constants.AuditActionDeviceCardUnbound: deviceCardUnbound, - constants.AuditActionDeviceCurrentCardSwitched: deviceCurrentCardSwitched, - constants.AuditActionDeviceWorkerObservationSynced: deviceWorkerObservationSynced, - constants.AuditActionCardExchangeCreated: cardExchangeCreated, - constants.AuditActionCardExchangeShippingInfoSubmitted: cardExchangeShippingInfoSubmitted, - constants.AuditActionCardExchangeShipped: cardExchangeShipped, - constants.AuditActionCardExchangeCompleted: cardExchangeCompleted, - constants.AuditActionCardExchangeCancelled: cardExchangeCancelled, - constants.AuditActionCardExchangeRenewed: cardExchangeRenewed, - constants.AuditActionDeviceExchangeCreated: deviceExchangeCreated, - constants.AuditActionDeviceExchangeShippingInfoSubmitted: deviceExchangeShippingInfoSubmitted, - constants.AuditActionDeviceExchangeShipped: deviceExchangeShipped, - constants.AuditActionDeviceExchangeCompleted: deviceExchangeCompleted, - constants.AuditActionDeviceExchangeCancelled: deviceExchangeCancelled, - constants.AuditActionDeviceExchangeRenewed: deviceExchangeRenewed, - constants.AuditActionSystemConfigUpdated: systemConfigUpdated, - constants.AuditActionPaymentConfigCreated: paymentConfigCreated, - constants.AuditActionPaymentConfigUpdated: paymentConfigUpdated, - constants.AuditActionPaymentConfigDeleted: paymentConfigDeleted, - constants.AuditActionPaymentConfigActivated: paymentConfigActivated, - constants.AuditActionPaymentConfigDeactivated: paymentConfigDeactivated, - constants.AuditActionCarrierCreated: carrierCreated, - constants.AuditActionCarrierUpdated: carrierUpdated, - constants.AuditActionCarrierDeleted: carrierDeleted, - constants.AuditActionCarrierStatusUpdated: carrierStatusUpdated, - constants.AuditActionWeComApplicationSaved: wecomApplicationSaved, - constants.AuditActionWeComDefaultCreatorSaved: wecomDefaultCreatorSaved, - constants.AuditActionWeComMembersSynced: wecomMembersSynced, - constants.AuditActionWeComApprovalSceneSaved: wecomApprovalSceneSaved, - constants.AuditActionOutboxReplayed: outboxReplayed, - constants.AuditActionOutboxExpiredLeaseReleased: outboxExpiredLeaseReleased, - constants.AuditActionIotCardImportTaskCreated: iotCardImportTaskCreated, - constants.AuditActionIotCardImportTaskCompleted: iotCardImportTaskCompleted, - constants.AuditActionDeviceImportTaskCreated: deviceImportTaskCreated, - constants.AuditActionDeviceImportTaskCompleted: deviceImportTaskCompleted, - constants.AuditActionAssetPackageBatchOrderTaskCreated: assetPackageBatchOrderTaskCreated, - constants.AuditActionAssetPackageBatchOrderTaskCompleted: assetPackageBatchOrderTaskCompleted, - constants.AuditActionOrderPackageInvalidateTaskCreated: orderPackageInvalidateTaskCreated, - constants.AuditActionOrderPackageInvalidateTaskCompleted: orderPackageInvalidateTaskCompleted, - constants.AuditActionOrderPackageInvalidateItem: orderPackageInvalidateItem, - constants.AuditActionExportTaskCreated: exportTaskCreated, - constants.AuditActionExportTaskCancelled: exportTaskCancelled, - constants.AuditActionNotificationDelivered: notificationDelivered, - constants.AuditActionNotificationRead: notificationRead, - constants.AuditActionNotificationReadAll: notificationReadAll, - constants.AuditActionNotificationCleanup: notificationCleanup, - constants.AuditActionNotificationCleanupItem: notificationCleanupItem, - constants.AuditActionLogRetentionCleanup: retentionCleanup, - constants.AuditActionPollingConfigCreated: pollingConfigCreated, - constants.AuditActionPollingConfigUpdated: pollingConfigUpdated, - constants.AuditActionPollingConfigDeleted: pollingConfigDeleted, - constants.AuditActionPollingConfigStatusUpdated: pollingConfigStatusUpdated, - constants.AuditActionPollingConcurrencyUpdated: pollingConcurrencyUpdated, - constants.AuditActionPollingConcurrencyReset: pollingConcurrencyReset, - constants.AuditActionPollingAlertRuleCreated: pollingAlertRuleCreated, - constants.AuditActionPollingAlertRuleUpdated: pollingAlertRuleUpdated, - constants.AuditActionPollingAlertRuleDeleted: pollingAlertRuleDeleted, - constants.AuditActionPollingManualTriggerSingle: pollingManualTriggerSingle, - constants.AuditActionPollingManualTriggerBatch: pollingManualTriggerBatch, - constants.AuditActionPollingManualTriggerByCondition: pollingManualTriggerByCondition, - constants.AuditActionPollingManualCancelled: pollingManualCancelled, - constants.AuditActionWeComCredentialsRead: wecomCredentialsRead, - constants.AuditActionRoleCreated: roleCreated, - constants.AuditActionRoleUpdated: roleUpdated, - constants.AuditActionRoleStatusUpdated: roleStatusUpdated, - constants.AuditActionRoleDefaultCreditUpdated: roleDefaultCreditUpdated, - constants.AuditActionRoleDeleted: roleDeleted, - constants.AuditActionRolePermissionsAssigned: rolePermissionsAssigned, - constants.AuditActionRolePermissionRemoved: rolePermissionRemoved, - constants.AuditActionRolePermissionsBatchRemoved: rolePermissionsBatchRemoved, - constants.AuditActionPermissionCreated: permissionCreated, - constants.AuditActionPermissionUpdated: permissionUpdated, - constants.AuditActionPermissionDeleted: permissionDeleted, - constants.AuditActionPackageSeriesCreated: packageSeriesCreated, - constants.AuditActionPackageSeriesUpdated: packageSeriesUpdated, - constants.AuditActionPackageSeriesDeleted: packageSeriesDeleted, - constants.AuditActionPackageSeriesStatusUpdated: packageSeriesStatusUpdated, - constants.AuditActionPackageCreated: packageCreated, - constants.AuditActionPackageUpdated: packageUpdated, - constants.AuditActionPackageDeleted: packageDeleted, - constants.AuditActionPackageStatusUpdated: packageStatusUpdated, - constants.AuditActionPackageShelfStatusUpdated: packageShelfStatusUpdated, - constants.AuditActionShopPackageShelfStatusUpdated: shopPackageShelfStatusUpdated, - constants.AuditActionPackageRetailPriceUpdated: packageRetailPriceUpdated, - constants.AuditActionShopSeriesGrantCreated: shopSeriesGrantCreated, - constants.AuditActionShopSeriesGrantUpdated: shopSeriesGrantUpdated, - constants.AuditActionShopSeriesGrantPackagesManaged: shopSeriesGrantPackagesManaged, - constants.AuditActionShopSeriesGrantDeleted: shopSeriesGrantDeleted, - constants.AuditActionShopPackageBatchAllocated: shopPackageBatchAllocated, - constants.AuditActionShopPackageAllocated: shopPackageAllocated, - constants.AuditActionShopPackageExpiryBaseUpdated: shopPackageExpiryBaseUpdated, - constants.AuditActionShopPackageBatchPricingUpdated: shopPackageBatchPricingUpdated, - constants.AuditActionShopPackagePricingItemUpdated: shopPackagePricingItemUpdated, - constants.AuditActionPackageUsageActivated: packageUsageActivated, - constants.AuditActionPackageUsageExpired: packageUsageExpired, - constants.AuditActionPackageUsageTrafficDeducted: packageUsageTrafficDeducted, - constants.AuditActionPackageUsageTrafficReset: packageUsageTrafficReset, - constants.AuditActionPackageUsageRefundInvalidated: packageUsageRefundInvalidated, - constants.AuditActionPackageUsageAssetInvalidated: packageUsageAssetInvalidated, - constants.AuditActionPackageUsageExpiresAtUpdated: packageUsageExpiresAtUpdated, - constants.AuditActionPackageUsageTrafficAdjusted: packageUsageTrafficAdjusted, - constants.AuditActionOrderCreated: orderCreated, - constants.AuditActionOrderCancelled: orderCancelled, - constants.AuditActionOrderWalletPaid: orderWalletPaid, - constants.AuditActionOrderExpiredClosed: orderExpiredClosed, - constants.AuditActionOrderOnlinePaid: orderOnlinePaid, - constants.AuditActionAgentWalletOrderDebited: agentWalletOrderDebited, - constants.AuditActionAgentWalletOrderReserved: agentWalletOrderReserved, - constants.AuditActionAgentWalletOrderReleased: agentWalletOrderReleased, - constants.AuditActionAgentWalletOrderCompleted: agentWalletOrderCompleted, - constants.AuditActionAgentWalletBalanceAdjusted: agentWalletBalanceAdjusted, - constants.AuditActionAgentWalletCreditChanged: agentWalletCreditChanged, - constants.AuditActionPaymentCreated: paymentCreated, - constants.AuditActionPaymentConfirmed: paymentConfirmed, - constants.AuditActionPaymentFailed: paymentFailed, - constants.AuditActionIntegrationAttemptStarted: integrationAttemptStarted, - constants.AuditActionIntegrationInboundReceived: integrationInboundReceived, - constants.AuditActionAgentRechargeCreated: agentRechargeCreated, - constants.AuditActionAgentRechargeCredited: agentRechargeCredited, - constants.AuditActionAgentRechargeClosed: agentRechargeClosed, - constants.AuditActionAssetRechargeAutoPurchased: assetRechargeAutoPurchased, - constants.AuditActionRefundCreated: refundCreated, - constants.AuditActionRefundApproved: refundApproved, - constants.AuditActionRefundRejected: refundRejected, - constants.AuditActionRefundReturned: refundReturned, - constants.AuditActionRefundResubmitted: refundResubmitted, - constants.AuditActionRefundCommissionInvalidated: refundCommissionInvalidated, - constants.AuditActionRefundAssetProcessed: refundAssetProcessed, - constants.AuditActionApprovalRequested: approvalRequested, - constants.AuditActionApprovalSubmissionSynced: approvalSubmissionSynced, - constants.AuditActionApprovalSubmissionRecovered: approvalSubmissionRecovered, - constants.AuditActionApprovalDecisionSynced: approvalDecisionSynced, - constants.AuditActionCommissionCalculated: commissionCalculated, - constants.AuditActionCommissionCredited: commissionCredited, - constants.AuditActionCommissionInvalidated: commissionInvalidated, - constants.AuditActionCommissionWithdrawalRequested: withdrawalRequested, - constants.AuditActionCommissionWithdrawalApproved: withdrawalApproved, - constants.AuditActionCommissionWithdrawalRejected: withdrawalRejected, + constants.AuditActionAccountCreated: accountCreated, + constants.AuditActionAccountUpdated: accountUpdated, + constants.AuditActionAccountDeleted: accountDeleted, + constants.AuditActionAccountPasswordReset: accountPasswordReset, + constants.AuditActionAccountPasswordChanged: accountPasswordChanged, + constants.AuditActionAccountWeComBound: accountWeComBound, + constants.AuditActionAuthLogin: authLogin, + constants.AuditActionAuthLogout: authLogout, + constants.AuditActionAuthTokenRefreshed: authTokenRefreshed, + constants.AuditActionAccountRolesAssigned: accountRolesAssigned, + constants.AuditActionAccountRoleRemoved: accountRoleRemoved, + constants.AuditActionShopRolesAssigned: shopRolesAssigned, + constants.AuditActionShopRoleDeleted: shopRoleDeleted, + constants.AuditActionShopCreated: shopCreated, + constants.AuditActionShopUpdated: shopUpdated, + constants.AuditActionShopEnabled: shopEnabled, + constants.AuditActionShopDisabled: shopDisabled, + constants.AuditActionShopDeleted: shopDeleted, + constants.AuditActionShopBusinessOwnerUpdated: shopBusinessOwnerUpdated, + constants.AuditActionShopClientLoginLimitUpdated: shopClientLoginLimitUpdated, + constants.AuditActionEnterpriseCreated: enterpriseCreated, + constants.AuditActionEnterpriseUpdated: enterpriseUpdated, + constants.AuditActionEnterpriseStatusUpdated: enterpriseStatusUpdated, + constants.AuditActionEnterprisePasswordUpdated: enterprisePasswordUpdated, + constants.AuditActionEnterpriseCardsAllocated: enterpriseCardsAllocated, + constants.AuditActionEnterpriseCardsRecalled: enterpriseCardsRecalled, + constants.AuditActionEnterpriseCardRemarkUpdated: enterpriseCardRemarkUpdated, + constants.AuditActionEnterpriseDevicesAllocated: enterpriseDevicesAllocated, + constants.AuditActionEnterpriseDevicesRecalled: enterpriseDevicesRecalled, + constants.AuditActionPersonalCustomerProfileUpdated: personalProfileUpdated, + constants.AuditActionPersonalCustomerPhoneBound: personalPhoneBound, + constants.AuditActionPersonalCustomerPhoneChanged: personalPhoneChanged, + constants.AuditActionPersonalCustomerWechatIdentityUpdated: personalWechatIdentityUpdated, + constants.AuditActionPersonalCustomerAssetBound: personalAssetBound, + constants.AuditActionPersonalCustomerAssetUnbound: personalAssetUnbound, + constants.AuditActionPersonalCustomerAssetBindingMigrated: personalAssetBindingMigrated, + constants.AuditActionIotCardCreated: iotCardCreated, + constants.AuditActionIotCardDeleted: iotCardDeleted, + constants.AuditActionIotCardDeactivated: iotCardDeactivated, + constants.AuditActionIotCardPollingStatusUpdated: iotCardPollingStatusUpdated, + constants.AuditActionIotCardPollingStatusBatchUpdated: iotCardPollingStatusBatchUpdated, + constants.AuditActionIotCardBatchDeleted: iotCardBatchDeleted, + constants.AuditActionIotCardAllocationBatch: iotCardAllocationBatch, + constants.AuditActionIotCardAllocated: iotCardAllocated, + constants.AuditActionIotCardRecallBatch: iotCardRecallBatch, + constants.AuditActionIotCardRecalled: iotCardRecalled, + constants.AuditActionIotCardSeriesBindingBatch: iotCardSeriesBindingBatch, + constants.AuditActionIotCardSeriesBound: iotCardSeriesBound, + constants.AuditActionIotCardSpeedTierSet: iotCardSpeedTierSet, + constants.AuditActionIotCardRealnamePolicyBatchUpdated: iotCardRealnamePolicyBatchUpdated, + constants.AuditActionIotCardRealnamePolicyUpdated: iotCardRealnamePolicyUpdated, + constants.AuditActionIotCardRealnameStatusUpdated: iotCardRealnameStatusUpdated, + constants.AuditActionIotCardRealnameCallbackSynced: iotCardRealnameCallbackSynced, + constants.AuditActionIotCardManualRefreshed: iotCardManualRefreshed, + constants.AuditActionIotCardPersonalRefreshed: iotCardPersonalRefreshed, + constants.AuditActionIotCardWorkerRealnameSynced: iotCardWorkerRealnameSynced, + constants.AuditActionIotCardWorkerTrafficSynced: iotCardWorkerTrafficSynced, + constants.AuditActionIotCardWorkerNetworkSynced: iotCardWorkerNetworkSynced, + constants.AuditActionIotCardManualStopped: iotCardManualStopped, + constants.AuditActionIotCardManualStarted: iotCardManualStarted, + constants.AuditActionIotCardAutoStopped: iotCardAutoStopped, + constants.AuditActionIotCardAutoStarted: iotCardAutoStarted, + constants.AuditActionIotCardOpenAPIStarted: iotCardOpenAPIStarted, + constants.AuditActionIotCardAutoStopReasonUpdated: iotCardAutoStopReasonUpdated, + constants.AuditActionDeviceCreated: deviceCreated, + constants.AuditActionDeviceDeleted: deviceDeleted, + constants.AuditActionDeviceDeactivated: deviceDeactivated, + constants.AuditActionDevicePollingStatusUpdated: devicePollingStatusUpdated, + constants.AuditActionDeviceAllocationBatch: deviceAllocationBatch, + constants.AuditActionDeviceAllocated: deviceAllocated, + constants.AuditActionDeviceRecallBatch: deviceRecallBatch, + constants.AuditActionDeviceRecalled: deviceRecalled, + constants.AuditActionDeviceSeriesBindingBatch: deviceSeriesBindingBatch, + constants.AuditActionDeviceSeriesBound: deviceSeriesBound, + constants.AuditActionDeviceRealnamePolicyBatchUpdated: deviceRealnamePolicyBatchUpdated, + constants.AuditActionDeviceRealnamePolicyUpdated: deviceRealnamePolicyUpdated, + constants.AuditActionDeviceStopped: deviceStopped, + constants.AuditActionDeviceStarted: deviceStarted, + constants.AuditActionDeviceWiFiSet: deviceWiFiSet, + constants.AuditActionDeviceSwitchModeSet: deviceSwitchModeSet, + constants.AuditActionDeviceRebooted: deviceRebooted, + constants.AuditActionDeviceReset: deviceReset, + constants.AuditActionDeviceCardBound: deviceCardBound, + constants.AuditActionDeviceCardUnbound: deviceCardUnbound, + constants.AuditActionDeviceCurrentCardSwitched: deviceCurrentCardSwitched, + constants.AuditActionDeviceWorkerObservationSynced: deviceWorkerObservationSynced, + constants.AuditActionCardExchangeCreated: cardExchangeCreated, + constants.AuditActionCardExchangeShippingInfoSubmitted: cardExchangeShippingInfoSubmitted, + constants.AuditActionCardExchangeShipped: cardExchangeShipped, + constants.AuditActionCardExchangeCompleted: cardExchangeCompleted, + constants.AuditActionCardExchangeCancelled: cardExchangeCancelled, + constants.AuditActionCardExchangeRenewed: cardExchangeRenewed, + constants.AuditActionDeviceExchangeCreated: deviceExchangeCreated, + constants.AuditActionDeviceExchangeShippingInfoSubmitted: deviceExchangeShippingInfoSubmitted, + constants.AuditActionDeviceExchangeShipped: deviceExchangeShipped, + constants.AuditActionDeviceExchangeCompleted: deviceExchangeCompleted, + constants.AuditActionDeviceExchangeCancelled: deviceExchangeCancelled, + constants.AuditActionDeviceExchangeRenewed: deviceExchangeRenewed, + constants.AuditActionSystemConfigUpdated: systemConfigUpdated, + constants.AuditActionPaymentConfigCreated: paymentConfigCreated, + constants.AuditActionPaymentConfigUpdated: paymentConfigUpdated, + constants.AuditActionPaymentConfigDeleted: paymentConfigDeleted, + constants.AuditActionPaymentConfigActivated: paymentConfigActivated, + constants.AuditActionPaymentConfigDeactivated: paymentConfigDeactivated, + constants.AuditActionCarrierCreated: carrierCreated, + constants.AuditActionCarrierUpdated: carrierUpdated, + constants.AuditActionCarrierDeleted: carrierDeleted, + constants.AuditActionCarrierStatusUpdated: carrierStatusUpdated, + constants.AuditActionEmployeeCollectionPaymentMethodCreated: employeeCollectionPaymentMethodCreated, + constants.AuditActionEmployeeCollectionPaymentMethodUpdated: employeeCollectionPaymentMethodUpdated, + constants.AuditActionEmployeeCollectionPaymentMethodDeleted: employeeCollectionPaymentMethodDeleted, + constants.AuditActionEmployeeCollectionBillCreated: employeeCollectionBillCreated, + constants.AuditActionEmployeeCollectionBillClosed: employeeCollectionBillClosed, + constants.AuditActionEmployeeCollectionApplicationSubmitted: employeeCollectionApplicationSubmitted, + constants.AuditActionEmployeeCollectionApplicationApproved: employeeCollectionApplicationApproved, + constants.AuditActionEmployeeCollectionApplicationRejected: employeeCollectionApplicationRejected, + constants.AuditActionEmployeeCollectionApplicationRevoked: employeeCollectionApplicationRevoked, + constants.AuditActionEmployeeCollectionBillRefundOffseted: employeeCollectionBillRefundOffseted, + constants.AuditActionWeComApplicationSaved: wecomApplicationSaved, + constants.AuditActionWeComDefaultCreatorSaved: wecomDefaultCreatorSaved, + constants.AuditActionWeComMembersSynced: wecomMembersSynced, + constants.AuditActionWeComApprovalSceneSaved: wecomApprovalSceneSaved, + constants.AuditActionOutboxReplayed: outboxReplayed, + constants.AuditActionOutboxExpiredLeaseReleased: outboxExpiredLeaseReleased, + constants.AuditActionIotCardImportTaskCreated: iotCardImportTaskCreated, + constants.AuditActionIotCardImportTaskCompleted: iotCardImportTaskCompleted, + constants.AuditActionDeviceImportTaskCreated: deviceImportTaskCreated, + constants.AuditActionDeviceImportTaskCompleted: deviceImportTaskCompleted, + constants.AuditActionAssetPackageBatchOrderTaskCreated: assetPackageBatchOrderTaskCreated, + constants.AuditActionAssetPackageBatchOrderTaskCompleted: assetPackageBatchOrderTaskCompleted, + constants.AuditActionOrderPackageInvalidateTaskCreated: orderPackageInvalidateTaskCreated, + constants.AuditActionOrderPackageInvalidateTaskCompleted: orderPackageInvalidateTaskCompleted, + constants.AuditActionOrderPackageInvalidateItem: orderPackageInvalidateItem, + constants.AuditActionExportTaskCreated: exportTaskCreated, + constants.AuditActionExportTaskCancelled: exportTaskCancelled, + constants.AuditActionNotificationDelivered: notificationDelivered, + constants.AuditActionNotificationRead: notificationRead, + constants.AuditActionNotificationReadAll: notificationReadAll, + constants.AuditActionNotificationCleanup: notificationCleanup, + constants.AuditActionNotificationCleanupItem: notificationCleanupItem, + constants.AuditActionLogRetentionCleanup: retentionCleanup, + constants.AuditActionPollingConfigCreated: pollingConfigCreated, + constants.AuditActionPollingConfigUpdated: pollingConfigUpdated, + constants.AuditActionPollingConfigDeleted: pollingConfigDeleted, + constants.AuditActionPollingConfigStatusUpdated: pollingConfigStatusUpdated, + constants.AuditActionPollingConcurrencyUpdated: pollingConcurrencyUpdated, + constants.AuditActionPollingConcurrencyReset: pollingConcurrencyReset, + constants.AuditActionPollingAlertRuleCreated: pollingAlertRuleCreated, + constants.AuditActionPollingAlertRuleUpdated: pollingAlertRuleUpdated, + constants.AuditActionPollingAlertRuleDeleted: pollingAlertRuleDeleted, + constants.AuditActionPollingManualTriggerSingle: pollingManualTriggerSingle, + constants.AuditActionPollingManualTriggerBatch: pollingManualTriggerBatch, + constants.AuditActionPollingManualTriggerByCondition: pollingManualTriggerByCondition, + constants.AuditActionPollingManualCancelled: pollingManualCancelled, + constants.AuditActionWeComCredentialsRead: wecomCredentialsRead, + constants.AuditActionRoleCreated: roleCreated, + constants.AuditActionRoleUpdated: roleUpdated, + constants.AuditActionRoleStatusUpdated: roleStatusUpdated, + constants.AuditActionRoleDefaultCreditUpdated: roleDefaultCreditUpdated, + constants.AuditActionRoleDeleted: roleDeleted, + constants.AuditActionRolePermissionsAssigned: rolePermissionsAssigned, + constants.AuditActionRolePermissionRemoved: rolePermissionRemoved, + constants.AuditActionRolePermissionsBatchRemoved: rolePermissionsBatchRemoved, + constants.AuditActionPermissionCreated: permissionCreated, + constants.AuditActionPermissionUpdated: permissionUpdated, + constants.AuditActionPermissionDeleted: permissionDeleted, + constants.AuditActionPackageSeriesCreated: packageSeriesCreated, + constants.AuditActionPackageSeriesUpdated: packageSeriesUpdated, + constants.AuditActionPackageSeriesDeleted: packageSeriesDeleted, + constants.AuditActionPackageSeriesStatusUpdated: packageSeriesStatusUpdated, + constants.AuditActionPackageCreated: packageCreated, + constants.AuditActionPackageUpdated: packageUpdated, + constants.AuditActionPackageDeleted: packageDeleted, + constants.AuditActionPackageStatusUpdated: packageStatusUpdated, + constants.AuditActionPackageShelfStatusUpdated: packageShelfStatusUpdated, + constants.AuditActionShopPackageShelfStatusUpdated: shopPackageShelfStatusUpdated, + constants.AuditActionPackageRetailPriceUpdated: packageRetailPriceUpdated, + constants.AuditActionShopSeriesGrantCreated: shopSeriesGrantCreated, + constants.AuditActionShopSeriesGrantUpdated: shopSeriesGrantUpdated, + constants.AuditActionShopSeriesGrantPackagesManaged: shopSeriesGrantPackagesManaged, + constants.AuditActionShopSeriesGrantDeleted: shopSeriesGrantDeleted, + constants.AuditActionShopPackageBatchAllocated: shopPackageBatchAllocated, + constants.AuditActionShopPackageAllocated: shopPackageAllocated, + constants.AuditActionShopPackageExpiryBaseUpdated: shopPackageExpiryBaseUpdated, + constants.AuditActionShopPackageBatchPricingUpdated: shopPackageBatchPricingUpdated, + constants.AuditActionShopPackagePricingItemUpdated: shopPackagePricingItemUpdated, + constants.AuditActionPackageUsageActivated: packageUsageActivated, + constants.AuditActionPackageUsageExpired: packageUsageExpired, + constants.AuditActionPackageUsageTrafficDeducted: packageUsageTrafficDeducted, + constants.AuditActionPackageUsageTrafficReset: packageUsageTrafficReset, + constants.AuditActionPackageUsageRefundInvalidated: packageUsageRefundInvalidated, + constants.AuditActionPackageUsageAssetInvalidated: packageUsageAssetInvalidated, + constants.AuditActionPackageUsageExpiresAtUpdated: packageUsageExpiresAtUpdated, + constants.AuditActionPackageUsageTrafficAdjusted: packageUsageTrafficAdjusted, + constants.AuditActionOrderCreated: orderCreated, + constants.AuditActionOrderCancelled: orderCancelled, + constants.AuditActionOrderWalletPaid: orderWalletPaid, + constants.AuditActionOrderExpiredClosed: orderExpiredClosed, + constants.AuditActionOrderOnlinePaid: orderOnlinePaid, + constants.AuditActionAgentWalletOrderDebited: agentWalletOrderDebited, + constants.AuditActionAgentWalletOrderReserved: agentWalletOrderReserved, + constants.AuditActionAgentWalletOrderReleased: agentWalletOrderReleased, + constants.AuditActionAgentWalletOrderCompleted: agentWalletOrderCompleted, + constants.AuditActionAgentWalletBalanceAdjusted: agentWalletBalanceAdjusted, + constants.AuditActionAgentWalletCreditChanged: agentWalletCreditChanged, + constants.AuditActionPaymentCreated: paymentCreated, + constants.AuditActionPaymentConfirmed: paymentConfirmed, + constants.AuditActionPaymentFailed: paymentFailed, + constants.AuditActionIntegrationAttemptStarted: integrationAttemptStarted, + constants.AuditActionIntegrationInboundReceived: integrationInboundReceived, + constants.AuditActionAgentRechargeCreated: agentRechargeCreated, + constants.AuditActionAgentRechargeCredited: agentRechargeCredited, + constants.AuditActionAgentRechargeClosed: agentRechargeClosed, + constants.AuditActionAssetRechargeAutoPurchased: assetRechargeAutoPurchased, + constants.AuditActionRefundCreated: refundCreated, + constants.AuditActionRefundApproved: refundApproved, + constants.AuditActionRefundRejected: refundRejected, + constants.AuditActionRefundReturned: refundReturned, + constants.AuditActionRefundResubmitted: refundResubmitted, + constants.AuditActionRefundCommissionInvalidated: refundCommissionInvalidated, + constants.AuditActionRefundAssetProcessed: refundAssetProcessed, + constants.AuditActionApprovalRequested: approvalRequested, + constants.AuditActionApprovalSubmissionSynced: approvalSubmissionSynced, + constants.AuditActionApprovalSubmissionRecovered: approvalSubmissionRecovered, + constants.AuditActionApprovalDecisionSynced: approvalDecisionSynced, + constants.AuditActionCommissionCalculated: commissionCalculated, + constants.AuditActionCommissionCredited: commissionCredited, + constants.AuditActionCommissionInvalidated: commissionInvalidated, + constants.AuditActionCommissionWithdrawalRequested: withdrawalRequested, + constants.AuditActionCommissionWithdrawalApproved: withdrawalApproved, + constants.AuditActionCommissionWithdrawalRejected: withdrawalRejected, }, resources: map[string]ResourceDefinition{ constants.AuditResourceAccount: { @@ -614,6 +637,25 @@ func NewRegistry() *Registry { Type: constants.AuditResourceCarrier, Name: "运营商配置", IdentityFields: []string{"id", "carrier_code", "carrier_name", "carrier_type", "status"}, }, + constants.AuditResourceEmployeeCollectionPaymentMethod: { + Type: constants.AuditResourceEmployeeCollectionPaymentMethod, Name: "线下收款方式", + IdentityFields: []string{"id", "code", "name", "status", "sort"}, + }, + constants.AuditResourceEmployeeCollectionBill: { + Type: constants.AuditResourceEmployeeCollectionBill, Name: "员工代收款账单", + IdentityFields: []string{"id", "source_type", "source_id", "source_key", "debtor_account_id", "status"}, + }, + constants.AuditResourceEmployeeCollectionApplication: { + Type: constants.AuditResourceEmployeeCollectionApplication, Name: "员工代收款核销申请", + IdentityFields: []string{ + "id", "applicant_account_id", "acting_operator_id", "payment_method_id", + "paid_amount", "status", "latest_approval_instance_id", + }, + }, + constants.AuditResourceEmployeeCollectionAttempt: { + Type: constants.AuditResourceEmployeeCollectionAttempt, Name: "员工代收款核销审批尝试记录", + IdentityFields: []string{"id", "application_id", "attempt_no", "paid_amount", "approval_instance_id"}, + }, constants.AuditResourceWeComApprovalScene: { Type: constants.AuditResourceWeComApprovalScene, Name: "企业微信审批场景配置", IdentityFields: []string{"id", "business_type", "application_id", "template_id", "template_name", "status"}, @@ -1227,6 +1269,23 @@ func connectionConfigAction(code, name, resourceType, risk string) ActionDefinit } } +// employeeCollectionAction 定义员工代收款账单与核销申请动作;建账发生在来源成功事务内, +// 订单来源由后台账号触发,充值入账与审批终态由 Outbox 消费、渠道回调或恢复任务触发。 +func employeeCollectionAction(code, name, primaryResource string) ActionDefinition { + return ActionDefinition{ + Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh, + PrimaryResource: primaryResource, RequireTransaction: true, + DefaultVisibility: constants.AuditSubjectResult, + AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult}, + AllowedOrigins: []ActionOrigin{ + {Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI}, + {Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker}, + {Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback}, + {Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler}, + }, + } +} + // Action 返回已注册动作定义。 func (r *Registry) Action(code string) (ActionDefinition, bool) { if r == nil { diff --git a/internal/model/dto/employee_collection_dto.go b/internal/model/dto/employee_collection_dto.go new file mode 100644 index 0000000..6945d23 --- /dev/null +++ b/internal/model/dto/employee_collection_dto.go @@ -0,0 +1,305 @@ +package dto + +import "time" + +// CreateEmployeeCollectionPaymentMethodRequest 创建线下收款方式请求 +// 枚举说明:enabled 对应 constants.EmployeeCollectionPaymentMethodStatusEnabled/Disabled(1 启用、0 停用)。 +type CreateEmployeeCollectionPaymentMethodRequest struct { + Code string `json:"code" validate:"required,min=1,max=64" required:"true" minLength:"1" maxLength:"64" description:"收款方式稳定编码,1-64 字符,未删除记录中唯一,被核销申请引用后不可修改"` + Name string `json:"name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"收款方式名称,1-100 字符"` + Sort *int64 `json:"sort" validate:"omitempty,min=0" minimum:"0" description:"排序值,非负整数,默认 0"` + Enabled *bool `json:"enabled" description:"是否启用,默认启用;停用后新核销申请不可选择该方式"` + Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注,最多 500 字符"` +} + +// UpdateEmployeeCollectionPaymentMethodRequest 更新线下收款方式请求 +// 全部字段可选:仅传入的字段被修改;已启用即被核销申请引用的记录不允许修改稳定编码。 +type UpdateEmployeeCollectionPaymentMethodRequest struct { + Code *string `json:"code" validate:"omitempty,min=1,max=64" minLength:"1" maxLength:"64" description:"收款方式稳定编码,仅未被核销申请引用时可修改"` + Name *string `json:"name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"收款方式名称"` + Sort *int64 `json:"sort" validate:"omitempty,min=0" minimum:"0" description:"排序值,非负整数"` + Enabled *bool `json:"enabled" description:"是否启用;停用后新核销申请不可选择该方式,历史申请仍展示冻结名称"` + Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注,最多 500 字符"` +} + +// EmployeeCollectionPaymentMethodListRequest 查询线下收款方式列表请求 +type EmployeeCollectionPaymentMethodListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"` + Enabled *bool `json:"enabled" query:"enabled" description:"按启用状态过滤;超级管理员可查询全部,其他后台账号仅返回启用项"` + Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"按稳定编码或名称模糊搜索,最多 100 字符"` +} + +// EmployeeCollectionPaymentMethodResponse 线下收款方式响应 +// 对外契约字段固定为 id/code/name/enabled,其余字段为维护与展示补充。 +type EmployeeCollectionPaymentMethodResponse struct { + ID uint `json:"id" description:"收款方式ID"` + Code string `json:"code" description:"收款方式稳定编码"` + Name string `json:"name" description:"收款方式名称"` + Enabled bool `json:"enabled" description:"是否启用;停用后新核销申请不可选择该方式"` + Sort int64 `json:"sort" description:"排序值"` + Remark string `json:"remark" description:"备注"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` +} + +// EmployeeCollectionPaymentMethodListResponse 线下收款方式列表响应 +type EmployeeCollectionPaymentMethodListResponse struct { + List []*EmployeeCollectionPaymentMethodResponse `json:"items" description:"收款方式列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"size" description:"每页条数"` +} + +// EmployeeCollectionBillListRequest 查询员工代收款账单列表请求。 +// 枚举说明:source_type 对应 constants.EmployeeCollectionSourceTypeOrder/Recharge, +// status 对应 constants.EmployeeCollectionBillStatusPending/Partial/Settled/Closed。 +type EmployeeCollectionBillListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"` + SourceType *string `json:"source_type" query:"source_type" validate:"omitempty,oneof=order recharge" enum:"order,recharge" description:"按来源类型过滤 (order:后台线下套餐订单, recharge:代理线下充值)"` + SourceNo *string `json:"source_no" query:"source_no" validate:"omitempty,max=64" maxLength:"64" description:"按来源单号精确过滤"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按账单状态过滤 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"` + DebtorAccountID *uint `json:"debtor_account_id" query:"debtor_account_id" description:"按欠款人后台账号ID过滤;非超级管理员固定为当前账号,该参数被忽略"` + CustomerID *uint `json:"customer_id" query:"customer_id" description:"按来源客户或店铺ID过滤,匹配来源订单买家或充值归属店铺"` + CreatedFrom *string `json:"created_from" query:"created_from" description:"创建时间起始(YYYY-MM-DD,含当日 00:00:00)"` + CreatedTo *string `json:"created_to" query:"created_to" description:"创建时间截止(YYYY-MM-DD,含当日 23:59:59)"` +} + +// EmployeeCollectionBillStatisticsRequest 查询员工代收款账单统计请求。 +// 与账单列表使用同一批筛选字段与可见性范围,但不接受分页参数。 +type EmployeeCollectionBillStatisticsRequest struct { + SourceType *string `json:"source_type" query:"source_type" validate:"omitempty,oneof=order recharge" enum:"order,recharge" description:"按来源类型过滤 (order:后台线下套餐订单, recharge:代理线下充值)"` + SourceNo *string `json:"source_no" query:"source_no" validate:"omitempty,max=64" maxLength:"64" description:"按来源单号精确过滤"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按账单状态过滤 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"` + DebtorAccountID *uint `json:"debtor_account_id" query:"debtor_account_id" description:"按欠款人后台账号ID过滤;非超级管理员固定为当前账号,该参数被忽略"` + CustomerID *uint `json:"customer_id" query:"customer_id" description:"按来源客户或店铺ID过滤,匹配来源订单买家或充值归属店铺"` + CreatedFrom *string `json:"created_from" query:"created_from" description:"创建时间起始(YYYY-MM-DD,含当日 00:00:00)"` + CreatedTo *string `json:"created_to" query:"created_to" description:"创建时间截止(YYYY-MM-DD,含当日 23:59:59)"` +} + +// EmployeeCollectionBillResponse 员工代收款账单行响应。 +type EmployeeCollectionBillResponse struct { + ID uint `json:"id" description:"账单ID"` + SourceType string `json:"source_type" description:"来源类型 (order:后台线下套餐订单, recharge:代理线下充值)"` + SourceTypeName string `json:"source_type_name" description:"来源类型中文名称"` + SourceID uint `json:"source_id" description:"来源业务主键ID"` + SourceNo string `json:"source_no" description:"来源单号"` + DebtorAccountID uint `json:"debtor_account_id" description:"欠款人后台账号ID"` + DebtorSnapshot map[string]any `json:"debtor_snapshot" description:"欠款人账号只读快照(账号ID、名称、类型)"` + CustomerSnapshot map[string]any `json:"customer_snapshot" description:"来源客户或店铺只读快照,不含付款凭证内容"` + ReceivableAmount int64 `json:"receivable_amount" description:"应收金额(分)"` + ReceivedAmount int64 `json:"received_amount" description:"已核销金额(分),仅企业微信最终通过的分摊计入"` + ReservedAmount int64 `json:"reserved_amount" description:"审批中预占金额(分)"` + RemainingAmount int64 `json:"remaining_amount" description:"剩余可核销金额(分),已关闭账单为 0"` + Status int `json:"status" description:"账单状态 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"` + StatusName string `json:"status_name" description:"账单状态中文名称"` + ApprovalPending bool `json:"approval_pending" description:"是否存在审批中分摊(预占大于零)"` + ClosedReason string `json:"closed_reason" description:"关闭原因,仅已关闭账单有值"` + ClosedAt *time.Time `json:"closed_at,omitempty" description:"关闭时间"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"最近更新时间"` +} + +// EmployeeCollectionBillListResponse 员工代收款账单列表响应。 +type EmployeeCollectionBillListResponse struct { + List []*EmployeeCollectionBillResponse `json:"items" description:"账单列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"size" description:"每页条数"` +} + +// EmployeeCollectionBillStatisticsResponse 员工代收款账单统计响应。 +// 与账单列表使用同一筛选条件与可见性范围。 +type EmployeeCollectionBillStatisticsResponse struct { + ReceivableTotal int64 `json:"receivable_total" description:"应收金额合计(分)"` + ReceivedTotal int64 `json:"received_total" description:"已核销金额合计(分)"` + UnsettledTotal int64 `json:"unsettled_total" description:"未核销金额合计(分),已关闭账单未核销余额按 0 计入"` + PendingBillCount int64 `json:"pending_bill_count" description:"待处理账单数,即待核销或部分核销账单数量"` +} + +// EmployeeCollectionBillRefundResponse 账单退款冲销关联响应。 +type EmployeeCollectionBillRefundResponse struct { + ID uint `json:"id" description:"冲销关联ID"` + RefundID uint `json:"refund_id" description:"退款申请ID"` + SourceOrderID uint `json:"source_order_id" description:"账单来源订单ID"` + RefundAmount int64 `json:"refund_amount" description:"本次退款成功金额(分)"` + BillReceivableAmount int64 `json:"bill_receivable_amount" description:"冲销前账单应收金额快照(分)"` + Outcome string `json:"outcome" description:"处理结果 (closed_full:来源订单全额退款关闭, reduced:按退款金额冲减应收, hint_only:仅记录退款关联提示)"` + OutcomeName string `json:"outcome_name" description:"处理结果中文名称"` + ReducedAmount int64 `json:"reduced_amount" description:"实际冲减应收金额(分)"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` +} + +// EmployeeCollectionBillAllocationResponse 账单分摊响应。 +type EmployeeCollectionBillAllocationResponse struct { + ID uint `json:"id" description:"分摊ID"` + ApplicationID uint `json:"application_id" description:"核销申请ID"` + ApplicationStatus int `json:"application_status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"` + ApplicationStatusName string `json:"application_status_name" description:"申请状态中文名称"` + AttemptID uint `json:"attempt_id" description:"所属审批尝试记录ID"` + Amount int64 `json:"amount" description:"本次分摊金额(分)"` + Status int `json:"status" description:"分摊状态 (0:审批中预占, 1:已通过, 2:已驳回或已释放)"` + StatusName string `json:"status_name" description:"分摊状态中文名称"` + ReleasedAt *time.Time `json:"released_at,omitempty" description:"预占释放时间,审批中为空"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` +} + +// EmployeeCollectionBillAttemptResponse 申请审批尝试记录响应,材料为本次提交的冻结快照。 +type EmployeeCollectionBillAttemptResponse struct { + ID uint `json:"id" description:"审批尝试记录ID,同时是通用审批业务ID"` + AttemptNo int `json:"attempt_no" description:"第几次提交,从 1 递增"` + PaymentMethodID uint `json:"payment_method_id" description:"本次冻结的收款方式字典ID"` + PaymentMethodCode string `json:"payment_method_code" description:"本次冻结的收款方式稳定编码"` + PaymentMethodName string `json:"payment_method_name" description:"本次冻结的收款方式名称"` + PaidAmount int64 `json:"paid_amount" description:"本次冻结的付款金额(分)"` + PayerName string `json:"payer_name" description:"付款方名称"` + PaidAt time.Time `json:"paid_at" description:"付款时间"` + ExternalTransactionNo string `json:"external_transaction_no" description:"人工确认的外部交易流水号,申请人可原样核对与重用"` + PaymentVoucherKeys []string `json:"payment_voucher_keys" description:"支付凭证对象存储Key列表,仅返回对象键引用"` + Remark string `json:"remark" description:"本次提交备注"` + SubmittedByAccountID uint `json:"submitted_by_account_id" description:"本次实际提交账号ID,代办时为超级管理员"` + ActingReason string `json:"acting_reason" description:"本次代办原因,非代办为空"` + AllocationSnapshot []map[string]any `json:"allocation_snapshot" description:"本次冻结的账单分摊快照"` + ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"本次尝试关联的通用审批实例ID"` + ApprovalStatus *int `json:"approval_status,omitempty" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"` + ApprovalStatusName string `json:"approval_status_name" description:"通用审批实例状态中文名称"` + ApprovalOpinion string `json:"approval_opinion" description:"渠道审批意见文本,取自通用审批实例终态决策快照;无意见时为空"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` +} + +// EmployeeCollectionBillApplicationResponse 账单关联的核销申请响应,含审批历史。 +type EmployeeCollectionBillApplicationResponse struct { + ID uint `json:"id" description:"核销申请ID"` + ApplicantAccountID uint `json:"applicant_account_id" description:"申请人后台账号ID"` + ActingOperatorID uint `json:"acting_operator_id" description:"实际代办的超级管理员账号ID,0 表示本人办理"` + ActingReason string `json:"acting_reason" description:"代办原因,非代办为空"` + PaymentMethodID uint `json:"payment_method_id" description:"线下收款方式字典ID"` + PaymentMethodCode string `json:"payment_method_code" description:"收款方式稳定编码快照"` + PaymentMethodName string `json:"payment_method_name" description:"收款方式名称快照"` + PaidAmount int64 `json:"paid_amount" description:"人工确认的付款金额(分)"` + PayerName string `json:"payer_name" description:"付款方名称"` + PaidAt time.Time `json:"paid_at" description:"付款时间"` + ExternalTransactionNo string `json:"external_transaction_no" description:"外部交易流水号"` + PaymentVoucherKeys []string `json:"payment_voucher_keys" description:"支付凭证对象存储Key列表,仅返回对象键引用"` + Remark string `json:"remark" description:"申请备注"` + Status int `json:"status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"` + StatusName string `json:"status_name" description:"申请状态中文名称"` + LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID,仅用于展示"` + DecidedAt *time.Time `json:"decided_at,omitempty" description:"审批终态到达时间"` + TerminalReason string `json:"terminal_reason" description:"异常终态说明,如企业微信通过后撤销"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + Attempts []*EmployeeCollectionBillAttemptResponse `json:"attempts" description:"该申请的审批尝试记录,按提交顺序排列"` +} + +// EmployeeCollectionBillDetailResponse 员工代收款账单详情响应。 +type EmployeeCollectionBillDetailResponse struct { + Bill *EmployeeCollectionBillResponse `json:"bill" description:"账单事实"` + Refunds []*EmployeeCollectionBillRefundResponse `json:"refunds" description:"来源订单退款冲销关联"` + Allocations []*EmployeeCollectionBillAllocationResponse `json:"allocations" description:"该账单的核销分摊"` + Applications []*EmployeeCollectionBillApplicationResponse `json:"applications" description:"涉及该账单的核销申请与审批历史"` +} + +// CloseEmployeeCollectionBillRequest 关闭员工代收款账单请求 +type CloseEmployeeCollectionBillRequest struct { + Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"关闭原因,必填,最多 500 字符"` +} + +// EmployeeCollectionApplicationAllocationRequest 核销申请中的单张账单分摊。 +type EmployeeCollectionApplicationAllocationRequest struct { + BillID uint `json:"bill_id" validate:"required" required:"true" description:"目标员工代收款账单ID"` + Amount int64 `json:"amount" validate:"required,min=1" required:"true" minimum:"1" description:"本次分摊金额(分),必须大于零且不超过该账单可核销余额"` +} + +// SubmitEmployeeCollectionApplicationRequest 创建或重提核销申请请求。 +// 枚举说明:allocations 由申请人提交,服务端按账单可核销余额与付款金额严格校验。 +type SubmitEmployeeCollectionApplicationRequest struct { + PaymentMethodID uint `json:"payment_method_id" validate:"required" required:"true" description:"线下收款方式字典ID,必须是启用中的字典项"` + PaidAmount int64 `json:"paid_amount" validate:"required,min=1" required:"true" minimum:"1" description:"人工确认的付款金额(分),必须大于零"` + PayerName string `json:"payer_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"付款方名称,1-100 字符"` + PaidAt string `json:"paid_at" validate:"required" required:"true" description:"付款时间,带时区的 RFC3339 格式,如 2026-08-31T10:00:00+08:00"` + ExternalTransactionNo string `json:"external_transaction_no" validate:"required,min=1,max=128" required:"true" minLength:"1" maxLength:"128" description:"经人工确认的外部交易流水号,OCR 结果必须人工更正后提交"` + PaymentVoucherKeys []string `json:"payment_voucher_keys" validate:"required,min=1,max=5,dive,min=1,max=512" required:"true" minItems:"1" maxItems:"5" description:"支付凭证对象存储Key列表,1-5 个既有对象键引用"` + Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"申请备注,最多 500 字符"` + ActingReason string `json:"acting_reason" validate:"omitempty,max=500" maxLength:"500" description:"超级管理员代办原因,1-500 字符;代办时必填,本人办理不得填写"` + Allocations []EmployeeCollectionApplicationAllocationRequest `json:"allocations" validate:"required,min=1,max=50,dive" required:"true" minItems:"1" maxItems:"50" description:"账单分摊列表,至少一条;每张账单本次只能出现一次"` +} + +// EmployeeCollectionApplicationListRequest 查询核销申请列表请求。 +type EmployeeCollectionApplicationListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按申请状态过滤 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"` + ApplicantAccountID *uint `json:"applicant_account_id" query:"applicant_account_id" description:"按申请人账号ID过滤;非超级管理员固定为当前账号,该参数被忽略"` + PaymentMethodID *uint `json:"payment_method_id" query:"payment_method_id" description:"按线下收款方式字典ID过滤"` + CreatedFrom *string `json:"created_from" query:"created_from" description:"创建时间起始(YYYY-MM-DD,含当日 00:00:00)"` + CreatedTo *string `json:"created_to" query:"created_to" description:"创建时间截止(YYYY-MM-DD,含当日 23:59:59)"` +} + +// EmployeeCollectionApplicationResponse 核销申请行响应。 +type EmployeeCollectionApplicationResponse struct { + ID uint `json:"id" description:"核销申请ID"` + ApplicantAccountID uint `json:"applicant_account_id" description:"申请人后台账号ID"` + ActingOperatorID uint `json:"acting_operator_id" description:"实际代办的超级管理员账号ID,0 表示本人办理"` + ActingReason string `json:"acting_reason" description:"代办原因,非代办为空"` + PaymentMethodID uint `json:"payment_method_id" description:"线下收款方式字典ID"` + PaymentMethodCode string `json:"payment_method_code" description:"收款方式稳定编码快照"` + PaymentMethodName string `json:"payment_method_name" description:"收款方式名称快照,字典改名不影响历史申请"` + PaidAmount int64 `json:"paid_amount" description:"人工确认的付款金额(分)"` + PayerName string `json:"payer_name" description:"付款方名称"` + PaidAt time.Time `json:"paid_at" description:"付款时间"` + ExternalTransactionNo string `json:"external_transaction_no" description:"人工确认的外部交易流水号,申请人可原样核对与重用"` + PaymentVoucherKeys []string `json:"payment_voucher_keys" description:"支付凭证对象存储Key列表,仅返回对象键引用"` + Remark string `json:"remark" description:"申请备注"` + Status int `json:"status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"` + StatusName string `json:"status_name" description:"申请状态中文名称"` + LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID,仅用于展示"` + LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID,仅用于展示"` + DecidedAt *time.Time `json:"decided_at,omitempty" description:"审批终态到达时间"` + TerminalReason string `json:"terminal_reason" description:"异常终态说明,如企业微信通过后撤销"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"最近更新时间"` +} + +// EmployeeCollectionApplicationListResponse 核销申请列表响应。 +type EmployeeCollectionApplicationListResponse struct { + List []*EmployeeCollectionApplicationResponse `json:"items" description:"核销申请列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"size" description:"每页条数"` +} + +// EmployeeCollectionApplicationAllocationResponse 核销申请分摊响应,含目标账单只读摘要。 +type EmployeeCollectionApplicationAllocationResponse struct { + ID uint `json:"id" description:"分摊ID"` + BillID uint `json:"bill_id" description:"目标账单ID"` + Amount int64 `json:"amount" description:"本次分摊金额(分)"` + Status int `json:"status" description:"分摊状态 (0:审批中预占, 1:已通过, 2:已驳回或已释放)"` + StatusName string `json:"status_name" description:"分摊状态中文名称"` + ReleasedAt *time.Time `json:"released_at,omitempty" description:"预占释放时间,审批中为空"` + BillSourceType string `json:"bill_source_type" description:"账单来源类型 (order:后台线下套餐订单, recharge:代理线下充值)"` + BillSourceNo string `json:"bill_source_no" description:"账单来源单号"` + BillReceivableAmount int64 `json:"bill_receivable_amount" description:"账单应收金额(分)"` + BillReceivedAmount int64 `json:"bill_received_amount" description:"账单已核销金额(分)"` + BillReservedAmount int64 `json:"bill_reserved_amount" description:"账单审批中预占金额(分)"` + BillStatus int `json:"bill_status" description:"账单状态 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"` + BillStatusName string `json:"bill_status_name" description:"账单状态中文名称"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` +} + +// EmployeeCollectionApplicationSubmitResponse 创建或重提核销申请响应。 +type EmployeeCollectionApplicationSubmitResponse struct { + Application *EmployeeCollectionApplicationResponse `json:"application" description:"核销申请事实"` + Attempt *EmployeeCollectionBillAttemptResponse `json:"attempt" description:"本次新增的审批尝试记录与冻结快照"` + Allocations []*EmployeeCollectionApplicationAllocationResponse `json:"allocations" description:"本次账单分摊与预占结果"` + ApprovalInstanceID uint `json:"approval_instance_id" description:"本次创建的企业微信通用审批实例ID"` + ApprovalStatus int `json:"approval_status" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"` + ApprovalStatusName string `json:"approval_status_name" description:"通用审批实例状态中文名称"` +} + +// EmployeeCollectionApplicationDetailResponse 核销申请详情响应。 +type EmployeeCollectionApplicationDetailResponse struct { + Application *EmployeeCollectionApplicationResponse `json:"application" description:"核销申请事实与最新审批引用"` + Allocations []*EmployeeCollectionApplicationAllocationResponse `json:"allocations" description:"该申请当前的账单分摊"` + Attempts []*EmployeeCollectionBillAttemptResponse `json:"attempts" description:"全部审批尝试记录,按提交顺序排列,历史材料不被覆盖"` +} diff --git a/internal/model/dto/order_dto.go b/internal/model/dto/order_dto.go index 929f902..a2c07ac 100644 --- a/internal/model/dto/order_dto.go +++ b/internal/model/dto/order_dto.go @@ -15,7 +15,7 @@ type CreateAdminOrderRequest struct { Identifier string `json:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(卡支持 ICCID,设备支持 VirtualNo、IMEI 或 SN)"` PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"` PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"` - PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储file_key列表(payment_method=offline时至少1个,最多5个,通过/storage/upload-url上传图片后获得)"` + PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储file_key列表(最多5个,通过/storage/upload-url上传图片后获得);线下订单需付款凭证,但实际收款金额大于 0 且由平台账号操作的非赠送线下订单会生成员工代收款账单,该场景凭证由核销申请环节提供,创建订单时可为空"` } type OrderListRequest struct { diff --git a/internal/model/employee_collection.go b/internal/model/employee_collection.go new file mode 100644 index 0000000..20c618d --- /dev/null +++ b/internal/model/employee_collection.go @@ -0,0 +1,150 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// EmployeeCollectionPaymentMethod 是线下收款方式字典项。 +// 状态语义见 constants.EmployeeCollectionPaymentMethodStatus*;被业务引用后只可停用。 +type EmployeeCollectionPaymentMethod struct { + gorm.Model + BaseModel `gorm:"embedded"` + Code string `gorm:"column:code;type:varchar(64);not null;comment:稳定编码,未删除记录中唯一" json:"code"` + Name string `gorm:"column:name;type:varchar(100);not null;comment:收款方式名称" json:"name"` + SortOrder int64 `gorm:"column:sort_order;type:bigint;not null;default:0;comment:排序值,从 0 递增" json:"sort_order"` + Status int `gorm:"column:status;type:smallint;not null;default:1;comment:状态 0-禁用 1-启用" json:"status"` + Remark string `gorm:"column:remark;type:varchar(500);not null;default:'';comment:备注" json:"remark"` +} + +// TableName 指定线下收款方式字典表名。 +func (EmployeeCollectionPaymentMethod) TableName() string { + return "tb_employee_collection_payment_method" +} + +// EmployeeCollectionBill 是后台账号代客户经办业务形成的本地暂挂欠款。 +// 由来源成功事务按来源唯一键 SourceKey 幂等创建,不存在历史扫描或手工建账路径。 +type EmployeeCollectionBill struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + SourceType string `gorm:"column:source_type;type:varchar(20);not null;comment:来源类型 order/recharge" json:"source_type"` + SourceID uint `gorm:"column:source_id;not null;comment:来源业务主键ID" json:"source_id"` + SourceKey string `gorm:"column:source_key;type:varchar(64);not null;comment:来源唯一键 order:{id} / recharge:{id}" json:"source_key"` + SourceNo string `gorm:"column:source_no;type:varchar(64);not null;default:'';comment:来源单号快照" json:"source_no"` + DebtorAccountID uint `gorm:"column:debtor_account_id;not null;comment:欠款人后台账号ID" json:"debtor_account_id"` + DebtorSnapshot datatypes.JSON `gorm:"column:debtor_snapshot;type:jsonb;not null;comment:欠款人账号快照" json:"debtor_snapshot"` + CustomerSnapshot datatypes.JSON `gorm:"column:customer_snapshot;type:jsonb;not null;comment:来源客户或店铺只读快照" json:"customer_snapshot"` + ReceivableAmount int64 `gorm:"column:receivable_amount;type:bigint;not null;comment:应收金额(分)" json:"receivable_amount"` + ReceivedAmount int64 `gorm:"column:received_amount;type:bigint;not null;default:0;comment:已核销金额(分)" json:"received_amount"` + ReservedAmount int64 `gorm:"column:reserved_amount;type:bigint;not null;default:0;comment:审批中预占金额(分)" json:"reserved_amount"` + Status int `gorm:"column:status;type:smallint;not null;default:0;comment:状态 0-待核销 1-部分核销 2-已核销 3-已关闭" json:"status"` + ClosedReason string `gorm:"column:closed_reason;type:varchar(500);not null;default:'';comment:关闭原因" json:"closed_reason"` + ClosedAt *time.Time `gorm:"column:closed_at;type:timestamptz;comment:关闭时间" json:"closed_at,omitempty"` + Creator uint `gorm:"column:creator;not null;default:0;comment:创建人用户ID" json:"creator"` + Updater uint `gorm:"column:updater;not null;default:0;comment:最近更新人用户ID" json:"updater"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定员工代收款账单表名。 +func (EmployeeCollectionBill) TableName() string { + return "tb_employee_collection_bill" +} + +// EmployeeCollectionApplication 是一笔外部付款的核销申请,只保存最新审批实例ID用于展示。 +type EmployeeCollectionApplication struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + ApplicantAccountID uint `gorm:"column:applicant_account_id;not null;comment:申请人后台账号ID" json:"applicant_account_id"` + ActingOperatorID uint `gorm:"column:acting_operator_id;not null;default:0;comment:实际代办的超级管理员账号ID,0 表示本人办理" json:"acting_operator_id"` + ActingReason string `gorm:"column:acting_reason;type:varchar(500);not null;default:'';comment:代办原因" json:"acting_reason"` + PaymentMethodID uint `gorm:"column:payment_method_id;not null;comment:线下收款方式字典ID" json:"payment_method_id"` + PaymentMethodCode string `gorm:"column:payment_method_code;type:varchar(64);not null;comment:收款方式编码快照" json:"payment_method_code"` + PaymentMethodName string `gorm:"column:payment_method_name;type:varchar(100);not null;comment:收款方式名称快照" json:"payment_method_name"` + PaidAmount int64 `gorm:"column:paid_amount;type:bigint;not null;comment:人工确认付款金额(分)" json:"paid_amount"` + PayerName string `gorm:"column:payer_name;type:varchar(100);not null;default:'';comment:付款方名称" json:"payer_name"` + PaidAt time.Time `gorm:"column:paid_at;type:timestamptz;not null;comment:付款时间" json:"paid_at"` + ExternalTransactionNo string `gorm:"column:external_transaction_no;type:varchar(128);not null;default:'';comment:外部交易流水号" json:"external_transaction_no"` + PaymentVoucherKeys StringJSONBArray `gorm:"column:payment_voucher_keys;type:jsonb;not null;comment:支付凭证对象存储Key列表" json:"payment_voucher_keys"` + Remark string `gorm:"column:remark;type:varchar(500);not null;default:'';comment:申请备注" json:"remark"` + Status int `gorm:"column:status;type:smallint;not null;default:0;comment:状态 0-审批中 1-已通过 2-已驳回 3-已撤销或已关闭" json:"status"` + LatestAttemptID uint `gorm:"column:latest_attempt_id;not null;default:0;comment:最新审批尝试记录ID" json:"latest_attempt_id"` + LatestApprovalInstanceID uint `gorm:"column:latest_approval_instance_id;not null;default:0;comment:最新通用审批实例ID" json:"latest_approval_instance_id"` + DecidedAt *time.Time `gorm:"column:decided_at;type:timestamptz;comment:审批终态到达时间" json:"decided_at,omitempty"` + TerminalReason string `gorm:"column:terminal_reason;type:varchar(500);not null;default:'';comment:异常终态说明" json:"terminal_reason"` + Creator uint `gorm:"column:creator;not null;default:0;comment:创建人用户ID" json:"creator"` + Updater uint `gorm:"column:updater;not null;default:0;comment:最近更新人用户ID" json:"updater"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定核销申请表名。 +func (EmployeeCollectionApplication) TableName() string { + return "tb_employee_collection_application" +} + +// EmployeeCollectionApplicationAttempt 是同一申请每次提交或重提对应的不可变审批材料。 +// 其主键同时作为通用审批实例的业务ID,使每次提交各自持有独立审批实例。 +type EmployeeCollectionApplicationAttempt struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + ApplicationID uint `gorm:"column:application_id;not null;comment:所属核销申请ID" json:"application_id"` + AttemptNo int `gorm:"column:attempt_no;type:int;not null;comment:第几次提交,从 1 递增" json:"attempt_no"` + PaymentMethodID uint `gorm:"column:payment_method_id;not null;comment:本次冻结的收款方式字典ID" json:"payment_method_id"` + PaymentMethodCode string `gorm:"column:payment_method_code;type:varchar(64);not null;comment:本次冻结的收款方式编码" json:"payment_method_code"` + PaymentMethodName string `gorm:"column:payment_method_name;type:varchar(100);not null;comment:本次冻结的收款方式名称" json:"payment_method_name"` + PaidAmount int64 `gorm:"column:paid_amount;type:bigint;not null;comment:本次冻结的付款金额(分)" json:"paid_amount"` + PayerName string `gorm:"column:payer_name;type:varchar(100);not null;default:'';comment:本次冻结的付款方名称" json:"payer_name"` + PaidAt time.Time `gorm:"column:paid_at;type:timestamptz;not null;comment:本次冻结的付款时间" json:"paid_at"` + ExternalTransactionNo string `gorm:"column:external_transaction_no;type:varchar(128);not null;default:'';comment:本次冻结的外部交易流水号" json:"external_transaction_no"` + PaymentVoucherKeys StringJSONBArray `gorm:"column:payment_voucher_keys;type:jsonb;not null;comment:本次冻结的支付凭证对象键列表" json:"payment_voucher_keys"` + Remark string `gorm:"column:remark;type:varchar(500);not null;default:'';comment:本次冻结的备注" json:"remark"` + SubmittedByAccountID uint `gorm:"column:submitted_by_account_id;not null;comment:本次实际提交账号ID" json:"submitted_by_account_id"` + ActingReason string `gorm:"column:acting_reason;type:varchar(500);not null;default:'';comment:本次代办原因" json:"acting_reason"` + AllocationSnapshot datatypes.JSON `gorm:"column:allocation_snapshot;type:jsonb;not null;comment:本次冻结的账单分摊快照" json:"allocation_snapshot"` + ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:本次尝试关联的通用审批实例ID" json:"approval_instance_id,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` +} + +// TableName 指定审批尝试记录表名。 +func (EmployeeCollectionApplicationAttempt) TableName() string { + return "tb_employee_collection_application_attempt" +} + +// EmployeeCollectionApplicationAllocation 是申请对单张账单的本次分摊。 +// 审批中预占账单余额,终态释放预占或转入已核销金额。 +type EmployeeCollectionApplicationAllocation struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + ApplicationID uint `gorm:"column:application_id;not null;comment:所属核销申请ID" json:"application_id"` + AttemptID uint `gorm:"column:attempt_id;not null;comment:所属审批尝试记录ID" json:"attempt_id"` + BillID uint `gorm:"column:bill_id;not null;comment:目标账单ID" json:"bill_id"` + Amount int64 `gorm:"column:amount;type:bigint;not null;comment:本次分摊金额(分)" json:"amount"` + Status int `gorm:"column:status;type:smallint;not null;default:0;comment:状态 0-审批中预占 1-已通过 2-已驳回或已释放" json:"status"` + ReleasedAt *time.Time `gorm:"column:released_at;type:timestamptz;comment:预占释放时间" json:"released_at,omitempty"` + Creator uint `gorm:"column:creator;not null;default:0;comment:创建人用户ID" json:"creator"` + Updater uint `gorm:"column:updater;not null;default:0;comment:最近更新人用户ID" json:"updater"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定申请分摊表名。 +func (EmployeeCollectionApplicationAllocation) TableName() string { + return "tb_employee_collection_application_allocation" +} + +// EmployeeCollectionBillRefund 是来源订单退款与账单之间的唯一冲销关联事实。 +type EmployeeCollectionBillRefund struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + BillID uint `gorm:"column:bill_id;not null;comment:目标账单ID" json:"bill_id"` + RefundID uint `gorm:"column:refund_id;not null;comment:退款申请ID" json:"refund_id"` + SourceOrderID uint `gorm:"column:source_order_id;not null;comment:账单来源订单ID" json:"source_order_id"` + RefundAmount int64 `gorm:"column:refund_amount;type:bigint;not null;comment:本次退款成功金额(分)" json:"refund_amount"` + BillReceivableAmount int64 `gorm:"column:bill_receivable_amount;type:bigint;not null;comment:冲销前账单应收金额快照(分)" json:"bill_receivable_amount"` + Outcome string `gorm:"column:outcome;type:varchar(20);not null;comment:处理结果 closed_full/reduced/hint_only" json:"outcome"` + ReducedAmount int64 `gorm:"column:reduced_amount;type:bigint;not null;default:0;comment:实际冲减应收金额(分)" json:"reduced_amount"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` +} + +// TableName 指定退款冲销关联表名。 +func (EmployeeCollectionBillRefund) TableName() string { + return "tb_employee_collection_bill_refund" +} diff --git a/internal/model/order.go b/internal/model/order.go index 233a981..b59db1b 100644 --- a/internal/model/order.go +++ b/internal/model/order.go @@ -75,7 +75,8 @@ type Order struct { // 支付配置 PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"` - // 线下支付凭证对象存储 file_key 列表(最多5个,仅线下支付订单必填) + // 线下支付凭证对象存储 file_key 列表(最多5个,线下支付订单必填; + // 会生成员工代收款账单的线下订单除外,该场景凭证由核销申请环节提供) PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:线下支付凭证对象存储file_key列表(jsonb存储[]string)" json:"payment_voucher_key"` } diff --git a/internal/query/employeecollection/application.go b/internal/query/employeecollection/application.go new file mode 100644 index 0000000..982ba7c --- /dev/null +++ b/internal/query/employeecollection/application.go @@ -0,0 +1,308 @@ +package employeecollection + +import ( + "context" + + "gorm.io/gorm" + + "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" +) + +// ApplicationQuery 查询员工代收款核销申请列表与详情。 +// Query 只做权限范围、筛选、分页与 DTO 投影,不修改任何状态。 +type ApplicationQuery struct { + db *gorm.DB +} + +// NewApplicationQuery 创建核销申请查询。 +func NewApplicationQuery(db *gorm.DB) *ApplicationQuery { + return &ApplicationQuery{db: db} +} + +// List 分页查询当前可见范围内的核销申请。 +func (q *ApplicationQuery) List( + ctx context.Context, + request dto.EmployeeCollectionApplicationListRequest, +) (*dto.EmployeeCollectionApplicationListResponse, error) { + query, err := q.applyApplicationScope(ctx, request) + if err != nil { + return nil, err + } + page, pageSize := normalizePage(request.Page, request.PageSize) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销申请总数失败") + } + var applications []model.EmployeeCollectionApplication + if err := query.Order("created_at DESC, id DESC"). + Offset((page - 1) * pageSize).Limit(pageSize). + Find(&applications).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销申请列表失败") + } + list := make([]*dto.EmployeeCollectionApplicationResponse, 0, len(applications)) + for index := range applications { + list = append(list, ProjectApplication(&applications[index])) + } + return &dto.EmployeeCollectionApplicationListResponse{ + List: list, Total: total, Page: page, PageSize: pageSize, + }, nil +} + +// Detail 返回当前可见范围内的核销申请详情,包含全部分摊与审批尝试历史。 +// 不可见与不存在返回同一错误,不产生可枚举差异。 +func (q *ApplicationQuery) Detail(ctx context.Context, id uint) (*dto.EmployeeCollectionApplicationDetailResponse, error) { + if q == nil || q.db == nil || id == 0 { + return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound) + } + scoped, err := applyApplicationVisibility(ctx, q.db.WithContext(ctx).Model(&model.EmployeeCollectionApplication{})) + if err != nil { + return nil, err + } + var application model.EmployeeCollectionApplication + if err := scoped.Where("id = ?", id).First(&application).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound) + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销申请详情失败") + } + allocations, err := q.applicationAllocations(ctx, application.ID) + if err != nil { + return nil, err + } + attempts, err := q.applicationAttemptList(ctx, application.ID) + if err != nil { + return nil, err + } + return &dto.EmployeeCollectionApplicationDetailResponse{ + Application: ProjectApplication(&application), Allocations: allocations, Attempts: attempts, + }, nil +} + +// applyApplicationScope 生成同时应用可见性与筛选条件的核销申请查询。 +func (q *ApplicationQuery) applyApplicationScope( + ctx context.Context, + request dto.EmployeeCollectionApplicationListRequest, +) (*gorm.DB, error) { + if q == nil || q.db == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "核销申请查询尚未配置") + } + query, err := applyApplicationVisibility(ctx, q.db.WithContext(ctx).Model(&model.EmployeeCollectionApplication{})) + if err != nil { + return nil, err + } + if request.ApplicantAccountID != nil && *request.ApplicantAccountID > 0 { + query = query.Where("applicant_account_id = ?", *request.ApplicantAccountID) + } + if request.PaymentMethodID != nil && *request.PaymentMethodID > 0 { + query = query.Where("payment_method_id = ?", *request.PaymentMethodID) + } + if request.Status != nil { + switch *request.Status { + case constants.EmployeeCollectionApplicationStatusPending, + constants.EmployeeCollectionApplicationStatusApproved, + constants.EmployeeCollectionApplicationStatusRejected, + constants.EmployeeCollectionApplicationStatusRevoked: + query = query.Where("status = ?", *request.Status) + default: + return nil, errors.New(errors.CodeInvalidParam, "不支持的核销申请状态") + } + } + if request.CreatedFrom != nil { + from, err := parseBillDate(*request.CreatedFrom) + if err != nil { + return nil, err + } + if from != nil { + query = query.Where("created_at >= ?", *from) + } + } + if request.CreatedTo != nil { + to, err := parseBillDate(*request.CreatedTo) + if err != nil { + return nil, err + } + if to != nil { + query = query.Where("created_at < ?", to.AddDate(0, 0, 1)) + } + } + return query, nil +} + +// applyApplicationVisibility 应用核销申请可见性:超级管理员见全部,其他账号仅见本人申请。 +func applyApplicationVisibility(ctx context.Context, query *gorm.DB) (*gorm.DB, error) { + if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin { + return query, nil + } + accountID := middleware.GetUserIDFromContext(ctx) + if accountID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + return query.Where("applicant_account_id = ?", accountID), nil +} + +// applicationAllocations 读取申请的全部分摊并附带目标账单只读摘要。 +func (q *ApplicationQuery) applicationAllocations( + ctx context.Context, + applicationID uint, +) ([]*dto.EmployeeCollectionApplicationAllocationResponse, error) { + var allocations []model.EmployeeCollectionApplicationAllocation + if err := q.db.WithContext(ctx).Where("application_id = ?", applicationID). + Order("bill_id ASC, id ASC").Find(&allocations).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销申请分摊失败") + } + billIDs := make([]uint, 0, len(allocations)) + for index := range allocations { + billIDs = append(billIDs, allocations[index].BillID) + } + bills := make(map[uint]model.EmployeeCollectionBill, len(billIDs)) + if len(billIDs) > 0 { + var loaded []model.EmployeeCollectionBill + if err := q.db.WithContext(ctx).Where("id IN ?", billIDs).Find(&loaded).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销分摊目标账单失败") + } + for index := range loaded { + bills[loaded[index].ID] = loaded[index] + } + } + result := make([]*dto.EmployeeCollectionApplicationAllocationResponse, 0, len(allocations)) + for index := range allocations { + allocation := allocations[index] + bill := bills[allocation.BillID] + result = append(result, &dto.EmployeeCollectionApplicationAllocationResponse{ + ID: allocation.ID, BillID: allocation.BillID, Amount: allocation.Amount, + Status: allocation.Status, StatusName: constants.GetEmployeeCollectionAllocationStatusName(allocation.Status), + ReleasedAt: allocation.ReleasedAt, + BillSourceType: bill.SourceType, BillSourceNo: bill.SourceNo, + BillReceivableAmount: bill.ReceivableAmount, BillReceivedAmount: bill.ReceivedAmount, + BillReservedAmount: bill.ReservedAmount, + BillStatus: bill.Status, BillStatusName: constants.GetEmployeeCollectionBillStatusName(bill.Status), + CreatedAt: allocation.CreatedAt, + }) + } + return result, nil +} + +// applicationAttemptList 读取申请的全部审批尝试记录与审批实例状态。 +func (q *ApplicationQuery) applicationAttemptList( + ctx context.Context, + applicationID uint, +) ([]*dto.EmployeeCollectionBillAttemptResponse, error) { + var attempts []model.EmployeeCollectionApplicationAttempt + if err := q.db.WithContext(ctx).Where("application_id = ?", applicationID). + Order("attempt_no ASC, id ASC").Find(&attempts).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销审批尝试记录失败") + } + approvalStatus, approvalOpinions, err := approvalStatusOfAttempts(ctx, q.db, attempts, true) + if err != nil { + return nil, err + } + return attemptResponses(applicationID, attempts, approvalStatus, approvalOpinions) +} + +// ProjectApplication 将核销申请事实投影为对外响应,附件只返回对象键引用。 +func ProjectApplication(application *model.EmployeeCollectionApplication) *dto.EmployeeCollectionApplicationResponse { + if application == nil { + return nil + } + vouchers := []string(application.PaymentVoucherKeys) + if vouchers == nil { + vouchers = []string{} + } + return &dto.EmployeeCollectionApplicationResponse{ + ID: application.ID, ApplicantAccountID: application.ApplicantAccountID, + ActingOperatorID: application.ActingOperatorID, ActingReason: application.ActingReason, + PaymentMethodID: application.PaymentMethodID, PaymentMethodCode: application.PaymentMethodCode, + PaymentMethodName: application.PaymentMethodName, PaidAmount: application.PaidAmount, + PayerName: application.PayerName, PaidAt: application.PaidAt, + ExternalTransactionNo: application.ExternalTransactionNo, PaymentVoucherKeys: vouchers, + Remark: application.Remark, Status: application.Status, + StatusName: constants.GetEmployeeCollectionApplicationStatusName(application.Status), + LatestAttemptID: application.LatestAttemptID, + LatestApprovalInstanceID: application.LatestApprovalInstanceID, + DecidedAt: application.DecidedAt, TerminalReason: application.TerminalReason, + CreatedAt: application.CreatedAt, UpdatedAt: application.UpdatedAt, + } +} + +// ApplicationSubmitProjection 是创建或重提核销申请返回的投影输入。 +type ApplicationSubmitProjection struct { + Application *model.EmployeeCollectionApplication + Attempt *model.EmployeeCollectionApplicationAttempt + Allocations []*model.EmployeeCollectionApplicationAllocation + Bills []*model.EmployeeCollectionBill + InstanceID uint + InstanceStatus int +} + +// ProjectApplicationSubmit 组装创建或重提核销申请的响应。 +// 附件只返回对象键引用,审批状态取本次创建的通用审批实例状态,不返回对象存储内容。 +func ProjectApplicationSubmit(projection ApplicationSubmitProjection) (*dto.EmployeeCollectionApplicationSubmitResponse, error) { + allocations, err := projectSubmitAllocations(projection.Allocations, projection.Bills) + if err != nil { + return nil, err + } + var attempts []*dto.EmployeeCollectionBillAttemptResponse + if projection.Attempt != nil { + approvalStatus := map[uint]int{} + if projection.InstanceID > 0 { + approvalStatus[projection.InstanceID] = projection.InstanceStatus + } + attempts, err = attemptResponses( + projection.Attempt.ApplicationID, + []model.EmployeeCollectionApplicationAttempt{*projection.Attempt}, + approvalStatus, map[uint]string{}, + ) + if err != nil { + return nil, err + } + } + response := &dto.EmployeeCollectionApplicationSubmitResponse{ + Application: ProjectApplication(projection.Application), + Allocations: allocations, + ApprovalInstanceID: projection.InstanceID, + ApprovalStatus: projection.InstanceStatus, + ApprovalStatusName: constants.GetApprovalStatusName(projection.InstanceStatus), + } + if len(attempts) > 0 { + response.Attempt = attempts[0] + } + return response, nil +} + +// projectSubmitAllocations 将分摊与目标账单摘要投影为响应。 +func projectSubmitAllocations( + allocations []*model.EmployeeCollectionApplicationAllocation, + bills []*model.EmployeeCollectionBill, +) ([]*dto.EmployeeCollectionApplicationAllocationResponse, error) { + billByID := make(map[uint]*model.EmployeeCollectionBill, len(bills)) + for _, bill := range bills { + if bill != nil { + billByID[bill.ID] = bill + } + } + result := make([]*dto.EmployeeCollectionApplicationAllocationResponse, 0, len(allocations)) + for _, allocation := range allocations { + if allocation == nil { + continue + } + bill := billByID[allocation.BillID] + if bill == nil { + return nil, errors.New(errors.CodeInternalError, "核销分摊缺少目标账单事实") + } + result = append(result, &dto.EmployeeCollectionApplicationAllocationResponse{ + ID: allocation.ID, BillID: allocation.BillID, Amount: allocation.Amount, + Status: allocation.Status, StatusName: constants.GetEmployeeCollectionAllocationStatusName(allocation.Status), + ReleasedAt: allocation.ReleasedAt, + BillSourceType: bill.SourceType, BillSourceNo: bill.SourceNo, + BillReceivableAmount: bill.ReceivableAmount, BillReceivedAmount: bill.ReceivedAmount, + BillReservedAmount: bill.ReservedAmount, + BillStatus: bill.Status, BillStatusName: constants.GetEmployeeCollectionBillStatusName(bill.Status), + CreatedAt: allocation.CreatedAt, + }) + } + return result, nil +} diff --git a/internal/query/employeecollection/bill.go b/internal/query/employeecollection/bill.go new file mode 100644 index 0000000..2f322f6 --- /dev/null +++ b/internal/query/employeecollection/bill.go @@ -0,0 +1,502 @@ +package employeecollection + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/bytedance/sonic" + "gorm.io/gorm" + + 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" +) + +// billDateLayout 是账单筛选使用的自然日格式。 +const billDateLayout = "2006-01-02" + +// BillQuery 查询员工代收款账单列表、统计与详情。 +// 列表与统计共用 applyBillScope 生成的同一个 *gorm.DB,禁止两处各写一套筛选。 +type BillQuery struct { + db *gorm.DB +} + +// NewBillQuery 创建员工代收款账单查询。 +func NewBillQuery(db *gorm.DB) *BillQuery { + return &BillQuery{db: db} +} + +// List 分页查询当前可见范围内的员工代收款账单。 +func (q *BillQuery) List(ctx context.Context, request dto.EmployeeCollectionBillListRequest) (*dto.EmployeeCollectionBillListResponse, error) { + query, err := q.applyBillScope(ctx, billFilterOfList(request)) + if err != nil { + return nil, err + } + page, pageSize := normalizePage(request.Page, request.PageSize) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询员工代收款账单总数失败") + } + var bills []model.EmployeeCollectionBill + if err := query.Order("created_at DESC, id DESC"). + Offset((page - 1) * pageSize).Limit(pageSize). + Find(&bills).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询员工代收款账单列表失败") + } + list := make([]*dto.EmployeeCollectionBillResponse, 0, len(bills)) + for index := range bills { + item, err := ProjectBill(&bills[index]) + if err != nil { + return nil, err + } + list = append(list, item) + } + return &dto.EmployeeCollectionBillListResponse{ + List: list, Total: total, Page: page, PageSize: pageSize, + }, nil +} + +// Statistics 汇总当前可见范围内的应收、已核销、未核销金额与待处理账单数。 +// 使用与列表完全相同的可见性与筛选条件。 +func (q *BillQuery) Statistics( + ctx context.Context, + request dto.EmployeeCollectionBillStatisticsRequest, +) (*dto.EmployeeCollectionBillStatisticsResponse, error) { + query, err := q.applyBillScope(ctx, billFilterOfStatistics(request)) + if err != nil { + return nil, err + } + var row struct { + ReceivableTotal int64 + ReceivedTotal int64 + UnsettledTotal int64 + PendingBillCount int64 + } + if err := query.Select( + `COALESCE(SUM(receivable_amount), 0) AS receivable_total, + COALESCE(SUM(received_amount), 0) AS received_total, + COALESCE(SUM(CASE WHEN status = ? THEN 0 ELSE receivable_amount - received_amount END), 0) AS unsettled_total, + COALESCE(SUM(CASE WHEN status IN (?, ?) THEN 1 ELSE 0 END), 0) AS pending_bill_count`, + constants.EmployeeCollectionBillStatusClosed, + constants.EmployeeCollectionBillStatusPending, + constants.EmployeeCollectionBillStatusPartial, + ).Scan(&row).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询员工代收款账单统计失败") + } + return &dto.EmployeeCollectionBillStatisticsResponse{ + ReceivableTotal: row.ReceivableTotal, ReceivedTotal: row.ReceivedTotal, + UnsettledTotal: row.UnsettledTotal, PendingBillCount: row.PendingBillCount, + }, nil +} + +// Detail 返回当前可见范围内的账单详情,包含退款冲销、分摊与申请审批历史。 +// 不可见与不存在返回同一错误,不产生可枚举差异。 +func (q *BillQuery) Detail(ctx context.Context, id uint) (*dto.EmployeeCollectionBillDetailResponse, error) { + if q == nil || q.db == nil || id == 0 { + return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound) + } + scoped, err := applyBillVisibility(ctx, q.db.WithContext(ctx).Model(&model.EmployeeCollectionBill{})) + if err != nil { + return nil, err + } + var bill model.EmployeeCollectionBill + if err := scoped.Where("id = ?", id).First(&bill).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound) + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询员工代收款账单详情失败") + } + billResponse, err := ProjectBill(&bill) + if err != nil { + return nil, err + } + refunds, err := q.billRefunds(ctx, bill.ID) + if err != nil { + return nil, err + } + allocations, applicationIDs, err := q.billAllocations(ctx, bill.ID) + if err != nil { + return nil, err + } + applications, err := q.billApplications(ctx, applicationIDs) + if err != nil { + return nil, err + } + return &dto.EmployeeCollectionBillDetailResponse{ + Bill: billResponse, Refunds: refunds, Allocations: allocations, Applications: applications, + }, nil +} + +// applyBillScope 生成同时应用可见性与筛选条件的账单查询。 +// 列表、统计与关闭后的可见性校验都必须走这一处,避免筛选或数据范围分叉。 +func (q *BillQuery) applyBillScope(ctx context.Context, filter billFilter) (*gorm.DB, error) { + if q == nil || q.db == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "员工代收款账单查询尚未配置") + } + query, err := applyBillVisibility(ctx, q.db.WithContext(ctx).Model(&model.EmployeeCollectionBill{})) + if err != nil { + return nil, err + } + return applyBillFilters(query, filter) +} + +// billFilter 账单列表与统计共用的筛选条件,屏蔽两种请求 DTO 的字段差异。 +type billFilter struct { + SourceType *string + SourceNo *string + Status *int + DebtorAccountID *uint + CustomerID *uint + CreatedFrom *string + CreatedTo *string +} + +// billFilterOfList 投影账单列表请求的筛选字段。 +func billFilterOfList(request dto.EmployeeCollectionBillListRequest) billFilter { + return billFilter{ + SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status, + DebtorAccountID: request.DebtorAccountID, CustomerID: request.CustomerID, + CreatedFrom: request.CreatedFrom, CreatedTo: request.CreatedTo, + } +} + +// billFilterOfStatistics 投影账单统计请求的筛选字段。 +func billFilterOfStatistics(request dto.EmployeeCollectionBillStatisticsRequest) billFilter { + return billFilter{ + SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status, + DebtorAccountID: request.DebtorAccountID, CustomerID: request.CustomerID, + CreatedFrom: request.CreatedFrom, CreatedTo: request.CreatedTo, + } +} + +// applyBillVisibility 应用账单可见性:超级管理员见全部,其他账号仅见本人欠款账单。 +func applyBillVisibility(ctx context.Context, query *gorm.DB) (*gorm.DB, error) { + if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin { + return query, nil + } + accountID := middleware.GetUserIDFromContext(ctx) + if accountID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + return query.Where("debtor_account_id = ?", accountID), nil +} + +// applyBillFilters 应用账单列表与统计共用的筛选条件。 +func applyBillFilters(query *gorm.DB, request billFilter) (*gorm.DB, error) { + if request.DebtorAccountID != nil && *request.DebtorAccountID > 0 { + query = query.Where("debtor_account_id = ?", *request.DebtorAccountID) + } + if request.SourceType != nil { + sourceType := strings.TrimSpace(*request.SourceType) + switch sourceType { + case "": + case constants.EmployeeCollectionSourceTypeOrder, constants.EmployeeCollectionSourceTypeRecharge: + query = query.Where("source_type = ?", sourceType) + default: + return nil, errors.New(errors.CodeInvalidParam, "不支持的账单来源类型") + } + } + if request.SourceNo != nil { + if sourceNo := strings.TrimSpace(*request.SourceNo); sourceNo != "" { + query = query.Where("source_no = ?", sourceNo) + } + } + if request.Status != nil { + switch *request.Status { + case constants.EmployeeCollectionBillStatusPending, constants.EmployeeCollectionBillStatusPartial, + constants.EmployeeCollectionBillStatusSettled, constants.EmployeeCollectionBillStatusClosed: + query = query.Where("status = ?", *request.Status) + default: + return nil, errors.New(errors.CodeInvalidParam, "不支持的账单状态") + } + } + if request.CustomerID != nil && *request.CustomerID > 0 { + customerID := strconv.FormatUint(uint64(*request.CustomerID), 10) + query = query.Where("customer_snapshot->>'buyer_id' = ? OR customer_snapshot->>'shop_id' = ?", customerID, customerID) + } + if request.CreatedFrom != nil { + from, err := parseBillDate(*request.CreatedFrom) + if err != nil { + return nil, err + } + if from != nil { + query = query.Where("created_at >= ?", *from) + } + } + if request.CreatedTo != nil { + to, err := parseBillDate(*request.CreatedTo) + if err != nil { + return nil, err + } + if to != nil { + query = query.Where("created_at < ?", to.AddDate(0, 0, 1)) + } + } + return query, nil +} + +// parseBillDate 解析自然日筛选值;空值表示不筛选。 +func parseBillDate(value string) (*time.Time, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, nil + } + parsed, err := time.ParseInLocation(billDateLayout, trimmed, time.Local) + if err != nil { + return nil, errors.New(errors.CodeInvalidParam, "账单筛选日期格式必须为 YYYY-MM-DD") + } + return &parsed, nil +} + +// billRefunds 读取账单的来源订单退款冲销关联。 +func (q *BillQuery) billRefunds(ctx context.Context, billID uint) ([]*dto.EmployeeCollectionBillRefundResponse, error) { + var refunds []model.EmployeeCollectionBillRefund + if err := q.db.WithContext(ctx).Where("bill_id = ?", billID).Order("id ASC").Find(&refunds).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单退款冲销关联失败") + } + result := make([]*dto.EmployeeCollectionBillRefundResponse, 0, len(refunds)) + for index := range refunds { + item := refunds[index] + result = append(result, &dto.EmployeeCollectionBillRefundResponse{ + ID: item.ID, RefundID: item.RefundID, SourceOrderID: item.SourceOrderID, + RefundAmount: item.RefundAmount, BillReceivableAmount: item.BillReceivableAmount, + Outcome: item.Outcome, OutcomeName: constants.GetEmployeeCollectionRefundOutcomeName(item.Outcome), + ReducedAmount: item.ReducedAmount, CreatedAt: item.CreatedAt, + }) + } + return result, nil +} + +// billAllocations 读取账单分摊,并返回涉及的去重申请 ID。 +func (q *BillQuery) billAllocations(ctx context.Context, billID uint) ([]*dto.EmployeeCollectionBillAllocationResponse, []uint, error) { + var allocations []model.EmployeeCollectionApplicationAllocation + if err := q.db.WithContext(ctx).Where("bill_id = ?", billID).Order("id ASC").Find(&allocations).Error; err != nil { + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单分摊失败") + } + applicationIDs := make([]uint, 0, len(allocations)) + seen := make(map[uint]struct{}, len(allocations)) + for index := range allocations { + if _, exists := seen[allocations[index].ApplicationID]; !exists { + seen[allocations[index].ApplicationID] = struct{}{} + applicationIDs = append(applicationIDs, allocations[index].ApplicationID) + } + } + applicationStatus := make(map[uint]int, len(applicationIDs)) + if len(applicationIDs) > 0 { + var applications []model.EmployeeCollectionApplication + if err := q.db.WithContext(ctx).Select("id", "status"). + Where("id IN ?", applicationIDs).Find(&applications).Error; err != nil { + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单分摊所属申请失败") + } + for index := range applications { + applicationStatus[applications[index].ID] = applications[index].Status + } + } + result := make([]*dto.EmployeeCollectionBillAllocationResponse, 0, len(allocations)) + for index := range allocations { + item := allocations[index] + status := applicationStatus[item.ApplicationID] + result = append(result, &dto.EmployeeCollectionBillAllocationResponse{ + ID: item.ID, ApplicationID: item.ApplicationID, + ApplicationStatus: status, ApplicationStatusName: constants.GetEmployeeCollectionApplicationStatusName(status), + AttemptID: item.AttemptID, Amount: item.Amount, + Status: item.Status, StatusName: constants.GetEmployeeCollectionAllocationStatusName(item.Status), + ReleasedAt: item.ReleasedAt, CreatedAt: item.CreatedAt, + }) + } + return result, applicationIDs, nil +} + +// billApplications 读取涉及该账单的申请及其审批尝试历史。 +func (q *BillQuery) billApplications(ctx context.Context, applicationIDs []uint) ([]*dto.EmployeeCollectionBillApplicationResponse, error) { + if len(applicationIDs) == 0 { + return []*dto.EmployeeCollectionBillApplicationResponse{}, nil + } + var applications []model.EmployeeCollectionApplication + if err := q.db.WithContext(ctx).Where("id IN ?", applicationIDs).Order("id ASC").Find(&applications).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单关联核销申请失败") + } + attemptsByApplication, approvalStatus, approvalOpinions, err := q.applicationAttempts(ctx, applicationIDs) + if err != nil { + return nil, err + } + result := make([]*dto.EmployeeCollectionBillApplicationResponse, 0, len(applications)) + for index := range applications { + application := applications[index] + vouchers := []string(application.PaymentVoucherKeys) + if vouchers == nil { + vouchers = []string{} + } + attempts, err := attemptResponses( + application.ID, attemptsByApplication[application.ID], approvalStatus, approvalOpinions) + if err != nil { + return nil, err + } + result = append(result, &dto.EmployeeCollectionBillApplicationResponse{ + ID: application.ID, ApplicantAccountID: application.ApplicantAccountID, + ActingOperatorID: application.ActingOperatorID, ActingReason: application.ActingReason, + PaymentMethodID: application.PaymentMethodID, PaymentMethodCode: application.PaymentMethodCode, + PaymentMethodName: application.PaymentMethodName, PaidAmount: application.PaidAmount, + PayerName: application.PayerName, PaidAt: application.PaidAt, + ExternalTransactionNo: application.ExternalTransactionNo, PaymentVoucherKeys: vouchers, + Remark: application.Remark, Status: application.Status, + StatusName: constants.GetEmployeeCollectionApplicationStatusName(application.Status), + LatestApprovalInstanceID: application.LatestApprovalInstanceID, + DecidedAt: application.DecidedAt, TerminalReason: application.TerminalReason, + CreatedAt: application.CreatedAt, + Attempts: attempts, + }) + } + return result, nil +} + +// applicationAttempts 读取申请的审批尝试记录、审批实例状态与审批意见。 +func (q *BillQuery) applicationAttempts( + ctx context.Context, + applicationIDs []uint, +) (map[uint][]model.EmployeeCollectionApplicationAttempt, map[uint]int, map[uint]string, error) { + var attempts []model.EmployeeCollectionApplicationAttempt + if err := q.db.WithContext(ctx).Where("application_id IN ?", applicationIDs). + Order("application_id ASC, attempt_no ASC").Find(&attempts).Error; err != nil { + return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批尝试记录失败") + } + approvalStatus, approvalOpinions, err := approvalStatusOfAttempts(ctx, q.db, attempts, true) + if err != nil { + return nil, nil, nil, err + } + grouped := make(map[uint][]model.EmployeeCollectionApplicationAttempt, len(applicationIDs)) + for index := range attempts { + grouped[attempts[index].ApplicationID] = append(grouped[attempts[index].ApplicationID], attempts[index]) + } + return grouped, approvalStatus, approvalOpinions, nil +} + +// approvalStatusOfAttempts 批量读取审批尝试记录关联的通用审批实例状态。 +// withOpinion 为真时同时读取终态决策快照并提取审批意见;列表路径传 false,避免批量加载渠道快照。 +func approvalStatusOfAttempts( + ctx context.Context, + db *gorm.DB, + attempts []model.EmployeeCollectionApplicationAttempt, + withOpinion bool, +) (map[uint]int, map[uint]string, error) { + instanceIDs := make([]uint, 0, len(attempts)) + for index := range attempts { + if attempts[index].ApprovalInstanceID != nil { + instanceIDs = append(instanceIDs, *attempts[index].ApprovalInstanceID) + } + } + approvalStatus := make(map[uint]int, len(instanceIDs)) + approvalOpinions := make(map[uint]string, len(instanceIDs)) + if len(instanceIDs) == 0 { + return approvalStatus, approvalOpinions, nil + } + columns := []string{"id", "status"} + if withOpinion { + columns = append(columns, "decision_snapshot") + } + var instances []model.ApprovalInstance + if err := db.WithContext(ctx).Select(columns). + Where("id IN ?", instanceIDs).Find(&instances).Error; err != nil { + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批实例状态失败") + } + for index := range instances { + approvalStatus[instances[index].ID] = instances[index].Status + if opinion := employeecollectiondomain.ExtractApprovalOpinion(instances[index].DecisionSnapshot); opinion != "" { + approvalOpinions[instances[index].ID] = opinion + } + } + return approvalStatus, approvalOpinions, nil +} + +// attemptResponses 投影审批尝试记录,附件只返回对象键引用与审批实例引用。 +func attemptResponses( + applicationID uint, + attempts []model.EmployeeCollectionApplicationAttempt, + approvalStatus map[uint]int, + approvalOpinions map[uint]string, +) ([]*dto.EmployeeCollectionBillAttemptResponse, error) { + result := make([]*dto.EmployeeCollectionBillAttemptResponse, 0, len(attempts)) + for index := range attempts { + attempt := attempts[index] + if attempt.ApplicationID != applicationID { + continue + } + vouchers := []string(attempt.PaymentVoucherKeys) + if vouchers == nil { + vouchers = []string{} + } + snapshot := make([]map[string]any, 0) + if len(attempt.AllocationSnapshot) > 0 { + if err := sonic.Unmarshal(attempt.AllocationSnapshot, &snapshot); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "解析审批尝试分摊快照失败") + } + } + var status *int + statusName := "" + opinion := "" + if attempt.ApprovalInstanceID != nil { + if value, ok := approvalStatus[*attempt.ApprovalInstanceID]; ok { + status = &value + statusName = constants.GetApprovalStatusName(value) + } + opinion = approvalOpinions[*attempt.ApprovalInstanceID] + } + result = append(result, &dto.EmployeeCollectionBillAttemptResponse{ + ID: attempt.ID, AttemptNo: attempt.AttemptNo, + PaymentMethodID: attempt.PaymentMethodID, PaymentMethodCode: attempt.PaymentMethodCode, + PaymentMethodName: attempt.PaymentMethodName, PaidAmount: attempt.PaidAmount, + PayerName: attempt.PayerName, PaidAt: attempt.PaidAt, + ExternalTransactionNo: attempt.ExternalTransactionNo, PaymentVoucherKeys: vouchers, + Remark: attempt.Remark, SubmittedByAccountID: attempt.SubmittedByAccountID, + ActingReason: attempt.ActingReason, AllocationSnapshot: snapshot, + ApprovalInstanceID: attempt.ApprovalInstanceID, + ApprovalStatus: status, ApprovalStatusName: statusName, + ApprovalOpinion: opinion, CreatedAt: attempt.CreatedAt, + }) + } + return result, nil +} + +// ProjectBill 将账单事实投影为对外响应;关闭用例与查询共用同一投影。 +func ProjectBill(bill *model.EmployeeCollectionBill) (*dto.EmployeeCollectionBillResponse, error) { + if bill == nil { + return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound) + } + debtorSnapshot := make(map[string]any) + if len(bill.DebtorSnapshot) > 0 { + if err := sonic.Unmarshal(bill.DebtorSnapshot, &debtorSnapshot); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "解析账单欠款人快照失败") + } + } + customerSnapshot := make(map[string]any) + if len(bill.CustomerSnapshot) > 0 { + if err := sonic.Unmarshal(bill.CustomerSnapshot, &customerSnapshot); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "解析账单客户快照失败") + } + } + remaining := int64(0) + if bill.Status != constants.EmployeeCollectionBillStatusClosed { + remaining = bill.ReceivableAmount - bill.ReceivedAmount - bill.ReservedAmount + if remaining < 0 { + remaining = 0 + } + } + return &dto.EmployeeCollectionBillResponse{ + ID: bill.ID, SourceType: bill.SourceType, + SourceTypeName: constants.GetEmployeeCollectionSourceTypeName(bill.SourceType), + SourceID: bill.SourceID, SourceNo: bill.SourceNo, + DebtorAccountID: bill.DebtorAccountID, DebtorSnapshot: debtorSnapshot, + CustomerSnapshot: customerSnapshot, + ReceivableAmount: bill.ReceivableAmount, ReceivedAmount: bill.ReceivedAmount, + ReservedAmount: bill.ReservedAmount, RemainingAmount: remaining, + Status: bill.Status, StatusName: constants.GetEmployeeCollectionBillStatusName(bill.Status), + ApprovalPending: bill.ReservedAmount > 0, + ClosedReason: bill.ClosedReason, ClosedAt: bill.ClosedAt, + CreatedAt: bill.CreatedAt, UpdatedAt: bill.UpdatedAt, + }, nil +} diff --git a/internal/query/employeecollection/payment_method.go b/internal/query/employeecollection/payment_method.go new file mode 100644 index 0000000..4f8d726 --- /dev/null +++ b/internal/query/employeecollection/payment_method.go @@ -0,0 +1,99 @@ +// Package employeecollection 提供员工代收款账单与线下收款方式字典的只读投影。 +// Query 只做权限范围、筛选、分页与 DTO 投影,不修改任何状态。 +package employeecollection + +import ( + "context" + "strings" + + "gorm.io/gorm" + + "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" +) + +// PaymentMethodQuery 查询线下收款方式字典。 +type PaymentMethodQuery struct { + db *gorm.DB +} + +// NewPaymentMethodQuery 创建线下收款方式字典查询。 +func NewPaymentMethodQuery(db *gorm.DB) *PaymentMethodQuery { + return &PaymentMethodQuery{db: db} +} + +// List 分页查询线下收款方式。 +// 超级管理员可查询全部字典项;其他后台账号仅返回启用项,保证停用方式不可用于新申请。 +func (q *PaymentMethodQuery) List( + ctx context.Context, + request dto.EmployeeCollectionPaymentMethodListRequest, +) (*dto.EmployeeCollectionPaymentMethodListResponse, error) { + if q == nil || q.db == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "线下收款方式查询尚未配置") + } + isSuperAdmin := middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin + if !isSuperAdmin && middleware.GetUserIDFromContext(ctx) == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + page, pageSize := normalizePage(request.Page, request.PageSize) + + query := q.db.WithContext(ctx).Model(&model.EmployeeCollectionPaymentMethod{}) + if !isSuperAdmin { + query = query.Where("status = ?", constants.EmployeeCollectionPaymentMethodStatusEnabled) + } else if request.Enabled != nil { + status := constants.EmployeeCollectionPaymentMethodStatusDisabled + if *request.Enabled { + status = constants.EmployeeCollectionPaymentMethodStatusEnabled + } + query = query.Where("status = ?", status) + } + if keyword := strings.TrimSpace(request.Keyword); keyword != "" { + pattern := "%" + keyword + "%" + query = query.Where("code LIKE ? OR name LIKE ?", pattern, pattern) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式总数失败") + } + var items []model.EmployeeCollectionPaymentMethod + if err := query.Order("sort_order ASC, id ASC"). + Offset((page - 1) * pageSize).Limit(pageSize). + Find(&items).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式列表失败") + } + list := make([]*dto.EmployeeCollectionPaymentMethodResponse, 0, len(items)) + for index := range items { + list = append(list, toPaymentMethodResponse(&items[index])) + } + return &dto.EmployeeCollectionPaymentMethodListResponse{ + List: list, Total: total, Page: page, PageSize: pageSize, + }, nil +} + +// toPaymentMethodResponse 将字典项投影为对外响应,只暴露既有对象键与稳定字段。 +func toPaymentMethodResponse(paymentMethod *model.EmployeeCollectionPaymentMethod) *dto.EmployeeCollectionPaymentMethodResponse { + 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, + } +} + +// normalizePage 归一化分页参数,执行 DefaultPageSize 与 MaxPageSize 上限。 +func normalizePage(page, pageSize int) (int, int) { + if page <= 0 { + page = constants.DefaultPage + } + if pageSize <= 0 { + pageSize = constants.DefaultPageSize + } + if pageSize > constants.MaxPageSize { + pageSize = constants.MaxPageSize + } + return page, pageSize +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index a6599d2..1e878ff 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -129,6 +129,11 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.PaymentMerchant != nil { registerPaymentMerchantRoutes(authGroup, handlers.PaymentMerchant, doc, basePath) } + if handlers.EmployeeCollection != nil { + registerEmployeeCollectionRoutes(authGroup, handlers.EmployeeCollection, doc, basePath) + registerEmployeeCollectionBillRoutes(authGroup, handlers.EmployeeCollection, doc, basePath) + registerEmployeeCollectionApplicationRoutes(authGroup, handlers.EmployeeCollection, doc, basePath) + } if handlers.AgentRecharge != nil { registerAgentRechargeRoutes(authGroup, handlers.AgentRecharge, doc, basePath) } diff --git a/internal/routes/employee_collection.go b/internal/routes/employee_collection.go new file mode 100644 index 0000000..6b0b664 --- /dev/null +++ b/internal/routes/employee_collection.go @@ -0,0 +1,144 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "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" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// requireEmployeeCollectionConfigAccess 限制线下收款方式字典维护仅超级管理员可用, +// 非超级管理员与其他不可见资源返回同一禁止访问错误,避免可枚举差异。 +func requireEmployeeCollectionConfigAccess(handler fiber.Handler) fiber.Handler { + return func(c *fiber.Ctx) error { + if middleware.GetUserTypeFromContext(c.UserContext()) != constants.UserTypeSuperAdmin { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return handler(c) + } +} + +func registerEmployeeCollectionRoutes(router fiber.Router, handler *admin.EmployeeCollectionHandler, doc *openapi.Generator, basePath string) { + paymentMethodPath := basePath + "/employee-collection-payment-methods" + wrap := requireEmployeeCollectionConfigAccess + + Register(router, doc, paymentMethodPath, "GET", "", handler.ListPaymentMethods, RouteSpec{ + Summary: "查询线下收款方式", + Description: "超级管理员可查询全部字典项;其他后台账号仅返回启用项,用于新核销申请选择收款方式。", + Tags: []string{"员工代收款"}, + Input: new(dto.EmployeeCollectionPaymentMethodListRequest), + Output: new(dto.EmployeeCollectionPaymentMethodListResponse), + Auth: true, + }) + Register(router, doc, paymentMethodPath, "POST", "", wrap(handler.CreatePaymentMethod), RouteSpec{ + Summary: "创建线下收款方式", + Description: "仅超级管理员。稳定编码在未删除记录中唯一。", + Tags: []string{"员工代收款"}, + Input: new(dto.CreateEmployeeCollectionPaymentMethodRequest), + Output: new(dto.EmployeeCollectionPaymentMethodResponse), + Auth: true, + }) + Register(router, doc, paymentMethodPath, "PUT", "/:id", wrap(handler.UpdatePaymentMethod), RouteSpec{ + Summary: "更新线下收款方式", + Description: "仅超级管理员。可修改名称、排序、启停与备注;已被核销申请引用时不允许修改稳定编码。", + Tags: []string{"员工代收款"}, + Input: new(dto.IDReq), + Body: new(dto.UpdateEmployeeCollectionPaymentMethodRequest), + Output: new(dto.EmployeeCollectionPaymentMethodResponse), + Auth: true, + }) + Register(router, doc, paymentMethodPath, "DELETE", "/:id", wrap(handler.DeletePaymentMethod), RouteSpec{ + Summary: "删除线下收款方式", + Description: "仅超级管理员。已被核销申请引用的字典项不可物理删除,只能停用。", + Tags: []string{"员工代收款"}, + Input: new(dto.IDReq), + Output: nil, + Auth: true, + }) +} + +// registerEmployeeCollectionBillRoutes 注册员工代收款账单查询、统计与关闭路由。 +// 账单可见性由应用层强制:欠款人仅见本人账单,超级管理员见全部。 +func registerEmployeeCollectionBillRoutes(router fiber.Router, handler *admin.EmployeeCollectionHandler, doc *openapi.Generator, basePath string) { + billPath := basePath + "/employee-collection-bills" + adminOnly := requireEmployeeCollectionConfigAccess + + Register(router, doc, billPath, "GET", "", handler.ListBills, RouteSpec{ + Summary: "查询员工代收款账单", + Description: "欠款人仅返回本人欠款账单,超级管理员返回全部;支持来源、状态、时间、欠款人、客户筛选与分页。", + Tags: []string{"员工代收款"}, + Input: new(dto.EmployeeCollectionBillListRequest), + Output: new(dto.EmployeeCollectionBillListResponse), + Auth: true, + }) + // 统计必须先于 /:id 注册,保证路径按字面量优先匹配。 + Register(router, doc, billPath, "GET", "/statistics", handler.StatisticsBills, RouteSpec{ + Summary: "查询员工代收款账单统计", + Description: "与账单列表使用相同筛选条件与可见性范围,返回应收、已核销、未核销金额合计与待处理账单数;不接受分页参数。", + Tags: []string{"员工代收款"}, + Input: new(dto.EmployeeCollectionBillStatisticsRequest), + Output: new(dto.EmployeeCollectionBillStatisticsResponse), + Auth: true, + }) + Register(router, doc, billPath, "GET", "/:id", handler.GetBill, RouteSpec{ + Summary: "查询员工代收款账单详情", + Description: "返回账单、来源退款冲销、分摊、核销申请与企业微信审批历史;附件只返回对象键引用。无权限与不存在返回同一错误。", + Tags: []string{"员工代收款"}, + Input: new(dto.IDReq), + Output: new(dto.EmployeeCollectionBillDetailResponse), + Auth: true, + }) + Register(router, doc, billPath, "POST", "/:id/close", adminOnly(handler.CloseBill), RouteSpec{ + Summary: "关闭员工代收款账单", + Description: "仅超级管理员。仅允许关闭待核销或部分核销且不存在审批中分摊的账单;关闭只作废未核销余额并保留已核销金额。", + Tags: []string{"员工代收款"}, + Input: new(dto.IDReq), + Body: new(dto.CloseEmployeeCollectionBillRequest), + Output: new(dto.EmployeeCollectionBillResponse), + Auth: true, + }) +} + +// registerEmployeeCollectionApplicationRoutes 注册核销申请的创建、重提与受控查询路由。 +// 可见性与代办权限由应用层强制:非超级管理员只能为本人账单操作,超级管理员代办必须填写原因。 +func registerEmployeeCollectionApplicationRoutes(router fiber.Router, handler *admin.EmployeeCollectionHandler, doc *openapi.Generator, basePath string) { + applicationPath := basePath + "/employee-collection-applications" + + Register(router, doc, applicationPath, "POST", "", handler.CreateApplication, RouteSpec{ + Summary: "创建核销申请", + Description: "员工为本人可见账单创建;超级管理员可为账单欠款人代办且必须填写 acting_reason。服务端按账单可核销余额与付款金额校验分摊,任一失败不保存申请与预占。", + Tags: []string{"员工代收款"}, + Input: new(dto.SubmitEmployeeCollectionApplicationRequest), + Output: new(dto.EmployeeCollectionApplicationSubmitResponse), + Auth: true, + }) + Register(router, doc, applicationPath, "GET", "", handler.ListApplications, RouteSpec{ + Summary: "查询核销申请", + Description: "非超级管理员仅返回本人申请,超级管理员返回全部;支持状态、收款方式、申请人与创建时间筛选和分页。", + Tags: []string{"员工代收款"}, + Input: new(dto.EmployeeCollectionApplicationListRequest), + Output: new(dto.EmployeeCollectionApplicationListResponse), + Auth: true, + }) + Register(router, doc, applicationPath, "GET", "/:id", handler.GetApplication, RouteSpec{ + Summary: "查询核销申请详情", + Description: "返回申请事实、账单分摊与全部审批尝试历史;附件只返回对象键引用。无权限与不存在返回同一错误。", + Tags: []string{"员工代收款"}, + Input: new(dto.IDReq), + Output: new(dto.EmployeeCollectionApplicationDetailResponse), + Auth: true, + }) + Register(router, doc, applicationPath, "PUT", "/:id", handler.ResubmitApplication, RouteSpec{ + Summary: "修改并重提核销申请", + Description: "仅申请人或被代办的超级管理员,且仅已驳回申请可修改;重提新增一条审批尝试记录与新的企业微信审批实例,历史材料不被覆盖。", + Tags: []string{"员工代收款"}, + Input: new(dto.IDReq), + Body: new(dto.SubmitEmployeeCollectionApplicationRequest), + Output: new(dto.EmployeeCollectionApplicationSubmitResponse), + Auth: true, + }) +} diff --git a/internal/service/agent_recharge/service.go b/internal/service/agent_recharge/service.go index df4cc8f..affb0b5 100644 --- a/internal/service/agent_recharge/service.go +++ b/internal/service/agent_recharge/service.go @@ -14,6 +14,7 @@ import ( "gorm.io/gorm" agentrechargeapp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" @@ -48,6 +49,12 @@ type Service struct { redis *redis.Client logger *zap.Logger rechargeAudit agentrechargeapp.RechargeAuditWriter + billCreation *employeecollectionapp.BillCreationService +} + +// SetEmployeeCollectionBillCreation 注入员工代收款账单建账用例。 +func (s *Service) SetEmployeeCollectionBillCreation(creation *employeecollectionapp.BillCreationService) { + s.billCreation = creation } // New 创建代理预充值服务实例 @@ -222,6 +229,13 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline if err != nil { return err } + // 员工代收款建账:人工确认入账与企微终审入账同属“线下充值入账成功”事实。 + if s.billCreation == nil { + return errors.New(errors.CodeInternalError, "员工代收款建账能力未配置") + } + if _, err := s.billCreation.CreateFromRechargeInTx(ctx, tx, record); err != nil { + return err + } return s.appendCreditedAudit(ctx, tx, record, nil, constants.RechargeStatusCompleted, "线下充值确认已入账") }) diff --git a/internal/service/order/service.go b/internal/service/order/service.go index d17f0ee..d40b1b7 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -11,8 +11,10 @@ import ( "time" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" + employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery" @@ -76,6 +78,12 @@ type Service struct { observationSeriesEvents cardObservationApp.SeriesEventWriter auditWriter *audit.Writer paymentIntegration *integrationlog.Repository + billCreation *employeecollectionapp.BillCreationService +} + +// SetEmployeeCollectionBillCreation 注入员工代收款账单建账用例。 +func (s *Service) SetEmployeeCollectionBillCreation(creation *employeecollectionapp.BillCreationService) { + s.billCreation = creation } // SetObservationSeriesEventWriter 注入购包成功观测序列 Outbox Writer。 @@ -221,9 +229,6 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde return nil, err } } - if req.PaymentMethod == model.PaymentMethodOffline && len(req.PaymentVoucherKey) == 0 { - return nil, errors.New(errors.CodeInvalidParam, "线下支付必须上传支付凭证") - } carrierType, carrierID := resolveAdminCarrierInfoFromVars(orderType, iotCardID, deviceID) existingOrderID, lockToken, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs) @@ -494,6 +499,14 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde } auditOrder = order + // 线下订单创建时付款凭证要求:会生成员工代收款账单的订单改由核销申请提供凭证, + // 其余既有线下订单保持原要求。判定复用建账判据,避免两处口径分叉。 + createsCollectionBill := employeecollectiondomain.ShouldCreateBillForOrder( + employeecollectiondomain.OrderBillSubjectFromOrder(order, containsGift)) + if req.PaymentMethod == model.PaymentMethodOffline && !createsCollectionBill && len(req.PaymentVoucherKey) == 0 { + return nil, errors.New(errors.CodeInvalidParam, "线下支付必须上传支付凭证") + } + // 线下支付订单写入支付凭证 file_key 列表 if req.PaymentMethod == model.PaymentMethodOffline { order.PaymentVoucherKey = model.StringJSONBArray(req.PaymentVoucherKey) @@ -510,7 +523,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde // 根据支付方式选择创建订单的方式 if req.PaymentMethod == model.PaymentMethodOffline { // 平台代购:创建订单并立即激活套餐 - if err := s.createOrderWithActivation(ctx, order, items); err != nil { + if err := s.createOrderWithActivation(ctx, order, items, containsGift); err != nil { return nil, err } s.markOrderCreated(ctx, idempotencyKey, order.ID) @@ -805,7 +818,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest // 根据支付方式选择创建订单的方式 if req.PaymentMethod == model.PaymentMethodOffline { // 平台代购:创建订单并立即激活套餐 - if err := s.createOrderWithActivation(ctx, order, items); err != nil { + if err := s.createOrderWithActivation(ctx, order, items, packageprice.ContainsGiftPackage(validationResult.Packages)); err != nil { return nil, err } s.markOrderCreated(ctx, idempotencyKey, order.ID) @@ -1215,12 +1228,20 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model return 0, nil } -func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Order, items []*model.OrderItem) error { +func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Order, items []*model.OrderItem, hasGiftPackage bool) error { + if s.billCreation == nil { + return errors.New(errors.CodeInternalError, "员工代收款建账能力未配置") + } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(order).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建代购订单失败") } + // 员工代收款建账:按来源唯一键 order:{id} 幂等,重复事务或重放返回既有账单。 + if _, err := s.billCreation.CreateFromOrderInTx(ctx, tx, order, hasGiftPackage); err != nil { + return err + } + for _, item := range items { item.OrderID = order.ID } diff --git a/internal/service/refund/approval_decision.go b/internal/service/refund/approval_decision.go index 38e41cf..235ff84 100644 --- a/internal/service/refund/approval_decision.go +++ b/internal/service/refund/approval_decision.go @@ -9,6 +9,7 @@ import ( "gorm.io/gorm/clause" approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/internal/model" @@ -101,6 +102,16 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T if err := s.refundWalletPayment(ctx, tx, &refund, &order, approvedAmount, event.SubmitterAccountID); err != nil { return err } + // 员工代收款冲销:接入点只在既有退款成功事务内,以 bill_id+refund_id 唯一事实幂等, + // 不依赖下方 changed 门;来源订单未建账时直接跳过,不阻断退款。 + if s.refundOffset == nil { + return errors.New(errors.CodeInternalError, "员工代收款退款冲销能力未配置") + } + if err := s.refundOffset.ApplyInTx(ctx, tx, employeecollectionapp.RefundOffsetSource{ + RefundID: refund.ID, OrderID: order.ID, RefundAmount: approvedAmount, + }); err != nil { + return err + } if err := s.appendCompletedNotification(ctx, tx, &refund); err != nil { return err } diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 5f24769..d4e80b7 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -15,6 +15,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification" refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" @@ -58,6 +59,12 @@ type Service struct { auditWriter *audit.Writer logger *zap.Logger paymentMerchantRuntime *merchantpayment.RuntimeLoader + refundOffset *employeecollectionapp.RefundOffsetService +} + +// SetEmployeeCollectionRefundOffset 注入员工代收款账单退款冲销用例。 +func (s *Service) SetEmployeeCollectionRefundOffset(offset *employeecollectionapp.RefundOffsetService) { + s.refundOffset = offset } // New 创建退款业务服务实例 diff --git a/migrations/000212_add_employee_collection_bills.down.sql b/migrations/000212_add_employee_collection_bills.down.sql new file mode 100644 index 0000000..240b2b5 --- /dev/null +++ b/migrations/000212_add_employee_collection_bills.down.sql @@ -0,0 +1,32 @@ +-- 回滚员工代收款账单闭环 Schema。 +-- 任一本 Change 的业务事实或新增审批场景配置存在时均禁止破坏性回滚: +-- 已产生的账单、申请、分摊、退款冲销事实无法通过重建 Schema 恢复。 + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM tb_employee_collection_bill) + OR EXISTS (SELECT 1 FROM tb_employee_collection_payment_method) + OR EXISTS (SELECT 1 FROM tb_employee_collection_application) + OR EXISTS (SELECT 1 FROM tb_employee_collection_application_attempt) + OR EXISTS (SELECT 1 FROM tb_employee_collection_application_allocation) + OR EXISTS (SELECT 1 FROM tb_employee_collection_bill_refund) THEN + RAISE EXCEPTION '存在员工代收款账单业务事实,禁止回滚员工代收款账单迁移'; + END IF; + IF EXISTS (SELECT 1 FROM tb_wecom_approval_scene WHERE business_type = 'employee_collection_approval') THEN + RAISE EXCEPTION '存在员工代收款核销审批场景配置,禁止回滚员工代收款账单迁移'; + END IF; +END $$; + +ALTER TABLE tb_wecom_approval_scene + DROP CONSTRAINT IF EXISTS chk_wecom_approval_scene_business; + +ALTER TABLE tb_wecom_approval_scene + ADD CONSTRAINT chk_wecom_approval_scene_business + CHECK (business_type IN ('refund_approval', 'offline_recharge_approval')); + +DROP TABLE IF EXISTS tb_employee_collection_bill_refund; +DROP TABLE IF EXISTS tb_employee_collection_application_allocation; +DROP TABLE IF EXISTS tb_employee_collection_application_attempt; +DROP TABLE IF EXISTS tb_employee_collection_application; +DROP TABLE IF EXISTS tb_employee_collection_bill; +DROP TABLE IF EXISTS tb_employee_collection_payment_method; diff --git a/migrations/000212_add_employee_collection_bills.up.sql b/migrations/000212_add_employee_collection_bills.up.sql new file mode 100644 index 0000000..64610ef --- /dev/null +++ b/migrations/000212_add_employee_collection_bills.up.sql @@ -0,0 +1,316 @@ +-- 员工代收款账单闭环:线下收款方式字典、账单、核销申请、审批尝试记录、分摊与退款冲销关联。 +-- 不使用数据库外键;关联以 ID 保存并由应用层显式校验。 +-- 建账只由来源成功事务携带的来源主键触发,来源唯一键兜底幂等,不提供历史扫描或补建路径。 + +-- 线下收款方式字典 +CREATE TABLE tb_employee_collection_payment_method ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(64) NOT NULL, + name VARCHAR(100) NOT NULL, + sort_order BIGINT NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 1, + remark VARCHAR(500) NOT NULL DEFAULT '', + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT chk_employee_collection_payment_method_code CHECK (code <> ''), + CONSTRAINT chk_employee_collection_payment_method_name CHECK (name <> ''), + CONSTRAINT chk_employee_collection_payment_method_status CHECK (status IN (0, 1)), + CONSTRAINT chk_employee_collection_payment_method_sort CHECK (sort_order >= 0) +); + +CREATE UNIQUE INDEX uk_employee_collection_payment_method_code + ON tb_employee_collection_payment_method (code) + WHERE deleted_at IS NULL; +CREATE INDEX idx_employee_collection_payment_method_status + ON tb_employee_collection_payment_method (status, sort_order, id) + WHERE deleted_at IS NULL; + +-- 员工代收款账单 +CREATE TABLE tb_employee_collection_bill ( + id BIGSERIAL PRIMARY KEY, + source_type VARCHAR(20) NOT NULL, + source_id BIGINT NOT NULL, + source_key VARCHAR(64) NOT NULL, + source_no VARCHAR(64) NOT NULL DEFAULT '', + debtor_account_id BIGINT NOT NULL, + debtor_snapshot JSONB NOT NULL, + customer_snapshot JSONB NOT NULL, + receivable_amount BIGINT NOT NULL, + received_amount BIGINT NOT NULL DEFAULT 0, + reserved_amount BIGINT NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 0, + closed_reason VARCHAR(500) NOT NULL DEFAULT '', + closed_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_employee_collection_bill_source UNIQUE (source_key), + CONSTRAINT chk_employee_collection_bill_source_type CHECK (source_type IN ('order', 'recharge')), + CONSTRAINT chk_employee_collection_bill_source CHECK (source_id > 0 AND source_key <> ''), + CONSTRAINT chk_employee_collection_bill_debtor CHECK (debtor_account_id > 0 AND jsonb_typeof(debtor_snapshot) = 'object'), + CONSTRAINT chk_employee_collection_bill_customer CHECK (jsonb_typeof(customer_snapshot) = 'object'), + CONSTRAINT chk_employee_collection_bill_receivable CHECK (receivable_amount > 0), + CONSTRAINT chk_employee_collection_bill_amounts CHECK ( + received_amount >= 0 AND reserved_amount >= 0 + AND received_amount + reserved_amount <= receivable_amount + ), + CONSTRAINT chk_employee_collection_bill_status CHECK (status IN (0, 1, 2, 3)) +); + +CREATE INDEX idx_employee_collection_bill_debtor + ON tb_employee_collection_bill (debtor_account_id, status, created_at, id); +CREATE INDEX idx_employee_collection_bill_status + ON tb_employee_collection_bill (status, created_at, id); +CREATE INDEX idx_employee_collection_bill_source + ON tb_employee_collection_bill (source_type, source_id); + +-- 核销申请 +CREATE TABLE tb_employee_collection_application ( + id BIGSERIAL PRIMARY KEY, + applicant_account_id BIGINT NOT NULL, + acting_operator_id BIGINT NOT NULL DEFAULT 0, + acting_reason VARCHAR(500) NOT NULL DEFAULT '', + payment_method_id BIGINT NOT NULL, + payment_method_code VARCHAR(64) NOT NULL, + payment_method_name VARCHAR(100) NOT NULL, + paid_amount BIGINT NOT NULL, + payer_name VARCHAR(100) NOT NULL DEFAULT '', + paid_at TIMESTAMPTZ NOT NULL, + external_transaction_no VARCHAR(128) NOT NULL DEFAULT '', + payment_voucher_keys JSONB NOT NULL, + remark VARCHAR(500) NOT NULL DEFAULT '', + status SMALLINT NOT NULL DEFAULT 0, + latest_attempt_id BIGINT NOT NULL DEFAULT 0, + latest_approval_instance_id BIGINT NOT NULL DEFAULT 0, + decided_at TIMESTAMPTZ, + terminal_reason VARCHAR(500) NOT NULL DEFAULT '', + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_employee_collection_application_applicant CHECK (applicant_account_id > 0), + CONSTRAINT chk_employee_collection_application_method CHECK ( + payment_method_id > 0 AND payment_method_code <> '' AND payment_method_name <> '' + ), + CONSTRAINT chk_employee_collection_application_amount CHECK (paid_amount > 0), + CONSTRAINT chk_employee_collection_application_vouchers CHECK ( + jsonb_typeof(payment_voucher_keys) = 'array' AND jsonb_array_length(payment_voucher_keys) >= 1 + ), + CONSTRAINT chk_employee_collection_application_acting CHECK ( + acting_operator_id = 0 OR acting_reason <> '' + ), + CONSTRAINT chk_employee_collection_application_status CHECK (status IN (0, 1, 2, 3)) +); + +CREATE INDEX idx_employee_collection_application_applicant + ON tb_employee_collection_application (applicant_account_id, status, id); +CREATE INDEX idx_employee_collection_application_status + ON tb_employee_collection_application (status, id); +CREATE INDEX idx_employee_collection_application_method + ON tb_employee_collection_application (payment_method_id, id); + +-- 审批尝试记录:同一申请的每次提交或重提各持有独立审批实例,历史材料不可覆盖 +CREATE TABLE tb_employee_collection_application_attempt ( + id BIGSERIAL PRIMARY KEY, + application_id BIGINT NOT NULL, + attempt_no INTEGER NOT NULL, + payment_method_id BIGINT NOT NULL, + payment_method_code VARCHAR(64) NOT NULL, + payment_method_name VARCHAR(100) NOT NULL, + paid_amount BIGINT NOT NULL, + payer_name VARCHAR(100) NOT NULL DEFAULT '', + paid_at TIMESTAMPTZ NOT NULL, + external_transaction_no VARCHAR(128) NOT NULL DEFAULT '', + payment_voucher_keys JSONB NOT NULL, + remark VARCHAR(500) NOT NULL DEFAULT '', + submitted_by_account_id BIGINT NOT NULL, + acting_reason VARCHAR(500) NOT NULL DEFAULT '', + allocation_snapshot JSONB NOT NULL, + approval_instance_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_employee_collection_attempt_no UNIQUE (application_id, attempt_no), + CONSTRAINT chk_employee_collection_attempt_application CHECK (application_id > 0), + CONSTRAINT chk_employee_collection_attempt_no_positive CHECK (attempt_no >= 1), + CONSTRAINT chk_employee_collection_attempt_amount CHECK (paid_amount > 0), + CONSTRAINT chk_employee_collection_attempt_submitter CHECK (submitted_by_account_id > 0), + CONSTRAINT chk_employee_collection_attempt_vouchers CHECK ( + jsonb_typeof(payment_voucher_keys) = 'array' AND jsonb_array_length(payment_voucher_keys) >= 1 + ), + CONSTRAINT chk_employee_collection_attempt_allocations CHECK ( + jsonb_typeof(allocation_snapshot) = 'array' AND jsonb_array_length(allocation_snapshot) >= 1 + ), + CONSTRAINT chk_employee_collection_attempt_instance CHECK ( + approval_instance_id IS NULL OR approval_instance_id > 0 + ) +); + +CREATE UNIQUE INDEX uk_employee_collection_attempt_instance + ON tb_employee_collection_application_attempt (approval_instance_id) + WHERE approval_instance_id IS NOT NULL; +CREATE INDEX idx_employee_collection_attempt_application + ON tb_employee_collection_application_attempt (application_id, attempt_no DESC); + +-- 申请—账单分摊:审批中预占,终态释放或转已核销 +CREATE TABLE tb_employee_collection_application_allocation ( + id BIGSERIAL PRIMARY KEY, + application_id BIGINT NOT NULL, + attempt_id BIGINT NOT NULL, + bill_id BIGINT NOT NULL, + amount BIGINT NOT NULL, + status SMALLINT NOT NULL DEFAULT 0, + released_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_employee_collection_allocation_attempt_bill UNIQUE (attempt_id, bill_id), + CONSTRAINT chk_employee_collection_allocation_amount CHECK (amount > 0), + CONSTRAINT chk_employee_collection_allocation_status CHECK (status IN (0, 1, 2)), + CONSTRAINT chk_employee_collection_allocation_released CHECK ((status = 0) = (released_at IS NULL)) +); + +CREATE INDEX idx_employee_collection_allocation_bill + ON tb_employee_collection_application_allocation (bill_id, status, id); +CREATE INDEX idx_employee_collection_allocation_application + ON tb_employee_collection_application_allocation (application_id, attempt_id, id); + +-- 退款冲销关联:同一退款对同一账单至多一条 +CREATE TABLE tb_employee_collection_bill_refund ( + id BIGSERIAL PRIMARY KEY, + bill_id BIGINT NOT NULL, + refund_id BIGINT NOT NULL, + source_order_id BIGINT NOT NULL, + refund_amount BIGINT NOT NULL, + bill_receivable_amount BIGINT NOT NULL, + outcome VARCHAR(20) NOT NULL, + reduced_amount BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_employee_collection_bill_refund UNIQUE (bill_id, refund_id), + CONSTRAINT chk_employee_collection_bill_refund_ids CHECK ( + bill_id > 0 AND refund_id > 0 AND source_order_id > 0 + ), + CONSTRAINT chk_employee_collection_bill_refund_amount CHECK ( + refund_amount > 0 AND bill_receivable_amount > 0 AND reduced_amount >= 0 + ), + CONSTRAINT chk_employee_collection_bill_refund_outcome CHECK ( + outcome IN ('closed_full', 'reduced', 'hint_only') + ), + CONSTRAINT chk_employee_collection_bill_refund_reduced CHECK ( + outcome <> 'reduced' OR reduced_amount > 0 + ) +); + +CREATE INDEX idx_employee_collection_bill_refund_bill + ON tb_employee_collection_bill_refund (bill_id, id); + +-- 扩展企微审批场景业务类型白名单,纳入员工代收款核销审批;既有两个场景语义不变。 +ALTER TABLE tb_wecom_approval_scene + DROP CONSTRAINT IF EXISTS chk_wecom_approval_scene_business; + +ALTER TABLE tb_wecom_approval_scene + ADD CONSTRAINT chk_wecom_approval_scene_business + CHECK (business_type IN ('refund_approval', 'offline_recharge_approval', 'employee_collection_approval')); + +COMMENT ON TABLE tb_employee_collection_payment_method IS '线下收款方式字典,被申请引用后只可停用'; +COMMENT ON COLUMN tb_employee_collection_payment_method.id IS '主键,收款方式ID'; +COMMENT ON COLUMN tb_employee_collection_payment_method.code IS '稳定编码,未删除记录中唯一,被引用后不可修改'; +COMMENT ON COLUMN tb_employee_collection_payment_method.name IS '收款方式名称'; +COMMENT ON COLUMN tb_employee_collection_payment_method.sort_order IS '排序值,从 0 递增,最小为 0'; +COMMENT ON COLUMN tb_employee_collection_payment_method.status IS '状态:0 禁用、1 启用'; +COMMENT ON COLUMN tb_employee_collection_payment_method.remark IS '备注'; +COMMENT ON COLUMN tb_employee_collection_payment_method.creator IS '创建人用户ID,0 表示系统'; +COMMENT ON COLUMN tb_employee_collection_payment_method.updater IS '最近更新人用户ID,0 表示系统'; +COMMENT ON COLUMN tb_employee_collection_payment_method.created_at IS '创建时间'; +COMMENT ON COLUMN tb_employee_collection_payment_method.updated_at IS '最近更新时间'; +COMMENT ON COLUMN tb_employee_collection_payment_method.deleted_at IS '软删除时间,非空表示已删除;被引用记录不删除只停用'; + +COMMENT ON TABLE tb_employee_collection_bill IS '员工代收款账单,按来源唯一键至多一张,只由来源成功事务创建'; +COMMENT ON COLUMN tb_employee_collection_bill.id IS '主键,账单ID'; +COMMENT ON COLUMN tb_employee_collection_bill.source_type IS '来源类型:order 后台线下套餐订单、recharge 代理线下充值'; +COMMENT ON COLUMN tb_employee_collection_bill.source_id IS '来源业务主键ID'; +COMMENT ON COLUMN tb_employee_collection_bill.source_key IS '来源唯一键 order:{id} / recharge:{id},全局唯一'; +COMMENT ON COLUMN tb_employee_collection_bill.source_no IS '来源单号快照'; +COMMENT ON COLUMN tb_employee_collection_bill.debtor_account_id IS '欠款人后台账号ID,创建后不可修改'; +COMMENT ON COLUMN tb_employee_collection_bill.debtor_snapshot IS '欠款人账号名称与归属快照'; +COMMENT ON COLUMN tb_employee_collection_bill.customer_snapshot IS '来源客户或店铺只读快照,不含敏感付款内容'; +COMMENT ON COLUMN tb_employee_collection_bill.receivable_amount IS '应收金额(单位:分)'; +COMMENT ON COLUMN tb_employee_collection_bill.received_amount IS '已核销金额(单位:分),仅企微最终通过的分摊增加'; +COMMENT ON COLUMN tb_employee_collection_bill.reserved_amount IS '审批中预占金额(单位:分),防止并发申请超额核销'; +COMMENT ON COLUMN tb_employee_collection_bill.status IS '状态:0 待核销、1 部分核销、2 已核销、3 已关闭'; +COMMENT ON COLUMN tb_employee_collection_bill.closed_reason IS '关闭原因,仅关闭时填写'; +COMMENT ON COLUMN tb_employee_collection_bill.closed_at IS '关闭时间,空表示未关闭'; +COMMENT ON COLUMN tb_employee_collection_bill.creator IS '创建人用户ID,0 表示系统'; +COMMENT ON COLUMN tb_employee_collection_bill.updater IS '最近更新人用户ID,0 表示系统'; +COMMENT ON COLUMN tb_employee_collection_bill.created_at IS '创建时间'; +COMMENT ON COLUMN tb_employee_collection_bill.updated_at IS '最近更新时间'; + +COMMENT ON TABLE tb_employee_collection_application IS '核销申请,按一笔外部付款一张申请,只保存最新审批实例ID'; +COMMENT ON COLUMN tb_employee_collection_application.id IS '主键,申请ID'; +COMMENT ON COLUMN tb_employee_collection_application.applicant_account_id IS '申请人后台账号ID'; +COMMENT ON COLUMN tb_employee_collection_application.acting_operator_id IS '实际代办的超级管理员账号ID,0 表示本人办理'; +COMMENT ON COLUMN tb_employee_collection_application.acting_reason IS '代办原因,代办时必填'; +COMMENT ON COLUMN tb_employee_collection_application.payment_method_id IS '线下收款方式字典ID'; +COMMENT ON COLUMN tb_employee_collection_application.payment_method_code IS '收款方式稳定编码快照'; +COMMENT ON COLUMN tb_employee_collection_application.payment_method_name IS '收款方式名称快照,字典改名不影响历史申请'; +COMMENT ON COLUMN tb_employee_collection_application.paid_amount IS '人工确认的付款金额(单位:分)'; +COMMENT ON COLUMN tb_employee_collection_application.payer_name IS '付款方名称'; +COMMENT ON COLUMN tb_employee_collection_application.paid_at IS '付款时间'; +COMMENT ON COLUMN tb_employee_collection_application.external_transaction_no IS '外部交易流水号,可由 OCR 预填但以人工确认值为准'; +COMMENT ON COLUMN tb_employee_collection_application.payment_voucher_keys IS '支付凭证对象存储 Key 列表(jsonb 存储 []string),只保存对象键引用'; +COMMENT ON COLUMN tb_employee_collection_application.remark IS '申请备注'; +COMMENT ON COLUMN tb_employee_collection_application.status IS '状态:0 审批中、1 已通过、2 已驳回、3 已撤销或已关闭'; +COMMENT ON COLUMN tb_employee_collection_application.latest_attempt_id IS '最新审批尝试记录ID,仅用于展示'; +COMMENT ON COLUMN tb_employee_collection_application.latest_approval_instance_id IS '最新通用审批实例ID,仅用于展示'; +COMMENT ON COLUMN tb_employee_collection_application.decided_at IS '审批终态到达时间'; +COMMENT ON COLUMN tb_employee_collection_application.terminal_reason IS '异常终态说明,如企微通过后撤销'; +COMMENT ON COLUMN tb_employee_collection_application.creator IS '创建人用户ID,0 表示系统'; +COMMENT ON COLUMN tb_employee_collection_application.updater IS '最近更新人用户ID,0 表示系统'; +COMMENT ON COLUMN tb_employee_collection_application.created_at IS '创建时间'; +COMMENT ON COLUMN tb_employee_collection_application.updated_at IS '最近更新时间'; + +COMMENT ON TABLE tb_employee_collection_application_attempt IS '核销申请审批尝试记录,每次提交或重提新增一条不可变材料'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.id IS '主键,审批尝试记录ID,同时作为通用审批业务ID'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.application_id IS '所属核销申请ID'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.attempt_no IS '第几次提交,从 1 递增,申请内唯一'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.payment_method_id IS '本次提交冻结的收款方式字典ID'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.payment_method_code IS '本次提交冻结的收款方式编码'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.payment_method_name IS '本次提交冻结的收款方式名称'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.paid_amount IS '本次提交冻结的付款金额(单位:分)'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.payer_name IS '本次提交冻结的付款方名称'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.paid_at IS '本次提交冻结的付款时间'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.external_transaction_no IS '本次提交冻结的外部交易流水号'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.payment_voucher_keys IS '本次提交冻结的支付凭证对象键列表'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.remark IS '本次提交冻结的备注'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.submitted_by_account_id IS '本次实际提交账号ID,代办时为超级管理员'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.acting_reason IS '本次代办原因,非代办为空'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.allocation_snapshot IS '本次提交冻结的账单分摊快照(账单摘要与金额)'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.approval_instance_id IS '本次尝试关联的通用审批实例ID,创建后不可修改'; +COMMENT ON COLUMN tb_employee_collection_application_attempt.created_at IS '创建时间'; + +COMMENT ON TABLE tb_employee_collection_application_allocation IS '核销申请对单张账单的本次分摊,审批中预占、终态释放或转已核销'; +COMMENT ON COLUMN tb_employee_collection_application_allocation.id IS '主键,分摊ID'; +COMMENT ON COLUMN tb_employee_collection_application_allocation.application_id IS '所属核销申请ID'; +COMMENT ON COLUMN tb_employee_collection_application_allocation.attempt_id IS '所属审批尝试记录ID,审批中预占按此次尝试计算'; +COMMENT ON COLUMN tb_employee_collection_application_allocation.bill_id IS '目标账单ID,按账单ID升序加锁'; +COMMENT ON COLUMN tb_employee_collection_application_allocation.amount IS '本次分摊金额(单位:分),必须大于 0'; +COMMENT ON COLUMN tb_employee_collection_application_allocation.status IS '状态:0 审批中预占、1 已通过转已核销、2 已驳回或已释放'; +COMMENT ON COLUMN tb_employee_collection_application_allocation.released_at IS '预占释放时间,审批中为空'; +COMMENT ON COLUMN tb_employee_collection_application_allocation.creator IS '创建人用户ID'; +COMMENT ON COLUMN tb_employee_collection_application_allocation.updater IS '最近更新人用户ID'; +COMMENT ON COLUMN tb_employee_collection_application_allocation.created_at IS '创建时间'; +COMMENT ON COLUMN tb_employee_collection_application_allocation.updated_at IS '最近更新时间'; + +COMMENT ON TABLE tb_employee_collection_bill_refund IS '来源订单退款与账单的冲销关联,同一退款对同一账单至多一条'; +COMMENT ON COLUMN tb_employee_collection_bill_refund.id IS '主键'; +COMMENT ON COLUMN tb_employee_collection_bill_refund.bill_id IS '目标账单ID'; +COMMENT ON COLUMN tb_employee_collection_bill_refund.refund_id IS '退款申请ID'; +COMMENT ON COLUMN tb_employee_collection_bill_refund.source_order_id IS '账单来源订单ID'; +COMMENT ON COLUMN tb_employee_collection_bill_refund.refund_amount IS '本次退款成功金额(单位:分)'; +COMMENT ON COLUMN tb_employee_collection_bill_refund.bill_receivable_amount IS '冲销前的账单应收金额快照(单位:分)'; +COMMENT ON COLUMN tb_employee_collection_bill_refund.outcome IS '处理结果:closed_full 全额关闭、reduced 部分冲减、hint_only 仅退款关联提示'; +COMMENT ON COLUMN tb_employee_collection_bill_refund.reduced_amount IS '实际冲减的应收金额(单位:分),仅 reduced 时大于 0'; +COMMENT ON COLUMN tb_employee_collection_bill_refund.created_at IS '创建时间'; diff --git a/openspec/changes/add-employee-collection-bills/design.md b/openspec/changes/add-employee-collection-bills/design.md index fd588ae..b967e7c 100644 --- a/openspec/changes/add-employee-collection-bills/design.md +++ b/openspec/changes/add-employee-collection-bills/design.md @@ -2,9 +2,11 @@ 见 `proposal.md` 与 `specs/employee-collection-bill/spec.md`。现有后台套餐订单、代理充值、企业微信审批和审计已有各自业务事实,但没有将“后台账号代客户经办后的公司应收”作为独立对象保存。现有 `tb_agent_recharge_record` 已有金额、支付凭证和审批实例关联;套餐订单已有 `actual_paid_amount`。这些来源只能提供已确定的金额和关联键,不能被新功能改写。 -本设计只处理上线后事件。线下付款并非本系统支付渠道事实,企业微信审批人员以第三方记录核验,因此本地只留存申请人声明、附件、冻结快照和企业微信最终结果。 +本设计只处理上线后事件,且不新增任何运行时开关或配置来表达上线切割点:建账只允许由“本次来源成功事务”携带的来源主键触发,禁止任何按历史订单或充值扫描、补建的代码路径;来源唯一键 `order:{id}` / `recharge:{id}` 兜底幂等。新表对既有旧代码无影响,单批发布即可。 -术语边界:员工代收款账单是后台账号代客户经办业务形成的本地暂挂欠款,不等同于来源订单、客户付款或企业微信审批单。核销申请对应一笔外部付款及一次企业微信审批实例;核销分摊是该申请对单张账单确认的本次金额,账单核销状态与申请审批状态独立保存。企业微信审批实例仅是本地业务单的外部审批渠道生命周期事实;外部交易流水号可由 OCR 预填,但必须经人工确认。线下收款方式是固定注册分类下的业务字典项,不等同于线上支付方式枚举或收款账户目录;已被引用的字典项只可停用并保留历史名称快照。 +线下付款并非本系统支付渠道事实,企业微信审批人员以第三方记录核验,因此本地只留存申请人声明、附件、冻结快照和企业微信最终结果。 + +术语边界:员工代收款账单是后台账号代客户经办业务形成的本地暂挂欠款,不等同于来源订单、客户付款或企业微信审批单。核销申请对应一笔外部付款及一次企业微信审批实例;核销分摊是该申请对单张账单确认的本次金额,账单核销状态与申请审批状态独立保存。审批尝试记录是同一核销申请每次提交或重提对应的不可变审批材料与实例关联事实;申请行只保存最新审批实例 ID 用于展示。企业微信审批实例仅是本地业务单的外部审批渠道生命周期事实;外部交易流水号可由 OCR 预填,但必须经人工确认。线下收款方式是固定注册分类下的业务字典项,不等同于线上支付方式枚举或收款账户目录;已被引用的字典项只可停用并保留历史名称快照。 ## Goals / Non-Goals @@ -17,6 +19,9 @@ - 不建设公司收款账户目录,不接入或改造 OCR 契约,不校验同一外部付款在不同申请间的累计分摊。 - 不回填历史业务,不允许“其他”来源手工建账,不处理平台代理 C 端资产钱包充值。 - 不以账单功能重构订单、代理充值或企业微信通用审批模块。 +- 不引入“财务”角色,也不启用 `RequirePermission` 路由鉴权体系;本期可见性只区分欠款人本人与超级管理员,财务独立可见性作为后续权限 Change 的待办。 +- 不新增导出;本期只提供与账单列表同筛选、同数据范围的统计聚合。 +- 不实施 AUG26-017 的线下充值材料字段,也不修改企业微信 `offline_recharge_approval` 场景业务字段。 @@ -24,7 +29,7 @@ ### 1. 账单、申请、分摊分表保存 -建立员工代收款账单、核销申请、核销分摊和线下收款方式字典四类事实。账单绑定唯一来源业务;申请绑定一次外部付款和一个企业微信审批实例;分摊连接申请与账单并冻结账单来源摘要。附件复用现有对象存储键/附件模式,审批快照复用既有通用审批上下文能力。 +建立员工代收款账单、核销申请、核销分摊、审批尝试记录和线下收款方式字典事实。账单绑定唯一来源业务;申请绑定一次外部付款,并保存最新企业微信审批实例 ID;每次提交或重提新增一条审批尝试记录并创建独立审批实例;分摊连接申请与账单并冻结账单来源摘要。附件复用现有对象存储键/附件模式,审批提交与渠道上下文复用既有通用审批能力。 不把多笔付款、账单状态或附件塞入来源订单 JSON:来源订单既有生命周期不等于员工欠款,且一笔付款对多账单是独立关系。 @@ -34,19 +39,36 @@ 不使用乐观展示余额或仅在回调时校验;那会让并发审批中申请超额占用同一账单。 -### 3. 企业微信审批作为唯一状态推进器 +### 3. 企业微信审批作为唯一状态推进器,重提新增审批尝试记录 -申请提交事务只写本地申请、冻结快照、审批实例及可靠提交请求。审批回调和既有兜底查询都进入同一个幂等消费用例:通过才计入账单已核销,驳回才释放预占。提交失败或渠道未知保持在途,禁止本地财务人工改审批结果。 +申请提交事务只写本地申请、冻结快照、审批尝试记录、审批实例及可靠提交请求。审批回调和既有兜底查询都进入同一个幂等消费用例:通过才计入账单已核销,驳回才释放预占。提交失败或渠道未知保持在途,禁止本地财务人工改审批结果。 -已驳回“重提”保留原申请主键,但新增一次审批实例及当次不可变快照;已通过分摊永不更新。这样列表可以按申请聚合,审计仍能回放每次审批。 +新增企业微信业务类型 `employee_collection_approval`,其 `business_id` 取本 Change 的**审批尝试记录主键**,申请行只保存最新审批实例 ID 用于展示。因此每次提交或重提都会新增一条审批尝试记录和一个审批实例,历史实例、材料与结果不被覆盖,列表按申请聚合、审计可回放每次审批。既有 `refund_approval`、`offline_recharge_approval` 两场景继续“一业务单一个审批实例”的语义,**不修改共享的唯一约束**。 -### 4. 来源事件采用幂等 Outbox/消费者 +企业微信“通过后撤销”不回滚已核销金额:申请转异常终态、保留审计、详情提示、禁止自动重提,与既有退款链路对 `revoked_after_approved` 的处理一致。 -线下套餐订单创建成功和代理充值审批入账完成后,在各自成功事务中写唯一来源事件或直接以唯一来源约束创建账单;选择以现有 Outbox 可用模式为准。消费者以来源类型+来源 ID 唯一约束去重。订单创建不得等待企业微信或外部付款;代理充值必须以“已通过且已入账”这一既有终态作为来源。 +### 4. 建账在来源成功事务内直建,不新增来源事件 -### 5. 退款只自动影响未存在已通过分摊的账单 +两类来源都在**各自成功事务内直接创建账单**,以来源唯一键去重,不新增来源 Outbox 事件类型、不引入消费者:建账只能由“本次来源成功事务”携带的来源主键触发,不存在按历史记录扫描或补建的路径。 -退款处理在退款成功业务事务中查找来源账单并锁定。无已通过分摊时写冲销/关闭事实及更新金额;有已通过分摊时不动账,只写可追溯关联提示。这避免已由企业微信核验的员工欠款被退款回调静默重建或冲销。 +- 后台线下套餐订单:建账判据为 `payment_method = offline` ∧ `operator_account_type = platform` ∧ 订单不含赠送套餐 ∧ `actual_paid_amount > 0`;`actual_paid_amount ≤ 0` 不建账,避免 0 元账单立即成为已核销。建账点位于订单激活事务内、`tx.Create(order)` 之后,来源唯一键 `order:{id}`。创建时付款凭证的放宽范围与该判据**严格互补**:建账订单不再强制创建时凭证,赠送等不建账订单保持既有凭证要求。 +- 代理线下预存款/主钱包充值:锚点为“平台账号发起的线下充值入账成功”这一既有事实,金额取充值记录的 `amount`,来源唯一键 `recharge:{id}`。该事实不依赖具体申请表结构,也不使用申请表未落库的新字段;既有企业微信终审通过入账与既有线下充值人工确认入账都产生该事实,因此两条入账入口完成入账时都建账。线上充值、审批未通过或未完成入账不建账。 + +订单创建不得等待企业微信或外部付款。重复订单事务、重复回调、重放或消费者重试都返回同一账单,不重复建账。该判据是订单建账的**唯一共享事实来源**,因此后台线下套餐订单与 H5 平台代购 offline 入口(同为平台账号操作、`actual_paid_amount > 0`)走同一判据建账,不因入口不同产生差异。 + +### 5. 退款只自动冲销不存在已通过或审批中分摊的账单 + +退款处理只在既有的**退款成功事务**内查找来源账单并锁定:接入点是当前已存在的退款成功处理链路,不实现 AUG26-006 的退款审批、状态机或渠道退款。 + +全额与部分以“**本次退款成功金额 vs 账单应收**”判定,账单应收即来源订单的 `actual_paid_amount`: + +- 本次退款成功金额等于或超过账单应收且账单不存在已通过分摊 → 关闭账单,原因记为来源订单全额退款; +- 本次退款成功金额小于账单应收且账单不存在已通过分摊 → 按退款金额冲减账单应收; +- 账单存在任一**已通过分摊**或任一**审批中(预占)分摊** → 不自动冲销,只在账单详情写退款关联提示。 + +把审批中分摊与已通过分摊同等对待,是因为预占余额可能大于冲减后的剩余应收,从而在审批通过后产生 `已核销 > 应收`。冲销写入必须自带唯一幂等事实(`bill_id + refund_id` 唯一),不得依赖退款成功事务的 `changed` 标志——该事务在重复投递时仍会执行资金写入。 + +本期冲销**只由企业微信审批通过的退款成功事务触发**。既有 legacy 人工退款审批通过入口(`internal/service/refund/service.go` 的 `Approve`,受默认开启的 `Approval.LegacyRefundManualEnabled` 开关控制)不在本期接入范围:该入口对携带审批实例的退款直接拒绝,仅历史上无审批实例的存量待审批退款可达,本 Change 上线后新产生的来源订单退款不可达该路径。这是**已登记的缺口**,若后续要求该入口一致冲销,应单独作为后续 Change 处理。 ## 业务动作契约 @@ -54,45 +76,55 @@ ### 收款方式字典维护 +- `GET /employee-collection-payment-methods`:登录后台账号均可读取。超级管理员返回全部字典项并支持按启停状态与编码/名称关键字筛选;其他后台账号仅返回启用项,用于新核销申请选择收款方式。分页返回 ID、稳定编码、名称、启用状态、排序、备注与创建/更新时间。 - `POST /employee-collection-payment-methods`:仅超级管理员。请求包含 `code`(1~64 字符、全局唯一)、`name`(1~100 字符)、`sort`(非负整数)、`enabled`、`remark`(最多 500 字符)。创建后返回字典 ID、字段值和创建时间。 - `PUT /employee-collection-payment-methods/:id`:仅超级管理员;不得修改已引用项的 `code`,可修改名称、排序、启停和备注。不存在返回既有“资源不存在”错误;重复编码返回稳定“收款方式编码已存在”错误。 -- `DELETE /employee-collection-payment-methods/:id`:仅未被申请引用的项可物理删除;已引用返回“收款方式已被引用,只能停用”。每个成功写操作记录操作者和前后快照。 +- `DELETE /employee-collection-payment-methods/:id`:仅未被申请引用的项可删除;已引用返回“收款方式已被引用,只能停用”。每个成功写操作记录操作者和前后快照。 +- 未引用项的删除以 `deleted_at` 软删除实现(`gorm.Model.DeletedAt`),对外语义与物理删除一致:列表与详情不可见、编码可被后续创建复用;保留软删除只为保住审计快照的可追溯性,不构成对外行为差异。 ### 账单查询与关闭 -- `GET /employee-collection-bills`:员工强制加 `debtor_account_id=当前账号`;财务、超级管理员按既有数据范围过滤。支持来源类型、来源单号、账单状态、欠款人、客户/店铺、创建时间范围筛选和分页。每行返回账单 ID、来源摘要、欠款人快照、应收、已核销、预占、剩余、状态和创建时间。 -- `GET /employee-collection-bills/:id`:在同一数据范围校验后返回账单、来源摘要、退款冲销、分摊、申请与审批历史;附件只返回既有授权下载所需的安全引用,不返回对象存储敏感内容。 +- `GET /employee-collection-bills`:欠款人本人强制加 `debtor_account_id=当前账号`;超级管理员查询全部。支持来源类型、来源单号、账单状态、欠款人、客户/店铺、创建时间范围筛选和分页。每行返回账单 ID、来源摘要、欠款人快照、应收、已核销、预占、剩余、状态和创建时间。 +- `GET /employee-collection-bills/statistics`:与列表相同的筛选条件与数据范围,返回应收、已核销、未核销金额合计和待处理账单数。 +- `GET /employee-collection-bills/:id`:在同一可见性校验后返回账单、来源摘要、退款冲销、分摊、申请与审批历史;附件只返回既有对象键引用并经既有预签名下载能力访问,不返回对象存储敏感内容,也不承诺资源级鉴权。 - `POST /employee-collection-bills/:id/close`:仅超级管理员;请求 `reason` 必填、最长 500 字符。事务中锁定账单,存在审批中申请返回“账单存在审批中核销申请,不能关闭”;已关闭返回既有状态冲突;成功时仅作废未核销余额并写关闭审计。 ### 核销申请创建、修改与重提 -- `POST /employee-collection-applications`:员工为本人可见账单创建,超级管理员可代办但请求必须附 `acting_reason`(1~500 字符)。请求包含 `payment_method_id`、`paid_amount`(正分)、`payer_name`、`paid_at`(带时区 RFC3339 时间)、`external_transaction_no`、`payment_voucher_keys`(1~5 个既有附件键)、`remark`、`allocations[]`;每个分摊包含 `bill_id` 和正的 `amount`。 -- 服务按账单 ID 升序锁定,校验字典启用、账单可见且未关闭、分摊不超过该账单 `应收-已核销-其他审批中预占`、分摊总额不超过 `paid_amount`。成功返回申请 ID、状态 `审批中`、审批实例 ID、冻结快照与各分摊;任一校验失败时不保存申请、分摊或预占。 -- `PUT /employee-collection-applications/:id`:仅申请人或代办超级管理员,且仅已驳回申请可修改;入参同创建。事务释放旧驳回版本无预占事实,重新锁定和校验账单,保存新的不可变材料快照并创建新的企业微信审批实例。已通过、审批中、已撤销/关闭状态返回状态冲突。 +- `POST /employee-collection-applications`:员工为本人可见账单创建,超级管理员可代办但请求必须附 `acting_reason`(1~500 字符)。请求包含 `payment_method_id`、`paid_amount`(正分)、`payer_name`、`paid_at`(带时区 RFC3339 时间)、`external_transaction_no`、`payment_voucher_keys`(1~5 个既有附件键)、`remark`、`allocations[]`;每个分摊包含 `bill_id` 和正的 `amount`。申请人按所选账单确定:本人办理时固定为当前账号且所选账单必须全部属于该账号;超级管理员代办时申请人为所选账单的唯一欠款人,所选账单必须同属该欠款人,代办人与原因分别记录在 `acting_operator_id` 与 `acting_reason`。 +- 服务按账单 ID 升序锁定,校验字典启用、账单可见且未关闭、分摊不超过该账单 `应收-已核销-其他审批中预占`、分摊总额不超过 `paid_amount`。成功时新增一条不可变审批尝试记录(冻结当次收款方式、外部付款、附件、备注与分摊快照)并创建对应企业微信审批实例,申请只保存最新审批实例 ID;返回申请 ID、状态 `审批中`、审批实例 ID、冻结快照与各分摊。任一校验失败时不保存申请、分摊或预占。 +- 分摊金额由申请人在提交前填写:界面可按账单产生时间从早到晚用本次付款金额预填、最后一张填入剩余金额,但服务端只接受客户端提交的 `allocations[]` 并按其校验,越界分摊一律拒绝;服务端不重新计算或改写申请人提交的分摊金额。 +- `PUT /employee-collection-applications/:id`:仅申请人或代办超级管理员,且仅已驳回申请可修改;入参同创建。事务释放旧驳回版本无预占事实,重新锁定和校验账单,新增一条审批尝试记录与一个新的企业微信审批实例,历史尝试、材料与结果不被覆盖。已通过、审批中、已撤销/关闭状态返回状态冲突。 ### 企业微信审批结果消费 - 企业微信回调和既有状态恢复任务均按审批实例 ID 进入同一应用用例,不提供后台“通过/驳回”接口。 - 最终通过:锁定申请及按 ID 升序的全部账单;仅当申请仍为审批中时,将每笔分摊从预占转入已核销,重新计算账单 `待核销/部分核销/已核销` 状态,标记申请已通过,并记录审批结果。重复或乱序的同一终态不重复增加已核销金额。 - 最终驳回:仅当申请仍为审批中时释放全部预占,标记已驳回并保存审批意见;重复回调不重复释放。提交失败、回调延迟和未知结果维持在途,由既有查询恢复任务确认,不得人工改写终态。 +- 通过后撤销:已通过后收到企业微信撤销时**不回滚已核销金额**;申请转异常终态、保留审计、账单详情提示来源申请异常,并禁止自动重提。 ### 来源建账与退款冲销 -- 后台线下套餐订单成功提交后,以 `order.id`、`operator_account_id`、`operator_account_type=platform` 和非空 `actual_paid_amount` 判定建账;来源唯一键为 `order:{id}`。重复订单事务、可靠事件重放或消费者重试均返回同一账单,不重复建账。 -- 代理线下充值仅在既有审批最终通过且钱包入账完成后,以 `agent_recharge.id` 为唯一来源建账;线上充值、审批未通过或未完成入账不建账。 -- 套餐退款成功时锁定来源账单:无已通过分摊的全额退款关闭账单;无已通过分摊的部分退款冲减应收;存在已通过分摊时只新增退款关联提示,不修改应收、已核销或员工欠款。 +- 后台线下套餐订单在成功事务内、`tx.Create(order)` 之后按决策 4 的判据建账:以实际操作的平台账号为欠款人、`actual_paid_amount` 为应收、`order:{id}` 为来源唯一键。重复订单事务、重放或重试均返回同一账单;不存在按历史订单扫描建账的路径;创建时付款凭证的放宽与该判据严格互补。 +- 代理线下充值在“平台账号发起的线下充值入账成功”事实达成时、于同一入账事务内建账,以充值记录 `amount` 为应收、`recharge:{id}` 为来源唯一键,欠款人为发起充值的平台账号;既有企业微信终审通过入账与既有线下充值人工确认入账两条入口都建账,线上充值、审批未通过或未完成入账不建账;不存在按历史充值扫描建账的路径。 +- 套餐退款成功时按决策 5 的口径锁定来源账单并冲销或仅提示;冲销以 `bill_id + refund_id` 唯一事实幂等,重复投递不二次冲减;接入点只在既有退款成功处理事务内,不实现 AUG26-006 的退款审批、状态机或渠道退款。 ## Risks / Trade-offs -- [企业微信回调重复、乱序或未知] → 以审批实例、申请状态和分摊状态条件更新幂等消费,复用渠道查询恢复。 -- [外部付款敏感信息泄露] → 附件使用对象键和既有授权访问;日志/审计只记录脱敏摘要与业务 ID。 -- [来源事件与账单创建不一致] → 在来源成功事务写可靠事件,消费者以唯一来源约束重放。 +- [企业微信回调重复、乱序、未知或通过后撤销] → 以审批实例、审批尝试记录、申请状态和分摊状态条件更新幂等消费,复用渠道查询恢复;通过后撤销不回滚已核销,转异常终态并禁止自动重提。 +- [外部付款敏感信息泄露] → 附件只保存既有对象键引用并经既有预签名下载访问,不承诺资源级鉴权;日志、审计和错误只记录脱敏摘要与业务 ID。 +- [建账与来源事务不一致] → 在来源成功事务内直建账单,来源唯一键 `order:{id}` / `recharge:{id}` 兜底;不新增来源事件与消费者,也不存在历史扫描路径。 +- [既有 legacy 线下充值人工确认入账路径] → 该路径(默认开启的兼容入口)同样产生“入账成功”事实并按本设计建账,避免员工欠款漏记;本 Change 不改该开关、不改该入口,开关是否关闭属运维独立决策。 - [超额核销] → 提交、重提和审批通过均锁定账单并校验预占余额。 -- [退款与核销并发] → 退款和审批消费按相同账单锁顺序串行,已通过分摊优先保留。 +- [退款与核销并发] → 退款冲销与审批消费按相同账单锁顺序串行;存在已通过或审批中分摊时一律不自动冲销,只写退款关联提示。 +- [权限口径缺失] → 既有系统无“财务”角色且未启用 `RequirePermission` 路由鉴权,本期只区分欠款人本人与超级管理员;财务独立可见性留给后续权限 Change。 +- [重提与审批核心唯一约束] → 新业务类型以审批尝试记录主键作为 `business_id`,不改共享唯一约束,既有两个审批场景语义不变。 +- [财务审计时间线未纳入新业务类型] → `internal/query/audit/finance.go` 的 `expandApprovals` 与 `approvalBusinessRefs` 的 `business_type` 分支、以及资金资源类型清单均未包含 `employee_collection_approval`,财务调查视图不会为该场景展开业务关联(不报错,仅缺失跳转)。spec 未要求,**不属本期范围**,登记为后续 Change 待办,不在本次改动内。 ## Migration Plan -1. 新增成对迁移创建字典、账单、申请、分摊、审批快照/冲销关联所需表、唯一约束和查询索引;不修改既有迁移。 -2. 先部署可读新表和来源事件的兼容代码,再启用账单生产与核销入口;上线时间作为历史切割点写受控配置或迁移基准。 -3. 在隔离环境验证上线前订单/充值不建账、重复事件不重复建账、并发预占、通过/驳回/重提、退款联动及迁移 up/down/up。 -4. 回滚时先停止新入口和事件消费;已有账单事实保留,只有维护者确认未产生不可逆业务数据时才执行 down。 \ No newline at end of file +1. 新增成对迁移(`.up.sql`/`.down.sql`)创建字典、账单、申请、分摊、审批尝试记录与退款冲销关联所需表、唯一约束和查询索引;不修改既有迁移,也不预占迁移编号——实施开始前按当时 `migrations/` 目录的最大编号顺延。 +2. 迁移扩展 `tb_wecom_approval_scene` 的 `business_type` CHECK 以纳入 `employee_collection_approval`(同样以新迁移扩展,不修改既有迁移)。施工时列全四处注册点:业务类型常量、`validApprovalBusinessType` 与 `sceneBusinessFields`、数据库 CHECK、`cmd/worker/main.go` 的审批决策消费者注册。 +3. 单批发布即可:新表对既有旧代码无影响,也不存在需要开关或基准数据表达的上线切割点;建账只由部署后发生的来源成功事务触发。 +4. 按 ENG-TEST-001 在维护者指定的 `junhong_cmp_test` PostgreSQL + Redis DB 6 验证:迁移从本地工作区以显式 `DB_*` 执行 `scripts/migrate.sh`,只创建、删除本 Change 自己的 fixture,禁止重置整库;仅连接、迁移或实际行为失败时才阻塞对应场景。验证项:不存在按历史记录扫描或补建的路径、重复消费来源不重复建账、并发预占、通过/驳回/重提/通过后撤销、退款三类联动、迁移 up/down/up。 +5. 回滚时先停止新入口;已有账单事实保留,只有维护者确认未产生不可逆业务数据时才执行 down。 \ No newline at end of file diff --git a/openspec/changes/add-employee-collection-bills/proposal.md b/openspec/changes/add-employee-collection-bills/proposal.md index 2db5776..e9833bb 100644 --- a/openspec/changes/add-employee-collection-bills/proposal.md +++ b/openspec/changes/add-employee-collection-bills/proposal.md @@ -6,10 +6,10 @@ ## What Changes -- 新增员工代收款账单:后台线下套餐订单创建成功后,或代理线下预存款/主钱包充值经企业微信审批通过并完成入账后,按确定金额为实际经办账号创建唯一账单。 +- 新增员工代收款账单:后台线下套餐订单创建成功后,或代理线下预存款/主钱包充值完成入账后,按确定金额为实际经办账号创建唯一账单。线下充值以“平台账号发起的线下充值入账成功”这一既有事实为锚点,既有企业微信终审通过入账与既有线下充值人工确认入账均适用;建账只由来源成功事务触发,不扫描或补建历史记录。 - 新增核销申请和账单分摊:员工按一笔外部付款创建一张申请,可选择多张账单并填写各自分摊金额;同一账单可由多笔已通过申请分次核销。 -- 新增固定分类的线下收款方式字典;申请冻结字典名称、外部付款、附件、账单分摊和审批快照。OCR 仅可预填流水号和付款金额,人工确认值才是业务事实。 -- 核销申请仅由企业微信最终通过或驳回驱动;提交失败、回调延迟或结果未知复用既有审批查询/恢复闭环,禁止本地人工绕过终审。 +- 新增固定分类的线下收款方式字典;申请冻结字典名称、外部付款、附件、账单分摊,每次提交或重提另存一条审批尝试记录。OCR 仅可预填流水号和付款金额,人工确认值才是业务事实。 +- 核销申请仅由企业微信最终结果驱动(通过、驳回、通过后撤销);提交失败、回调延迟或结果未知复用既有审批查询/恢复闭环,禁止本地人工绕过终审,通过后撤销不回滚已核销金额。 - 新增账单、核销申请、分摊、附件与审批记录的权限受控查询;超级管理员关闭未结清账单、来源订单退款时的账单冲销均保留审计。 - **BREAKING**:会生成员工账单的后台线下套餐订单不再在创建时强制上传付款凭证;凭证和外部付款信息改为核销申请必填。赠送套餐等不生成账单的既有线下订单继续保持原凭证要求。 @@ -17,7 +17,7 @@ ### New Capabilities -- `employee-collection-bill`: 员工代收款账单、线下收款方式、分摊核销申请、企业微信审批闭环、退款冲销和财务查询。 +- `employee-collection-bill`: 员工代收款账单、线下收款方式、分摊核销申请、企业微信审批闭环、退款冲销和受控查询。 ### Modified Capabilities @@ -25,7 +25,9 @@ ## Impact -- 数据:新增账单、收款方式字典、核销申请、分摊和审批快照等表;订单/充值来源只保存可追溯关联,不回填历史记录。 +- 数据:新增账单、收款方式字典、核销申请、分摊、审批尝试记录和退款冲销关联等表;订单/充值来源只保存可追溯关联,不回填历史记录。 - 写侧:后台线下套餐下单、代理线下充值入账、核销提交/重提/关闭、企业微信审批结果消费、套餐退款。 -- 读取:财务账单与申请列表、详情、导出;员工仅看本人,财务/超级管理员按既有数据范围看全部。 +- 读取:账单与申请列表、详情,以及与列表同筛选、同数据范围的统计聚合;欠款人仅看本人,超级管理员看全部;不新增导出。 +- 配置:不新增任何运行时开关或配置来表达上线切割点;新表对既有旧代码无影响,单批发布即可。 +- 权限:本期不引入“财务”角色,也不启用 `RequirePermission` 路由鉴权体系;财务独立可见性留待后续权限 Change。 - 依赖:复用既有企业微信通用审批实例、可靠提交和状态恢复机制;OCR 与外部付款渠道不在本 Change 新建契约。 \ No newline at end of file diff --git a/openspec/changes/add-employee-collection-bills/specs/employee-collection-bill/spec.md b/openspec/changes/add-employee-collection-bills/specs/employee-collection-bill/spec.md index bf53a3f..01987f3 100644 --- a/openspec/changes/add-employee-collection-bills/specs/employee-collection-bill/spec.md +++ b/openspec/changes/add-employee-collection-bills/specs/employee-collection-bill/spec.md @@ -1,33 +1,59 @@ ## Purpose -为后台账号代客户经办的线下套餐购买和代理预存款充值建立独立的员工应收、外部付款核验和企业微信终审闭环;该能力只保存可追溯的本地业务事实,不推测或回填历史第三方付款。 +为后台账号代客户经办的线下套餐购买和代理预存款充值建立独立的员工应收与核销核验闭环,核销申请以企业微信为唯一终审;该能力只保存可追溯的本地业务事实,不推测或回填历史第三方付款。 ## ADDED Requirements -### Requirement: 员工代收款账单来源、金额与上线边界 -系统 SHALL 仅为功能上线后新发生的下列业务创建员工代收款账单,并以实际发起该业务的后台账号作为不可修改的欠款人: +### Requirement: 员工代收款账单来源、金额与建账判据 +系统 SHALL 仅为下列业务创建员工代收款账单,并以实际发起该业务的后台账号作为不可修改的欠款人: -- 平台业务员或超级管理员创建的、会生成账单的后台线下套餐订单,账单金额取订单 `actual_paid_amount`;代理代购和无代理归属自营 C 端均适用。该订单创建成功后仍按既有规则立即激活。 -- 代理线下预存款/主钱包充值在企业微信最终通过且完成入账后,账单金额取对应充值记录 `amount`。 +- 后台线下套餐订单,同时满足 `payment_method = offline`、`operator_account_type = platform`、订单不含赠送套餐、且订单 `actual_paid_amount > 0`;账单应收金额取订单 `actual_paid_amount`,代理代购和无代理归属自营 C 端均适用。`actual_paid_amount ≤ 0` 的订单 MUST NOT 创建账单。该订单创建成功后仍按既有规则立即激活。 +- 代理线下预存款/主钱包充值完成入账后,账单金额取对应充值记录 `amount`;入账完成以“平台账号发起的线下充值入账成功”这一既有事实为准,既有企业微信终审通过入账与既有线下充值人工确认入账均适用。 -客户自行线上支付、平台代理 C 端客户充值资产钱包、订单失败或取消、赠送套餐等不产生员工账单的既有线下订单,以及“其他”手工来源 MUST NOT 创建账单。系统 MUST 为同一来源业务建立至多一张账单,并保存来源类型、来源 ID、来源单号、客户/店铺快照、欠款人、应收金额和创建时间;上线前业务不回填、不补建。 +客户自行线上支付、平台代理 C 端客户充值资产钱包、订单失败或取消、赠送套餐等不产生员工账单的既有线下订单,以及“其他”手工来源 MUST NOT 创建账单。系统 MUST 为同一来源业务建立至多一张账单,并保存来源类型、来源 ID、来源单号、客户/店铺快照、欠款人、应收金额和创建时间。建账 MUST 只由来源成功事务携带的来源主键触发;系统 MUST NOT 通过扫描历史订单或充值记录补建账单。 #### Scenario: 后台线下套餐订单产生账单 -- **WHEN** 平台业务员或超级管理员成功创建一个需要生成账单的后台线下套餐订单 +- **WHEN** 平台业务员或超级管理员成功创建一个满足建账判据、且 `actual_paid_amount` 大于零的后台线下套餐订单 - **THEN** 系统以该操作账号为欠款人、以订单 `actual_paid_amount` 为应收金额创建唯一待核销账单,且订单无需因未上传付款凭证而阻断 -#### Scenario: 审批入账的代理充值产生账单 -- **WHEN** 功能上线后代理线下预存款/主钱包充值经企业微信最终通过并完成入账 +#### Scenario: 线下充值完成入账产生账单 +- **WHEN** 平台账号发起的代理线下预存款/主钱包充值经企业微信最终通过并完成入账 - **THEN** 系统以实际发起充值的后台账号和充值 `amount` 创建唯一待核销账单 -#### Scenario: 重复来源或历史业务不产生重复账单 -- **WHEN** 同一来源业务被重复处理、重复回调,或业务发生在功能上线前 -- **THEN** 系统至多保留一张来源关联账单,且不补建上线前账单 +#### Scenario: 人工确认入账同样产生账单 +- **WHEN** 未关联企业微信审批实例的线下充值经后台人工确认入口完成入账 +- **THEN** 系统同样以发起充值的后台账号和充值 `amount` 创建唯一待核销账单 + +#### Scenario: 重复来源不产生重复账单 +- **WHEN** 同一来源业务被重复处理、重复回调或重试 +- **THEN** 系统至多保留一张来源关联账单,不新增第二张账单 + +#### Scenario: 零金额与赠送订单不建账 +- **WHEN** 后台线下套餐订单的 `actual_paid_amount` 小于等于零,或订单包含赠送套餐 +- **THEN** 系统不创建任何账单,且该订单的创建时付款凭证要求保持既有规则 + +#### Scenario: 历史来源记录不被扫描建账 +- **WHEN** 建账用例运行,而某笔上线前已存在的订单或充值从未由本次来源成功事务携带来源主键 +- **THEN** 系统不为该记录创建账单 ### Requirement: 账单余额、状态与关闭 账单 SHALL 独立维护 `待核销`、`部分核销`、`已核销`、`已关闭` 状态及应收金额、已核销金额、审批中预占金额和剩余可核销金额。只有企业微信最终通过的分摊增加已核销金额;审批中的分摊预占剩余可核销金额,防止并发申请超额核销。账单已核销金额等于应收金额时 MUST 为已核销;关闭账单只作废当时未核销余额,已核销金额必须保留。 -欠款人离职、禁用或变更组织后,账单欠款人身份和既有账单范围 MUST 保持不变。员工仅可查询本人账单和申请;财务与超级管理员可按既有数据范围查询;仅超级管理员可代办创建、修改或重提申请,且必须记录实际代办人和原因。 +欠款人离职、禁用或变更组织后,账单欠款人身份和既有账单范围 MUST 保持不变。欠款人(平台账号)仅可查询本人账单与本人申请;超级管理员可查询全部;本期 MUST NOT 引入“财务”角色,MUST NOT 依赖 `RequirePermission` 路由鉴权体系实现可见性。仅超级管理员可代办创建、修改或重提申请,且必须记录实际代办人和原因。 + +系统 SHALL 提供与账单列表相同筛选条件和相同可见性范围的统计聚合,返回应收金额合计、已核销金额合计、未核销金额合计和待处理账单数。 + +#### Scenario: 欠款人仅见本人账单与申请 +- **WHEN** 非超级管理员的后台账号查询账单列表、账单详情或申请 +- **THEN** 系统仅返回以该账号为欠款人的账单及由该账号发起的申请,且无权限与不存在不产生可枚举差异 + +#### Scenario: 超级管理员查询全部账单 +- **WHEN** 超级管理员查询账单列表 +- **THEN** 系统返回其可见范围内的全部账单,不按欠款人账号过滤 + +#### Scenario: 统计与列表同口径 +- **WHEN** 查询统计聚合与使用相同筛选条件的账单列表 +- **THEN** 统计的应收、已核销、未核销金额与待处理账单数与列表数据一致 #### Scenario: 部分核销后仍可继续核销 - **WHEN** 一张账单存在企业微信已通过但未结清的分摊 @@ -42,22 +68,22 @@ - **THEN** 系统拒绝关闭,账单金额和状态不变 ### Requirement: 外部付款核销申请与分摊 -员工 SHALL 按一笔外部付款创建一张核销申请。申请 MUST 选择一个启用的线下收款方式字典项,并保存其稳定编码和名称快照;必须保存经人工确认的付款金额、付款方、付款时间、外部交易流水号、至少一个支付凭证和可选其他凭证、备注及一个或多个账单分摊。申请人只能通过勾选可见账单创建分摊,系统带出只读来源订单、客户和资产信息。 +员工 SHALL 按一笔外部付款创建一张核销申请。申请 MUST 选择一个启用的线下收款方式字典项,并保存其稳定编码和名称快照;必须保存经人工确认的付款金额、付款方、付款时间、外部交易流水号、至少一个支付凭证和可选其他凭证、备注及一个或多个账单分摊。支付凭证 MUST 复用既有附件键,只保存对象键引用并经既有预签名下载能力访问,本能力 MUST NOT 承诺资源级附件鉴权。申请人只能通过勾选可见账单创建分摊,系统带出只读来源订单、客户和资产信息。 -系统 SHALL 按账单产生时间从早到晚用本次付款金额预填分摊,最后一张填入剩余金额;申请人可修改各分摊金额。单笔分摊 MUST 大于零且不得超过该账单可核销余额;分摊总额 MUST 不超过本次人工确认付款金额。同一外部付款可被多个核销申请引用,本期 MUST NOT 对跨申请累计分摊金额实施系统防重或金额上限校验。 +系统 SHALL 校验申请人提交的账单分摊:申请人按账单产生时间从早到晚用本次付款金额预填分摊、最后一张填入剩余金额,并可在提交前修改各分摊金额;服务端 MUST 按校验规则拒绝越界分摊。单笔分摊 MUST 大于零且不得超过该账单可核销余额;分摊总额 MUST 不超过本次人工确认付款金额。同一外部付款可被多个核销申请引用,本期 MUST NOT 对跨申请累计分摊金额实施系统防重或金额上限校验。 #### Scenario: 一笔付款分摊多张账单 - **WHEN** 员工选择多张可见账单并提交一笔外部付款的核销申请 -- **THEN** 系统按账单时间预填分摊、校验每张账单可核销余额和申请总额,并为该申请创建唯一企业微信审批实例 +- **THEN** 系统校验每张账单可核销余额和申请总额、拒绝越界分摊,并为该申请创建唯一企业微信审批实例 #### Scenario: 账单并发申请预占 - **WHEN** 两个核销申请并发选择同一账单的剩余余额 - **THEN** 系统至多接受不超过该账单未核销余额的审批中和已通过分摊,其余申请返回余额不足且不创建超额分摊 ### Requirement: 核销申请审批、重提与幂等 -核销申请状态 SHALL 为 `审批中`、`已通过`、`已驳回`、`已撤销/已关闭`,且不得以申请状态覆盖账单核销状态。提交或重提时系统 MUST 冻结当次收款方式、外部付款、附件、备注、账单分摊及审批材料快照,并创建新的企业微信审批实例。 +核销申请状态 SHALL 为 `审批中`、`已通过`、`已驳回`、`已撤销/已关闭`,且不得以申请状态覆盖账单核销状态。提交或重提时系统 MUST 新增一条不可变审批尝试记录(冻结当次收款方式、外部付款、附件、备注、账单分摊及审批材料快照)并为其创建新的企业微信审批实例;申请只保存最新审批实例 ID 用于展示。企业微信业务类型 MUST 为 `employee_collection_approval`,其业务标识 MUST 取审批尝试记录主键,使同一申请的多次提交各自持有独立审批实例,且 MUST NOT 修改既有审批实例的共享唯一约束或既有 `refund_approval`、`offline_recharge_approval` 场景语义。 -企业微信最终通过时,系统 MUST 幂等地将申请标记为已通过、将各分摊写入账单已核销金额并释放其预占;最终驳回时 MUST 标记申请已驳回、释放全部预占且保留审批意见。已驳回申请可修改全部申请内容后重提,历史审批实例、材料和结果不得覆盖;已通过分摊不可修改。企业微信提交失败、回调延迟或结果未知时申请保持在途,系统 MUST 使用既有查询/恢复机制确认渠道结果,且不得由本地人工通过或拒绝绕过企业微信。 +企业微信最终通过时,系统 MUST 幂等地将申请标记为已通过、将各分摊写入账单已核销金额并释放其预占;最终驳回时 MUST 标记申请已驳回、释放全部预占且保留审批意见。已驳回申请可修改全部申请内容后重提,历史审批实例、材料和结果不得覆盖;已通过分摊不可修改。企业微信提交失败、回调延迟或结果未知时申请保持在途,系统 MUST 使用既有查询/恢复机制确认渠道结果,且不得由本地人工通过或拒绝绕过企业微信。企业微信对已通过申请撤销时,系统 MUST NOT 回滚已核销金额,MUST 将申请转为异常终态、保留审计与账单详情提示,并禁止自动重提。 #### Scenario: 企业微信通过核销申请 - **WHEN** 企业微信对含多笔分摊的核销申请返回最终通过,且该结果首次被消费 @@ -65,19 +91,31 @@ #### Scenario: 企业微信驳回后重提 - **WHEN** 企业微信最终驳回核销申请 -- **THEN** 系统释放预占、保留驳回实例和意见;员工或有代办权限的超级管理员修改申请后重提时创建新的审批实例 +- **THEN** 系统释放预占、保留驳回实例和意见;员工或有代办权限的超级管理员修改申请后重提时新增审批尝试记录并创建新的审批实例,历史实例与材料不被覆盖 + +#### Scenario: 企业微信通过后撤销 +- **WHEN** 已通过的核销申请收到企业微信撤销结果 +- **THEN** 系统不回滚已核销金额,将申请标记为异常终态、保留审计并在账单详情提示,且不允许自动重提 ### Requirement: 收款方式字典、退款联动与可追溯性 -系统 SHALL 提供唯一固定分类的线下收款方式字典。超级管理员可维护名称、稳定编码、排序、启停和备注;已被业务引用的字典项 MUST NOT 被物理删除,只能停用,且历史申请继续显示冻结名称。 +系统 SHALL 提供唯一固定分类的线下收款方式字典,其对外契约 MUST 包含 ID、稳定编码、名称和启用状态,引用方 MUST 保存名称快照。超级管理员可维护名称、稳定编码、排序、启停和备注;已被业务引用的字典项 MUST NOT 被物理删除,只能停用,且历史申请继续显示冻结名称。该字典分类由本能力独占维护,MUST NOT 被定义为“核销专用”,本能力也 MUST NOT 修改企业微信 `offline_recharge_approval` 场景的业务字段。 -来源套餐订单全额退款且账单从未存在已通过分摊时,系统 MUST 自动关闭账单并记录“来源订单全额退款”;部分退款且从未存在已通过分摊时,系统 MUST 按退款金额冲减账单应收金额并保留来源订单退款冲销记录。账单存在任一已通过分摊时,系统 MUST NOT 自动冲销,仅在账单详情提示来源订单退款。退款不恢复已核销账单的员工欠款。 +来源套餐订单退款成功时,系统 SHALL 以本次退款成功金额与账单应收(即来源订单 `actual_paid_amount`)比较:退款成功金额等于或超过账单应收且账单不存在已通过或审批中分摊时,系统 MUST 自动关闭账单并记录“来源订单全额退款”;退款成功金额小于账单应收且账单不存在已通过或审批中分摊时,系统 MUST 按退款金额冲减账单应收金额并保留来源订单退款冲销记录。账单存在任一已通过分摊或任一审批中(预占)分摊时,系统 MUST NOT 自动冲销,仅在账单详情提示来源订单退款。同一退款对同一账单 MUST 至多产生一条冲销记录。退款不恢复已核销账单的员工欠款,系统 MUST NOT 实现退款审批、退款状态机或支付渠道退款。 账单、申请、分摊、附件、审批实例、字典快照、关闭和退款冲销 MUST 可按权限查询并记录操作审计;审计和日志不得保存完整支付凭证敏感内容。OCR 若可用仅用于预填,必须允许申请人或审核人更正,且识别值不是资金事实。 #### Scenario: 来源订单部分退款且未核销 -- **WHEN** 来源套餐订单部分退款,且其账单不存在任何已通过分摊 +- **WHEN** 来源套餐订单退款成功金额小于账单应收,且其账单不存在任何已通过或审批中分摊 - **THEN** 系统按退款金额冲减账单应收金额,保留退款冲销关联,并重新计算账单状态和可核销余额 +#### Scenario: 来源订单全额退款且未核销 +- **WHEN** 来源套餐订单退款成功金额等于或超过账单应收,且其账单不存在任何已通过或审批中分摊 +- **THEN** 系统自动关闭账单、记录“来源订单全额退款”,并保留已核销金额与审计 + +#### Scenario: 存在审批中分摊的账单退款不自动冲销 +- **WHEN** 来源套餐订单退款成功,而其账单存在审批中(预占)分摊但不存在已通过分摊 +- **THEN** 系统不修改账单应收、已核销或预占金额,仅写退款关联提示 + #### Scenario: 被引用字典项停用 - **WHEN** 超级管理员停用已被核销申请引用的线下收款方式 - **THEN** 新申请不可选择该方式,历史申请仍展示其冻结名称和稳定编码 diff --git a/openspec/changes/add-employee-collection-bills/tasks.md b/openspec/changes/add-employee-collection-bills/tasks.md index 959fb91..4a6a9d2 100644 --- a/openspec/changes/add-employee-collection-bills/tasks.md +++ b/openspec/changes/add-employee-collection-bills/tasks.md @@ -1,27 +1,27 @@ ## 1. 账单数据与基础契约 -- [ ] 1.1 盘点现有订单、代理充值、通用企业微信审批、附件、Outbox 和审计模型,确定来源事件、附件键和审批快照的复用点;不得复制敏感付款内容。 -- [ ] 1.2 新增成对迁移及 GORM 模型:线下收款方式字典、员工代收款账单、核销申请、申请—账单分摊、退款冲销/审批快照关联;为来源唯一性、审批实例唯一性、账单查询和分摊锁定建立约束/索引。 -- [ ] 1.3 定义金额分、账单状态、申请状态、来源类型和稳定错误码;实现中文名称、DTO 枚举说明及金额/附件/分摊校验。 -- [ ] 1.4 实现超级管理员维护线下收款方式字典的新增、编辑、启停和受引用不可删除规则,并写配置审计。 +- [x] 1.1 盘点现有订单、代理充值、通用企业微信审批、钱包入账、附件、Outbox 和审计模型,确定来源建账点、附件键和审批尝试记录的复用点;确认新业务类型 `employee_collection_approval` 以审批尝试记录主键作为 `business_id` 的兼容方案;不得复制敏感付款内容。 +- [x] 1.2 新增成对迁移与 GORM 模型:线下收款方式字典、员工代收款账单、核销申请、申请—账单分摊、审批尝试记录、退款冲销关联;为来源唯一性、审批实例唯一性、账单查询和分摊锁定建立约束/索引。同时新增迁移扩展 `tb_wecom_approval_scene` 的 `business_type` CHECK 以纳入新业务类型(不修改既有迁移)。迁移不预占编号,按实施开始时 `migrations/` 目录最大编号顺延。 +- [x] 1.3 定义金额分、账单状态、申请状态、来源类型和稳定错误码;实现中文名称、DTO 枚举说明及金额/附件/分摊校验。 +- [x] 1.4 实现超级管理员维护线下收款方式字典的新增、编辑、启停和受引用不可删除规则,并写配置审计。 ## 2. 来源建账与账单读取 -- [ ] 2.1 在后台线下套餐订单成功路径识别应建账场景,以实际操作后台账号和 `actual_paid_amount` 可靠、幂等地创建账单;调整仅该场景的创建时付款凭证要求,保留赠送等非建账订单的既有要求。 -- [ ] 2.2 在代理线下预存款/主钱包充值企业微信通过且完成入账路径可靠、幂等地创建账单,金额取充值 `amount`;线上充值和未入账审批不得建账。 -- [ ] 2.3 实现账单列表、详情和统计 Query:按来源、状态、时间、员工、客户筛选,员工仅见本人,财务/超级管理员按数据范围见全部;返回应收、已核销、预占和未核销金额及审批中标识。 -- [ ] 2.4 实现账单关闭用例:仅超级管理员、仅允许无审批中申请的未结清账单、必须填写原因,并在事务内保存状态变化与成功审计。 +- [x] 2.1 在后台线下套餐订单成功事务内(`tx.Create(order)` 之后)按判据 `payment_method = offline` ∧ `operator_account_type = platform` ∧ 订单不含赠送套餐 ∧ `actual_paid_amount > 0` 以实际操作平台账号为欠款人、以 `actual_paid_amount` 为应收创建账单;`actual_paid_amount ≤ 0` 不建账;以 `order:{id}` 唯一键保证重复处理、重放或重试返回同一账单;创建时付款凭证的放宽范围与该判据严格互补,赠送等不建账订单保持既有凭证要求;不新增来源 Outbox 事件类型,不存在按历史订单扫描建账的路径。 +- [x] 2.2 在“平台账号发起的线下充值入账成功”这一既有事实上、于入账事务内以 `recharge:{id}` 唯一键创建账单,金额取充值 `amount`,欠款人为发起充值的平台账号;覆盖既有企业微信终审通过入账与既有线下充值人工确认入账两条入口;线上充值、审批未通过或未完成入账不建账;不依赖申请表未落库的新字段,不存在按历史充值扫描建账的路径。 +- [x] 2.3 实现账单列表、详情和统计 Query:按来源、状态、时间、欠款人、客户筛选,欠款人仅见本人,超级管理员见全部;统计与列表使用相同筛选条件与可见性范围,返回应收、已核销、未核销金额合计与待处理账单数;列表返回应收、已核销、预占和未核销金额及审批中标识。 +- [x] 2.4 实现账单关闭用例:仅超级管理员、仅允许无审批中申请的未结清账单、必须填写原因,并在事务内保存状态变化与成功审计。 ## 3. 核销申请、审批与退款联动 -- [ ] 3.1 实现核销申请创建和已驳回重提:锁定选中账单、按时间预填、校验付款金额与分摊、预占余额、冻结收款方式/外部付款/附件/账单摘要,并创建新的企业微信审批实例和可靠提交请求。 -- [ ] 3.2 接入企业微信最终通过、驳回、提交失败和状态查询恢复:通过时一次性增加已核销并释放预占,驳回时释放预占;用审批实例、状态条件更新和账单锁保证重复/乱序回调不重复核销。 -- [ ] 3.3 实现代办权限、申请/分摊/审批历史查询及附件授权访问;超级管理员代办创建、修改或重提时强制记录实际代办人和原因。 -- [ ] 3.4 在套餐退款成功处理链路实现账单冲销:无已通过分摊的全额退款自动关闭、部分退款冲减应收;存在已通过分摊时只保留退款关联提示,不恢复欠款。 -- [ ] 3.5 为建账、申请提交/重提、审批通过/驳回、账单关闭和退款冲销补齐事务内审计;检查日志、错误和导出不暴露附件内容、完整交易敏感体或 OCR 原始结果。 +- [x] 3.1 实现核销申请创建和已驳回重提:锁定选中账单、按时间预填、校验付款金额与分摊、预占余额、每次提交或重提新增一条不可变审批尝试记录(冻结收款方式/外部付款/附件/账单摘要)并以尝试记录主键为 `business_id` 创建新的企业微信审批实例和可靠提交请求;申请只保存最新审批实例 ID。 +- [x] 3.2 接入企业微信最终通过、驳回、通过后撤销、提交失败和状态查询恢复:通过时一次性增加已核销并释放预占,驳回时释放预占,通过后撤销时不回滚已核销并将申请转异常终态、保留审计、禁止自动重提;用审批实例、审批尝试记录、状态条件更新和账单锁保证重复/乱序回调不重复核销;施工时列全四处注册点:业务类型常量、`validApprovalBusinessType` 与 `sceneBusinessFields`、数据库 CHECK、`cmd/worker/main.go` 的审批决策消费者注册。 +- [x] 3.3 实现代办权限、申请/分摊/审批历史查询及附件访问:附件只返回既有对象键引用并复用既有预签名下载能力,不承诺资源级鉴权;超级管理员代办创建、修改或重提时强制记录实际代办人和原因。 +- [x] 3.4 在既有套餐退款成功处理事务内实现账单冲销:以本次退款成功金额与账单应收(来源订单 `actual_paid_amount`)比较,等于应收且无已通过或审批中分摊时自动关闭、小于应收且无已通过或审批中分摊时冲减应收;存在已通过或审批中(预占)分摊时只写退款关联提示,不修改应收、已核销或预占;以 `bill_id + refund_id` 唯一事实幂等,不依赖退款事务的 `changed` 标志;不实现 AUG26-006 的退款审批、状态机或渠道退款。 +- [x] 3.5 为建账、申请提交/重提、审批通过/驳回/通过后撤销、账单关闭和退款冲销补齐事务内审计;检查日志、审计和错误不暴露附件内容、完整交易敏感体或 OCR 原始结果。 ## 4. 路由、文档与验证 -- [ ] 4.1 注册账单、申请、字典和导出所需路由及 RouteSpec,补齐 `internal/bootstrap`、`cmd/api/docs.go` 和 `cmd/gendocs/main.go` 装配;Handler 使用 `pkg/response` 和稳定错误。 -- [ ] 4.2 在隔离数据库按显式 `DB_*` 和 `scripts/migrate.sh` 验证迁移 up/down/up;人工核对上线前来源不建账、来源幂等、并发预占、审批重放、驳回重提、关闭限制及退款三类联动。 -- [ ] 4.3 运行 `gofmt -w`(变更 Go 文件)、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-employee-collection-bills --strict`、`openspec doctor --json` 和 `./scripts/context-health.sh`;自动化测试按项目决策为 N/A。 \ No newline at end of file +- [x] 4.1 注册账单(含统计聚合)、申请和字典所需路由及 RouteSpec,补齐 `internal/bootstrap`、`cmd/api/docs.go` 和 `cmd/gendocs/main.go` 装配;Handler 使用 `pkg/response` 和稳定错误;不新增导出路由。 +- [x] 4.2 按 ENG-TEST-001 在维护者指定的 `junhong_cmp_test` PostgreSQL + Redis DB 6 验证:迁移从本地工作区以显式 `DB_*` 执行 `scripts/migrate.sh`,只创建、删除本 Change 自己的 fixture,禁止重置整库,不额外要求独立数据库、Redis DB 或 namespace,仅连接、迁移或实际行为失败时才阻塞对应场景。人工核对:不存在按历史记录扫描或补建的路径、重复消费来源不重复建账、并发预占、审批通过/驳回/重提/通过后撤销/重放、关闭限制、退款三类联动(全额关闭、部分冲减、存在已通过或审批中分摊仅提示),以及迁移 up/down/up。 +- [x] 4.3 运行 `gofmt -w`(变更 Go 文件)、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-employee-collection-bills --strict`、`openspec doctor --json` 和 `./scripts/context-health.sh`;自动化测试按项目决策为 N/A。 \ No newline at end of file diff --git a/pkg/constants/approval.go b/pkg/constants/approval.go index e29ebc1..8086045 100644 --- a/pkg/constants/approval.go +++ b/pkg/constants/approval.go @@ -7,6 +7,9 @@ const ( ApprovalBusinessTypeRefund = "refund_approval" // ApprovalBusinessTypeOfflineRecharge 表示员工线下代充值审批业务场景。 ApprovalBusinessTypeOfflineRecharge = "offline_recharge_approval" + // ApprovalBusinessTypeEmployeeCollection 表示员工代收款核销审批业务场景。 + // 该场景的业务ID取核销申请的审批尝试记录主键,使同一申请每次提交各自持有独立审批实例。 + ApprovalBusinessTypeEmployeeCollection = "employee_collection_approval" ) const ( @@ -48,6 +51,24 @@ const ( ApprovalFieldRefundReason = "refund_reason" // ApprovalFieldPackageUsageID 表示退款指定套餐使用记录 ID 业务字段。 ApprovalFieldPackageUsageID = "package_usage_id" + // ApprovalFieldCollectionApplicationID 表示员工代收款核销申请 ID 业务字段。 + ApprovalFieldCollectionApplicationID = "collection_application_id" + // ApprovalFieldCollectionPaymentMethod 表示员工代收款线下收款方式名称业务字段。 + ApprovalFieldCollectionPaymentMethod = "collection_payment_method" + // ApprovalFieldCollectionPaidAmount 表示员工代收款付款金额元展示业务字段。 + ApprovalFieldCollectionPaidAmount = "collection_paid_amount" + // ApprovalFieldCollectionPaidAmountCent 表示员工代收款付款金额分业务字段。 + ApprovalFieldCollectionPaidAmountCent = "collection_paid_amount_cent" + // ApprovalFieldCollectionPayerName 表示员工代收款付款方名称业务字段。 + ApprovalFieldCollectionPayerName = "collection_payer_name" + // ApprovalFieldCollectionPaidAt 表示员工代收款付款时间业务字段。 + ApprovalFieldCollectionPaidAt = "collection_paid_at" + // ApprovalFieldCollectionExternalTransactionNo 表示员工代收款外部交易流水号业务字段。 + ApprovalFieldCollectionExternalTransactionNo = "collection_external_transaction_no" + // ApprovalFieldCollectionBillCount 表示员工代收款分摊账单数量业务字段。 + ApprovalFieldCollectionBillCount = "collection_bill_count" + // ApprovalFieldCollectionBillSummary 表示员工代收款分摊账单摘要业务字段。 + ApprovalFieldCollectionBillSummary = "collection_bill_summary" ) const ( diff --git a/pkg/constants/audit.go b/pkg/constants/audit.go index ef8fbfb..2e6e2be 100644 --- a/pkg/constants/audit.go +++ b/pkg/constants/audit.go @@ -409,6 +409,26 @@ const ( AuditActionWeComMembersSynced = "wecom.application.sync_members" // AuditActionWeComApprovalSceneSaved 表示保存企业微信审批场景配置。 AuditActionWeComApprovalSceneSaved = "wecom.approval_scene.save" + // AuditActionEmployeeCollectionPaymentMethodCreated 表示创建线下收款方式。 + AuditActionEmployeeCollectionPaymentMethodCreated = "employee_collection.payment_method.create" + // AuditActionEmployeeCollectionPaymentMethodUpdated 表示更新线下收款方式。 + AuditActionEmployeeCollectionPaymentMethodUpdated = "employee_collection.payment_method.update" + // AuditActionEmployeeCollectionPaymentMethodDeleted 表示删除线下收款方式。 + AuditActionEmployeeCollectionPaymentMethodDeleted = "employee_collection.payment_method.delete" + // AuditActionEmployeeCollectionBillCreated 表示创建员工代收款账单。 + AuditActionEmployeeCollectionBillCreated = "employee_collection.bill.create" + // AuditActionEmployeeCollectionBillClosed 表示关闭员工代收款账单。 + AuditActionEmployeeCollectionBillClosed = "employee_collection.bill.close" + // AuditActionEmployeeCollectionApplicationSubmitted 表示提交或重提核销申请。 + AuditActionEmployeeCollectionApplicationSubmitted = "employee_collection.application.submit" + // AuditActionEmployeeCollectionApplicationApproved 表示核销申请企业微信最终通过。 + AuditActionEmployeeCollectionApplicationApproved = "employee_collection.application.approve" + // AuditActionEmployeeCollectionApplicationRejected 表示核销申请企业微信最终驳回或撤销。 + AuditActionEmployeeCollectionApplicationRejected = "employee_collection.application.reject" + // AuditActionEmployeeCollectionApplicationRevoked 表示已通过核销申请被企业微信撤销。 + AuditActionEmployeeCollectionApplicationRevoked = "employee_collection.application.revoke" + // AuditActionEmployeeCollectionBillRefundOffseted 表示来源订单退款成功对账单的冲销或提示。 + AuditActionEmployeeCollectionBillRefundOffseted = "employee_collection.bill.refund_offset" // AuditActionOutboxReplayed 表示人工重放 Outbox 事件。 AuditActionOutboxReplayed = "outbox.replayed" // AuditActionOutboxExpiredLeaseReleased 表示人工释放 Outbox 过期租约。 @@ -533,6 +553,12 @@ const ( AuditOperationOutboxReplay = "outbox_replay" // AuditOperationOutboxReleaseExpiredLease 表示 Outbox 人工释放过期租约接缝操作类型。 AuditOperationOutboxReleaseExpiredLease = "outbox_release_expired_lease" + // AuditOperationEmployeeCollectionPaymentMethodCreate 表示创建线下收款方式。 + AuditOperationEmployeeCollectionPaymentMethodCreate = "employee_collection_payment_method_create" + // AuditOperationEmployeeCollectionPaymentMethodUpdate 表示更新线下收款方式。 + AuditOperationEmployeeCollectionPaymentMethodUpdate = "employee_collection_payment_method_update" + // AuditOperationEmployeeCollectionPaymentMethodDelete 表示删除线下收款方式。 + AuditOperationEmployeeCollectionPaymentMethodDelete = "employee_collection_payment_method_delete" ) const ( @@ -542,6 +568,14 @@ const ( AuditResourcePaymentConfig = "payment_config" // AuditResourceCarrier 表示运营商配置资源。 AuditResourceCarrier = "carrier" + // AuditResourceEmployeeCollectionPaymentMethod 表示线下收款方式字典资源。 + AuditResourceEmployeeCollectionPaymentMethod = "employee_collection_payment_method" + // AuditResourceEmployeeCollectionBill 表示员工代收款账单资源。 + AuditResourceEmployeeCollectionBill = "employee_collection_bill" + // AuditResourceEmployeeCollectionApplication 表示员工代收款核销申请资源。 + AuditResourceEmployeeCollectionApplication = "employee_collection_application" + // AuditResourceEmployeeCollectionAttempt 表示员工代收款核销审批尝试记录资源。 + AuditResourceEmployeeCollectionAttempt = "employee_collection_application_attempt" // AuditResourceWeComApprovalScene 表示企业微信审批场景配置资源。 AuditResourceWeComApprovalScene = "wecom_approval_scene" // AuditResourceOutboxEvent 表示公共 Outbox 事件资源。 @@ -692,6 +726,14 @@ const ( AuditResourceRoleRetentionMonth = "retention_month" // AuditResourceRoleNotificationTarget 表示本次写操作的通知资源。 AuditResourceRoleNotificationTarget = "notification_target" + // AuditResourceRoleCollectionBill 表示员工代收款账单主资源。 + AuditResourceRoleCollectionBill = "collection_bill" + // AuditResourceRoleCollectionApplication 表示员工代收款核销申请主资源。 + AuditResourceRoleCollectionApplication = "collection_application" + // AuditResourceRoleCollectionAttempt 表示员工代收款核销审批尝试记录资源。 + AuditResourceRoleCollectionAttempt = "collection_attempt" + // AuditResourceRoleCollectionBillAffected 表示核销申请影响的员工代收款账单资源。 + AuditResourceRoleCollectionBillAffected = "collection_bill_affected" // AuditResourceRoleSensitiveReadTarget 表示敏感读取目标资源。 AuditResourceRoleSensitiveReadTarget = "sensitive_read_target" // AuditResourceRoleAccountTarget 表示账号生命周期的目标账号。 diff --git a/pkg/constants/employee_collection.go b/pkg/constants/employee_collection.go new file mode 100644 index 0000000..31fd322 --- /dev/null +++ b/pkg/constants/employee_collection.go @@ -0,0 +1,164 @@ +package constants + +// 员工代收款账单来源类型,取值与 tb_employee_collection_bill.source_type 检查约束一致。 +const ( + // EmployeeCollectionSourceTypeOrder 表示来源为后台线下套餐订单。 + EmployeeCollectionSourceTypeOrder = "order" + // EmployeeCollectionSourceTypeRecharge 表示来源为代理线下充值入账。 + EmployeeCollectionSourceTypeRecharge = "recharge" +) + +// 员工代收款账单状态,取值与 tb_employee_collection_bill.status 检查约束一致。 +const ( + // EmployeeCollectionBillStatusPending 表示账单待核销。 + EmployeeCollectionBillStatusPending = 0 + // EmployeeCollectionBillStatusPartial 表示账单部分核销。 + EmployeeCollectionBillStatusPartial = 1 + // EmployeeCollectionBillStatusSettled 表示账单已核销。 + EmployeeCollectionBillStatusSettled = 2 + // EmployeeCollectionBillStatusClosed 表示账单已关闭,未核销余额作废。 + EmployeeCollectionBillStatusClosed = 3 +) + +// 核销申请状态,取值与 tb_employee_collection_application.status 检查约束一致。 +// 申请状态不覆盖账单核销状态,两者独立保存。 +const ( + // EmployeeCollectionApplicationStatusPending 表示核销申请审批中。 + EmployeeCollectionApplicationStatusPending = 0 + // EmployeeCollectionApplicationStatusApproved 表示核销申请企业微信最终通过。 + EmployeeCollectionApplicationStatusApproved = 1 + // EmployeeCollectionApplicationStatusRejected 表示核销申请企业微信最终驳回。 + EmployeeCollectionApplicationStatusRejected = 2 + // EmployeeCollectionApplicationStatusRevoked 表示核销申请通过后撤销等异常终态或已关闭。 + EmployeeCollectionApplicationStatusRevoked = 3 +) + +// 核销分摊状态,取值与 tb_employee_collection_application_allocation.status 检查约束一致。 +const ( + // EmployeeCollectionAllocationStatusPending 表示分摊审批中并预占账单余额。 + EmployeeCollectionAllocationStatusPending = 0 + // EmployeeCollectionAllocationStatusApproved 表示分摊已通过并转入账单已核销金额。 + EmployeeCollectionAllocationStatusApproved = 1 + // EmployeeCollectionAllocationStatusReleased 表示分摊已驳回或已释放预占。 + EmployeeCollectionAllocationStatusReleased = 2 +) + +// 退款冲销处理结果,取值与 tb_employee_collection_bill_refund.outcome 检查约束一致。 +const ( + // EmployeeCollectionRefundOutcomeClosedFull 表示退款金额等于账单应收且无分摊,账单全额关闭。 + EmployeeCollectionRefundOutcomeClosedFull = "closed_full" + // EmployeeCollectionRefundOutcomeReduced 表示退款金额小于账单应收且无分摊,按退款金额冲减应收。 + EmployeeCollectionRefundOutcomeReduced = "reduced" + // EmployeeCollectionRefundOutcomeHintOnly 表示存在已通过或审批中分摊,仅写退款关联提示。 + EmployeeCollectionRefundOutcomeHintOnly = "hint_only" +) + +// 线下收款方式字典启停状态,取值与 tb_employee_collection_payment_method.status 检查约束一致。 +const ( + // EmployeeCollectionPaymentMethodStatusDisabled 表示收款方式已停用,新申请不可选择。 + EmployeeCollectionPaymentMethodStatusDisabled = 0 + // EmployeeCollectionPaymentMethodStatusEnabled 表示收款方式已启用。 + EmployeeCollectionPaymentMethodStatusEnabled = 1 +) + +// 员工代收款账单与核销申请的输入边界。 +const ( + // EmployeeCollectionCodeMaxLength 表示收款方式稳定编码最大长度。 + EmployeeCollectionCodeMaxLength = 64 + // EmployeeCollectionNameMaxLength 表示收款方式名称最大长度。 + EmployeeCollectionNameMaxLength = 100 + // EmployeeCollectionRemarkMaxLength 表示备注最大长度。 + EmployeeCollectionRemarkMaxLength = 500 + // EmployeeCollectionPayerNameMaxLength 表示付款方名称最大长度。 + EmployeeCollectionPayerNameMaxLength = 100 + // EmployeeCollectionExternalTransactionNoMaxLength 表示外部交易流水号最大长度。 + EmployeeCollectionExternalTransactionNoMaxLength = 128 + // EmployeeCollectionVoucherMinCount 表示一次申请要求的最少支付凭证数量。 + EmployeeCollectionVoucherMinCount = 1 + // EmployeeCollectionVoucherMaxCount 表示一次申请允许的最多支付凭证数量。 + EmployeeCollectionVoucherMaxCount = 5 + // EmployeeCollectionVoucherKeyMaxLength 表示单个支付凭证对象键最大长度。 + EmployeeCollectionVoucherKeyMaxLength = 512 + // EmployeeCollectionAllocationMaxCount 表示一次申请允许的最多账单分摊数量。 + EmployeeCollectionAllocationMaxCount = 50 +) + +// 员工代收款审计配置键前缀。 +const ( + // EmployeeCollectionAuditConfigKeyPrefix 表示线下收款方式字典审计配置键前缀。 + EmployeeCollectionAuditConfigKeyPrefix = "employee_collection.payment_method" + // EmployeeCollectionAuditModule 表示员工代收款能力的审计模块标识。 + EmployeeCollectionAuditModule = "employee_collection" +) + +// GetEmployeeCollectionBillStatusName 返回员工代收款账单状态的中文名称。 +func GetEmployeeCollectionBillStatusName(status int) string { + switch status { + case EmployeeCollectionBillStatusPending: + return "待核销" + case EmployeeCollectionBillStatusPartial: + return "部分核销" + case EmployeeCollectionBillStatusSettled: + return "已核销" + case EmployeeCollectionBillStatusClosed: + return "已关闭" + default: + return "未知状态" + } +} + +// GetEmployeeCollectionApplicationStatusName 返回核销申请状态的中文名称。 +func GetEmployeeCollectionApplicationStatusName(status int) string { + switch status { + case EmployeeCollectionApplicationStatusPending: + return "审批中" + case EmployeeCollectionApplicationStatusApproved: + return "已通过" + case EmployeeCollectionApplicationStatusRejected: + return "已驳回" + case EmployeeCollectionApplicationStatusRevoked: + return "已撤销或已关闭" + default: + return "未知状态" + } +} + +// GetEmployeeCollectionAllocationStatusName 返回核销分摊状态的中文名称。 +func GetEmployeeCollectionAllocationStatusName(status int) string { + switch status { + case EmployeeCollectionAllocationStatusPending: + return "审批中预占" + case EmployeeCollectionAllocationStatusApproved: + return "已通过" + case EmployeeCollectionAllocationStatusReleased: + return "已驳回或已释放" + default: + return "未知状态" + } +} + +// GetEmployeeCollectionSourceTypeName 返回员工代收款账单来源类型的中文名称。 +func GetEmployeeCollectionSourceTypeName(sourceType string) string { + switch sourceType { + case EmployeeCollectionSourceTypeOrder: + return "后台线下套餐订单" + case EmployeeCollectionSourceTypeRecharge: + return "代理线下充值" + default: + return "未知来源" + } +} + +// GetEmployeeCollectionRefundOutcomeName 返回退款冲销处理结果的中文名称。 +func GetEmployeeCollectionRefundOutcomeName(outcome string) string { + switch outcome { + case EmployeeCollectionRefundOutcomeClosedFull: + return "来源订单全额退款关闭" + case EmployeeCollectionRefundOutcomeReduced: + return "按退款金额冲减应收" + case EmployeeCollectionRefundOutcomeHintOnly: + return "仅记录退款关联提示" + default: + return "未知结果" + } +} diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index 2dcdeb7..1b6c572 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -176,6 +176,22 @@ const ( // 审计留存相关错误 (1220-1229) CodeAuditDataArchived = 1220 // 查询范围已归档,当前不支持在线查询 + // 员工代收款账单相关错误 (1230-1249) + CodeEmployeeCollectionPaymentMethodNotFound = 1230 // 线下收款方式不存在 + CodeEmployeeCollectionPaymentMethodCodeExists = 1231 // 线下收款方式编码已存在 + CodeEmployeeCollectionPaymentMethodReferenced = 1232 // 线下收款方式已被引用,只能停用 + CodeEmployeeCollectionPaymentMethodDisabled = 1233 // 线下收款方式已停用 + CodeEmployeeCollectionBillNotFound = 1234 // 员工代收款账单不存在 + CodeEmployeeCollectionBillNotSettleable = 1235 // 账单状态不允许核销 + CodeEmployeeCollectionAllocationExceeded = 1236 // 分摊金额超过账单可核销余额 + CodeEmployeeCollectionAllocationAmountInvalid = 1237 // 分摊金额必须大于零 + CodeEmployeeCollectionPaidAmountExceeded = 1238 // 分摊总额超过本次付款金额 + CodeEmployeeCollectionApplicationNotFound = 1239 // 核销申请不存在 + CodeEmployeeCollectionApplicationStatusInvalid = 1240 // 核销申请状态不允许此操作 + CodeEmployeeCollectionApplicationPending = 1241 // 账单存在审批中核销申请,不能关闭 + CodeEmployeeCollectionBillClosed = 1242 // 账单已关闭 + CodeEmployeeCollectionVoucherInvalid = 1243 // 支付凭证数量或内容不符合要求 + // 服务端错误 (2000-2999) -> 5xx HTTP 状态码 CodeInternalError = 2001 // 内部服务器错误 CodeDatabaseError = 2002 // 数据库错误 @@ -320,6 +336,20 @@ var allErrorCodes = []int{ CodeWeComApplicationNotFound, CodeWeComCredentialInvalid, CodeAuditDataArchived, + CodeEmployeeCollectionPaymentMethodNotFound, + CodeEmployeeCollectionPaymentMethodCodeExists, + CodeEmployeeCollectionPaymentMethodReferenced, + CodeEmployeeCollectionPaymentMethodDisabled, + CodeEmployeeCollectionBillNotFound, + CodeEmployeeCollectionBillNotSettleable, + CodeEmployeeCollectionAllocationExceeded, + CodeEmployeeCollectionAllocationAmountInvalid, + CodeEmployeeCollectionPaidAmountExceeded, + CodeEmployeeCollectionApplicationNotFound, + CodeEmployeeCollectionApplicationStatusInvalid, + CodeEmployeeCollectionApplicationPending, + CodeEmployeeCollectionBillClosed, + CodeEmployeeCollectionVoucherInvalid, CodeInternalError, CodeDatabaseError, CodeRedisError, @@ -338,144 +368,158 @@ func init() { // errorMessages 错误消息映射表(中文) var errorMessages = map[int]string{ - CodeSuccess: "成功", - CodeInvalidParam: "参数验证失败", - CodeMissingToken: "缺失认证令牌", - CodeInvalidToken: "无效或过期的令牌", - CodeUnauthorized: "未授权访问", - CodeForbidden: "禁止访问", - CodeNotFound: "资源未找到", - CodeConflict: "资源冲突", - CodeTooManyRequests: "请求过多,请稍后重试", - CodeRequestTooLarge: "请求体过大", - CodeAccountNotFound: "账号不存在", - CodeAccountDisabled: "账号已禁用", - CodeAccountDeleted: "账号已删除", - CodeUsernameExists: "用户名已存在", - CodePhoneExists: "手机号已存在", - CodeInvalidPassword: "密码格式不正确", - CodePasswordTooWeak: "密码强度不足", - CodeParentIDRequired: "非 root 用户必须提供上级账号", - CodeInvalidParentID: "上级账号不存在或无效", - CodeCannotModifyParent: "禁止修改上级账号", - CodeCannotModifyUserType: "禁止修改用户类型", - CodeRoleNotFound: "角色不存在", - CodeRoleNameExists: "角色名称已存在", - CodePermissionNotFound: "权限不存在", - CodePermCodeExists: "权限编码已存在", - CodeInvalidPermCode: "权限编码格式不正确(应为 module:action 格式)", - CodeRoleAlreadyAssigned: "角色已分配", - CodePermAlreadyAssigned: "权限已分配", - CodeRoleInUse: "角色使用中,无法删除", - CodeShopNotFound: "店铺不存在", - CodeShopCodeExists: "店铺编号已存在", - CodeShopLevelExceeded: "店铺层级不能超过 7 级", - CodeEnterpriseNotFound: "企业不存在", - CodeEnterpriseCodeExists: "企业编号已存在", - CodeCustomerNotFound: "个人客户不存在", - CodeCustomerPhoneExists: "个人客户手机号已存在", - CodeShopDisabled: "店铺已禁用", - CodeInvalidStatus: "状态不允许此操作", - CodeInsufficientBalance: "余额不足", - CodeWithdrawalNotFound: "提现申请不存在", - CodeWalletNotFound: "钱包不存在", - CodeInsufficientQuota: "额度不足", - CodeIotCardNotFound: "IoT 卡不存在", - CodeIotCardBoundToDevice: "IoT 卡已绑定设备,不能单独操作", - CodeIotCardStatusNotAllowed: "卡状态不允许此操作", - CodeAssetAllocationRecordNotFound: "分配记录不存在", - CodeNotDirectSubordinate: "只能操作直属下级店铺", - CodeCannotAllocateToSelf: "不能分配给自己", - CodeCannotRecallFromSelf: "不能从自己回收", - CodeCardAlreadyAuthorized: "卡已授权给该企业", - CodeCardNotAuthorized: "卡未授权给该企业", - CodeCannotAuthorizeOthersCard: "不能授权非自己的卡", - CodeCannotRevokeOthersAuthorization: "不能回收非自己创建的授权", - CodeCannotAuthorizeBoundCard: "不能授权已绑定设备的卡", - CodeCannotAuthorizeToOthersEnterprise: "不能授权给非自己的企业", - CodeDeviceAlreadyAuthorized: "设备已授权给该企业", - CodeDeviceNotAuthorized: "设备未授权给该企业", - CodeDeviceAuthorizedToOther: "设备已授权给其他企业", - CodeCannotAuthorizeOthersDevice: "不能授权非自己的设备", - CodeStorageNotConfigured: "对象存储服务未配置", - CodeStorageUploadFailed: "文件上传失败", - CodeStorageDownloadFailed: "文件下载失败", - CodeStorageFileNotFound: "文件不存在", - CodeStorageInvalidPurpose: "不支持的文件用途", - CodeStorageInvalidFileType: "不支持的文件类型", - CodeCarrierNotFound: "运营商不存在", - CodeCarrierCodeExists: "运营商编码已存在", - CodeGatewayError: "Gateway 请求失败", - CodeGatewayEncryptError: "数据加密失败", - CodeGatewaySignError: "签名生成失败", - CodeGatewayTimeout: "Gateway 请求超时", - CodeGatewayInvalidResp: "Gateway 响应格式错误", - CodeRechargeAmountInvalid: "充值金额不符合要求", - CodeRechargeNotFound: "充值订单不存在", - CodeRechargeAlreadyPaid: "充值订单已支付", - CodePurchaseOnBehalfForbidden: "无权使用线下支付", - CodePurchaseOnBehalfInvalidTarget: "代购目标无效", - CodeForceRechargeRequired: "必须充值指定金额", - CodeForceRechargeAmountMismatch: "强充金额不匹配", - CodePollingConfigNotFound: "轮询配置不存在", - CodePollingConfigNameExists: "轮询配置名称已存在", - CodePollingQueueFull: "轮询队列已满", - CodePollingConcurrencyLimit: "并发数已达上限", - CodePollingAlertRuleNotFound: "告警规则不存在", - CodePollingCleanupConfigNotFound: "数据清理配置不存在", - CodePollingManualTriggerLimit: "手动触发次数已达上限", - CodeNoAvailablePackage: "没有可用套餐", - CodePackageActivationConflict: "套餐正在激活中,请稍后重试", - CodeNoMainPackage: "必须有主套餐才能购买加油包", - CodeRealnameRequired: "设备/卡必须先完成实名认证才能购买套餐", - CodeMixedOrderForbidden: "同订单不能同时购买正式套餐和加油包", - CodeMainPackageExists: "已有主套餐,不能重复购买主套餐", - CodeWechatConfigNotFound: "微信支付配置不存在", - CodeWechatConfigActive: "不能删除当前生效的支付配置,请先停用", - CodeWechatConfigHasPendingOrders: "该配置存在未完成的支付订单,暂时无法删除", - CodeFuiouPayFailed: "支付发起失败,请重试", - CodeFuiouCallbackInvalid: "支付回调签名验证失败", - CodeNoPaymentConfig: "当前无可用的支付配置,请联系管理员", - CodeAssetNotFound: "资产不存在", - CodeWechatConfigUnavailable: "微信配置不可用", - CodeSmsSendFailed: "短信发送失败", - CodeVerificationCodeInvalid: "验证码错误或已过期", - CodePhoneAlreadyBound: "手机号已被其他客户绑定", - CodeAlreadyBoundPhone: "当前客户已绑定手机号,不可重复绑定", - CodeOldPhoneMismatch: "旧手机号与当前绑定不匹配", - CodeNeedRealname: "该套餐需实名认证后购买", - CodeOpenIDNotFound: "未找到微信授权信息,请先完成授权", - CodeRealnameNotAvailable: "请先完成充值或购买套餐后再进行实名认证", - CodeExchangeOrderNotFound: "换货单不存在或无权限", - CodeExchangeInProgress: "该资产存在进行中的换货单", - CodeExchangeStatusInvalid: "换货单当前状态不允许此操作", - CodeExchangeAssetTypeMismatch: "换货资产类型必须一致(卡换卡/设备换设备)", - CodeExchangeNewAssetNotInStock: "新资产非在库状态,不可用于换货", - CodeExchangeAssetNotExchanged: "资产当前状态不允许转新", - CodeExchangeMigrationFailed: "换货数据迁移失败", - CodeExchangeActiveRefund: "该资产存在退款申请", - CodePaymentMethodUnavailable: "当前资产不支持所选支付方式", - CodeWeComApplicationNotFound: "企业微信应用配置不存在或已禁用", - CodeWeComCredentialInvalid: "企业微信凭据配置无效", - CodeAuditDataArchived: "数据已归档,第一阶段不支持在线查询", - CodeInvalidCredentials: "用户名或密码错误", - CodeAccountLocked: "账号已锁定", - CodePasswordExpired: "密码已过期", - CodeInvalidOldPassword: "旧密码错误", - CodeWechatOAuthFailed: "微信授权失败", - CodeWechatUserInfoFailed: "获取微信用户信息失败", - CodeWechatPayFailed: "微信支付发起失败", - CodeWechatCallbackInvalid: "微信回调验证失败", - CodeOpenAPIAuthFailed: "开放接口认证失败", - CodeOpenAPISignInvalid: "开放接口签名无效", - CodeOpenAPITimestampInvalid: "开放接口时间戳无效", - CodeOpenAPINonceReplay: "重复请求,请勿重放", - CodeInternalError: "内部服务器错误", - CodeDatabaseError: "数据库错误", - CodeRedisError: "缓存服务错误", - CodeServiceUnavailable: "服务暂时不可用", - CodeTimeout: "请求超时", - CodeTaskQueueError: "任务队列错误", + CodeSuccess: "成功", + CodeInvalidParam: "参数验证失败", + CodeMissingToken: "缺失认证令牌", + CodeInvalidToken: "无效或过期的令牌", + CodeUnauthorized: "未授权访问", + CodeForbidden: "禁止访问", + CodeNotFound: "资源未找到", + CodeConflict: "资源冲突", + CodeTooManyRequests: "请求过多,请稍后重试", + CodeRequestTooLarge: "请求体过大", + CodeAccountNotFound: "账号不存在", + CodeAccountDisabled: "账号已禁用", + CodeAccountDeleted: "账号已删除", + CodeUsernameExists: "用户名已存在", + CodePhoneExists: "手机号已存在", + CodeInvalidPassword: "密码格式不正确", + CodePasswordTooWeak: "密码强度不足", + CodeParentIDRequired: "非 root 用户必须提供上级账号", + CodeInvalidParentID: "上级账号不存在或无效", + CodeCannotModifyParent: "禁止修改上级账号", + CodeCannotModifyUserType: "禁止修改用户类型", + CodeRoleNotFound: "角色不存在", + CodeRoleNameExists: "角色名称已存在", + CodePermissionNotFound: "权限不存在", + CodePermCodeExists: "权限编码已存在", + CodeInvalidPermCode: "权限编码格式不正确(应为 module:action 格式)", + CodeRoleAlreadyAssigned: "角色已分配", + CodePermAlreadyAssigned: "权限已分配", + CodeRoleInUse: "角色使用中,无法删除", + CodeShopNotFound: "店铺不存在", + CodeShopCodeExists: "店铺编号已存在", + CodeShopLevelExceeded: "店铺层级不能超过 7 级", + CodeEnterpriseNotFound: "企业不存在", + CodeEnterpriseCodeExists: "企业编号已存在", + CodeCustomerNotFound: "个人客户不存在", + CodeCustomerPhoneExists: "个人客户手机号已存在", + CodeShopDisabled: "店铺已禁用", + CodeInvalidStatus: "状态不允许此操作", + CodeInsufficientBalance: "余额不足", + CodeWithdrawalNotFound: "提现申请不存在", + CodeWalletNotFound: "钱包不存在", + CodeInsufficientQuota: "额度不足", + CodeIotCardNotFound: "IoT 卡不存在", + CodeIotCardBoundToDevice: "IoT 卡已绑定设备,不能单独操作", + CodeIotCardStatusNotAllowed: "卡状态不允许此操作", + CodeAssetAllocationRecordNotFound: "分配记录不存在", + CodeNotDirectSubordinate: "只能操作直属下级店铺", + CodeCannotAllocateToSelf: "不能分配给自己", + CodeCannotRecallFromSelf: "不能从自己回收", + CodeCardAlreadyAuthorized: "卡已授权给该企业", + CodeCardNotAuthorized: "卡未授权给该企业", + CodeCannotAuthorizeOthersCard: "不能授权非自己的卡", + CodeCannotRevokeOthersAuthorization: "不能回收非自己创建的授权", + CodeCannotAuthorizeBoundCard: "不能授权已绑定设备的卡", + CodeCannotAuthorizeToOthersEnterprise: "不能授权给非自己的企业", + CodeDeviceAlreadyAuthorized: "设备已授权给该企业", + CodeDeviceNotAuthorized: "设备未授权给该企业", + CodeDeviceAuthorizedToOther: "设备已授权给其他企业", + CodeCannotAuthorizeOthersDevice: "不能授权非自己的设备", + CodeStorageNotConfigured: "对象存储服务未配置", + CodeStorageUploadFailed: "文件上传失败", + CodeStorageDownloadFailed: "文件下载失败", + CodeStorageFileNotFound: "文件不存在", + CodeStorageInvalidPurpose: "不支持的文件用途", + CodeStorageInvalidFileType: "不支持的文件类型", + CodeCarrierNotFound: "运营商不存在", + CodeCarrierCodeExists: "运营商编码已存在", + CodeGatewayError: "Gateway 请求失败", + CodeGatewayEncryptError: "数据加密失败", + CodeGatewaySignError: "签名生成失败", + CodeGatewayTimeout: "Gateway 请求超时", + CodeGatewayInvalidResp: "Gateway 响应格式错误", + CodeRechargeAmountInvalid: "充值金额不符合要求", + CodeRechargeNotFound: "充值订单不存在", + CodeRechargeAlreadyPaid: "充值订单已支付", + CodePurchaseOnBehalfForbidden: "无权使用线下支付", + CodePurchaseOnBehalfInvalidTarget: "代购目标无效", + CodeForceRechargeRequired: "必须充值指定金额", + CodeForceRechargeAmountMismatch: "强充金额不匹配", + CodePollingConfigNotFound: "轮询配置不存在", + CodePollingConfigNameExists: "轮询配置名称已存在", + CodePollingQueueFull: "轮询队列已满", + CodePollingConcurrencyLimit: "并发数已达上限", + CodePollingAlertRuleNotFound: "告警规则不存在", + CodePollingCleanupConfigNotFound: "数据清理配置不存在", + CodePollingManualTriggerLimit: "手动触发次数已达上限", + CodeNoAvailablePackage: "没有可用套餐", + CodePackageActivationConflict: "套餐正在激活中,请稍后重试", + CodeNoMainPackage: "必须有主套餐才能购买加油包", + CodeRealnameRequired: "设备/卡必须先完成实名认证才能购买套餐", + CodeMixedOrderForbidden: "同订单不能同时购买正式套餐和加油包", + CodeMainPackageExists: "已有主套餐,不能重复购买主套餐", + CodeWechatConfigNotFound: "微信支付配置不存在", + CodeWechatConfigActive: "不能删除当前生效的支付配置,请先停用", + CodeWechatConfigHasPendingOrders: "该配置存在未完成的支付订单,暂时无法删除", + CodeFuiouPayFailed: "支付发起失败,请重试", + CodeFuiouCallbackInvalid: "支付回调签名验证失败", + CodeNoPaymentConfig: "当前无可用的支付配置,请联系管理员", + CodeAssetNotFound: "资产不存在", + CodeWechatConfigUnavailable: "微信配置不可用", + CodeSmsSendFailed: "短信发送失败", + CodeVerificationCodeInvalid: "验证码错误或已过期", + CodePhoneAlreadyBound: "手机号已被其他客户绑定", + CodeAlreadyBoundPhone: "当前客户已绑定手机号,不可重复绑定", + CodeOldPhoneMismatch: "旧手机号与当前绑定不匹配", + CodeNeedRealname: "该套餐需实名认证后购买", + CodeOpenIDNotFound: "未找到微信授权信息,请先完成授权", + CodeRealnameNotAvailable: "请先完成充值或购买套餐后再进行实名认证", + CodeExchangeOrderNotFound: "换货单不存在或无权限", + CodeExchangeInProgress: "该资产存在进行中的换货单", + CodeExchangeStatusInvalid: "换货单当前状态不允许此操作", + CodeExchangeAssetTypeMismatch: "换货资产类型必须一致(卡换卡/设备换设备)", + CodeExchangeNewAssetNotInStock: "新资产非在库状态,不可用于换货", + CodeExchangeAssetNotExchanged: "资产当前状态不允许转新", + CodeExchangeMigrationFailed: "换货数据迁移失败", + CodeExchangeActiveRefund: "该资产存在退款申请", + CodePaymentMethodUnavailable: "当前资产不支持所选支付方式", + CodeWeComApplicationNotFound: "企业微信应用配置不存在或已禁用", + CodeWeComCredentialInvalid: "企业微信凭据配置无效", + CodeAuditDataArchived: "数据已归档,第一阶段不支持在线查询", + CodeEmployeeCollectionPaymentMethodNotFound: "线下收款方式不存在", + CodeEmployeeCollectionPaymentMethodCodeExists: "线下收款方式编码已存在", + CodeEmployeeCollectionPaymentMethodReferenced: "线下收款方式已被核销申请引用,只能停用", + CodeEmployeeCollectionPaymentMethodDisabled: "线下收款方式已停用,不能用于新的核销申请", + CodeEmployeeCollectionBillNotFound: "员工代收款账单不存在", + CodeEmployeeCollectionBillNotSettleable: "账单当前状态不允许核销", + CodeEmployeeCollectionAllocationExceeded: "分摊金额超过账单可核销余额", + CodeEmployeeCollectionAllocationAmountInvalid: "分摊金额必须大于零", + CodeEmployeeCollectionPaidAmountExceeded: "分摊总额不能超过本次付款金额", + CodeEmployeeCollectionApplicationNotFound: "核销申请不存在", + CodeEmployeeCollectionApplicationStatusInvalid: "核销申请当前状态不允许此操作", + CodeEmployeeCollectionApplicationPending: "账单存在审批中核销申请,不能关闭", + CodeEmployeeCollectionBillClosed: "账单已关闭", + CodeEmployeeCollectionVoucherInvalid: "支付凭证数量或内容不符合要求", + CodeInvalidCredentials: "用户名或密码错误", + CodeAccountLocked: "账号已锁定", + CodePasswordExpired: "密码已过期", + CodeInvalidOldPassword: "旧密码错误", + CodeWechatOAuthFailed: "微信授权失败", + CodeWechatUserInfoFailed: "获取微信用户信息失败", + CodeWechatPayFailed: "微信支付发起失败", + CodeWechatCallbackInvalid: "微信回调验证失败", + CodeOpenAPIAuthFailed: "开放接口认证失败", + CodeOpenAPISignInvalid: "开放接口签名无效", + CodeOpenAPITimestampInvalid: "开放接口时间戳无效", + CodeOpenAPINonceReplay: "重复请求,请勿重放", + CodeInternalError: "内部服务器错误", + CodeDatabaseError: "数据库错误", + CodeRedisError: "缓存服务错误", + CodeServiceUnavailable: "服务暂时不可用", + CodeTimeout: "请求超时", + CodeTaskQueueError: "任务队列错误", } // GetMessage 获取错误码对应的消息 @@ -502,7 +546,10 @@ func GetHTTPStatus(code int) int { return 401 // Unauthorized case CodeForbidden: return 403 // Forbidden - case CodeNotFound: + case CodeNotFound, + CodeEmployeeCollectionPaymentMethodNotFound, + CodeEmployeeCollectionBillNotFound, + CodeEmployeeCollectionApplicationNotFound: return 404 // Not Found case CodeAuditDataArchived: return 410 // Gone @@ -515,6 +562,13 @@ func GetHTTPStatus(code int) int { CodeEnterpriseCodeExists, CodeCustomerPhoneExists, CodeCarrierCodeExists, + CodeEmployeeCollectionPaymentMethodCodeExists, + CodeEmployeeCollectionPaymentMethodReferenced, + CodeEmployeeCollectionPaymentMethodDisabled, + CodeEmployeeCollectionBillNotSettleable, + CodeEmployeeCollectionApplicationStatusInvalid, + CodeEmployeeCollectionApplicationPending, + CodeEmployeeCollectionBillClosed, CodeOpenAPINonceReplay: return 409 // Conflict case CodeTooManyRequests: diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 991fd6d..0e94165 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -69,6 +69,7 @@ func BuildDocHandlers() *bootstrap.Handlers { AssetWallet: admin.NewAssetWalletHandler(nil), WechatConfig: admin.NewWechatConfigHandler(nil), PaymentMerchant: admin.NewPaymentMerchantHandler(nil), + EmployeeCollection: admin.NewEmployeeCollectionHandler(nil, nil, nil), AgentRecharge: admin.NewAgentRechargeHandler(nil, nil), Refund: admin.NewRefundHandler(nil), OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(nil),