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

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