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

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