feat(checkout): 修复后置损耗结算全额归号主、支持角色隔离实时试算与全流程UI重构
- fix(pricing): 后置消耗/损耗金额 100% 划归号主收益,超出预收部分平台不抽成,避免被平台费吸收 - feat(checkout): 前端新增结账实时试算预览组件 (CheckoutTrialPreview + useCheckoutSimulator),角色隔离展示(仅见自身收入/退款,屏蔽平台服务费) - refactor(admin): 重构交易全流程时间线 (ProcessTimeline.vue),将平铺挤压指标重构成4大分类仪表盘微型卡片矩阵,升级可视化业务状态流转徽章与阶段色彩主题 - style(admin): 优化支付与交接流水元信息网格布局,防止超宽屏下长单行拉伸
This commit is contained in:
@@ -336,16 +336,22 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消耗品按申报金额计价,允许超过预收;号主侧按预收消耗品占比线性外推
|
// 消耗品按申报金额计价,允许超过预收;号主侧按预收消耗品占比外推,超额及无预收损耗全额归号主
|
||||||
usedBuyerConsumablePriceCent := maxCent(consumableAmountCent, 0)
|
usedBuyerConsumablePriceCent := maxCent(consumableAmountCent, 0)
|
||||||
consumableUseRatio := 0.0
|
var usedOwnerConsumablePriceCent int64
|
||||||
if prepaidConsumablePriceCent > 0 {
|
if prepaidConsumablePriceCent > 0 {
|
||||||
consumableUseRatio = maxRatio(float64(usedBuyerConsumablePriceCent)/float64(prepaidConsumablePriceCent), 0)
|
if usedBuyerConsumablePriceCent <= prepaidConsumablePriceCent {
|
||||||
}
|
consumableUseRatio := maxRatio(float64(usedBuyerConsumablePriceCent)/float64(prepaidConsumablePriceCent), 0)
|
||||||
usedOwnerConsumablePriceCent := int64(math.Round(float64(prepaidOwnerConsumablePriceCent) * consumableUseRatio))
|
usedOwnerConsumablePriceCent = int64(math.Round(float64(prepaidOwnerConsumablePriceCent) * consumableUseRatio))
|
||||||
if usedBuyerConsumablePriceCent <= prepaidConsumablePriceCent {
|
usedOwnerConsumablePriceCent = minCent(usedOwnerConsumablePriceCent, prepaidOwnerConsumablePriceCent)
|
||||||
usedBuyerConsumablePriceCent = minCent(usedBuyerConsumablePriceCent, prepaidConsumablePriceCent)
|
} else {
|
||||||
usedOwnerConsumablePriceCent = minCent(usedOwnerConsumablePriceCent, prepaidOwnerConsumablePriceCent)
|
// 预收范围内按号主预收基准,超出预收部分作为后置损耗全额划归号主(平台不抽成)
|
||||||
|
excessCent := usedBuyerConsumablePriceCent - prepaidConsumablePriceCent
|
||||||
|
usedOwnerConsumablePriceCent = prepaidOwnerConsumablePriceCent + excessCent
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 原订单无预收消耗品,后置申报的消耗/损耗金额 100% 归号主,平台不抽成
|
||||||
|
usedOwnerConsumablePriceCent = usedBuyerConsumablePriceCent
|
||||||
}
|
}
|
||||||
|
|
||||||
pureCoinPlatformFeeCent := maxCent(usedBuyerCoinPriceCent-usedOwnerCoinPriceCent, 0)
|
pureCoinPlatformFeeCent := maxCent(usedBuyerCoinPriceCent-usedOwnerCoinPriceCent, 0)
|
||||||
|
|||||||
@@ -417,8 +417,8 @@ func TestBuildCheckoutSettlementWithConsumable(t *testing.T) {
|
|||||||
settlement.DepositCompensationCent, checkout.DepositDeductAmountCent)
|
settlement.DepositCompensationCent, checkout.DepositDeductAmountCent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证号主总收入 = 租金 + 押金赔付
|
// 验证号主总收入 = 租金(含后置消耗品全额划归号主) + 押金赔付
|
||||||
expectedOwnerIncome := order.OwnerRentAmountCent + checkout.DepositDeductAmountCent
|
expectedOwnerIncome := order.OwnerRentAmountCent + checkout.ConsumableAmountCent + checkout.DepositDeductAmountCent
|
||||||
if settlement.OwnerIncomeCent != expectedOwnerIncome {
|
if settlement.OwnerIncomeCent != expectedOwnerIncome {
|
||||||
t.Fatalf("OwnerIncomeCent = %d, want %d",
|
t.Fatalf("OwnerIncomeCent = %d, want %d",
|
||||||
settlement.OwnerIncomeCent, expectedOwnerIncome)
|
settlement.OwnerIncomeCent, expectedOwnerIncome)
|
||||||
|
|||||||
@@ -528,3 +528,146 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,8 @@
|
|||||||
|
|
||||||
.record-meta {
|
.record-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 6px 12px;
|
gap: 8px 14px;
|
||||||
color: #52616f;
|
color: #52616f;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -178,8 +178,8 @@
|
|||||||
|
|
||||||
.record-meta {
|
.record-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 6px 12px;
|
gap: 8px 14px;
|
||||||
color: #52616f;
|
color: #52616f;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckoutSimulation } from '../composables/useCheckoutSimulator'
|
||||||
|
import { money } from '../composables/useOrderSnapshot'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
simulation: CheckoutSimulation
|
||||||
|
variant?: 'desktop' | 'mobile'
|
||||||
|
}>(),
|
||||||
|
{ variant: 'desktop' }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="props.simulation" :class="['trial-preview-container', props.variant]">
|
||||||
|
<!-- ───────────── 号主视角 ───────────── -->
|
||||||
|
<template v-if="props.simulation.role === 'owner'">
|
||||||
|
<div class="trial-header">
|
||||||
|
<span class="trial-title"> <i class="trial-icon">💰</i> 结账到手试算(仅您可见) </span>
|
||||||
|
<span v-if="props.simulation.isDepositSufficient" class="badge success">押金充足</span>
|
||||||
|
<span v-else class="badge danger">押金不足</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="trial-body">
|
||||||
|
<div class="main-metric">
|
||||||
|
<span class="label">预计最终到手总额</span>
|
||||||
|
<strong class="value income">¥{{ money(props.simulation.totalIncomeYuan) }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-breakdown">
|
||||||
|
<div class="breakdown-item">
|
||||||
|
<span>租金收益</span>
|
||||||
|
<span>¥{{ money(props.simulation.rentIncomeYuan) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.simulation.damageCompensationYuan > 0" class="breakdown-item highlight">
|
||||||
|
<span>损耗/损坏赔偿 (全额到手)</span>
|
||||||
|
<span class="text-green">+¥{{ money(props.simulation.damageCompensationYuan) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.simulation.isDepositSufficient" class="trial-note info">
|
||||||
|
<span>💡 租客押金充足,方案确认后款项将全额转入您的可用余额,平台 0 抽成。</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="trial-note warning">
|
||||||
|
<span
|
||||||
|
>⚠️ 租客押金不足差额 ¥{{
|
||||||
|
money(props.simulation.shortfallYuan)
|
||||||
|
}},无法自动完结,需协商调低金额或发起争议。</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ───────────── 租客视角 ───────────── -->
|
||||||
|
<template v-else-if="props.simulation.role === 'renter'">
|
||||||
|
<div class="trial-header">
|
||||||
|
<span class="trial-title"> <i class="trial-icon">🧾</i> 费用与退款试算(仅您可见) </span>
|
||||||
|
<span v-if="props.simulation.isDepositSufficient" class="badge info">正常结算</span>
|
||||||
|
<span v-else class="badge danger">需补差额</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="trial-body">
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<div class="main-metric">
|
||||||
|
<span class="label">实际租用消费</span>
|
||||||
|
<strong class="value">¥{{ money(props.simulation.actualCostYuan) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="main-metric">
|
||||||
|
<span class="label">预计原路退还</span>
|
||||||
|
<strong class="value refund">¥{{ money(props.simulation.totalRefundYuan) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-breakdown">
|
||||||
|
<div class="breakdown-item">
|
||||||
|
<span>实际使用租金</span>
|
||||||
|
<span>¥{{ money(props.simulation.buyerRentYuan) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.simulation.damageDeductYuan > 0" class="breakdown-item">
|
||||||
|
<span>损耗赔偿扣除 (从押金)</span>
|
||||||
|
<span class="text-danger">-¥{{ money(props.simulation.damageDeductYuan) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.simulation.rentRefundYuan > 0" class="breakdown-item">
|
||||||
|
<span>未用租金退还</span>
|
||||||
|
<span class="text-green">+¥{{ money(props.simulation.rentRefundYuan) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="breakdown-item">
|
||||||
|
<span>剩余押金退还</span>
|
||||||
|
<span class="text-green">+¥{{ money(props.simulation.depositRefundYuan) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.simulation.overshootYuan > 0" class="trial-note warning">
|
||||||
|
<span
|
||||||
|
>ℹ️ 哈夫币使用已打超预收,超出金额 ¥{{
|
||||||
|
money(props.simulation.overshootYuan)
|
||||||
|
}}
|
||||||
|
已自动从押金抵扣。</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-if="!props.simulation.isDepositSufficient" class="trial-note danger">
|
||||||
|
<span
|
||||||
|
>⚠️ 押金不足以抵扣打超与赔偿,差额 ¥{{
|
||||||
|
money(props.simulation.shortfallYuan)
|
||||||
|
}},无法自动同意。</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.trial-preview-container {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #334155;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-preview-container.mobile {
|
||||||
|
margin: 10px 12px 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-icon {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.success {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.info {
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #0369a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.danger {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-metric {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-metric .label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-metric .value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-metric .value.income {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-metric .value.refund {
|
||||||
|
color: #0284c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-breakdown {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-item.highlight {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-green {
|
||||||
|
color: #16a34a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-note.info {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #166534;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-note.warning {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-note.danger {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<Order | null>
|
||||||
|
isOwner: Ref<boolean>
|
||||||
|
isRenter: Ref<boolean>
|
||||||
|
form: Ref<CounterFormState>
|
||||||
|
}) {
|
||||||
|
return computed<CheckoutSimulation>(() => {
|
||||||
|
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<string, unknown> | undefined
|
||||||
|
const priceBreakdown =
|
||||||
|
((assetSummary?.price_breakdown || snapshot?.price_breakdown) as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { showDialog, showToast } from 'vant'
|
|||||||
|
|
||||||
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
|
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
|
||||||
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.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 { useOrderActions, CONFIRMABLE_ACTIONS } from '@/features/orders/composables/useOrderActions'
|
||||||
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
|
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
|
||||||
import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect'
|
import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect'
|
||||||
@@ -129,6 +131,13 @@ const showCheckoutCounterAction = computed(
|
|||||||
(isRenter.value && order.value.status === 'pending_checkout_accept'))
|
(isRenter.value && order.value.status === 'pending_checkout_accept'))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const checkoutSimulation = useCheckoutSimulator({
|
||||||
|
order,
|
||||||
|
isOwner,
|
||||||
|
isRenter,
|
||||||
|
form: counterForm,
|
||||||
|
})
|
||||||
|
|
||||||
// 状态颜色映射(vant 标签主题)。
|
// 状态颜色映射(vant 标签主题)。
|
||||||
function getStatusTagType(status: string) {
|
function getStatusTagType(status: string) {
|
||||||
if (['completed', 'received'].includes(status)) return 'success'
|
if (['completed', 'received'].includes(status)) return 'success'
|
||||||
@@ -505,7 +514,7 @@ async function copyListingCode() {
|
|||||||
}}。可填实际哈夫币消耗(允许超过发布量),打超按比例从押金扣。提案将交给对方确认。
|
}}。可填实际哈夫币消耗(允许超过发布量),打超按比例从押金扣。提案将交给对方确认。
|
||||||
</p>
|
</p>
|
||||||
<van-cell-group :border="false">
|
<van-cell-group :border="false">
|
||||||
<van-field label="额外消耗品已用">
|
<van-field label="物资/损耗金额">
|
||||||
<template #input>
|
<template #input>
|
||||||
<input
|
<input
|
||||||
v-model.number="counterForm.consumableAmountYuan"
|
v-model.number="counterForm.consumableAmountYuan"
|
||||||
@@ -533,6 +542,9 @@ async function copyListingCode() {
|
|||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
|
<!-- 实时结算试算预览(仅展示当前角色自身收支,完全屏蔽平台服务费) -->
|
||||||
|
<CheckoutTrialPreview :simulation="checkoutSimulation" variant="mobile" />
|
||||||
<van-field
|
<van-field
|
||||||
v-model="counterForm.reason"
|
v-model="counterForm.reason"
|
||||||
rows="2"
|
rows="2"
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
OrderHandoffTimeline,
|
OrderHandoffTimeline,
|
||||||
OrderResourceUsageEditor,
|
OrderResourceUsageEditor,
|
||||||
} from '@/features/orders'
|
} from '@/features/orders'
|
||||||
|
import CheckoutTrialPreview from '@/features/orders/components/CheckoutTrialPreview.vue'
|
||||||
|
import { useCheckoutSimulator } from '@/features/orders/composables/useCheckoutSimulator'
|
||||||
import { useOrderActions } from '@/features/orders/composables/useOrderActions'
|
import { useOrderActions } from '@/features/orders/composables/useOrderActions'
|
||||||
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
|
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
|
||||||
import { formatCent } from '@/shared/utils/money'
|
import { formatCent } from '@/shared/utils/money'
|
||||||
@@ -128,6 +130,13 @@ const checkingPayment = cashier.checkingPayment
|
|||||||
const paymentPayURL = cashier.payURL
|
const paymentPayURL = cashier.payURL
|
||||||
const activePayWayLabel = computedPayWayLabel(activePayment)
|
const activePayWayLabel = computedPayWayLabel(activePayment)
|
||||||
const handoffFocused = ref(false)
|
const handoffFocused = ref(false)
|
||||||
|
|
||||||
|
const checkoutSimulation = useCheckoutSimulator({
|
||||||
|
order,
|
||||||
|
isOwner,
|
||||||
|
isRenter,
|
||||||
|
form: counterForm,
|
||||||
|
})
|
||||||
const hasSidebarActions = computed(() => {
|
const hasSidebarActions = computed(() => {
|
||||||
if (!order.value) return false
|
if (!order.value) return false
|
||||||
return (
|
return (
|
||||||
@@ -532,7 +541,7 @@ watch([() => route.query.focus, order, loading], () => {
|
|||||||
</p>
|
</p>
|
||||||
<h3 class="sub-title">调整消耗与扣款</h3>
|
<h3 class="sub-title">调整消耗与扣款</h3>
|
||||||
<el-form class="form-grid" label-position="top">
|
<el-form class="form-grid" label-position="top">
|
||||||
<el-form-item label="额外消耗品已用金额">
|
<el-form-item label="物资/后置损耗金额(元)">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="counterForm.consumableAmountYuan"
|
v-model="counterForm.consumableAmountYuan"
|
||||||
class="full-control"
|
class="full-control"
|
||||||
@@ -560,6 +569,10 @@ watch([() => route.query.focus, order, loading], () => {
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 实时结算试算预览(仅展示当前角色自身收支,完全屏蔽平台服务费) -->
|
||||||
|
<CheckoutTrialPreview :simulation="checkoutSimulation" variant="desktop" />
|
||||||
|
|
||||||
<el-input
|
<el-input
|
||||||
v-model="counterForm.reason"
|
v-model="counterForm.reason"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
|
|||||||
Reference in New Issue
Block a user