Files
hfb_sys/backend/internal/modules/distribution/lifecycle.go
T
yml2213 d3d571357a refactor(order): 结账单状态常量化并锁定仲裁结单统计口径
仲裁结案会把结账单置为终态 settled,但该状态一直是裸字符串;同时 adminfinance 按日结算聚合只按
accepted 过滤,容易被误读成"仲裁单掉出统计"。本次只做卫生与口径固化,不改任何行为:
- order 新增 CheckoutStatus{Submitted,Countered,Accepted,Disputed,Settled},包内沿用非导出别名
- dispute 的仲裁结案与争议取消分支改用常量;distribution 不能反向依赖 order,本地声明同名常量
- adminfinance 聚合里的 oc.status = 'accepted' 改为常量绑定,并注释说明仲裁单不计入是有意口径
- 新增 distribution 结账单退款重算用例(该分支原先无覆盖):accepted/settled 按结账单实际租金,
  submitted 不被采用;dashboard 用例补一张 settled 结账单,钉住"不计入已结算订单数"
2026-09-16 12:49:21 +08:00

506 lines
18 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 distribution
import (
"errors"
"sync"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/wallet"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 结账单状态常量,与 order 模块的 checkoutStatus* 取值一致。
// order 模块依赖 distribution,本包不能反向 import,故在此本地声明,避免裸字符串漂移。
const (
checkoutStatusAccepted = "accepted"
checkoutStatusSettled = "settled"
)
// distributionTablesPresent 探测分销财务表是否存在,仅首次访问查询 information_schema,
// 兼容尚未执行分销迁移的旧环境,避免每次支付/退款事务都做系统表查询。
var (
distributionTablePresent bool
distributionTableCheckOnce sync.Once
)
func distributionTablesPresent(tx *gorm.DB) bool {
distributionTableCheckOnce.Do(func() {
distributionTablePresent = tx.Migrator().HasTable(&model.DistributionOrderFinancial{})
})
return distributionTablePresent
}
// resolveDistributionMode 只根据买卖双方站点判定模式,不查库、不猜规则。
// buySiteID / listingSiteID 为 0 表示主站。
func resolveDistributionMode(buySiteID, listingSiteID uint64) string {
switch {
case buySiteID == 0 && listingSiteID == 0:
return ""
case buySiteID == 0:
return ModeTwoPartyDistributorReceive
case listingSiteID == 0:
return ModeTwoPartyPlatformReceive
case buySiteID == listingSiteID:
return ModeSiteSelfSell
default:
return ModeThreeParty
}
}
func derefSiteID(id *uint64) uint64 {
if id == nil || *id == 0 {
return 0
}
return *id
}
func loadListingSiteID(tx *gorm.DB, listingID uint64) (uint64, error) {
if listingID == 0 {
return 0, nil
}
var listing model.RentalListing
if err := tx.Select("id, distribution_site_id").First(&listing, listingID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
return 0, err
}
return derefSiteID(listing.DistributionSiteID), nil
}
func loadActiveSiteByID(tx *gorm.DB, siteID uint64) (*model.DistributionSite, error) {
var site model.DistributionSite
if err := tx.Where("id = ? AND status = 'active'", siteID).First(&site).Error; err != nil {
return nil, ErrSiteNotFound
}
return &site, nil
}
func loadActiveSellSiteByCode(tx *gorm.DB, siteCode string) (*model.DistributionSite, error) {
var site model.DistributionSite
if err := tx.Where("site_code = ? AND status = 'active' AND display_account_enabled = ?", siteCode, true).First(&site).Error; err != nil {
return nil, ErrSiteNotFound
}
return &site, nil
}
func ensureAccountDisplayTx(tx *gorm.DB, siteID, listingID, accountID uint64) error {
var relation model.DistributionSiteAccount
if err := tx.Where("site_id = ? AND listing_id = ? AND account_id = ? AND display_status = 'enabled'", siteID, listingID, accountID).First(&relation).Error; err != nil {
return ErrAccountDisplayNotFound
}
return nil
}
func findActiveRuleTx(tx *gorm.DB, siteID uint64, mode string, at time.Time) (*model.DistributionRuleVersion, error) {
var rule model.DistributionRuleVersion
err := tx.Where("mode = ? AND status = 'active' AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)", mode, at, at).
Where("site_id = ? OR site_id IS NULL", siteID).
Order("CASE WHEN site_id IS NULL THEN 1 ELSE 0 END, effective_from DESC, id DESC").
First(&rule).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrRuleNotFound
}
return nil, err
}
return &rule, nil
}
// ResolveOrderSourceTx 先按买卖站点判定模式,再查该 mode 的生效规则,不再在两方规则之间猜优先级。
func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uint64, at time.Time) (*model.DistributionSite, *model.DistributionRuleVersion, error) {
listingSiteID, err := loadListingSiteID(tx, listingID)
if err != nil {
return nil, nil, err
}
var buySite *model.DistributionSite
buySiteID := uint64(0)
if siteCode != "" {
buySite, err = loadActiveSellSiteByCode(tx, siteCode)
if err != nil {
return nil, nil, err
}
if err := ensureAccountDisplayTx(tx, buySite.ID, listingID, accountID); err != nil {
return nil, nil, err
}
buySiteID = buySite.ID
}
mode := resolveDistributionMode(buySiteID, listingSiteID)
if mode == "" {
return nil, nil, nil
}
var anchor *model.DistributionSite
switch mode {
case ModeTwoPartyDistributorReceive:
anchor, err = loadActiveSiteByID(tx, listingSiteID)
if err != nil {
return nil, nil, err
}
default:
if buySite == nil {
return nil, nil, ErrSiteNotFound
}
anchor = buySite
}
rule, err := findActiveRuleTx(tx, anchor.ID, mode, at)
if err != nil {
return nil, nil, err
}
return anchor, rule, nil
}
func ruleFromVersion(rule model.DistributionRuleVersion) Rule {
parsed := Rule{
Mode: rule.Mode,
PlatformReceiveRatio: rule.PlatformReceiveRatio,
PlatformSellRatio: rule.PlatformSellRatio,
DistributorReceiveRatio: rule.DistributorReceiveRatio,
DistributorSellRatio: rule.DistributorSellRatio,
DistributorARatio: rule.DistributorARatio,
DistributorBRatio: rule.DistributorBRatio,
ServiceFeeValue: rule.ServiceFeeValue,
ServiceFeeUnit: rule.ServiceFeeUnit,
PaymentFeeBPS: rule.PaymentFeeBPS,
TaxEnabled: rule.TaxEnabled,
TaxRateBPS: rule.TaxRateBPS,
}
normalizeRulePercents(&parsed)
return parsed
}
// calculateForRule 按规则快照计算手续费、服务费和分成基数。
func calculateForRule(rule model.DistributionRuleVersion, input SettlementInput) (SettlementResult, error) {
return CalculateSettlement(ruleFromVersion(rule), input)
}
// SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。
// 利润 = 租金 − 号主 − 加价(通道手续费由主站承担,只记录),再按 35%/40%/25% 切;尾差记入 RoundingAdjustmentCent。
func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error {
if !distributionTablesPresent(tx) {
return nil
}
var order model.RentalOrder
if err := tx.First(&order, orderID).Error; err != nil {
return err
}
if order.DistributionSiteID == nil || order.DistributionRuleVersionID == nil {
return nil
}
var rule model.DistributionRuleVersion
if err := tx.First(&rule, *order.DistributionRuleVersionID).Error; err != nil {
return err
}
grossRent := order.RentAmountCent
if grossRent <= 0 {
grossRent = grossPaidCent
}
markup := order.ShelfMarkupCent
if markup < 0 {
markup = 0
}
ownerRent := order.OwnerRentAmountCent
if ownerRent < 0 {
ownerRent = 0
}
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: grossRent, OwnerRentAmountCent: ownerRent, MarkupAmountCent: markup})
if err != nil {
return err
}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, result.MarkupAmountCent)
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, result.MarkupAmountCent, 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
}
var count int64
if err := tx.Model(&model.DistributionOrderParty{}).Where("order_id = ?", order.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
parties := make([]model.DistributionOrderParty, 0, len(splits))
for _, split := range splits {
partyType := PartyTypeSite
if split.PartyRole == "platform_sell" || split.PartyRole == "platform_receive" {
partyType = PartyTypePlatform
}
partyID := *order.DistributionSiteID
if partyType == PartyTypePlatform {
partyID = 0
} else if split.PartyRole == "distributor_a" {
// 三方模式下 distributor_a 为商品收号站
var listing model.RentalListing
if err := tx.Select("distribution_site_id").First(&listing, order.ListingID).Error; err == nil && listing.DistributionSiteID != nil {
partyID = *listing.DistributionSiteID
}
}
partySiteID := *order.DistributionSiteID
if partyType == PartyTypeSite && partyID > 0 {
partySiteID = partyID
}
parties = append(parties, model.DistributionOrderParty{OrderID: order.ID, SiteID: partySiteID, PartyRole: split.PartyRole, PartyType: partyType, PartyID: partyID, Ratio: split.Ratio, IncomeAmountCent: split.AmountCent, ProfitAmountCent: split.AmountCent})
}
if len(parties) == 0 {
return nil
}
return tx.Create(&parties).Error
}
// ApplyRefundTx 按累计成功退款或结账结算重算手续费、分成基数和各方分成,不覆盖订单创建时的规则版本。
func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) 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
}
if refundAmountCent < 0 {
return ErrInvalidAmount
}
var rule model.DistributionRuleVersion
if err := tx.First(&rule, financial.RuleVersionID).Error; err != nil {
return err
}
var order model.RentalOrder
if err := tx.First(&order, orderID).Error; err != nil {
return err
}
// 判定实际租金退款和号主应得租金:
// 1. 如果存在已达成一致的结账单(accepted 或 settled),以结账单中的实际消费租金与号主实际所得为准
var checkout model.OrderCheckout
hasAcceptedCheckout := false
if err := tx.Where("order_id = ? AND status IN ?", orderID, []string{checkoutStatusAccepted, checkoutStatusSettled}).
Order("id DESC").First(&checkout).Error; err == nil {
hasAcceptedCheckout = true
}
rentRefundCent := refundAmountCent
ownerRent := order.OwnerRentAmountCent
if ownerRent < 0 {
ownerRent = 0
}
if hasAcceptedCheckout {
// 结账单中,实际有效租金为 checkout.RentAmountCent
// 真实的租金退款为 订单初始租金 - 实际有效租金
actualRentRefund := order.RentAmountCent - checkout.RentAmountCent
if actualRentRefund < 0 {
actualRentRefund = 0
}
rentRefundCent = actualRentRefund
// 号主实际租金所得以结账单的实际租金收入为准
ownerRent = checkout.OwnerRentAmountCent
if ownerRent < 0 {
ownerRent = 0
}
} else {
// 无结账单场景(例如交接超时、提前取消退款、维权全额退款):
// refundAmountCent 是租客收到的总退款(含押金与租金),押金不计入分销毛收入
// 先抵扣押金退款,剩余部分才属于租金退款
depositRefund := order.DepositAmountCent
if refundAmountCent < depositRefund {
depositRefund = refundAmountCent
}
actualRentRefund := refundAmountCent - depositRefund
if actualRentRefund < 0 {
actualRentRefund = 0
}
rentRefundCent = actualRentRefund
// 如果租金全额退款,号主应得租金为 0
if rentRefundCent >= financial.GrossPaidAmountCent {
ownerRent = 0
}
}
if rentRefundCent > financial.GrossPaidAmountCent {
rentRefundCent = financial.GrossPaidAmountCent
}
result, err := calculateForRule(rule, SettlementInput{
GrossPaidAmountCent: financial.GrossPaidAmountCent,
RefundAmountCent: rentRefundCent,
OwnerRentAmountCent: ownerRent,
MarkupAmountCent: financial.MarkupAmountCent,
})
if err != nil {
return err
}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, result.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,
"payment_fee_cent": result.PaymentFeeCent,
"service_fee_cent": result.ServiceFeeCent,
"split_base_amount_cent": result.SplitBaseAmountCent,
"rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, result.MarkupAmountCent, splits),
"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("id = ?", party.ID).
Updates(map[string]any{
"income_amount_cent": split.AmountCent,
"profit_amount_cent": split.AmountCent,
}).Error; err != nil {
return err
}
}
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 !orderStatusAllowsRelease(order.Status) {
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 !orderStatusAllowsRelease(order.Status) {
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
}
func orderStatusAllowsRelease(status string) bool {
return status == "completed" || status == "closed"
}
// 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
}