feat(distribution): 通道费改主站承担,并按冷却期自动释放结算
利润不再扣支付通道费;站点可配置结算冷却期,到期自动入账,已释放后退款从收款站钱包回冲。同时修复当日自动批次无法追加、三方回冲打错钱包,以及测试库自增重置被 MySQL 统计缓存跳过的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"hfb_sys/backend/internal/logging"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/adminnotification"
|
||||
"hfb_sys/backend/internal/modules/distribution"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
ordermodule "hfb_sys/backend/internal/modules/order"
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
@@ -130,6 +131,11 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.Status == "completed" {
|
||||
if err := distribution.EnsureCompletedReleaseAtTx(tx, order.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Save(&listing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type SiteDTO struct {
|
||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
||||
TaxEnabled bool `json:"tax_enabled"`
|
||||
TaxRateBPS int64 `json:"tax_rate_bps"`
|
||||
SettlementHoldDays int `json:"settlement_hold_days"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -29,6 +30,7 @@ type CreateSiteRequest struct {
|
||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
||||
TaxEnabled *bool `json:"tax_enabled"`
|
||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||
SettlementHoldDays *int `json:"settlement_hold_days"`
|
||||
}
|
||||
|
||||
type UpdateSiteRequest struct {
|
||||
@@ -39,6 +41,7 @@ type UpdateSiteRequest struct {
|
||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
||||
TaxEnabled *bool `json:"tax_enabled"`
|
||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||
SettlementHoldDays *int `json:"settlement_hold_days"`
|
||||
LogoURL *string `json:"logo_url"`
|
||||
FaviconURL *string `json:"favicon_url"`
|
||||
BannersJSON *string `json:"banners_json"`
|
||||
@@ -91,17 +94,22 @@ type UpdateRuleStatusRequest struct {
|
||||
}
|
||||
|
||||
type DistributionOrderDTO struct {
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
SiteID uint64 `json:"site_id"`
|
||||
ListingID uint64 `json:"listing_id"`
|
||||
AccountID uint64 `json:"account_id"`
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
RenterID uint64 `json:"renter_id"`
|
||||
Status string `json:"status"`
|
||||
HandoffStatus string `json:"handoff_status"`
|
||||
RefundStatus string `json:"refund_status"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
SiteID uint64 `json:"site_id"`
|
||||
ListingID uint64 `json:"listing_id"`
|
||||
AccountID uint64 `json:"account_id"`
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
RenterID uint64 `json:"renter_id"`
|
||||
Status string `json:"status"`
|
||||
HandoffStatus string `json:"handoff_status"`
|
||||
RefundStatus string `json:"refund_status"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
|
||||
// 分销快照释放状态:pending=待释放,settled=已释放入账;release_at 为预计释放时间。
|
||||
DistributorSettlementStatus string `json:"distributor_settlement_status"`
|
||||
ReleaseAt *time.Time `json:"release_at,omitempty"`
|
||||
|
||||
DistributionMode string `json:"distribution_mode"`
|
||||
GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"`
|
||||
RefundAmountCent int64 `json:"refund_amount_cent"`
|
||||
@@ -318,6 +326,7 @@ type OnboardSiteRequest struct {
|
||||
DisplayAccountEnabled *bool `json:"display_account_enabled"`
|
||||
TaxEnabled *bool `json:"tax_enabled"`
|
||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||
SettlementHoldDays *int `json:"settlement_hold_days"`
|
||||
AdminUsername string `json:"admin_username" binding:"required,min=3,max=64"`
|
||||
AdminPassword string `json:"admin_password" binding:"required,min=6,max=64"`
|
||||
AdminNickname string `json:"admin_nickname"`
|
||||
@@ -403,6 +412,7 @@ type SiteWalletDTO struct {
|
||||
SiteID uint64 `json:"site_id"`
|
||||
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
||||
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
|
||||
PendingReleaseCent int64 `json:"pending_release_cent"`
|
||||
TotalSettledCent int64 `json:"total_settled_cent"`
|
||||
TotalWithdrawnCent int64 `json:"total_withdrawn_cent"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) {
|
||||
if financial.GrossPaidAmountCent != grossPaid || financial.SiteID != site.ID {
|
||||
t.Fatalf("unexpected financial snapshot: %+v", financial)
|
||||
}
|
||||
if financial.SplitBaseAmountCent != 3950 || financial.ServiceFeeCent != 988 {
|
||||
if financial.SplitBaseAmountCent != 4000 || financial.ServiceFeeCent != 1000 || financial.PaymentFeeCent != 50 {
|
||||
t.Fatalf("profit snapshot = %+v", financial)
|
||||
}
|
||||
|
||||
@@ -311,10 +311,10 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) {
|
||||
if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&parties).Error; err != nil || len(parties) != 2 {
|
||||
t.Fatalf("unexpected parties count: len=%d, err=%v", len(parties), err)
|
||||
}
|
||||
if parties[0].PartyRole != "distributor_receive" || parties[0].PartyType != PartyTypeSite || parties[0].PartyID != site.ID || parties[0].IncomeAmountCent != 1383 {
|
||||
if parties[0].PartyRole != "distributor_receive" || parties[0].PartyType != PartyTypeSite || parties[0].PartyID != site.ID || parties[0].IncomeAmountCent != 1400 {
|
||||
t.Fatalf("distributor party = %+v", parties[0])
|
||||
}
|
||||
if parties[1].PartyRole != "platform_sell" || parties[1].PartyType != PartyTypePlatform || parties[1].PartyID != 0 || parties[1].IncomeAmountCent != 1580 {
|
||||
if parties[1].PartyRole != "platform_sell" || parties[1].PartyType != PartyTypePlatform || parties[1].PartyID != 0 || parties[1].IncomeAmountCent != 1600 {
|
||||
t.Fatalf("platform party = %+v", parties[1])
|
||||
}
|
||||
|
||||
@@ -393,10 +393,10 @@ func TestSnapshotPaidOrderTxSplitsPercentOfProfit(t *testing.T) {
|
||||
if seller == nil || platform == nil {
|
||||
t.Fatalf("missing parties: %+v", parties)
|
||||
}
|
||||
if seller.IncomeAmountCent != 1580 {
|
||||
t.Fatalf("seller 40%% of profit = %d, want 1580", seller.IncomeAmountCent)
|
||||
if seller.IncomeAmountCent != 1600 {
|
||||
t.Fatalf("seller 40%% of profit = %d, want 1600", seller.IncomeAmountCent)
|
||||
}
|
||||
if platform.IncomeAmountCent != 1383 || platform.PartyID != 0 {
|
||||
if platform.IncomeAmountCent != 1400 || platform.PartyID != 0 {
|
||||
t.Fatalf("platform receive = %+v", platform)
|
||||
}
|
||||
}
|
||||
@@ -460,15 +460,15 @@ func TestApplyRefundTxWithCheckoutAndDeposit(t *testing.T) {
|
||||
if financial.NetPaidAmountCent != 53330 {
|
||||
t.Fatalf("financial.NetPaidAmountCent = %d, want 53330", financial.NetPaidAmountCent)
|
||||
}
|
||||
if financial.SplitBaseAmountCent != 6913 {
|
||||
t.Fatalf("financial.SplitBaseAmountCent = %d, want 6913", financial.SplitBaseAmountCent)
|
||||
if financial.SplitBaseAmountCent != 7180 {
|
||||
t.Fatalf("financial.SplitBaseAmountCent = %d, want 7180", financial.SplitBaseAmountCent)
|
||||
}
|
||||
|
||||
var distributorParty model.DistributionOrderParty
|
||||
if err := db.Where("order_id = ? AND party_role = 'distributor_receive'", order.ID).First(&distributorParty).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if distributorParty.IncomeAmountCent != 2420 {
|
||||
t.Fatalf("distributorParty.IncomeAmountCent = %d, want 2420", distributorParty.IncomeAmountCent)
|
||||
if distributorParty.IncomeAmountCent != 2513 {
|
||||
t.Fatalf("distributorParty.IncomeAmountCent = %d, want 2513", distributorParty.IncomeAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,8 @@ func normalizeRulePercents(row *Rule) {
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateSettlement 先扣通道手续费和号主应得,得到利润后再按 35/40/25 切。
|
||||
// CalculateSettlement 先扣号主应得与分站加价,得到利润后再按 35/40/25 切。
|
||||
// 支付通道手续费由主站承担:只记录金额供对账,不再从分成基数中扣除。
|
||||
func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, error) {
|
||||
if input.GrossPaidAmountCent < 0 || input.RefundAmountCent < 0 || input.OwnerRentAmountCent < 0 || input.MarkupAmountCent < 0 || input.RefundAmountCent > input.GrossPaidAmountCent {
|
||||
return SettlementResult{}, ErrInvalidAmount
|
||||
@@ -146,7 +147,7 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er
|
||||
}
|
||||
netPaid := input.GrossPaidAmountCent - input.RefundAmountCent
|
||||
paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000)
|
||||
profit := netPaid - input.OwnerRentAmountCent - input.MarkupAmountCent - paymentFee
|
||||
profit := netPaid - input.OwnerRentAmountCent - input.MarkupAmountCent
|
||||
if profit < 0 {
|
||||
profit = 0
|
||||
}
|
||||
|
||||
@@ -37,14 +37,14 @@ func TestNormalizeSplitPercent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateSettlementSplitsProfitAfterOwnerAndFee(t *testing.T) {
|
||||
func TestCalculateSettlementSplitsProfitWithoutPaymentFee(t *testing.T) {
|
||||
rule := Rule{ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50}
|
||||
got, err := CalculateSettlement(rule, SettlementInput{GrossPaidAmountCent: 10000, OwnerRentAmountCent: 6000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 通道费 50,利润 10000-6000-50=3950,服务费 25%=988
|
||||
if got.NetPaidAmountCent != 10000 || got.PaymentFeeCent != 50 || got.SplitBaseAmountCent != 3950 || got.ServiceFeeCent != 988 {
|
||||
// 通道费 50 由主站承担(仅记录),利润 10000-6000=4000,服务费 25%=1000
|
||||
if got.NetPaidAmountCent != 10000 || got.PaymentFeeCent != 50 || got.SplitBaseAmountCent != 4000 || got.ServiceFeeCent != 1000 {
|
||||
t.Fatalf("unexpected settlement: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/wallet"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// autoBatchPrefix 区分自动释放批次与主站财务手工创建的结算批次(DS 前缀)。
|
||||
const autoBatchPrefix = "DA"
|
||||
|
||||
// autoReleaseBlockedRefundStatuses 进行中的退款不得自动释放,避免先入账再回冲。
|
||||
var autoReleaseBlockedRefundStatuses = []string{"pending", "pending_review", "refunding"}
|
||||
|
||||
// autoReleaseBlockedDisputeStatuses 开放中的维权订单暂缓释放。
|
||||
var autoReleaseBlockedDisputeStatuses = []string{"open", "processing"}
|
||||
|
||||
// AutoReleaseDueSettlements 扫描到达释放时间的分销订单,按站点自动创建批次并释放到可提现余额。
|
||||
// 返回实际触达释放的订单数。
|
||||
func (r *Repository) AutoReleaseDueSettlements(ctx context.Context) (int, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, ErrDependencyUnavailable
|
||||
}
|
||||
if !liveMoneyMovementEnabled() {
|
||||
return 0, ErrLiveMoneyMovementFrozen
|
||||
}
|
||||
now := time.Now()
|
||||
var orderIDs []uint64
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("distribution_order_financials f").
|
||||
Select("DISTINCT f.order_id").
|
||||
Joins("JOIN rental_orders ro ON ro.id = f.order_id").
|
||||
Where("f.settlement_status = ? AND f.release_at IS NOT NULL AND f.release_at <= ?", "pending", now).
|
||||
Where("ro.status = ?", "completed").
|
||||
Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses).
|
||||
Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses).
|
||||
Order("f.order_id ASC").
|
||||
Scan(&orderIDs).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(orderIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var siteIDs []uint64
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("distribution_order_parties p").
|
||||
Select("DISTINCT p.party_id").
|
||||
Where("p.order_id IN ? AND p.party_type = ?", orderIDs, PartyTypeSite).
|
||||
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = p.id)").
|
||||
Order("p.party_id ASC").
|
||||
Scan(&siteIDs).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
released := 0
|
||||
for _, siteID := range siteIDs {
|
||||
if siteID == 0 {
|
||||
continue
|
||||
}
|
||||
count, err := r.releaseSiteSettlements(ctx, siteID, orderIDs, now)
|
||||
if err != nil {
|
||||
return released, fmt.Errorf("释放站点 %d 分销结算失败: %w", siteID, err)
|
||||
}
|
||||
released += count
|
||||
}
|
||||
return released, nil
|
||||
}
|
||||
|
||||
// releaseSiteSettlements 在事务内释放指定站点上所有到期订单的份额:
|
||||
// 复用当天的自动批次,生成结算明细并把总额入账到站点钱包。
|
||||
func (r *Repository) releaseSiteSettlements(ctx context.Context, siteID uint64, orderIDs []uint64, now time.Time) (int, error) {
|
||||
releasedOrders := 0
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 先锁财务快照再锁参与方,与 ApplyRefundTx 保持同一顺序,避免并发退款回冲时死锁。
|
||||
var lockedFinancials []model.DistributionOrderFinancial
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("order_id IN ?", orderIDs).
|
||||
Order("id ASC").Find(&lockedFinancials).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var parties []model.DistributionOrderParty
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("order_id IN ? AND party_type = ? AND party_id = ?", orderIDs, PartyTypeSite, siteID).
|
||||
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = distribution_order_parties.id)").
|
||||
Order("order_id ASC, id ASC").Find(&parties).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
releasable := make(map[uint64]bool, len(parties))
|
||||
checked := make(map[uint64]struct{}, len(parties))
|
||||
eligible := make([]model.DistributionOrderParty, 0, len(parties))
|
||||
for _, party := range parties {
|
||||
if _, done := checked[party.OrderID]; !done {
|
||||
ok, err := releasableOrder(tx, party.OrderID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
checked[party.OrderID] = struct{}{}
|
||||
releasable[party.OrderID] = ok
|
||||
}
|
||||
if !releasable[party.OrderID] {
|
||||
continue
|
||||
}
|
||||
eligible = append(eligible, party)
|
||||
}
|
||||
if len(eligible) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
batch, err := ensureAutoBatch(tx, siteID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var total int64
|
||||
orderSet := make(map[uint64]struct{}, len(eligible))
|
||||
for _, party := range eligible {
|
||||
item := model.DistributionSettlementItem{
|
||||
BatchID: batch.ID, OrderID: party.OrderID, PartyID: party.ID,
|
||||
AmountCent: party.IncomeAmountCent, Status: "settled",
|
||||
}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
total += party.IncomeAmountCent
|
||||
orderSet[party.OrderID] = struct{}{}
|
||||
}
|
||||
if total > 0 {
|
||||
if err := wallet.AppendEntries(tx, wallet.Entry{
|
||||
UserID: 0,
|
||||
DistributionSiteID: siteID,
|
||||
Direction: "in",
|
||||
AmountCent: total,
|
||||
BalanceType: "available",
|
||||
BizType: "distribution_settlement",
|
||||
BizNo: batch.BatchNo,
|
||||
Remark: fmt.Sprintf("分销自动结算释放 %s", batch.BatchNo),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&model.DistributionSettlementBatch{}).Where("id = ?", batch.ID).
|
||||
Updates(map[string]any{
|
||||
"total_amount_cent": gorm.Expr("total_amount_cent + ?", total),
|
||||
"status": "settled",
|
||||
"settled_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
orderList := make([]uint64, 0, len(orderSet))
|
||||
for orderID := range orderSet {
|
||||
orderList = append(orderList, orderID)
|
||||
}
|
||||
if err := markFinancialsSettledIfSitePartiesDone(tx, orderList, now); err != nil {
|
||||
return err
|
||||
}
|
||||
releasedOrders = len(orderList)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return releasedOrders, nil
|
||||
}
|
||||
|
||||
// releasableOrder 复核订单是否仍满足释放条件,并返回财务快照。
|
||||
// 订单在扫描后可能进入退款或维权流程,因此事务内需要重新校验。
|
||||
func releasableOrder(tx *gorm.DB, orderID uint64, now time.Time) (bool, error) {
|
||||
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 false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if financial.SettlementStatus != "pending" || financial.ReleaseAt == nil || financial.ReleaseAt.After(now) {
|
||||
return false, nil
|
||||
}
|
||||
var order model.RentalOrder
|
||||
if err := tx.Select("id, status, refund_status").First(&order, orderID).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if order.Status != "completed" || slices.Contains(autoReleaseBlockedRefundStatuses, order.RefundStatus) {
|
||||
return false, nil
|
||||
}
|
||||
var openDisputes int64
|
||||
if err := tx.Table("disputes").
|
||||
Where("order_id = ? AND status IN ?", orderID, autoReleaseBlockedDisputeStatuses).
|
||||
Count(&openDisputes).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return openDisputes == 0, nil
|
||||
}
|
||||
|
||||
// ensureAutoBatch 复用当天的自动批次(不论 pending/settled),没有则创建,保证对账按站点按天聚合。
|
||||
func ensureAutoBatch(tx *gorm.DB, siteID uint64, now time.Time) (*model.DistributionSettlementBatch, error) {
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var batch model.DistributionSettlementBatch
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("site_id = ? AND batch_no LIKE ? AND period_start = ?", siteID, autoBatchPrefix+"%", startOfDay).
|
||||
Order("id ASC").First(&batch).Error
|
||||
if err == nil {
|
||||
return &batch, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
batch = model.DistributionSettlementBatch{
|
||||
SiteID: siteID,
|
||||
BatchNo: fmt.Sprintf("%s%s%d", autoBatchPrefix, now.Format("20060102150405.000000000"), siteID),
|
||||
PeriodStart: startOfDay,
|
||||
PeriodEnd: startOfDay.AddDate(0, 0, 1),
|
||||
Status: "pending",
|
||||
}
|
||||
if err := tx.Create(&batch).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &batch, nil
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestApplyRefundTxSetsReleaseAtForCompletedOrder(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
now := time.Now()
|
||||
site := mustCreateDistSite(t, db, "rel_site", "释放测试站", "rel.dist.com")
|
||||
if err := db.Model(&site).Update("settlement_hold_days", 3).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
|
||||
settledAt := now.Add(-time.Hour).Truncate(time.Second)
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-RELEASE-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000,
|
||||
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
|
||||
SettledAt: &settledAt,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ApplyRefundTx(db, order.ID, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var financial model.DistributionOrderFinancial
|
||||
if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if financial.ReleaseAt == nil {
|
||||
t.Fatalf("release_at not set: %+v", financial)
|
||||
}
|
||||
want := settledAt.AddDate(0, 0, 3)
|
||||
if !financial.ReleaseAt.Equal(want) {
|
||||
t.Fatalf("release_at = %v, want %v", financial.ReleaseAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoReleaseDueSettlements(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
now := time.Now()
|
||||
site := mustCreateDistSite(t, db, "auto_site", "自动释放站", "auto.dist.com")
|
||||
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
|
||||
past := now.Add(-time.Minute)
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-AUTO-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none",
|
||||
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
financial := model.DistributionOrderFinancial{
|
||||
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
|
||||
GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000,
|
||||
SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past,
|
||||
}
|
||||
if err := db.Create(&financial).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parties := []model.DistributionOrderParty{
|
||||
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400},
|
||||
{OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: PartyTypePlatform, PartyID: 0, IncomeAmountCent: 1600},
|
||||
}
|
||||
if err := db.Create(&parties).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
released, err := repo.AutoReleaseDueSettlements(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("AutoReleaseDueSettlements failed: %v", err)
|
||||
}
|
||||
if released != 1 {
|
||||
t.Fatalf("released = %d, want 1", released)
|
||||
}
|
||||
|
||||
var updated model.DistributionOrderFinancial
|
||||
if err := db.Where("order_id = ?", order.ID).First(&updated).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.SettlementStatus != "settled" || updated.ReleasedAt == nil {
|
||||
t.Fatalf("financial not released: %+v", updated)
|
||||
}
|
||||
|
||||
var account model.WalletAccount
|
||||
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account.AvailableBalanceCent != 1400 {
|
||||
t.Fatalf("site wallet = %d, want 1400", account.AvailableBalanceCent)
|
||||
}
|
||||
|
||||
var batch model.DistributionSettlementBatch
|
||||
if err := db.Where("site_id = ? AND status = 'settled'", site.ID).First(&batch).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch.TotalAmountCent != 1400 || len(batch.BatchNo) < 2 || batch.BatchNo[:2] != autoBatchPrefix {
|
||||
t.Fatalf("batch = %+v", batch)
|
||||
}
|
||||
|
||||
var itemCount int64
|
||||
if err := db.Model(&model.DistributionSettlementItem{}).
|
||||
Where("batch_id = ? AND status = 'settled'", batch.ID).Count(&itemCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if itemCount != 1 {
|
||||
t.Fatalf("settled items = %d, want 1", itemCount)
|
||||
}
|
||||
|
||||
// 幂等:重复执行不再释放,也不再入账。
|
||||
released, err = repo.AutoReleaseDueSettlements(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if released != 0 {
|
||||
t.Fatalf("second run released = %d, want 0", released)
|
||||
}
|
||||
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account.AvailableBalanceCent != 1400 {
|
||||
t.Fatalf("site wallet after rerun = %d, want 1400", account.AvailableBalanceCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoReleaseSkipsOrderInRefund(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
now := time.Now()
|
||||
site := mustCreateDistSite(t, db, "hold_site", "退款拦截站", "hold.dist.com")
|
||||
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
|
||||
past := now.Add(-time.Minute)
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-HOLD-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "pending_review",
|
||||
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
financial := model.DistributionOrderFinancial{
|
||||
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
|
||||
GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000,
|
||||
SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past,
|
||||
}
|
||||
if err := db.Create(&financial).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderParty{
|
||||
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive",
|
||||
PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
released, err := repo.AutoReleaseDueSettlements(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if released != 0 {
|
||||
t.Fatalf("released = %d, want 0 while refund is pending", released)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefundAfterReleaseReversesSiteWallet(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
now := time.Now()
|
||||
site := mustCreateDistSite(t, db, "rev_site", "回冲测试站", "rev.dist.com")
|
||||
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
|
||||
past := now.Add(-time.Minute)
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-REV-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none",
|
||||
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
financial := model.DistributionOrderFinancial{
|
||||
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
|
||||
GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000,
|
||||
SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past,
|
||||
}
|
||||
if err := db.Create(&financial).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&[]model.DistributionOrderParty{
|
||||
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400},
|
||||
{OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: PartyTypePlatform, PartyID: 0, IncomeAmountCent: 1600},
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 释放后退款 3000:利润 1000,分站份额降为 350,应回冲 1050。
|
||||
if err := ApplyRefundTx(db, order.ID, 3000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var account model.WalletAccount
|
||||
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account.AvailableBalanceCent != 350 {
|
||||
t.Fatalf("wallet after reversal = %d, want 350", account.AvailableBalanceCent)
|
||||
}
|
||||
var updated model.DistributionOrderFinancial
|
||||
if err := db.Where("order_id = ?", order.ID).First(&updated).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.SettlementStatus != "settled" {
|
||||
t.Fatalf("released financial must stay settled, got %s", updated.SettlementStatus)
|
||||
}
|
||||
|
||||
// 余额被提空后再次回冲应允许扣成负数,避免回冲失败。
|
||||
if err := db.Model(&model.WalletAccount{}).Where("id = ?", account.ID).
|
||||
Update("available_balance_cent", 0).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ApplyRefundTx(db, order.ID, 10000); err != nil {
|
||||
t.Fatalf("reverse with empty wallet failed: %v", err)
|
||||
}
|
||||
if err := db.Where("id = ?", account.ID).First(&account).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account.AvailableBalanceCent != -350 {
|
||||
t.Fatalf("wallet = %d, want -350 (allow negative on reversal)", account.AvailableBalanceCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCompletedReleaseAtTxSetsReleaseAt(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
now := time.Now()
|
||||
site := mustCreateDistSite(t, db, "ens_site", "完单释放站", "ens.dist.com")
|
||||
if err := db.Model(&site).Update("settlement_hold_days", 2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
|
||||
settledAt := now.Add(-time.Hour).Truncate(time.Second)
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-ENS-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000,
|
||||
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
|
||||
SettledAt: &settledAt,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := EnsureCompletedReleaseAtTx(db, order.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var financial model.DistributionOrderFinancial
|
||||
if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if financial.ReleaseAt == nil {
|
||||
t.Fatal("release_at not set")
|
||||
}
|
||||
want := settledAt.AddDate(0, 0, 2)
|
||||
if !financial.ReleaseAt.Equal(want) {
|
||||
t.Fatalf("release_at = %v, want %v", financial.ReleaseAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoReleaseAppendsSameDayBatch(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
now := time.Now()
|
||||
site := mustCreateDistSite(t, db, "day_site", "同日追加站", "day.dist.com")
|
||||
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
|
||||
past := now.Add(-time.Minute)
|
||||
|
||||
seed := func(orderNo string) {
|
||||
t.Helper()
|
||||
order := model.RentalOrder{
|
||||
OrderNo: orderNo, ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none",
|
||||
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderFinancial{
|
||||
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
|
||||
GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000,
|
||||
SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderParty{
|
||||
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive",
|
||||
PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
seed("ORD-DAY-1")
|
||||
if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 1 {
|
||||
t.Fatalf("first release = %d err=%v, want 1", released, err)
|
||||
}
|
||||
var firstBatch model.DistributionSettlementBatch
|
||||
if err := db.Where("site_id = ?", site.ID).First(&firstBatch).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seed("ORD-DAY-2")
|
||||
if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 1 {
|
||||
t.Fatalf("second release = %d err=%v, want 1", released, err)
|
||||
}
|
||||
|
||||
var batches []model.DistributionSettlementBatch
|
||||
if err := db.Where("site_id = ? AND batch_no LIKE ?", site.ID, autoBatchPrefix+"%").Find(&batches).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(batches) != 1 {
|
||||
t.Fatalf("same-day auto batches = %d, want 1", len(batches))
|
||||
}
|
||||
if batches[0].ID != firstBatch.ID {
|
||||
t.Fatalf("second release created a new batch %d, want %d", batches[0].ID, firstBatch.ID)
|
||||
}
|
||||
if batches[0].TotalAmountCent != 2800 {
|
||||
t.Fatalf("batch total = %d, want 2800", batches[0].TotalAmountCent)
|
||||
}
|
||||
var itemCount int64
|
||||
if err := db.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", batches[0].ID).Count(&itemCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if itemCount != 2 {
|
||||
t.Fatalf("batch items = %d, want 2", itemCount)
|
||||
}
|
||||
var account model.WalletAccount
|
||||
if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account.AvailableBalanceCent != 2800 {
|
||||
t.Fatalf("wallet = %d, want 2800", account.AvailableBalanceCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoReleaseSkipsRefundingOrder(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
now := time.Now()
|
||||
site := mustCreateDistSite(t, db, "rf_site", "退款中拦截站", "rf.dist.com")
|
||||
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
|
||||
past := now.Add(-time.Minute)
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-RF-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "refunding",
|
||||
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderFinancial{
|
||||
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
|
||||
GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000,
|
||||
SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderParty{
|
||||
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive",
|
||||
PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
released, err := repo.AutoReleaseDueSettlements(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if released != 0 {
|
||||
t.Fatalf("released = %d, want 0 while refunding", released)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreePartyRefundAfterReleaseReversesReceiverWallet(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
now := time.Now()
|
||||
siteA := mustCreateDistSite(t, db, "rev_a", "收号站", "reva.dist.com")
|
||||
siteB := mustCreateDistSite(t, db, "rev_b", "卖出站", "revb.dist.com")
|
||||
rule := mustCreateActiveRule(t, db, siteB.ID, ModeThreeParty, now)
|
||||
past := now.Add(-time.Minute)
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-3P-REV", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none",
|
||||
DistributionSiteID: &siteB.ID, DistributionRuleVersionID: &rule.ID,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderFinancial{
|
||||
OrderID: order.ID, SiteID: siteB.ID, RuleVersionID: rule.ID,
|
||||
GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000,
|
||||
SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&[]model.DistributionOrderParty{
|
||||
{OrderID: order.ID, SiteID: siteB.ID, PartyRole: "distributor_a", PartyType: PartyTypeSite, PartyID: siteA.ID, IncomeAmountCent: 1400, Ratio: 35},
|
||||
{OrderID: order.ID, SiteID: siteB.ID, PartyRole: "distributor_b", PartyType: PartyTypeSite, PartyID: siteB.ID, IncomeAmountCent: 1600, Ratio: 40},
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 2 {
|
||||
t.Fatalf("released = %d err=%v, want 2", released, err)
|
||||
}
|
||||
|
||||
// 退款 3000 后利润 1000:A 350、B 400;回冲必须打到 PartyID(收号站 A),不能打到快照 SiteID(卖出站 B)。
|
||||
if err := ApplyRefundTx(db, order.ID, 3000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var walletA, walletB model.WalletAccount
|
||||
if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteA.ID).First(&walletA).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteB.ID).First(&walletB).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if walletA.AvailableBalanceCent != 350 {
|
||||
t.Fatalf("receiver wallet A = %d, want 350 (clawback on PartyID)", walletA.AvailableBalanceCent)
|
||||
}
|
||||
if walletB.AvailableBalanceCent != 400 {
|
||||
t.Fatalf("seller wallet B = %d, want 400", walletB.AvailableBalanceCent)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ var (
|
||||
ErrSyncListingNotFound = errors.New("sync listing not found")
|
||||
ErrEmptyListingIDs = errors.New("listing ids required")
|
||||
ErrInvalidListingStatus = errors.New("invalid listing status")
|
||||
ErrInvalidSettlementHold = errors.New("settlement hold days must be between 0 and 365")
|
||||
)
|
||||
|
||||
const maxSyncListingIDs = 200
|
||||
@@ -55,6 +56,14 @@ func liveMoneyMovementEnabled() bool {
|
||||
return env != "production" && env != "prod"
|
||||
}
|
||||
|
||||
// normalizeSettlementHoldDays 校验站点结算冷却天数,允许 0(订单完成即释放)到 365 天。
|
||||
func normalizeSettlementHoldDays(days int) (int, error) {
|
||||
if days < 0 || days > 365 {
|
||||
return 0, ErrInvalidSettlementHold
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
@@ -232,6 +241,13 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
|
||||
if req.TaxRateBPS != nil {
|
||||
row.TaxRateBPS = *req.TaxRateBPS
|
||||
}
|
||||
if req.SettlementHoldDays != nil {
|
||||
holdDays, err := normalizeSettlementHoldDays(*req.SettlementHoldDays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.SettlementHoldDays = holdDays
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -244,52 +260,70 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
|
||||
|
||||
func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
|
||||
var row model.DistributionSite
|
||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrSiteNotFound
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrSiteNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.SiteName != nil {
|
||||
row.SiteName = strings.TrimSpace(*req.SiteName)
|
||||
}
|
||||
if req.Domain != nil {
|
||||
row.Domain = strings.TrimSpace(*req.Domain)
|
||||
}
|
||||
if req.Status != nil {
|
||||
row.Status = strings.TrimSpace(*req.Status)
|
||||
}
|
||||
if req.DisplayAccountEnabled != nil {
|
||||
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
|
||||
}
|
||||
if req.DefaultSupportGroupID != nil {
|
||||
row.DefaultSupportGroupID = req.DefaultSupportGroupID
|
||||
}
|
||||
if req.TaxEnabled != nil {
|
||||
row.TaxEnabled = *req.TaxEnabled
|
||||
}
|
||||
if req.TaxRateBPS != nil {
|
||||
row.TaxRateBPS = *req.TaxRateBPS
|
||||
}
|
||||
if req.LogoURL != nil {
|
||||
row.LogoURL = strings.TrimSpace(*req.LogoURL)
|
||||
}
|
||||
if req.FaviconURL != nil {
|
||||
row.FaviconURL = strings.TrimSpace(*req.FaviconURL)
|
||||
}
|
||||
if req.BannersJSON != nil {
|
||||
row.BannersJSON = *req.BannersJSON
|
||||
}
|
||||
if req.AnnouncementsJSON != nil {
|
||||
row.AnnouncementsJSON = *req.AnnouncementsJSON
|
||||
}
|
||||
if req.FooterInfoJSON != nil {
|
||||
row.FooterInfoJSON = *req.FooterInfoJSON
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
if err := r.ensureSiteAccessWithDB(ctx, tx, adminID, row.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.SiteName != nil {
|
||||
row.SiteName = strings.TrimSpace(*req.SiteName)
|
||||
}
|
||||
if req.Domain != nil {
|
||||
row.Domain = strings.TrimSpace(*req.Domain)
|
||||
}
|
||||
if req.Status != nil {
|
||||
row.Status = strings.TrimSpace(*req.Status)
|
||||
}
|
||||
if req.DisplayAccountEnabled != nil {
|
||||
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
|
||||
}
|
||||
if req.DefaultSupportGroupID != nil {
|
||||
row.DefaultSupportGroupID = req.DefaultSupportGroupID
|
||||
}
|
||||
if req.TaxEnabled != nil {
|
||||
row.TaxEnabled = *req.TaxEnabled
|
||||
}
|
||||
if req.TaxRateBPS != nil {
|
||||
row.TaxRateBPS = *req.TaxRateBPS
|
||||
}
|
||||
holdDaysChanged := false
|
||||
if req.SettlementHoldDays != nil {
|
||||
holdDays, err := normalizeSettlementHoldDays(*req.SettlementHoldDays)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
holdDaysChanged = holdDays != row.SettlementHoldDays
|
||||
row.SettlementHoldDays = holdDays
|
||||
}
|
||||
if req.LogoURL != nil {
|
||||
row.LogoURL = strings.TrimSpace(*req.LogoURL)
|
||||
}
|
||||
if req.FaviconURL != nil {
|
||||
row.FaviconURL = strings.TrimSpace(*req.FaviconURL)
|
||||
}
|
||||
if req.BannersJSON != nil {
|
||||
row.BannersJSON = *req.BannersJSON
|
||||
}
|
||||
if req.AnnouncementsJSON != nil {
|
||||
row.AnnouncementsJSON = *req.AnnouncementsJSON
|
||||
}
|
||||
if req.FooterInfoJSON != nil {
|
||||
row.FooterInfoJSON = *req.FooterInfoJSON
|
||||
}
|
||||
if err := tx.Save(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if holdDaysChanged {
|
||||
return rewritePendingReleaseAt(tx, row.ID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := siteDTO(row)
|
||||
@@ -403,6 +437,7 @@ func siteDTO(row model.DistributionSite) SiteDTO {
|
||||
DefaultSupportGroupID: row.DefaultSupportGroupID,
|
||||
TaxEnabled: row.TaxEnabled,
|
||||
TaxRateBPS: row.TaxRateBPS,
|
||||
SettlementHoldDays: row.SettlementHoldDays,
|
||||
LogoURL: row.LogoURL,
|
||||
FaviconURL: row.FaviconURL,
|
||||
BannersJSON: row.BannersJSON,
|
||||
@@ -476,6 +511,8 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList
|
||||
COALESCE(f.tax_amount_cent, 0) AS tax_amount_cent,
|
||||
COALESCE(f.split_base_amount_cent, 0) AS split_base_amount_cent,
|
||||
COALESCE(f.rounding_adjustment_cent, 0) AS rounding_adjustment_cent,
|
||||
COALESCE(f.settlement_status, '') AS distributor_settlement_status,
|
||||
f.release_at AS release_at,
|
||||
ro.created_at,
|
||||
COALESCE(rl.listing_no, '') AS listing_no,
|
||||
COALESCE(ga.title, '') AS title,
|
||||
@@ -555,6 +592,8 @@ func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*Distri
|
||||
COALESCE(f.tax_amount_cent, 0) AS tax_amount_cent,
|
||||
COALESCE(f.split_base_amount_cent, 0) AS split_base_amount_cent,
|
||||
COALESCE(f.rounding_adjustment_cent, 0) AS rounding_adjustment_cent,
|
||||
COALESCE(f.settlement_status, '') AS distributor_settlement_status,
|
||||
f.release_at AS release_at,
|
||||
ro.created_at,
|
||||
COALESCE(rl.listing_no, '') AS listing_no,
|
||||
COALESCE(ga.title, '') AS title,
|
||||
@@ -1048,7 +1087,7 @@ func markFinancialsSettledIfSitePartiesDone(tx *gorm.DB, orderIDs []uint64, now
|
||||
}
|
||||
if err := tx.Model(&model.DistributionOrderFinancial{}).
|
||||
Where("order_id = ?", orderID).
|
||||
Updates(map[string]any{"settlement_status": "settled", "settled_at": now}).Error; err != nil {
|
||||
Updates(map[string]any{"settlement_status": "settled", "settled_at": now, "released_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1312,6 +1351,14 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
|
||||
if req.TaxRateBPS != nil {
|
||||
taxRate = *req.TaxRateBPS
|
||||
}
|
||||
holdDays := 7
|
||||
if req.SettlementHoldDays != nil {
|
||||
normalized, err := normalizeSettlementHoldDays(*req.SettlementHoldDays)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
holdDays = normalized
|
||||
}
|
||||
|
||||
site := model.DistributionSite{
|
||||
SiteCode: req.SiteCode,
|
||||
@@ -1321,6 +1368,7 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
|
||||
DisplayAccountEnabled: displayEnabled,
|
||||
TaxEnabled: taxEnabled,
|
||||
TaxRateBPS: taxRate,
|
||||
SettlementHoldDays: holdDays,
|
||||
}
|
||||
if err := tx.Create(&site).Error; err != nil {
|
||||
return err
|
||||
@@ -1891,6 +1939,19 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
|
||||
Where("dsb.site_id = ? AND dsi.status = 'settled'", siteID).
|
||||
Select("COALESCE(SUM(dsi.amount_cent), 0)").Scan(&totalSettled).Error
|
||||
|
||||
// 待释放收益:订单已完成、快照待释放、且尚未进入任何结算批次的站内份额。
|
||||
var pendingRelease int64
|
||||
_ = r.db.WithContext(ctx).Table("distribution_order_parties p").
|
||||
Joins("JOIN distribution_order_financials f ON f.order_id = p.order_id").
|
||||
Joins("JOIN rental_orders ro ON ro.id = p.order_id").
|
||||
Where("p.party_type = ? AND p.party_id = ?", PartyTypeSite, siteID).
|
||||
Where("f.settlement_status = 'pending' AND f.release_at IS NOT NULL").
|
||||
Where("ro.status = 'completed'").
|
||||
Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses).
|
||||
Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses).
|
||||
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = p.id)").
|
||||
Select("COALESCE(SUM(p.income_amount_cent), 0)").Scan(&pendingRelease).Error
|
||||
|
||||
var totalWithdrawn int64
|
||||
_ = r.db.WithContext(ctx).Model(&model.WithdrawalRequest{}).
|
||||
Where("user_id = 0 AND distribution_site_id = ? AND status = 'completed'", siteID).
|
||||
@@ -1900,6 +1961,7 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
|
||||
SiteID: siteID,
|
||||
AvailableBalanceCent: account.AvailableBalanceCent,
|
||||
FrozenBalanceCent: account.FrozenBalanceCent,
|
||||
PendingReleaseCent: pendingRelease,
|
||||
TotalSettledCent: totalSettled,
|
||||
TotalWithdrawnCent: totalWithdrawn,
|
||||
}, nil
|
||||
|
||||
@@ -23,6 +23,7 @@ func distributionTestDB(t *testing.T) *gorm.DB {
|
||||
&model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{},
|
||||
&model.RentalListing{}, &model.GameAccount{}, &model.User{},
|
||||
&model.WalletAccount{}, &model.WalletLedger{}, &model.WithdrawalRequest{},
|
||||
&model.Dispute{},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -984,3 +985,93 @@ func TestLiveMoneyMovementFrozenInProduction(t *testing.T) {
|
||||
t.Fatalf("withdraw err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSiteRewritesPendingReleaseAt(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
now := time.Now()
|
||||
site := mustCreateDistSite(t, db, "hold_upd", "冷却回写站", "holdupd.dist.com")
|
||||
if err := db.Model(&site).Update("settlement_hold_days", 7).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
|
||||
settledAt := now.Add(-time.Hour).Truncate(time.Second)
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-HOLD-UPD", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000,
|
||||
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
|
||||
SettledAt: &settledAt,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldRelease := settledAt.AddDate(0, 0, 7)
|
||||
if err := db.Create(&model.DistributionOrderFinancial{
|
||||
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
|
||||
GrossPaidAmountCent: 10000, SettlementStatus: "pending", ReleaseAt: &oldRelease,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
holdDays := 0
|
||||
if _, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{SettlementHoldDays: &holdDays}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var financial model.DistributionOrderFinancial
|
||||
if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if financial.ReleaseAt == nil || !financial.ReleaseAt.Equal(settledAt) {
|
||||
t.Fatalf("release_at = %v, want %v after hold days set to 0", financial.ReleaseAt, settledAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSiteWalletPendingReleaseExcludesOpenDispute(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
now := time.Now()
|
||||
site := mustCreateDistSite(t, db, "pend_dsp", "待释放纠纷站", "penddsp.dist.com")
|
||||
rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now)
|
||||
past := now.Add(-time.Minute)
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-PEND-DSP", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4,
|
||||
Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none",
|
||||
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderFinancial{
|
||||
OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID,
|
||||
GrossPaidAmountCent: 10000, SettlementStatus: "pending", ReleaseAt: &past,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionOrderParty{
|
||||
OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive",
|
||||
PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
walletDTO, err := repo.GetSiteWallet(t.Context(), 0, site.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if walletDTO.PendingReleaseCent != 1400 {
|
||||
t.Fatalf("pending release = %d, want 1400", walletDTO.PendingReleaseCent)
|
||||
}
|
||||
|
||||
if err := db.Create(&model.Dispute{
|
||||
OrderID: order.ID, InitiatorID: 4, TargetUserID: 3, Type: "cannot_login", Status: "open",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
walletDTO, err = repo.GetSiteWallet(t.Context(), 0, site.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if walletDTO.PendingReleaseCent != 0 {
|
||||
t.Fatalf("pending release with open dispute = %d, want 0", walletDTO.PendingReleaseCent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,9 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
||||
return nil, err
|
||||
}
|
||||
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
|
||||
_ = distribution.ApplyRefundTx(tx, order.ID, 0)
|
||||
if err := distribution.ApplyRefundTx(tx, order.ID, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return refund, nil
|
||||
|
||||
@@ -30,6 +30,9 @@ type Entry struct {
|
||||
BizType string
|
||||
BizNo string
|
||||
Remark string
|
||||
// AllowNegative 仅用于结算回冲等追偿场景:余额不足时允许扣成负数,
|
||||
// 由后续收益抵扣,避免回冲失败导致账实不一致。
|
||||
AllowNegative bool
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
@@ -240,7 +243,7 @@ func applyEntry(account *model.WalletAccount, entry Entry) (int64, error) {
|
||||
if entry.Direction == "in" {
|
||||
account.AvailableBalanceCent += entry.AmountCent
|
||||
} else {
|
||||
if account.AvailableBalanceCent < entry.AmountCent {
|
||||
if account.AvailableBalanceCent < entry.AmountCent && !entry.AllowNegative {
|
||||
return 0, ErrInsufficientBalance
|
||||
}
|
||||
account.AvailableBalanceCent -= entry.AmountCent
|
||||
|
||||
Reference in New Issue
Block a user