Files
junhong_cmp_fiber/internal/application/employeecollection/refund_offset.go
break ce24d5612e
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
feat(员工代收款): 新增员工代收款账单闭环
- 新增 6 张表与成对迁移 000212,扩展企业微信审批场景业务类型白名单
- 后台线下套餐订单与两条代理线下充值入账路径在来源成功事务内建账,来源唯一键幂等
- 核销申请、审批尝试记录、账单分摊预占与驳回重提,审批业务类型 employee_collection_approval
- 企业微信终态消费幂等:通过转已核销、驳回释放预占、通过后撤销不回滚并转异常终态
- 退款成功事务内按 bill_id+refund_id 幂等冲销账单或仅写退款关联提示
- 线下收款方式字典、账单查询/统计/关闭、申请查询与代办权限,均写入事务内审计

OpenSpec Change: add-employee-collection-bills
2026-09-10 18:24:05 +08:00

162 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "不支持的退款冲销处理结果")
}
}