diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index f1e14d6..4418252 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -27,9 +27,16 @@ type RentalOrder struct { PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"` RentOriginalAmountCent 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"` RenterGrowthLevelName string `gorm:"size:32;not null;default:'普通'" json:"renter_growth_level_name"` 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"` GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"` AccountSnapshot datatypes.JSON `json:"account_snapshot"` diff --git a/backend/internal/model/order_checkout.go b/backend/internal/model/order_checkout.go index 637f18b..9443c05 100644 --- a/backend/internal/model/order_checkout.go +++ b/backend/internal/model/order_checkout.go @@ -18,6 +18,9 @@ type OrderCheckout struct { OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"` PlatformFeeCent 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:"-"` CoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"coin_consumed_m"` OtherAmountCent int64 `gorm:"not null;default:0" json:"-"` diff --git a/backend/internal/model/renter_growth.go b/backend/internal/model/renter_growth.go index f6d46c9..252ff51 100644 --- a/backend/internal/model/renter_growth.go +++ b/backend/internal/model/renter_growth.go @@ -8,6 +8,8 @@ type RenterGrowthLedger struct { 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"` 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"` AfterPoints int64 `gorm:"not null;default:0" json:"after_points"` BeforeLevel string `gorm:"size:32;not null;default:'normal'" json:"before_level"` diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index a00a319..27d0c59 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -12,6 +12,7 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/adminnotification" "hfb_sys/backend/internal/modules/notification" + ordermodule "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/rentergrowth" "hfb_sys/backend/internal/modules/wallet" "hfb_sys/backend/pkg/money" @@ -112,7 +113,23 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r return err } 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 } } @@ -146,6 +163,8 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r "renter_refund_amount_cent": settlement.RenterRefundAmountCent, "owner_income_amount_cent": settlement.OwnerIncomeAmountCent, "deposit_deduct_amount_cent": settlement.DepositDeductAmountCent, + "actual_coin_consumed_m": order.ActualCoinConsumedM, + "actual_pure_coin_cent": order.ActualPureCoinAmountCent, "before_order_status": beforeOrderStatus, "after_order_status": order.Status, "before_handoff_status": beforeHandoffStatus, @@ -200,19 +219,22 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r return &dto, nil } -func arbitrationActualRentCent(order model.RentalOrder, settlement arbitrationSettlement) int64 { - rentRefundCent := settlement.RenterRefundAmountCent - settlement.RenterDepositRefundCent - if rentRefundCent < 0 { - rentRefundCent = 0 +func arbitrationCoinConsumedM(tx *gorm.DB, row model.Dispute, req ArbitrateRequest) (float64, *model.OrderCheckout, error) { + var checkout *model.OrderCheckout + if row.CheckoutID != nil && *row.CheckoutID > 0 { + var saved model.OrderCheckout + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&saved, *row.CheckoutID).Error; err != nil { + return 0, nil, err + } + checkout = &saved } - if rentRefundCent > order.RentAmountCent { - rentRefundCent = order.RentAmountCent + if req.ActualCoinConsumedM != nil { + return *req.ActualCoinConsumedM, checkout, nil } - actualRentCent := order.RentAmountCent - rentRefundCent - if actualRentCent < 0 { - return 0 + if checkout != nil { + return checkout.CoinConsumedM, checkout, nil } - return actualRentCent + return 0, nil, ErrInvalidDispute } func isPlatformManagedOrder(order model.RentalOrder) bool { diff --git a/backend/internal/modules/dispute/dto.go b/backend/internal/modules/dispute/dto.go index d51a146..6e21025 100644 --- a/backend/internal/modules/dispute/dto.go +++ b/backend/internal/modules/dispute/dto.go @@ -33,6 +33,7 @@ type DisputeDTO struct { PreviousHandoffStatus string `json:"previous_handoff_status"` PreviousSettlementStatus string `json:"previous_settlement_status"` CheckoutID *uint64 `json:"checkout_id"` + CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"` PreviousCheckoutStatus string `json:"previous_checkout_status"` ArbitrationResult string `json:"arbitration_result"` ArbitrationRemark string `json:"arbitration_remark"` @@ -56,9 +57,10 @@ type AdminCreateRequest struct { } type ArbitrateRequest struct { - Result string `json:"result" binding:"required"` - Remark string `json:"remark" binding:"required"` - AmountCent int64 `json:"amount_cent"` + Result string `json:"result" binding:"required"` + Remark string `json:"remark" binding:"required"` + AmountCent int64 `json:"amount_cent"` + ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"` } type AdminListQuery struct { diff --git a/backend/internal/modules/dispute/presenter.go b/backend/internal/modules/dispute/presenter.go index 1a0c1ee..24c20de 100644 --- a/backend/internal/modules/dispute/presenter.go +++ b/backend/internal/modules/dispute/presenter.go @@ -10,16 +10,17 @@ import ( type disputeRow struct { model.Dispute - OrderNo string - OrderStatus string - HandoffStatus string - SettlementStatus string - ListingNo string - Title string - OwnerID uint64 - RenterID uint64 - OwnerPhone string - RenterPhone string + OrderNo string + OrderStatus string + HandoffStatus string + SettlementStatus string + ListingNo string + Title string + OwnerID uint64 + RenterID uint64 + OwnerPhone string + RenterPhone string + CheckoutCoinConsumedM *float64 } func (row disputeRow) toDTO() DisputeDTO { @@ -48,6 +49,7 @@ func (row disputeRow) toDTO() DisputeDTO { PreviousHandoffStatus: row.PreviousHandoffStatus, PreviousSettlementStatus: row.PreviousSettlementStatus, CheckoutID: row.CheckoutID, + CheckoutCoinConsumedM: row.CheckoutCoinConsumedM, PreviousCheckoutStatus: row.PreviousCheckoutStatus, ArbitrationResult: row.ArbitrationResult, ArbitrationRemark: row.ArbitrationRemark, diff --git a/backend/internal/modules/dispute/query.go b/backend/internal/modules/dispute/query.go index e82f654..121632f 100644 --- a/backend/internal/modules/dispute/query.go +++ b/backend/internal/modules/dispute/query.go @@ -92,7 +92,7 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { return r.adminFilterQuery(ctx). 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, - 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 { @@ -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 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 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 { diff --git a/backend/internal/modules/dispute/repository_test.go b/backend/internal/modules/dispute/repository_test.go index 49225a6..cb5101f 100644 --- a/backend/internal/modules/dispute/repository_test.go +++ b/backend/internal/modules/dispute/repository_test.go @@ -1,6 +1,8 @@ package dispute import ( + "context" + "errors" "strings" "testing" "time" @@ -8,9 +10,36 @@ import ( "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" + "gorm.io/datatypes" "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 { t.Helper() db := database.NewTestDB() @@ -261,10 +290,12 @@ func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) { if adminNotificationCount == 0 { t.Fatal("platform managed dispute should notify managed admin") } + actualCoinConsumedM := 0.0 if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{ - Result: "release_deposit", - Remark: "确认租金给卖家,线下结算", + Result: "release_deposit", + Remark: "确认租金给卖家,线下结算", + ActualCoinConsumedM: &actualCoinConsumedM, }, AuditMeta{}); err != nil { 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) { order := model.RentalOrder{ ID: 11, diff --git a/backend/internal/modules/dispute/service.go b/backend/internal/modules/dispute/service.go index d7c3e0b..e274ed4 100644 --- a/backend/internal/modules/dispute/service.go +++ b/backend/internal/modules/dispute/service.go @@ -90,5 +90,8 @@ func (s *Service) Arbitrate(ctx context.Context, adminID uint64, id uint64, req if id == 0 || req.Result == "" || req.Remark == "" { 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) } diff --git a/backend/internal/modules/order/checkout.go b/backend/internal/modules/order/checkout.go index b9d1f7e..52abe10 100644 --- a/backend/internal/modules/order/checkout.go +++ b/backend/internal/modules/order/checkout.go @@ -255,6 +255,9 @@ func applyCounterCheckoutUpdate(checkout *model.OrderCheckout, next model.OrderC checkout.OwnerRentAmountCent = next.OwnerRentAmountCent checkout.PlatformFeeCent = next.PlatformFeeCent checkout.DepositAmountCent = next.DepositAmountCent + checkout.PureCoinAmountCent = next.PureCoinAmountCent + checkout.PureCoinDiscountCent = next.PureCoinDiscountCent + checkout.PureCoinPayableCent = next.PureCoinPayableCent checkout.ConsumableAmountCent = next.ConsumableAmountCent checkout.CoinConsumedM = next.CoinConsumedM checkout.OtherAmountCent = next.OtherAmountCent diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index 4056d33..46e80af 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -40,10 +40,13 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che return nil, err } applyCheckoutSettlement(checkout, settlement) + order.ActualCoinConsumedM = settlement.CoinConsumedM + order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent + order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil { 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 } 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.OwnerRentAmountCent = settlement.OwnerRentIncomeCent checkout.PlatformFeeCent = settlement.PlatformFeeCent + checkout.PureCoinAmountCent = settlement.PureCoinAmountCent + checkout.PureCoinDiscountCent = settlement.PureCoinDiscountCent + checkout.PureCoinPayableCent = settlement.PureCoinPayableCent checkout.RenterRefundAmountCent = settlement.RenterRefundCent checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent checkout.ShortfallCent = settlement.ShortfallCent diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index f6d4cd1..a60389c 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -37,9 +37,16 @@ type OrderDTO struct { PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"` RentOriginalAmountCent int64 `json:"rent_original_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"` RenterGrowthLevelName string `json:"renter_growth_level_name,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"` GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"` AccountSnapshot datatypes.JSON `json:"account_snapshot"` @@ -191,6 +198,9 @@ type CheckoutDTO struct { OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"` PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"` 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"` CoinConsumedM float64 `json:"coin_consumed_m"` OtherAmountCent int64 `json:"other_amount_cent"` diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 0f7e3c2..36292e1 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -48,7 +48,7 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ if err != nil { return err } - rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(rentOriginalAmountCent, pricing.PlatformFeeCent, growthSnapshot.DiscountBps) + rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(pricing.PureCoinAmountCent, pricing.PureCoinPlatformFeeCent, growthSnapshot.DiscountBps) if rentDiscountAmountCent > 0 { pricing.PlatformFeeCent -= rentDiscountAmountCent pricing.RentAmountCent -= rentDiscountAmountCent @@ -59,34 +59,36 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ return err } order := model.RentalOrder{ - OrderNo: orderNo, - ListingID: listing.ID, - AccountID: listing.AccountID, - OwnerID: listing.OwnerID, - RenterID: renterID, - EstimatedDurationHours: rentHours, - RentAmountCent: pricing.RentAmountCent, - OwnerRentAmountCent: pricing.OwnerRentAmountCent, - DepositAmountCent: depositWaiver.PaidCent, - DepositOriginalAmountCent: depositOriginalAmountCent, - DepositWaivedAmountCent: depositWaiver.WaivedCent, - DepositFreeLevelQuotaCent: growthSnapshot.LevelDepositFreeQuotaCent, - DepositFreeManualQuotaCent: growthSnapshot.ManualDepositFreeQuotaCent, - DepositFreeUsedBeforeCent: depositWaiver.UsedBeforeCent, - PlatformFeeCent: pricing.PlatformFeeCent, - RentOriginalAmountCent: rentOriginalAmountCent, - RentDiscountAmountCent: rentDiscountAmountCent, - RenterGrowthLevel: growthSnapshot.LevelCode, - RenterGrowthLevelName: growthSnapshot.LevelName, - RenterDiscountBps: growthSnapshot.DiscountBps, - AccountSnapshot: snapshot, - Status: orderStatusPendingPayment, - HandoffStatus: handoffStatusNone, - HandoffMode: listingHandoffMode(listing), - SettlementMode: listingSettlementMode(listing), - ManagedAdminID: listing.ManagedAdminID, - SettlementStatus: settlementStatusUnsettled, - OfflineSettlementStatus: offlineSettlementStatusNone, + OrderNo: orderNo, + ListingID: listing.ID, + AccountID: listing.AccountID, + OwnerID: listing.OwnerID, + RenterID: renterID, + EstimatedDurationHours: rentHours, + RentAmountCent: pricing.RentAmountCent, + OwnerRentAmountCent: pricing.OwnerRentAmountCent, + DepositAmountCent: depositWaiver.PaidCent, + DepositOriginalAmountCent: depositOriginalAmountCent, + DepositWaivedAmountCent: depositWaiver.WaivedCent, + DepositFreeLevelQuotaCent: growthSnapshot.LevelDepositFreeQuotaCent, + DepositFreeManualQuotaCent: growthSnapshot.ManualDepositFreeQuotaCent, + DepositFreeUsedBeforeCent: depositWaiver.UsedBeforeCent, + PlatformFeeCent: pricing.PlatformFeeCent, + RentOriginalAmountCent: rentOriginalAmountCent, + RentDiscountAmountCent: rentDiscountAmountCent, + PureCoinOriginalAmountCent: pricing.PureCoinAmountCent, + ExtraItemOriginalAmountCent: pricing.ExtraItemAmountCent, + RenterGrowthLevel: growthSnapshot.LevelCode, + RenterGrowthLevelName: growthSnapshot.LevelName, + RenterDiscountBps: growthSnapshot.DiscountBps, + AccountSnapshot: snapshot, + Status: orderStatusPendingPayment, + HandoffStatus: handoffStatusNone, + HandoffMode: listingHandoffMode(listing), + SettlementMode: listingSettlementMode(listing), + ManagedAdminID: listing.ManagedAdminID, + SettlementStatus: settlementStatusUnsettled, + OfflineSettlementStatus: offlineSettlementStatusNone, } if err := tx.Create(&order).Error; err != nil { return err diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 604565b..4d4c853 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -87,9 +87,16 @@ func (row orderRow) toAdminDTO() OrderDTO { PlatformFeeCent: &platformFeeCent, RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder), RentDiscountAmountCent: row.RentDiscountAmountCent, + PureCoinOriginalAmountCent: orderPureCoinOriginalAmountCent(row.RentalOrder), + ExtraItemOriginalAmountCent: orderExtraItemOriginalAmountCent(row.RentalOrder), + ActualCoinConsumedM: row.ActualCoinConsumedM, + ActualPureCoinAmountCent: row.ActualPureCoinAmountCent, + ActualPureCoinDiscountCent: row.ActualPureCoinDiscountCent, RenterGrowthLevel: effectiveRenterGrowthLevel(row.RentalOrder), RenterGrowthLevelName: effectiveRenterGrowthLevelName(row.RentalOrder), RenterDiscountBps: effectiveRenterDiscountBps(row.RentalOrder), + GrowthPointsBasisCent: row.GrowthPointsBasisCent, + GrowthPointsPerYuan: row.GrowthPointsPerYuan, GrowthPointsAwarded: row.GrowthPointsAwarded, GrowthPointsAwardedAt: row.GrowthPointsAwardedAt, AccountSnapshot: row.AccountSnapshot, @@ -225,6 +232,9 @@ func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO { OwnerRentAmountCent: &ownerRentAmountCent, PlatformFeeCent: &platformFeeCent, DepositAmountCent: checkout.DepositAmountCent, + PureCoinAmountCent: checkout.PureCoinAmountCent, + PureCoinDiscountCent: checkout.PureCoinDiscountCent, + PureCoinPayableCent: checkout.PureCoinPayableCent, ConsumableAmountCent: checkout.ConsumableAmountCent, CoinConsumedM: checkout.CoinConsumedM, OtherAmountCent: checkout.OtherAmountCent, @@ -315,11 +325,18 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64) dto.OwnerRentAmountCent = &ownerAmountCent dto.RentOriginalAmountCent = 0 dto.RentDiscountAmountCent = 0 + dto.PureCoinOriginalAmountCent = 0 + dto.ExtraItemOriginalAmountCent = 0 + dto.ActualCoinConsumedM = 0 + dto.ActualPureCoinAmountCent = 0 + dto.ActualPureCoinDiscountCent = 0 dto.RenterGrowthLevel = "" dto.RenterGrowthLevelName = "" dto.RenterDiscountBps = 0 dto.GrowthPointsAwarded = 0 dto.GrowthPointsAwardedAt = nil + dto.GrowthPointsBasisCent = 0 + dto.GrowthPointsPerYuan = 0 sanitizeOrderSnapshot(&dto.AccountSnapshot, "owner") case userID == order.RenterID: rentAmountCent := order.RentAmountCent @@ -340,11 +357,18 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64) dto.OwnerRentAmountCent = nil dto.RentOriginalAmountCent = 0 dto.RentDiscountAmountCent = 0 + dto.PureCoinOriginalAmountCent = 0 + dto.ExtraItemOriginalAmountCent = 0 + dto.ActualCoinConsumedM = 0 + dto.ActualPureCoinAmountCent = 0 + dto.ActualPureCoinDiscountCent = 0 dto.RenterGrowthLevel = "" dto.RenterGrowthLevelName = "" dto.RenterDiscountBps = 0 dto.GrowthPointsAwarded = 0 dto.GrowthPointsAwardedAt = nil + dto.GrowthPointsBasisCent = 0 + dto.GrowthPointsPerYuan = 0 sanitizeOrderSnapshot(&dto.AccountSnapshot, "") } } @@ -379,6 +403,9 @@ func applyCheckoutPriceView(dto *CheckoutDTO, order model.RentalOrder, userID ui dto.RentAmountCent = nil dto.OwnerRentAmountCent = &ownerAmountCent dto.OwnerIncomeAmountCent = &ownerIncomeAmountCent + dto.PureCoinAmountCent = 0 + dto.PureCoinDiscountCent = 0 + dto.PureCoinPayableCent = 0 case userID == order.RenterID: dto.PriceRole = "renter" dto.DisplayAmountCent = rentAmountCent @@ -390,6 +417,9 @@ func applyCheckoutPriceView(dto *CheckoutDTO, order model.RentalOrder, userID ui dto.DisplayAmountCent = 0 dto.RentAmountCent = nil dto.OwnerRentAmountCent = nil + dto.PureCoinAmountCent = 0 + dto.PureCoinDiscountCent = 0 + dto.PureCoinPayableCent = 0 } } diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index 03a0e8f..935b659 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -7,15 +7,21 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/rentergrowth" "hfb_sys/backend/pkg/money" "gorm.io/datatypes" ) type orderPricing struct { - RentAmountCent int64 - OwnerRentAmountCent int64 - PlatformFeeCent int64 + RentAmountCent int64 + OwnerRentAmountCent int64 + PlatformFeeCent int64 + PureCoinAmountCent int64 + OwnerPureCoinAmountCent int64 + ExtraItemAmountCent int64 + OwnerExtraItemAmountCent int64 + PureCoinPlatformFeeCent int64 } type checkoutSettlement struct { @@ -27,10 +33,34 @@ type checkoutSettlement struct { RenterRefundCent int64 PlatformFeeCent int64 ActualRentAmountCent int64 + PureCoinAmountCent int64 + PureCoinDiscountCent int64 + PureCoinPayableCent int64 + ExtraItemAmountCent int64 + CoinConsumedM float64 OvershootAmountCent 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 { if order.EstimatedDurationHours > 0 { return order.EstimatedDurationHours @@ -85,10 +115,34 @@ func buildOrderPricing(listing model.RentalListing, account model.GameAccount) o if 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{ - RentAmountCent: rentAmountCent, - OwnerRentAmountCent: ownerRentAmountCent, - PlatformFeeCent: platformFeeCent, + RentAmountCent: rentAmountCent, + OwnerRentAmountCent: ownerRentAmountCent, + 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, PlatformFeeCent: settlement.PlatformFeeCent, DepositAmountCent: order.DepositAmountCent, + PureCoinAmountCent: settlement.PureCoinAmountCent, + PureCoinDiscountCent: settlement.PureCoinDiscountCent, + PureCoinPayableCent: settlement.PureCoinPayableCent, ConsumableAmountCent: consumableAmountCent, CoinConsumedM: roundQuantity(coinConsumedM), OtherAmountCent: otherAmountCent, @@ -185,18 +242,13 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i orderOwnerRentAmountCent := order.OwnerRentAmountCent orderDepositAmountCent := order.DepositAmountCent - buyerCoinBasePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "buyer_coin_base_price") * 100)) - if buyerCoinBasePriceCent <= 0 || buyerCoinBasePriceCent > orderRentAmountCent { - buyerCoinBasePriceCent = orderRentAmountCent - } + buyerCoinBasePriceCent := orderPureCoinOriginalAmountCent(order) sellerCoinBasePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "seller_coin_base_price") * 100)) if sellerCoinBasePriceCent <= 0 || sellerCoinBasePriceCent > orderOwnerRentAmountCent { - sellerCoinBasePriceCent = orderOwnerRentAmountCent - } - prepaidConsumablePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "consumable_price") * 100)) - if prepaidConsumablePriceCent <= 0 || prepaidConsumablePriceCent > orderRentAmountCent-buyerCoinBasePriceCent { - prepaidConsumablePriceCent = maxCent(orderRentAmountCent-buyerCoinBasePriceCent, 0) + ownerExtraItemCent := minCent(orderExtraItemOriginalAmountCent(order), orderOwnerRentAmountCent) + sellerCoinBasePriceCent = maxCent(orderOwnerRentAmountCent-ownerExtraItemCent, 0) } + prepaidConsumablePriceCent := orderExtraItemOriginalAmountCent(order) prepaidOwnerConsumablePriceCent := maxCent(orderOwnerRentAmountCent-sellerCoinBasePriceCent, 0) consumedM := roundQuantity(coinConsumedM) @@ -243,7 +295,10 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i 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 if actualOwnerRentCent > actualBuyerRentCent { actualOwnerRentCent = actualBuyerRentCent @@ -278,11 +333,44 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i RenterRefundCent: rentRefundCent + depositRefundCent, PlatformFeeCent: platformFeeCent, ActualRentAmountCent: actualRentAmountCent, + PureCoinAmountCent: usedBuyerCoinPriceCent, + PureCoinDiscountCent: pureCoinDiscountCent, + PureCoinPayableCent: pureCoinPayableCent, + ExtraItemAmountCent: usedBuyerConsumablePriceCent, + CoinConsumedM: consumedM, OvershootAmountCent: overshootCent, 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 { if len(raw) == 0 { return 0 diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 3789054..c7320f8 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -162,7 +162,15 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) { ServerRegion: "国服", LoginPlatform: "steam", 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 { t.Fatalf("create account failed: %v", err) @@ -170,7 +178,7 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) { listing := model.RentalListing{ AccountID: account.ID, OwnerID: owner.ID, - PriceCent: 10000, + PriceCent: 38300, DepositAmountCent: 100000, Status: listingStatusPublished, ReviewStatus: listingReviewStatusApproved, @@ -183,11 +191,14 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) { if err != nil { t.Fatalf("Create() error = %v", err) } - if dto.RentAmountCent == nil || *dto.RentAmountCent != 9900 { - t.Fatalf("rent amount = %v, want 9900", dto.RentAmountCent) + if dto.RentAmountCent == nil || *dto.RentAmountCent != 38037 { + t.Fatalf("rent amount = %v, want 38037", dto.RentAmountCent) } - if dto.RentOriginalAmountCent != 10000 || dto.RentDiscountAmountCent != 100 { - t.Fatalf("discount snapshot = original %d discount %d, want 10000/100", dto.RentOriginalAmountCent, dto.RentDiscountAmountCent) + if dto.RentOriginalAmountCent != 38300 || dto.RentDiscountAmountCent != 263 { + 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 { 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 { t.Fatalf("find saved order failed: %v", err) } - if saved.OwnerRentAmountCent != 9000 || saved.PlatformFeeCent != 900 { - t.Fatalf("pricing = owner %d platform %d, want 9000/900", saved.OwnerRentAmountCent, saved.PlatformFeeCent) + if saved.OwnerRentAmountCent != 35300 || saved.PlatformFeeCent != 2737 { + t.Fatalf("pricing = owner %d platform %d, want 35300/2737", saved.OwnerRentAmountCent, saved.PlatformFeeCent) } if saved.DepositAmountCent != 30000 || saved.DepositWaivedAmountCent != 70000 { t.Fatalf("deposit = paid %d waived %d, want 30000/70000", saved.DepositAmountCent, saved.DepositWaivedAmountCent) diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go index 466d0b3..4d9749f 100644 --- a/backend/internal/modules/order/repository_test.go +++ b/backend/internal/modules/order/repository_test.go @@ -5,11 +5,116 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/rentergrowth" "hfb_sys/backend/internal/timeutil" "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) { order := model.RentalOrder{ RentAmountCent: 38300, diff --git a/backend/internal/modules/payment/refund.go b/backend/internal/modules/payment/refund.go index 8f098a5..dced72c 100644 --- a/backend/internal/modules/payment/refund.go +++ b/backend/internal/modules/payment/refund.go @@ -6,6 +6,7 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/rentergrowth" "go.uber.org/zap" "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) { 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 payment, nil @@ -290,7 +291,7 @@ func (r *Repository) syncRefundPayment(ctx context.Context, payment *model.Payme } if runtimeConfig.isMockMode() { 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 } } @@ -371,7 +372,7 @@ func (r *Repository) applyRefundChannelStatus(ctx context.Context, payment *mode } switch status { case "refunded": - return r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent) + return r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType) case "failed": return r.markOrderRefundFailed(ctx, payment.OrderID, payment.AmountCent) default: @@ -394,13 +395,23 @@ func refundQueryRequest(payment model.PaymentOrder, originalPayment model.Paymen ProviderRefundID: payment.ProviderOrderID, } } -func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64, refundAmountCent int64) error { - now := time.Now() - return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ - "refund_status": "refunded", - "refund_amount_cent": refundAmountCent, - "refunded_at": now, - }).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() + order.RefundStatus = "refunded" + order.RefundAmountCent = refundAmountCent + order.RefundedAt = &now + if bizType == "admin_refund" && order.Status == "completed" && refundAmountCent >= order.RentAmountCent+order.DepositAmountCent { + 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 { return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ diff --git a/backend/internal/modules/payment/repository_integration_test.go b/backend/internal/modules/payment/repository_integration_test.go index 3540bc1..8c5e5ce 100644 --- a/backend/internal/modules/payment/repository_integration_test.go +++ b/backend/internal/modules/payment/repository_integration_test.go @@ -9,6 +9,7 @@ import ( "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" ordermodule "hfb_sys/backend/internal/modules/order" + "hfb_sys/backend/internal/modules/rentergrowth" "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 测试支付回调结果结构 func TestNotifyResultHasRequiredFields(t *testing.T) { result := NotifyResult{ diff --git a/backend/internal/modules/rentergrowth/growth.go b/backend/internal/modules/rentergrowth/growth.go index 14a76ff..98e254b 100644 --- a/backend/internal/modules/rentergrowth/growth.go +++ b/backend/internal/modules/rentergrowth/growth.go @@ -26,6 +26,7 @@ const ( MaxLevelPoints = 1_000_000_000 MaxLevelQuotaCent = 100_000_000 SourceOrderCompleted = "order_completed" + SourceOrderRefunded = "order_refunded" SourceAdminAdjustment = "admin_adjustment" ) @@ -243,7 +244,7 @@ func CalculateDiscountCent(originalRentCent int64, platformFeeCent int64, discou 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 { return nil } @@ -271,10 +272,12 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i if err != nil { return err } + order.GrowthPointsBasisCent = maxInt64(actualPureCoinCent, 0) + order.GrowthPointsPerYuan = cfg.PointsPerYuan if !cfg.Enabled { return nil } - points := PointsForRent(actualRentCent, cfg.PointsPerYuan) + points := PointsForPureCoin(actualPureCoinCent, cfg.PointsPerYuan) if points <= 0 { return nil } @@ -293,16 +296,18 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i } now := time.Now() ledger := model.RenterGrowthLedger{ - UserID: order.RenterID, - OrderID: &order.ID, - Points: points, - BeforePoints: beforePoints, - AfterPoints: afterPoints, - BeforeLevel: beforeLevel.Code, - AfterLevel: afterLevel.Code, - Source: source, - Remark: "租号订单完成发放成长积分", - CreatedAt: now, + UserID: order.RenterID, + OrderID: &order.ID, + Points: points, + BasisAmountCent: actualPureCoinCent, + PointsPerYuan: cfg.PointsPerYuan, + BeforePoints: beforePoints, + AfterPoints: afterPoints, + BeforeLevel: beforeLevel.Code, + AfterLevel: afterLevel.Code, + Source: source, + Remark: "租号订单完成发放成长积分", + CreatedAt: now, } if err := tx.Create(&ledger).Error; err != nil { return err @@ -312,14 +317,90 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i return nil } -func PointsForRent(actualRentCent int64, pointsPerYuan int64) int64 { - if actualRentCent <= 0 { +// RevokeOrderCompleted 在已完成订单被全额人工退款后扣回该单已发放的成长积分。 +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 } if pointsPerYuan <= 0 { 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 { diff --git a/backend/internal/modules/rentergrowth/growth_test.go b/backend/internal/modules/rentergrowth/growth_test.go index c23d9c6..bdaedd9 100644 --- a/backend/internal/modules/rentergrowth/growth_test.go +++ b/backend/internal/modules/rentergrowth/growth_test.go @@ -159,6 +159,9 @@ func TestAwardOrderCompletedUpdatesUserAndIsIdempotent(t *testing.T) { if order.GrowthPointsAwarded != 20 || order.GrowthPointsAwardedAt == nil { 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 if err := db.First(&savedUser, user.ID).Error; err != nil { t.Fatalf("find user failed: %v", err) @@ -186,4 +189,72 @@ func TestAwardOrderCompletedUpdatesUserAndIsIdempotent(t *testing.T) { if ledgerCount != 1 { 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) + } } diff --git a/backend/migrations/000043_renter_growth_pure_coin_basis.sql b/backend/migrations/000043_renter_growth_pure_coin_basis.sql new file mode 100644 index 0000000..2f8c301 --- /dev/null +++ b/backend/migrations/000043_renter_growth_pure_coin_basis.sql @@ -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; diff --git a/frontend/src/features/admin/views/AdminDisputesView.vue b/frontend/src/features/admin/views/AdminDisputesView.vue index 801f4e5..6c8e9b2 100644 --- a/frontend/src/features/admin/views/AdminDisputesView.vue +++ b/frontend/src/features/admin/views/AdminDisputesView.vue @@ -26,6 +26,7 @@ const evidenceDispute = ref(null) const result = ref('release_deposit') const remark = ref('') const amount = ref() +const actualCoinConsumedM = ref() const currentPage = ref(1) const currentPageSize = ref(20) const total = ref(0) @@ -33,6 +34,15 @@ const keywordFilter = ref('') const statusFilter = ref('') const typeFilter = ref('') 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( () => 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 (!result.value || !remark.value.trim()) 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 }) function canArbitrate(row: Dispute) { @@ -146,6 +162,7 @@ function openArbitration(row: Dispute) { result.value = row.arbitration_result || 'release_deposit' remark.value = row.arbitration_remark || '' amount.value = undefined + actualCoinConsumedM.value = row.checkout_coin_consumed_m ?? undefined } function resetFilters() { @@ -276,16 +293,26 @@ async function handleArbitrate() { ElMessage.warning('请填写部分退款金额') return } + if ( + isCompletedResult.value && + !activeDispute.value.checkout_id && + !hasValidActualCoinConsumedM.value + ) { + ElMessage.warning('请填写最终实际哈夫币消耗 M') + return + } submitting.value = true try { await arbitrateDispute(activeDispute.value.id, { result: result.value, remark: remark.value.trim(), amount_cent: amount.value ? yuanToCent(amount.value) : undefined, + actual_coin_consumed_m: isCompletedResult.value ? actualCoinConsumedM.value : undefined, }) ElMessage.success('仲裁结果已保存,双方已收到通知') activeDispute.value = null remark.value = '' + actualCoinConsumedM.value = undefined await loadDisputes() } catch (error) { ElMessage.error(readError(error, '仲裁失败')) @@ -488,6 +515,18 @@ async function handleArbitrate() {

金额表示从押金中赔付给号主的部分;不填则默认处理全额押金。

+
+ 最终实际哈夫币消耗(M) + +
offlineSettlementAction.value?.enabl const canAdminCreateDispute = computed(() => canOperate.value && !order.value?.active_dispute) const isPlatformManaged = computed( () => - order.value?.handoff_mode === 'platform' || - order.value?.settlement_mode === 'platform_managed' + order.value?.handoff_mode === 'platform' || order.value?.settlement_mode === 'platform_managed' ) const offlineSettlementStatus = computed(() => order.value?.offline_settlement_status || 'none') // 押金暂扣:仅进行中、有实付押金、且未暂扣过的订单可暂扣。 @@ -267,6 +266,10 @@ const hasOwnerRentBreakdown = computed(() => ownerCoinBasePriceCent.value !== nu const fundSplitRows = computed(() => { if (!order.value) return [] 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) { rows.push( { 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 }) if (hasOwnerRentBreakdown.value) { rows.push( @@ -525,7 +538,9 @@ async function submitPlatformCheckoutCounter() { function openAdminDispute() { 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' : 'cannot_login', targetRole: 'renter', @@ -886,12 +901,7 @@ function paymentPaidAt(record: AdminPayment) { {{ offlineSettlementAction?.label || '确认线下结算' }} - + 发起申诉 结算租金
{{ moneyCent(order.checkout.display_amount_cent) }}
+
+
实际纯币原价
+
{{ moneyCent(order.checkout.pure_coin_amount_cent) }}
+
+
+
实际纯币优惠
+
{{ moneyCent(order.checkout.pure_coin_discount_cent) }}
+
+
+
折后纯币金额
+
{{ moneyCent(order.checkout.pure_coin_payable_cent) }}
+
退还租客
{{ moneyCent(order.checkout.renter_refund_amount_cent) }}
@@ -1196,6 +1218,14 @@ function paymentPaidAt(record: AdminPayment) {
哈夫币消耗
{{ quantity(order.checkout.coin_consumed_m) }}M
+
+
积分计算基数
+
{{ moneyCent(order.growth_points_basis_cent) }}
+
+
+
本单成长积分
+
{{ order.growth_points_awarded || 0 }}
+
发起人
ID {{ order.checkout.initiated_by }}
@@ -1364,12 +1394,7 @@ function paymentPaidAt(record: AdminPayment) { - +

{{ order.order_no }} · 商品编号 {{ listingCode }} · {{ order.title }} @@ -1395,12 +1420,7 @@ function paymentPaidAt(record: AdminPayment) { - +

{{ order.order_no }} · 商品编号 {{ listingCode }} · {{ order.title }} @@ -1440,11 +1460,7 @@ function paymentPaidAt(record: AdminPayment) {

diff --git a/frontend/src/features/disputes/api/disputes.ts b/frontend/src/features/disputes/api/disputes.ts index f6bb5aa..5614be9 100644 --- a/frontend/src/features/disputes/api/disputes.ts +++ b/frontend/src/features/disputes/api/disputes.ts @@ -28,6 +28,7 @@ export interface Dispute { previous_handoff_status?: string previous_settlement_status?: string checkout_id?: number + checkout_coin_consumed_m?: number previous_checkout_status?: string arbitration_result: string arbitration_remark: string @@ -93,7 +94,12 @@ export async function fetchAdminDisputes(page = 1, pageSize = 20, query: AdminDi export async function arbitrateDispute( 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>( `/admin/disputes/${id}/arbitrate`, diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 1e8860a..425a8c6 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -32,9 +32,16 @@ export interface Order { platform_fee_cent?: number rent_original_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_name?: string renter_discount_bps?: number + growth_points_basis_cent?: number + growth_points_per_yuan?: number growth_points_awarded?: number growth_points_awarded_at?: string account_snapshot?: Record @@ -107,6 +114,9 @@ export interface Checkout { owner_rent_amount_cent?: number platform_fee_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 coin_consumed_m: number other_amount_cent: number diff --git a/frontend/src/features/orders/components/OrderCheckoutSummary.vue b/frontend/src/features/orders/components/OrderCheckoutSummary.vue index 42baac6..a33b320 100644 --- a/frontend/src/features/orders/components/OrderCheckoutSummary.vue +++ b/frontend/src/features/orders/components/OrderCheckoutSummary.vue @@ -25,6 +25,22 @@ const checkout = computed(() => props.order.checkout) title="实际结算租金" :value="`¥${money(amountYuan(checkout.display_amount_cent))}`" /> + + + - + props.order.checkout) :value="`¥${money(amountYuan(checkout.owner_income_amount_cent))}`" value-class="green-text" /> + props.order.checkout) 实际结算租金 ¥{{ money(amountYuan(checkout.display_amount_cent)) }}
+
+ 实际纯币原价 + ¥{{ money(amountYuan(checkout.pure_coin_amount_cent)) }} +
+
+ 成长等级优惠 + -¥{{ money(amountYuan(checkout.pure_coin_discount_cent)) }} +
+
+ 折后纯币金额 + ¥{{ money(amountYuan(checkout.pure_coin_payable_cent)) }} +
预收押金 @@ -138,6 +173,15 @@ const checkout = computed(() => props.order.checkout) 号主最终收入(租金 + 押金赔付) ¥{{ money(amountYuan(checkout.owner_income_amount_cent)) }}
+
+ + 本单成长积分(基数 ¥{{ money(amountYuan(props.order.growth_points_basis_cent)) }}) + + +{{ Number(props.order.growth_points_awarded || 0) }} +

{{ checkout.content }}

diff --git a/frontend/src/features/orders/composables/useOrderActions.ts b/frontend/src/features/orders/composables/useOrderActions.ts index c1ed508..a91bcd3 100644 --- a/frontend/src/features/orders/composables/useOrderActions.ts +++ b/frontend/src/features/orders/composables/useOrderActions.ts @@ -174,6 +174,13 @@ export function useOrderActions(options: UseOrderActionsOptions) { const orderRentDisplayAmount = computed(() => 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(() => 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') ) }) - const checkoutShortfallCent = computed(() => - Number(order.value?.checkout?.shortfall_cent || 0) - ) + const checkoutShortfallCent = computed(() => Number(order.value?.checkout?.shortfall_cent || 0)) const checkoutOvershootCent = computed(() => Number(order.value?.checkout?.overshoot_amount_cent || 0) ) @@ -410,6 +415,9 @@ export function useOrderActions(options: UseOrderActionsOptions) { await confirmCheckout(order.value.id) options.notifySuccess('结账已确认,订单完成') await loadOrder() + if (isRenter.value) { + await session.loadMe().catch(() => undefined) + } } catch (error) { options.notifyError(readError(error, '确认结账失败')) } finally { @@ -469,6 +477,9 @@ export function useOrderActions(options: UseOrderActionsOptions) { await acceptCheckout(order.value.id) options.notifySuccess('已确认结账,订单完成') await loadOrder() + if (isRenter.value) { + await session.loadMe().catch(() => undefined) + } } catch (error) { options.notifyError(readError(error, '确认结账失败')) } finally { @@ -676,6 +687,7 @@ export function useOrderActions(options: UseOrderActionsOptions) { listingCode, orderAmountLabel, orderRentDisplayAmount, + renterDiscountDisplayCent, ownerIncomeDisplayAmount, ownerIncomeLabel, canOpenDispute, diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.vue b/frontend/src/features/orders/views/MobileOrderDetailView.vue index 7992582..e92194f 100644 --- a/frontend/src/features/orders/views/MobileOrderDetailView.vue +++ b/frontend/src/features/orders/views/MobileOrderDetailView.vue @@ -69,6 +69,7 @@ const { listingCode, orderAmountLabel, orderRentDisplayAmount, + renterDiscountDisplayCent, ownerIncomeDisplayAmount, ownerIncomeLabel, canOpenDispute, @@ -188,12 +189,9 @@ async function copyListingCode() {
{{ orderAmountLabel }} ¥{{ money(orderRentDisplayAmount) }} - + {{ order.renter_growth_level_name || '成长等级' }}优惠 ¥{{ - money(amountYuan(order.rent_discount_amount_cent)) + money(amountYuan(renterDiscountDisplayCent)) }}
@@ -212,6 +210,16 @@ async function copyListingCode() { 交接状态 {{ orderHandoffStatusLabel(order) }}
+
+ 实际纯币原价 + ¥{{ money(amountYuan(order.actual_pure_coin_amount_cent)) }} +
+
+ 本单成长积分 + +{{ order.growth_points_awarded || 0 }} +
@@ -366,8 +374,10 @@ async function copyListingCode() { >

结账处理

- {{ checkoutRoundLabel || '请核对消耗与扣款' }}。确认无误后同意完结;也可修改方案交对方确认(最多 - 6 轮)。押金不足无法自动完结,请改方案或争议。 + {{ + checkoutRoundLabel || '请核对消耗与扣款' + }}。确认无误后同意完结;也可修改方案交对方确认(最多 6 + 轮)。押金不足无法自动完结,请改方案或争议。

押金不足差额 ¥{{ money(amountYuan(checkoutShortfallCent)) }},无法同意完结。 @@ -490,7 +500,9 @@ async function copyListingCode() {

+
+ 实际纯币原价 + ¥{{ money(amountYuan(order.actual_pure_coin_amount_cent)) }} +
+
+ 本单成长积分 + +{{ order.growth_points_awarded || 0 }} +