diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index 4418252..a49ec76 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -32,6 +32,7 @@ type RentalOrder struct { 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:"-"` + ActualExtraItemAmountCent int64 `gorm:"not null;default:0" json:"-"` 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"` RenterDiscountBps int `gorm:"not null;default:10000" json:"renter_discount_bps"` diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index 9248702..5c6234a 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -53,7 +53,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r if err != nil { return err } - settlement, err := buildArbitrationSettlement(order, req, frozenBalance) + settlement, actualSettlement, linkedCheckout, depositDeductAmountCent, err := buildArbitrationDecision(tx, row, order, req, frozenBalance) if err != nil { return err } @@ -114,23 +114,16 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r return err } if order.Status == "completed" { - coinConsumedM, linkedCheckout, err := arbitrationCoinConsumedM(tx, row, req) - if err != nil { - return err + if actualSettlement == nil { + return ErrInvalidDispute } - pureCoin := ordermodule.CalculateActualPureCoinAmounts(order, coinConsumedM) - order.ActualCoinConsumedM = pureCoin.ConsumedM - order.ActualPureCoinAmountCent = pureCoin.AmountCent - order.ActualPureCoinDiscountCent = pureCoin.DiscountCent + applyArbitrationActualSettlement(&order, linkedCheckout, *actualSettlement, depositDeductAmountCent) 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 { + if err := rentergrowth.AwardOrderCompleted(tx, &order, actualSettlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil { return err } } @@ -166,6 +159,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r "deposit_deduct_amount_cent": settlement.DepositDeductAmountCent, "actual_coin_consumed_m": order.ActualCoinConsumedM, "actual_pure_coin_cent": order.ActualPureCoinAmountCent, + "actual_extra_item_cent": order.ActualExtraItemAmountCent, "before_order_status": beforeOrderStatus, "after_order_status": order.Status, "before_handoff_status": beforeHandoffStatus, @@ -220,22 +214,131 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r return &dto, nil } -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 +func buildArbitrationDecision(tx *gorm.DB, row model.Dispute, order model.RentalOrder, req ArbitrateRequest, renterFrozenBalanceCent int64) (arbitrationSettlement, *ordermodule.ActualCheckoutSettlement, *model.OrderCheckout, int64, error) { + if arbitrationUsesActualCheckout(req.Result) { + return buildActualCheckoutArbitrationSettlement(tx, row, order, req, renterFrozenBalanceCent) } + 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 { - return *req.ActualCoinConsumedM, checkout, nil + return *req.ActualCoinConsumedM, 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 { @@ -275,24 +378,18 @@ func applyPlatformManagedSettlement(order *model.RentalOrder, settlement *arbitr } type arbitrationSettlement struct { - Entries []wallet.Entry - RenterRefundAmountCent int64 - RenterDepositRefundCent int64 - OwnerIncomeAmountCent int64 - DepositDeductAmountCent int64 + Entries []wallet.Entry + RenterRefundAmountCent int64 + RenterDepositRefundCent int64 + OwnerIncomeAmountCent int64 + DepositDeductAmountCent int64 + ActualExtraItemAmountCent int64 } -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 +func newArbitrationSettlement(order model.RentalOrder, renterFrozenBalanceCent int64) arbitrationSettlement { settlement := arbitrationSettlement{} orderID := order.ID - releaseFrozenAmountCent := money.MinCent(totalCent, renterFrozenBalanceCent) + releaseFrozenAmountCent := money.MinCent(order.RentAmountCent+order.DepositAmountCent, renterFrozenBalanceCent) if releaseFrozenAmountCent > 0 { settlement.Entries = append(settlement.Entries, wallet.Entry{ UserID: order.RenterID, @@ -305,6 +402,36 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r 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) { if amountCent <= 0 { @@ -316,17 +443,7 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r if amountCent <= 0 { return } - 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, - }) + settlement.addOwnerIncome(order, amountCent, remark) } switch req.Result { @@ -503,6 +620,9 @@ func buildArbitrationHandoffContent(req ArbitrateRequest, settlement arbitration if settlement.DepositDeductAmountCent > 0 { content += "\n押金赔付扣除:" + money.FormatWithSymbol(settlement.DepositDeductAmountCent) } + if settlement.ActualExtraItemAmountCent > 0 { + content += "\n额外物品消耗:" + money.FormatWithSymbol(settlement.ActualExtraItemAmountCent) + } return content } diff --git a/backend/internal/modules/dispute/dto.go b/backend/internal/modules/dispute/dto.go index 6e21025..d57364c 100644 --- a/backend/internal/modules/dispute/dto.go +++ b/backend/internal/modules/dispute/dto.go @@ -9,38 +9,40 @@ import ( ) type DisputeDTO struct { - ID uint64 `json:"id"` - OrderID uint64 `json:"order_id"` - OrderNo string `json:"order_no"` - OrderStatus string `json:"order_status"` - HandoffStatus string `json:"handoff_status"` - SettlementStatus string `json:"settlement_status"` - ListingNo string `json:"listing_no"` - Title string `json:"title"` - OwnerID uint64 `json:"owner_id"` - RenterID uint64 `json:"renter_id"` - OwnerPhone string `json:"owner_phone"` - RenterPhone string `json:"renter_phone"` - InitiatorID uint64 `json:"initiator_id"` - InitiatorType string `json:"initiator_type"` - InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"` - TargetUserID uint64 `json:"target_user_id"` - Type string `json:"type"` - Status string `json:"status"` - Description string `json:"description"` - EvidenceURLS datatypes.JSON `json:"evidence_urls"` - PreviousOrderStatus string `json:"previous_order_status"` - 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"` - HandledBy *uint64 `json:"handled_by"` - HandledAt *time.Time `json:"handled_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + OrderStatus string `json:"order_status"` + HandoffStatus string `json:"handoff_status"` + SettlementStatus string `json:"settlement_status"` + ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"` + ListingNo string `json:"listing_no"` + Title string `json:"title"` + OwnerID uint64 `json:"owner_id"` + RenterID uint64 `json:"renter_id"` + OwnerPhone string `json:"owner_phone"` + RenterPhone string `json:"renter_phone"` + InitiatorID uint64 `json:"initiator_id"` + InitiatorType string `json:"initiator_type"` + InitiatorAdminID *uint64 `json:"initiator_admin_id,omitempty"` + TargetUserID uint64 `json:"target_user_id"` + Type string `json:"type"` + Status string `json:"status"` + Description string `json:"description"` + EvidenceURLS datatypes.JSON `json:"evidence_urls"` + PreviousOrderStatus string `json:"previous_order_status"` + 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"` + CheckoutConsumableAmountCent *int64 `json:"checkout_consumable_amount_cent,omitempty"` + PreviousCheckoutStatus string `json:"previous_checkout_status"` + ArbitrationResult string `json:"arbitration_result"` + ArbitrationRemark string `json:"arbitration_remark"` + HandledBy *uint64 `json:"handled_by"` + HandledAt *time.Time `json:"handled_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CreateRequest struct { @@ -57,10 +59,11 @@ type AdminCreateRequest struct { } type ArbitrateRequest struct { - Result string `json:"result" binding:"required"` - Remark string `json:"remark" binding:"required"` - AmountCent int64 `json:"amount_cent"` - ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"` + Result string `json:"result" binding:"required"` + Remark string `json:"remark" binding:"required"` + AmountCent int64 `json:"amount_cent"` + ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"` + ActualConsumableAmountCent *int64 `json:"actual_consumable_amount_cent"` } type AdminListQuery struct { diff --git a/backend/internal/modules/dispute/presenter.go b/backend/internal/modules/dispute/presenter.go index 24c20de..1740fc5 100644 --- a/backend/internal/modules/dispute/presenter.go +++ b/backend/internal/modules/dispute/presenter.go @@ -10,53 +10,57 @@ 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 - CheckoutCoinConsumedM *float64 + OrderNo string + OrderStatus string + HandoffStatus string + SettlementStatus string + ExtraItemOriginalAmountCent int64 + ListingNo string + Title string + OwnerID uint64 + RenterID uint64 + OwnerPhone string + RenterPhone string + CheckoutCoinConsumedM *float64 + CheckoutConsumableAmountCent *int64 } func (row disputeRow) toDTO() DisputeDTO { return DisputeDTO{ - ID: row.ID, - OrderID: row.OrderID, - OrderNo: row.OrderNo, - OrderStatus: row.OrderStatus, - HandoffStatus: row.HandoffStatus, - SettlementStatus: row.SettlementStatus, - ListingNo: row.ListingNo, - Title: row.Title, - OwnerID: row.OwnerID, - RenterID: row.RenterID, - OwnerPhone: row.OwnerPhone, - RenterPhone: row.RenterPhone, - InitiatorID: row.InitiatorID, - InitiatorType: effectiveInitiatorType(row.Dispute), - InitiatorAdminID: row.InitiatorAdminID, - TargetUserID: row.TargetUserID, - Type: row.Type, - Status: row.Status, - Description: row.Description, - EvidenceURLS: row.EvidenceURLS, - PreviousOrderStatus: row.PreviousOrderStatus, - PreviousHandoffStatus: row.PreviousHandoffStatus, - PreviousSettlementStatus: row.PreviousSettlementStatus, - CheckoutID: row.CheckoutID, - CheckoutCoinConsumedM: row.CheckoutCoinConsumedM, - PreviousCheckoutStatus: row.PreviousCheckoutStatus, - ArbitrationResult: row.ArbitrationResult, - ArbitrationRemark: row.ArbitrationRemark, - HandledBy: row.HandledBy, - HandledAt: row.HandledAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + ID: row.ID, + OrderID: row.OrderID, + OrderNo: row.OrderNo, + OrderStatus: row.OrderStatus, + HandoffStatus: row.HandoffStatus, + SettlementStatus: row.SettlementStatus, + ExtraItemOriginalAmountCent: row.ExtraItemOriginalAmountCent, + ListingNo: row.ListingNo, + Title: row.Title, + OwnerID: row.OwnerID, + RenterID: row.RenterID, + OwnerPhone: row.OwnerPhone, + RenterPhone: row.RenterPhone, + InitiatorID: row.InitiatorID, + InitiatorType: effectiveInitiatorType(row.Dispute), + InitiatorAdminID: row.InitiatorAdminID, + TargetUserID: row.TargetUserID, + Type: row.Type, + Status: row.Status, + Description: row.Description, + EvidenceURLS: row.EvidenceURLS, + PreviousOrderStatus: row.PreviousOrderStatus, + PreviousHandoffStatus: row.PreviousHandoffStatus, + PreviousSettlementStatus: row.PreviousSettlementStatus, + CheckoutID: row.CheckoutID, + CheckoutCoinConsumedM: row.CheckoutCoinConsumedM, + CheckoutConsumableAmountCent: row.CheckoutConsumableAmountCent, + PreviousCheckoutStatus: row.PreviousCheckoutStatus, + ArbitrationResult: row.ArbitrationResult, + ArbitrationRemark: row.ArbitrationRemark, + HandledBy: row.HandledBy, + HandledAt: row.HandledAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, } } diff --git a/backend/internal/modules/dispute/query.go b/backend/internal/modules/dispute/query.go index 121632f..6d86f91 100644 --- a/backend/internal/modules/dispute/query.go +++ b/backend/internal/modules/dispute/query.go @@ -91,8 +91,10 @@ func applyUserVisibleFilter(db *gorm.DB, userID uint64) *gorm.DB { 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, c.coin_consumed_m AS checkout_coin_consumed_m`) + o.owner_id, o.renter_id, o.extra_item_original_amount_cent, + 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 { diff --git a/backend/internal/modules/dispute/repository_test.go b/backend/internal/modules/dispute/repository_test.go index cb5101f..575e1b7 100644 --- a/backend/internal/modules/dispute/repository_test.go +++ b/backend/internal/modules/dispute/repository_test.go @@ -345,10 +345,21 @@ func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) { } 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", Remark: "确认实际消耗 50M", 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 { t.Fatalf("带实际消耗 M 仲裁失败: %v", err) } @@ -356,8 +367,8 @@ func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) { 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) + if saved.ActualCoinConsumedM != 50 || saved.ActualPureCoinAmountCent != 13150 || saved.ActualExtraItemAmountCent != 12000 || saved.GrowthPointsAwarded != 131 { + 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" _, renter, order := createDisputeOrderFixture(t, db, inputOrder) checkout := model.OrderCheckout{ - OrderID: order.ID, - InitiatedBy: renter.ID, - Status: "submitted", - CoinConsumedM: 25, + OrderID: order.ID, + InitiatedBy: renter.ID, + Status: "submitted", + ConsumableAmountCent: 7000, + CoinConsumedM: 25, } if err := db.Create(&checkout).Error; err != nil { t.Fatalf("创建结账记录失败: %v", err) @@ -387,8 +399,8 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) { 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 created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 || created.CheckoutConsumableAmountCent == nil || *created.CheckoutConsumableAmountCent != 7000 { + 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{ @@ -401,8 +413,15 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) { 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) + if saved.ActualCoinConsumedM != 25 || saved.ActualPureCoinAmountCent != 6575 || saved.ActualExtraItemAmountCent != 7000 || saved.GrowthPointsAwarded != 65 { + 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) } } diff --git a/backend/internal/modules/dispute/service.go b/backend/internal/modules/dispute/service.go index e274ed4..099c8d5 100644 --- a/backend/internal/modules/dispute/service.go +++ b/backend/internal/modules/dispute/service.go @@ -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) { 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) } diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index 46e80af..41d093d 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -43,6 +43,7 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che order.ActualCoinConsumedM = settlement.CoinConsumedM order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent + order.ActualExtraItemAmountCent = settlement.ExtraItemAmountCent if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil { return nil, err } diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index a60389c..d2dde8f 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -42,6 +42,7 @@ type OrderDTO struct { 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"` + ActualExtraItemAmountCent int64 `json:"actual_extra_item_amount_cent,omitempty"` RenterGrowthLevel string `json:"renter_growth_level,omitempty"` RenterGrowthLevelName string `json:"renter_growth_level_name,omitempty"` RenterDiscountBps int `json:"renter_discount_bps,omitempty"` diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 4d4c853..c96cdf5 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -92,6 +92,7 @@ func (row orderRow) toAdminDTO() OrderDTO { ActualCoinConsumedM: row.ActualCoinConsumedM, ActualPureCoinAmountCent: row.ActualPureCoinAmountCent, ActualPureCoinDiscountCent: row.ActualPureCoinDiscountCent, + ActualExtraItemAmountCent: row.ActualExtraItemAmountCent, RenterGrowthLevel: effectiveRenterGrowthLevel(row.RentalOrder), RenterGrowthLevelName: effectiveRenterGrowthLevelName(row.RentalOrder), RenterDiscountBps: effectiveRenterDiscountBps(row.RentalOrder), @@ -330,6 +331,7 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64) dto.ActualCoinConsumedM = 0 dto.ActualPureCoinAmountCent = 0 dto.ActualPureCoinDiscountCent = 0 + dto.ActualExtraItemAmountCent = 0 dto.RenterGrowthLevel = "" dto.RenterGrowthLevelName = "" dto.RenterDiscountBps = 0 @@ -362,6 +364,7 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64) dto.ActualCoinConsumedM = 0 dto.ActualPureCoinAmountCent = 0 dto.ActualPureCoinDiscountCent = 0 + dto.ActualExtraItemAmountCent = 0 dto.RenterGrowthLevel = "" dto.RenterGrowthLevelName = "" dto.RenterDiscountBps = 0 diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index 935b659..47c6569 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -50,9 +50,28 @@ type ActualPureCoinAmounts struct { 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 计算纯币原价及等级优惠。 func CalculateActualPureCoinAmounts(order model.RentalOrder, coinConsumedM float64) ActualPureCoinAmounts { - settlement := calculateCheckoutSettlement(order, 0, coinConsumedM, 0) + settlement := CalculateActualCheckoutSettlement(order, 0, coinConsumedM, 0) return ActualPureCoinAmounts{ AmountCent: settlement.PureCoinAmountCent, 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 { if order.EstimatedDurationHours > 0 { return order.EstimatedDurationHours diff --git a/backend/migrations/000045_order_actual_extra_item_amount.sql b/backend/migrations/000045_order_actual_extra_item_amount.sql new file mode 100644 index 0000000..87a525d --- /dev/null +++ b/backend/migrations/000045_order_actual_extra_item_amount.sql @@ -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; diff --git a/frontend/src/features/admin/views/AdminDisputesView.vue b/frontend/src/features/admin/views/AdminDisputesView.vue index 6c8e9b2..9c8be2e 100644 --- a/frontend/src/features/admin/views/AdminDisputesView.vue +++ b/frontend/src/features/admin/views/AdminDisputesView.vue @@ -15,7 +15,7 @@ import { } from '@/shared/utils/statusLabels' import { formatDateTime } from '@/shared/utils/time' 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' const loading = ref(false) @@ -27,6 +27,7 @@ const result = ref('release_deposit') const remark = ref('') const amount = ref() const actualCoinConsumedM = ref() +const actualConsumableAmountYuan = ref() const currentPage = ref(1) const currentPageSize = ref(20) const total = ref(0) @@ -43,6 +44,12 @@ const hasValidActualCoinConsumedM = computed( Number.isFinite(actualCoinConsumedM.value) && actualCoinConsumedM.value >= 0 ) +const hasValidActualConsumableAmount = computed( + () => + typeof actualConsumableAmountYuan.value === 'number' && + Number.isFinite(actualConsumableAmountYuan.value) && + actualConsumableAmountYuan.value >= 0 +) const pendingCount = computed( () => disputes.value.filter(item => ['open', 'processing'].includes(item.status)).length ) @@ -62,6 +69,7 @@ const canSubmitArbitration = computed(() => { !hasValidActualCoinConsumedM.value ) return false + if (isCompletedResult.value && !hasValidActualConsumableAmount.value) return false return true }) function canArbitrate(row: Dispute) { @@ -163,6 +171,9 @@ function openArbitration(row: Dispute) { remark.value = row.arbitration_remark || '' amount.value = 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() { @@ -301,6 +312,10 @@ async function handleArbitrate() { ElMessage.warning('请填写最终实际哈夫币消耗 M') return } + if (isCompletedResult.value && !hasValidActualConsumableAmount.value) { + ElMessage.warning('请填写最终实际额外物品消耗金额') + return + } submitting.value = true try { await arbitrateDispute(activeDispute.value.id, { @@ -308,11 +323,15 @@ async function handleArbitrate() { remark: remark.value.trim(), amount_cent: amount.value ? yuanToCent(amount.value) : undefined, actual_coin_consumed_m: isCompletedResult.value ? actualCoinConsumedM.value : undefined, + actual_consumable_amount_cent: isCompletedResult.value + ? yuanToCent(actualConsumableAmountYuan.value) + : undefined, }) ElMessage.success('仲裁结果已保存,双方已收到通知') activeDispute.value = null remark.value = '' actualCoinConsumedM.value = undefined + actualConsumableAmountYuan.value = undefined await loadDisputes() } catch (error) { ElMessage.error(readError(error, '仲裁失败')) @@ -527,6 +546,18 @@ async function handleArbitrate() { placeholder="实际消耗 M" /> +
+ 最终实际额外消耗品(元) + +
>( diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 425a8c6..445a216 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -37,6 +37,7 @@ export interface Order { actual_coin_consumed_m?: number actual_pure_coin_amount_cent?: number actual_pure_coin_discount_cent?: number + actual_extra_item_amount_cent?: number renter_growth_level?: string renter_growth_level_name?: string renter_discount_bps?: number