支持私人地址人工报价及海外入库托盘打印和短少确认

This commit is contained in:
2026-11-04 14:11:26 +08:00
parent 84dd9dcbb4
commit 20bde6c72d
11 changed files with 292 additions and 22 deletions
+111 -13
View File
@@ -19,12 +19,14 @@ import {
adjustPublishedInboundFee,
calculateInboundFee,
confirmInboundFee,
createManualInboundQuote,
createFeeActionIdempotencyKey,
createFeeAdjustmentIdempotencyKey,
getLatestInboundFee,
previewInboundFee,
sendInboundFee,
} from './api'
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
import type { AdjustPublishedInboundFeeRequest, InboundFeePreview, InboundFeeSnapshot } from './types'
import { getInboundMeasurement } from '../measurement/api'
import type { InboundMeasurementDetail } from '../measurement/types'
@@ -32,6 +34,7 @@ const props = defineProps<{
customerId?: string
tenantId?: string
inboundId: string
destinationType?: string | null
compact?: boolean
displayOnly?: boolean
}>()
@@ -55,7 +58,17 @@ 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 trial = reactive({
weightSource: 'ACTUAL',
volumeSource: 'ACTUAL',
unitPrice: undefined as number | undefined,
reason: '',
})
let manualQuoteIdempotencyKey = ''
let manualQuoteFingerprint = ''
const trialPreview = ref<InboundFeePreview | null>(null)
const trialPreviewLoading = ref(false)
let trialPreviewSerial = 0
const adjustFormRef = ref<FormInstance>()
let loadSequence = 0
let adjustmentFingerprint = ''
@@ -84,6 +97,17 @@ 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 isPrivateAddress = computed(() => props.destinationType === 'PRIVATE')
const canManualQuote = computed(() => access.can('firstleg:inbound:confirm-send-fee'))
const canRunTrial = computed(() => isPrivateAddress.value
? canManualQuote.value && snapshot.value?.customerConfirmationStatus !== 'CONFIRMED'
: canCalculate.value)
const manualQuoteValid = computed(() => !isPrivateAddress.value || (
Boolean(measurement.value?.items.length) && Number(trialPreview.value?.chargeableQuantity) > 0
&& !trialPreviewLoading.value && Number(trial.unitPrice) > 0
&& Number.isFinite(Number(trial.unitPrice)) && trial.reason.trim().length >= 2
&& trial.reason.trim().length <= 500
))
const customerContextId = computed(() => props.customerId || props.tenantId || '')
const normalizedInboundId = computed(() => {
const identifier = String(props.inboundId ?? '').trim()
@@ -107,12 +131,13 @@ const sourceOptions = computed(() => {
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 [
const options = [
{ 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 },
]
return isPrivateAddress.value ? options.filter(item => item.value === 'ACTUAL') : options
})
function feeStatusInfo(status?: string) {
@@ -229,20 +254,72 @@ function openAdjustment() {
async function openTrial() {
trialLoading.value = true
try { measurement.value = await getInboundMeasurement(customerContextId.value, normalizedInboundId.value); trial.weightSource = 'ACTUAL'; trial.volumeSource = 'ACTUAL'; trialOpen.value = true }
adjustmentError.value = null
try {
measurement.value = await getInboundMeasurement(customerContextId.value, normalizedInboundId.value)
trial.weightSource = 'ACTUAL'
trial.volumeSource = 'ACTUAL'
trial.unitPrice = undefined
trial.reason = ''
manualQuoteIdempotencyKey = ''
manualQuoteFingerprint = ''
trialOpen.value = true
await loadTrialPreview()
}
catch (error) { ElMessage.error(getApiErrorDetails(error, '测量数据加载失败,无法试算').message) }
finally { trialLoading.value = false }
}
async function loadTrialPreview() {
if (!isPrivateAddress.value) return
adjustmentError.value = null
const serial = ++trialPreviewSerial
trialPreview.value = null
trialPreviewLoading.value = false
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) return
trialPreviewLoading.value = true
try {
const result = await previewInboundFee(customerContextId.value, normalizedInboundId.value, {
selectedWeightKg: weight.weight, selectedVolumeCbm: volume.volume,
weightSource: weight.value, volumeSource: volume.value, selectedUnitPrice: null,
})
if (serial === trialPreviewSerial) trialPreview.value = result
} catch (error) {
if (serial === trialPreviewSerial) adjustmentError.value = getApiErrorDetails(error, '计费量读取失败,请重试')
} finally {
if (serial === trialPreviewSerial) trialPreviewLoading.value = false
}
}
async function submitTrial() {
if (!canRunTrial.value || !manualQuoteValid.value) return
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 }
adjustmentError.value = null
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, '费用试算失败') }
const selection = { selectedWeightKg: weight.weight, selectedVolumeCbm: volume.volume }
let result: InboundFeeSnapshot
if (isPrivateAddress.value) {
const request = { ...selection, unitPrice: Number(trial.unitPrice), reason: trial.reason.trim() }
const fingerprint = JSON.stringify([customerContextId.value, normalizedInboundId.value, request])
if (!manualQuoteIdempotencyKey || fingerprint !== manualQuoteFingerprint) {
manualQuoteFingerprint = fingerprint
manualQuoteIdempotencyKey = createFeeActionIdempotencyKey('manual')
}
result = await createManualInboundQuote(customerContextId.value, normalizedInboundId.value, request, manualQuoteIdempotencyKey)
} else {
result = await calculateInboundFee(customerContextId.value, normalizedInboundId.value,
{ ...selection, weightSource: weight.value, volumeSource: volume.value }, createFeeActionIdempotencyKey('calculate'))
}
snapshot.value = result
trialOpen.value = false
emit('changed', result)
ElMessage.success(isPrivateAddress.value ? '人工报价已保存' : '费用试算已更新')
} catch (error) { adjustmentError.value = getApiErrorDetails(error, isPrivateAddress.value ? '人工报价失败' : '费用试算失败') }
finally { trialLoading.value = false }
}
@@ -401,12 +478,12 @@ defineExpose({ reload: loadFee })
><RefreshCw :size="16" :class="{ 'is-spinning': loading }" /></el-button>
</el-tooltip>
<el-button
v-if="!displayOnly && canCalculate"
v-if="!displayOnly && canRunTrial"
type="primary"
plain
:loading="trialLoading"
@click="openTrial"
><CircleDollarSign :size="16" />费用试算</el-button>
><CircleDollarSign :size="16" />{{ isPrivateAddress ? '人工头程报价' : '费用试算' }}</el-button>
<el-button
v-if="!displayOnly && canAdjust && adjustmentEligible"
data-testid="adjust-fee-button"
@@ -439,12 +516,33 @@ defineExpose({ reload: loadFee })
</div>
</header>
<AppDialog class="admin-workbench is-dialog" v-model="trialOpen" title="头程费用试算" width="min(760px, calc(100vw - 28px))" :confirm-loading="trialLoading" confirm-text="开始试算" @confirm="submitTrial">
<AppDialog
class="admin-workbench is-dialog"
v-model="trialOpen"
:title="isPrivateAddress ? '私人地址头程报价' : '头程费用试算'"
width="min(760px, calc(100vw - 28px))"
:confirm-loading="trialLoading"
:confirm-disabled="!canRunTrial || !manualQuoteValid"
:confirm-text="isPrivateAddress ? '保存人工报价' : '开始试算'"
@confirm="submitTrial"
>
<div class="trial-source-grid">
<section><h4>计费重量(单选)<InlineHelp title="试算取值说明"><p>重量和体积各选择一项;默认选择实际测量数据。</p></InlineHelp></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>
<section><h4>计费重量(单选)<InlineHelp title="试算取值说明"><p>重量和体积各选择一项;默认选择实际测量数据。</p></InlineHelp></h4><el-radio-group v-model="trial.weightSource" @change="loadTrialPreview"><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" @change="loadTrialPreview"><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>
<el-form v-if="isPrivateAddress" label-position="top" class="adjustment-form" :disabled="trialLoading">
<el-form-item label="服务端计费量">
<span>{{ trialPreview ? `${formatNumber(trialPreview.chargeableQuantity, 6)} ${trialPreview.chargeUnit}` : '—' }}</span>
<el-button link :loading="trialPreviewLoading" @click="loadTrialPreview">刷新计费量</el-button>
</el-form-item>
<el-form-item :label="`人工单价(CNY / ${trialPreview?.chargeUnit || '—'})`" required>
<el-input-number v-model="trial.unitPrice" aria-label="人工单价" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入人工单价" />
</el-form-item>
<el-form-item label="报价原因" required>
<el-input v-model="trial.reason" aria-label="报价原因" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入报价原因,至少2个字" />
</el-form-item>
</el-form>
</AppDialog>
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
@@ -545,7 +643,7 @@ defineExpose({ reload: loadFee })
<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>{{ isPrivateAddress ? '目的地' : 'FBA 目的仓' }}</dt><dd>{{ isPrivateAddress ? '私人地址' : snapshot.destinationCode || '—' }}</dd></div>
<div><dt>价格有效期</dt><dd>{{ formatDateTime(snapshot.rateEffectiveFrom) }} 至 {{ formatDateTime(snapshot.rateEffectiveTo) }}</dd></div>
</dl>
</section>