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, "不支持的退款冲销处理结果") } }