优化仲裁

This commit is contained in:
yml2213
2026-07-29 18:59:07 +08:00
parent c9d0803b53
commit 8c2431c4f2
15 changed files with 387 additions and 142 deletions
+1
View File
@@ -32,6 +32,7 @@ type RentalOrder struct {
ActualCoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"actual_coin_consumed_m"` ActualCoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"actual_coin_consumed_m"`
ActualPureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"` ActualPureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"`
ActualPureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"` ActualPureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"`
ActualExtraItemAmountCent 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"`
+168 -48
View File
@@ -53,7 +53,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
if err != nil { if err != nil {
return err return err
} }
settlement, err := buildArbitrationSettlement(order, req, frozenBalance) settlement, actualSettlement, linkedCheckout, depositDeductAmountCent, err := buildArbitrationDecision(tx, row, order, req, frozenBalance)
if err != nil { if err != nil {
return err return err
} }
@@ -114,23 +114,16 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
return err return err
} }
if order.Status == "completed" { if order.Status == "completed" {
coinConsumedM, linkedCheckout, err := arbitrationCoinConsumedM(tx, row, req) if actualSettlement == nil {
if err != nil { return ErrInvalidDispute
return err
} }
pureCoin := ordermodule.CalculateActualPureCoinAmounts(order, coinConsumedM) applyArbitrationActualSettlement(&order, linkedCheckout, *actualSettlement, depositDeductAmountCent)
order.ActualCoinConsumedM = pureCoin.ConsumedM
order.ActualPureCoinAmountCent = pureCoin.AmountCent
order.ActualPureCoinDiscountCent = pureCoin.DiscountCent
if linkedCheckout != nil { if linkedCheckout != nil {
linkedCheckout.PureCoinAmountCent = pureCoin.AmountCent
linkedCheckout.PureCoinDiscountCent = pureCoin.DiscountCent
linkedCheckout.PureCoinPayableCent = pureCoin.PayableCent
if err := tx.Save(linkedCheckout).Error; err != nil { if err := tx.Save(linkedCheckout).Error; err != nil {
return err return err
} }
} }
if err := rentergrowth.AwardOrderCompleted(tx, &order, pureCoin.AmountCent, rentergrowth.SourceOrderCompleted); err != nil { if err := rentergrowth.AwardOrderCompleted(tx, &order, actualSettlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil {
return err return err
} }
} }
@@ -166,6 +159,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
"deposit_deduct_amount_cent": settlement.DepositDeductAmountCent, "deposit_deduct_amount_cent": settlement.DepositDeductAmountCent,
"actual_coin_consumed_m": order.ActualCoinConsumedM, "actual_coin_consumed_m": order.ActualCoinConsumedM,
"actual_pure_coin_cent": order.ActualPureCoinAmountCent, "actual_pure_coin_cent": order.ActualPureCoinAmountCent,
"actual_extra_item_cent": order.ActualExtraItemAmountCent,
"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,
@@ -220,22 +214,131 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
return &dto, nil return &dto, nil
} }
func arbitrationCoinConsumedM(tx *gorm.DB, row model.Dispute, req ArbitrateRequest) (float64, *model.OrderCheckout, error) { func buildArbitrationDecision(tx *gorm.DB, row model.Dispute, order model.RentalOrder, req ArbitrateRequest, renterFrozenBalanceCent int64) (arbitrationSettlement, *ordermodule.ActualCheckoutSettlement, *model.OrderCheckout, int64, error) {
var checkout *model.OrderCheckout if arbitrationUsesActualCheckout(req.Result) {
if row.CheckoutID != nil && *row.CheckoutID > 0 { return buildActualCheckoutArbitrationSettlement(tx, row, order, req, renterFrozenBalanceCent)
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
} }
settlement, err := buildArbitrationSettlement(order, req, renterFrozenBalanceCent)
return settlement, nil, nil, 0, err
}
func arbitrationUsesActualCheckout(result string) bool {
switch result {
case "release_deposit", "deduct_deposit", "compensate_owner":
return true
default:
return false
}
}
func buildActualCheckoutArbitrationSettlement(tx *gorm.DB, row model.Dispute, order model.RentalOrder, req ArbitrateRequest, renterFrozenBalanceCent int64) (arbitrationSettlement, *ordermodule.ActualCheckoutSettlement, *model.OrderCheckout, int64, error) {
settlement := newArbitrationSettlement(order, renterFrozenBalanceCent)
linkedCheckout, err := lockArbitrationCheckout(tx, row)
if err != nil {
return settlement, nil, nil, 0, err
}
coinConsumedM, err := arbitrationCoinConsumedM(linkedCheckout, req)
if err != nil {
return settlement, nil, linkedCheckout, 0, err
}
consumableAmountCent, err := arbitrationConsumableAmountCent(order, linkedCheckout, req)
if err != nil {
return settlement, nil, linkedCheckout, 0, err
}
depositDeductAmountCent, err := arbitrationDepositDeductAmountCent(order, req)
if err != nil {
return settlement, nil, linkedCheckout, 0, err
}
actualSettlement := ordermodule.CalculateActualCheckoutSettlement(order, consumableAmountCent, coinConsumedM, depositDeductAmountCent)
if actualSettlement.ShortfallCent > 0 {
return settlement, nil, linkedCheckout, 0, ErrInvalidDispute
}
if actualSettlement.OwnerIncomeCent > 0 {
settlement.addOwnerIncome(order, actualSettlement.OwnerIncomeCent, "仲裁按实际消耗结算给号主")
}
if actualSettlement.RenterRefundCent > 0 {
settlement.RenterRefundAmountCent = actualSettlement.RenterRefundCent
settlement.RenterDepositRefundCent = actualSettlement.DepositRefundCent
}
settlement.DepositDeductAmountCent = depositDeductAmountCent + actualSettlement.OvershootAmountCent
settlement.ActualExtraItemAmountCent = actualSettlement.ExtraItemAmountCent
return settlement, &actualSettlement, linkedCheckout, depositDeductAmountCent, nil
}
func lockArbitrationCheckout(tx *gorm.DB, row model.Dispute) (*model.OrderCheckout, error) {
if row.CheckoutID == nil || *row.CheckoutID == 0 {
return nil, nil
}
var checkout model.OrderCheckout
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&checkout, *row.CheckoutID).Error; err != nil {
return nil, err
}
return &checkout, nil
}
func arbitrationCoinConsumedM(checkout *model.OrderCheckout, req ArbitrateRequest) (float64, error) {
if req.ActualCoinConsumedM != nil { if req.ActualCoinConsumedM != nil {
return *req.ActualCoinConsumedM, checkout, nil return *req.ActualCoinConsumedM, nil
} }
if checkout != nil { if checkout != nil {
return checkout.CoinConsumedM, checkout, nil return checkout.CoinConsumedM, nil
} }
return 0, nil, ErrInvalidDispute return 0, ErrInvalidDispute
}
func arbitrationConsumableAmountCent(order model.RentalOrder, checkout *model.OrderCheckout, req ArbitrateRequest) (int64, error) {
if req.ActualConsumableAmountCent != nil {
return *req.ActualConsumableAmountCent, nil
}
if checkout != nil {
return checkout.ConsumableAmountCent, nil
}
if order.ExtraItemOriginalAmountCent <= 0 {
return 0, nil
}
return 0, ErrInvalidDispute
}
func arbitrationDepositDeductAmountCent(order model.RentalOrder, req ArbitrateRequest) (int64, error) {
switch req.Result {
case "release_deposit":
return 0, nil
case "deduct_deposit", "compensate_owner":
deductAmountCent := req.AmountCent
if deductAmountCent <= 0 {
deductAmountCent = order.DepositAmountCent
}
if deductAmountCent > order.DepositAmountCent {
return 0, ErrInvalidDispute
}
return deductAmountCent, nil
default:
return 0, ErrInvalidDispute
}
}
func applyArbitrationActualSettlement(order *model.RentalOrder, checkout *model.OrderCheckout, settlement ordermodule.ActualCheckoutSettlement, depositDeductAmountCent int64) {
order.ActualCoinConsumedM = settlement.CoinConsumedM
order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent
order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent
order.ActualExtraItemAmountCent = settlement.ExtraItemAmountCent
if checkout == nil {
return
}
checkout.RentAmountCent = settlement.ActualRentAmountCent
checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent
checkout.PlatformFeeCent = settlement.PlatformFeeCent
checkout.PureCoinAmountCent = settlement.PureCoinAmountCent
checkout.PureCoinDiscountCent = settlement.PureCoinDiscountCent
checkout.PureCoinPayableCent = settlement.PureCoinPayableCent
checkout.ConsumableAmountCent = settlement.ExtraItemAmountCent
checkout.CoinConsumedM = settlement.CoinConsumedM
checkout.DepositDeductAmountCent = depositDeductAmountCent
checkout.OtherAmountCent = depositDeductAmountCent
checkout.RenterRefundAmountCent = settlement.RenterRefundCent
checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent
checkout.ShortfallCent = settlement.ShortfallCent
checkout.OvershootAmountCent = settlement.OvershootAmountCent
} }
func isPlatformManagedOrder(order model.RentalOrder) bool { func isPlatformManagedOrder(order model.RentalOrder) bool {
@@ -275,24 +378,18 @@ func applyPlatformManagedSettlement(order *model.RentalOrder, settlement *arbitr
} }
type arbitrationSettlement struct { type arbitrationSettlement struct {
Entries []wallet.Entry Entries []wallet.Entry
RenterRefundAmountCent int64 RenterRefundAmountCent int64
RenterDepositRefundCent int64 RenterDepositRefundCent int64
OwnerIncomeAmountCent int64 OwnerIncomeAmountCent int64
DepositDeductAmountCent int64 DepositDeductAmountCent int64
ActualExtraItemAmountCent int64
} }
func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, renterFrozenBalanceCent int64) (arbitrationSettlement, error) { func newArbitrationSettlement(order model.RentalOrder, renterFrozenBalanceCent int64) arbitrationSettlement {
rentAmountCent := order.RentAmountCent
depositAmountCent := order.DepositAmountCent
ownerRentAmountCent := order.OwnerRentAmountCent
if ownerRentAmountCent <= 0 || ownerRentAmountCent > rentAmountCent {
ownerRentAmountCent = rentAmountCent
}
totalCent := rentAmountCent + depositAmountCent
settlement := arbitrationSettlement{} settlement := arbitrationSettlement{}
orderID := order.ID orderID := order.ID
releaseFrozenAmountCent := money.MinCent(totalCent, renterFrozenBalanceCent) releaseFrozenAmountCent := money.MinCent(order.RentAmountCent+order.DepositAmountCent, renterFrozenBalanceCent)
if releaseFrozenAmountCent > 0 { if releaseFrozenAmountCent > 0 {
settlement.Entries = append(settlement.Entries, wallet.Entry{ settlement.Entries = append(settlement.Entries, wallet.Entry{
UserID: order.RenterID, UserID: order.RenterID,
@@ -305,6 +402,36 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
Remark: "仲裁释放冻结金额", Remark: "仲裁释放冻结金额",
}) })
} }
return settlement
}
func (settlement *arbitrationSettlement) addOwnerIncome(order model.RentalOrder, amountCent int64, remark string) {
if settlement == nil || amountCent <= 0 {
return
}
orderID := order.ID
settlement.OwnerIncomeAmountCent += amountCent
settlement.Entries = append(settlement.Entries, wallet.Entry{
UserID: order.OwnerID,
OrderID: &orderID,
Direction: "in",
AmountCent: amountCent,
BalanceType: "available",
BizType: "arbitration_owner_income",
BizNo: order.OrderNo,
Remark: remark,
})
}
func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, renterFrozenBalanceCent int64) (arbitrationSettlement, error) {
rentAmountCent := order.RentAmountCent
depositAmountCent := order.DepositAmountCent
ownerRentAmountCent := order.OwnerRentAmountCent
if ownerRentAmountCent <= 0 || ownerRentAmountCent > rentAmountCent {
ownerRentAmountCent = rentAmountCent
}
totalCent := rentAmountCent + depositAmountCent
settlement := newArbitrationSettlement(order, renterFrozenBalanceCent)
addRenterRefund := func(amountCent int64, remark string) { addRenterRefund := func(amountCent int64, remark string) {
if amountCent <= 0 { if amountCent <= 0 {
@@ -316,17 +443,7 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
if amountCent <= 0 { if amountCent <= 0 {
return return
} }
settlement.OwnerIncomeAmountCent += amountCent settlement.addOwnerIncome(order, amountCent, remark)
settlement.Entries = append(settlement.Entries, wallet.Entry{
UserID: order.OwnerID,
OrderID: &orderID,
Direction: "in",
AmountCent: amountCent,
BalanceType: "available",
BizType: "arbitration_owner_income",
BizNo: order.OrderNo,
Remark: remark,
})
} }
switch req.Result { switch req.Result {
@@ -503,6 +620,9 @@ func buildArbitrationHandoffContent(req ArbitrateRequest, settlement arbitration
if settlement.DepositDeductAmountCent > 0 { if settlement.DepositDeductAmountCent > 0 {
content += "\n押金赔付扣除:" + money.FormatWithSymbol(settlement.DepositDeductAmountCent) content += "\n押金赔付扣除:" + money.FormatWithSymbol(settlement.DepositDeductAmountCent)
} }
if settlement.ActualExtraItemAmountCent > 0 {
content += "\n额外物品消耗:" + money.FormatWithSymbol(settlement.ActualExtraItemAmountCent)
}
return content return content
} }
+39 -36
View File
@@ -9,38 +9,40 @@ import (
) )
type DisputeDTO struct { type DisputeDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"` OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"` OrderNo string `json:"order_no"`
OrderStatus string `json:"order_status"` OrderStatus string `json:"order_status"`
HandoffStatus string `json:"handoff_status"` HandoffStatus string `json:"handoff_status"`
SettlementStatus string `json:"settlement_status"` SettlementStatus string `json:"settlement_status"`
ListingNo string `json:"listing_no"` ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"`
Title string `json:"title"` ListingNo string `json:"listing_no"`
OwnerID uint64 `json:"owner_id"` Title string `json:"title"`
RenterID uint64 `json:"renter_id"` OwnerID uint64 `json:"owner_id"`
OwnerPhone string `json:"owner_phone"` RenterID uint64 `json:"renter_id"`
RenterPhone string `json:"renter_phone"` OwnerPhone string `json:"owner_phone"`
InitiatorID uint64 `json:"initiator_id"` RenterPhone string `json:"renter_phone"`
InitiatorType string `json:"initiator_type"` InitiatorID uint64 `json:"initiator_id"`
InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"` InitiatorType string `json:"initiator_type"`
TargetUserID uint64 `json:"target_user_id"` InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"`
Type string `json:"type"` TargetUserID uint64 `json:"target_user_id"`
Status string `json:"status"` Type string `json:"type"`
Description string `json:"description"` Status string `json:"status"`
EvidenceURLS datatypes.JSON `json:"evidence_urls"` Description string `json:"description"`
PreviousOrderStatus string `json:"previous_order_status"` EvidenceURLS datatypes.JSON `json:"evidence_urls"`
PreviousHandoffStatus string `json:"previous_handoff_status"` PreviousOrderStatus string `json:"previous_order_status"`
PreviousSettlementStatus string `json:"previous_settlement_status"` PreviousHandoffStatus string `json:"previous_handoff_status"`
CheckoutID *uint64 `json:"checkout_id"` PreviousSettlementStatus string `json:"previous_settlement_status"`
CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"` CheckoutID *uint64 `json:"checkout_id"`
PreviousCheckoutStatus string `json:"previous_checkout_status"` CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"`
ArbitrationResult string `json:"arbitration_result"` CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"`
ArbitrationRemark string `json:"arbitration_remark"` PreviousCheckoutStatus string `json:"previous_checkout_status"`
HandledBy *uint64 `json:"handled_by"` ArbitrationResult string `json:"arbitration_result"`
HandledAt *time.Time `json:"handled_at"` ArbitrationRemark string `json:"arbitration_remark"`
CreatedAt time.Time `json:"created_at"` HandledBy *uint64 `json:"handled_by"`
UpdatedAt time.Time `json:"updated_at"` HandledAt *time.Time `json:"handled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
type CreateRequest struct { type CreateRequest struct {
@@ -57,10 +59,11 @@ 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"` ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"`
ActualConsumableAmountCent *int64 `json:"actual_consumable_amount_cent"`
} }
type AdminListQuery struct { type AdminListQuery struct {
+47 -43
View File
@@ -10,53 +10,57 @@ 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 ExtraItemOriginalAmountCent int64
Title string ListingNo string
OwnerID uint64 Title string
RenterID uint64 OwnerID uint64
OwnerPhone string RenterID uint64
RenterPhone string OwnerPhone string
CheckoutCoinConsumedM *float64 RenterPhone string
CheckoutCoinConsumedM *float64
CheckoutConsumableAmountCent *int64
} }
func (row disputeRow) toDTO() DisputeDTO { func (row disputeRow) toDTO() DisputeDTO {
return DisputeDTO{ return DisputeDTO{
ID: row.ID, ID: row.ID,
OrderID: row.OrderID, OrderID: row.OrderID,
OrderNo: row.OrderNo, OrderNo: row.OrderNo,
OrderStatus: row.OrderStatus, OrderStatus: row.OrderStatus,
HandoffStatus: row.HandoffStatus, HandoffStatus: row.HandoffStatus,
SettlementStatus: row.SettlementStatus, SettlementStatus: row.SettlementStatus,
ListingNo: row.ListingNo, ExtraItemOriginalAmountCent: row.ExtraItemOriginalAmountCent,
Title: row.Title, ListingNo: row.ListingNo,
OwnerID: row.OwnerID, Title: row.Title,
RenterID: row.RenterID, OwnerID: row.OwnerID,
OwnerPhone: row.OwnerPhone, RenterID: row.RenterID,
RenterPhone: row.RenterPhone, OwnerPhone: row.OwnerPhone,
InitiatorID: row.InitiatorID, RenterPhone: row.RenterPhone,
InitiatorType: effectiveInitiatorType(row.Dispute), InitiatorID: row.InitiatorID,
InitiatorAdminID: row.InitiatorAdminID, InitiatorType: effectiveInitiatorType(row.Dispute),
TargetUserID: row.TargetUserID, InitiatorAdminID: row.InitiatorAdminID,
Type: row.Type, TargetUserID: row.TargetUserID,
Status: row.Status, Type: row.Type,
Description: row.Description, Status: row.Status,
EvidenceURLS: row.EvidenceURLS, Description: row.Description,
PreviousOrderStatus: row.PreviousOrderStatus, EvidenceURLS: row.EvidenceURLS,
PreviousHandoffStatus: row.PreviousHandoffStatus, PreviousOrderStatus: row.PreviousOrderStatus,
PreviousSettlementStatus: row.PreviousSettlementStatus, PreviousHandoffStatus: row.PreviousHandoffStatus,
CheckoutID: row.CheckoutID, PreviousSettlementStatus: row.PreviousSettlementStatus,
CheckoutCoinConsumedM: row.CheckoutCoinConsumedM, CheckoutID: row.CheckoutID,
PreviousCheckoutStatus: row.PreviousCheckoutStatus, CheckoutCoinConsumedM: row.CheckoutCoinConsumedM,
ArbitrationResult: row.ArbitrationResult, CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent,
ArbitrationRemark: row.ArbitrationRemark, PreviousCheckoutStatus: row.PreviousCheckoutStatus,
HandledBy: row.HandledBy, ArbitrationResult: row.ArbitrationResult,
HandledAt: row.HandledAt, ArbitrationRemark: row.ArbitrationRemark,
CreatedAt: row.CreatedAt, HandledBy: row.HandledBy,
UpdatedAt: row.UpdatedAt, HandledAt: row.HandledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
} }
} }
+4 -2
View File
@@ -91,8 +91,10 @@ func applyUserVisibleFilter(db *gorm.DB, userID uint64) *gorm.DB {
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { 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, o.extra_item_original_amount_cent,
l.listing_no, a.title, c.coin_consumed_m AS checkout_coin_consumed_m`) owner.phone AS owner_phone, renter.phone AS renter_phone,
l.listing_no, a.title, c.coin_consumed_m AS checkout_coin_consumed_m,
c.consumable_amount_cent AS checkout_consumable_amount_cent`)
} }
func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB { func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB {
@@ -345,10 +345,21 @@ func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) {
} }
actualCoinConsumedM := 50.0 actualCoinConsumedM := 50.0
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{ actualConsumableAmountCent := int64(12000)
_, err = repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
Result: "release_deposit", Result: "release_deposit",
Remark: "确认实际消耗 50M", Remark: "确认实际消耗 50M",
ActualCoinConsumedM: &actualCoinConsumedM, ActualCoinConsumedM: &actualCoinConsumedM,
}, AuditMeta{})
if !errors.Is(err, ErrInvalidDispute) {
t.Fatalf("缺少实际额外物品金额的仲裁错误 = %v, want ErrInvalidDispute", err)
}
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
Result: "release_deposit",
Remark: "确认实际消耗 50M 和额外物品",
ActualCoinConsumedM: &actualCoinConsumedM,
ActualConsumableAmountCent: &actualConsumableAmountCent,
}, AuditMeta{}); err != nil { }, AuditMeta{}); err != nil {
t.Fatalf("带实际消耗 M 仲裁失败: %v", err) t.Fatalf("带实际消耗 M 仲裁失败: %v", err)
} }
@@ -356,8 +367,8 @@ func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) {
if err := db.First(&saved, order.ID).Error; err != nil { if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err) t.Fatalf("读取订单失败: %v", err)
} }
if saved.ActualCoinConsumedM != 50 || saved.ActualPureCoinAmountCent != 13150 || saved.GrowthPointsAwarded != 131 { if saved.ActualCoinConsumedM != 50 || saved.ActualPureCoinAmountCent != 13150 || saved.ActualExtraItemAmountCent != 12000 || saved.GrowthPointsAwarded != 131 {
t.Fatalf("最终 M/纯币/积分 = %.2f/%d/%d, want 50/13150/131", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.GrowthPointsAwarded) t.Fatalf("最终 M/纯币/额外物品/积分 = %.2f/%d/%d/%d, want 50/13150/12000/131", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.ActualExtraItemAmountCent, saved.GrowthPointsAwarded)
} }
} }
@@ -372,10 +383,11 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
inputOrder.SettlementStatus = "pending" inputOrder.SettlementStatus = "pending"
_, renter, order := createDisputeOrderFixture(t, db, inputOrder) _, renter, order := createDisputeOrderFixture(t, db, inputOrder)
checkout := model.OrderCheckout{ checkout := model.OrderCheckout{
OrderID: order.ID, OrderID: order.ID,
InitiatedBy: renter.ID, InitiatedBy: renter.ID,
Status: "submitted", Status: "submitted",
CoinConsumedM: 25, ConsumableAmountCent: 7000,
CoinConsumedM: 25,
} }
if err := db.Create(&checkout).Error; err != nil { if err := db.Create(&checkout).Error; err != nil {
t.Fatalf("创建结账记录失败: %v", err) t.Fatalf("创建结账记录失败: %v", err)
@@ -387,8 +399,8 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("创建结账争议失败: %v", err) t.Fatalf("创建结账争议失败: %v", err)
} }
if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 { if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 || created.CheckoutConsumableAmountCent == nil || *created.CheckoutConsumableAmountCent != 7000 {
t.Fatalf("关联结账/M = %v/%v, want %d/25", created.CheckoutID, created.CheckoutCoinConsumedM, checkout.ID) t.Fatalf("关联结账/M/额外物品 = %v/%v/%v, want %d/25/7000", created.CheckoutID, created.CheckoutCoinConsumedM, created.CheckoutConsumableAmountCent, checkout.ID)
} }
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{ if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
@@ -401,8 +413,15 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
if err := db.First(&saved, order.ID).Error; err != nil { if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err) t.Fatalf("读取订单失败: %v", err)
} }
if saved.ActualCoinConsumedM != 25 || saved.ActualPureCoinAmountCent != 6575 || saved.GrowthPointsAwarded != 65 { if saved.ActualCoinConsumedM != 25 || saved.ActualPureCoinAmountCent != 6575 || saved.ActualExtraItemAmountCent != 7000 || saved.GrowthPointsAwarded != 65 {
t.Fatalf("最终 M/纯币/积分 = %.2f/%d/%d, want 25/6575/65", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.GrowthPointsAwarded) t.Fatalf("最终 M/纯币/额外物品/积分 = %.2f/%d/%d/%d, want 25/6575/7000/65", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.ActualExtraItemAmountCent, saved.GrowthPointsAwarded)
}
var savedCheckout model.OrderCheckout
if err := db.First(&savedCheckout, checkout.ID).Error; err != nil {
t.Fatalf("读取结账记录失败: %v", err)
}
if savedCheckout.ConsumableAmountCent != 7000 || savedCheckout.RentAmountCent != 13509 || savedCheckout.RenterRefundAmountCent != 39528 {
t.Fatalf("仲裁后结账消耗品/租金/退款 = %d/%d/%d, want 7000/13509/39528", savedCheckout.ConsumableAmountCent, savedCheckout.RentAmountCent, savedCheckout.RenterRefundAmountCent)
} }
} }
@@ -93,5 +93,8 @@ func (s *Service) Arbitrate(ctx context.Context, adminID uint64, id uint64, req
if req.ActualCoinConsumedM != nil && (*req.ActualCoinConsumedM < 0 || *req.ActualCoinConsumedM > 1_000_000_000) { if req.ActualCoinConsumedM != nil && (*req.ActualCoinConsumedM < 0 || *req.ActualCoinConsumedM > 1_000_000_000) {
return nil, ErrInvalidDispute return nil, ErrInvalidDispute
} }
if req.ActualConsumableAmountCent != nil && (*req.ActualConsumableAmountCent < 0 || *req.ActualConsumableAmountCent > 100_000_000_000) {
return nil, ErrInvalidDispute
}
return s.repo.Arbitrate(ctx, adminID, id, req, meta) return s.repo.Arbitrate(ctx, adminID, id, req, meta)
} }
@@ -43,6 +43,7 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
order.ActualCoinConsumedM = settlement.CoinConsumedM order.ActualCoinConsumedM = settlement.CoinConsumedM
order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent
order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent
order.ActualExtraItemAmountCent = settlement.ExtraItemAmountCent
if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil { if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil {
return nil, err return nil, err
} }
+1
View File
@@ -42,6 +42,7 @@ type OrderDTO struct {
ActualCoinConsumedM float64 `json:"actual_coin_consumed_m,omitempty"` ActualCoinConsumedM float64 `json:"actual_coin_consumed_m,omitempty"`
ActualPureCoinAmountCent int64 `json:"actual_pure_coin_amount_cent,omitempty"` ActualPureCoinAmountCent int64 `json:"actual_pure_coin_amount_cent,omitempty"`
ActualPureCoinDiscountCent int64 `json:"actual_pure_coin_discount_cent,omitempty"` ActualPureCoinDiscountCent int64 `json:"actual_pure_coin_discount_cent,omitempty"`
ActualExtraItemAmountCent int64 `json:"actual_extra_item_amount_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"`
@@ -92,6 +92,7 @@ func (row orderRow) toAdminDTO() OrderDTO {
ActualCoinConsumedM: row.ActualCoinConsumedM, ActualCoinConsumedM: row.ActualCoinConsumedM,
ActualPureCoinAmountCent: row.ActualPureCoinAmountCent, ActualPureCoinAmountCent: row.ActualPureCoinAmountCent,
ActualPureCoinDiscountCent: row.ActualPureCoinDiscountCent, ActualPureCoinDiscountCent: row.ActualPureCoinDiscountCent,
ActualExtraItemAmountCent: row.ActualExtraItemAmountCent,
RenterGrowthLevel: effectiveRenterGrowthLevel(row.RentalOrder), RenterGrowthLevel: effectiveRenterGrowthLevel(row.RentalOrder),
RenterGrowthLevelName: effectiveRenterGrowthLevelName(row.RentalOrder), RenterGrowthLevelName: effectiveRenterGrowthLevelName(row.RentalOrder),
RenterDiscountBps: effectiveRenterDiscountBps(row.RentalOrder), RenterDiscountBps: effectiveRenterDiscountBps(row.RentalOrder),
@@ -330,6 +331,7 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
dto.ActualCoinConsumedM = 0 dto.ActualCoinConsumedM = 0
dto.ActualPureCoinAmountCent = 0 dto.ActualPureCoinAmountCent = 0
dto.ActualPureCoinDiscountCent = 0 dto.ActualPureCoinDiscountCent = 0
dto.ActualExtraItemAmountCent = 0
dto.RenterGrowthLevel = "" dto.RenterGrowthLevel = ""
dto.RenterGrowthLevelName = "" dto.RenterGrowthLevelName = ""
dto.RenterDiscountBps = 0 dto.RenterDiscountBps = 0
@@ -362,6 +364,7 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
dto.ActualCoinConsumedM = 0 dto.ActualCoinConsumedM = 0
dto.ActualPureCoinAmountCent = 0 dto.ActualPureCoinAmountCent = 0
dto.ActualPureCoinDiscountCent = 0 dto.ActualPureCoinDiscountCent = 0
dto.ActualExtraItemAmountCent = 0
dto.RenterGrowthLevel = "" dto.RenterGrowthLevel = ""
dto.RenterGrowthLevelName = "" dto.RenterGrowthLevelName = ""
dto.RenterDiscountBps = 0 dto.RenterDiscountBps = 0
+45 -1
View File
@@ -50,9 +50,28 @@ type ActualPureCoinAmounts struct {
ConsumedM float64 ConsumedM float64
} }
// ActualCheckoutSettlement 是正常结账与仲裁共用的实际消耗结算结果。
type ActualCheckoutSettlement struct {
OwnerRentIncomeCent int64
DepositCompensationCent int64
OwnerIncomeCent int64
RentRefundCent int64
DepositRefundCent int64
RenterRefundCent int64
PlatformFeeCent int64
ActualRentAmountCent int64
PureCoinAmountCent int64
PureCoinDiscountCent int64
PureCoinPayableCent int64
ExtraItemAmountCent int64
CoinConsumedM float64
OvershootAmountCent int64
ShortfallCent int64
}
// CalculateActualPureCoinAmounts 按订单价格快照和实际消耗 M 计算纯币原价及等级优惠。 // CalculateActualPureCoinAmounts 按订单价格快照和实际消耗 M 计算纯币原价及等级优惠。
func CalculateActualPureCoinAmounts(order model.RentalOrder, coinConsumedM float64) ActualPureCoinAmounts { func CalculateActualPureCoinAmounts(order model.RentalOrder, coinConsumedM float64) ActualPureCoinAmounts {
settlement := calculateCheckoutSettlement(order, 0, coinConsumedM, 0) settlement := CalculateActualCheckoutSettlement(order, 0, coinConsumedM, 0)
return ActualPureCoinAmounts{ return ActualPureCoinAmounts{
AmountCent: settlement.PureCoinAmountCent, AmountCent: settlement.PureCoinAmountCent,
DiscountCent: settlement.PureCoinDiscountCent, DiscountCent: settlement.PureCoinDiscountCent,
@@ -61,6 +80,31 @@ func CalculateActualPureCoinAmounts(order model.RentalOrder, coinConsumedM float
} }
} }
// CalculateActualCheckoutSettlement 按实际纯币和额外物品消耗计算完整结算。
func CalculateActualCheckoutSettlement(order model.RentalOrder, consumableAmountCent int64, coinConsumedM float64, depositDeductAmountCent int64) ActualCheckoutSettlement {
return toActualCheckoutSettlement(calculateCheckoutSettlement(order, consumableAmountCent, coinConsumedM, depositDeductAmountCent))
}
func toActualCheckoutSettlement(settlement checkoutSettlement) ActualCheckoutSettlement {
return ActualCheckoutSettlement{
OwnerRentIncomeCent: settlement.OwnerRentIncomeCent,
DepositCompensationCent: settlement.DepositCompensationCent,
OwnerIncomeCent: settlement.OwnerIncomeCent,
RentRefundCent: settlement.RentRefundCent,
DepositRefundCent: settlement.DepositRefundCent,
RenterRefundCent: settlement.RenterRefundCent,
PlatformFeeCent: settlement.PlatformFeeCent,
ActualRentAmountCent: settlement.ActualRentAmountCent,
PureCoinAmountCent: settlement.PureCoinAmountCent,
PureCoinDiscountCent: settlement.PureCoinDiscountCent,
PureCoinPayableCent: settlement.PureCoinPayableCent,
ExtraItemAmountCent: settlement.ExtraItemAmountCent,
CoinConsumedM: settlement.CoinConsumedM,
OvershootAmountCent: settlement.OvershootAmountCent,
ShortfallCent: settlement.ShortfallCent,
}
}
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
@@ -0,0 +1,9 @@
-- +goose Up
ALTER TABLE rental_orders
ADD COLUMN actual_extra_item_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '最终实际额外物品金额(分)' AFTER actual_pure_coin_discount_cent;
-- +goose Down
ALTER TABLE rental_orders
DROP COLUMN actual_extra_item_amount_cent;
@@ -15,7 +15,7 @@ import {
} from '@/shared/utils/statusLabels' } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time' import { formatDateTime } from '@/shared/utils/time'
import { formatListingNo } from '@/shared/utils/listingDisplay' import { formatListingNo } from '@/shared/utils/listingDisplay'
import { yuanToCent } from '@/shared/utils/money' import { centToYuan, yuanToCent } from '@/shared/utils/money'
import AdminTablePagination from '../components/AdminTablePagination.vue' import AdminTablePagination from '../components/AdminTablePagination.vue'
const loading = ref(false) const loading = ref(false)
@@ -27,6 +27,7 @@ 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 actualCoinConsumedM = ref<number | undefined>()
const actualConsumableAmountYuan = 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)
@@ -43,6 +44,12 @@ const hasValidActualCoinConsumedM = computed(
Number.isFinite(actualCoinConsumedM.value) && Number.isFinite(actualCoinConsumedM.value) &&
actualCoinConsumedM.value >= 0 actualCoinConsumedM.value >= 0
) )
const hasValidActualConsumableAmount = computed(
() =>
typeof actualConsumableAmountYuan.value === 'number' &&
Number.isFinite(actualConsumableAmountYuan.value) &&
actualConsumableAmountYuan.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
) )
@@ -62,6 +69,7 @@ const canSubmitArbitration = computed(() => {
!hasValidActualCoinConsumedM.value !hasValidActualCoinConsumedM.value
) )
return false return false
if (isCompletedResult.value && !hasValidActualConsumableAmount.value) return false
return true return true
}) })
function canArbitrate(row: Dispute) { function canArbitrate(row: Dispute) {
@@ -163,6 +171,9 @@ function openArbitration(row: Dispute) {
remark.value = row.arbitration_remark || '' remark.value = row.arbitration_remark || ''
amount.value = undefined amount.value = undefined
actualCoinConsumedM.value = row.checkout_coin_consumed_m ?? undefined actualCoinConsumedM.value = row.checkout_coin_consumed_m ?? undefined
actualConsumableAmountYuan.value = centToYuan(
row.checkout_consumable_amount_cent ?? row.extra_item_original_amount_cent ?? 0
)
} }
function resetFilters() { function resetFilters() {
@@ -301,6 +312,10 @@ async function handleArbitrate() {
ElMessage.warning('请填写最终实际哈夫币消耗 M') ElMessage.warning('请填写最终实际哈夫币消耗 M')
return return
} }
if (isCompletedResult.value && !hasValidActualConsumableAmount.value) {
ElMessage.warning('请填写最终实际额外物品消耗金额')
return
}
submitting.value = true submitting.value = true
try { try {
await arbitrateDispute(activeDispute.value.id, { await arbitrateDispute(activeDispute.value.id, {
@@ -308,11 +323,15 @@ async function handleArbitrate() {
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, actual_coin_consumed_m: isCompletedResult.value ? actualCoinConsumedM.value : undefined,
actual_consumable_amount_cent: isCompletedResult.value
? yuanToCent(actualConsumableAmountYuan.value)
: undefined,
}) })
ElMessage.success('仲裁结果已保存,双方已收到通知') ElMessage.success('仲裁结果已保存,双方已收到通知')
activeDispute.value = null activeDispute.value = null
remark.value = '' remark.value = ''
actualCoinConsumedM.value = undefined actualCoinConsumedM.value = undefined
actualConsumableAmountYuan.value = undefined
await loadDisputes() await loadDisputes()
} catch (error) { } catch (error) {
ElMessage.error(readError(error, '仲裁失败')) ElMessage.error(readError(error, '仲裁失败'))
@@ -527,6 +546,18 @@ async function handleArbitrate() {
placeholder="实际消耗 M" placeholder="实际消耗 M"
/> />
</div> </div>
<div v-if="isCompletedResult" class="arbitration-field">
<span>最终实际额外消耗品</span>
<el-input-number
v-model="actualConsumableAmountYuan"
class="full-control"
:min="0"
:max="1000000000"
:precision="2"
:step="1"
placeholder="实际额外消耗品金额"
/>
</div>
<el-input <el-input
v-model="remark" v-model="remark"
class="panel-action" class="panel-action"
@@ -10,6 +10,7 @@ export interface Dispute {
order_status: string order_status: string
handoff_status: string handoff_status: string
settlement_status: string settlement_status: string
extra_item_original_amount_cent?: number
listing_no: string listing_no: string
title: string title: string
owner_id: number owner_id: number
@@ -29,6 +30,7 @@ export interface Dispute {
previous_settlement_status?: string previous_settlement_status?: string
checkout_id?: number checkout_id?: number
checkout_coin_consumed_m?: number checkout_coin_consumed_m?: number
checkout_consumable_amount_cent?: number
previous_checkout_status?: string previous_checkout_status?: string
arbitration_result: string arbitration_result: string
arbitration_remark: string arbitration_remark: string
@@ -99,6 +101,7 @@ export async function arbitrateDispute(
remark: string remark: string
amount_cent?: number amount_cent?: number
actual_coin_consumed_m?: number actual_coin_consumed_m?: number
actual_consumable_amount_cent?: number
} }
) { ) {
const { data } = await apiClient.post<ApiResponse<Dispute>>( const { data } = await apiClient.post<ApiResponse<Dispute>>(
@@ -37,6 +37,7 @@ export interface Order {
actual_coin_consumed_m?: number actual_coin_consumed_m?: number
actual_pure_coin_amount_cent?: number actual_pure_coin_amount_cent?: number
actual_pure_coin_discount_cent?: number actual_pure_coin_discount_cent?: number
actual_extra_item_amount_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