按实际纯币金额计算租客优惠与积分
This commit is contained in:
@@ -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"`
|
||||||
|
|||||||
@@ -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:"-"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user