feat(distribution): 通道费改主站承担,并按冷却期自动释放结算
利润不再扣支付通道费;站点可配置结算冷却期,到期自动入账,已释放后退款从收款站钱包回冲。同时修复当日自动批次无法追加、三方回冲打错钱包,以及测试库自增重置被 MySQL 统计缓存跳过的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,8 +6,10 @@ import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/wallet"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// distributionTablesPresent 探测分销财务表是否存在,仅首次访问查询 information_schema,
|
||||
@@ -172,7 +174,7 @@ func calculateForRule(rule model.DistributionRuleVersion, input SettlementInput)
|
||||
}
|
||||
|
||||
// SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。
|
||||
// 利润 = 租金 − 号主 − 通道手续费,再按 35%/40%/25% 切;尾差记入 RoundingAdjustmentCent。
|
||||
// 利润 = 租金 − 号主 − 加价(通道手续费由主站承担,只记录),再按 35%/40%/25% 切;尾差记入 RoundingAdjustmentCent。
|
||||
func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error {
|
||||
if !distributionTablesPresent(tx) {
|
||||
return nil
|
||||
@@ -206,6 +208,14 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
|
||||
}
|
||||
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, markup)
|
||||
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossRent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: markup, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, markup, splits), SettlementStatus: "pending"}
|
||||
// 支付回调可能重复触发:保留已写入的释放与结算时间,避免把已释放的快照重置回待释放。
|
||||
var existing model.DistributionOrderFinancial
|
||||
if err := tx.Where("order_id = ?", order.ID).First(&existing).Error; err == nil {
|
||||
financial.ReleaseAt = existing.ReleaseAt
|
||||
financial.ReleasedAt = existing.ReleasedAt
|
||||
financial.SettledAt = existing.SettledAt
|
||||
financial.SettlementStatus = existing.SettlementStatus
|
||||
}
|
||||
if err := tx.Where("order_id = ?", order.ID).Assign(financial).FirstOrCreate(&financial).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -246,7 +256,7 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
|
||||
return nil
|
||||
}
|
||||
var financial model.DistributionOrderFinancial
|
||||
if err := tx.Where("order_id = ?", orderID).First(&financial).Error; err != nil {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_id = ?", orderID).First(&financial).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
@@ -327,6 +337,15 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
|
||||
return err
|
||||
}
|
||||
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, financial.MarkupAmountCent)
|
||||
// 已释放入账的订单保持 settled,避免自动释放任务重复入账;差额通过回冲流水追偿。
|
||||
settlementStatus := "pending"
|
||||
if financial.ReleasedAt != nil {
|
||||
settlementStatus = "settled"
|
||||
}
|
||||
releaseAt, err := computeReleaseAt(tx, order, financial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&financial).Updates(map[string]any{
|
||||
"refund_amount_cent": rentRefundCent,
|
||||
"net_paid_amount_cent": result.NetPaidAmountCent,
|
||||
@@ -334,13 +353,47 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
|
||||
"service_fee_cent": result.ServiceFeeCent,
|
||||
"split_base_amount_cent": result.SplitBaseAmountCent,
|
||||
"rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, financial.MarkupAmountCent, splits),
|
||||
"settlement_status": "pending",
|
||||
"settlement_status": settlementStatus,
|
||||
"release_at": releaseAt,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, split := range splits {
|
||||
var party model.DistributionOrderParty
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("order_id = ? AND party_role = ?", orderID, split.PartyRole).First(&party).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if party.IncomeAmountCent == split.AmountCent && party.ProfitAmountCent == split.AmountCent {
|
||||
continue
|
||||
}
|
||||
// 已结算入账的分站份额被退款调减时,从收款站点钱包扣回差额(允许扣成负数,由后续收益抵扣)。
|
||||
settled, err := partySettled(tx, party.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if split.AmountCent < party.IncomeAmountCent && party.PartyType == PartyTypeSite && settled {
|
||||
reversal := party.IncomeAmountCent - split.AmountCent
|
||||
if err := wallet.AppendEntries(tx, wallet.Entry{
|
||||
UserID: 0,
|
||||
DistributionSiteID: party.PartyID,
|
||||
OrderID: &orderID,
|
||||
Direction: "out",
|
||||
AmountCent: reversal,
|
||||
BalanceType: "available",
|
||||
BizType: "distribution_reversal",
|
||||
BizNo: order.OrderNo,
|
||||
Remark: "结算入账后退款回冲",
|
||||
AllowNegative: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&model.DistributionOrderParty{}).
|
||||
Where("order_id = ? AND party_role = ?", orderID, split.PartyRole).
|
||||
Where("id = ?", party.ID).
|
||||
Updates(map[string]any{
|
||||
"income_amount_cent": split.AmountCent,
|
||||
"profit_amount_cent": split.AmountCent,
|
||||
@@ -350,3 +403,88 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureCompletedReleaseAtTx 在订单进入 completed 且尚未入账时写入 release_at。
|
||||
// 无分销快照时为 no-op,供结账、仲裁、退款重算共用,避免完单后漏写导致永远无法自动释放。
|
||||
func EnsureCompletedReleaseAtTx(tx *gorm.DB, orderID uint64) error {
|
||||
if !distributionTablesPresent(tx) {
|
||||
return nil
|
||||
}
|
||||
var financial model.DistributionOrderFinancial
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_id = ?", orderID).First(&financial).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var order model.RentalOrder
|
||||
if err := tx.Select("id, status, settled_at").First(&order, orderID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
releaseAt, err := computeReleaseAt(tx, order, financial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&financial).Update("release_at", releaseAt).Error
|
||||
}
|
||||
|
||||
// computeReleaseAt 按站点当前冷却天数计算释放时间;已入账快照保持原 release_at。
|
||||
func computeReleaseAt(tx *gorm.DB, order model.RentalOrder, financial model.DistributionOrderFinancial) (*time.Time, error) {
|
||||
if financial.ReleasedAt != nil {
|
||||
return financial.ReleaseAt, nil
|
||||
}
|
||||
if order.Status != "completed" {
|
||||
return financial.ReleaseAt, nil
|
||||
}
|
||||
completedAt := order.SettledAt
|
||||
if completedAt == nil {
|
||||
now := time.Now()
|
||||
completedAt = &now
|
||||
}
|
||||
var site model.DistributionSite
|
||||
if err := tx.Select("id, settlement_hold_days").First(&site, financial.SiteID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
due := completedAt.AddDate(0, 0, site.SettlementHoldDays)
|
||||
return &due, nil
|
||||
}
|
||||
|
||||
// rewritePendingReleaseAt 站点冷却天数变更后,按新规则回写尚未入账的已完成订单释放时间。
|
||||
func rewritePendingReleaseAt(tx *gorm.DB, siteID uint64) error {
|
||||
var financials []model.DistributionOrderFinancial
|
||||
if err := tx.Where("site_id = ? AND settlement_status = ? AND released_at IS NULL", siteID, "pending").
|
||||
Find(&financials).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, financial := range financials {
|
||||
var order model.RentalOrder
|
||||
if err := tx.Select("id, status, settled_at").First(&order, financial.OrderID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if order.Status != "completed" {
|
||||
continue
|
||||
}
|
||||
releaseAt, err := computeReleaseAt(tx, order, financial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.DistributionOrderFinancial{}).Where("id = ?", financial.ID).
|
||||
Update("release_at", releaseAt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// partySettled 判断参与方份额是否已经通过结算批次入账。
|
||||
func partySettled(tx *gorm.DB, partyID uint64) (bool, error) {
|
||||
var count int64
|
||||
if err := tx.Model(&model.DistributionSettlementItem{}).
|
||||
Where("party_id = ? AND status = 'settled'", partyID).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user