package distribution import ( "errors" "sync" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/wallet" "gorm.io/gorm" "gorm.io/gorm/clause" ) // 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 } } parties = append(parties, model.DistributionOrderParty{OrderID: order.ID, SiteID: *order.DistributionSiteID, 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 ('accepted', 'settled')", orderID). 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 }