支持私人地址人工报价及海外入库托盘打印和短少确认
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user