按实际纯币金额计算租客优惠与积分

This commit is contained in:
yml2213
2026-07-27 10:45:41 +08:00
parent 99e8eb28af
commit 8af7333969
30 changed files with 983 additions and 154 deletions
+7
View File
@@ -27,9 +27,16 @@ type RentalOrder struct {
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"` PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
RentOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` RentOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
RentDiscountAmountCent int64 `gorm:"not null;default:0" json:"-"` RentDiscountAmountCent int64 `gorm:"not null;default:0" json:"-"`
PureCoinOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
ExtraItemOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
ActualCoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"actual_coin_consumed_m"`
ActualPureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"`
ActualPureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"`
RenterGrowthLevel string `gorm:"size:32;not null;default:'normal'" json:"renter_growth_level"` RenterGrowthLevel string `gorm:"size:32;not null;default:'normal'" json:"renter_growth_level"`
RenterGrowthLevelName string `gorm:"size:32;not null;default:'普通'" json:"renter_growth_level_name"` RenterGrowthLevelName string `gorm:"size:32;not null;default:'普通'" json:"renter_growth_level_name"`
RenterDiscountBps int `gorm:"not null;default:10000" json:"renter_discount_bps"` RenterDiscountBps int `gorm:"not null;default:10000" json:"renter_discount_bps"`
GrowthPointsBasisCent int64 `gorm:"not null;default:0" json:"growth_points_basis_cent"`
GrowthPointsPerYuan int64 `gorm:"not null;default:0" json:"growth_points_per_yuan"`
GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"` GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"` GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"` AccountSnapshot datatypes.JSON `json:"account_snapshot"`
+3
View File
@@ -18,6 +18,9 @@ type OrderCheckout struct {
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"` OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"`
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"` PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"` DepositAmountCent int64 `gorm:"not null;default:0" json:"-"`
PureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"`
PureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"`
PureCoinPayableCent int64 `gorm:"not null;default:0" json:"-"`
ConsumableAmountCent int64 `gorm:"not null;default:0" json:"-"` ConsumableAmountCent int64 `gorm:"not null;default:0" json:"-"`
CoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"coin_consumed_m"` CoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"coin_consumed_m"`
OtherAmountCent int64 `gorm:"not null;default:0" json:"-"` OtherAmountCent int64 `gorm:"not null;default:0" json:"-"`
+2
View File
@@ -8,6 +8,8 @@ type RenterGrowthLedger struct {
UserID uint64 `gorm:"not null;index" json:"user_id"` UserID uint64 `gorm:"not null;index" json:"user_id"`
OrderID *uint64 `gorm:"index;uniqueIndex:uk_renter_growth_order_source,priority:1" json:"order_id,omitempty"` OrderID *uint64 `gorm:"index;uniqueIndex:uk_renter_growth_order_source,priority:1" json:"order_id,omitempty"`
Points int64 `gorm:"not null;default:0" json:"points"` Points int64 `gorm:"not null;default:0" json:"points"`
BasisAmountCent int64 `gorm:"not null;default:0" json:"basis_amount_cent"`
PointsPerYuan int64 `gorm:"not null;default:0" json:"points_per_yuan"`
BeforePoints int64 `gorm:"not null;default:0" json:"before_points"` BeforePoints int64 `gorm:"not null;default:0" json:"before_points"`
AfterPoints int64 `gorm:"not null;default:0" json:"after_points"` AfterPoints int64 `gorm:"not null;default:0" json:"after_points"`
BeforeLevel string `gorm:"size:32;not null;default:'normal'" json:"before_level"` BeforeLevel string `gorm:"size:32;not null;default:'normal'" json:"before_level"`
+33 -11
View File
@@ -12,6 +12,7 @@ import (
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/adminnotification" "hfb_sys/backend/internal/modules/adminnotification"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
ordermodule "hfb_sys/backend/internal/modules/order"
"hfb_sys/backend/internal/modules/rentergrowth" "hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/internal/modules/wallet" "hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/pkg/money" "hfb_sys/backend/pkg/money"
@@ -112,7 +113,23 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
return err return err
} }
if order.Status == "completed" { if order.Status == "completed" {
if err := rentergrowth.AwardOrderCompleted(tx, &order, arbitrationActualRentCent(order, settlement), rentergrowth.SourceOrderCompleted); err != nil { coinConsumedM, linkedCheckout, err := arbitrationCoinConsumedM(tx, row, req)
if err != nil {
return err
}
pureCoin := ordermodule.CalculateActualPureCoinAmounts(order, coinConsumedM)
order.ActualCoinConsumedM = pureCoin.ConsumedM
order.ActualPureCoinAmountCent = pureCoin.AmountCent
order.ActualPureCoinDiscountCent = pureCoin.DiscountCent
if linkedCheckout != nil {
linkedCheckout.PureCoinAmountCent = pureCoin.AmountCent
linkedCheckout.PureCoinDiscountCent = pureCoin.DiscountCent
linkedCheckout.PureCoinPayableCent = pureCoin.PayableCent
if err := tx.Save(linkedCheckout).Error; err != nil {
return err
}
}
if err := rentergrowth.AwardOrderCompleted(tx, &order, pureCoin.AmountCent, rentergrowth.SourceOrderCompleted); err != nil {
return err return err
} }
} }
@@ -146,6 +163,8 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
"renter_refund_amount_cent": settlement.RenterRefundAmountCent, "renter_refund_amount_cent": settlement.RenterRefundAmountCent,
"owner_income_amount_cent": settlement.OwnerIncomeAmountCent, "owner_income_amount_cent": settlement.OwnerIncomeAmountCent,
"deposit_deduct_amount_cent": settlement.DepositDeductAmountCent, "deposit_deduct_amount_cent": settlement.DepositDeductAmountCent,
"actual_coin_consumed_m": order.ActualCoinConsumedM,
"actual_pure_coin_cent": order.ActualPureCoinAmountCent,
"before_order_status": beforeOrderStatus, "before_order_status": beforeOrderStatus,
"after_order_status": order.Status, "after_order_status": order.Status,
"before_handoff_status": beforeHandoffStatus, "before_handoff_status": beforeHandoffStatus,
@@ -200,19 +219,22 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
return &dto, nil return &dto, nil
} }
func arbitrationActualRentCent(order model.RentalOrder, settlement arbitrationSettlement) int64 { func arbitrationCoinConsumedM(tx *gorm.DB, row model.Dispute, req ArbitrateRequest) (float64, *model.OrderCheckout, error) {
rentRefundCent := settlement.RenterRefundAmountCent - settlement.RenterDepositRefundCent var checkout *model.OrderCheckout
if rentRefundCent < 0 { if row.CheckoutID != nil && *row.CheckoutID > 0 {
rentRefundCent = 0 var saved model.OrderCheckout
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&saved, *row.CheckoutID).Error; err != nil {
return 0, nil, err
} }
if rentRefundCent > order.RentAmountCent { checkout = &saved
rentRefundCent = order.RentAmountCent
} }
actualRentCent := order.RentAmountCent - rentRefundCent if req.ActualCoinConsumedM != nil {
if actualRentCent < 0 { return *req.ActualCoinConsumedM, checkout, nil
return 0
} }
return actualRentCent if checkout != nil {
return checkout.CoinConsumedM, checkout, nil
}
return 0, nil, ErrInvalidDispute
} }
func isPlatformManagedOrder(order model.RentalOrder) bool { func isPlatformManagedOrder(order model.RentalOrder) bool {
+2
View File
@@ -33,6 +33,7 @@ type DisputeDTO struct {
PreviousHandoffStatus string `json:"previous_handoff_status"` PreviousHandoffStatus string `json:"previous_handoff_status"`
PreviousSettlementStatus string `json:"previous_settlement_status"` PreviousSettlementStatus string `json:"previous_settlement_status"`
CheckoutID *uint64 `json:"checkout_id"` CheckoutID *uint64 `json:"checkout_id"`
CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"`
PreviousCheckoutStatus string `json:"previous_checkout_status"` PreviousCheckoutStatus string `json:"previous_checkout_status"`
ArbitrationResult string `json:"arbitration_result"` ArbitrationResult string `json:"arbitration_result"`
ArbitrationRemark string `json:"arbitration_remark"` ArbitrationRemark string `json:"arbitration_remark"`
@@ -59,6 +60,7 @@ type ArbitrateRequest struct {
Result string `json:"result" binding:"required"` Result string `json:"result" binding:"required"`
Remark string `json:"remark" binding:"required"` Remark string `json:"remark" binding:"required"`
AmountCent int64 `json:"amount_cent"` AmountCent int64 `json:"amount_cent"`
ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"`
} }
type AdminListQuery struct { type AdminListQuery struct {
@@ -20,6 +20,7 @@ type disputeRow struct {
RenterID uint64 RenterID uint64
OwnerPhone string OwnerPhone string
RenterPhone string RenterPhone string
CheckoutCoinConsumedM *float64
} }
func (row disputeRow) toDTO() DisputeDTO { func (row disputeRow) toDTO() DisputeDTO {
@@ -48,6 +49,7 @@ func (row disputeRow) toDTO() DisputeDTO {
PreviousHandoffStatus: row.PreviousHandoffStatus, PreviousHandoffStatus: row.PreviousHandoffStatus,
PreviousSettlementStatus: row.PreviousSettlementStatus, PreviousSettlementStatus: row.PreviousSettlementStatus,
CheckoutID: row.CheckoutID, CheckoutID: row.CheckoutID,
CheckoutCoinConsumedM: row.CheckoutCoinConsumedM,
PreviousCheckoutStatus: row.PreviousCheckoutStatus, PreviousCheckoutStatus: row.PreviousCheckoutStatus,
ArbitrationResult: row.ArbitrationResult, ArbitrationResult: row.ArbitrationResult,
ArbitrationRemark: row.ArbitrationRemark, ArbitrationRemark: row.ArbitrationRemark,
+3 -2
View File
@@ -92,7 +92,7 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
return r.adminFilterQuery(ctx). return r.adminFilterQuery(ctx).
Select(`d.*, o.order_no, o.status AS order_status, o.handoff_status, o.settlement_status, Select(`d.*, o.order_no, o.status AS order_status, o.handoff_status, o.settlement_status,
o.owner_id, o.renter_id, owner.phone AS owner_phone, renter.phone AS renter_phone, o.owner_id, o.renter_id, owner.phone AS owner_phone, renter.phone AS renter_phone,
l.listing_no, a.title`) l.listing_no, a.title, c.coin_consumed_m AS checkout_coin_consumed_m`)
} }
func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB { func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB {
@@ -101,7 +101,8 @@ func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB {
Joins("JOIN rental_listings AS l ON l.id = o.listing_id"). Joins("JOIN rental_listings AS l ON l.id = o.listing_id").
Joins("JOIN game_accounts AS a ON a.id = o.account_id"). Joins("JOIN game_accounts AS a ON a.id = o.account_id").
Joins("JOIN users AS owner ON owner.id = o.owner_id"). Joins("JOIN users AS owner ON owner.id = o.owner_id").
Joins("JOIN users AS renter ON renter.id = o.renter_id") Joins("JOIN users AS renter ON renter.id = o.renter_id").
Joins("LEFT JOIN order_checkouts AS c ON c.id = d.checkout_id")
} }
func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB { func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB {
@@ -1,6 +1,8 @@
package dispute package dispute
import ( import (
"context"
"errors"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -8,9 +10,36 @@ import (
"hfb_sys/backend/internal/database" "hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
) )
func disputePureCoinOrder() model.RentalOrder {
return model.RentalOrder{
Status: "renting",
HandoffStatus: "received",
SettlementStatus: "unsettled",
RentAmountCent: 38037,
OwnerRentAmountCent: 35300,
DepositAmountCent: 15000,
RentOriginalAmountCent: 38300,
RentDiscountAmountCent: 263,
PureCoinOriginalAmountCent: 26300,
ExtraItemOriginalAmountCent: 12000,
RenterDiscountBps: 9900,
AccountSnapshot: datatypes.JSON([]byte(`{
"haf_coin_amount": 100000000,
"asset_summary": {
"price_breakdown": {
"buyer_coin_base_price": 263,
"seller_coin_base_price": 233,
"consumable_price": 120
}
}
}`)),
}
}
func setupDisputeTestDB(t *testing.T) *gorm.DB { func setupDisputeTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
db := database.NewTestDB() db := database.NewTestDB()
@@ -261,10 +290,12 @@ func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
if adminNotificationCount == 0 { if adminNotificationCount == 0 {
t.Fatal("platform managed dispute should notify managed admin") t.Fatal("platform managed dispute should notify managed admin")
} }
actualCoinConsumedM := 0.0
if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{ if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{
Result: "release_deposit", Result: "release_deposit",
Remark: "确认租金给卖家,线下结算", Remark: "确认租金给卖家,线下结算",
ActualCoinConsumedM: &actualCoinConsumedM,
}, AuditMeta{}); err != nil { }, AuditMeta{}); err != nil {
t.Fatalf("仲裁失败: %v", err) t.Fatalf("仲裁失败: %v", err)
} }
@@ -291,6 +322,90 @@ func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
} }
} }
func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) {
db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
return "refunded", nil
})})
_, renter, order := createDisputeOrderFixture(t, db, disputePureCoinOrder())
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
Type: "haf_coin_dispute",
Description: "核对最终哈夫币消耗",
})
if err != nil {
t.Fatalf("创建申诉失败: %v", err)
}
_, err = repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
Result: "release_deposit",
Remark: "确认完成订单",
}, AuditMeta{})
if !errors.Is(err, ErrInvalidDispute) {
t.Fatalf("缺少实际消耗 M 的仲裁错误 = %v, want ErrInvalidDispute", err)
}
actualCoinConsumedM := 50.0
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
Result: "release_deposit",
Remark: "确认实际消耗 50M",
ActualCoinConsumedM: &actualCoinConsumedM,
}, AuditMeta{}); err != nil {
t.Fatalf("带实际消耗 M 仲裁失败: %v", err)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if saved.ActualCoinConsumedM != 50 || saved.ActualPureCoinAmountCent != 13150 || saved.GrowthPointsAwarded != 131 {
t.Fatalf("最终 M/纯币/积分 = %.2f/%d/%d, want 50/13150/131", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.GrowthPointsAwarded)
}
}
func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
return "refunded", nil
})})
inputOrder := disputePureCoinOrder()
inputOrder.Status = "pending_checkout_confirm"
inputOrder.HandoffStatus = "pending_owner_checkout"
inputOrder.SettlementStatus = "pending"
_, renter, order := createDisputeOrderFixture(t, db, inputOrder)
checkout := model.OrderCheckout{
OrderID: order.ID,
InitiatedBy: renter.ID,
Status: "submitted",
CoinConsumedM: 25,
}
if err := db.Create(&checkout).Error; err != nil {
t.Fatalf("创建结账记录失败: %v", err)
}
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
Type: "checkout_amount",
Description: "结账金额有争议",
})
if err != nil {
t.Fatalf("创建结账争议失败: %v", err)
}
if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 {
t.Fatalf("关联结账/M = %v/%v, want %d/25", created.CheckoutID, created.CheckoutCoinConsumedM, checkout.ID)
}
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
Result: "release_deposit",
Remark: "沿用双方结账消耗",
}, AuditMeta{}); err != nil {
t.Fatalf("仲裁复用结账 M 失败: %v", err)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if saved.ActualCoinConsumedM != 25 || saved.ActualPureCoinAmountCent != 6575 || saved.GrowthPointsAwarded != 65 {
t.Fatalf("最终 M/纯币/积分 = %.2f/%d/%d, want 25/6575/65", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.GrowthPointsAwarded)
}
}
func TestBuildArbitrationSettlementSkipsFrozenReleaseWhenNoFrozenBalance(t *testing.T) { func TestBuildArbitrationSettlementSkipsFrozenReleaseWhenNoFrozenBalance(t *testing.T) {
order := model.RentalOrder{ order := model.RentalOrder{
ID: 11, ID: 11,
@@ -90,5 +90,8 @@ func (s *Service) Arbitrate(ctx context.Context, adminID uint64, id uint64, req
if id == 0 || req.Result == "" || req.Remark == "" { if id == 0 || req.Result == "" || req.Remark == "" {
return nil, ErrInvalidDispute return nil, ErrInvalidDispute
} }
if req.ActualCoinConsumedM != nil && (*req.ActualCoinConsumedM < 0 || *req.ActualCoinConsumedM > 1_000_000_000) {
return nil, ErrInvalidDispute
}
return s.repo.Arbitrate(ctx, adminID, id, req, meta) return s.repo.Arbitrate(ctx, adminID, id, req, meta)
} }
@@ -255,6 +255,9 @@ func applyCounterCheckoutUpdate(checkout *model.OrderCheckout, next model.OrderC
checkout.OwnerRentAmountCent = next.OwnerRentAmountCent checkout.OwnerRentAmountCent = next.OwnerRentAmountCent
checkout.PlatformFeeCent = next.PlatformFeeCent checkout.PlatformFeeCent = next.PlatformFeeCent
checkout.DepositAmountCent = next.DepositAmountCent checkout.DepositAmountCent = next.DepositAmountCent
checkout.PureCoinAmountCent = next.PureCoinAmountCent
checkout.PureCoinDiscountCent = next.PureCoinDiscountCent
checkout.PureCoinPayableCent = next.PureCoinPayableCent
checkout.ConsumableAmountCent = next.ConsumableAmountCent checkout.ConsumableAmountCent = next.ConsumableAmountCent
checkout.CoinConsumedM = next.CoinConsumedM checkout.CoinConsumedM = next.CoinConsumedM
checkout.OtherAmountCent = next.OtherAmountCent checkout.OtherAmountCent = next.OtherAmountCent
@@ -40,10 +40,13 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
return nil, err return nil, err
} }
applyCheckoutSettlement(checkout, settlement) applyCheckoutSettlement(checkout, settlement)
order.ActualCoinConsumedM = settlement.CoinConsumedM
order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent
order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent
if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil { if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil {
return nil, err return nil, err
} }
if err := rentergrowth.AwardOrderCompleted(tx, order, settlement.ActualRentAmountCent, rentergrowth.SourceOrderCompleted); err != nil { if err := rentergrowth.AwardOrderCompleted(tx, order, settlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil {
return nil, err return nil, err
} }
if err := saveFinalizedCheckout(tx, order, checkout, listing, account); err != nil { if err := saveFinalizedCheckout(tx, order, checkout, listing, account); err != nil {
@@ -110,6 +113,9 @@ func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutS
checkout.RentAmountCent = settlement.ActualRentAmountCent checkout.RentAmountCent = settlement.ActualRentAmountCent
checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent
checkout.PlatformFeeCent = settlement.PlatformFeeCent checkout.PlatformFeeCent = settlement.PlatformFeeCent
checkout.PureCoinAmountCent = settlement.PureCoinAmountCent
checkout.PureCoinDiscountCent = settlement.PureCoinDiscountCent
checkout.PureCoinPayableCent = settlement.PureCoinPayableCent
checkout.RenterRefundAmountCent = settlement.RenterRefundCent checkout.RenterRefundAmountCent = settlement.RenterRefundCent
checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent
checkout.ShortfallCent = settlement.ShortfallCent checkout.ShortfallCent = settlement.ShortfallCent
+10
View File
@@ -37,9 +37,16 @@ type OrderDTO struct {
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"` PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
RentOriginalAmountCent int64 `json:"rent_original_amount_cent,omitempty"` RentOriginalAmountCent int64 `json:"rent_original_amount_cent,omitempty"`
RentDiscountAmountCent int64 `json:"rent_discount_amount_cent,omitempty"` RentDiscountAmountCent int64 `json:"rent_discount_amount_cent,omitempty"`
PureCoinOriginalAmountCent int64 `json:"pure_coin_original_amount_cent,omitempty"`
ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"`
ActualCoinConsumedM float64 `json:"actual_coin_consumed_m,omitempty"`
ActualPureCoinAmountCent int64 `json:"actual_pure_coin_amount_cent,omitempty"`
ActualPureCoinDiscountCent int64 `json:"actual_pure_coin_discount_cent,omitempty"`
RenterGrowthLevel string `json:"renter_growth_level,omitempty"` RenterGrowthLevel string `json:"renter_growth_level,omitempty"`
RenterGrowthLevelName string `json:"renter_growth_level_name,omitempty"` RenterGrowthLevelName string `json:"renter_growth_level_name,omitempty"`
RenterDiscountBps int `json:"renter_discount_bps,omitempty"` RenterDiscountBps int `json:"renter_discount_bps,omitempty"`
GrowthPointsBasisCent int64 `json:"growth_points_basis_cent,omitempty"`
GrowthPointsPerYuan int64 `json:"growth_points_per_yuan,omitempty"`
GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"` GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"` GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"` AccountSnapshot datatypes.JSON `json:"account_snapshot"`
@@ -191,6 +198,9 @@ type CheckoutDTO struct {
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"` OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"` PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
DepositAmountCent int64 `json:"deposit_amount_cent"` DepositAmountCent int64 `json:"deposit_amount_cent"`
PureCoinAmountCent int64 `json:"pure_coin_amount_cent"`
PureCoinDiscountCent int64 `json:"pure_coin_discount_cent"`
PureCoinPayableCent int64 `json:"pure_coin_payable_cent"`
ConsumableAmountCent int64 `json:"consumable_amount_cent"` ConsumableAmountCent int64 `json:"consumable_amount_cent"`
CoinConsumedM float64 `json:"coin_consumed_m"` CoinConsumedM float64 `json:"coin_consumed_m"`
OtherAmountCent int64 `json:"other_amount_cent"` OtherAmountCent int64 `json:"other_amount_cent"`
+3 -1
View File
@@ -48,7 +48,7 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
if err != nil { if err != nil {
return err return err
} }
rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(rentOriginalAmountCent, pricing.PlatformFeeCent, growthSnapshot.DiscountBps) rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(pricing.PureCoinAmountCent, pricing.PureCoinPlatformFeeCent, growthSnapshot.DiscountBps)
if rentDiscountAmountCent > 0 { if rentDiscountAmountCent > 0 {
pricing.PlatformFeeCent -= rentDiscountAmountCent pricing.PlatformFeeCent -= rentDiscountAmountCent
pricing.RentAmountCent -= rentDiscountAmountCent pricing.RentAmountCent -= rentDiscountAmountCent
@@ -76,6 +76,8 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
PlatformFeeCent: pricing.PlatformFeeCent, PlatformFeeCent: pricing.PlatformFeeCent,
RentOriginalAmountCent: rentOriginalAmountCent, RentOriginalAmountCent: rentOriginalAmountCent,
RentDiscountAmountCent: rentDiscountAmountCent, RentDiscountAmountCent: rentDiscountAmountCent,
PureCoinOriginalAmountCent: pricing.PureCoinAmountCent,
ExtraItemOriginalAmountCent: pricing.ExtraItemAmountCent,
RenterGrowthLevel: growthSnapshot.LevelCode, RenterGrowthLevel: growthSnapshot.LevelCode,
RenterGrowthLevelName: growthSnapshot.LevelName, RenterGrowthLevelName: growthSnapshot.LevelName,
RenterDiscountBps: growthSnapshot.DiscountBps, RenterDiscountBps: growthSnapshot.DiscountBps,
@@ -87,9 +87,16 @@ func (row orderRow) toAdminDTO() OrderDTO {
PlatformFeeCent: &platformFeeCent, PlatformFeeCent: &platformFeeCent,
RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder), RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder),
RentDiscountAmountCent: row.RentDiscountAmountCent, RentDiscountAmountCent: row.RentDiscountAmountCent,
PureCoinOriginalAmountCent: orderPureCoinOriginalAmountCent(row.RentalOrder),
ExtraItemOriginalAmountCent: orderExtraItemOriginalAmountCent(row.RentalOrder),
ActualCoinConsumedM: row.ActualCoinConsumedM,
ActualPureCoinAmountCent: row.ActualPureCoinAmountCent,
ActualPureCoinDiscountCent: row.ActualPureCoinDiscountCent,
RenterGrowthLevel: effectiveRenterGrowthLevel(row.RentalOrder), RenterGrowthLevel: effectiveRenterGrowthLevel(row.RentalOrder),
RenterGrowthLevelName: effectiveRenterGrowthLevelName(row.RentalOrder), RenterGrowthLevelName: effectiveRenterGrowthLevelName(row.RentalOrder),
RenterDiscountBps: effectiveRenterDiscountBps(row.RentalOrder), RenterDiscountBps: effectiveRenterDiscountBps(row.RentalOrder),
GrowthPointsBasisCent: row.GrowthPointsBasisCent,
GrowthPointsPerYuan: row.GrowthPointsPerYuan,
GrowthPointsAwarded: row.GrowthPointsAwarded, GrowthPointsAwarded: row.GrowthPointsAwarded,
GrowthPointsAwardedAt: row.GrowthPointsAwardedAt, GrowthPointsAwardedAt: row.GrowthPointsAwardedAt,
AccountSnapshot: row.AccountSnapshot, AccountSnapshot: row.AccountSnapshot,
@@ -225,6 +232,9 @@ func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
OwnerRentAmountCent: &ownerRentAmountCent, OwnerRentAmountCent: &ownerRentAmountCent,
PlatformFeeCent: &platformFeeCent, PlatformFeeCent: &platformFeeCent,
DepositAmountCent: checkout.DepositAmountCent, DepositAmountCent: checkout.DepositAmountCent,
PureCoinAmountCent: checkout.PureCoinAmountCent,
PureCoinDiscountCent: checkout.PureCoinDiscountCent,
PureCoinPayableCent: checkout.PureCoinPayableCent,
ConsumableAmountCent: checkout.ConsumableAmountCent, ConsumableAmountCent: checkout.ConsumableAmountCent,
CoinConsumedM: checkout.CoinConsumedM, CoinConsumedM: checkout.CoinConsumedM,
OtherAmountCent: checkout.OtherAmountCent, OtherAmountCent: checkout.OtherAmountCent,
@@ -315,11 +325,18 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
dto.OwnerRentAmountCent = &ownerAmountCent dto.OwnerRentAmountCent = &ownerAmountCent
dto.RentOriginalAmountCent = 0 dto.RentOriginalAmountCent = 0
dto.RentDiscountAmountCent = 0 dto.RentDiscountAmountCent = 0
dto.PureCoinOriginalAmountCent = 0
dto.ExtraItemOriginalAmountCent = 0
dto.ActualCoinConsumedM = 0
dto.ActualPureCoinAmountCent = 0
dto.ActualPureCoinDiscountCent = 0
dto.RenterGrowthLevel = "" dto.RenterGrowthLevel = ""
dto.RenterGrowthLevelName = "" dto.RenterGrowthLevelName = ""
dto.RenterDiscountBps = 0 dto.RenterDiscountBps = 0
dto.GrowthPointsAwarded = 0 dto.GrowthPointsAwarded = 0
dto.GrowthPointsAwardedAt = nil dto.GrowthPointsAwardedAt = nil
dto.GrowthPointsBasisCent = 0
dto.GrowthPointsPerYuan = 0
sanitizeOrderSnapshot(&dto.AccountSnapshot, "owner") sanitizeOrderSnapshot(&dto.AccountSnapshot, "owner")
case userID == order.RenterID: case userID == order.RenterID:
rentAmountCent := order.RentAmountCent rentAmountCent := order.RentAmountCent
@@ -340,11 +357,18 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
dto.OwnerRentAmountCent = nil dto.OwnerRentAmountCent = nil
dto.RentOriginalAmountCent = 0 dto.RentOriginalAmountCent = 0
dto.RentDiscountAmountCent = 0 dto.RentDiscountAmountCent = 0
dto.PureCoinOriginalAmountCent = 0
dto.ExtraItemOriginalAmountCent = 0
dto.ActualCoinConsumedM = 0
dto.ActualPureCoinAmountCent = 0
dto.ActualPureCoinDiscountCent = 0
dto.RenterGrowthLevel = "" dto.RenterGrowthLevel = ""
dto.RenterGrowthLevelName = "" dto.RenterGrowthLevelName = ""
dto.RenterDiscountBps = 0 dto.RenterDiscountBps = 0
dto.GrowthPointsAwarded = 0 dto.GrowthPointsAwarded = 0
dto.GrowthPointsAwardedAt = nil dto.GrowthPointsAwardedAt = nil
dto.GrowthPointsBasisCent = 0
dto.GrowthPointsPerYuan = 0
sanitizeOrderSnapshot(&dto.AccountSnapshot, "") sanitizeOrderSnapshot(&dto.AccountSnapshot, "")
} }
} }
@@ -379,6 +403,9 @@ func applyCheckoutPriceView(dto *CheckoutDTO, order model.RentalOrder, userID ui
dto.RentAmountCent = nil dto.RentAmountCent = nil
dto.OwnerRentAmountCent = &ownerAmountCent dto.OwnerRentAmountCent = &ownerAmountCent
dto.OwnerIncomeAmountCent = &ownerIncomeAmountCent dto.OwnerIncomeAmountCent = &ownerIncomeAmountCent
dto.PureCoinAmountCent = 0
dto.PureCoinDiscountCent = 0
dto.PureCoinPayableCent = 0
case userID == order.RenterID: case userID == order.RenterID:
dto.PriceRole = "renter" dto.PriceRole = "renter"
dto.DisplayAmountCent = rentAmountCent dto.DisplayAmountCent = rentAmountCent
@@ -390,6 +417,9 @@ func applyCheckoutPriceView(dto *CheckoutDTO, order model.RentalOrder, userID ui
dto.DisplayAmountCent = 0 dto.DisplayAmountCent = 0
dto.RentAmountCent = nil dto.RentAmountCent = nil
dto.OwnerRentAmountCent = nil dto.OwnerRentAmountCent = nil
dto.PureCoinAmountCent = 0
dto.PureCoinDiscountCent = 0
dto.PureCoinPayableCent = 0
} }
} }
+98 -10
View File
@@ -7,6 +7,7 @@ import (
"time" "time"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/pkg/money" "hfb_sys/backend/pkg/money"
"gorm.io/datatypes" "gorm.io/datatypes"
@@ -16,6 +17,11 @@ type orderPricing struct {
RentAmountCent int64 RentAmountCent int64
OwnerRentAmountCent int64 OwnerRentAmountCent int64
PlatformFeeCent int64 PlatformFeeCent int64
PureCoinAmountCent int64
OwnerPureCoinAmountCent int64
ExtraItemAmountCent int64
OwnerExtraItemAmountCent int64
PureCoinPlatformFeeCent int64
} }
type checkoutSettlement struct { type checkoutSettlement struct {
@@ -27,10 +33,34 @@ type checkoutSettlement struct {
RenterRefundCent int64 RenterRefundCent int64
PlatformFeeCent int64 PlatformFeeCent int64
ActualRentAmountCent int64 ActualRentAmountCent int64
PureCoinAmountCent int64
PureCoinDiscountCent int64
PureCoinPayableCent int64
ExtraItemAmountCent int64
CoinConsumedM float64
OvershootAmountCent int64 OvershootAmountCent int64
ShortfallCent int64 ShortfallCent int64
} }
// ActualPureCoinAmounts 是跨正常结账与仲裁共用的最终纯币计价结果。
type ActualPureCoinAmounts struct {
AmountCent int64
DiscountCent int64
PayableCent int64
ConsumedM float64
}
// CalculateActualPureCoinAmounts 按订单价格快照和实际消耗 M 计算纯币原价及等级优惠。
func CalculateActualPureCoinAmounts(order model.RentalOrder, coinConsumedM float64) ActualPureCoinAmounts {
settlement := calculateCheckoutSettlement(order, 0, coinConsumedM, 0)
return ActualPureCoinAmounts{
AmountCent: settlement.PureCoinAmountCent,
DiscountCent: settlement.PureCoinDiscountCent,
PayableCent: settlement.PureCoinPayableCent,
ConsumedM: settlement.CoinConsumedM,
}
}
func orderDurationHours(order model.RentalOrder) int { func orderDurationHours(order model.RentalOrder) int {
if order.EstimatedDurationHours > 0 { if order.EstimatedDurationHours > 0 {
return order.EstimatedDurationHours return order.EstimatedDurationHours
@@ -85,10 +115,34 @@ func buildOrderPricing(listing model.RentalListing, account model.GameAccount) o
if platformFeeCent < 0 { if platformFeeCent < 0 {
platformFeeCent = 0 platformFeeCent = 0
} }
pureCoinAmountCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "buyer_coin_base_price") * 100))
extraItemSnapshotCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "consumable_price") * 100))
if pureCoinAmountCent <= 0 {
if extraItemSnapshotCent > 0 && extraItemSnapshotCent <= rentAmountCent {
pureCoinAmountCent = rentAmountCent - extraItemSnapshotCent
} else {
pureCoinAmountCent = rentAmountCent
}
}
if pureCoinAmountCent < 0 || pureCoinAmountCent > rentAmountCent {
pureCoinAmountCent = rentAmountCent
}
extraItemAmountCent := maxCent(rentAmountCent-pureCoinAmountCent, 0)
ownerPureCoinAmountCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "seller_coin_base_price") * 100))
if ownerPureCoinAmountCent <= 0 || ownerPureCoinAmountCent > ownerRentAmountCent {
ownerPureCoinAmountCent = maxCent(ownerRentAmountCent-minCent(extraItemAmountCent, ownerRentAmountCent), 0)
}
ownerExtraItemAmountCent := maxCent(ownerRentAmountCent-ownerPureCoinAmountCent, 0)
pureCoinPlatformFeeCent := maxCent(pureCoinAmountCent-ownerPureCoinAmountCent, 0)
return orderPricing{ return orderPricing{
RentAmountCent: rentAmountCent, RentAmountCent: rentAmountCent,
OwnerRentAmountCent: ownerRentAmountCent, OwnerRentAmountCent: ownerRentAmountCent,
PlatformFeeCent: platformFeeCent, PlatformFeeCent: platformFeeCent,
PureCoinAmountCent: pureCoinAmountCent,
OwnerPureCoinAmountCent: ownerPureCoinAmountCent,
ExtraItemAmountCent: extraItemAmountCent,
OwnerExtraItemAmountCent: ownerExtraItemAmountCent,
PureCoinPlatformFeeCent: pureCoinPlatformFeeCent,
} }
} }
@@ -162,6 +216,9 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c
OwnerRentAmountCent: settlement.OwnerRentIncomeCent, OwnerRentAmountCent: settlement.OwnerRentIncomeCent,
PlatformFeeCent: settlement.PlatformFeeCent, PlatformFeeCent: settlement.PlatformFeeCent,
DepositAmountCent: order.DepositAmountCent, DepositAmountCent: order.DepositAmountCent,
PureCoinAmountCent: settlement.PureCoinAmountCent,
PureCoinDiscountCent: settlement.PureCoinDiscountCent,
PureCoinPayableCent: settlement.PureCoinPayableCent,
ConsumableAmountCent: consumableAmountCent, ConsumableAmountCent: consumableAmountCent,
CoinConsumedM: roundQuantity(coinConsumedM), CoinConsumedM: roundQuantity(coinConsumedM),
OtherAmountCent: otherAmountCent, OtherAmountCent: otherAmountCent,
@@ -185,18 +242,13 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
orderOwnerRentAmountCent := order.OwnerRentAmountCent orderOwnerRentAmountCent := order.OwnerRentAmountCent
orderDepositAmountCent := order.DepositAmountCent orderDepositAmountCent := order.DepositAmountCent
buyerCoinBasePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "buyer_coin_base_price") * 100)) buyerCoinBasePriceCent := orderPureCoinOriginalAmountCent(order)
if buyerCoinBasePriceCent <= 0 || buyerCoinBasePriceCent > orderRentAmountCent {
buyerCoinBasePriceCent = orderRentAmountCent
}
sellerCoinBasePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "seller_coin_base_price") * 100)) sellerCoinBasePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "seller_coin_base_price") * 100))
if sellerCoinBasePriceCent <= 0 || sellerCoinBasePriceCent > orderOwnerRentAmountCent { if sellerCoinBasePriceCent <= 0 || sellerCoinBasePriceCent > orderOwnerRentAmountCent {
sellerCoinBasePriceCent = orderOwnerRentAmountCent ownerExtraItemCent := minCent(orderExtraItemOriginalAmountCent(order), orderOwnerRentAmountCent)
} sellerCoinBasePriceCent = maxCent(orderOwnerRentAmountCent-ownerExtraItemCent, 0)
prepaidConsumablePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "consumable_price") * 100))
if prepaidConsumablePriceCent <= 0 || prepaidConsumablePriceCent > orderRentAmountCent-buyerCoinBasePriceCent {
prepaidConsumablePriceCent = maxCent(orderRentAmountCent-buyerCoinBasePriceCent, 0)
} }
prepaidConsumablePriceCent := orderExtraItemOriginalAmountCent(order)
prepaidOwnerConsumablePriceCent := maxCent(orderOwnerRentAmountCent-sellerCoinBasePriceCent, 0) prepaidOwnerConsumablePriceCent := maxCent(orderOwnerRentAmountCent-sellerCoinBasePriceCent, 0)
consumedM := roundQuantity(coinConsumedM) consumedM := roundQuantity(coinConsumedM)
@@ -243,7 +295,10 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
usedOwnerConsumablePriceCent = minCent(usedOwnerConsumablePriceCent, prepaidOwnerConsumablePriceCent) usedOwnerConsumablePriceCent = minCent(usedOwnerConsumablePriceCent, prepaidOwnerConsumablePriceCent)
} }
actualBuyerRentCent := usedBuyerCoinPriceCent + usedBuyerConsumablePriceCent pureCoinPlatformFeeCent := maxCent(usedBuyerCoinPriceCent-usedOwnerCoinPriceCent, 0)
pureCoinDiscountCent := rentergrowth.CalculateDiscountCent(usedBuyerCoinPriceCent, pureCoinPlatformFeeCent, effectiveRenterDiscountBps(order))
pureCoinPayableCent := maxCent(usedBuyerCoinPriceCent-pureCoinDiscountCent, 0)
actualBuyerRentCent := pureCoinPayableCent + usedBuyerConsumablePriceCent
actualOwnerRentCent := usedOwnerCoinPriceCent + usedOwnerConsumablePriceCent actualOwnerRentCent := usedOwnerCoinPriceCent + usedOwnerConsumablePriceCent
if actualOwnerRentCent > actualBuyerRentCent { if actualOwnerRentCent > actualBuyerRentCent {
actualOwnerRentCent = actualBuyerRentCent actualOwnerRentCent = actualBuyerRentCent
@@ -278,11 +333,44 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
RenterRefundCent: rentRefundCent + depositRefundCent, RenterRefundCent: rentRefundCent + depositRefundCent,
PlatformFeeCent: platformFeeCent, PlatformFeeCent: platformFeeCent,
ActualRentAmountCent: actualRentAmountCent, ActualRentAmountCent: actualRentAmountCent,
PureCoinAmountCent: usedBuyerCoinPriceCent,
PureCoinDiscountCent: pureCoinDiscountCent,
PureCoinPayableCent: pureCoinPayableCent,
ExtraItemAmountCent: usedBuyerConsumablePriceCent,
CoinConsumedM: consumedM,
OvershootAmountCent: overshootCent, OvershootAmountCent: overshootCent,
ShortfallCent: shortfallCent, ShortfallCent: shortfallCent,
} }
} }
func orderPureCoinOriginalAmountCent(order model.RentalOrder) int64 {
originalRentCent := effectiveRentOriginalAmountCent(order)
if order.PureCoinOriginalAmountCent > 0 {
return minCent(order.PureCoinOriginalAmountCent, originalRentCent)
}
if order.ExtraItemOriginalAmountCent > 0 {
return maxCent(originalRentCent-minCent(order.ExtraItemOriginalAmountCent, originalRentCent), 0)
}
pureCoinCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "buyer_coin_base_price") * 100))
if pureCoinCent > 0 && pureCoinCent <= originalRentCent {
return pureCoinCent
}
extraItemCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "consumable_price") * 100))
if extraItemCent > 0 && extraItemCent <= originalRentCent {
return originalRentCent - extraItemCent
}
return originalRentCent
}
func orderExtraItemOriginalAmountCent(order model.RentalOrder) int64 {
originalRentCent := effectiveRentOriginalAmountCent(order)
if order.ExtraItemOriginalAmountCent > 0 {
return minCent(order.ExtraItemOriginalAmountCent, originalRentCent)
}
pureCoinCent := orderPureCoinOriginalAmountCent(order)
return maxCent(originalRentCent-pureCoinCent, 0)
}
func readOrderSnapshotCoinM(raw datatypes.JSON) float64 { func readOrderSnapshotCoinM(raw datatypes.JSON) float64 {
if len(raw) == 0 { if len(raw) == 0 {
return 0 return 0
@@ -162,7 +162,15 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
ServerRegion: "国服", ServerRegion: "国服",
LoginPlatform: "steam", LoginPlatform: "steam",
Title: "测试账号", Title: "测试账号",
AssetSummary: datatypes.JSON([]byte(`{"price_breakdown":{"seller_total_price":90,"platform_markup_amount":10}}`)), AssetSummary: datatypes.JSON([]byte(`{
"price_breakdown": {
"buyer_coin_base_price": 263,
"seller_coin_base_price": 233,
"consumable_price": 120,
"seller_total_price": 353,
"platform_markup_amount": 30
}
}`)),
} }
if err := db.Create(&account).Error; err != nil { if err := db.Create(&account).Error; err != nil {
t.Fatalf("create account failed: %v", err) t.Fatalf("create account failed: %v", err)
@@ -170,7 +178,7 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
listing := model.RentalListing{ listing := model.RentalListing{
AccountID: account.ID, AccountID: account.ID,
OwnerID: owner.ID, OwnerID: owner.ID,
PriceCent: 10000, PriceCent: 38300,
DepositAmountCent: 100000, DepositAmountCent: 100000,
Status: listingStatusPublished, Status: listingStatusPublished,
ReviewStatus: listingReviewStatusApproved, ReviewStatus: listingReviewStatusApproved,
@@ -183,11 +191,14 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Create() error = %v", err) t.Fatalf("Create() error = %v", err)
} }
if dto.RentAmountCent == nil || *dto.RentAmountCent != 9900 { if dto.RentAmountCent == nil || *dto.RentAmountCent != 38037 {
t.Fatalf("rent amount = %v, want 9900", dto.RentAmountCent) t.Fatalf("rent amount = %v, want 38037", dto.RentAmountCent)
} }
if dto.RentOriginalAmountCent != 10000 || dto.RentDiscountAmountCent != 100 { if dto.RentOriginalAmountCent != 38300 || dto.RentDiscountAmountCent != 263 {
t.Fatalf("discount snapshot = original %d discount %d, want 10000/100", dto.RentOriginalAmountCent, dto.RentDiscountAmountCent) t.Fatalf("discount snapshot = original %d discount %d, want 38300/263", dto.RentOriginalAmountCent, dto.RentDiscountAmountCent)
}
if dto.PureCoinOriginalAmountCent != 26300 || dto.ExtraItemOriginalAmountCent != 12000 {
t.Fatalf("pure coin/extra snapshot = %d/%d, want 26300/12000", dto.PureCoinOriginalAmountCent, dto.ExtraItemOriginalAmountCent)
} }
if dto.RenterGrowthLevel != "platinum" || dto.RenterGrowthLevelName != "铂金" || dto.RenterDiscountBps != 9900 { if dto.RenterGrowthLevel != "platinum" || dto.RenterGrowthLevelName != "铂金" || dto.RenterDiscountBps != 9900 {
t.Fatalf("growth snapshot = %s/%s/%d, want platinum/铂金/9900", dto.RenterGrowthLevel, dto.RenterGrowthLevelName, dto.RenterDiscountBps) t.Fatalf("growth snapshot = %s/%s/%d, want platinum/铂金/9900", dto.RenterGrowthLevel, dto.RenterGrowthLevelName, dto.RenterDiscountBps)
@@ -196,8 +207,8 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
if err := db.First(&saved, dto.ID).Error; err != nil { if err := db.First(&saved, dto.ID).Error; err != nil {
t.Fatalf("find saved order failed: %v", err) t.Fatalf("find saved order failed: %v", err)
} }
if saved.OwnerRentAmountCent != 9000 || saved.PlatformFeeCent != 900 { if saved.OwnerRentAmountCent != 35300 || saved.PlatformFeeCent != 2737 {
t.Fatalf("pricing = owner %d platform %d, want 9000/900", saved.OwnerRentAmountCent, saved.PlatformFeeCent) t.Fatalf("pricing = owner %d platform %d, want 35300/2737", saved.OwnerRentAmountCent, saved.PlatformFeeCent)
} }
if saved.DepositAmountCent != 30000 || saved.DepositWaivedAmountCent != 70000 { if saved.DepositAmountCent != 30000 || saved.DepositWaivedAmountCent != 70000 {
t.Fatalf("deposit = paid %d waived %d, want 30000/70000", saved.DepositAmountCent, saved.DepositWaivedAmountCent) t.Fatalf("deposit = paid %d waived %d, want 30000/70000", saved.DepositAmountCent, saved.DepositWaivedAmountCent)
@@ -5,11 +5,116 @@ import (
"time" "time"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/internal/timeutil" "hfb_sys/backend/internal/timeutil"
"gorm.io/datatypes" "gorm.io/datatypes"
) )
func pureCoinDiscountOrder() model.RentalOrder {
return model.RentalOrder{
RentAmountCent: 38037,
OwnerRentAmountCent: 35300,
DepositAmountCent: 15000,
RentOriginalAmountCent: 38300,
RentDiscountAmountCent: 263,
PureCoinOriginalAmountCent: 26300,
ExtraItemOriginalAmountCent: 12000,
RenterDiscountBps: 9900,
AccountSnapshot: datatypes.JSON([]byte(`{
"haf_coin_amount": 100000000,
"asset_summary": {
"price_breakdown": {
"buyer_coin_base_price": 263,
"seller_coin_base_price": 233,
"consumable_price": 120
}
}
}`)),
}
}
func TestCalculateCheckoutSettlementUsesOnlyActualPureCoinForDiscount(t *testing.T) {
tests := []struct {
name string
consumableAmountCent int64
coinConsumedM float64
wantPureCoinCent int64
wantDiscountCent int64
wantActualRentCent int64
wantOvershootCent int64
}{
{
name: "全量纯币和额外物品",
consumableAmountCent: 12000,
coinConsumedM: 100,
wantPureCoinCent: 26300,
wantDiscountCent: 263,
wantActualRentCent: 38037,
wantOvershootCent: 0,
},
{
name: "部分纯币和全量额外物品",
consumableAmountCent: 12000,
coinConsumedM: 50,
wantPureCoinCent: 13150,
wantDiscountCent: 132,
wantActualRentCent: 25018,
wantOvershootCent: 0,
},
{
name: "只使用额外物品",
consumableAmountCent: 12000,
coinConsumedM: 0,
wantPureCoinCent: 0,
wantDiscountCent: 0,
wantActualRentCent: 12000,
wantOvershootCent: 0,
},
{
name: "纯币打超仍参与优惠",
consumableAmountCent: 12000,
coinConsumedM: 110,
wantPureCoinCent: 28930,
wantDiscountCent: 290,
wantActualRentCent: 40640,
wantOvershootCent: 2603,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
settlement := calculateCheckoutSettlement(pureCoinDiscountOrder(), test.consumableAmountCent, test.coinConsumedM, 0)
if settlement.PureCoinAmountCent != test.wantPureCoinCent || settlement.PureCoinDiscountCent != test.wantDiscountCent {
t.Fatalf("纯币原价/优惠 = %d/%d, want %d/%d", settlement.PureCoinAmountCent, settlement.PureCoinDiscountCent, test.wantPureCoinCent, test.wantDiscountCent)
}
if settlement.ActualRentAmountCent != test.wantActualRentCent || settlement.OvershootAmountCent != test.wantOvershootCent {
t.Fatalf("实际租金/打超 = %d/%d, want %d/%d", settlement.ActualRentAmountCent, settlement.OvershootAmountCent, test.wantActualRentCent, test.wantOvershootCent)
}
})
}
}
func TestBuildOrderPricingDoesNotDiscountAllExtraItemOrder(t *testing.T) {
pricing := buildOrderPricing(
model.RentalListing{PriceCent: 12000},
model.GameAccount{AssetSummary: datatypes.JSON([]byte(`{
"price_breakdown": {
"buyer_coin_base_price": 0,
"seller_coin_base_price": 0,
"consumable_price": 120,
"seller_total_price": 120
}
}`))},
)
if pricing.PureCoinAmountCent != 0 || pricing.ExtraItemAmountCent != 12000 || pricing.PureCoinPlatformFeeCent != 0 {
t.Fatalf("全额外物品订单纯币/额外物品/纯币差价 = %d/%d/%d, want 0/12000/0", pricing.PureCoinAmountCent, pricing.ExtraItemAmountCent, pricing.PureCoinPlatformFeeCent)
}
if discount := rentergrowth.CalculateDiscountCent(pricing.PureCoinAmountCent, pricing.PureCoinPlatformFeeCent, 9900); discount != 0 {
t.Fatalf("全额外物品订单优惠 = %d, want 0", discount)
}
}
func TestCalculateCheckoutSettlementRefundsUnusedRent(t *testing.T) { func TestCalculateCheckoutSettlementRefundsUnusedRent(t *testing.T) {
order := model.RentalOrder{ order := model.RentalOrder{
RentAmountCent: 38300, RentAmountCent: 38300,
+20 -9
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/rentergrowth"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/datatypes" "gorm.io/datatypes"
@@ -273,7 +274,7 @@ func (r *Repository) prepareRefundOrder(ctx context.Context, originalPayment mod
} }
func (r *Repository) syncRefundPayment(ctx context.Context, payment *model.PaymentOrder, queryTerminal bool) (*model.PaymentOrder, error) { func (r *Repository) syncRefundPayment(ctx context.Context, payment *model.PaymentOrder, queryTerminal bool) (*model.PaymentOrder, error) {
if payment.Status == "refunded" && !queryTerminal { if payment.Status == "refunded" && !queryTerminal {
if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent); err != nil { if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType); err != nil {
return nil, err return nil, err
} }
return payment, nil return payment, nil
@@ -290,7 +291,7 @@ func (r *Repository) syncRefundPayment(ctx context.Context, payment *model.Payme
} }
if runtimeConfig.isMockMode() { if runtimeConfig.isMockMode() {
if payment.Status == "refunded" { if payment.Status == "refunded" {
if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent); err != nil { if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType); err != nil {
return nil, err return nil, err
} }
} }
@@ -371,7 +372,7 @@ func (r *Repository) applyRefundChannelStatus(ctx context.Context, payment *mode
} }
switch status { switch status {
case "refunded": case "refunded":
return r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent) return r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType)
case "failed": case "failed":
return r.markOrderRefundFailed(ctx, payment.OrderID, payment.AmountCent) return r.markOrderRefundFailed(ctx, payment.OrderID, payment.AmountCent)
default: default:
@@ -394,13 +395,23 @@ func refundQueryRequest(payment model.PaymentOrder, originalPayment model.Paymen
ProviderRefundID: payment.ProviderOrderID, ProviderRefundID: payment.ProviderOrderID,
} }
} }
func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64, refundAmountCent int64) error { func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
}
now := time.Now() now := time.Now()
return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ order.RefundStatus = "refunded"
"refund_status": "refunded", order.RefundAmountCent = refundAmountCent
"refund_amount_cent": refundAmountCent, order.RefundedAt = &now
"refunded_at": now, if bizType == "admin_refund" && order.Status == "completed" && refundAmountCent >= order.RentAmountCent+order.DepositAmountCent {
}).Error if err := rentergrowth.RevokeOrderCompleted(tx, &order); err != nil {
return err
}
}
return tx.Save(&order).Error
})
} }
func (r *Repository) markOrderRefunding(ctx context.Context, orderID uint64, refundAmountCent int64) error { func (r *Repository) markOrderRefunding(ctx context.Context, orderID uint64, refundAmountCent int64) error {
return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{
@@ -9,6 +9,7 @@ import (
"hfb_sys/backend/internal/database" "hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
ordermodule "hfb_sys/backend/internal/modules/order" ordermodule "hfb_sys/backend/internal/modules/order"
"hfb_sys/backend/internal/modules/rentergrowth"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -388,6 +389,55 @@ func TestRefundAmountMustNotExceedOriginal(t *testing.T) {
} }
} }
func TestSuccessfulAdminFullRefundRevokesCompletedOrderGrowthPoints(t *testing.T) {
db := setupPaymentTestDB(t)
repo := NewRepository(db, nil, nil)
renter := model.User{Phone: "13900001991", RenterGrowthLevel: "normal"}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("create renter failed: %v", err)
}
order := model.RentalOrder{
OrderNo: "ORD202607260991",
RenterID: renter.ID,
RentAmountCent: 1000,
Status: "completed",
GrowthPointsBasisCent: 1000,
GrowthPointsPerYuan: 1,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
if err := rentergrowth.AwardOrderCompleted(db, &order, 1000, rentergrowth.SourceOrderCompleted); err != nil {
t.Fatalf("award growth points failed: %v", err)
}
if err := db.Save(&order).Error; err != nil {
t.Fatalf("save awarded order failed: %v", err)
}
if err := repo.updateOrderRefundStatus(t.Context(), order.ID, 1000, "admin_refund"); err != nil {
t.Fatalf("update admin refund status failed: %v", err)
}
if err := repo.updateOrderRefundStatus(t.Context(), order.ID, 1000, "admin_refund"); err != nil {
t.Fatalf("repeat admin refund status failed: %v", err)
}
var savedUser model.User
if err := db.First(&savedUser, renter.ID).Error; err != nil {
t.Fatalf("find renter failed: %v", err)
}
if savedUser.RenterGrowthPoints != 0 || savedUser.RenterGrowthLevel != rentergrowth.DefaultLevelCode {
t.Fatalf("growth after refund = %d/%s, want 0/%s", savedUser.RenterGrowthPoints, savedUser.RenterGrowthLevel, rentergrowth.DefaultLevelCode)
}
var revokeCount int64
if err := db.Model(&model.RenterGrowthLedger{}).
Where("order_id = ? AND source = ?", order.ID, rentergrowth.SourceOrderRefunded).
Count(&revokeCount).Error; err != nil {
t.Fatalf("count revoke ledger failed: %v", err)
}
if revokeCount != 1 {
t.Fatalf("revoke ledger count = %d, want 1", revokeCount)
}
}
// TestPaymentNotifyResultStructure 测试支付回调结果结构 // TestPaymentNotifyResultStructure 测试支付回调结果结构
func TestNotifyResultHasRequiredFields(t *testing.T) { func TestNotifyResultHasRequiredFields(t *testing.T) {
result := NotifyResult{ result := NotifyResult{
@@ -26,6 +26,7 @@ const (
MaxLevelPoints = 1_000_000_000 MaxLevelPoints = 1_000_000_000
MaxLevelQuotaCent = 100_000_000 MaxLevelQuotaCent = 100_000_000
SourceOrderCompleted = "order_completed" SourceOrderCompleted = "order_completed"
SourceOrderRefunded = "order_refunded"
SourceAdminAdjustment = "admin_adjustment" SourceAdminAdjustment = "admin_adjustment"
) )
@@ -243,7 +244,7 @@ func CalculateDiscountCent(originalRentCent int64, platformFeeCent int64, discou
return discountCent return discountCent
} }
func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent int64, source string) error { func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualPureCoinCent int64, source string) error {
if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 { if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 {
return nil return nil
} }
@@ -271,10 +272,12 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i
if err != nil { if err != nil {
return err return err
} }
order.GrowthPointsBasisCent = maxInt64(actualPureCoinCent, 0)
order.GrowthPointsPerYuan = cfg.PointsPerYuan
if !cfg.Enabled { if !cfg.Enabled {
return nil return nil
} }
points := PointsForRent(actualRentCent, cfg.PointsPerYuan) points := PointsForPureCoin(actualPureCoinCent, cfg.PointsPerYuan)
if points <= 0 { if points <= 0 {
return nil return nil
} }
@@ -296,6 +299,8 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i
UserID: order.RenterID, UserID: order.RenterID,
OrderID: &order.ID, OrderID: &order.ID,
Points: points, Points: points,
BasisAmountCent: actualPureCoinCent,
PointsPerYuan: cfg.PointsPerYuan,
BeforePoints: beforePoints, BeforePoints: beforePoints,
AfterPoints: afterPoints, AfterPoints: afterPoints,
BeforeLevel: beforeLevel.Code, BeforeLevel: beforeLevel.Code,
@@ -312,14 +317,90 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i
return nil return nil
} }
func PointsForRent(actualRentCent int64, pointsPerYuan int64) int64 { // RevokeOrderCompleted 在已完成订单被全额人工退款后扣回该单已发放的成长积分。
if actualRentCent <= 0 { func RevokeOrderCompleted(tx *gorm.DB, order *model.RentalOrder) error {
if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 {
return nil
}
var existing model.RenterGrowthLedger
err := tx.Where("order_id = ? AND source = ?", order.ID, SourceOrderRefunded).First(&existing).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
points := order.GrowthPointsAwarded
if points <= 0 {
var awarded model.RenterGrowthLedger
if err := tx.Where("order_id = ? AND source = ?", order.ID, SourceOrderCompleted).First(&awarded).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
points = awarded.Points
}
if points <= 0 {
return nil
}
cfg, err := ConfigForTx(tx)
if err != nil {
return err
}
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, order.RenterID).Error; err != nil {
return err
}
beforePoints := maxInt64(user.RenterGrowthPoints, 0)
revokedPoints := points
if revokedPoints > beforePoints {
revokedPoints = beforePoints
}
beforeLevel := LevelForPoints(cfg, beforePoints)
afterPoints := beforePoints - revokedPoints
afterLevel := LevelForPoints(cfg, afterPoints)
if !cfg.Enabled {
beforeLevel = LevelRule{Code: DefaultLevelCode, Name: DefaultLevelName}
afterLevel = beforeLevel
}
user.RenterGrowthPoints = afterPoints
user.RenterGrowthLevel = afterLevel.Code
if err := tx.Save(&user).Error; err != nil {
return err
}
orderID := order.ID
return tx.Create(&model.RenterGrowthLedger{
UserID: order.RenterID,
OrderID: &orderID,
Points: -revokedPoints,
BasisAmountCent: order.GrowthPointsBasisCent,
PointsPerYuan: order.GrowthPointsPerYuan,
BeforePoints: beforePoints,
AfterPoints: afterPoints,
BeforeLevel: beforeLevel.Code,
AfterLevel: afterLevel.Code,
Source: SourceOrderRefunded,
Remark: "已完成订单全额退款扣回成长积分",
CreatedAt: time.Now(),
}).Error
}
func PointsForPureCoin(actualPureCoinCent int64, pointsPerYuan int64) int64 {
if actualPureCoinCent <= 0 {
return 0 return 0
} }
if pointsPerYuan <= 0 { if pointsPerYuan <= 0 {
pointsPerYuan = DefaultPointsPerYuan pointsPerYuan = DefaultPointsPerYuan
} }
return (actualRentCent / 100) * pointsPerYuan return (actualPureCoinCent / 100) * pointsPerYuan
}
// PointsForRent 保留旧调用兼容,积分基数现统一解释为实际纯币金额。
func PointsForRent(actualPureCoinCent int64, pointsPerYuan int64) int64 {
return PointsForPureCoin(actualPureCoinCent, pointsPerYuan)
} }
func LevelForPoints(cfg Config, points int64) LevelRule { func LevelForPoints(cfg Config, points int64) LevelRule {
@@ -159,6 +159,9 @@ func TestAwardOrderCompletedUpdatesUserAndIsIdempotent(t *testing.T) {
if order.GrowthPointsAwarded != 20 || order.GrowthPointsAwardedAt == nil { if order.GrowthPointsAwarded != 20 || order.GrowthPointsAwardedAt == nil {
t.Fatalf("order awarded = %d at %v, want 20 with time", order.GrowthPointsAwarded, order.GrowthPointsAwardedAt) t.Fatalf("order awarded = %d at %v, want 20 with time", order.GrowthPointsAwarded, order.GrowthPointsAwardedAt)
} }
if order.GrowthPointsBasisCent != 2000 || order.GrowthPointsPerYuan != 1 {
t.Fatalf("order points basis = %d x %d, want 2000 x 1", order.GrowthPointsBasisCent, order.GrowthPointsPerYuan)
}
var savedUser model.User var savedUser model.User
if err := db.First(&savedUser, user.ID).Error; err != nil { if err := db.First(&savedUser, user.ID).Error; err != nil {
t.Fatalf("find user failed: %v", err) t.Fatalf("find user failed: %v", err)
@@ -186,4 +189,72 @@ func TestAwardOrderCompletedUpdatesUserAndIsIdempotent(t *testing.T) {
if ledgerCount != 1 { if ledgerCount != 1 {
t.Fatalf("ledger count = %d, want 1", ledgerCount) t.Fatalf("ledger count = %d, want 1", ledgerCount)
} }
if err := RevokeOrderCompleted(db, &order); err != nil {
t.Fatalf("RevokeOrderCompleted() error = %v", err)
}
if err := RevokeOrderCompleted(db, &order); err != nil {
t.Fatalf("second RevokeOrderCompleted() error = %v", err)
}
if err := db.First(&savedUser, user.ID).Error; err != nil {
t.Fatalf("find user after revoke failed: %v", err)
}
if savedUser.RenterGrowthPoints != 290 || savedUser.RenterGrowthLevel != DefaultLevelCode {
t.Fatalf("user growth after revoke = %d/%s, want 290/%s", savedUser.RenterGrowthPoints, savedUser.RenterGrowthLevel, DefaultLevelCode)
}
if err := db.Model(&model.RenterGrowthLedger{}).Where("order_id = ?", order.ID).Count(&ledgerCount).Error; err != nil {
t.Fatalf("count ledger after revoke failed: %v", err)
}
if ledgerCount != 2 {
t.Fatalf("ledger count after revoke = %d, want 2", ledgerCount)
}
}
func TestRevokeOrderCompletedRecordsIdempotencyWhenUserHasNoPoints(t *testing.T) {
db := database.NewTestDB()
if err := db.AutoMigrate(
&model.User{},
&model.RentalOrder{},
&model.SystemConfig{},
&model.RenterGrowthLedger{},
); err != nil {
t.Fatalf("migrate failed: %v", err)
}
user := model.User{Phone: "13800008889", RenterGrowthLevel: DefaultLevelCode}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user failed: %v", err)
}
order := model.RentalOrder{
OrderNo: "RG202607260002",
RenterID: user.ID,
GrowthPointsAwarded: 20,
GrowthPointsBasisCent: 2000,
GrowthPointsPerYuan: 1,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
if err := RevokeOrderCompleted(db, &order); err != nil {
t.Fatalf("RevokeOrderCompleted() error = %v", err)
}
if err := db.Model(&model.User{}).Where("id = ?", user.ID).Update("renter_growth_points", 100).Error; err != nil {
t.Fatalf("add later points failed: %v", err)
}
if err := RevokeOrderCompleted(db, &order); err != nil {
t.Fatalf("repeat RevokeOrderCompleted() error = %v", err)
}
var saved model.User
if err := db.First(&saved, user.ID).Error; err != nil {
t.Fatalf("find user failed: %v", err)
}
if saved.RenterGrowthPoints != 100 {
t.Fatalf("later points after repeated callback = %d, want 100", saved.RenterGrowthPoints)
}
var ledger model.RenterGrowthLedger
if err := db.Where("order_id = ? AND source = ?", order.ID, SourceOrderRefunded).First(&ledger).Error; err != nil {
t.Fatalf("find revoke ledger failed: %v", err)
}
if ledger.Points != 0 {
t.Fatalf("revoke ledger points = %d, want 0", ledger.Points)
}
} }
@@ -0,0 +1,39 @@
-- +goose Up
ALTER TABLE rental_orders
ADD COLUMN pure_coin_original_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '下单时租客纯币原价快照(分)' AFTER rent_discount_amount_cent,
ADD COLUMN extra_item_original_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '下单时额外物品原价快照(分)' AFTER pure_coin_original_amount_cent,
ADD COLUMN actual_coin_consumed_m DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '最终确认哈夫币消耗(M)' AFTER extra_item_original_amount_cent,
ADD COLUMN actual_pure_coin_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '最终实际纯币原价(分)' AFTER actual_coin_consumed_m,
ADD COLUMN actual_pure_coin_discount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '最终实际纯币等级优惠(分)' AFTER actual_pure_coin_amount_cent,
ADD COLUMN growth_points_basis_cent BIGINT NOT NULL DEFAULT 0 COMMENT '成长积分计算纯币基数(分)' AFTER renter_discount_bps,
ADD COLUMN growth_points_per_yuan BIGINT NOT NULL DEFAULT 0 COMMENT '成长积分发放倍率' AFTER growth_points_basis_cent;
ALTER TABLE order_checkouts
ADD COLUMN pure_coin_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '方案实际纯币原价(分)' AFTER deposit_amount_cent,
ADD COLUMN pure_coin_discount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '方案实际纯币等级优惠(分)' AFTER pure_coin_amount_cent,
ADD COLUMN pure_coin_payable_cent BIGINT NOT NULL DEFAULT 0 COMMENT '方案实际折后纯币金额(分)' AFTER pure_coin_discount_cent;
ALTER TABLE renter_growth_ledger
ADD COLUMN basis_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '积分计算纯币基数(分)' AFTER points,
ADD COLUMN points_per_yuan BIGINT NOT NULL DEFAULT 0 COMMENT '积分发放倍率' AFTER basis_amount_cent;
-- +goose Down
ALTER TABLE order_checkouts
DROP COLUMN pure_coin_payable_cent,
DROP COLUMN pure_coin_discount_cent,
DROP COLUMN pure_coin_amount_cent;
ALTER TABLE renter_growth_ledger
DROP COLUMN points_per_yuan,
DROP COLUMN basis_amount_cent;
ALTER TABLE rental_orders
DROP COLUMN growth_points_per_yuan,
DROP COLUMN growth_points_basis_cent,
DROP COLUMN actual_pure_coin_discount_cent,
DROP COLUMN actual_pure_coin_amount_cent,
DROP COLUMN actual_coin_consumed_m,
DROP COLUMN extra_item_original_amount_cent,
DROP COLUMN pure_coin_original_amount_cent;
@@ -26,6 +26,7 @@ const evidenceDispute = ref<Dispute | null>(null)
const result = ref('release_deposit') const result = ref('release_deposit')
const remark = ref('') const remark = ref('')
const amount = ref<number | undefined>() const amount = ref<number | undefined>()
const actualCoinConsumedM = ref<number | undefined>()
const currentPage = ref(1) const currentPage = ref(1)
const currentPageSize = ref(20) const currentPageSize = ref(20)
const total = ref(0) const total = ref(0)
@@ -33,6 +34,15 @@ const keywordFilter = ref('')
const statusFilter = ref('') const statusFilter = ref('')
const typeFilter = ref('') const typeFilter = ref('')
const isPartialRefund = computed(() => result.value === 'partial_refund') const isPartialRefund = computed(() => result.value === 'partial_refund')
const isCompletedResult = computed(() =>
['deduct_deposit', 'release_deposit', 'compensate_owner'].includes(result.value)
)
const hasValidActualCoinConsumedM = computed(
() =>
typeof actualCoinConsumedM.value === 'number' &&
Number.isFinite(actualCoinConsumedM.value) &&
actualCoinConsumedM.value >= 0
)
const pendingCount = computed( const pendingCount = computed(
() => disputes.value.filter(item => ['open', 'processing'].includes(item.status)).length () => disputes.value.filter(item => ['open', 'processing'].includes(item.status)).length
) )
@@ -46,6 +56,12 @@ const canSubmitArbitration = computed(() => {
if (submitting.value || !activeDispute.value) return false if (submitting.value || !activeDispute.value) return false
if (!result.value || !remark.value.trim()) return false if (!result.value || !remark.value.trim()) return false
if (isPartialRefund.value && (!amount.value || amount.value <= 0)) return false if (isPartialRefund.value && (!amount.value || amount.value <= 0)) return false
if (
isCompletedResult.value &&
!activeDispute.value.checkout_id &&
!hasValidActualCoinConsumedM.value
)
return false
return true return true
}) })
function canArbitrate(row: Dispute) { function canArbitrate(row: Dispute) {
@@ -146,6 +162,7 @@ function openArbitration(row: Dispute) {
result.value = row.arbitration_result || 'release_deposit' result.value = row.arbitration_result || 'release_deposit'
remark.value = row.arbitration_remark || '' remark.value = row.arbitration_remark || ''
amount.value = undefined amount.value = undefined
actualCoinConsumedM.value = row.checkout_coin_consumed_m ?? undefined
} }
function resetFilters() { function resetFilters() {
@@ -276,16 +293,26 @@ async function handleArbitrate() {
ElMessage.warning('请填写部分退款金额') ElMessage.warning('请填写部分退款金额')
return return
} }
if (
isCompletedResult.value &&
!activeDispute.value.checkout_id &&
!hasValidActualCoinConsumedM.value
) {
ElMessage.warning('请填写最终实际哈夫币消耗 M')
return
}
submitting.value = true submitting.value = true
try { try {
await arbitrateDispute(activeDispute.value.id, { await arbitrateDispute(activeDispute.value.id, {
result: result.value, result: result.value,
remark: remark.value.trim(), remark: remark.value.trim(),
amount_cent: amount.value ? yuanToCent(amount.value) : undefined, amount_cent: amount.value ? yuanToCent(amount.value) : undefined,
actual_coin_consumed_m: isCompletedResult.value ? actualCoinConsumedM.value : undefined,
}) })
ElMessage.success('仲裁结果已保存,双方已收到通知') ElMessage.success('仲裁结果已保存,双方已收到通知')
activeDispute.value = null activeDispute.value = null
remark.value = '' remark.value = ''
actualCoinConsumedM.value = undefined
await loadDisputes() await loadDisputes()
} catch (error) { } catch (error) {
ElMessage.error(readError(error, '仲裁失败')) ElMessage.error(readError(error, '仲裁失败'))
@@ -488,6 +515,18 @@ async function handleArbitrate() {
<p v-if="['deduct_deposit', 'compensate_owner'].includes(result)"> <p v-if="['deduct_deposit', 'compensate_owner'].includes(result)">
金额表示从押金中赔付给号主的部分不填则默认处理全额押金 金额表示从押金中赔付给号主的部分不填则默认处理全额押金
</p> </p>
<div v-if="isCompletedResult" class="arbitration-field">
<span>最终实际哈夫币消耗M</span>
<el-input-number
v-model="actualCoinConsumedM"
class="full-control"
:min="0"
:max="1000000000"
:precision="2"
:step="1"
placeholder="实际消耗 M"
/>
</div>
<el-input <el-input
v-model="remark" v-model="remark"
class="panel-action" class="panel-action"
@@ -639,6 +678,18 @@ async function handleArbitrate() {
width: 280px; width: 280px;
} }
.arbitration-field {
display: grid;
gap: 6px;
margin-top: 12px;
}
.arbitration-field span {
color: #475569;
font-size: 13px;
font-weight: 700;
}
.main-cell, .main-cell,
.party-cell { .party-cell {
display: grid; display: grid;
@@ -156,8 +156,7 @@ const canOfflineSettlement = computed(() => offlineSettlementAction.value?.enabl
const canAdminCreateDispute = computed(() => canOperate.value && !order.value?.active_dispute) const canAdminCreateDispute = computed(() => canOperate.value && !order.value?.active_dispute)
const isPlatformManaged = computed( const isPlatformManaged = computed(
() => () =>
order.value?.handoff_mode === 'platform' || order.value?.handoff_mode === 'platform' || order.value?.settlement_mode === 'platform_managed'
order.value?.settlement_mode === 'platform_managed'
) )
const offlineSettlementStatus = computed(() => order.value?.offline_settlement_status || 'none') const offlineSettlementStatus = computed(() => order.value?.offline_settlement_status || 'none')
// 押金暂扣:仅进行中、有实付押金、且未暂扣过的订单可暂扣。 // 押金暂扣:仅进行中、有实付押金、且未暂扣过的订单可暂扣。
@@ -267,6 +266,10 @@ const hasOwnerRentBreakdown = computed(() => ownerCoinBasePriceCent.value !== nu
const fundSplitRows = computed(() => { const fundSplitRows = computed(() => {
if (!order.value) return [] if (!order.value) return []
const rows: FundSplitRow[] = [] const rows: FundSplitRow[] = []
rows.push(
{ label: '预收纯币原价', amountCent: order.value.pure_coin_original_amount_cent },
{ label: '预收额外物品', amountCent: order.value.extra_item_original_amount_cent }
)
if (Number(order.value.rent_discount_amount_cent || 0) > 0) { if (Number(order.value.rent_discount_amount_cent || 0) > 0) {
rows.push( rows.push(
{ label: '原始租金', amountCent: order.value.rent_original_amount_cent }, { label: '原始租金', amountCent: order.value.rent_original_amount_cent },
@@ -276,6 +279,16 @@ const fundSplitRows = computed(() => {
} }
) )
} }
if (order.value.status === 'completed') {
rows.push(
{ label: '最终实际纯币原价', amountCent: order.value.actual_pure_coin_amount_cent },
{
label: '最终实际纯币优惠',
amountCent: -Number(order.value.actual_pure_coin_discount_cent || 0),
},
{ label: '积分计算纯币基数', amountCent: order.value.growth_points_basis_cent }
)
}
rows.push({ label: '租客租金', amountCent: order.value.rent_amount_cent }) rows.push({ label: '租客租金', amountCent: order.value.rent_amount_cent })
if (hasOwnerRentBreakdown.value) { if (hasOwnerRentBreakdown.value) {
rows.push( rows.push(
@@ -525,7 +538,9 @@ async function submitPlatformCheckoutCounter() {
function openAdminDispute() { function openAdminDispute() {
adminDisputeForm.value = { adminDisputeForm.value = {
type: ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value?.status || '') type: ['pending_checkout_confirm', 'pending_checkout_accept'].includes(
order.value?.status || ''
)
? 'checkout_amount' ? 'checkout_amount'
: 'cannot_login', : 'cannot_login',
targetRole: 'renter', targetRole: 'renter',
@@ -886,12 +901,7 @@ function paymentPaidAt(record: AdminPayment) {
<el-button v-if="canOfflineSettlement" type="success" @click="openOfflineSettlement"> <el-button v-if="canOfflineSettlement" type="success" @click="openOfflineSettlement">
{{ offlineSettlementAction?.label || '确认线下结算' }} {{ offlineSettlementAction?.label || '确认线下结算' }}
</el-button> </el-button>
<el-button <el-button v-if="canAdminCreateDispute" type="warning" plain @click="openAdminDispute">
v-if="canAdminCreateDispute"
type="warning"
plain
@click="openAdminDispute"
>
发起申诉 发起申诉
</el-button> </el-button>
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')" <el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
@@ -1176,6 +1186,18 @@ function paymentPaidAt(record: AdminPayment) {
<dt>结算租金</dt> <dt>结算租金</dt>
<dd>{{ moneyCent(order.checkout.display_amount_cent) }}</dd> <dd>{{ moneyCent(order.checkout.display_amount_cent) }}</dd>
</div> </div>
<div>
<dt>实际纯币原价</dt>
<dd>{{ moneyCent(order.checkout.pure_coin_amount_cent) }}</dd>
</div>
<div>
<dt>实际纯币优惠</dt>
<dd>{{ moneyCent(order.checkout.pure_coin_discount_cent) }}</dd>
</div>
<div>
<dt>折后纯币金额</dt>
<dd>{{ moneyCent(order.checkout.pure_coin_payable_cent) }}</dd>
</div>
<div> <div>
<dt>退还租客</dt> <dt>退还租客</dt>
<dd>{{ moneyCent(order.checkout.renter_refund_amount_cent) }}</dd> <dd>{{ moneyCent(order.checkout.renter_refund_amount_cent) }}</dd>
@@ -1196,6 +1218,14 @@ function paymentPaidAt(record: AdminPayment) {
<dt>哈夫币消耗</dt> <dt>哈夫币消耗</dt>
<dd>{{ quantity(order.checkout.coin_consumed_m) }}M</dd> <dd>{{ quantity(order.checkout.coin_consumed_m) }}M</dd>
</div> </div>
<div v-if="order.status === 'completed'">
<dt>积分计算基数</dt>
<dd>{{ moneyCent(order.growth_points_basis_cent) }}</dd>
</div>
<div v-if="order.status === 'completed'">
<dt>本单成长积分</dt>
<dd>{{ order.growth_points_awarded || 0 }}</dd>
</div>
<div> <div>
<dt>发起人</dt> <dt>发起人</dt>
<dd>ID {{ order.checkout.initiated_by }}</dd> <dd>ID {{ order.checkout.initiated_by }}</dd>
@@ -1364,12 +1394,7 @@ function paymentPaidAt(record: AdminPayment) {
</template> </template>
</el-dialog> </el-dialog>
<el-dialog <el-dialog v-model="platformHandoffVisible" title="客服代交接" width="560px" destroy-on-close>
v-model="platformHandoffVisible"
title="客服代交接"
width="560px"
destroy-on-close
>
<div v-if="order" class="dialog-body"> <div v-if="order" class="dialog-body">
<p> <p>
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }} <strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
@@ -1395,12 +1420,7 @@ function paymentPaidAt(record: AdminPayment) {
</template> </template>
</el-dialog> </el-dialog>
<el-dialog <el-dialog v-model="adminDisputeVisible" title="发起订单申诉" width="620px" destroy-on-close>
v-model="adminDisputeVisible"
title="发起订单申诉"
width="620px"
destroy-on-close
>
<div v-if="order" class="dialog-body"> <div v-if="order" class="dialog-body">
<p> <p>
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }} <strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
@@ -1440,11 +1460,7 @@ function paymentPaidAt(record: AdminPayment) {
</div> </div>
<template #footer> <template #footer>
<el-button @click="adminDisputeVisible = false">取消</el-button> <el-button @click="adminDisputeVisible = false">取消</el-button>
<el-button <el-button type="primary" :loading="adminDisputeSubmitting" @click="submitAdminDispute">
type="primary"
:loading="adminDisputeSubmitting"
@click="submitAdminDispute"
>
发起申诉 发起申诉
</el-button> </el-button>
</template> </template>
@@ -28,6 +28,7 @@ export interface Dispute {
previous_handoff_status?: string previous_handoff_status?: string
previous_settlement_status?: string previous_settlement_status?: string
checkout_id?: number checkout_id?: number
checkout_coin_consumed_m?: number
previous_checkout_status?: string previous_checkout_status?: string
arbitration_result: string arbitration_result: string
arbitration_remark: string arbitration_remark: string
@@ -93,7 +94,12 @@ export async function fetchAdminDisputes(page = 1, pageSize = 20, query: AdminDi
export async function arbitrateDispute( export async function arbitrateDispute(
id: number, id: number,
payload: { result: string; remark: string; amount_cent?: number } payload: {
result: string
remark: string
amount_cent?: number
actual_coin_consumed_m?: number
}
) { ) {
const { data } = await apiClient.post<ApiResponse<Dispute>>( const { data } = await apiClient.post<ApiResponse<Dispute>>(
`/admin/disputes/${id}/arbitrate`, `/admin/disputes/${id}/arbitrate`,
@@ -32,9 +32,16 @@ export interface Order {
platform_fee_cent?: number platform_fee_cent?: number
rent_original_amount_cent?: number rent_original_amount_cent?: number
rent_discount_amount_cent?: number rent_discount_amount_cent?: number
pure_coin_original_amount_cent?: number
extra_item_original_amount_cent?: number
actual_coin_consumed_m?: number
actual_pure_coin_amount_cent?: number
actual_pure_coin_discount_cent?: number
renter_growth_level?: string renter_growth_level?: string
renter_growth_level_name?: string renter_growth_level_name?: string
renter_discount_bps?: number renter_discount_bps?: number
growth_points_basis_cent?: number
growth_points_per_yuan?: number
growth_points_awarded?: number growth_points_awarded?: number
growth_points_awarded_at?: string growth_points_awarded_at?: string
account_snapshot?: Record<string, unknown> account_snapshot?: Record<string, unknown>
@@ -107,6 +114,9 @@ export interface Checkout {
owner_rent_amount_cent?: number owner_rent_amount_cent?: number
platform_fee_cent?: number platform_fee_cent?: number
deposit_amount_cent: number deposit_amount_cent: number
pure_coin_amount_cent: number
pure_coin_discount_cent: number
pure_coin_payable_cent: number
consumable_amount_cent: number consumable_amount_cent: number
coin_consumed_m: number coin_consumed_m: number
other_amount_cent: number other_amount_cent: number
@@ -25,6 +25,22 @@ const checkout = computed(() => props.order.checkout)
title="实际结算租金" title="实际结算租金"
:value="`¥${money(amountYuan(checkout.display_amount_cent))}`" :value="`¥${money(amountYuan(checkout.display_amount_cent))}`"
/> />
<van-cell
v-if="props.isRenter"
title="实际纯币原价"
:value="`¥${money(amountYuan(checkout.pure_coin_amount_cent))}`"
/>
<van-cell
v-if="props.isRenter && Number(checkout.pure_coin_discount_cent || 0) > 0"
title="成长等级优惠"
:value="`-¥${money(amountYuan(checkout.pure_coin_discount_cent))}`"
value-class="green-text"
/>
<van-cell
v-if="props.isRenter"
title="折后纯币金额"
:value="`¥${money(amountYuan(checkout.pure_coin_payable_cent))}`"
/>
<van-cell <van-cell
title="押金总额" title="押金总额"
:label=" :label="
@@ -43,10 +59,7 @@ const checkout = computed(() => props.order.checkout)
:value="`¥${money(amountYuan(checkout.deposit_deduct_amount_cent))}`" :value="`¥${money(amountYuan(checkout.deposit_deduct_amount_cent))}`"
value-class="red-text" value-class="red-text"
/> />
<van-cell <van-cell title="哈夫币实际消耗" :value="`${checkout.coin_consumed_m}M`" />
title="哈夫币实际消耗"
:value="`${checkout.coin_consumed_m}M`"
/>
<van-cell <van-cell
v-if="Number(checkout.overshoot_amount_cent || 0) > 0" v-if="Number(checkout.overshoot_amount_cent || 0) > 0"
title="打超金额(从押金)" title="打超金额(从押金)"
@@ -77,6 +90,13 @@ const checkout = computed(() => props.order.checkout)
:value="`¥${money(amountYuan(checkout.owner_income_amount_cent))}`" :value="`¥${money(amountYuan(checkout.owner_income_amount_cent))}`"
value-class="green-text" value-class="green-text"
/> />
<van-cell
v-if="props.isRenter && props.order.status === 'completed'"
title="本单成长积分"
:label="`积分基数 ¥${money(amountYuan(props.order.growth_points_basis_cent))}`"
:value="`+${Number(props.order.growth_points_awarded || 0)}`"
value-class="green-text"
/>
<van-cell v-if="checkout.content" title="结账备注" :label="checkout.content" /> <van-cell v-if="checkout.content" title="结账备注" :label="checkout.content" />
<van-cell <van-cell
v-if="checkout.owner_adjustment_reason" v-if="checkout.owner_adjustment_reason"
@@ -95,6 +115,21 @@ const checkout = computed(() => props.order.checkout)
<span>实际结算租金</span> <span>实际结算租金</span>
<strong>¥{{ money(amountYuan(checkout.display_amount_cent)) }}</strong> <strong>¥{{ money(amountYuan(checkout.display_amount_cent)) }}</strong>
</div> </div>
<div v-if="props.isRenter" class="summary-row">
<span>实际纯币原价</span>
<strong>¥{{ money(amountYuan(checkout.pure_coin_amount_cent)) }}</strong>
</div>
<div
v-if="props.isRenter && Number(checkout.pure_coin_discount_cent || 0) > 0"
class="summary-row"
>
<span>成长等级优惠</span>
<strong class="amount">-¥{{ money(amountYuan(checkout.pure_coin_discount_cent)) }}</strong>
</div>
<div v-if="props.isRenter" class="summary-row">
<span>折后纯币金额</span>
<strong>¥{{ money(amountYuan(checkout.pure_coin_payable_cent)) }}</strong>
</div>
<div class="summary-row"> <div class="summary-row">
<span>预收押金</span> <span>预收押金</span>
<strong> <strong>
@@ -138,6 +173,15 @@ const checkout = computed(() => props.order.checkout)
<span>号主最终收入租金 + 押金赔付</span> <span>号主最终收入租金 + 押金赔付</span>
<strong class="amount">¥{{ money(amountYuan(checkout.owner_income_amount_cent)) }}</strong> <strong class="amount">¥{{ money(amountYuan(checkout.owner_income_amount_cent)) }}</strong>
</div> </div>
<div
v-if="props.isRenter && props.order.status === 'completed'"
class="summary-row highlight"
>
<span>
本单成长积分基数 ¥{{ money(amountYuan(props.order.growth_points_basis_cent)) }}
</span>
<strong class="amount">+{{ Number(props.order.growth_points_awarded || 0) }}</strong>
</div>
<div v-if="checkout.content" class="summary-note"> <div v-if="checkout.content" class="summary-note">
<label>说明</label> <label>说明</label>
<p>{{ checkout.content }}</p> <p>{{ checkout.content }}</p>
@@ -174,6 +174,13 @@ export function useOrderActions(options: UseOrderActionsOptions) {
const orderRentDisplayAmount = computed(() => const orderRentDisplayAmount = computed(() =>
order.value ? orderRentAmount(order.value, session.userId) : 0 order.value ? orderRentAmount(order.value, session.userId) : 0
) )
const renterDiscountDisplayCent = computed(() => {
if (!order.value) return 0
if (order.value.status === 'completed') {
return Number(order.value.actual_pure_coin_discount_cent || 0)
}
return Number(order.value.rent_discount_amount_cent || 0)
})
const ownerIncomeDisplayAmount = computed(() => const ownerIncomeDisplayAmount = computed(() =>
order.value ? ownerActualIncome(order.value, session.userId) : null order.value ? ownerActualIncome(order.value, session.userId) : null
) )
@@ -206,9 +213,7 @@ export function useOrderActions(options: UseOrderActionsOptions) {
(isRenter.value && order.value.status === 'pending_checkout_accept') (isRenter.value && order.value.status === 'pending_checkout_accept')
) )
}) })
const checkoutShortfallCent = computed(() => const checkoutShortfallCent = computed(() => Number(order.value?.checkout?.shortfall_cent || 0))
Number(order.value?.checkout?.shortfall_cent || 0)
)
const checkoutOvershootCent = computed(() => const checkoutOvershootCent = computed(() =>
Number(order.value?.checkout?.overshoot_amount_cent || 0) Number(order.value?.checkout?.overshoot_amount_cent || 0)
) )
@@ -410,6 +415,9 @@ export function useOrderActions(options: UseOrderActionsOptions) {
await confirmCheckout(order.value.id) await confirmCheckout(order.value.id)
options.notifySuccess('结账已确认,订单完成') options.notifySuccess('结账已确认,订单完成')
await loadOrder() await loadOrder()
if (isRenter.value) {
await session.loadMe().catch(() => undefined)
}
} catch (error) { } catch (error) {
options.notifyError(readError(error, '确认结账失败')) options.notifyError(readError(error, '确认结账失败'))
} finally { } finally {
@@ -469,6 +477,9 @@ export function useOrderActions(options: UseOrderActionsOptions) {
await acceptCheckout(order.value.id) await acceptCheckout(order.value.id)
options.notifySuccess('已确认结账,订单完成') options.notifySuccess('已确认结账,订单完成')
await loadOrder() await loadOrder()
if (isRenter.value) {
await session.loadMe().catch(() => undefined)
}
} catch (error) { } catch (error) {
options.notifyError(readError(error, '确认结账失败')) options.notifyError(readError(error, '确认结账失败'))
} finally { } finally {
@@ -676,6 +687,7 @@ export function useOrderActions(options: UseOrderActionsOptions) {
listingCode, listingCode,
orderAmountLabel, orderAmountLabel,
orderRentDisplayAmount, orderRentDisplayAmount,
renterDiscountDisplayCent,
ownerIncomeDisplayAmount, ownerIncomeDisplayAmount,
ownerIncomeLabel, ownerIncomeLabel,
canOpenDispute, canOpenDispute,
@@ -69,6 +69,7 @@ const {
listingCode, listingCode,
orderAmountLabel, orderAmountLabel,
orderRentDisplayAmount, orderRentDisplayAmount,
renterDiscountDisplayCent,
ownerIncomeDisplayAmount, ownerIncomeDisplayAmount,
ownerIncomeLabel, ownerIncomeLabel,
canOpenDispute, canOpenDispute,
@@ -188,12 +189,9 @@ async function copyListingCode() {
<div class="meta-item"> <div class="meta-item">
<span class="meta-label">{{ orderAmountLabel }}</span> <span class="meta-label">{{ orderAmountLabel }}</span>
<strong class="meta-value accent">¥{{ money(orderRentDisplayAmount) }}</strong> <strong class="meta-value accent">¥{{ money(orderRentDisplayAmount) }}</strong>
<span <span v-if="isRenter && renterDiscountDisplayCent > 0" class="meta-note">
v-if="isRenter && Number(order.rent_discount_amount_cent || 0) > 0"
class="meta-note"
>
{{ order.renter_growth_level_name || '成长等级' }}优惠 ¥{{ {{ order.renter_growth_level_name || '成长等级' }}优惠 ¥{{
money(amountYuan(order.rent_discount_amount_cent)) money(amountYuan(renterDiscountDisplayCent))
}} }}
</span> </span>
</div> </div>
@@ -212,6 +210,16 @@ async function copyListingCode() {
<span class="meta-label">交接状态</span> <span class="meta-label">交接状态</span>
<strong class="meta-value">{{ orderHandoffStatusLabel(order) }}</strong> <strong class="meta-value">{{ orderHandoffStatusLabel(order) }}</strong>
</div> </div>
<div v-if="isRenter && order.status === 'completed' && !order.checkout" class="meta-item">
<span class="meta-label">实际纯币原价</span>
<strong class="meta-value accent"
>¥{{ money(amountYuan(order.actual_pure_coin_amount_cent)) }}</strong
>
</div>
<div v-if="isRenter && order.status === 'completed' && !order.checkout" class="meta-item">
<span class="meta-label">本单成长积分</span>
<strong class="meta-value income">+{{ order.growth_points_awarded || 0 }}</strong>
</div>
</div> </div>
</section> </section>
@@ -366,8 +374,10 @@ async function copyListingCode() {
> >
<h3 class="section-title">结账处理</h3> <h3 class="section-title">结账处理</h3>
<p class="action-hint"> <p class="action-hint">
{{ checkoutRoundLabel || '请核对消耗与扣款' }}。确认无误后同意完结;也可修改方案交对方确认(最多 {{
6 轮)。押金不足无法自动完结,请改方案或争议。 checkoutRoundLabel || '请核对消耗与扣款'
}}。确认无误后同意完结;也可修改方案交对方确认(最多 6
轮)。押金不足无法自动完结,请改方案或争议。
</p> </p>
<p v-if="checkoutShortfallCent > 0" class="action-hint" style="color: #ef4444"> <p v-if="checkoutShortfallCent > 0" class="action-hint" style="color: #ef4444">
押金不足差额 ¥{{ money(amountYuan(checkoutShortfallCent)) }},无法同意完结。 押金不足差额 ¥{{ money(amountYuan(checkoutShortfallCent)) }},无法同意完结。
@@ -490,7 +500,9 @@ async function copyListingCode() {
</header> </header>
<div class="popup-body"> <div class="popup-body">
<p class="form-hint-info"> <p class="form-hint-info">
{{ checkoutRoundLabel }}。可填实际哈夫币消耗(允许超过发布量),打超按比例从押金扣。提案将交给对方确认。 {{
checkoutRoundLabel
}}。可填实际哈夫币消耗(允许超过发布量),打超按比例从押金扣。提案将交给对方确认。
</p> </p>
<van-cell-group :border="false"> <van-cell-group :border="false">
<van-field label="额外消耗品已用"> <van-field label="额外消耗品已用">
@@ -68,6 +68,7 @@ const {
listingCode, listingCode,
orderAmountLabel, orderAmountLabel,
orderRentDisplayAmount, orderRentDisplayAmount,
renterDiscountDisplayCent,
ownerIncomeDisplayAmount, ownerIncomeDisplayAmount,
ownerIncomeLabel, ownerIncomeLabel,
canOpenDispute, canOpenDispute,
@@ -388,12 +389,9 @@ watch([() => route.query.focus, order, loading], () => {
<div class="metric-card highlight"> <div class="metric-card highlight">
<span class="metric-label">{{ orderAmountLabel }}</span> <span class="metric-label">{{ orderAmountLabel }}</span>
<strong class="metric-value amount">¥{{ money(orderRentDisplayAmount) }}</strong> <strong class="metric-value amount">¥{{ money(orderRentDisplayAmount) }}</strong>
<span <span v-if="isRenter && renterDiscountDisplayCent > 0" class="metric-note">
v-if="isRenter && Number(order.rent_discount_amount_cent || 0) > 0"
class="metric-note"
>
{{ order.renter_growth_level_name || '成长等级' }}优惠 ¥{{ {{ order.renter_growth_level_name || '成长等级' }}优惠 ¥{{
money(amountYuan(order.rent_discount_amount_cent)) money(amountYuan(renterDiscountDisplayCent))
}} }}
</span> </span>
</div> </div>
@@ -410,6 +408,22 @@ watch([() => route.query.focus, order, loading], () => {
>已免押 ¥{{ money(amountYuan(order.deposit_waived_amount_cent)) }}</span >已免押 ¥{{ money(amountYuan(order.deposit_waived_amount_cent)) }}</span
> >
</div> </div>
<div
v-if="isRenter && order.status === 'completed' && !order.checkout"
class="metric-card"
>
<span class="metric-label">实际纯币原价</span>
<strong class="metric-value amount"
>¥{{ money(amountYuan(order.actual_pure_coin_amount_cent)) }}</strong
>
</div>
<div
v-if="isRenter && order.status === 'completed' && !order.checkout"
class="metric-card income"
>
<span class="metric-label">本单成长积分</span>
<strong class="metric-value">+{{ order.growth_points_awarded || 0 }}</strong>
</div>
</div> </div>
<div v-if="order" class="info-section"> <div v-if="order" class="info-section">