fix(distribution): 修复分销结账退款分成计算逻辑与利润统计,优化分销订单表格列宽与展示体验
This commit is contained in:
@@ -139,6 +139,7 @@ type DistributionOrderDTO struct {
|
||||
type DistributionOrderListQuery struct {
|
||||
SiteID uint64
|
||||
Kind string
|
||||
Role string
|
||||
Status string
|
||||
Keyword string
|
||||
Page int
|
||||
|
||||
@@ -146,6 +146,7 @@ func (h *Handler) ListOrders(c *gin.Context) {
|
||||
result, err := h.service.ListOrders(c.Request.Context(), DistributionOrderListQuery{
|
||||
SiteID: scoped,
|
||||
Kind: c.Query("kind"),
|
||||
Role: c.Query("role"),
|
||||
Status: c.Query("status"),
|
||||
Keyword: c.Query("keyword"),
|
||||
Page: page,
|
||||
|
||||
@@ -240,7 +240,7 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
|
||||
return tx.Create(&parties).Error
|
||||
}
|
||||
|
||||
// ApplyRefundTx 按累计成功退款金额重算手续费、分成基数和各方分成,不覆盖订单创建时的规则版本。
|
||||
// ApplyRefundTx 按累计成功退款或结账结算重算手续费、分成基数和各方分成,不覆盖订单创建时的规则版本。
|
||||
func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
|
||||
if !distributionTablesPresent(tx) {
|
||||
return nil
|
||||
@@ -255,33 +255,96 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
|
||||
if refundAmountCent < 0 {
|
||||
return ErrInvalidAmount
|
||||
}
|
||||
if refundAmountCent > financial.GrossPaidAmountCent {
|
||||
refundAmountCent = financial.GrossPaidAmountCent
|
||||
}
|
||||
|
||||
var rule model.DistributionRuleVersion
|
||||
if err := tx.First(&rule, financial.RuleVersionID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var order model.RentalOrder
|
||||
ownerRent := int64(0)
|
||||
if err := tx.Select("owner_rent_amount_cent").First(&order, orderID).Error; err == nil {
|
||||
ownerRent = order.OwnerRentAmountCent
|
||||
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
|
||||
}
|
||||
}
|
||||
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: financial.GrossPaidAmountCent, RefundAmountCent: refundAmountCent, OwnerRentAmountCent: ownerRent, MarkupAmountCent: financial.MarkupAmountCent})
|
||||
|
||||
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, financial.MarkupAmountCent)
|
||||
if err := tx.Model(&financial).Updates(map[string]any{"refund_amount_cent": refundAmountCent, "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, financial.MarkupAmountCent, splits), "settlement_status": "pending"}).Error; err != nil {
|
||||
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, financial.MarkupAmountCent, splits),
|
||||
"settlement_status": "pending",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, split := range splits {
|
||||
if err := tx.Model(&model.DistributionOrderParty{}).
|
||||
Where("order_id = ? AND party_role = ?", orderID, split.PartyRole).
|
||||
Updates(map[string]any{"income_amount_cent": split.AmountCent, "profit_amount_cent": split.AmountCent}).Error; err != nil {
|
||||
Updates(map[string]any{
|
||||
"income_amount_cent": split.AmountCent,
|
||||
"profit_amount_cent": split.AmountCent,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,3 +399,76 @@ func TestSnapshotPaidOrderTxSplitsPercentOfProfit(t *testing.T) {
|
||||
t.Fatalf("platform receive = %+v", platform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRefundTxWithCheckoutAndDeposit(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
if err := db.AutoMigrate(&model.OrderCheckout{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
site := model.DistributionSite{SiteCode: "checkout_site", SiteName: "结账测试站", Status: "active"}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := model.DistributionRuleVersion{
|
||||
SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active",
|
||||
DistributorReceiveRatio: 35, PlatformSellRatio: 40,
|
||||
ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-CHECKOUT-REFUND", ListingID: 10, AccountID: 20, OwnerID: 30, RenterID: 40, Status: "renting",
|
||||
RentAmountCent: 63130, DepositAmountCent: 15500, OwnerRentAmountCent: 55950,
|
||||
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, DistributionMode: ModeTwoPartyDistributorReceive,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SnapshotPaidOrderTx(db, order.ID, 63130); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
checkout := model.OrderCheckout{
|
||||
OrderID: order.ID,
|
||||
Status: "accepted",
|
||||
RentAmountCent: 53330,
|
||||
OwnerRentAmountCent: 46150,
|
||||
DepositAmountCent: 15500,
|
||||
RenterRefundAmountCent: 25300,
|
||||
OwnerIncomeAmountCent: 46150,
|
||||
}
|
||||
if err := db.Create(&checkout).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 退款 25300(包含押金全退 15500 + 租金退款 9800)
|
||||
if err := ApplyRefundTx(db, order.ID, 25300); err != nil {
|
||||
t.Fatalf("ApplyRefundTx failed: %v", err)
|
||||
}
|
||||
|
||||
var financial model.DistributionOrderFinancial
|
||||
if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if financial.RefundAmountCent != 9800 {
|
||||
t.Fatalf("financial.RefundAmountCent = %d, want 9800 (only rent refund)", financial.RefundAmountCent)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -424,7 +424,7 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList
|
||||
Joins("LEFT JOIN users u ON u.id = ro.renter_id")
|
||||
|
||||
if query.SiteID > 0 {
|
||||
base = base.Where("ro.distribution_site_id = ?", query.SiteID)
|
||||
base = base.Where("(ro.distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND (p.site_id = ? OR p.party_id = ?)))", query.SiteID, query.SiteID, query.SiteID)
|
||||
}
|
||||
if query.Status != "" {
|
||||
base = base.Where("ro.status = ?", query.Status)
|
||||
@@ -435,7 +435,13 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList
|
||||
}
|
||||
switch strings.TrimSpace(query.Kind) {
|
||||
case "sold":
|
||||
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_sell','distributor_b'))")
|
||||
if query.Role == "sell" {
|
||||
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_sell','distributor_b'))")
|
||||
} else if query.Role == "receive" {
|
||||
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_receive','distributor_a'))")
|
||||
} else {
|
||||
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_sell','distributor_b','distributor_receive','distributor_a'))")
|
||||
}
|
||||
case "receive":
|
||||
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_receive','distributor_a'))")
|
||||
case "", "all":
|
||||
@@ -486,7 +492,7 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList
|
||||
var parties []model.DistributionOrderParty
|
||||
pq := r.db.WithContext(ctx).Where("order_id IN ?", orderIDs)
|
||||
if query.SiteID > 0 {
|
||||
pq = pq.Where("site_id = ?", query.SiteID)
|
||||
pq = pq.Where("(site_id = ? OR party_id = ?)", query.SiteID, query.SiteID)
|
||||
}
|
||||
if err := pq.Find(&parties).Error; err == nil {
|
||||
partiesByOrder := make(map[uint64][]model.DistributionOrderParty)
|
||||
@@ -500,6 +506,9 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList
|
||||
var splitCent int64
|
||||
var roles []string
|
||||
for _, p := range plist {
|
||||
if p.PartyType != PartyTypeSite {
|
||||
continue
|
||||
}
|
||||
totalEarning += p.IncomeAmountCent
|
||||
if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" {
|
||||
roles = append(roles, "sell")
|
||||
@@ -566,12 +575,15 @@ func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*Distri
|
||||
return nil, err
|
||||
}
|
||||
var parties []model.DistributionOrderParty
|
||||
if err := r.db.WithContext(ctx).Where("order_id = ? AND site_id = ?", item.OrderID, item.SiteID).Find(&parties).Error; err == nil {
|
||||
if err := r.db.WithContext(ctx).Where("order_id = ? AND (site_id = ? OR party_id = ?)", item.OrderID, item.SiteID, item.SiteID).Find(&parties).Error; err == nil {
|
||||
var totalEarning int64
|
||||
var markupCent int64
|
||||
var splitCent int64
|
||||
var roles []string
|
||||
for _, p := range parties {
|
||||
if p.PartyType != PartyTypeSite {
|
||||
continue
|
||||
}
|
||||
totalEarning += p.IncomeAmountCent
|
||||
if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" {
|
||||
roles = append(roles, "sell")
|
||||
@@ -2079,12 +2091,12 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID
|
||||
|
||||
var todayProfit int64
|
||||
_ = r.db.WithContext(ctx).Model(&model.DistributionOrderParty{}).
|
||||
Where("site_id = ? AND created_at >= ?", siteID, todayStr+" 00:00:00").
|
||||
Where("(site_id = ? OR party_id = ?) AND party_type = 'site' AND created_at >= ?", siteID, siteID, todayStr+" 00:00:00").
|
||||
Select("COALESCE(SUM(profit_amount_cent), 0)").Scan(&todayProfit).Error
|
||||
|
||||
var monthProfit int64
|
||||
_ = r.db.WithContext(ctx).Model(&model.DistributionOrderParty{}).
|
||||
Where("site_id = ? AND created_at >= ?", siteID, monthStr+"-01 00:00:00").
|
||||
Where("(site_id = ? OR party_id = ?) AND party_type = 'site' AND created_at >= ?", siteID, siteID, monthStr+"-01 00:00:00").
|
||||
Select("COALESCE(SUM(profit_amount_cent), 0)").Scan(&monthProfit).Error
|
||||
|
||||
var wallet model.WalletAccount
|
||||
@@ -2101,41 +2113,49 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID
|
||||
var tRows []trendRow
|
||||
startDate := time.Now().AddDate(0, 0, -6).Format("2006-01-02")
|
||||
dateFormatExpr := "DATE_FORMAT(ro.created_at, '%Y-%m-%d')"
|
||||
dateFormatPartyExpr := "DATE_FORMAT(created_at, '%Y-%m-%d')"
|
||||
if r.db != nil && r.db.Dialector != nil && r.db.Dialector.Name() == "sqlite" {
|
||||
dateFormatExpr = "strftime('%Y-%m-%d', ro.created_at)"
|
||||
dateFormatPartyExpr = "strftime('%Y-%m-%d', created_at)"
|
||||
}
|
||||
selectTrendSQL := fmt.Sprintf("%s AS date_str, COUNT(ro.id) AS orders, COALESCE(SUM(f.gross_paid_amount_cent), 0) AS gmv", dateFormatExpr)
|
||||
_ = r.db.WithContext(ctx).Table("rental_orders ro").
|
||||
Joins("JOIN distribution_order_financials f ON f.order_id = ro.id").
|
||||
Where("ro.distribution_site_id = ? AND ro.created_at >= ?", siteID, startDate+" 00:00:00").
|
||||
Where("(ro.distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND (p.site_id = ? OR p.party_id = ?))) AND ro.created_at >= ?", siteID, siteID, siteID, startDate+" 00:00:00").
|
||||
Select(selectTrendSQL).
|
||||
Group(dateFormatExpr).
|
||||
Order("date_str ASC").
|
||||
Scan(&tRows).Error
|
||||
|
||||
type profitRow struct {
|
||||
DateStr string `gorm:"column:date_str"`
|
||||
Profit int64 `gorm:"column:profit"`
|
||||
}
|
||||
var pRows []profitRow
|
||||
_ = r.db.WithContext(ctx).Model(&model.DistributionOrderParty{}).
|
||||
Where("(site_id = ? OR party_id = ?) AND party_type = 'site' AND created_at >= ?", siteID, siteID, startDate+" 00:00:00").
|
||||
Select(fmt.Sprintf("%s AS date_str, COALESCE(SUM(profit_amount_cent), 0) AS profit", dateFormatPartyExpr)).
|
||||
Group(dateFormatPartyExpr).
|
||||
Scan(&pRows).Error
|
||||
pRowMap := make(map[string]int64, len(pRows))
|
||||
for _, pr := range pRows {
|
||||
pRowMap[pr.DateStr] = pr.Profit
|
||||
}
|
||||
|
||||
tRowMap := make(map[string]trendRow, len(tRows))
|
||||
for _, tr := range tRows {
|
||||
tRowMap[tr.DateStr] = tr
|
||||
}
|
||||
for i := -6; i <= 0; i++ {
|
||||
dStr := time.Now().AddDate(0, 0, i).Format("2006-01-02")
|
||||
if tr, ok := tRowMap[dStr]; ok {
|
||||
trendDays = append(trendDays, DistributionTrendPoint{
|
||||
Date: dStr,
|
||||
ReceivedCount: tr.Received,
|
||||
OrderCount: tr.Orders,
|
||||
GMVCent: tr.GMV,
|
||||
ProfitCent: tr.Profit,
|
||||
})
|
||||
} else {
|
||||
trendDays = append(trendDays, DistributionTrendPoint{
|
||||
Date: dStr,
|
||||
ReceivedCount: 0,
|
||||
OrderCount: 0,
|
||||
GMVCent: 0,
|
||||
ProfitCent: 0,
|
||||
})
|
||||
}
|
||||
tr := tRowMap[dStr]
|
||||
trendDays = append(trendDays, DistributionTrendPoint{
|
||||
Date: dStr,
|
||||
ReceivedCount: tr.Received,
|
||||
OrderCount: tr.Orders,
|
||||
GMVCent: tr.GMV,
|
||||
ProfitCent: pRowMap[dStr],
|
||||
})
|
||||
}
|
||||
|
||||
return &DistributionAnalyticsDTO{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/distribution"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
"hfb_sys/backend/internal/modules/wallet"
|
||||
@@ -55,6 +56,9 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
||||
if err := saveFinalizedCheckout(tx, order, checkout, listing, account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
|
||||
_ = distribution.ApplyRefundTx(tx, order.ID, 0)
|
||||
}
|
||||
|
||||
return refund, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user