729 lines
39 KiB
Vue
729 lines
39 KiB
Vue
<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>
|