This commit is contained in:
2026-09-03 10:46:07 +08:00
parent 913a8df7f3
commit 176f815519
17 changed files with 1128 additions and 44 deletions
@@ -14,6 +14,7 @@ import {
} from '@/shared/api/http'
import {
adjustPublishedInboundFee,
calculateInboundFee,
confirmInboundFee,
createFeeActionIdempotencyKey,
createFeeAdjustmentIdempotencyKey,
@@ -21,6 +22,8 @@ import {
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
@@ -44,6 +47,10 @@ const adjustmentError = ref<ApiErrorDetails | null>(null)
const conflictRefreshed = ref(false)
const snapshot = ref<InboundFeeSnapshot | null>(null)
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 = ''
@@ -71,6 +78,7 @@ 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()
@@ -83,6 +91,18 @@ const adjustmentEligible = computed(() => (
const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费')
const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM')
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' }> = {
@@ -177,6 +197,38 @@ function openAdjustment() {
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'
@@ -299,6 +351,13 @@ defineExpose({ reload: loadFee })
@click="loadFee()"
><RefreshCw :size="16" /></el-button>
</el-tooltip>
<el-button
v-if="canCalculate"
type="primary"
plain
:loading="trialLoading"
@click="openTrial"
><CircleDollarSign :size="16" />费用试算</el-button>
<el-button
v-if="canAdjust && adjustmentEligible"
data-testid="adjust-fee-button"
@@ -313,6 +372,13 @@ defineExpose({ reload: loadFee })
:disabled="Boolean(feeAction)"
@click="transitionFee('confirm')"
><Check :size="16" />确认费用</el-button>
<el-button
v-if="canConfirm && canSend && snapshot?.feeStatus === 'CALCULATED'"
type="primary"
:loading="Boolean(feeAction)"
:disabled="Boolean(feeAction)"
@click="confirmAndPush"
><Send :size="16" />确认推送费用</el-button>
<el-button
v-if="canSend && snapshot?.feeStatus === 'CONFIRMED'"
data-testid="send-fee-button"
@@ -324,6 +390,14 @@ defineExpose({ reload: loadFee })
</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>
@@ -539,6 +613,7 @@ defineExpose({ reload: loadFee })
.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) {