diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index c1d4be7..f4ed584 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -340,16 +340,22 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i } } - // 消耗品按申报金额计价,允许超过预收;号主侧按预收消耗品占比线性外推 + // 消耗品按申报金额计价,允许超过预收;号主侧按预收消耗品占比外推,超额及无预收损耗全额归号主 usedBuyerConsumablePriceCent := maxCent(consumableAmountCent, 0) - consumableUseRatio := 0.0 + var usedOwnerConsumablePriceCent int64 if prepaidConsumablePriceCent > 0 { - consumableUseRatio = maxRatio(float64(usedBuyerConsumablePriceCent)/float64(prepaidConsumablePriceCent), 0) - } - usedOwnerConsumablePriceCent := int64(math.Round(float64(prepaidOwnerConsumablePriceCent) * consumableUseRatio)) - if usedBuyerConsumablePriceCent <= prepaidConsumablePriceCent { - usedBuyerConsumablePriceCent = minCent(usedBuyerConsumablePriceCent, prepaidConsumablePriceCent) - usedOwnerConsumablePriceCent = minCent(usedOwnerConsumablePriceCent, prepaidOwnerConsumablePriceCent) + if usedBuyerConsumablePriceCent <= prepaidConsumablePriceCent { + consumableUseRatio := maxRatio(float64(usedBuyerConsumablePriceCent)/float64(prepaidConsumablePriceCent), 0) + usedOwnerConsumablePriceCent = int64(math.Round(float64(prepaidOwnerConsumablePriceCent) * consumableUseRatio)) + usedOwnerConsumablePriceCent = minCent(usedOwnerConsumablePriceCent, prepaidOwnerConsumablePriceCent) + } else { + // 预收范围内按号主预收基准,超出预收部分作为后置损耗全额划归号主(平台不抽成) + excessCent := usedBuyerConsumablePriceCent - prepaidConsumablePriceCent + usedOwnerConsumablePriceCent = prepaidOwnerConsumablePriceCent + excessCent + } + } else { + // 原订单无预收消耗品,后置申报的消耗/损耗金额 100% 归号主,平台不抽成 + usedOwnerConsumablePriceCent = usedBuyerConsumablePriceCent } pureCoinPlatformFeeCent := maxCent(usedBuyerCoinPriceCent-usedOwnerCoinPriceCent, 0) diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 371400e..84490ad 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -513,8 +513,8 @@ func TestBuildCheckoutSettlementWithConsumable(t *testing.T) { settlement.DepositCompensationCent, checkout.DepositDeductAmountCent) } - // 验证号主总收入 = 租金 + 押金赔付 - expectedOwnerIncome := order.OwnerRentAmountCent + checkout.DepositDeductAmountCent + // 验证号主总收入 = 租金(含后置消耗品全额划归号主) + 押金赔付 + expectedOwnerIncome := order.OwnerRentAmountCent + checkout.ConsumableAmountCent + checkout.DepositDeductAmountCent if settlement.OwnerIncomeCent != expectedOwnerIncome { t.Fatalf("OwnerIncomeCent = %d, want %d", settlement.OwnerIncomeCent, expectedOwnerIncome) diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go index e49aae4..6b6c07c 100644 --- a/backend/internal/modules/order/repository_test.go +++ b/backend/internal/modules/order/repository_test.go @@ -552,3 +552,145 @@ func TestApplyAdminOrderFiltersSmartRouting(t *testing.T) { }) } } + +func TestCalculateCheckoutSettlement_NoPrepaidConsumable_OwnerGetsFullLoss(t *testing.T) { + // 原订单无预收消耗品(consumable_price: 0),买家币价 263 元,卖家币价 233 元,平台加价 30 元 + order := model.RentalOrder{ + RentAmountCent: 26300, + OwnerRentAmountCent: 23300, + DepositAmountCent: 10000, + AccountSnapshot: datatypes.JSON([]byte(`{ + "haf_coin_amount": 100000000, + "asset_summary": { + "price_breakdown": { + "buyer_coin_base_price": 263, + "seller_coin_base_price": 233, + "consumable_price": 0 + } + } + }`)), + } + + // 结账时,耗币 100M(全额使用,原价 263/233),但号主后置申报了 50 元物资损耗(5000 分) + settlement := calculateCheckoutSettlement(order, 5000, 100, 0) + + // 1. 租客实际承担租金 = 纯币 26300 + 损耗 5000 = 31300 + if settlement.ActualRentAmountCent != 31300 { + t.Fatalf("ActualRentAmountCent = %d, want 31300", settlement.ActualRentAmountCent) + } + + // 2. 号主租金收益 = 纯币 23300 + 损耗 5000(全额划归号主) = 28300 + if settlement.OwnerRentIncomeCent != 28300 { + t.Fatalf("OwnerRentIncomeCent = %d, want 28300 (号主损耗必须全额划归号主)", settlement.OwnerRentIncomeCent) + } + + // 3. 号主总收入必须包含全部损耗 + if settlement.OwnerIncomeCent != 28300 { + t.Fatalf("OwnerIncomeCent = %d, want 28300", settlement.OwnerIncomeCent) + } + + // 4. 平台服务费必须保持原加价 3000(30 元),绝不能把号主的 50 元损耗吞掉! + if settlement.PlatformFeeCent != 3000 { + t.Fatalf("PlatformFeeCent = %d, want 3000 (平台绝不能从号主损耗中抽成)", settlement.PlatformFeeCent) + } + + // 5. 打超金额 = 31300 - 26300 = 5000,从押金扣除 + if settlement.OvershootAmountCent != 5000 { + t.Fatalf("OvershootAmountCent = %d, want 5000", settlement.OvershootAmountCent) + } + // 押金 10000 - 5000 = 5000 退给租客 + if settlement.DepositRefundCent != 5000 { + t.Fatalf("DepositRefundCent = %d, want 5000", settlement.DepositRefundCent) + } +} + +func TestCalculateCheckoutSettlement_PrepaidConsumableOvershoot_OwnerGetsExcess(t *testing.T) { + // 原订单有预收消耗品:买家预收 20 元,号主预收 16 元(平台抽 4 元);纯币 100 / 80 + order := model.RentalOrder{ + RentAmountCent: 12000, + OwnerRentAmountCent: 9600, + DepositAmountCent: 5000, + AccountSnapshot: datatypes.JSON([]byte(`{ + "haf_coin_amount": 100000000, + "asset_summary": { + "price_breakdown": { + "buyer_coin_base_price": 100, + "seller_coin_base_price": 80, + "consumable_price": 20 + } + } + }`)), + } + + // 实际消耗品达到 50 元(5000 分),超出预收 30 元(3000 分) + settlement := calculateCheckoutSettlement(order, 5000, 100, 0) + + // 号主预收部分 1600 + 超额损耗部分 3000 = 4600;纯币 8000;总号主租金 12600 + if settlement.OwnerRentIncomeCent != 12600 { + t.Fatalf("OwnerRentIncomeCent = %d, want 12600 (超出预收部分必须全额划归号主)", settlement.OwnerRentIncomeCent) + } + + // 平台服务费仍为初始纯币差额 2000 + 预收消耗品差额 400 = 2400 + if settlement.PlatformFeeCent != 2400 { + t.Fatalf("PlatformFeeCent = %d, want 2400 (超额部分平台不抽成)", settlement.PlatformFeeCent) + } +} + +func TestApplyCheckoutPriceView_RoleIsolationAndPlatformFeeHidden(t *testing.T) { + order := model.RentalOrder{ + ID: 1, + OwnerID: 100, + RenterID: 200, + RentAmountCent: 30000, + OwnerRentAmountCent: 25000, + DepositAmountCent: 10000, + } + rentCent := int64(30000) + ownerRentCent := int64(25000) + platformCent := int64(5000) + renterRefundCent := int64(8000) + ownerIncomeCent := int64(27000) + + checkout := model.OrderCheckout{ + ID: 10, + OrderID: 1, + RentAmountCent: rentCent, + OwnerRentAmountCent: ownerRentCent, + PlatformFeeCent: platformCent, + RenterRefundAmountCent: renterRefundCent, + OwnerIncomeAmountCent: ownerIncomeCent, + DepositDeductAmountCent: 2000, + } + + // 1. 号主视角 + ownerDTO := toCheckoutAdminDTO(checkout) + applyCheckoutPriceView(&ownerDTO, order, 100) + if ownerDTO.PlatformFeeCent != nil { + t.Fatalf("号主视角必须屏蔽 PlatformFeeCent, 实际为: %v", *ownerDTO.PlatformFeeCent) + } + if ownerDTO.RenterRefundAmountCent != nil { + t.Fatalf("号主视角必须屏蔽 RenterRefundAmountCent, 实际为: %v", *ownerDTO.RenterRefundAmountCent) + } + if ownerDTO.RentAmountCent != nil { + t.Fatalf("号主视角必须屏蔽 RentAmountCent, 实际为: %v", *ownerDTO.RentAmountCent) + } + if ownerDTO.OwnerIncomeAmountCent == nil || *ownerDTO.OwnerIncomeAmountCent != 27000 { + t.Fatalf("号主视角必须展示 OwnerIncomeAmountCent, 实际为: %v", ownerDTO.OwnerIncomeAmountCent) + } + + // 2. 租客视角 + renterDTO := toCheckoutAdminDTO(checkout) + applyCheckoutPriceView(&renterDTO, order, 200) + if renterDTO.PlatformFeeCent != nil { + t.Fatalf("租客视角必须屏蔽 PlatformFeeCent, 实际为: %v", *renterDTO.PlatformFeeCent) + } + if renterDTO.OwnerRentAmountCent != nil { + t.Fatalf("租客视角必须屏蔽 OwnerRentAmountCent, 实际为: %v", *renterDTO.OwnerRentAmountCent) + } + if renterDTO.OwnerIncomeAmountCent != nil { + t.Fatalf("租客视角必须屏蔽 OwnerIncomeAmountCent, 实际为: %v", *renterDTO.OwnerIncomeAmountCent) + } + if renterDTO.RenterRefundAmountCent == nil || *renterDTO.RenterRefundAmountCent != 8000 { + t.Fatalf("租客视角必须展示 RenterRefundAmountCent, 实际为: %v", renterDTO.RenterRefundAmountCent) + } +} diff --git a/frontend/src/features/admin/components/AdminOrderPaymentRecords.css b/frontend/src/features/admin/components/AdminOrderPaymentRecords.css index d1751d4..3aa947e 100644 --- a/frontend/src/features/admin/components/AdminOrderPaymentRecords.css +++ b/frontend/src/features/admin/components/AdminOrderPaymentRecords.css @@ -41,8 +41,8 @@ .record-meta { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 6px 12px; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 8px 14px; color: #52616f; font-size: 12px; line-height: 1.5; diff --git a/frontend/src/features/admin/components/ProcessTimeline.vue b/frontend/src/features/admin/components/ProcessTimeline.vue index e9826f7..f06222d 100644 --- a/frontend/src/features/admin/components/ProcessTimeline.vue +++ b/frontend/src/features/admin/components/ProcessTimeline.vue @@ -1,4 +1,5 @@ + + + + diff --git a/frontend/src/features/orders/composables/useCheckoutSimulator.ts b/frontend/src/features/orders/composables/useCheckoutSimulator.ts new file mode 100644 index 0000000..f6d49a4 --- /dev/null +++ b/frontend/src/features/orders/composables/useCheckoutSimulator.ts @@ -0,0 +1,169 @@ +import { computed, type Ref } from 'vue' +import type { Order } from '../api/orders' +import type { CounterFormState } from './useOrderActions' +import { readSnapshot, roundMoney, roundQuantity, readNumber } from './useOrderSnapshot' + +export interface OwnerSimulation { + role: 'owner' + totalIncomeYuan: number + rentIncomeYuan: number + damageCompensationYuan: number + lossCompensationYuan: number + shortfallYuan: number + isDepositSufficient: boolean +} + +export interface RenterSimulation { + role: 'renter' + actualCostYuan: number + buyerRentYuan: number + damageDeductYuan: number + totalRefundYuan: number + rentRefundYuan: number + depositRefundYuan: number + overshootYuan: number + shortfallYuan: number + isDepositSufficient: boolean +} + +export type CheckoutSimulation = OwnerSimulation | RenterSimulation | null + +export function useCheckoutSimulator(options: { + order: Ref + isOwner: Ref + isRenter: Ref + form: Ref +}) { + return computed(() => { + const o = options.order.value + if (!o) return null + + const consumedM = roundQuantity(Math.max(Number(options.form.value.coin_consumed_m || 0), 0)) + const consumableYuan = roundMoney( + Math.max(Number(options.form.value.consumableAmountYuan || 0), 0) + ) + const depositDeductYuan = roundMoney( + Math.max(Number(options.form.value.depositDeductAmountYuan || 0), 0) + ) + + const snapshot = readSnapshot(o) + const assetSummary = snapshot?.asset_summary as Record | undefined + const priceBreakdown = + ((assetSummary?.price_breakdown || snapshot?.price_breakdown) as + | Record + | undefined) || {} + + const totalCoinM = roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000) + const buyerRatio = readNumber(priceBreakdown['buyer_ratio']) + const sellerRatio = readNumber(priceBreakdown['seller_ratio']) + const buyerCoinBaseYuan = readNumber(priceBreakdown['buyer_coin_base_price']) + const sellerCoinBaseYuan = readNumber(priceBreakdown['seller_coin_base_price']) + const prepaidConsumableYuan = readNumber(priceBreakdown['consumable_price']) + + const coinUseRatio = totalCoinM > 0 ? roundQuantity(consumedM / totalCoinM) : 1 + const isCoinOvershoot = totalCoinM > 0 && consumedM > totalCoinM + const depositYuan = Number(o.deposit_amount_cent || 0) / 100 + + if (options.isOwner.value) { + // ────────── 号主视角试算 ────────── + let ownerCoinYuan = + sellerRatio > 0 && (totalCoinM > 0 || consumedM > 0) + ? roundMoney((consumedM * 100) / sellerRatio) + : roundMoney(sellerCoinBaseYuan * coinUseRatio) + if (!isCoinOvershoot) { + ownerCoinYuan = Math.min( + ownerCoinYuan, + sellerCoinBaseYuan > 0 ? sellerCoinBaseYuan : (o.owner_rent_amount_cent || 0) / 100 + ) + } + + // 消耗品/后置损耗计算 + let ownerConsumableYuan: number + if (prepaidConsumableYuan > 0) { + const ownerRentBaseYuan = (o.owner_rent_amount_cent || 0) / 100 + const prepaidOwnerConsumableYuan = Math.max( + roundMoney(ownerRentBaseYuan - sellerCoinBaseYuan), + 0 + ) + if (consumableYuan <= prepaidConsumableYuan) { + ownerConsumableYuan = roundMoney( + prepaidOwnerConsumableYuan * (consumableYuan / prepaidConsumableYuan) + ) + } else { + // 超出预收部分全额归号主 + const excessYuan = roundMoney(consumableYuan - prepaidConsumableYuan) + ownerConsumableYuan = roundMoney(prepaidOwnerConsumableYuan + excessYuan) + } + } else { + // 无预收消耗品:后置申报损耗 100% 归号主 + ownerConsumableYuan = consumableYuan + } + + const ownerRentIncomeYuan = roundMoney(ownerCoinYuan + ownerConsumableYuan) + + // 押金赔付覆盖与差额 + const damageCoveredYuan = Math.min(depositDeductYuan, depositYuan) + const shortfallYuan = Math.max(roundMoney(depositDeductYuan - depositYuan), 0) + const totalIncomeYuan = roundMoney(ownerRentIncomeYuan + damageCoveredYuan) + + return { + role: 'owner', + totalIncomeYuan, + rentIncomeYuan: ownerRentIncomeYuan, + damageCompensationYuan: damageCoveredYuan, + lossCompensationYuan: ownerConsumableYuan, + shortfallYuan, + isDepositSufficient: shortfallYuan <= 0, + } + } + + if (options.isRenter.value) { + // ────────── 租客视角试算 ────────── + let buyerCoinYuan = + buyerRatio > 0 && (totalCoinM > 0 || consumedM > 0) + ? roundMoney((consumedM * 100) / buyerRatio) + : roundMoney(buyerCoinBaseYuan * coinUseRatio) + if (!isCoinOvershoot) { + buyerCoinYuan = Math.min( + buyerCoinYuan, + buyerCoinBaseYuan > 0 ? buyerCoinBaseYuan : (o.rent_amount_cent || 0) / 100 + ) + } + + const actualBuyerRentYuan = roundMoney(buyerCoinYuan + consumableYuan) + const prepaidRentYuan = (o.rent_amount_cent || 0) / 100 + const rentOvershootYuan = Math.max(roundMoney(actualBuyerRentYuan - prepaidRentYuan), 0) + const rentRefundYuan = Math.max(roundMoney(prepaidRentYuan - actualBuyerRentYuan), 0) + + const damageAppliedYuan = Math.min(depositDeductYuan, depositYuan) + const depositLeftAfterDamage = Math.max(roundMoney(depositYuan - damageAppliedYuan), 0) + const overshootCoveredYuan = Math.min(rentOvershootYuan, depositLeftAfterDamage) + const depositRefundYuan = Math.max( + roundMoney(depositLeftAfterDamage - overshootCoveredYuan), + 0 + ) + + const shortfallYuan = roundMoney( + Math.max(roundMoney(depositDeductYuan - damageAppliedYuan), 0) + + Math.max(roundMoney(rentOvershootYuan - overshootCoveredYuan), 0) + ) + const totalRefundYuan = roundMoney(rentRefundYuan + depositRefundYuan) + const actualCostYuan = roundMoney(actualBuyerRentYuan + damageAppliedYuan) + + return { + role: 'renter', + actualCostYuan, + buyerRentYuan: actualBuyerRentYuan, + damageDeductYuan: depositDeductYuan, + totalRefundYuan, + rentRefundYuan, + depositRefundYuan, + overshootYuan: rentOvershootYuan, + shortfallYuan, + isDepositSufficient: shortfallYuan <= 0, + } + } + + return null + }) +} diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.vue b/frontend/src/features/orders/views/MobileOrderDetailView.vue index 4cbcc07..8e3a93f 100644 --- a/frontend/src/features/orders/views/MobileOrderDetailView.vue +++ b/frontend/src/features/orders/views/MobileOrderDetailView.vue @@ -5,6 +5,8 @@ import { showDialog, showToast } from 'vant' import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue' import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue' +import CheckoutTrialPreview from '@/features/orders/components/CheckoutTrialPreview.vue' +import { useCheckoutSimulator } from '@/features/orders/composables/useCheckoutSimulator' import { useOrderActions, CONFIRMABLE_ACTIONS } from '@/features/orders/composables/useOrderActions' import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier' import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect' @@ -139,6 +141,13 @@ const showCheckoutCounterAction = computed( (isRenter.value && order.value.status === 'pending_checkout_accept')) ) +const checkoutSimulation = useCheckoutSimulator({ + order, + isOwner, + isRenter, + form: counterForm, +}) + // 状态颜色映射(vant 标签主题)。 function getStatusTagType(status: string) { if (['completed', 'received'].includes(status)) return 'success' @@ -529,7 +538,7 @@ async function copyListingCode() { }}。可填实际哈夫币消耗(允许超过发布量),打超按比例从押金扣。提案将交给对方确认。

- +