完善订单交接结账流程留痕

This commit is contained in:
yml2213
2026-08-28 23:48:32 +08:00
parent a9a9127076
commit c87883cc65
27 changed files with 915 additions and 5 deletions
@@ -63,6 +63,25 @@ export interface AdminPickupFinancialAdjustment {
created_at: string
}
export interface AdminPickupProcessEvent {
id: number
stage: string
action: string
actor_type: string
actor_id: number
actor_name: string
target_type: string
target_id?: number
target_name: string
content: string
reason: string
payload: Record<string, unknown>
attachment_urls: string[]
state_before: Record<string, unknown>
state_after: Record<string, unknown>
created_at: string
}
export interface AvailableListing {
id: number
listing_no: string
@@ -226,6 +245,13 @@ export async function fetchAdminPickupFinancialAdjustments(id: string | number)
return Array.isArray(data.data) ? data.data : []
}
export async function fetchAdminPickupProcessEvents(id: string | number) {
const { data } = await apiClient.get<ApiResponse<{ items: AdminPickupProcessEvent[] }>>(
`/admin/pickups/${id}/process-events`
)
return Array.isArray(data.data?.items) ? data.data.items : []
}
export async function settleAdminPickupFinancialAdjustment(
id: number,
req: AdminPickupFinancialAdjustmentSettlementRequest = {}
@@ -0,0 +1,225 @@
<script setup lang="ts">
import { formatCentWithSymbol } from '@/shared/utils/money'
import { formatDateTime } from '@/shared/utils/time'
export interface ProcessTimelineEvent {
id: number
stage: string
action: string
actor_type: string
actor_id: number
actor_name: string
target_type?: string
target_id?: number
target_name?: string
content?: string
reason?: string
payload?: Record<string, unknown>
attachment_urls?: string[]
state_before?: Record<string, unknown>
state_after?: Record<string, unknown>
created_at: string
}
const props = defineProps<{ events: ProcessTimelineEvent[]; emptyText?: string }>()
const actionLabels: Record<string, string> = {
owner_handoff_submitted: '号主提交交接', platform_handoff_submitted: '客服代交接',
admin_force_handoff: '客服确认交接', renter_received_confirmed: '租客确认收号',
checkout_submitted: '租客发起结账', checkout_countered: '修改结账方案',
checkout_confirmed: '号主确认结账', checkout_accepted: '租客接受结账方案',
platform_checkout_countered: '客服修改结账方案', platform_checkout_confirmed: '客服确认结账',
platform_checkout_dispute_opened: '客服发起结账争议', offline_settlement_confirmed: '确认线下结算',
deposit_held: '暂扣押金', deposit_released: '归还暂扣押金', pickup_created: '创建提号',
pickup_completed: '完成提号', pickup_profit_updated: '修改提号利润',
pickup_financial_adjusted: '创建财务调整', pickup_adjustment_settled: '确认财务调整',
pickup_offline_settlement_confirmed: '确认提号线下结算', pickup_cancelled: '取消提号',
}
const stageLabels: Record<string, string> = {
handoff: '交接', checkout: '结账', settlement: '结算', dispute: '争议', pickup: '提号',
}
const payloadLabels: Record<string, string> = {
checkout_id: '结账单', round: '协商轮次', turn: '等待确认方', proposed_by: '方案提出人',
rent_amount_cent: '实际结算租金', owner_rent_amount_cent: '号主租金', platform_fee_cent: '平台费用',
deposit_amount_cent: '押金', consumable_amount_cent: '消耗品金额', coin_consumed_m: '哈夫币消耗',
deposit_deduct_amount_cent: '押金赔付扣除', renter_refund_amount_cent: '退还租客',
owner_income_amount_cent: '号主最终收入', shortfall_cent: '押金不足差额', overshoot_amount_cent: '打超金额',
offline_settlement_amount_cent: '线下结算金额', deposit_hold_amount_cent: '暂扣金额',
profit_amount_cent: '利润金额', settle_amount_cent: '结算给号主', profit_delta_cent: '利润调整',
settle_delta_cent: '结算调整', settlement_mode: '结算方式', account_source: '账号来源', source_channel: '来源渠道',
}
const primaryPayloadKeys = new Set([
'round', 'turn', 'rent_amount_cent', 'renter_refund_amount_cent', 'owner_income_amount_cent',
'offline_settlement_amount_cent', 'profit_amount_cent', 'settle_amount_cent',
])
const stateLabels: Record<string, Record<string, string>> = {
order_status: {
pending_payment: '待支付', pending_handoff: '待交接', renting: '租用中', overdue: '已逾期',
pending_checkout_confirm: '待号主确认结账', pending_checkout_accept: '待租客确认结账',
completed: '已完成', cancelled: '已取消', closed: '已关闭', abnormal: '异常',
},
handoff_status: {
none: '未开始', pending_owner: '待号主交接', pending_renter_confirm: '待租客确认收号',
received: '租客已收号', pending_owner_checkout: '待号主确认结账',
pending_renter_checkout: '待租客确认结账', returned: '已归还',
},
settlement_status: { unsettled: '未结算', pending: '结算待确认', settled: '已结算', arbitrated: '仲裁结算' },
offline_settlement_status: { none: '无需线下结算', pending: '待线下结算', settled: '已线下结算' },
pickup_status: { pending: '待处理', processing: '处理中', completed: '已完成', cancelled: '已取消' },
}
const stateFieldLabels: Record<string, string> = {
order_status: '订单', handoff_status: '交接', settlement_status: '结算',
offline_settlement_status: '线下结算', pickup_status: '提号',
}
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<string, unknown>) {
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 === 'turn') return value === 'owner' ? '号主' : value === 'renter' ? '租客' : String(value)
if (key === 'account_source') return value === 'external' ? '外部上传' : '站内上传'
if (key === 'settlement_mode') return value === 'platform_managed' ? '平台线下结算' : '号主钱包结算'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
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 || {}
return Object.keys(stateFieldLabels)
.filter(key => before[key] !== undefined && before[key] !== after[key])
.map(key => ({ label: stateFieldLabels[key], before: stateValue(key, before[key]), after: stateValue(key, after[key]) }))
}
function hasMore(item: ProcessTimelineEvent) {
return detailPayloadRows(item).length > 0 || stateChanges(item).length > 0
}
</script>
<template>
<el-empty v-if="props.events.length === 0" :description="props.emptyText || '暂无过程记录'" />
<div v-else class="process-timeline">
<article v-for="item in props.events" :key="item.id" class="process-item">
<span class="process-dot"></span>
<header class="process-heading">
<div class="heading-main">
<span class="stage-badge">{{ stageLabel(item.stage) }}</span>
<strong>{{ actionLabel(item.action) }}</strong>
</div>
<time>{{ formatDateTime(item.created_at) }}</time>
</header>
<div class="process-people">
<span><b>{{ actorRole(item) }}</b>{{ actorLabel(item) }}</span>
<span v-if="item.target_name" class="target-text">通知/关联{{ item.target_name }}</span>
</div>
<p v-if="item.content" class="process-content">{{ item.content }}</p>
<p v-if="item.reason" class="process-reason"><b>备注/原因</b>{{ item.reason }}</p>
<div v-if="primaryPayloadRows(item).length" class="process-summary">
<div v-for="[key, value] in primaryPayloadRows(item)" :key="key">
<span>{{ payloadLabel(key) }}</span>
<strong>{{ payloadValue(key, value) }}</strong>
</div>
</div>
<details v-if="hasMore(item)" class="process-details">
<summary>查看完整明细与状态变化</summary>
<div v-if="detailPayloadRows(item).length" class="process-payload">
<div v-for="[key, value] in detailPayloadRows(item)" :key="key">
<span>{{ payloadLabel(key) }}</span>
<strong>{{ payloadValue(key, value) }}</strong>
</div>
</div>
<div v-if="stateChanges(item).length" class="process-state">
<b>状态变化</b>
<span v-for="change in stateChanges(item)" :key="change.label">
{{ change.label }}{{ change.before }} <i></i> {{ change.after }}
</span>
</div>
</details>
<div v-if="item.attachment_urls?.length" class="process-attachments">
<el-image
v-for="url in item.attachment_urls"
:key="url"
:src="url"
:preview-src-list="item.attachment_urls"
preview-teleported
fit="cover"
/>
</div>
</article>
</div>
</template>
<style scoped>
.process-timeline { display: grid; gap: 12px; padding-left: 4px; }
.process-item { position: relative; padding: 14px 16px 14px 22px; border: 1px solid #e5eaf2; border-radius: 10px; background: #fff; box-shadow: 0 1px 2px rgb(15 23 42 / 2%); }
.process-item::before { content: ''; position: absolute; top: -13px; bottom: calc(100% - 1px); left: -1px; width: 1px; background: #dce5f0; }
.process-item:first-child::before { display: none; }
.process-dot { position: absolute; left: -5px; top: 21px; width: 9px; height: 9px; border: 2px solid #fff; border-radius: 50%; background: #ff6a00; box-shadow: 0 0 0 1px #f5a561; }
.process-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.heading-main { display: flex; align-items: center; gap: 8px; min-width: 0; }
.heading-main strong { color: #182232; font-size: 15px; }
.stage-badge { flex: none; padding: 2px 7px; border-radius: 4px; background: #fff3e8; color: #d85d00; font-size: 12px; font-weight: 600; }
.process-heading time { flex: none; color: #94a3b8; font-size: 12px; white-space: nowrap; }
.process-people { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 8px; color: #64748b; font-size: 12px; }
.process-people b { margin-right: 6px; color: #475569; font-weight: 600; }
.target-text { color: #718096; }
.process-content, .process-reason { margin: 10px 0 0; color: #334155; line-height: 1.65; white-space: pre-wrap; }
.process-reason { padding: 8px 10px; border-radius: 6px; background: #fff9ed; color: #9a5b13; }
.process-reason b { margin-right: 8px; }
.process-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 11px; }
.process-summary div { display: flex; align-items: baseline; gap: 7px; padding: 7px 10px; border: 1px solid #e6edf5; border-radius: 6px; background: #f8fafc; }
.process-summary span { color: #64748b; font-size: 12px; }
.process-summary strong { color: #1e293b; font-size: 13px; }
.process-details { margin-top: 10px; }
.process-details summary { width: fit-content; color: #477fc1; font-size: 12px; cursor: pointer; user-select: none; }
.process-details[open] summary { margin-bottom: 9px; }
.process-payload { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 7px 12px; padding: 10px; border-radius: 7px; background: #f4f7fb; }
.process-payload div { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; }
.process-payload span { color: #64748b; }
.process-payload strong { color: #1e293b; text-align: right; word-break: break-word; }
.process-state { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 9px; color: #64748b; font-size: 12px; }
.process-state b { color: #475569; }
.process-state span { padding-left: 10px; border-left: 1px solid #dbe4ee; }
.process-state i { color: #94a3b8; font-style: normal; }
.process-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.process-attachments :deep(.el-image) { width: 76px; height: 76px; border: 1px solid #e2e8f0; border-radius: 6px; }
@media (max-width: 700px) {
.process-item { padding: 13px 12px 13px 18px; }
.process-heading { align-items: flex-start; flex-direction: column; gap: 5px; }
.process-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.process-summary div { min-width: 0; flex-direction: column; gap: 2px; }
.process-payload { grid-template-columns: 1fr; }
}
</style>
@@ -20,9 +20,11 @@ import {
adminResetHandoff,
adminSealOrder,
fetchAdminHandoffRecords,
fetchAdminOrderProcessEvents,
fetchAdminOrder,
type HandoffRecord,
type Order,
type ProcessEvent,
type RefundStatus,
} from '@/features/orders'
import { adminCreateOrderDispute } from '@/features/disputes'
@@ -44,6 +46,7 @@ import {
} from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
import { formatGameName, formatListingNo } from '@/shared/utils/listingDisplay'
import ProcessTimeline from '../components/ProcessTimeline.vue'
const route = useRoute()
const router = useRouter()
@@ -51,6 +54,7 @@ const loading = ref(false)
const submitting = ref(false)
const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const processEvents = ref<ProcessEvent[]>([])
const paymentRecords = ref<AdminPayment[]>([])
type OrderActionType =
| 'close'
@@ -355,8 +359,14 @@ async function loadOrder() {
loading.value = true
try {
order.value = await fetchAdminOrder(String(route.params.id))
handoffRecords.value = await fetchAdminHandoffRecords(String(route.params.id))
paymentRecords.value = (await fetchAdminPayments({ order_id: String(route.params.id) })).items
const [handoffs, events, payments] = await Promise.all([
fetchAdminHandoffRecords(String(route.params.id)),
fetchAdminOrderProcessEvents(String(route.params.id)),
fetchAdminPayments({ order_id: String(route.params.id) }),
])
handoffRecords.value = handoffs
processEvents.value = events
paymentRecords.value = payments.items
await loadRefundStatus()
} finally {
loading.value = false
@@ -783,6 +793,10 @@ function settlementModeLabel(mode?: string) {
return map[mode || 'owner_wallet'] || mode || '-'
}
function accountSourceLabel(source?: string) {
return source === 'external' ? '外部上传' : '站内上传'
}
function offlineSettlementStatusLabel(status?: string) {
const map: Record<string, string> = {
none: '无需线下结算',
@@ -1132,6 +1146,34 @@ function paymentPaidAt(record: AdminPayment) {
</dl>
</section>
<section class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>账号来源与处理方式</h2>
</div>
<dl class="detail-list">
<div>
<dt>账号来源</dt>
<dd>{{ accountSourceLabel(order.account_source) }}</dd>
</div>
<div v-if="order.source_channel">
<dt>外部渠道</dt>
<dd>{{ order.source_channel }}</dd>
</div>
<div>
<dt>交接责任</dt>
<dd>{{ handoffModeLabel(order.handoff_mode) }}</dd>
</div>
<div>
<dt>结算方式</dt>
<dd>{{ settlementModeLabel(order.settlement_mode) }}</dd>
</div>
<div v-if="order.managed_admin_id">
<dt>负责客服</dt>
<dd>ID {{ order.managed_admin_id }}</dd>
</div>
</dl>
</section>
<section v-if="isPlatformManaged" class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>平台代管</h2>
@@ -1373,6 +1415,17 @@ function paymentPaidAt(record: AdminPayment) {
</div>
</section>
<section class="dashboard-panel detail-panel order-wide-panel">
<div class="panel-heading">
<h2>交易全流程</h2>
<span class="panel-subtitle">每次提交修改确认及结算均单独留痕</span>
</div>
<ProcessTimeline
:events="processEvents"
empty-text="暂无新版过程记录旧订单仍可查看下方原始交接记录"
/>
</section>
<section class="dashboard-panel detail-panel order-wide-panel">
<div class="panel-heading">
<h2>交接记录</h2>
@@ -7,11 +7,13 @@ import { useRoute } from 'vue-router'
import {
fetchAdminPickup,
fetchAdminPickupFinancialAdjustments,
fetchAdminPickupProcessEvents,
createAdminPickupFinancialAdjustment,
settleAdminPickupFinancialAdjustment,
updateAdminPickupProfit,
type AdminPickup,
type AdminPickupFinancialAdjustment,
type AdminPickupProcessEvent,
} from '@/features/admin/api/adminPickup'
import { quantity, readNumber, readUnitPrice } from '@/features/orders/composables/useOrderSnapshot'
import { adminPath } from '@/shared/utils/adminPath'
@@ -24,6 +26,7 @@ import {
} from '@/shared/utils/money'
import { pickupStatusLabel } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
import ProcessTimeline from '../components/ProcessTimeline.vue'
interface SnapshotResource {
key: string
@@ -38,6 +41,7 @@ const route = useRoute()
const loading = ref(false)
const pickup = ref<AdminPickup | null>(null)
const financialAdjustments = ref<AdminPickupFinancialAdjustment[]>([])
const processEvents = ref<AdminPickupProcessEvent[]>([])
const profitDialogVisible = ref(false)
const financialAdjustmentDialogVisible = ref(false)
const profitSaving = ref(false)
@@ -117,12 +121,14 @@ onMounted(loadPickup)
async function loadPickup() {
loading.value = true
try {
const [item, adjustments] = await Promise.all([
const [item, adjustments, events] = await Promise.all([
fetchAdminPickup(String(route.params.id)),
fetchAdminPickupFinancialAdjustments(String(route.params.id)),
fetchAdminPickupProcessEvents(String(route.params.id)),
])
pickup.value = item
financialAdjustments.value = adjustments
processEvents.value = events
} finally {
loading.value = false
}
@@ -467,6 +473,17 @@ function readSnapshotResources(summary: Record<string, unknown> | null): Snapsho
</dl>
</section>
<section class="dashboard-panel detail-panel pickup-wide-panel">
<div class="panel-heading">
<h2>提号全流程</h2>
<span class="panel-subtitle">创建完成调整及线下结算均单独留痕</span>
</div>
<ProcessTimeline
:events="processEvents"
empty-text="暂无新版过程记录可先查看下方备注和财务调整记录"
/>
</section>
<section
v-if="financialAdjustments.length"
class="dashboard-panel detail-panel pickup-wide-panel"
@@ -46,6 +46,8 @@ export interface Order {
growth_points_awarded?: number
growth_points_awarded_at?: string
account_snapshot?: Record<string, unknown>
account_source?: 'internal' | 'external' | string
source_channel?: string
listing_snapshot?: string
checkout_info?: string
counter_info?: string
@@ -149,6 +151,27 @@ export interface HandoffRecord {
created_at: string
}
export interface ProcessEvent {
id: number
business_type: string
business_id: number
stage: string
action: string
actor_type: 'user' | 'admin' | 'system' | string
actor_id: number
actor_name: string
target_type: string
target_id?: number
target_name: string
content: string
reason: string
payload: Record<string, unknown>
attachment_urls: string[]
state_before: Record<string, unknown>
state_after: Record<string, unknown>
created_at: string
}
export interface PaymentOrder {
id: number
payment_no: string
@@ -288,6 +311,13 @@ export async function fetchHandoffRecords(id: string | number) {
return Array.isArray(data.data?.items) ? data.data.items : []
}
export async function fetchAdminOrderProcessEvents(id: string | number) {
const { data } = await apiClient.get<ApiResponse<{ items: ProcessEvent[] }>>(
`/admin/orders/${id}/process-events`
)
return Array.isArray(data.data?.items) ? data.data.items : []
}
export async function confirmReceive(id: number) {
const { data } = await apiClient.post<ApiResponse<{ confirmed: boolean }>>(
`/orders/${id}/confirm-receive`