按实际纯币金额计算租客优惠与积分
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/adminnotification"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
ordermodule "hfb_sys/backend/internal/modules/order"
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
"hfb_sys/backend/internal/modules/wallet"
|
||||
"hfb_sys/backend/pkg/money"
|
||||
@@ -112,7 +113,23 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
return err
|
||||
}
|
||||
if order.Status == "completed" {
|
||||
if err := rentergrowth.AwardOrderCompleted(tx, &order, arbitrationActualRentCent(order, settlement), rentergrowth.SourceOrderCompleted); err != nil {
|
||||
coinConsumedM, linkedCheckout, err := arbitrationCoinConsumedM(tx, row, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pureCoin := ordermodule.CalculateActualPureCoinAmounts(order, coinConsumedM)
|
||||
order.ActualCoinConsumedM = pureCoin.ConsumedM
|
||||
order.ActualPureCoinAmountCent = pureCoin.AmountCent
|
||||
order.ActualPureCoinDiscountCent = pureCoin.DiscountCent
|
||||
if linkedCheckout != nil {
|
||||
linkedCheckout.PureCoinAmountCent = pureCoin.AmountCent
|
||||
linkedCheckout.PureCoinDiscountCent = pureCoin.DiscountCent
|
||||
linkedCheckout.PureCoinPayableCent = pureCoin.PayableCent
|
||||
if err := tx.Save(linkedCheckout).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := rentergrowth.AwardOrderCompleted(tx, &order, pureCoin.AmountCent, rentergrowth.SourceOrderCompleted); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -146,6 +163,8 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
"renter_refund_amount_cent": settlement.RenterRefundAmountCent,
|
||||
"owner_income_amount_cent": settlement.OwnerIncomeAmountCent,
|
||||
"deposit_deduct_amount_cent": settlement.DepositDeductAmountCent,
|
||||
"actual_coin_consumed_m": order.ActualCoinConsumedM,
|
||||
"actual_pure_coin_cent": order.ActualPureCoinAmountCent,
|
||||
"before_order_status": beforeOrderStatus,
|
||||
"after_order_status": order.Status,
|
||||
"before_handoff_status": beforeHandoffStatus,
|
||||
@@ -200,19 +219,22 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func arbitrationActualRentCent(order model.RentalOrder, settlement arbitrationSettlement) int64 {
|
||||
rentRefundCent := settlement.RenterRefundAmountCent - settlement.RenterDepositRefundCent
|
||||
if rentRefundCent < 0 {
|
||||
rentRefundCent = 0
|
||||
func arbitrationCoinConsumedM(tx *gorm.DB, row model.Dispute, req ArbitrateRequest) (float64, *model.OrderCheckout, error) {
|
||||
var checkout *model.OrderCheckout
|
||||
if row.CheckoutID != nil && *row.CheckoutID > 0 {
|
||||
var saved model.OrderCheckout
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&saved, *row.CheckoutID).Error; err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
checkout = &saved
|
||||
}
|
||||
if rentRefundCent > order.RentAmountCent {
|
||||
rentRefundCent = order.RentAmountCent
|
||||
if req.ActualCoinConsumedM != nil {
|
||||
return *req.ActualCoinConsumedM, checkout, nil
|
||||
}
|
||||
actualRentCent := order.RentAmountCent - rentRefundCent
|
||||
if actualRentCent < 0 {
|
||||
return 0
|
||||
if checkout != nil {
|
||||
return checkout.CoinConsumedM, checkout, nil
|
||||
}
|
||||
return actualRentCent
|
||||
return 0, nil, ErrInvalidDispute
|
||||
}
|
||||
|
||||
func isPlatformManagedOrder(order model.RentalOrder) bool {
|
||||
|
||||
@@ -33,6 +33,7 @@ type DisputeDTO struct {
|
||||
PreviousHandoffStatus string `json:"previous_handoff_status"`
|
||||
PreviousSettlementStatus string `json:"previous_settlement_status"`
|
||||
CheckoutID *uint64 `json:"checkout_id"`
|
||||
CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"`
|
||||
PreviousCheckoutStatus string `json:"previous_checkout_status"`
|
||||
ArbitrationResult string `json:"arbitration_result"`
|
||||
ArbitrationRemark string `json:"arbitration_remark"`
|
||||
@@ -56,9 +57,10 @@ type AdminCreateRequest struct {
|
||||
}
|
||||
|
||||
type ArbitrateRequest struct {
|
||||
Result string `json:"result" binding:"required"`
|
||||
Remark string `json:"remark" binding:"required"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
Result string `json:"result" binding:"required"`
|
||||
Remark string `json:"remark" binding:"required"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"`
|
||||
}
|
||||
|
||||
type AdminListQuery struct {
|
||||
|
||||
@@ -10,16 +10,17 @@ import (
|
||||
|
||||
type disputeRow struct {
|
||||
model.Dispute
|
||||
OrderNo string
|
||||
OrderStatus string
|
||||
HandoffStatus string
|
||||
SettlementStatus string
|
||||
ListingNo string
|
||||
Title string
|
||||
OwnerID uint64
|
||||
RenterID uint64
|
||||
OwnerPhone string
|
||||
RenterPhone string
|
||||
OrderNo string
|
||||
OrderStatus string
|
||||
HandoffStatus string
|
||||
SettlementStatus string
|
||||
ListingNo string
|
||||
Title string
|
||||
OwnerID uint64
|
||||
RenterID uint64
|
||||
OwnerPhone string
|
||||
RenterPhone string
|
||||
CheckoutCoinConsumedM *float64
|
||||
}
|
||||
|
||||
func (row disputeRow) toDTO() DisputeDTO {
|
||||
@@ -48,6 +49,7 @@ func (row disputeRow) toDTO() DisputeDTO {
|
||||
PreviousHandoffStatus: row.PreviousHandoffStatus,
|
||||
PreviousSettlementStatus: row.PreviousSettlementStatus,
|
||||
CheckoutID: row.CheckoutID,
|
||||
CheckoutCoinConsumedM: row.CheckoutCoinConsumedM,
|
||||
PreviousCheckoutStatus: row.PreviousCheckoutStatus,
|
||||
ArbitrationResult: row.ArbitrationResult,
|
||||
ArbitrationRemark: row.ArbitrationRemark,
|
||||
|
||||
@@ -92,7 +92,7 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
||||
return r.adminFilterQuery(ctx).
|
||||
Select(`d.*, o.order_no, o.status AS order_status, o.handoff_status, o.settlement_status,
|
||||
o.owner_id, o.renter_id, owner.phone AS owner_phone, renter.phone AS renter_phone,
|
||||
l.listing_no, a.title`)
|
||||
l.listing_no, a.title, c.coin_consumed_m AS checkout_coin_consumed_m`)
|
||||
}
|
||||
|
||||
func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB {
|
||||
@@ -101,7 +101,8 @@ func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB {
|
||||
Joins("JOIN rental_listings AS l ON l.id = o.listing_id").
|
||||
Joins("JOIN game_accounts AS a ON a.id = o.account_id").
|
||||
Joins("JOIN users AS owner ON owner.id = o.owner_id").
|
||||
Joins("JOIN users AS renter ON renter.id = o.renter_id")
|
||||
Joins("JOIN users AS renter ON renter.id = o.renter_id").
|
||||
Joins("LEFT JOIN order_checkouts AS c ON c.id = d.checkout_id")
|
||||
}
|
||||
|
||||
func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package dispute
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -8,9 +10,36 @@ import (
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func disputePureCoinOrder() model.RentalOrder {
|
||||
return model.RentalOrder{
|
||||
Status: "renting",
|
||||
HandoffStatus: "received",
|
||||
SettlementStatus: "unsettled",
|
||||
RentAmountCent: 38037,
|
||||
OwnerRentAmountCent: 35300,
|
||||
DepositAmountCent: 15000,
|
||||
RentOriginalAmountCent: 38300,
|
||||
RentDiscountAmountCent: 263,
|
||||
PureCoinOriginalAmountCent: 26300,
|
||||
ExtraItemOriginalAmountCent: 12000,
|
||||
RenterDiscountBps: 9900,
|
||||
AccountSnapshot: datatypes.JSON([]byte(`{
|
||||
"haf_coin_amount": 100000000,
|
||||
"asset_summary": {
|
||||
"price_breakdown": {
|
||||
"buyer_coin_base_price": 263,
|
||||
"seller_coin_base_price": 233,
|
||||
"consumable_price": 120
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
}
|
||||
}
|
||||
|
||||
func setupDisputeTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := database.NewTestDB()
|
||||
@@ -261,10 +290,12 @@ func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
|
||||
if adminNotificationCount == 0 {
|
||||
t.Fatal("platform managed dispute should notify managed admin")
|
||||
}
|
||||
actualCoinConsumedM := 0.0
|
||||
|
||||
if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{
|
||||
Result: "release_deposit",
|
||||
Remark: "确认租金给卖家,线下结算",
|
||||
Result: "release_deposit",
|
||||
Remark: "确认租金给卖家,线下结算",
|
||||
ActualCoinConsumedM: &actualCoinConsumedM,
|
||||
}, AuditMeta{}); err != nil {
|
||||
t.Fatalf("仲裁失败: %v", err)
|
||||
}
|
||||
@@ -291,6 +322,90 @@ func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) {
|
||||
db := setupDisputeTestDB(t)
|
||||
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
|
||||
return "refunded", nil
|
||||
})})
|
||||
_, renter, order := createDisputeOrderFixture(t, db, disputePureCoinOrder())
|
||||
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
|
||||
Type: "haf_coin_dispute",
|
||||
Description: "核对最终哈夫币消耗",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建申诉失败: %v", err)
|
||||
}
|
||||
|
||||
_, err = repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
|
||||
Result: "release_deposit",
|
||||
Remark: "确认完成订单",
|
||||
}, AuditMeta{})
|
||||
if !errors.Is(err, ErrInvalidDispute) {
|
||||
t.Fatalf("缺少实际消耗 M 的仲裁错误 = %v, want ErrInvalidDispute", err)
|
||||
}
|
||||
|
||||
actualCoinConsumedM := 50.0
|
||||
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
|
||||
Result: "release_deposit",
|
||||
Remark: "确认实际消耗 50M",
|
||||
ActualCoinConsumedM: &actualCoinConsumedM,
|
||||
}, AuditMeta{}); err != nil {
|
||||
t.Fatalf("带实际消耗 M 仲裁失败: %v", err)
|
||||
}
|
||||
var saved model.RentalOrder
|
||||
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||
t.Fatalf("读取订单失败: %v", err)
|
||||
}
|
||||
if saved.ActualCoinConsumedM != 50 || saved.ActualPureCoinAmountCent != 13150 || saved.GrowthPointsAwarded != 131 {
|
||||
t.Fatalf("最终 M/纯币/积分 = %.2f/%d/%d, want 50/13150/131", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.GrowthPointsAwarded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
|
||||
db := setupDisputeTestDB(t)
|
||||
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
|
||||
return "refunded", nil
|
||||
})})
|
||||
inputOrder := disputePureCoinOrder()
|
||||
inputOrder.Status = "pending_checkout_confirm"
|
||||
inputOrder.HandoffStatus = "pending_owner_checkout"
|
||||
inputOrder.SettlementStatus = "pending"
|
||||
_, renter, order := createDisputeOrderFixture(t, db, inputOrder)
|
||||
checkout := model.OrderCheckout{
|
||||
OrderID: order.ID,
|
||||
InitiatedBy: renter.ID,
|
||||
Status: "submitted",
|
||||
CoinConsumedM: 25,
|
||||
}
|
||||
if err := db.Create(&checkout).Error; err != nil {
|
||||
t.Fatalf("创建结账记录失败: %v", err)
|
||||
}
|
||||
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
|
||||
Type: "checkout_amount",
|
||||
Description: "结账金额有争议",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建结账争议失败: %v", err)
|
||||
}
|
||||
if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 {
|
||||
t.Fatalf("关联结账/M = %v/%v, want %d/25", created.CheckoutID, created.CheckoutCoinConsumedM, checkout.ID)
|
||||
}
|
||||
|
||||
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
|
||||
Result: "release_deposit",
|
||||
Remark: "沿用双方结账消耗",
|
||||
}, AuditMeta{}); err != nil {
|
||||
t.Fatalf("仲裁复用结账 M 失败: %v", err)
|
||||
}
|
||||
var saved model.RentalOrder
|
||||
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||
t.Fatalf("读取订单失败: %v", err)
|
||||
}
|
||||
if saved.ActualCoinConsumedM != 25 || saved.ActualPureCoinAmountCent != 6575 || saved.GrowthPointsAwarded != 65 {
|
||||
t.Fatalf("最终 M/纯币/积分 = %.2f/%d/%d, want 25/6575/65", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.GrowthPointsAwarded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArbitrationSettlementSkipsFrozenReleaseWhenNoFrozenBalance(t *testing.T) {
|
||||
order := model.RentalOrder{
|
||||
ID: 11,
|
||||
|
||||
@@ -90,5 +90,8 @@ func (s *Service) Arbitrate(ctx context.Context, adminID uint64, id uint64, req
|
||||
if id == 0 || req.Result == "" || req.Remark == "" {
|
||||
return nil, ErrInvalidDispute
|
||||
}
|
||||
if req.ActualCoinConsumedM != nil && (*req.ActualCoinConsumedM < 0 || *req.ActualCoinConsumedM > 1_000_000_000) {
|
||||
return nil, ErrInvalidDispute
|
||||
}
|
||||
return s.repo.Arbitrate(ctx, adminID, id, req, meta)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user