diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index e7424c2..c7b9164 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -336,16 +336,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 5d2dd05..0d750ac 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -417,8 +417,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 0a6be77..afd7c82 100644 --- a/backend/internal/modules/order/repository_test.go +++ b/backend/internal/modules/order/repository_test.go @@ -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) + } +} + + 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..4ea56d5 100644 --- a/frontend/src/features/admin/components/ProcessTimeline.vue +++ b/frontend/src/features/admin/components/ProcessTimeline.vue @@ -56,14 +56,14 @@ const stageLabels: Record = { } const payloadLabels: Record = { - checkout_id: '结账单', + checkout_id: '结账单ID', round: '协商轮次', turn: '等待确认方', proposed_by: '方案提出人', rent_amount_cent: '实际结算租金', - owner_rent_amount_cent: '号主租金', - platform_fee_cent: '平台费用', - deposit_amount_cent: '押金', + owner_rent_amount_cent: '号主基础租金', + platform_fee_cent: '平台服务费', + deposit_amount_cent: '订单押金', consumable_amount_cent: '消耗品金额', coin_consumed_m: '哈夫币消耗', deposit_deduct_amount_cent: '押金赔付扣除', @@ -71,7 +71,7 @@ const payloadLabels: Record = { owner_income_amount_cent: '号主最终收入', shortfall_cent: '押金不足差额', overshoot_amount_cent: '打超金额', - offline_settlement_amount_cent: '线下结算金额', + offline_settlement_amount_cent: '线下待结算', deposit_hold_amount_cent: '暂扣金额', profit_amount_cent: '利润金额', settle_amount_cent: '结算给号主', @@ -131,46 +131,127 @@ const stateLabels: Record> = { } const stateFieldLabels: Record = { - order_status: '订单', - handoff_status: '交接', - settlement_status: '结算', + order_status: '订单状态', + handoff_status: '交接状态', + settlement_status: '结算状态', offline_settlement_status: '线下结算', - pickup_status: '提号', + pickup_status: '提号状态', } +interface PayloadItem { + key: string + label: string + value: string + variant: 'income' | 'refund' | 'fee' | 'deduct' | 'primary' | 'neutral' +} + +interface PayloadCategory { + id: string + title: string + icon: string + items: PayloadItem[] +} + +const CATEGORY_DEFINITIONS: { + id: string + title: string + icon: string + keys: string[] +}[] = [ + { + id: 'financial', + title: '资金结算明细', + icon: '💰', + keys: [ + 'owner_rent_amount_cent', + 'platform_fee_cent', + 'owner_income_amount_cent', + 'renter_refund_amount_cent', + 'rent_amount_cent', + 'offline_settlement_amount_cent', + 'profit_amount_cent', + 'settle_amount_cent', + 'profit_delta_cent', + 'settle_delta_cent', + ], + }, + { + id: 'deposit', + title: '押金与赔付扣除', + icon: '🛡️', + keys: [ + 'deposit_amount_cent', + 'deposit_deduct_amount_cent', + 'overshoot_amount_cent', + 'shortfall_cent', + 'deposit_hold_amount_cent', + ], + }, + { + id: 'gameplay', + title: '消耗与游戏指标', + icon: '🎮', + keys: ['coin_consumed_m', 'consumable_amount_cent'], + }, + { + id: 'meta', + title: '流程单据与属性', + icon: '📋', + keys: [ + 'checkout_id', + 'round', + 'turn', + 'proposed_by', + 'settlement_mode', + 'account_source', + 'source_channel', + ], + }, +] + function actionLabel(action: string) { return actionLabels[action] || action } + function stageLabel(stage: string) { return stageLabels[stage] || '流程' } + function actorLabel(item: ProcessTimelineEvent) { if (item.actor_name) return item.actor_name if (item.actor_type === 'system') return '系统' return `${item.actor_type === 'admin' ? '客服' : '用户'} ID ${item.actor_id}` } + function actorRole(item: ProcessTimelineEvent) { if (item.actor_type === 'admin') return '客服操作' if (item.actor_type === 'system') return '系统处理' return '用户操作' } + function payloadRows(payload?: Record) { if (!payload || typeof payload !== 'object') return [] return Object.entries(payload).filter( ([, value]) => value !== null && value !== undefined && value !== '' ) } + function primaryPayloadRows(item: ProcessTimelineEvent) { return payloadRows(item.payload).filter(([key]) => primaryPayloadKeys.has(key)) } + function detailPayloadRows(item: ProcessTimelineEvent) { return payloadRows(item.payload).filter(([key]) => !primaryPayloadKeys.has(key)) } + function payloadLabel(key: string) { return payloadLabels[key] || key } + function payloadValue(key: string, value: unknown) { if (key.endsWith('_cent')) return formatCentWithSymbol(Number(value || 0)) + if (key === 'coin_consumed_m') return `${value} M` + if (key === 'round') return `第 ${value} 轮` if (key === 'turn') return value === 'owner' ? '号主' : value === 'renter' ? '租客' : String(value) if (key === 'account_source') return value === 'external' ? '外部上传' : '站内上传' @@ -179,10 +260,135 @@ function payloadValue(key: string, value: unknown) { if (typeof value === 'object') return JSON.stringify(value) return String(value) } + +function itemVariant(key: string): PayloadItem['variant'] { + if ( + key === 'owner_income_amount_cent' || + key === 'profit_amount_cent' || + key === 'settle_amount_cent' + ) { + return 'income' + } + if (key === 'renter_refund_amount_cent') { + return 'refund' + } + if (key === 'platform_fee_cent') { + return 'fee' + } + if ( + key === 'deposit_deduct_amount_cent' || + key === 'overshoot_amount_cent' || + key === 'shortfall_cent' || + key === 'deposit_hold_amount_cent' + ) { + return 'deduct' + } + if (key === 'rent_amount_cent' || key === 'deposit_amount_cent') { + return 'primary' + } + return 'neutral' +} + +function categorizedPayload(item: ProcessTimelineEvent): PayloadCategory[] { + const detailRows = detailPayloadRows(item) + if (detailRows.length === 0) return [] + + const rowMap = new Map(detailRows) + const usedKeys = new Set() + const result: PayloadCategory[] = [] + + for (const cat of CATEGORY_DEFINITIONS) { + const items: PayloadItem[] = [] + for (const k of cat.keys) { + if (rowMap.has(k)) { + usedKeys.add(k) + items.push({ + key: k, + label: payloadLabel(k), + value: payloadValue(k, rowMap.get(k)), + variant: itemVariant(k), + }) + } + } + if (items.length > 0) { + result.push({ + id: cat.id, + title: cat.title, + icon: cat.icon, + items, + }) + } + } + + const remainingKeys = detailRows.filter(([k]) => !usedKeys.has(k)) + if (remainingKeys.length > 0) { + const gameplayItems: PayloadItem[] = [] + const metaItems: PayloadItem[] = [] + + for (const [k, v] of remainingKeys) { + const isMoney = k.endsWith('_cent') + const pItem: PayloadItem = { + key: k, + label: payloadLabel(k), + value: payloadValue(k, v), + variant: itemVariant(k), + } + if (isMoney) { + const finCat = result.find(c => c.id === 'financial') + if (finCat) { + finCat.items.push(pItem) + continue + } + } + if ( + typeof v === 'number' || + k.includes('kill') || + k.includes('count') || + k.includes('coin') + ) { + gameplayItems.push(pItem) + } else { + metaItems.push(pItem) + } + } + + if (gameplayItems.length > 0) { + const existing = result.find(c => c.id === 'gameplay') + if (existing) { + existing.items.push(...gameplayItems) + } else { + result.push({ + id: 'gameplay', + title: '消耗与游戏指标', + icon: '🎮', + items: gameplayItems, + }) + } + } + + if (metaItems.length > 0) { + const existing = result.find(c => c.id === 'meta') + if (existing) { + existing.items.push(...metaItems) + } else { + result.push({ + id: 'meta', + title: '其他属性与单据', + icon: '📋', + items: metaItems, + }) + } + } + } + + return result +} + function stateValue(key: string, value: unknown) { const raw = String(value || '') return stateLabels[key]?.[raw] || raw || '-' } + function stateChanges(item: ProcessTimelineEvent) { const before = item.state_before || {} const after = item.state_after || {} @@ -194,6 +400,27 @@ function stateChanges(item: ProcessTimelineEvent) { after: stateValue(key, after[key]), })) } + +function stateToneClass(val: string) { + if ( + ['已完成', '已结算', '租客已收号', '已归还', '处理完成', '已线下结算'].some(s => + val.includes(s) + ) + ) { + return 'tone-success' + } + if (['租用中', '处理中'].some(s => val.includes(s))) { + return 'tone-primary' + } + if (['待', '等待', '未结算', '未开始'].some(s => val.includes(s))) { + return 'tone-warning' + } + if (['已取消', '已关闭', '异常', '争议'].some(s => val.includes(s))) { + return 'tone-danger' + } + return 'tone-neutral' +} + function hasMore(item: ProcessTimelineEvent) { return detailPayloadRows(item).length > 0 || stateChanges(item).length > 0 } @@ -202,46 +429,108 @@ function hasMore(item: ProcessTimelineEvent) {