Files
admin-web/src/modules/inbounds/fee/InboundFeePanel.vue
T
2026-09-21 11:17:11 +08:00

729 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Check, CircleDollarSign, PencilLine, RefreshCw, Send } from '@lucide/vue'
import { firstLegBusinessTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import {
ApiResponseError,
getApiErrorDetails,
type ApiErrorDetails,
} from '@/shared/api/http'
import {
adjustPublishedInboundFee,
calculateInboundFee,
confirmInboundFee,
createFeeActionIdempotencyKey,
createFeeAdjustmentIdempotencyKey,
getLatestInboundFee,
sendInboundFee,
} from './api'
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
import { getInboundMeasurement } from '../measurement/api'
import type { InboundMeasurementDetail } from '../measurement/types'
const props = defineProps<{
customerId?: string
tenantId?: string
inboundId: string
compact?: boolean
displayOnly?: boolean
}>()
const emit = defineEmits<{
changed: [snapshot: InboundFeeSnapshot]
loaded: [snapshot: InboundFeeSnapshot | null]
}>()
const access = useAccessPreviewStore()
const loading = ref(false)
const adjusting = ref(false)
const feeAction = ref<'confirm' | 'send' | null>(null)
const notFound = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const adjustmentError = ref<ApiErrorDetails | null>(null)
const conflictRefreshed = ref(false)
const snapshot = ref<InboundFeeSnapshot | null>(null)
const hasLoadedOnce = ref(false)
const adjustOpen = ref(false)
const trialOpen = ref(false)
const trialLoading = ref(false)
const measurement = ref<InboundMeasurementDetail | null>(null)
const trial = reactive({ weightSource: 'ACTUAL', volumeSource: 'ACTUAL' })
const adjustFormRef = ref<FormInstance>()
let loadSequence = 0
let adjustmentFingerprint = ''
let adjustmentIdempotencyKey = ''
const adjustmentForm = reactive({
expectedFeeVersion: 0,
chargeableQuantity: 0,
unitPrice: 0,
additionalAmount: 0,
reason: '',
})
const adjustmentRules: FormRules = {
chargeableQuantity: [{ required: true, message: '请输入调整后的计费量', trigger: 'change' }],
unitPrice: [{ required: true, message: '请输入调整后的单价', trigger: 'change' }],
additionalAmount: [{ required: true, message: '请输入调整后的附加费', trigger: 'change' }],
reason: [
{ required: true, whitespace: true, message: '请填写费用调整原因', trigger: 'blur' },
{ min: 2, max: 500, message: '调整原因需为 2–500 个字符', trigger: 'blur' },
],
}
const canView = computed(() => access.can('firstleg:fba-pricing:view'))
const canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust'))
const canConfirm = computed(() => access.can('firstleg:fba-pricing:confirm'))
const canSend = computed(() => access.can('firstleg:inbound:send-fee'))
const canCalculate = computed(() => access.can('firstleg:fba-pricing:calculate'))
const customerContextId = computed(() => props.customerId || props.tenantId || '')
const normalizedInboundId = computed(() => {
const identifier = String(props.inboundId ?? '').trim()
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
})
const adjustmentEligible = computed(() => (
['CALCULATED', 'CONFIRMED', 'SENT'].includes(snapshot.value?.feeStatus || '')
&& snapshot.value?.nextProcessStatus === 'WAIT_SPLIT'
&& snapshot.value?.customerConfirmationStatus !== 'CONFIRMED'
))
const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费')
const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM')
const convertedQuantityLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '体积重量' : '重量折算体积')
const adjustmentBaseline = computed(() => Number(snapshot.value?.baselineChargeableQuantity || 0))
const adjustmentMinimum = computed(() => Number(snapshot.value?.lowerChargeableQuantity || 0))
const adjustmentMaximum = computed(() => Number(snapshot.value?.upperChargeableQuantity || 0))
const adjustmentQuantityChanged = computed(() => Math.abs(Number(adjustmentForm.chargeableQuantity) - adjustmentBaseline.value) > 1e-8)
const statusInfo = computed(() => feeStatusInfo(snapshot.value?.feeStatus))
const sourceOptions = computed(() => {
const value = measurement.value
if (!value) return []
const itemWeight = value.items.reduce((sum, item) => sum + Number(item.totalWeightKg || 0), 0)
const itemVolume = value.items.reduce((sum, item) => sum + Number(item.totalVolumeCbm || 0), 0)
return [
{ value: 'CUSTOMER', label: '客户下单数据', weight: Number(value.originalGrossWeightKg || 0), volume: Number(value.originalVolumeCbm || 0) },
{ value: 'OUTER', label: '外箱规数据', weight: itemWeight, volume: itemVolume },
{ value: 'ACTUAL', label: '实际测量数据', weight: Number(value.actualNetWeightKg || value.actualGrossWeightKg || 0), volume: Number(value.actualVolumeCbm || 0) },
{ value: 'WAREHOUSE', label: '仓库建议数据', weight: itemWeight, volume: itemVolume },
]
})
function feeStatusInfo(status?: string) {
const map: Record<string, { label: string; type: 'success' | 'warning' | 'info' }> = {
CALCULATED: { label: '已试算', type: 'warning' },
CONFIRMED: { label: '已确认', type: 'info' },
SENT: { label: '已发布', type: 'success' },
SUPERSEDED: { label: '历史版本', type: 'info' },
}
return map[status || ''] || { label: '未知状态', type: 'info' as const }
}
function formatNumber(value: number | null | undefined, digits = 3) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits }).format(Number(value))
}
function formatMoney(value: number | null | undefined, currency?: string) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
try {
return new Intl.NumberFormat('zh-CN', {
style: 'currency', currency: currency || 'CNY', minimumFractionDigits: 2,
}).format(Number(value))
} catch {
return `${formatNumber(value, 2)} ${currency || ''}`.trim()
}
}
function formatDateTime(value: string | null | undefined) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value.replace('T', ' ')
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).format(date)
}
function taxModeLabel(value: string) {
return firstLegTaxModeLabel(value)
}
function pricingTaxModeLabel(value: string | null | undefined) {
if (value === 'TAX_INCLUDED') return '包税'
if (value === 'TAX_EXCLUDED') return '不包税'
return '—'
}
function roundingModeLabel(value: string) {
const labels: Record<string, string> = {
UP: '远离零方向', DOWN: '向零方向', CEILING: '向上', FLOOR: '向下',
HALF_UP: '四舍五入', HALF_DOWN: '五舍六入', HALF_EVEN: '银行家舍入',
}
return labels[value] || '未知规则'
}
async function loadFee(options: { preserveAdjustment?: boolean } = {}) {
const sequence = ++loadSequence
loadError.value = null
notFound.value = false
if (!customerContextId.value || !normalizedInboundId.value || !canView.value) {
snapshot.value = null
loading.value = false
return
}
loading.value = true
try {
const result = await getLatestInboundFee(customerContextId.value, normalizedInboundId.value)
if (sequence !== loadSequence) return
snapshot.value = result
emit('loaded', result)
if (!options.preserveAdjustment && adjustOpen.value) fillAdjustmentForm(result)
} catch (error) {
if (sequence !== loadSequence) return
const hadSnapshot = Boolean(snapshot.value)
if (error instanceof ApiResponseError && error.code === 'A0404') {
notFound.value = !hadSnapshot
if (!hadSnapshot) {
snapshot.value = null
emit('loaded', null)
}
} else {
loadError.value = getApiErrorDetails(error, '费用快照加载失败,请稍后重试')
if (!hadSnapshot) {
snapshot.value = null
emit('loaded', null)
}
}
} finally {
if (sequence === loadSequence) {
loading.value = false
hasLoadedOnce.value = true
}
}
}
function fillAdjustmentForm(value: InboundFeeSnapshot) {
adjustmentForm.expectedFeeVersion = Number(value.feeVersion)
adjustmentForm.chargeableQuantity = Number(value.chargeableQuantity)
adjustmentForm.unitPrice = Number(value.unitPrice)
adjustmentForm.additionalAmount = Number(value.additionalAmount)
adjustmentForm.reason = ''
adjustmentError.value = null
conflictRefreshed.value = false
adjustmentFingerprint = ''
adjustmentIdempotencyKey = ''
}
function openAdjustment() {
if (!snapshot.value || !adjustmentEligible.value) return
fillAdjustmentForm(snapshot.value)
adjustOpen.value = true
}
async function openTrial() {
trialLoading.value = true
try { measurement.value = await getInboundMeasurement(customerContextId.value, normalizedInboundId.value); trial.weightSource = 'ACTUAL'; trial.volumeSource = 'ACTUAL'; trialOpen.value = true }
catch (error) { ElMessage.error(getApiErrorDetails(error, '测量数据加载失败,无法试算').message) }
finally { trialLoading.value = false }
}
async function submitTrial() {
const weight = sourceOptions.value.find((item) => item.value === trial.weightSource)
const volume = sourceOptions.value.find((item) => item.value === trial.volumeSource)
if (!weight?.weight || !volume?.volume) { ElMessage.warning('所选重量或体积没有有效数据'); return }
trialLoading.value = true
try {
const result = await calculateInboundFee(customerContextId.value, normalizedInboundId.value, { selectedWeightKg: weight.weight, selectedVolumeCbm: volume.volume, weightSource: weight.value, volumeSource: volume.value }, createFeeActionIdempotencyKey('calculate'))
snapshot.value = result; trialOpen.value = false; emit('changed', result); ElMessage.success('费用试算已更新')
} catch (error) { adjustmentError.value = getApiErrorDetails(error, '费用试算失败') }
finally { trialLoading.value = false }
}
async function confirmAndPush() {
if (!snapshot.value || feeAction.value) return
try { await ElMessageBox.confirm('将确认当前试算结果并立即推送给客户,是否继续?', '确认推送费用', { type: 'warning', confirmButtonText: '确认推送' }) } catch { return }
feeAction.value = 'confirm'
try {
await confirmInboundFee(customerContextId.value, normalizedInboundId.value, createFeeActionIdempotencyKey('confirm'))
feeAction.value = 'send'
const result = await sendInboundFee(customerContextId.value, normalizedInboundId.value, createFeeActionIdempotencyKey('send'))
snapshot.value = result; emit('changed', result); ElMessage.success('费用已确认并推送给客户')
} catch (error) { adjustmentError.value = getApiErrorDetails(error, '确认推送费用失败'); await loadFee() }
finally { feeAction.value = null }
}
async function transitionFee(action: 'confirm' | 'send') {
if (!snapshot.value || feeAction.value) return
const confirming = action === 'confirm'
const expectedStatus = confirming ? 'CALCULATED' : 'CONFIRMED'
if (snapshot.value.feeStatus !== expectedStatus) return
try {
await ElMessageBox.confirm(
confirming
? '确认后费用金额与计费依据将进入已确认状态,下一步需要发送费用。是否继续?'
: '发送后费用将正式发布,并进入客户应收应付费用来源。是否继续?',
confirming ? '确认头程费用' : '发送头程费用',
{
type: confirming ? 'warning' : 'success',
confirmButtonText: confirming ? '确认费用' : '确认发送',
cancelButtonText: '取消',
},
)
} catch {
return
}
feeAction.value = action
adjustmentError.value = null
try {
const key = createFeeActionIdempotencyKey(action)
const result = confirming
? await confirmInboundFee(customerContextId.value, normalizedInboundId.value, key)
: await sendInboundFee(customerContextId.value, normalizedInboundId.value, key)
snapshot.value = result
emit('changed', result)
ElMessage.success(confirming ? '费用已确认,请继续发送费用' : '费用已发送并发布')
} catch (error) {
adjustmentError.value = getApiErrorDetails(error, confirming ? '费用确认失败,请刷新后重试' : '费用发送失败,请刷新后重试')
await loadFee()
} finally {
feeAction.value = null
}
}
function adjustmentPayload(): AdjustPublishedInboundFeeRequest {
return {
expectedFeeVersion: adjustmentForm.expectedFeeVersion,
chargeableQuantity: Number(adjustmentForm.chargeableQuantity),
unitPrice: Number(adjustmentForm.unitPrice),
additionalAmount: Number(adjustmentForm.additionalAmount),
reason: adjustmentForm.reason.trim(),
toleranceConfirmed: true,
}
}
function getAdjustmentKey(payload: AdjustPublishedInboundFeeRequest) {
const fingerprint = JSON.stringify(payload)
if (!adjustmentIdempotencyKey || fingerprint !== adjustmentFingerprint) {
adjustmentFingerprint = fingerprint
adjustmentIdempotencyKey = createFeeAdjustmentIdempotencyKey()
}
return adjustmentIdempotencyKey
}
async function submitAdjustment() {
if (!snapshot.value) return
const valid = await adjustFormRef.value?.validate().catch(() => false)
if (!valid) return
const nextQuantity = Number(adjustmentForm.chargeableQuantity)
if (nextQuantity < adjustmentMinimum.value || nextQuantity > adjustmentMaximum.value) {
ElMessage.warning(`调整后计费量须在基准值的 ±10% 范围内(${formatNumber(adjustmentMinimum.value, 4)}–${formatNumber(adjustmentMaximum.value, 4)} ${chargeUnitLabel.value})`)
return
}
if (adjustmentQuantityChanged.value) {
try {
await ElMessageBox.confirm(
`计费量将由 ${formatNumber(adjustmentBaseline.value, 4)} 调整为 ${formatNumber(nextQuantity, 4)} ${chargeUnitLabel.value},并生成新费用版本。是否继续?`,
'再次确认计费量调整',
{ type: 'warning', confirmButtonText: '确认调整', cancelButtonText: '返回检查' },
)
} catch {
return
}
}
const payload = adjustmentPayload()
adjustmentError.value = null
conflictRefreshed.value = false
adjusting.value = true
try {
const result = await adjustPublishedInboundFee(
customerContextId.value,
normalizedInboundId.value,
payload,
getAdjustmentKey(payload),
)
snapshot.value = result
adjustOpen.value = false
ElMessage.success(`费用已调整为第 ${result.feeVersion} 版`)
emit('changed', result)
} catch (error) {
adjustmentError.value = getApiErrorDetails(error, '费用调整失败,请检查后重试')
if (error instanceof ApiResponseError && error.code === 'A0409') {
await loadFee({ preserveAdjustment: true })
conflictRefreshed.value = true
adjustmentFingerprint = ''
adjustmentIdempotencyKey = ''
}
} finally {
adjusting.value = false
}
}
watch(
() => [customerContextId.value, normalizedInboundId.value, access.permissionSignature],
() => {
adjustOpen.value = false
snapshot.value = null
hasLoadedOnce.value = false
void loadFee()
},
{ immediate: true },
)
defineExpose({ reload: loadFee })
</script>
<template>
<section class="inbound-fee-panel" :aria-busy="loading" aria-labelledby="inbound-fee-title">
<header class="fee-heading" :class="{ 'fee-heading--compact': compact }">
<h3 v-if="compact" id="inbound-fee-title" class="fee-heading-title--compact">头程自动初算</h3>
<div v-else class="fee-heading-copy">
<span class="fee-heading-icon" aria-hidden="true"><CircleDollarSign :size="20" /></span>
<div>
<h3 id="inbound-fee-title">入库费用快照</h3>
<p>测量审核后的头程费用,人工确认后才发布。</p>
</div>
</div>
<div class="fee-heading-actions">
<el-tooltip content="刷新费用快照" placement="top">
<el-button
class="fee-refresh-button"
circle
aria-label="刷新费用快照"
:aria-busy="loading"
:disabled="loading || !customerContextId || !normalizedInboundId || !canView"
@click="loadFee()"
><RefreshCw :size="16" :class="{ 'is-spinning': loading }" /></el-button>
</el-tooltip>
<el-button
v-if="!displayOnly && canCalculate"
type="primary"
plain
:loading="trialLoading"
@click="openTrial"
><CircleDollarSign :size="16" />费用试算</el-button>
<el-button
v-if="!displayOnly && canAdjust && adjustmentEligible"
data-testid="adjust-fee-button"
type="primary"
@click="openAdjustment"
><PencilLine :size="16" />{{ snapshot?.feeStatus === 'CALCULATED' ? '调整初算费用' : '调整已发布费用' }}</el-button>
<el-button
v-if="!displayOnly && canConfirm && snapshot?.feeStatus === 'CALCULATED'"
data-testid="confirm-fee-button"
type="primary"
:loading="feeAction === 'confirm'"
:disabled="Boolean(feeAction)"
@click="transitionFee('confirm')"
><Check :size="16" />确认费用</el-button>
<el-button
v-if="!displayOnly && canConfirm && canSend && snapshot?.feeStatus === 'CALCULATED'"
type="primary"
:loading="Boolean(feeAction)"
:disabled="Boolean(feeAction)"
@click="confirmAndPush"
><Send :size="16" />确认推送费用</el-button>
<el-button
v-if="!displayOnly && canSend && snapshot?.feeStatus === 'CONFIRMED'"
data-testid="send-fee-button"
type="primary"
:loading="feeAction === 'send'"
:disabled="Boolean(feeAction)"
@click="transitionFee('send')"
><Send :size="16" />发送费用</el-button>
</div>
</header>
<AppDialog v-model="trialOpen" title="头程费用试算" width="min(760px, calc(100vw - 28px))" :confirm-loading="trialLoading" confirm-text="开始试算" @confirm="submitTrial">
<el-alert title="重量和体积各选择一项;默认选择实际测量数据。" type="info" :closable="false" show-icon />
<div class="trial-source-grid">
<section><h4>计费重量(单选)</h4><el-radio-group v-model="trial.weightSource"><el-radio v-for="item in sourceOptions" :key="`weight-${item.value}`" :value="item.value" :disabled="item.weight <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.weight) }} KG</strong></el-radio></el-radio-group></section>
<section><h4>计费体积(单选)</h4><el-radio-group v-model="trial.volumeSource"><el-radio v-for="item in sourceOptions" :key="`volume-${item.value}`" :value="item.value" :disabled="item.volume <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.volume, 6) }} CBM</strong></el-radio></el-radio-group></section>
</div>
</AppDialog>
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
<template #title>当前账号没有查看入库费用的权限</template>
<p>需要权限:firstleg:fba-pricing:view</p>
</el-alert>
<div v-else-if="loading && !snapshot && !hasLoadedOnce" class="fee-loading" aria-live="polite">
<el-skeleton :rows="6" animated />
<span class="sr-only">正在加载费用快照</span>
</div>
<el-alert v-if="loadError && snapshot" class="fee-refresh-error" type="error" :closable="false" show-icon role="status">
<template #title>最新费用快照暂时无法刷新,当前仍显示上一版快照
</template>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
</el-alert>
<el-alert v-else-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title>费用快照加载失败</template>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
<el-button size="small" @click="loadFee()">重新加载</el-button>
</el-alert>
<el-empty v-else-if="notFound" :image-size="64" description="尚未生成费用快照">
<p class="empty-note">测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p>
</el-empty>
<div v-else-if="snapshot" class="fee-content">
<div class="fee-summary" aria-label="费用关键结果">
<div class="fee-total">
<span>头程自动费用 · {{ snapshot.currency }}</span>
<strong>{{ formatMoney(snapshot.totalAmount, snapshot.currency) }}</strong>
<small>基础运费小计 {{ formatMoney(snapshot.subtotalAmount, snapshot.currency) }} · 附加费 {{ formatMoney(snapshot.additionalAmount, snapshot.currency) }}</small>
</div>
<div class="fee-summary-item">
<span>最终计费量</span>
<strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong>
<small>{{ chargeModeLabel }}</small>
</div>
<div class="fee-summary-item">
<span>单价</span>
<strong>{{ formatMoney(snapshot.unitPrice, snapshot.currency) }} / {{ chargeUnitLabel }}</strong>
<small>{{ snapshot.channelCode }}</small>
</div>
<div class="fee-summary-item">
<span>附加费</span>
<strong>{{ formatMoney(snapshot.additionalAmount, snapshot.currency) }}</strong>
<small>{{ snapshot.additionalAmount ? '已计入总额' : '无附加费' }}</small>
</div>
<div class="fee-summary-item">
<span>快照版本</span>
<div class="version-status"><strong>V{{ snapshot.feeVersion }}</strong><el-tag :type="statusInfo.type" effect="light">{{ statusInfo.label }}</el-tag></div>
<small>{{ snapshot.feeNo }}</small>
</div>
</div>
<section class="calculation-section" aria-labelledby="calculation-title">
<div class="section-title">
<div><h4 id="calculation-title">计费依据</h4><p v-if="!compact">实际重量与体积重量取大后计费,金额以服务端快照为准。</p></div>
<div class="calculation-tags">
<el-tag effect="plain">订单报关:{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
<el-tag effect="plain">报价税制:{{ pricingTaxModeLabel(snapshot.pricingTaxMode) }}</el-tag>
</div>
</div>
<div class="calculation-flow">
<div><span>实际重量</span><strong>{{ formatNumber(snapshot.actualWeightKg, 3) }} KG</strong></div>
<div><span>实际体积</span><strong>{{ formatNumber(snapshot.actualVolumeCbm, 4) }} CBM</strong></div>
<div><span>{{ convertedQuantityLabel }}</span><strong>{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong><small>系数 {{ formatNumber(snapshot.conversionFactor, 4) }}</small></div>
<div class="is-chargeable"><span>取大后的计费量</span><strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong></div>
</div>
<div class="amount-equation" aria-label="费用计算公式">
<span>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</span>
<i>×</i>
<span>{{ formatMoney(snapshot.unitPrice, snapshot.currency) }} / {{ chargeUnitLabel }}</span>
<i>+</i>
<span>附加费 {{ formatMoney(snapshot.additionalAmount, snapshot.currency) }}</span>
<i>=</i>
<strong>{{ formatMoney(snapshot.totalAmount, snapshot.currency) }}</strong>
</div>
</section>
<details class="fee-advanced">
<summary>查看价格来源与时间记录</summary>
<div class="fee-detail-grid">
<section v-if="snapshot.ruleCode" aria-labelledby="rule-title">
<div class="section-title"><div><h4 id="rule-title">客户计费规则</h4><p>{{ snapshot.ruleCode }} · V{{ snapshot.chargeRuleVersion }}</p></div></div>
<dl>
<div><dt>客户</dt><dd>{{ snapshot.customerName }}</dd></div>
<div><dt>业务类型</dt><dd>{{ firstLegBusinessTypeLabel(snapshot.businessType) }}</dd></div>
<div><dt>计费口径</dt><dd>{{ chargeModeLabel }}</dd></div>
<div><dt>舍入规则</dt><dd>{{ snapshot.roundingScale == null ? '—' : `保留 ${snapshot.roundingScale} 位,${roundingModeLabel(snapshot.roundingMode)}` }}</dd></div>
</dl>
</section>
<section aria-labelledby="rate-title">
<div class="section-title"><div><h4 id="rate-title">渠道价格</h4><p>{{ snapshot.rateCode }} · V{{ snapshot.carrierRateVersion }}</p></div></div>
<dl>
<div><dt>起运仓</dt><dd>{{ snapshot.originWarehouseName }}</dd></div>
<div><dt>渠道</dt><dd>{{ snapshot.channelCode }}</dd></div>
<div><dt>FBA 目的仓</dt><dd>{{ snapshot.destinationCode }}</dd></div>
<div><dt>价格有效期</dt><dd>{{ formatDateTime(snapshot.rateEffectiveFrom) }} 至 {{ formatDateTime(snapshot.rateEffectiveTo) }}</dd></div>
</dl>
</section>
</div>
<section class="fee-timeline" aria-labelledby="fee-timeline-title">
<div class="section-title"><div><h4 id="fee-timeline-title">时间记录</h4></div></div>
<ol>
<li><span>试算</span><strong>{{ formatDateTime(snapshot.calculatedAt) }}</strong></li>
<li :class="{ pending: !snapshot.confirmedAt }"><span>确认</span><strong>{{ formatDateTime(snapshot.confirmedAt) }}</strong></li>
<li :class="{ pending: !snapshot.sentAt }"><span>发布</span><strong>{{ formatDateTime(snapshot.sentAt) }}</strong></li>
</ol>
</section>
</details>
</div>
<OperationErrorToast :details="adjustmentError" />
<AppDialog
v-model="adjustOpen"
title="调整入库费用"
width="min(760px, calc(100vw - 28px))"
confirm-text="确认调整并生成新版本"
confirm-type="warning"
:confirm-loading="adjusting"
@confirm="submitAdjustment"
>
<el-alert type="warning" :closable="false" show-icon title="调整会生成新的费用版本;小计和总额由服务端按计费量、单价和附加费重算。" />
<el-alert class="adjustment-alert" type="info" :closable="false" show-icon>
<template #title>计费量允许在系统原始基准值 ±{{ formatNumber(snapshot?.tolerancePercent, 2) }}% 内调整</template>
<p>系统基准 {{ formatNumber(adjustmentBaseline, 4) }} {{ chargeUnitLabel }},服务端允许范围 {{ formatNumber(adjustmentMinimum, 4) }}–{{ formatNumber(adjustmentMaximum, 4) }} {{ chargeUnitLabel }}(包含边界);超出范围将无法提交。</p>
</el-alert>
<el-alert v-if="conflictRefreshed" class="adjustment-alert" type="warning" :closable="false" show-icon>
<template #title>费用已被其他操作员更新,系统已刷新最新快照</template>
<p>当前输入仍保留,请先核对最新版本后再提交。</p>
</el-alert>
<el-form ref="adjustFormRef" :model="adjustmentForm" :rules="adjustmentRules" label-position="top" class="adjustment-form" :disabled="adjusting">
<div class="adjustment-amounts">
<el-form-item label="调整后计费量" prop="chargeableQuantity" required>
<el-input-number v-model="adjustmentForm.chargeableQuantity" aria-label="调整后计费量" :min="adjustmentMinimum" :max="adjustmentMaximum" :precision="4" :step="1" controls-position="right" placeholder="请输入调整后计费量" />
<span class="adjustment-unit">{{ chargeUnitLabel }}</span>
</el-form-item>
<el-form-item label="调整后单价" prop="unitPrice" required>
<el-input-number v-model="adjustmentForm.unitPrice" aria-label="调整后单价" :min="0" :max="999999999999.9999" :precision="4" :step="0.1" controls-position="right" placeholder="请输入调整后单价" />
<span class="adjustment-unit">{{ snapshot?.currency }} / {{ chargeUnitLabel }}</span>
</el-form-item>
<el-form-item label="调整后附加费" prop="additionalAmount" required>
<el-input-number v-model="adjustmentForm.additionalAmount" aria-label="调整后附加费" :min="0" :max="999999999999.99" :precision="2" :step="10" controls-position="right" placeholder="请输入调整后附加费" />
<span class="adjustment-unit">{{ snapshot?.currency }}</span>
</el-form-item>
</div>
<div class="adjustment-total"><span>当前服务端计算金额</span><strong>{{ formatMoney(snapshot?.totalAmount, snapshot?.currency) }}</strong><small>提交后以服务端返回的新版本金额为准</small></div>
<el-form-item label="调整原因" prop="reason" required>
<el-input v-model="adjustmentForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="说明价格变更、补充费用或修正原因,记录将进入审计历史" />
</el-form-item>
</el-form>
</AppDialog>
</section>
</template>
<style scoped>
.inbound-fee-panel { min-width: 0; color: var(--cc-text); }
.fee-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 16px; }
.fee-heading--compact { align-items: center; justify-content: space-between; padding: 12px 0 8px; }
.fee-heading h3.fee-heading-title--compact { color: #344054; font-size: var(--admin-font-section, 14px); line-height: 1.4; }
.fee-heading-copy { display: flex; min-width: 0; align-items: flex-start; gap: 12px; }
.fee-heading-icon { display: grid; width: 38px; height: 38px; flex: 0 0 38px; place-items: center; border-radius: 10px; background: #eef4ff; color: var(--cc-primary); }
.fee-heading h3, .section-title h4 { margin: 0; }
.fee-heading h3 { font-size: 16px; }
.fee-heading p, .section-title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.fee-heading-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.fee-heading-actions .el-button { margin-left: 0; }
.fee-refresh-button .is-spinning { animation: fee-refresh-spin 0.8s linear infinite; }
.fee-loading { padding: 20px 0; }
.fee-refresh-error { margin-bottom: 10px; }
.inbound-fee-panel :deep(.el-alert p) { margin: 5px 0 0; line-height: 1.55; }
.inbound-fee-panel :deep(.el-alert .el-button) { margin-top: 8px; }
.empty-note { margin: 0; color: var(--cc-muted); font-size: 12px; }
.fee-content { border-top: 1px solid var(--cc-border); }
.fee-summary { display: grid; grid-template-columns: minmax(230px, 1.3fr) repeat(4, minmax(135px, 1fr)); border-top: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.fee-total, .fee-summary-item { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 3px; padding: 11px 14px; }
.fee-summary-item { border-left: 1px solid var(--cc-border); }
.fee-summary span, .fee-summary small { color: var(--cc-muted); font-size: 11px; }
.fee-summary strong { font-size: 18px; line-height: 1.25; overflow-wrap: anywhere; }
.fee-total strong { color: #0f5c49; font-size: 21px; letter-spacing: -.01em; }
.fee-summary-item strong { font-size: 15px; }
.version-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
@keyframes fee-refresh-spin { to { transform: rotate(360deg); } }
.calculation-section, .fee-timeline { padding: 10px 0; border-bottom: 1px solid var(--cc-border); }
.section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.calculation-tags { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.section-title h4 { font-size: 14px; }
.calculation-flow { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 7px; border-top: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.calculation-flow > div { display: flex; min-width: 0; min-height: 46px; flex-direction: row; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 10px; border-right: 1px solid var(--cc-border); }
.calculation-flow > div:last-child { border-right: 0; }
.calculation-flow span, .calculation-flow small { color: var(--cc-muted); font-size: 11px; }
.calculation-flow strong { font-size: 13px; overflow-wrap: anywhere; }
.calculation-flow small { display: none; }
.calculation-flow .is-chargeable { background: #f7fbfa; }
.calculation-flow .is-chargeable strong { color: #087d70; }
.amount-equation { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 5px; padding: 4px 0 0; color: #475467; font-size: 12px; }
.amount-equation i { color: #98a2b3; font-style: normal; }
.amount-equation strong { color: var(--cc-text); font-size: 15px; }
.fee-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-bottom: 1px solid var(--cc-border); }
.fee-detail-grid > section { min-width: 0; padding: 22px 20px 22px 0; }
.fee-detail-grid > section + section { padding-right: 0; padding-left: 20px; border-left: 1px solid var(--cc-border); }
.fee-detail-grid dl { margin: 14px 0 0; }
.fee-detail-grid dl > div { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 12px; padding: 9px 0; border-top: 1px solid #edf0f4; font-size: 12px; }
.fee-detail-grid dt { color: var(--cc-muted); }
.fee-detail-grid dd { min-width: 0; margin: 0; font-weight: 600; overflow-wrap: anywhere; }
.fee-timeline { border-bottom: 0; }
.fee-timeline ol { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; margin: 18px 0 0; padding: 0; list-style: none; }
.fee-timeline li { position: relative; display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 0 18px 0 20px; }
.fee-timeline li::before { position: absolute; top: 4px; left: 0; width: 9px; height: 9px; border-radius: 50%; background: #12b76a; content: ''; }
.fee-timeline li::after { position: absolute; top: 8px; right: 0; left: 10px; z-index: -1; height: 1px; background: var(--cc-border); content: ''; }
.fee-timeline li:last-child::after { display: none; }
.fee-timeline li.pending::before { background: #98a2b3; }
.fee-timeline span { color: var(--cc-muted); font-size: 11px; }
.fee-timeline strong { font-size: 12px; overflow-wrap: anywhere; }
.fee-advanced { border-top: 1px solid var(--cc-border); }
.fee-advanced > summary { padding: 10px 2px; color: var(--cc-primary); font-size: 12px; font-weight: 600; cursor: pointer; }
.fee-advanced:not([open]) > summary { border-bottom: 0; }
.fee-advanced .fee-detail-grid > section:only-child { grid-column: 1 / -1; }
.adjustment-alert { margin-top: 12px; }
.adjustment-form { margin-top: 18px; }
.adjustment-amounts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
.adjustment-amounts :deep(.el-form-item) { min-width: 0; margin-bottom: 18px; }
.adjustment-amounts :deep(.el-form-item__content) { display: grid; grid-template-columns: minmax(0, 1fr) auto; flex-wrap: nowrap; column-gap: 8px; }
.adjustment-amounts :deep(.el-input-number) { width: 100%; }
.adjustment-unit { min-width: max-content; color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.adjustment-total { display: grid; grid-template-columns: auto minmax(150px, 1fr) auto; align-items: baseline; gap: 18px; margin: 0 0 18px; padding: 13px 16px; border-radius: 8px; background: #f8fafc; }
.adjustment-total span { color: var(--cc-muted); font-size: 12px; }
.adjustment-total strong { justify-self: center; font-size: 18px; white-space: nowrap; }
.adjustment-total small { color: var(--cc-muted); font-size: 11px; text-align: right; }
.trial-source-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-top: 16px; }.trial-source-grid section { padding: 14px; border: 1px solid var(--cc-border); border-radius: 8px; }.trial-source-grid h4 { margin: 0 0 12px; }.trial-source-grid :deep(.el-radio-group) { display: grid; gap: 10px; }.trial-source-grid :deep(.el-radio) { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; height: auto; margin: 0; padding: 10px; border: 1px solid var(--cc-border); border-radius: 6px; }.trial-source-grid :deep(.el-radio__label) { display: flex; min-width: 0; justify-content: space-between; gap: 10px; }.trial-source-grid strong { color: var(--cc-text); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 900px) {
.fee-summary { grid-template-columns: 1fr 1fr; }
.fee-total { grid-column: 1 / -1; }
.fee-summary-item:first-of-type { border-left: 0; }
.calculation-flow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.calculation-flow > div:nth-child(2) { border-right: 0; }
.calculation-flow > div:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); }
}
@media (max-width: 720px) {
.fee-heading { align-items: stretch; flex-direction: column; }
.fee-heading-actions { justify-content: flex-start; }
.fee-heading-actions .el-button:not(.is-circle) { flex: 1; min-height: 44px; }
.fee-summary, .calculation-flow, .fee-detail-grid, .fee-timeline ol { grid-template-columns: 1fr; }
.fee-summary-item { border-top: 1px solid var(--cc-border); border-left: 0; }
.calculation-flow > div { min-height: 76px; border-right: 0; border-bottom: 1px solid var(--cc-border); }
.calculation-flow > div:last-child { border-bottom: 0; }
.fee-detail-grid > section, .fee-detail-grid > section + section { padding: 20px 0; border-left: 0; }
.fee-detail-grid > section + section { border-top: 1px solid var(--cc-border); }
.fee-timeline ol { gap: 16px; }
.fee-timeline li { padding-left: 20px; }
.fee-timeline li::after { display: none; }
.adjustment-amounts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.adjustment-total { grid-template-columns: auto 1fr; }
.adjustment-total strong { justify-self: end; }
.adjustment-total small { grid-column: 1 / -1; text-align: left; }
}
@media (max-width: 520px) {
.adjustment-amounts { grid-template-columns: 1fr; gap: 0; }
.adjustment-total { grid-template-columns: 1fr; gap: 6px; }
.adjustment-total strong { justify-self: start; }
}
@media (max-width: 360px) {
.fee-heading-copy { gap: 8px; }
.fee-heading-icon { width: 34px; height: 34px; flex-basis: 34px; }
.fee-total, .fee-summary-item { padding: 16px 12px; }
.fee-total strong { font-size: 22px; }
.fee-detail-grid dl > div { grid-template-columns: 1fr; gap: 4px; }
.amount-equation { align-items: flex-start; flex-direction: column; }
.amount-equation i { display: none; }
}
</style>