更新计费预览与测量精度并精简作业界面

This commit is contained in:
2026-10-28 11:06:42 +08:00
parent e41e9e6d3f
commit 6f8dc920ca
21 changed files with 522 additions and 150 deletions
@@ -76,7 +76,7 @@ import {
confirmInboundFee,
createFeeAdjustmentIdempotencyKey,
createFeeActionIdempotencyKey,
getLatestInboundFee,
previewInboundFee,
getInboundMeasurement,
getPlatformInboundHold,
downloadInboundCartonLabelsBatch,
@@ -93,6 +93,7 @@ import {
DropshipPalletPrintPanel,
sendInboundFee,
type InboundFeeSnapshot,
type InboundFeePreview,
type InboundMeasurementDetail,
type InboundHoldDetail,
} from '@/modules/inbounds'
@@ -270,10 +271,16 @@ const feeConfirmOpen = ref(false)
const feeConfirmWeight = ref<number | null>(null)
const feeConfirmVolume = ref<number | null>(null)
const feeConfirmReferenceLoading = ref(false)
const feeConfirmReference = ref<InboundFeeSnapshot | null>(null)
const feeConfirmReference = ref<InboundFeePreview | null>(null)
const feeConfirmPriceReference = ref<Pick<InboundFeePreview, 'unitPrice' | 'currency' | 'chargeUnit'> | null>(null)
const feeConfirmPriceMissing = ref(false)
const feeConfirmUnitPrice = ref<number | null>(null)
const feeConfirmPriceReason = ref('')
let feeConfirmPreviewSerial = 0
let feeConfirmPreviewTimer: ReturnType<typeof setTimeout> | null = null
const feeConfirmPreviewKey = ref('')
let feeConfirmSettingInitialPrice = false
const feeConfirmSelectionKey = computed(() => JSON.stringify([feeConfirmWeight.value, feeConfirmVolume.value, feeConfirmUnitPrice.value]))
type FeeConfirmAttempt = {
fingerprint: string
calculateKey: string
@@ -285,19 +292,20 @@ type FeeConfirmAttempt = {
let feeConfirmAttempt: FeeConfirmAttempt | null = null
const feeConfirmPriceChanged = computed(() => {
if (feeConfirmPriceMissing.value) return true
const reference = Number(feeConfirmReference.value?.unitPrice)
const reference = feeConfirmPriceReference.value?.unitPrice
const current = Number(feeConfirmUnitPrice.value)
return reference >= 0 && current >= 0 && Math.abs(reference - current) > 0.000001
return reference != null && reference >= 0 && current >= 0 && Math.abs(reference - current) > 0.000001
})
const feeConfirmPriceUnit = computed(() => {
const snapshot = feeConfirmReference.value
const snapshot = feeConfirmPriceReference.value
return snapshot ? `${snapshot.currency} / ${snapshot.chargeUnit}` : 'CNY / 计费单位'
})
const feeConfirmEstimatedTotal = computed(() => {
const snapshot = feeConfirmReference.value
const unitPrice = Number(feeConfirmUnitPrice.value)
if (!snapshot || !(unitPrice >= 0)) return null
return Number(snapshot.chargeableQuantity || 0) * unitPrice + Number(snapshot.additionalAmount || 0)
if (!snapshot || feeConfirmReferenceLoading.value || feeConfirmPreviewKey.value !== feeConfirmSelectionKey.value
|| feeConfirmUnitPrice.value == null || !(unitPrice > 0)) return null
return snapshot.totalAmount
})
const feeConfirmRows = computed(() => {
const summary = detail.value?.summary
@@ -537,34 +545,37 @@ function calculatedMeasurementRow(
const customerMeasurementRows = computed(() => (detail.value?.order.cargoes || []).map((row) => (
calculatedMeasurementRow(row, measurementSectionModes.customer, true)
)))
const outerMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => {
function hasSavedOuterMeasurement(row: InboundMeasurementDetail['items'][number]) {
return [row.calculatedOuterLengthCm, row.calculatedOuterWidthCm, row.calculatedOuterHeightCm,
row.unitVolumeCbm, row.totalVolumeCbm].every(value => value != null && Number.isFinite(Number(value)) && Number(value) > 0)
&& Boolean(row.calculationRuleVersion)
}
const outerMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row): Record<string, unknown> => {
const source = row as unknown as Record<string, unknown>
const recalculated = calculatedMeasurementRow(source, measurementSectionModes.outer)
const savedDimensions = [row.calculatedOuterLengthCm, row.calculatedOuterWidthCm, row.calculatedOuterHeightCm]
const recalculatedDimensions = [recalculated.lengthCm, recalculated.widthCm, recalculated.heightCm]
const warehouseOuterOverride = String(row.calculationRuleVersion || '').startsWith('WAREHOUSE_OUTER_OVERRIDE_')
if (savedDimensions.every((value) => Number.isFinite(Number(value)) && Number(value) > 0)
&& (warehouseOuterOverride
|| savedDimensions.every((value, index) => Math.abs(Number(value) - Number(recalculatedDimensions[index])) < 0.001))
&& measurementCalculationModeForRow(source) === measurementSectionModes.outer) {
const packageCount = Number(row.packageCount)
const unitWeightKg = Number(row.unitWeightKg) > 0
? Number(row.unitWeightKg)
: Number.isFinite(Number(measurementDetail.value?.actualGrossWeightKg))
&& Number(measurementDetail.value?.actualPackageCount) > 0
? Number(measurementDetail.value?.actualGrossWeightKg) / Number(measurementDetail.value?.actualPackageCount)
: null
return {
...source,
lengthCm: Number(savedDimensions[0]),
widthCm: Number(savedDimensions[1]),
heightCm: Number(savedDimensions[2]),
unitWeightKg,
totalWeightKg: unitWeightKg != null && Number.isFinite(packageCount) ? unitWeightKg * packageCount : null,
}
const saved = hasSavedOuterMeasurement(row)
const packageCount = Number(row.packageCount)
const unitWeightKg = Number(row.unitWeightKg) > 0
? Number(row.unitWeightKg)
: Number.isFinite(Number(measurementDetail.value?.actualGrossWeightKg))
&& Number(measurementDetail.value?.actualPackageCount) > 0
? Number(measurementDetail.value?.actualGrossWeightKg) / Number(measurementDetail.value?.actualPackageCount)
: null
// 外箱规和对客计费只认已保存快照,当前客户配置的变化不得在前端重写历史测量。
return {
...source,
lengthCm: saved ? row.calculatedOuterLengthCm : null,
widthCm: saved ? row.calculatedOuterWidthCm : null,
heightCm: saved ? row.calculatedOuterHeightCm : null,
unitVolumeCbm: saved ? row.unitVolumeCbm : null,
totalVolumeCbm: saved ? row.totalVolumeCbm : null,
volumeWeightKg: saved ? row.volumeWeightKg : null,
unitWeightKg,
totalWeightKg: unitWeightKg != null && Number.isFinite(packageCount) ? unitWeightKg * packageCount : null,
}
return recalculated
}))
const hasSavedOuterMeasurementData = computed(() => hasActualMeasurementData.value
&& measurementDetail.value!.items.every(hasSavedOuterMeasurement))
const actualMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => (
calculatedMeasurementRow(row as unknown as Record<string, unknown>, measurementSectionModes.actual, false, false)
)))
@@ -878,7 +889,7 @@ function formatNumber(value: number | null | undefined, digits = 2) {
function formatMeasurementVolume(value: number | null | undefined) {
return value == null || !Number.isFinite(value) ? '—'
: value.toLocaleString('zh-CN', { minimumFractionDigits: 3, maximumFractionDigits: 3 })
: value.toLocaleString('zh-CN', { minimumFractionDigits: 4, maximumFractionDigits: 4 })
}
function partyValue(parties: Array<Record<string, unknown>>, type: 'SENDER' | 'RECEIVER', key: string) {
@@ -1163,9 +1174,10 @@ async function saveWarehouseOuterDimensions(row: Record<string, unknown>) {
outerLengthCm: dimensions[0],
outerWidthCm: dimensions[1],
outerHeightCm: dimensions[2],
calculatedOuterLengthCm: dimensions[0],
calculatedOuterWidthCm: dimensions[1],
calculatedOuterHeightCm: dimensions[2],
// 保存完成后等待服务端返回完整计算快照,不用修正输入值伪装计算结果。
calculatedOuterLengthCm: null,
calculatedOuterWidthCm: null,
calculatedOuterHeightCm: null,
} : item),
}
const refreshed = await waitForSavedMeasurementVersion(
@@ -1737,12 +1749,12 @@ async function openFeeConfirmDialog() {
if (confirmFeeSending.value) {
return
}
if (measurementStrategySavingId.value) {
if (measurementStrategySavingId.value || measurementOuterSavingId.value) {
ElMessage.warning('外箱规计算规则正在保存,请稍后再发送费用')
return
}
const outerTotals = measurementTotals(outerMeasurementRows.value)
if (!outerMeasurementRows.value.length || !(Number(outerTotals.volume) > 0)) {
if (!hasSavedOuterMeasurementData.value || !(Number(outerTotals.volume) > 0)) {
ElMessage.warning('外箱规数据不完整,请先保存外箱规尺寸和计算规则')
return
}
@@ -1754,44 +1766,79 @@ async function openFeeConfirmDialog() {
: measuredGrossWeight > 0 ? measuredGrossWeight : measuredNetWeight > 0 ? measuredNetWeight : null
feeConfirmVolume.value = Number(outerTotals.volume)
feeConfirmReference.value = null
feeConfirmPriceReference.value = null
feeConfirmPriceMissing.value = false
feeConfirmUnitPrice.value = null
feeConfirmPriceReason.value = ''
feeConfirmAttempt = null
feeConfirmOpen.value = true
await refreshFeeConfirmPreview(true)
}
function cancelFeeConfirmPreview() {
feeConfirmPreviewSerial += 1
if (feeConfirmPreviewTimer) clearTimeout(feeConfirmPreviewTimer)
feeConfirmPreviewTimer = null
feeConfirmReferenceLoading.value = false
feeConfirmPreviewKey.value = ''
}
async function refreshFeeConfirmPreview(initializePrice = false) {
cancelFeeConfirmPreview()
const summary = detail.value?.summary
const selectedWeightKg = Number(feeConfirmWeight.value)
const selectedVolumeCbm = Number(feeConfirmVolume.value)
const selectedUnitPrice = feeConfirmUnitPrice.value
if (!feeConfirmOpen.value || !summary?.inboundId || !(selectedWeightKg > 0) || !(selectedVolumeCbm > 0)
|| (selectedUnitPrice != null && !(selectedUnitPrice > 0))) {
feeConfirmReference.value = null
return
}
const serial = feeConfirmPreviewSerial
feeConfirmReferenceLoading.value = true
try {
const tenantId = String(summary.tenantId)
const inboundId = String(summary.inboundId)
let snapshot: InboundFeeSnapshot
try {
snapshot = await getLatestInboundFee(tenantId, inboundId)
} catch (error) {
const details = getApiErrorDetails(error, '价格表参考单价加载失败')
if (details.code !== 'A0404') throw error
snapshot = await calculateInboundFee(tenantId, inboundId, {
selectedWeightKg: Number(feeConfirmWeight.value),
selectedVolumeCbm: Number(feeConfirmVolume.value),
weightSource: 'ACTUAL',
volumeSource: 'OUTER',
}, createFeeActionIdempotencyKey('calculate'))
}
const snapshot = await previewInboundFee(String(summary.tenantId), String(summary.inboundId), {
selectedWeightKg, selectedVolumeCbm, selectedUnitPrice, weightSource: 'ACTUAL', volumeSource: 'OUTER',
})
if (serial !== feeConfirmPreviewSerial || !feeConfirmOpen.value) return
feeConfirmReference.value = snapshot
feeConfirmUnitPrice.value = Number(snapshot.unitPrice)
} catch (error) {
const details = getApiErrorDetails(error, '价格表参考单价加载失败')
if (details.code === 'A0404') {
feeConfirmPriceMissing.value = true
ElMessage.warning('当前FBA价格未命中,请填写本次对客单价和人工定价原因')
} else {
ElMessage.error(details.message)
feeConfirmOpen.value = false
// unitPrice 是价格表匹配价;人工单价只参与服务端金额预览,不覆盖该只读参考。
feeConfirmPriceReference.value = { unitPrice: snapshot.unitPrice, currency: snapshot.currency, chargeUnit: snapshot.chargeUnit }
feeConfirmPriceMissing.value = !snapshot.priceMatched
if (initializePrice) {
feeConfirmSettingInitialPrice = true
feeConfirmUnitPrice.value = snapshot.unitPrice
feeConfirmSettingInitialPrice = false
}
feeConfirmPreviewKey.value = feeConfirmSelectionKey.value
if (initializePrice && !snapshot.priceMatched) ElMessage.warning('当前FBA价格未命中,请填写本次对客单价和人工定价原因')
} catch (error) {
if (serial !== feeConfirmPreviewSerial || !feeConfirmOpen.value) return
feeConfirmReference.value = null
ElMessage.error(getApiErrorDetails(error, '计费预览加载失败,请稍后重试').message)
} finally {
feeConfirmReferenceLoading.value = false
if (serial === feeConfirmPreviewSerial) feeConfirmReferenceLoading.value = false
}
}
watch([feeConfirmWeight, feeConfirmVolume, feeConfirmUnitPrice], () => {
if (!feeConfirmOpen.value || confirmFeeSending.value || feeConfirmSettingInitialPrice) return
cancelFeeConfirmPreview()
feeConfirmReference.value = null
if (!(Number(feeConfirmWeight.value) > 0) || !(Number(feeConfirmVolume.value) > 0)
|| (feeConfirmUnitPrice.value != null && !(feeConfirmUnitPrice.value > 0))) return
feeConfirmReferenceLoading.value = true
feeConfirmPreviewTimer = setTimeout(() => { void refreshFeeConfirmPreview() }, 250)
}, { flush: 'sync' })
watch(feeConfirmOpen, open => {
if (!open) cancelFeeConfirmPreview()
}, { flush: 'sync' })
watch(() => `${detail.value?.summary.tenantId}:${detail.value?.summary.inboundId}`, () => {
feeConfirmOpen.value = false
}, { flush: 'sync' })
function currentFeeConfirmAttempt(
selectedWeightKg: number,
selectedVolumeCbm: number,
@@ -1831,6 +1878,11 @@ async function executeConfirmAndSendFee() {
ElMessage.warning('对客单价必须大于 0')
return
}
if (feeConfirmReferenceLoading.value || !feeConfirmReference.value
|| feeConfirmPreviewKey.value !== feeConfirmSelectionKey.value) {
ElMessage.warning('请等待当前计费重量和体积的预览更新后再发送')
return
}
if (feeConfirmPriceChanged.value && feeConfirmPriceReason.value.trim().length < 2) {
ElMessage.warning('人工修改参考单价后,请填写至少 2 个字符的调整原因')
return
@@ -2402,6 +2454,7 @@ onMounted(() => {
if (!isDetailRoute.value) void loadOrders()
})
onBeforeUnmount(() => {
cancelFeeConfirmPreview()
ordersRequestSerial += 1
statusCountsRequestSerial += 1
detailAssociationRequestSerial += 1
@@ -2418,7 +2471,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
<template>
<AdminPage class="admin-workbench admin-density">
<section class="business-page unified-orders-page admin-density">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="feeConfirmOpen" :title="orderDialogTitle('编辑计费数据', detail?.summary.inboundCode)" confirm-text="确认并发送" :confirm-loading="confirmFeeSending || feeConfirmReferenceLoading" width="min(920px, 94vw)" fullscreenable :teleported="false" @confirm="executeConfirmAndSendFee">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="feeConfirmOpen" :title="orderDialogTitle('编辑计费数据', detail?.summary.inboundCode)" confirm-text="确认并发送" :confirm-loading="confirmFeeSending" :confirm-disabled="feeConfirmReferenceLoading || !feeConfirmReference" width="min(920px, 94vw)" fullscreenable :teleported="false" @confirm="executeConfirmAndSendFee">
<el-alert class="fee-confirm-price-alert" title="价格表单价仅作为参考;本次对客单价可人工修改,客户将一次确认计费量、单价和费用合计。" type="info" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work">
<h3 class="density-dialog-section__title">本次计费数据 <small>毛重、体积和对客单价可修改</small></h3>
@@ -2431,15 +2484,15 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
</tr>
<tr>
<th scope="row"><label for="fee-confirm-weight">计费毛重</label></th>
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-weight" v-model="feeConfirmWeight" :min="0" :precision="3" :controls="false" placeholder="请输入计费毛重" /><span>kg</span></div></td>
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-weight" v-model="feeConfirmWeight" :min="0" :precision="3" :controls="false" :disabled="confirmFeeSending" placeholder="请输入计费毛重" /><span>kg</span></div></td>
<th scope="row"><label for="fee-confirm-volume">计费体积</label></th>
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-volume" v-model="feeConfirmVolume" :min="0" :precision="6" :controls="false" placeholder="请输入计费体积" /><span>m³</span></div></td>
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-volume" v-model="feeConfirmVolume" :min="0" :precision="6" :controls="false" :disabled="confirmFeeSending" placeholder="请输入计费体积" /><span>m³</span></div></td>
</tr>
<tr>
<th scope="row">价格表参考单价</th>
<td>{{ feeConfirmPriceMissing ? '未命中' : feeConfirmReference?.unitPrice == null ? '正在读取参考单价' : `${formatNumber(Number(feeConfirmReference.unitPrice), 4)} ${feeConfirmPriceUnit}` }}</td>
<td>{{ feeConfirmPriceMissing ? '未命中' : feeConfirmPriceReference?.unitPrice == null ? '正在读取参考单价' : `${formatNumber(feeConfirmPriceReference.unitPrice, 4)} ${feeConfirmPriceUnit}` }}</td>
<th scope="row"><label for="fee-confirm-unit-price">本次对客单价</label></th>
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-unit-price" v-model="feeConfirmUnitPrice" :min="0" :precision="4" :controls="false" :disabled="feeConfirmReferenceLoading" placeholder="请输入对客单价" /><span>{{ feeConfirmPriceUnit }}</span></div></td>
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-unit-price" v-model="feeConfirmUnitPrice" :min="0" :precision="4" :controls="false" :disabled="feeConfirmReferenceLoading || confirmFeeSending" placeholder="请输入对客单价" /><span>{{ feeConfirmPriceUnit }}</span></div></td>
</tr>
<tr v-if="feeConfirmReference">
<th scope="row">参考计费量</th><td>{{ formatNumber(Number(feeConfirmReference.chargeableQuantity), feeConfirmReference.roundingScale) }} {{ feeConfirmReference.chargeUnit }}</td>