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, } }