feat: 接入旧站管理端并完整迁移后台能力
This commit is contained in:
@@ -46,11 +46,18 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
||||
order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent
|
||||
order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent
|
||||
order.ActualExtraItemAmountCent = settlement.ExtraItemAmountCent
|
||||
order.InsuranceFeeCent = settlement.InsuranceFeeCent
|
||||
if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.DistributionSiteID == nil || *order.DistributionSiteID == 0 {
|
||||
if err := rentergrowth.AwardOrderCompleted(tx, order, settlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil {
|
||||
if err := rentergrowth.AwardOrderBothCompleted(
|
||||
tx,
|
||||
order,
|
||||
settlement.ActualRentAmountCent,
|
||||
settlement.OwnerRentIncomeCent,
|
||||
rentergrowth.SourceOrderCompleted,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -140,6 +147,8 @@ func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutS
|
||||
checkout.RentAmountCent = settlement.ActualRentAmountCent
|
||||
checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent
|
||||
checkout.PlatformFeeCent = settlement.PlatformFeeCent
|
||||
checkout.InsuranceFeeBps = settlement.InsuranceFeeBps
|
||||
checkout.InsuranceFeeCent = settlement.InsuranceFeeCent
|
||||
checkout.PureCoinAmountCent = settlement.PureCoinAmountCent
|
||||
checkout.PureCoinDiscountCent = settlement.PureCoinDiscountCent
|
||||
checkout.PureCoinPayableCent = settlement.PureCoinPayableCent
|
||||
|
||||
@@ -9,81 +9,91 @@ import (
|
||||
)
|
||||
|
||||
type OrderDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
ListingID uint64 `json:"listing_id"`
|
||||
ListingNo string `json:"listing_no"`
|
||||
AccountID uint64 `json:"account_id"`
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
RenterID uint64 `json:"renter_id"`
|
||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||
RenterPhone string `json:"renter_phone,omitempty"`
|
||||
Title string `json:"title"`
|
||||
ServerRegion string `json:"server_region"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
RentedAt *time.Time `json:"rented_at"`
|
||||
EstimatedDurationHours int `json:"estimated_duration_hours"`
|
||||
EstimatedEndAt *time.Time `json:"estimated_end_at,omitempty"`
|
||||
PriceRole string `json:"price_role,omitempty"`
|
||||
DisplayAmountCent int64 `json:"display_amount_cent"`
|
||||
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
|
||||
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
|
||||
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
|
||||
DepositFreeLevelQuotaCent int64 `json:"deposit_free_level_quota_cent,omitempty"`
|
||||
DepositFreeManualQuotaCent int64 `json:"deposit_free_manual_quota_cent,omitempty"`
|
||||
DepositFreeUsedBeforeCent int64 `json:"deposit_free_used_before_cent,omitempty"`
|
||||
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
||||
BasePriceCent int64 `json:"base_price_cent,omitempty"`
|
||||
ShelfMarkupCent int64 `json:"shelf_markup_cent,omitempty"`
|
||||
MarkupSiteID uint64 `json:"markup_site_id,omitempty"`
|
||||
ShelfRatio float64 `json:"shelf_ratio,omitempty"`
|
||||
ShelfRatioDelta float64 `json:"shelf_ratio_delta,omitempty"`
|
||||
RentOriginalAmountCent int64 `json:"rent_original_amount_cent,omitempty"`
|
||||
RentDiscountAmountCent int64 `json:"rent_discount_amount_cent,omitempty"`
|
||||
PureCoinOriginalAmountCent int64 `json:"pure_coin_original_amount_cent,omitempty"`
|
||||
ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"`
|
||||
ActualCoinConsumedM float64 `json:"actual_coin_consumed_m,omitempty"`
|
||||
ActualPureCoinAmountCent int64 `json:"actual_pure_coin_amount_cent,omitempty"`
|
||||
ActualPureCoinDiscountCent int64 `json:"actual_pure_coin_discount_cent,omitempty"`
|
||||
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"`
|
||||
GrowthPointsBasisCent int64 `json:"growth_points_basis_cent,omitempty"`
|
||||
GrowthPointsPerYuan int64 `json:"growth_points_per_yuan,omitempty"`
|
||||
GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"`
|
||||
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"`
|
||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||
AccountSource string `json:"account_source"`
|
||||
SourceChannel string `json:"source_channel,omitempty"`
|
||||
Status string `json:"status"`
|
||||
HandoffStatus string `json:"handoff_status"`
|
||||
HandoffMode string `json:"handoff_mode"`
|
||||
SettlementMode string `json:"settlement_mode"`
|
||||
ManagedAdminID *uint64 `json:"managed_admin_id,omitempty"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
OfflineSettlementStatus string `json:"offline_settlement_status,omitempty"`
|
||||
OfflineSettlementAmountCent int64 `json:"offline_settlement_amount_cent"`
|
||||
OfflineSettlementRemark string `json:"offline_settlement_remark,omitempty"`
|
||||
OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"`
|
||||
OfflineSettledAt *time.Time `json:"offline_settled_at,omitempty"`
|
||||
RefundStatus string `json:"refund_status,omitempty"`
|
||||
RefundAmountCent int64 `json:"refund_amount_cent"`
|
||||
DepositHoldStatus string `json:"deposit_hold_status,omitempty"`
|
||||
DepositHoldAmountCent int64 `json:"deposit_hold_amount_cent"`
|
||||
DepositHoldReason string `json:"deposit_hold_reason,omitempty"`
|
||||
DepositHeldAt *time.Time `json:"deposit_held_at,omitempty"`
|
||||
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at,omitempty"`
|
||||
ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"`
|
||||
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
||||
AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"`
|
||||
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
|
||||
DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"`
|
||||
SourceTrackCode string `json:"source_track_code,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `json:"id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
ListingID uint64 `json:"listing_id"`
|
||||
ListingNo string `json:"listing_no"`
|
||||
AccountID uint64 `json:"account_id"`
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
RenterID uint64 `json:"renter_id"`
|
||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||
RenterPhone string `json:"renter_phone,omitempty"`
|
||||
Title string `json:"title"`
|
||||
ServerRegion string `json:"server_region"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
RentedAt *time.Time `json:"rented_at"`
|
||||
EstimatedDurationHours int `json:"estimated_duration_hours"`
|
||||
EstimatedEndAt *time.Time `json:"estimated_end_at,omitempty"`
|
||||
PriceRole string `json:"price_role,omitempty"`
|
||||
DisplayAmountCent int64 `json:"display_amount_cent"`
|
||||
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
|
||||
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
|
||||
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
|
||||
DepositFreeLevelQuotaCent int64 `json:"deposit_free_level_quota_cent,omitempty"`
|
||||
DepositFreeManualQuotaCent int64 `json:"deposit_free_manual_quota_cent,omitempty"`
|
||||
DepositFreeUsedBeforeCent int64 `json:"deposit_free_used_before_cent,omitempty"`
|
||||
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
||||
BasePriceCent int64 `json:"base_price_cent,omitempty"`
|
||||
ShelfMarkupCent int64 `json:"shelf_markup_cent,omitempty"`
|
||||
MarkupSiteID uint64 `json:"markup_site_id,omitempty"`
|
||||
ShelfRatio float64 `json:"shelf_ratio,omitempty"`
|
||||
ShelfRatioDelta float64 `json:"shelf_ratio_delta,omitempty"`
|
||||
RentOriginalAmountCent int64 `json:"rent_original_amount_cent,omitempty"`
|
||||
RentDiscountAmountCent int64 `json:"rent_discount_amount_cent,omitempty"`
|
||||
PureCoinOriginalAmountCent int64 `json:"pure_coin_original_amount_cent,omitempty"`
|
||||
ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"`
|
||||
ActualCoinConsumedM float64 `json:"actual_coin_consumed_m,omitempty"`
|
||||
ActualPureCoinAmountCent int64 `json:"actual_pure_coin_amount_cent,omitempty"`
|
||||
ActualPureCoinDiscountCent int64 `json:"actual_pure_coin_discount_cent,omitempty"`
|
||||
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"`
|
||||
GrowthPointsBasisCent int64 `json:"growth_points_basis_cent,omitempty"`
|
||||
GrowthPointsPerYuan int64 `json:"growth_points_per_yuan,omitempty"`
|
||||
GrowthPointsMultiplierBps int `json:"growth_points_multiplier_bps,omitempty"`
|
||||
GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"`
|
||||
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"`
|
||||
OwnerGrowthPointsBasisCent int64 `json:"owner_growth_points_basis_cent,omitempty"`
|
||||
OwnerGrowthPointsPerYuan int64 `json:"owner_growth_points_per_yuan,omitempty"`
|
||||
OwnerGrowthPointsMultiplierBps int `json:"owner_growth_points_multiplier_bps,omitempty"`
|
||||
OwnerGrowthPointsAwarded int64 `json:"owner_growth_points_awarded,omitempty"`
|
||||
OwnerGrowthPointsAwardedAt *time.Time `json:"owner_growth_points_awarded_at,omitempty"`
|
||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||
AccountSource string `json:"account_source"`
|
||||
SourceChannel string `json:"source_channel,omitempty"`
|
||||
IsPremium bool `json:"is_premium"`
|
||||
CompensationPlan string `json:"compensation_plan"`
|
||||
InsuranceFeeBps int `json:"insurance_fee_bps"`
|
||||
InsuranceFeeCent int64 `json:"insurance_fee_cent"`
|
||||
Status string `json:"status"`
|
||||
HandoffStatus string `json:"handoff_status"`
|
||||
HandoffMode string `json:"handoff_mode"`
|
||||
SettlementMode string `json:"settlement_mode"`
|
||||
ManagedAdminID *uint64 `json:"managed_admin_id,omitempty"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
OfflineSettlementStatus string `json:"offline_settlement_status,omitempty"`
|
||||
OfflineSettlementAmountCent int64 `json:"offline_settlement_amount_cent"`
|
||||
OfflineSettlementRemark string `json:"offline_settlement_remark,omitempty"`
|
||||
OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"`
|
||||
OfflineSettledAt *time.Time `json:"offline_settled_at,omitempty"`
|
||||
RefundStatus string `json:"refund_status,omitempty"`
|
||||
RefundAmountCent int64 `json:"refund_amount_cent"`
|
||||
DepositHoldStatus string `json:"deposit_hold_status,omitempty"`
|
||||
DepositHoldAmountCent int64 `json:"deposit_hold_amount_cent"`
|
||||
DepositHoldReason string `json:"deposit_hold_reason,omitempty"`
|
||||
DepositHeldAt *time.Time `json:"deposit_held_at,omitempty"`
|
||||
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at,omitempty"`
|
||||
ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"`
|
||||
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
||||
AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"`
|
||||
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
|
||||
DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"`
|
||||
SourceTrackCode string `json:"source_track_code,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UserOrderListItemDTO 是“我的订单”列表的最小展示数据,详情数据仅由单订单接口返回。
|
||||
@@ -129,7 +139,8 @@ type AdminOrderListItemDTO struct {
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// OwnerIncomeAmountCent 为号主实际到手金额(分),仅在已结账/已仲裁订单上返回;
|
||||
// 用指针区分「未结账(null)」与「到手 0 元」。数据来自结账表最新一行。
|
||||
// 用指针区分「未结账(null)」与「到手 0 元」。平台代管订单取线下结算金额,
|
||||
// 其他订单取号主钱包最终入账合计。
|
||||
OwnerIncomeAmountCent *int64 `json:"owner_income_amount_cent,omitempty"`
|
||||
}
|
||||
|
||||
@@ -329,6 +340,8 @@ type CheckoutDTO struct {
|
||||
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
|
||||
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
|
||||
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
||||
InsuranceFeeBps int `json:"insurance_fee_bps"`
|
||||
InsuranceFeeCent int64 `json:"insurance_fee_cent"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
PureCoinAmountCent int64 `json:"pure_coin_amount_cent"`
|
||||
PureCoinDiscountCent int64 `json:"pure_coin_discount_cent"`
|
||||
|
||||
@@ -22,6 +22,8 @@ func writeOrderError(c *gin.Context, err error) {
|
||||
response.Error(c, http.StatusConflict, "listing_unavailable", "该账号暂不可租")
|
||||
case errors.Is(err, ErrCannotRentOwnListing):
|
||||
response.BadRequest(c, "不能租用自己发布的账号")
|
||||
case errors.Is(err, ErrPremiumLevelRequired):
|
||||
response.Error(c, http.StatusForbidden, "platinum_level_required", "成长等级达到铂金及以上才能租用极品专区账号")
|
||||
case errors.Is(err, ErrInsufficientBalance):
|
||||
response.Error(c, http.StatusConflict, "insufficient_balance", "钱包余额不足,请先充值")
|
||||
case errors.Is(err, ErrChannelPaymentRequired):
|
||||
|
||||
@@ -103,6 +103,9 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
||||
DiscountBps: rentergrowth.DefaultDiscountBps,
|
||||
}
|
||||
}
|
||||
if listing.IsPremium && !rentergrowth.CanViewPremiumLevel(growthSnapshot.LevelCode) {
|
||||
return ErrPremiumLevelRequired
|
||||
}
|
||||
rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(pricing.PureCoinAmountCent, pricing.PureCoinPlatformFeeCent, growthSnapshot.DiscountBps)
|
||||
if rentDiscountAmountCent > 0 {
|
||||
pricing.PlatformFeeCent -= rentDiscountAmountCent
|
||||
@@ -151,6 +154,9 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
||||
AccountSnapshot: snapshot,
|
||||
AccountSource: accountSource,
|
||||
SourceChannel: sourceChannel,
|
||||
IsPremium: listing.IsPremium,
|
||||
CompensationPlan: normalizedCompensationPlan(listing.CompensationPlan),
|
||||
InsuranceFeeBps: listing.InsuranceFeeBps,
|
||||
Status: orderStatusPendingPayment,
|
||||
HandoffStatus: handoffStatusNone,
|
||||
HandoffMode: listingHandoffMode(listing),
|
||||
@@ -194,6 +200,13 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
||||
return r.FindForUser(ctx, renterID, createdID, req.DistributionSiteCode)
|
||||
}
|
||||
|
||||
func normalizedCompensationPlan(plan string) string {
|
||||
if plan == "insurance" {
|
||||
return "insurance"
|
||||
}
|
||||
return "normal"
|
||||
}
|
||||
|
||||
func loadShelfRatioTx(tx *gorm.DB, siteCode string, listingID uint64) (float64, uint64, error) {
|
||||
siteCode = strings.TrimSpace(siteCode)
|
||||
if siteCode == "" || listingID == 0 {
|
||||
|
||||
@@ -66,78 +66,88 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
||||
ownerRentAmountCent := row.OwnerRentAmountCent
|
||||
platformFeeCent := row.PlatformFeeCent
|
||||
return OrderDTO{
|
||||
ID: row.ID,
|
||||
OrderNo: row.OrderNo,
|
||||
ListingID: row.ListingID,
|
||||
ListingNo: row.ListingNo,
|
||||
AccountID: row.AccountID,
|
||||
OwnerID: row.OwnerID,
|
||||
RenterID: row.RenterID,
|
||||
OwnerPhone: row.OwnerPhone,
|
||||
RenterPhone: row.RenterPhone,
|
||||
Title: row.Title,
|
||||
ServerRegion: row.ServerRegion,
|
||||
LoginPlatform: row.LoginPlatform,
|
||||
RentedAt: rentedAt,
|
||||
EstimatedDurationHours: durationHours,
|
||||
EstimatedEndAt: estimatedEndAt,
|
||||
PriceRole: "admin",
|
||||
DisplayAmountCent: row.RentAmountCent,
|
||||
RentAmountCent: &rentAmountCent,
|
||||
OwnerRentAmountCent: &ownerRentAmountCent,
|
||||
DepositAmountCent: row.DepositAmountCent,
|
||||
DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder),
|
||||
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
|
||||
DepositFreeLevelQuotaCent: row.DepositFreeLevelQuotaCent,
|
||||
DepositFreeManualQuotaCent: row.DepositFreeManualQuotaCent,
|
||||
DepositFreeUsedBeforeCent: row.DepositFreeUsedBeforeCent,
|
||||
PlatformFeeCent: &platformFeeCent,
|
||||
BasePriceCent: row.BasePriceCent,
|
||||
ShelfMarkupCent: row.ShelfMarkupCent,
|
||||
MarkupSiteID: row.MarkupSiteID,
|
||||
ShelfRatio: row.ShelfRatio,
|
||||
ShelfRatioDelta: row.ShelfRatioDelta,
|
||||
RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder),
|
||||
RentDiscountAmountCent: row.RentDiscountAmountCent,
|
||||
PureCoinOriginalAmountCent: orderPureCoinOriginalAmountCent(row.RentalOrder),
|
||||
ExtraItemOriginalAmountCent: orderExtraItemOriginalAmountCent(row.RentalOrder),
|
||||
ActualCoinConsumedM: row.ActualCoinConsumedM,
|
||||
ActualPureCoinAmountCent: row.ActualPureCoinAmountCent,
|
||||
ActualPureCoinDiscountCent: row.ActualPureCoinDiscountCent,
|
||||
ActualExtraItemAmountCent: row.ActualExtraItemAmountCent,
|
||||
RenterGrowthLevel: effectiveRenterGrowthLevel(row.RentalOrder),
|
||||
RenterGrowthLevelName: effectiveRenterGrowthLevelName(row.RentalOrder),
|
||||
RenterDiscountBps: effectiveRenterDiscountBps(row.RentalOrder),
|
||||
GrowthPointsBasisCent: row.GrowthPointsBasisCent,
|
||||
GrowthPointsPerYuan: row.GrowthPointsPerYuan,
|
||||
GrowthPointsAwarded: row.GrowthPointsAwarded,
|
||||
GrowthPointsAwardedAt: row.GrowthPointsAwardedAt,
|
||||
AccountSnapshot: row.AccountSnapshot,
|
||||
AccountSource: effectiveAccountSource(row.RentalOrder),
|
||||
SourceChannel: row.SourceChannel,
|
||||
Status: row.Status,
|
||||
HandoffStatus: row.HandoffStatus,
|
||||
HandoffMode: effectiveHandoffMode(row.RentalOrder),
|
||||
SettlementMode: effectiveSettlementMode(row.RentalOrder),
|
||||
ManagedAdminID: row.ManagedAdminID,
|
||||
SettlementStatus: row.SettlementStatus,
|
||||
OfflineSettlementStatus: effectiveOfflineSettlementStatus(row.RentalOrder),
|
||||
OfflineSettlementAmountCent: row.OfflineSettlementAmountCent,
|
||||
OfflineSettlementRemark: row.OfflineSettlementRemark,
|
||||
OfflineSettledBy: row.OfflineSettledBy,
|
||||
OfflineSettledAt: row.OfflineSettledAt,
|
||||
RefundStatus: row.RefundStatus,
|
||||
RefundAmountCent: row.RefundAmountCent,
|
||||
DepositHoldStatus: row.DepositHoldStatus,
|
||||
DepositHoldAmountCent: row.DepositHoldAmountCent,
|
||||
DepositHoldReason: row.DepositHoldReason,
|
||||
DepositHeldAt: row.DepositHeldAt,
|
||||
DepositHoldReleasedAt: row.DepositHoldReleasedAt,
|
||||
AdminActions: adminActionsForOrder(row.RentalOrder),
|
||||
DistributionSiteID: row.DistributionSiteID,
|
||||
SourceTrackCode: row.SourceTrackCode,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
ID: row.ID,
|
||||
OrderNo: row.OrderNo,
|
||||
ListingID: row.ListingID,
|
||||
ListingNo: row.ListingNo,
|
||||
AccountID: row.AccountID,
|
||||
OwnerID: row.OwnerID,
|
||||
RenterID: row.RenterID,
|
||||
OwnerPhone: row.OwnerPhone,
|
||||
RenterPhone: row.RenterPhone,
|
||||
Title: row.Title,
|
||||
ServerRegion: row.ServerRegion,
|
||||
LoginPlatform: row.LoginPlatform,
|
||||
RentedAt: rentedAt,
|
||||
EstimatedDurationHours: durationHours,
|
||||
EstimatedEndAt: estimatedEndAt,
|
||||
PriceRole: "admin",
|
||||
DisplayAmountCent: row.RentAmountCent,
|
||||
RentAmountCent: &rentAmountCent,
|
||||
OwnerRentAmountCent: &ownerRentAmountCent,
|
||||
DepositAmountCent: row.DepositAmountCent,
|
||||
DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder),
|
||||
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
|
||||
DepositFreeLevelQuotaCent: row.DepositFreeLevelQuotaCent,
|
||||
DepositFreeManualQuotaCent: row.DepositFreeManualQuotaCent,
|
||||
DepositFreeUsedBeforeCent: row.DepositFreeUsedBeforeCent,
|
||||
PlatformFeeCent: &platformFeeCent,
|
||||
BasePriceCent: row.BasePriceCent,
|
||||
ShelfMarkupCent: row.ShelfMarkupCent,
|
||||
MarkupSiteID: row.MarkupSiteID,
|
||||
ShelfRatio: row.ShelfRatio,
|
||||
ShelfRatioDelta: row.ShelfRatioDelta,
|
||||
RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder),
|
||||
RentDiscountAmountCent: row.RentDiscountAmountCent,
|
||||
PureCoinOriginalAmountCent: orderPureCoinOriginalAmountCent(row.RentalOrder),
|
||||
ExtraItemOriginalAmountCent: orderExtraItemOriginalAmountCent(row.RentalOrder),
|
||||
ActualCoinConsumedM: row.ActualCoinConsumedM,
|
||||
ActualPureCoinAmountCent: row.ActualPureCoinAmountCent,
|
||||
ActualPureCoinDiscountCent: row.ActualPureCoinDiscountCent,
|
||||
ActualExtraItemAmountCent: row.ActualExtraItemAmountCent,
|
||||
RenterGrowthLevel: effectiveRenterGrowthLevel(row.RentalOrder),
|
||||
RenterGrowthLevelName: effectiveRenterGrowthLevelName(row.RentalOrder),
|
||||
RenterDiscountBps: effectiveRenterDiscountBps(row.RentalOrder),
|
||||
GrowthPointsBasisCent: row.GrowthPointsBasisCent,
|
||||
GrowthPointsPerYuan: row.GrowthPointsPerYuan,
|
||||
GrowthPointsMultiplierBps: row.GrowthPointsMultiplierBps,
|
||||
GrowthPointsAwarded: row.GrowthPointsAwarded,
|
||||
GrowthPointsAwardedAt: row.GrowthPointsAwardedAt,
|
||||
OwnerGrowthPointsBasisCent: row.OwnerGrowthPointsBasisCent,
|
||||
OwnerGrowthPointsPerYuan: row.OwnerGrowthPointsPerYuan,
|
||||
OwnerGrowthPointsMultiplierBps: row.OwnerGrowthPointsMultiplierBps,
|
||||
OwnerGrowthPointsAwarded: row.OwnerGrowthPointsAwarded,
|
||||
OwnerGrowthPointsAwardedAt: row.OwnerGrowthPointsAwardedAt,
|
||||
AccountSnapshot: row.AccountSnapshot,
|
||||
AccountSource: effectiveAccountSource(row.RentalOrder),
|
||||
SourceChannel: row.SourceChannel,
|
||||
IsPremium: row.IsPremium,
|
||||
CompensationPlan: normalizedCompensationPlan(row.CompensationPlan),
|
||||
InsuranceFeeBps: row.InsuranceFeeBps,
|
||||
InsuranceFeeCent: row.InsuranceFeeCent,
|
||||
Status: row.Status,
|
||||
HandoffStatus: row.HandoffStatus,
|
||||
HandoffMode: effectiveHandoffMode(row.RentalOrder),
|
||||
SettlementMode: effectiveSettlementMode(row.RentalOrder),
|
||||
ManagedAdminID: row.ManagedAdminID,
|
||||
SettlementStatus: row.SettlementStatus,
|
||||
OfflineSettlementStatus: effectiveOfflineSettlementStatus(row.RentalOrder),
|
||||
OfflineSettlementAmountCent: row.OfflineSettlementAmountCent,
|
||||
OfflineSettlementRemark: row.OfflineSettlementRemark,
|
||||
OfflineSettledBy: row.OfflineSettledBy,
|
||||
OfflineSettledAt: row.OfflineSettledAt,
|
||||
RefundStatus: row.RefundStatus,
|
||||
RefundAmountCent: row.RefundAmountCent,
|
||||
DepositHoldStatus: row.DepositHoldStatus,
|
||||
DepositHoldAmountCent: row.DepositHoldAmountCent,
|
||||
DepositHoldReason: row.DepositHoldReason,
|
||||
DepositHeldAt: row.DepositHeldAt,
|
||||
DepositHoldReleasedAt: row.DepositHoldReleasedAt,
|
||||
AdminActions: adminActionsForOrder(row.RentalOrder),
|
||||
DistributionSiteID: row.DistributionSiteID,
|
||||
SourceTrackCode: row.SourceTrackCode,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +322,8 @@ func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
|
||||
RentAmountCent: &rentAmountCent,
|
||||
OwnerRentAmountCent: &ownerRentAmountCent,
|
||||
PlatformFeeCent: &platformFeeCent,
|
||||
InsuranceFeeBps: checkout.InsuranceFeeBps,
|
||||
InsuranceFeeCent: checkout.InsuranceFeeCent,
|
||||
DepositAmountCent: checkout.DepositAmountCent,
|
||||
PureCoinAmountCent: checkout.PureCoinAmountCent,
|
||||
PureCoinDiscountCent: checkout.PureCoinDiscountCent,
|
||||
@@ -425,6 +437,7 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
|
||||
dto.GrowthPointsAwardedAt = nil
|
||||
dto.GrowthPointsBasisCent = 0
|
||||
dto.GrowthPointsPerYuan = 0
|
||||
dto.GrowthPointsMultiplierBps = 0
|
||||
sanitizeOrderSnapshot(&dto.AccountSnapshot, "owner")
|
||||
case userID == order.RenterID:
|
||||
rentAmountCent := order.RentAmountCent
|
||||
@@ -437,6 +450,11 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
|
||||
dto.RenterGrowthLevel = effectiveRenterGrowthLevel(order)
|
||||
dto.RenterGrowthLevelName = effectiveRenterGrowthLevelName(order)
|
||||
dto.RenterDiscountBps = effectiveRenterDiscountBps(order)
|
||||
dto.OwnerGrowthPointsBasisCent = 0
|
||||
dto.OwnerGrowthPointsPerYuan = 0
|
||||
dto.OwnerGrowthPointsMultiplierBps = 0
|
||||
dto.OwnerGrowthPointsAwarded = 0
|
||||
dto.OwnerGrowthPointsAwardedAt = nil
|
||||
sanitizeOrderSnapshot(&dto.AccountSnapshot, "renter")
|
||||
default:
|
||||
dto.PriceRole = ""
|
||||
@@ -458,6 +476,12 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
|
||||
dto.GrowthPointsAwardedAt = nil
|
||||
dto.GrowthPointsBasisCent = 0
|
||||
dto.GrowthPointsPerYuan = 0
|
||||
dto.GrowthPointsMultiplierBps = 0
|
||||
dto.OwnerGrowthPointsBasisCent = 0
|
||||
dto.OwnerGrowthPointsPerYuan = 0
|
||||
dto.OwnerGrowthPointsMultiplierBps = 0
|
||||
dto.OwnerGrowthPointsAwarded = 0
|
||||
dto.OwnerGrowthPointsAwardedAt = nil
|
||||
sanitizeOrderSnapshot(&dto.AccountSnapshot, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ type checkoutSettlement struct {
|
||||
DepositRefundCent int64
|
||||
RenterRefundCent int64
|
||||
PlatformFeeCent int64
|
||||
InsuranceFeeBps int
|
||||
InsuranceFeeCent int64
|
||||
ActualRentAmountCent int64
|
||||
PureCoinAmountCent int64
|
||||
PureCoinDiscountCent int64
|
||||
@@ -58,6 +60,8 @@ type ActualCheckoutSettlement struct {
|
||||
DepositRefundCent int64
|
||||
RenterRefundCent int64
|
||||
PlatformFeeCent int64
|
||||
InsuranceFeeBps int
|
||||
InsuranceFeeCent int64
|
||||
ActualRentAmountCent int64
|
||||
PureCoinAmountCent int64
|
||||
PureCoinDiscountCent int64
|
||||
@@ -93,6 +97,8 @@ func toActualCheckoutSettlement(settlement checkoutSettlement) ActualCheckoutSet
|
||||
DepositRefundCent: settlement.DepositRefundCent,
|
||||
RenterRefundCent: settlement.RenterRefundCent,
|
||||
PlatformFeeCent: settlement.PlatformFeeCent,
|
||||
InsuranceFeeBps: settlement.InsuranceFeeBps,
|
||||
InsuranceFeeCent: settlement.InsuranceFeeCent,
|
||||
ActualRentAmountCent: settlement.ActualRentAmountCent,
|
||||
PureCoinAmountCent: settlement.PureCoinAmountCent,
|
||||
PureCoinDiscountCent: settlement.PureCoinDiscountCent,
|
||||
@@ -372,6 +378,8 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c
|
||||
RentAmountCent: settlement.ActualRentAmountCent,
|
||||
OwnerRentAmountCent: settlement.OwnerRentIncomeCent,
|
||||
PlatformFeeCent: settlement.PlatformFeeCent,
|
||||
InsuranceFeeBps: settlement.InsuranceFeeBps,
|
||||
InsuranceFeeCent: settlement.InsuranceFeeCent,
|
||||
DepositAmountCent: order.DepositAmountCent,
|
||||
PureCoinAmountCent: settlement.PureCoinAmountCent,
|
||||
PureCoinDiscountCent: settlement.PureCoinDiscountCent,
|
||||
@@ -499,6 +507,8 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
||||
// 可完结时租客实际承担的租金向金额;有 shortfall 时仍展示完整应付便于协商
|
||||
actualRentAmountCent := actualBuyerRentCent
|
||||
ownerRentIncomeCent := actualOwnerRentCent
|
||||
insuranceFeeCent := calculateInsuranceFeeCent(usedOwnerCoinPriceCent, order.InsuranceFeeBps)
|
||||
ownerRentIncomeCent = maxCent(ownerRentIncomeCent-insuranceFeeCent, 0)
|
||||
platformFeeCent := maxCent(actualRentAmountCent-ownerRentIncomeCent, 0)
|
||||
depositCompensationCent := damageAppliedCent
|
||||
|
||||
@@ -510,6 +520,8 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
||||
DepositRefundCent: depositRefundCent,
|
||||
RenterRefundCent: rentRefundCent + depositRefundCent,
|
||||
PlatformFeeCent: platformFeeCent,
|
||||
InsuranceFeeBps: order.InsuranceFeeBps,
|
||||
InsuranceFeeCent: insuranceFeeCent,
|
||||
ActualRentAmountCent: actualRentAmountCent,
|
||||
PureCoinAmountCent: usedBuyerCoinPriceCent,
|
||||
PureCoinDiscountCent: pureCoinDiscountCent,
|
||||
@@ -521,6 +533,14 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
||||
}
|
||||
}
|
||||
|
||||
func calculateInsuranceFeeCent(ownerRentIncomeCent int64, feeBps int) int64 {
|
||||
if ownerRentIncomeCent <= 0 || feeBps <= 0 {
|
||||
return 0
|
||||
}
|
||||
feeCent := (ownerRentIncomeCent*int64(feeBps) + 5_000) / 10_000
|
||||
return minCent(feeCent, ownerRentIncomeCent)
|
||||
}
|
||||
|
||||
func orderPureCoinOriginalAmountCent(order model.RentalOrder) int64 {
|
||||
originalRentCent := effectiveRentOriginalAmountCent(order)
|
||||
if order.PureCoinOriginalAmountCent > 0 {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hfb_jysh/backend/internal/model"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
func TestCalculateInsuranceFeeCent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ownerCent int64
|
||||
bps int
|
||||
want int64
|
||||
}{
|
||||
{name: "普通赔付不收费", ownerCent: 10000, bps: 0, want: 0},
|
||||
{name: "普通保险百分之八", ownerCent: 10000, bps: 800, want: 800},
|
||||
{name: "极品保险百分之四", ownerCent: 10000, bps: 400, want: 400},
|
||||
{name: "四舍五入到分", ownerCent: 12345, bps: 800, want: 988},
|
||||
{name: "服务费不超过到手租金", ownerCent: 100, bps: 10000, want: 100},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := calculateInsuranceFeeCent(tt.ownerCent, tt.bps); got != tt.want {
|
||||
t.Fatalf("calculateInsuranceFeeCent() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateCheckoutSettlementDeductsPremiumInsuranceFee(t *testing.T) {
|
||||
order := model.RentalOrder{
|
||||
RentAmountCent: 38300,
|
||||
OwnerRentAmountCent: 35300,
|
||||
DepositAmountCent: 15000,
|
||||
InsuranceFeeBps: 400,
|
||||
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
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
}
|
||||
|
||||
settlement := calculateCheckoutSettlement(order, 12000, 100, 0)
|
||||
if settlement.InsuranceFeeBps != 400 || settlement.InsuranceFeeCent != 932 {
|
||||
t.Fatalf("保险费用快照不正确: bps=%d fee=%d", settlement.InsuranceFeeBps, settlement.InsuranceFeeCent)
|
||||
}
|
||||
if settlement.OwnerRentIncomeCent != 34368 {
|
||||
t.Fatalf("号主净租金 = %d, want 34368", settlement.OwnerRentIncomeCent)
|
||||
}
|
||||
if settlement.PlatformFeeCent != 3932 {
|
||||
t.Fatalf("平台收入 = %d, want 3932", settlement.PlatformFeeCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateCheckoutSettlementUsesActualPureCoinAsInsuranceBase(t *testing.T) {
|
||||
order := model.RentalOrder{
|
||||
RentAmountCent: 25000,
|
||||
OwnerRentAmountCent: 25000,
|
||||
DepositAmountCent: 15000,
|
||||
InsuranceFeeBps: 800,
|
||||
AccountSnapshot: datatypes.JSON([]byte(`{
|
||||
"haf_coin_amount": 100000000,
|
||||
"asset_summary": {
|
||||
"price_breakdown": {
|
||||
"buyer_ratio": 40,
|
||||
"seller_ratio": 40,
|
||||
"buyer_coin_base_price": 250,
|
||||
"seller_coin_base_price": 250,
|
||||
"consumable_price": 0
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
}
|
||||
|
||||
settlement := calculateCheckoutSettlement(order, 5000, 50, 0)
|
||||
if settlement.PureCoinAmountCent != 12500 {
|
||||
t.Fatalf("实际纯币原价 = %d, want 12500", settlement.PureCoinAmountCent)
|
||||
}
|
||||
if settlement.InsuranceFeeCent != 1000 {
|
||||
t.Fatalf("保险费用 = %d, want 1000", settlement.InsuranceFeeCent)
|
||||
}
|
||||
if settlement.ExtraItemAmountCent != 5000 {
|
||||
t.Fatalf("额外物品 = %d, want 5000", settlement.ExtraItemAmountCent)
|
||||
}
|
||||
if settlement.OwnerRentIncomeCent != 16500 {
|
||||
t.Fatalf("号主净租金 = %d, want 16500", settlement.OwnerRentIncomeCent)
|
||||
}
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Adm
|
||||
}
|
||||
}
|
||||
if len(incomeOrderIDs) > 0 {
|
||||
ownerIncome, err = r.latestOwnerIncomeByOrder(ctx, incomeOrderIDs)
|
||||
ownerIncome, err = r.ownerActualIncomeByOrder(ctx, incomeOrderIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -241,7 +241,7 @@ func (r *Repository) adminOrderCountQuery(ctx context.Context, query AdminOrderQ
|
||||
upper := strings.ToUpper(kw)
|
||||
needsJoin := false
|
||||
if kw != "" {
|
||||
if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") {
|
||||
if isPhoneKeyword(kw) || strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") {
|
||||
needsJoin = false
|
||||
} else if id, err := strconv.ParseUint(kw, 10, 64); err == nil && id > 0 && kw[0] != '0' && len(kw) < 9 {
|
||||
needsJoin = false
|
||||
@@ -255,48 +255,42 @@ func (r *Repository) adminOrderCountQuery(ctx context.Context, query AdminOrderQ
|
||||
return r.adminOrderJoinQuery(ctx)
|
||||
}
|
||||
|
||||
// latestOwnerIncomeByOrder 批量读取每单「最新一次结账」的号主实际到手金额(分)。
|
||||
// ownerActualIncomeByOrder 批量读取每单号主最终实际结算金额(分)。
|
||||
//
|
||||
// 后台订单列表专用:一次查询覆盖整页,不会退化为逐行查询的 N+1。
|
||||
// 只取结账表两列,不加载 content / evidence_urls 等大字段。
|
||||
// 结账行只能代表协商结果,不能代表仲裁后的最终落账:
|
||||
// - 平台代管订单:最终金额写入 rental_orders.offline_settlement_amount_cent;
|
||||
// - 普通钱包订单:最终金额以 wallet_ledger 中号主的实际入账合计为准。
|
||||
//
|
||||
// 语义:多轮还价是原地更新同一行,但订单可能经历多次结账流程,
|
||||
// 因此按 order_id 取 MAX(id) 作为最终结账行。
|
||||
//
|
||||
// 执行计划(MySQL 8.4 实测,EXPLAIN FORMAT=TREE):
|
||||
//
|
||||
// -> Nested loop inner join
|
||||
// -> Materialize
|
||||
// -> Group aggregate: max(order_checkouts.id)
|
||||
// -> Covering index scan on order_checkouts using idx_order_checkouts_order_id
|
||||
// -> Single-row index lookup on oc using PRIMARY (id=latest.latest_id)
|
||||
//
|
||||
// 用 STRAIGHT_JOIN 固定连接顺序:先物化「每单最新结账 id」小集合(走
|
||||
// idx_order_checkouts_order_id 覆盖索引),再按主键逐行取金额。避免优化器
|
||||
// 在数据量增长后改选全表扫描;开销只与当前页订单数相关,与结账表总量无关。
|
||||
func (r *Repository) latestOwnerIncomeByOrder(ctx context.Context, orderIDs []uint64) (map[uint64]int64, error) {
|
||||
// 仲裁可能不更新历史结账行,甚至 dispute.checkout_id 为空,因此不能继续取
|
||||
// order_checkouts 的 owner_income_amount_cent,否则列表会显示仲裁前金额。
|
||||
func (r *Repository) ownerActualIncomeByOrder(ctx context.Context, orderIDs []uint64) (map[uint64]int64, error) {
|
||||
result := make(map[uint64]int64, len(orderIDs))
|
||||
if len(orderIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
latest := r.db.WithContext(ctx).
|
||||
Table("order_checkouts").
|
||||
Select("order_id, MAX(id) AS latest_id").
|
||||
Where("order_id IN ?", orderIDs).
|
||||
Group("order_id")
|
||||
var rows []struct {
|
||||
OrderID uint64
|
||||
ID uint64
|
||||
OwnerIncomeAmountCent int64
|
||||
}
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("(?) AS latest", latest).
|
||||
Select("oc.order_id, oc.owner_income_amount_cent").
|
||||
Joins("STRAIGHT_JOIN order_checkouts AS oc ON oc.id = latest.latest_id").
|
||||
Table("rental_orders AS o").
|
||||
Select(`o.id,
|
||||
CASE WHEN o.settlement_mode = 'platform_managed'
|
||||
THEN COALESCE(o.offline_settlement_amount_cent, 0)
|
||||
ELSE COALESCE(SUM(wl.amount_cent), 0)
|
||||
END AS owner_income_amount_cent`).
|
||||
Joins(`LEFT JOIN wallet_ledger AS wl
|
||||
ON wl.order_id = o.id
|
||||
AND wl.user_id = o.owner_id
|
||||
AND wl.direction = 'in'
|
||||
AND wl.biz_type IN ?`, []string{walletBizOwnerIncome, walletBizDepositCompensation, "arbitration_owner_income"}).
|
||||
Where("o.id IN ?", orderIDs).
|
||||
Group("o.id, o.settlement_mode, o.offline_settlement_amount_cent").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.OrderID] = row.OwnerIncomeAmountCent
|
||||
result[row.ID] = row.OwnerIncomeAmountCent
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -334,9 +328,14 @@ func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB {
|
||||
}
|
||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||
upper := strings.ToUpper(keyword)
|
||||
if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) {
|
||||
// 11 位手机号:命中 users(phone) 唯一索引
|
||||
db = db.Where("(owner.phone = ? OR renter.phone = ?)", keyword, keyword)
|
||||
if isPhoneKeyword(keyword) {
|
||||
// 先由 users(phone) 唯一索引解析用户,再让订单表 owner_id/renter_id 索引驱动。
|
||||
// 不能直接写 owner.phone/renter.phone:跨表别名 OR 会让 MySQL 倒序扫描全部订单。
|
||||
db = db.Where(
|
||||
"(o.owner_id = (SELECT id FROM users WHERE phone = ?) OR o.renter_id = (SELECT id FROM users WHERE phone = ?))",
|
||||
keyword,
|
||||
keyword,
|
||||
)
|
||||
} else if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") {
|
||||
// 订单号:命中 rental_orders(order_no) 唯一索引
|
||||
db = db.Where("o.order_no = ?", keyword)
|
||||
@@ -532,3 +531,7 @@ func isAllDigits(s string) bool {
|
||||
}
|
||||
return len(s) > 0
|
||||
}
|
||||
|
||||
func isPhoneKeyword(s string) bool {
|
||||
return len(s) == 11 && s[0] == '1' && isAllDigits(s)
|
||||
}
|
||||
|
||||
@@ -2160,23 +2160,25 @@ func createOpenPlatformCheckout(t *testing.T, db *gorm.DB, order model.RentalOrd
|
||||
return checkout
|
||||
}
|
||||
|
||||
// TestListAdminUsesFinalizedCheckoutOwnerIncome 校验后台订单列表带回「号主实际收入」,
|
||||
// 且金额取自结账表最终行(已结账/已仲裁时才是定论值)。
|
||||
func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) {
|
||||
// TestListAdminPlatformManagedArbitrationUsesOfflineSettlement 校验平台代管订单仲裁后
|
||||
// 列表读取线下实际结算金额,不能继续展示旧结账行中的金额。
|
||||
func TestListAdminPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
adminID := uint64(7002)
|
||||
_, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
|
||||
order.Status = orderStatusCompleted
|
||||
order.SettlementStatus = settlementStatusSettled
|
||||
order.SettlementStatus = settlementStatusArbitrated
|
||||
order.OfflineSettlementStatus = offlineSettlementStatusSettled
|
||||
order.OfflineSettlementAmountCent = 382
|
||||
if err := db.Save(&order).Error; err != nil {
|
||||
t.Fatalf("save completed order failed: %v", err)
|
||||
t.Fatalf("save arbitrated order failed: %v", err)
|
||||
}
|
||||
checkout := createOpenPlatformCheckout(t, db, order, renter.ID)
|
||||
checkout.Status = checkoutStatusAccepted
|
||||
// 模拟仲裁前遗留的结账金额,列表不能把它当成最终收入。
|
||||
checkout.OwnerIncomeAmountCent = 6000
|
||||
if err := db.Save(&checkout).Error; err != nil {
|
||||
t.Fatalf("save finalized checkout failed: %v", err)
|
||||
t.Fatalf("save stale checkout failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20})
|
||||
@@ -2186,16 +2188,15 @@ func TestListAdminUsesFinalizedCheckoutOwnerIncome(t *testing.T) {
|
||||
if len(result.Items) != 1 {
|
||||
t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items))
|
||||
}
|
||||
if result.Items[0].SettlementStatus != settlementStatusSettled {
|
||||
t.Fatalf("settlement status = %q, want %q", result.Items[0].SettlementStatus, settlementStatusSettled)
|
||||
if result.Items[0].SettlementStatus != settlementStatusArbitrated {
|
||||
t.Fatalf("settlement status = %q, want %q", result.Items[0].SettlementStatus, settlementStatusArbitrated)
|
||||
}
|
||||
// 列表需直接展示号主实际收入,供客服在列表页核对结算。
|
||||
income := result.Items[0].OwnerIncomeAmountCent
|
||||
if income == nil {
|
||||
t.Fatalf("owner income = nil, want 6000 for settled order")
|
||||
t.Fatalf("owner income = nil, want 382 for platform-managed arbitrated order")
|
||||
}
|
||||
if *income != 6000 {
|
||||
t.Fatalf("owner income = %d, want 6000", *income)
|
||||
if *income != 382 {
|
||||
t.Fatalf("owner income = %d, want 382", *income)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2227,28 +2228,54 @@ func TestListAdminOmitsOwnerIncomeWhileNegotiating(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAdminOwnerIncomeUsesLatestCheckout 校验一单存在多行结账记录时取最新一行(MAX(id))。
|
||||
func TestListAdminOwnerIncomeUsesLatestCheckout(t *testing.T) {
|
||||
// TestListAdminWalletArbitrationUsesWalletIncome 校验普通钱包订单仲裁后列表汇总
|
||||
// 号主实际钱包入账,而不是读取仲裁前遗留的结账金额。
|
||||
func TestListAdminWalletArbitrationUsesWalletIncome(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
adminID := uint64(7004)
|
||||
_, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
|
||||
owner, renter, order := createPlatformManagedCheckoutOrder(t, db, adminID)
|
||||
order.Status = orderStatusCompleted
|
||||
order.SettlementStatus = settlementStatusSettled
|
||||
order.SettlementStatus = settlementStatusArbitrated
|
||||
order.SettlementMode = settlementModeOwnerWallet
|
||||
order.OfflineSettlementStatus = offlineSettlementStatusNone
|
||||
order.OfflineSettlementAmountCent = 0
|
||||
if err := db.Save(&order).Error; err != nil {
|
||||
t.Fatalf("save completed order failed: %v", err)
|
||||
t.Fatalf("save arbitrated order failed: %v", err)
|
||||
}
|
||||
first := createOpenPlatformCheckout(t, db, order, renter.ID)
|
||||
first.Status = checkoutStatusAccepted
|
||||
first.OwnerIncomeAmountCent = 1111
|
||||
if err := db.Save(&first).Error; err != nil {
|
||||
t.Fatalf("save first checkout failed: %v", err)
|
||||
checkout := createOpenPlatformCheckout(t, db, order, renter.ID)
|
||||
checkout.OwnerIncomeAmountCent = 56013
|
||||
if err := db.Save(&checkout).Error; err != nil {
|
||||
t.Fatalf("save stale checkout failed: %v", err)
|
||||
}
|
||||
second := createOpenPlatformCheckout(t, db, order, renter.ID)
|
||||
second.Status = checkoutStatusAccepted
|
||||
second.OwnerIncomeAmountCent = 2222
|
||||
if err := db.Save(&second).Error; err != nil {
|
||||
t.Fatalf("save second checkout failed: %v", err)
|
||||
|
||||
orderID := order.ID
|
||||
ledgers := []model.WalletLedger{
|
||||
{
|
||||
LedgerNo: "WL-ARBITRATION-OWNER",
|
||||
UserID: owner.ID,
|
||||
OrderID: &orderID,
|
||||
Direction: "in",
|
||||
AmountCent: 38613,
|
||||
BalanceType: "available",
|
||||
BizType: "arbitration_owner_income",
|
||||
BizNo: order.OrderNo,
|
||||
Remark: "仲裁按实际消耗结算给号主",
|
||||
},
|
||||
{
|
||||
LedgerNo: "WL-ARBITRATION-OTHER-USER",
|
||||
UserID: renter.ID,
|
||||
OrderID: &orderID,
|
||||
Direction: "in",
|
||||
AmountCent: 999999,
|
||||
BalanceType: "available",
|
||||
BizType: "arbitration_owner_income",
|
||||
BizNo: order.OrderNo,
|
||||
Remark: "不应计入号主收入",
|
||||
},
|
||||
}
|
||||
if err := db.Create(&ledgers).Error; err != nil {
|
||||
t.Fatalf("create wallet ledgers failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20})
|
||||
@@ -2259,8 +2286,36 @@ func TestListAdminOwnerIncomeUsesLatestCheckout(t *testing.T) {
|
||||
t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items))
|
||||
}
|
||||
income := result.Items[0].OwnerIncomeAmountCent
|
||||
if income == nil || *income != 2222 {
|
||||
t.Fatalf("owner income = %v, want 2222 from latest checkout row", income)
|
||||
if income == nil || *income != 38613 {
|
||||
t.Fatalf("owner income = %v, want 38613 from arbitration wallet income", income)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAdminArbitratedWithoutPayoutReturnsZero 校验仲裁已经形成终态但无实际入账时,
|
||||
// 列表返回明确的 0 元,而不是因为缺少结账行显示为未知(null)。
|
||||
func TestListAdminArbitratedWithoutPayoutReturnsZero(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
adminID := uint64(7005)
|
||||
_, _, order := createPlatformManagedCheckoutOrder(t, db, adminID)
|
||||
order.Status = orderStatusClosed
|
||||
order.SettlementStatus = settlementStatusArbitrated
|
||||
order.OfflineSettlementStatus = offlineSettlementStatusNone
|
||||
order.OfflineSettlementAmountCent = 0
|
||||
if err := db.Save(&order).Error; err != nil {
|
||||
t.Fatalf("save arbitrated order failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := repo.ListAdmin(t.Context(), AdminOrderQuery{Page: 1, PageSize: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAdmin() error = %v", err)
|
||||
}
|
||||
if len(result.Items) != 1 {
|
||||
t.Fatalf("ListAdmin() returned %d items, want 1", len(result.Items))
|
||||
}
|
||||
income := result.Items[0].OwnerIncomeAmountCent
|
||||
if income == nil || *income != 0 {
|
||||
t.Fatalf("owner income = %v, want explicit zero for arbitrated order without payout", income)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2513,23 +2568,24 @@ func seedSettledAdminOrders(t *testing.T, db *gorm.DB, from, to int) {
|
||||
t.Fatalf("create listing %d failed: %v", i, err)
|
||||
}
|
||||
order := model.RentalOrder{
|
||||
OrderNo: fmt.Sprintf("ORD-N1-%04d", i),
|
||||
ListingID: listing.ID,
|
||||
AccountID: account.ID,
|
||||
OwnerID: owner.ID,
|
||||
RenterID: renter.ID,
|
||||
Status: orderStatusCompleted,
|
||||
SettlementStatus: settlementStatusSettled,
|
||||
HandoffMode: handoffModePlatform,
|
||||
SettlementMode: settlementModePlatformManaged,
|
||||
ManagedAdminID: &adminID,
|
||||
RentAmountCent: 10000,
|
||||
OwnerRentAmountCent: 8000,
|
||||
PlatformFeeCent: 2000,
|
||||
DepositAmountCent: 1000,
|
||||
EstimatedDurationHours: 24,
|
||||
OfflineSettlementStatus: offlineSettlementStatusNone,
|
||||
AccountSnapshot: datatypes.JSON([]byte(`{"haf_coin_amount":1000000}`)),
|
||||
OrderNo: fmt.Sprintf("ORD-N1-%04d", i),
|
||||
ListingID: listing.ID,
|
||||
AccountID: account.ID,
|
||||
OwnerID: owner.ID,
|
||||
RenterID: renter.ID,
|
||||
Status: orderStatusCompleted,
|
||||
SettlementStatus: settlementStatusSettled,
|
||||
HandoffMode: handoffModePlatform,
|
||||
SettlementMode: settlementModePlatformManaged,
|
||||
ManagedAdminID: &adminID,
|
||||
RentAmountCent: 10000,
|
||||
OwnerRentAmountCent: 8000,
|
||||
PlatformFeeCent: 2000,
|
||||
DepositAmountCent: 1000,
|
||||
EstimatedDurationHours: 24,
|
||||
OfflineSettlementStatus: offlineSettlementStatusNone,
|
||||
OfflineSettlementAmountCent: int64(1000 + i),
|
||||
AccountSnapshot: datatypes.JSON([]byte(`{"haf_coin_amount":1000000}`)),
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("create order %d failed: %v", i, err)
|
||||
@@ -2600,26 +2656,28 @@ func (c *sqlCaptureLogger) Trace(_ context.Context, _ time.Time, fc func() (stri
|
||||
c.statements = append(c.statements, sql)
|
||||
}
|
||||
|
||||
// TestLatestOwnerIncomeQueryIsSingleBatchStatement 锁定号主实际收入的取数形态:
|
||||
// 整页一条语句、用 STRAIGHT_JOIN 固定「先物化最新结账 id,再按主键取金额」的顺序。
|
||||
// 该形态对应 EXPLAIN:覆盖索引扫描 + 主键单行查找,不随行数产生额外查询。
|
||||
func TestLatestOwnerIncomeQueryIsSingleBatchStatement(t *testing.T) {
|
||||
// TestOwnerActualIncomeQueryIsSingleBatchStatement 锁定号主实际收入的取数形态:
|
||||
// 整页一条语句,平台代管读取线下结算金额,其他订单汇总钱包最终入账。
|
||||
func TestOwnerActualIncomeQueryIsSingleBatchStatement(t *testing.T) {
|
||||
capture := &sqlCaptureLogger{Interface: logger.Default.LogMode(logger.Silent)}
|
||||
db := setupOrderTestDB(t).Session(&gorm.Session{Logger: capture})
|
||||
repo := NewRepository(db)
|
||||
|
||||
if _, err := repo.latestOwnerIncomeByOrder(t.Context(), []uint64{11, 12, 13}); err != nil {
|
||||
t.Fatalf("latestOwnerIncomeByOrder() error = %v", err)
|
||||
if _, err := repo.ownerActualIncomeByOrder(t.Context(), []uint64{11, 12, 13}); err != nil {
|
||||
t.Fatalf("ownerActualIncomeByOrder() error = %v", err)
|
||||
}
|
||||
if len(capture.statements) != 1 {
|
||||
t.Fatalf("取数语句条数 = %d, want 1(多行必须合并为单条批量查询)", len(capture.statements))
|
||||
}
|
||||
sql := capture.statements[0]
|
||||
if !strings.Contains(sql, "STRAIGHT_JOIN order_checkouts") {
|
||||
t.Fatalf("缺少 STRAIGHT_JOIN 固定连接顺序,实际 SQL:\n%s", sql)
|
||||
if !strings.Contains(sql, "LEFT JOIN wallet_ledger AS wl") {
|
||||
t.Fatalf("缺少钱包入账汇总,实际 SQL:\n%s", sql)
|
||||
}
|
||||
if !strings.Contains(sql, "offline_settlement_amount_cent") {
|
||||
t.Fatalf("缺少平台代管线下结算金额,实际 SQL:\n%s", sql)
|
||||
}
|
||||
if !strings.Contains(sql, "GROUP BY") {
|
||||
t.Fatalf("缺少按 order_id 分组的 MAX(id) 子查询,实际 SQL:\n%s", sql)
|
||||
t.Fatalf("缺少按订单分组汇总,实际 SQL:\n%s", sql)
|
||||
}
|
||||
if strings.Contains(sql, "SELECT *") {
|
||||
t.Fatalf("应只取所需列,避免读取 content/evidence_urls 大字段,实际 SQL:\n%s", sql)
|
||||
|
||||
@@ -511,9 +511,9 @@ func TestApplyAdminOrderFiltersSmartRouting(t *testing.T) {
|
||||
wantSQLFrag string
|
||||
}{
|
||||
{
|
||||
name: "11位手机号路由至users(phone)",
|
||||
name: "11位手机号先解析用户再走订单角色索引",
|
||||
query: AdminOrderQuery{Keyword: "13800138000"},
|
||||
wantSQLFrag: "owner.phone = ? OR renter.phone = ?",
|
||||
wantSQLFrag: "o.owner_id = (SELECT id FROM users WHERE phone = ?)",
|
||||
},
|
||||
{
|
||||
name: "RO前缀订单号路由至rental_orders(order_no)",
|
||||
|
||||
@@ -10,6 +10,7 @@ var (
|
||||
ErrInvalidRentHours = errors.New("invalid rent hours")
|
||||
ErrListingUnavailable = errors.New("listing unavailable")
|
||||
ErrCannotRentOwnListing = errors.New("cannot rent own listing")
|
||||
ErrPremiumLevelRequired = errors.New("platinum level required for premium listing")
|
||||
ErrInsufficientBalance = errors.New("insufficient balance")
|
||||
ErrOrderCannotPay = errors.New("order cannot pay")
|
||||
ErrChannelPaymentRequired = errors.New("channel payment required")
|
||||
|
||||
Reference in New Issue
Block a user