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

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:"-"`
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"`
+3
View File
@@ -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:"-"`
+2
View File
@@ -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"`
+33 -11
View File
@@ -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 {
+5 -3
View File
@@ -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 {
+12 -10
View File
@@ -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,
+3 -2
View File
@@ -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 {
@@ -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,
@@ -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)
}
@@ -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
@@ -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
+10
View File
@@ -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"`
+31 -29
View File
@@ -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
@@ -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
}
}
+104 -16
View File
@@ -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
@@ -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)
@@ -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,
+21 -10
View File
@@ -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{
@@ -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{
+96 -15
View File
@@ -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 {
@@ -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)
}
}
@@ -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 remark = ref('')
const amount = ref<number | undefined>()
const actualCoinConsumedM = ref<number | undefined>()
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() {
<p v-if="['deduct_deposit', 'compensate_owner'].includes(result)">
金额表示从押金中赔付给号主的部分不填则默认处理全额押金
</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
v-model="remark"
class="panel-action"
@@ -639,6 +678,18 @@ async function handleArbitrate() {
width: 280px;
}
.arbitration-field {
display: grid;
gap: 6px;
margin-top: 12px;
}
.arbitration-field span {
color: #475569;
font-size: 13px;
font-weight: 700;
}
.main-cell,
.party-cell {
display: grid;
@@ -156,8 +156,7 @@ const canOfflineSettlement = computed(() => 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) {
<el-button v-if="canOfflineSettlement" type="success" @click="openOfflineSettlement">
{{ offlineSettlementAction?.label || '确认线下结算' }}
</el-button>
<el-button
v-if="canAdminCreateDispute"
type="warning"
plain
@click="openAdminDispute"
>
<el-button v-if="canAdminCreateDispute" type="warning" plain @click="openAdminDispute">
发起申诉
</el-button>
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
@@ -1176,6 +1186,18 @@ function paymentPaidAt(record: AdminPayment) {
<dt>结算租金</dt>
<dd>{{ moneyCent(order.checkout.display_amount_cent) }}</dd>
</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>
<dt>退还租客</dt>
<dd>{{ moneyCent(order.checkout.renter_refund_amount_cent) }}</dd>
@@ -1196,6 +1218,14 @@ function paymentPaidAt(record: AdminPayment) {
<dt>哈夫币消耗</dt>
<dd>{{ quantity(order.checkout.coin_consumed_m) }}M</dd>
</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>
<dt>发起人</dt>
<dd>ID {{ order.checkout.initiated_by }}</dd>
@@ -1364,12 +1394,7 @@ function paymentPaidAt(record: AdminPayment) {
</template>
</el-dialog>
<el-dialog
v-model="platformHandoffVisible"
title="客服代交接"
width="560px"
destroy-on-close
>
<el-dialog v-model="platformHandoffVisible" title="客服代交接" width="560px" destroy-on-close>
<div v-if="order" class="dialog-body">
<p>
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
@@ -1395,12 +1420,7 @@ function paymentPaidAt(record: AdminPayment) {
</template>
</el-dialog>
<el-dialog
v-model="adminDisputeVisible"
title="发起订单申诉"
width="620px"
destroy-on-close
>
<el-dialog v-model="adminDisputeVisible" title="发起订单申诉" width="620px" destroy-on-close>
<div v-if="order" class="dialog-body">
<p>
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
@@ -1440,11 +1460,7 @@ function paymentPaidAt(record: AdminPayment) {
</div>
<template #footer>
<el-button @click="adminDisputeVisible = false">取消</el-button>
<el-button
type="primary"
:loading="adminDisputeSubmitting"
@click="submitAdminDispute"
>
<el-button type="primary" :loading="adminDisputeSubmitting" @click="submitAdminDispute">
发起申诉
</el-button>
</template>
@@ -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<ApiResponse<Dispute>>(
`/admin/disputes/${id}/arbitrate`,
@@ -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<string, unknown>
@@ -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
@@ -25,6 +25,22 @@ const checkout = computed(() => props.order.checkout)
title="实际结算租金"
: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
title="押金总额"
:label="
@@ -43,10 +59,7 @@ const checkout = computed(() => props.order.checkout)
:value="`¥${money(amountYuan(checkout.deposit_deduct_amount_cent))}`"
value-class="red-text"
/>
<van-cell
title="哈夫币实际消耗"
:value="`${checkout.coin_consumed_m}M`"
/>
<van-cell title="哈夫币实际消耗" :value="`${checkout.coin_consumed_m}M`" />
<van-cell
v-if="Number(checkout.overshoot_amount_cent || 0) > 0"
title="打超金额(从押金)"
@@ -77,6 +90,13 @@ const checkout = computed(() => props.order.checkout)
:value="`¥${money(amountYuan(checkout.owner_income_amount_cent))}`"
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.owner_adjustment_reason"
@@ -95,6 +115,21 @@ const checkout = computed(() => props.order.checkout)
<span>实际结算租金</span>
<strong>¥{{ money(amountYuan(checkout.display_amount_cent)) }}</strong>
</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">
<span>预收押金</span>
<strong>
@@ -138,6 +173,15 @@ const checkout = computed(() => props.order.checkout)
<span>号主最终收入租金 + 押金赔付</span>
<strong class="amount">¥{{ money(amountYuan(checkout.owner_income_amount_cent)) }}</strong>
</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">
<label>说明</label>
<p>{{ checkout.content }}</p>
@@ -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,
@@ -69,6 +69,7 @@ const {
listingCode,
orderAmountLabel,
orderRentDisplayAmount,
renterDiscountDisplayCent,
ownerIncomeDisplayAmount,
ownerIncomeLabel,
canOpenDispute,
@@ -188,12 +189,9 @@ async function copyListingCode() {
<div class="meta-item">
<span class="meta-label">{{ orderAmountLabel }}</span>
<strong class="meta-value accent">¥{{ money(orderRentDisplayAmount) }}</strong>
<span
v-if="isRenter && Number(order.rent_discount_amount_cent || 0) > 0"
class="meta-note"
>
<span v-if="isRenter && renterDiscountDisplayCent > 0" class="meta-note">
{{ order.renter_growth_level_name || '成长等级' }}优惠 ¥{{
money(amountYuan(order.rent_discount_amount_cent))
money(amountYuan(renterDiscountDisplayCent))
}}
</span>
</div>
@@ -212,6 +210,16 @@ async function copyListingCode() {
<span class="meta-label">交接状态</span>
<strong class="meta-value">{{ orderHandoffStatusLabel(order) }}</strong>
</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>
</section>
@@ -366,8 +374,10 @@ async function copyListingCode() {
>
<h3 class="section-title">结账处理</h3>
<p class="action-hint">
{{ checkoutRoundLabel || '请核对消耗与扣款' }}。确认无误后同意完结;也可修改方案交对方确认(最多
6 轮)。押金不足无法自动完结,请改方案或争议。
{{
checkoutRoundLabel || '请核对消耗与扣款'
}}。确认无误后同意完结;也可修改方案交对方确认(最多 6
轮)。押金不足无法自动完结,请改方案或争议。
</p>
<p v-if="checkoutShortfallCent > 0" class="action-hint" style="color: #ef4444">
押金不足差额 ¥{{ money(amountYuan(checkoutShortfallCent)) }},无法同意完结。
@@ -490,7 +500,9 @@ async function copyListingCode() {
</header>
<div class="popup-body">
<p class="form-hint-info">
{{ checkoutRoundLabel }}。可填实际哈夫币消耗(允许超过发布量),打超按比例从押金扣。提案将交给对方确认。
{{
checkoutRoundLabel
}}。可填实际哈夫币消耗(允许超过发布量),打超按比例从押金扣。提案将交给对方确认。
</p>
<van-cell-group :border="false">
<van-field label="额外消耗品已用">
@@ -68,6 +68,7 @@ const {
listingCode,
orderAmountLabel,
orderRentDisplayAmount,
renterDiscountDisplayCent,
ownerIncomeDisplayAmount,
ownerIncomeLabel,
canOpenDispute,
@@ -388,12 +389,9 @@ watch([() => route.query.focus, order, loading], () => {
<div class="metric-card highlight">
<span class="metric-label">{{ orderAmountLabel }}</span>
<strong class="metric-value amount">¥{{ money(orderRentDisplayAmount) }}</strong>
<span
v-if="isRenter && Number(order.rent_discount_amount_cent || 0) > 0"
class="metric-note"
>
<span v-if="isRenter && renterDiscountDisplayCent > 0" class="metric-note">
{{ order.renter_growth_level_name || '成长等级' }}优惠 ¥{{
money(amountYuan(order.rent_discount_amount_cent))
money(amountYuan(renterDiscountDisplayCent))
}}
</span>
</div>
@@ -410,6 +408,22 @@ watch([() => route.query.focus, order, loading], () => {
>已免押 ¥{{ money(amountYuan(order.deposit_waived_amount_cent)) }}</span
>
</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 v-if="order" class="info-section">