diff --git a/src/modules/inbounds/fee/InboundFeePanel.test.ts b/src/modules/inbounds/fee/InboundFeePanel.test.ts index 73c7ae96..b72aa05a 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.test.ts +++ b/src/modules/inbounds/fee/InboundFeePanel.test.ts @@ -117,6 +117,27 @@ describe('InboundFeePanel', () => { expect(wrapper.find('.surface-panel').exists()).toBe(false) }) + it('supports a display-only fee snapshot without calculation or transition actions', async () => { + vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null }) + const pinia = setupPermissions([ + 'firstleg:fba-pricing:view', 'firstleg:fba-pricing:calculate', 'firstleg:fba-pricing:adjust', + 'firstleg:fba-pricing:confirm', 'firstleg:inbound:send-fee', + ]) + const wrapper = mount(InboundFeePanel, { + props: { customerId: '10001', inboundId: '88', displayOnly: true }, + global: { plugins: [pinia, ElementPlus] }, + }) + await flushPromises() + + expect(wrapper.find('button[aria-label="刷新费用快照"]').exists()).toBe(true) + expect(wrapper.text()).not.toContain('费用试算') + expect(wrapper.text()).not.toContain('确认费用') + expect(wrapper.text()).not.toContain('确认推送费用') + expect(wrapper.find('[data-testid="adjust-fee-button"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="confirm-fee-button"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false) + }) + it('keeps the legacy integration prop compatible while the page layer uses customer context', async () => { const pinia = setupPermissions(['firstleg:fba-pricing:view']) mount(InboundFeePanel, { diff --git a/src/modules/inbounds/fee/InboundFeePanel.vue b/src/modules/inbounds/fee/InboundFeePanel.vue index db29eef6..e1b50e67 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.vue +++ b/src/modules/inbounds/fee/InboundFeePanel.vue @@ -30,6 +30,7 @@ const props = defineProps<{ tenantId?: string inboundId: string compact?: boolean + displayOnly?: boolean }>() const emit = defineEmits<{ @@ -352,20 +353,20 @@ defineExpose({ reload: loadFee }) > 费用试算 调整初算费用 确认费用 确认推送费用 { - it('blocks confirm-send when the customer has already confirmed the fee', () => { - expect(source).toContain("snapshot.customerConfirmationStatus === 'CONFIRMED'") - expect(source).toContain('客户已确认该费用,费用已锁定,不允许再修改或重新发送') - expect(source).toContain('async function openFeeConfirmDialog()') + it('shows the server confirmation lock without reading a stale fee snapshot', () => { + const openDialogSource = source.slice(source.indexOf('async function openFeeConfirmDialog()'), source.indexOf('async function executeConfirmAndSendFee()')) + const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange')) + expect(openDialogSource).not.toContain('getLatestInboundFee') + expect(sendFeeSource).toContain("ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message)") }) it('only exposes the six product main statuses', () => { @@ -88,7 +89,7 @@ describe('UnifiedOrdersView contract', () => { }) it('covers real list states and keeps operation column first and left-fixed', () => { - expect(source).toContain("{ key: 'actions', label: '操作', width: 300, fixed: 'left'") + expect(source).toContain("{ key: 'actions', label: '操作', width: 270, fixed: 'left'") expect(source).toContain("key: 'selection', type: 'selection'") expect(source).not.toContain('批量审核接口待接入') expect(source).toContain('@selection-change="handleOrderSelectionChange"') @@ -115,7 +116,7 @@ describe('UnifiedOrdersView contract', () => { expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("key: 'inboundCode'")) expect(columnSource).not.toContain("key: 'coLoadGroup'") expect(columnSource).not.toContain("key: 'orderNo'") - expect(columnSource).toContain("key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 260, slotName: 'inboundCode'") + expect(columnSource).toContain("key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 160, slotName: 'inboundCode'") expect(columnSource).toContain("{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态'") expect(columnSource).not.toContain("key: 'loadingStatus'") expect(columnSource).not.toContain("key: 'businessDate'") @@ -124,8 +125,8 @@ describe('UnifiedOrdersView contract', () => { expect(columnSource).not.toContain("key: 'recommendedPalletType'") expect(columnSource).toContain("key: 'requestedPickupAt', prop: 'requestedPickupAt', label: '预约提货时间'") expect(columnSource.match(/defaultVisible: false/g)).toHaveLength(13) - expect(columnSource).toContain("key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到货时间', minWidth: 220, slotName: 'expectedArrival' }") - expect(columnSource).toContain("key: 'estimatedVolumeCbm', prop: 'estimatedVolumeCbm', label: '预估体积(m³)', minWidth: 180, slotName: 'estimatedVolume' }") + expect(columnSource).toContain("key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到货时间', minWidth: 170, slotName: 'expectedArrival' }") + expect(columnSource).toContain("key: 'estimatedVolumeCbm', prop: 'estimatedVolumeCbm', label: '预估体积(m³)', minWidth: 120, slotName: 'estimatedVolume' }") expect(columnSource).toContain("key: 'expectedVolumeCbm', prop: 'expectedVolumeCbm', label: '下单体积(m³)', minWidth: 130, slotName: 'field' }") const planningColumnPositions = [ columnSource.indexOf("key: 'expectedArrivalAt'"), @@ -391,13 +392,13 @@ describe('UnifiedOrdersView contract', () => { it('confirms customer billing from the saved outer-carton totals', () => { expect(source).toContain('const outerTotals = measurementTotals(outerMeasurementRows.value)') expect(source).toContain("ElMessage.warning('外箱规数据不完整,请先保存外箱规尺寸和计算规则')") - expect(source).toContain("weightSource: 'OUTER'") - expect(source).toContain("volumeSource: 'OUTER'") + expect(source).toContain("weightSource: 'ACTUAL'") + expect(source).toContain("volumeSource: 'ACTUAL'") expect(source).not.toContain('测量审核通过后才能确认并发送费用') expect(source).not.toContain("reviewStatus !== 'APPROVED'") expect(source).toContain('const hasActualMeasurementData = computed(() => (measurementDetail.value?.items?.length ?? 0) > 0)') expect(source).toContain("暂无实际测量数据,不能确认并发送费用") - expect(source).toContain(':disabled="confirmFeeSending || measurementLoading || !hasActualMeasurementData || !canSendFeeForCurrentStatus"') + expect(source).not.toContain(':disabled="confirmFeeSending || measurementLoading || !hasActualMeasurementData"') expect(source).toContain('selectAndSaveOuterMeasurementMode($event)') expect(source).toContain('const saved = await updateMeasurementCalculationMode(null, mode, ruleId)') expect(source).toContain('coefficientMode: targetIds.has(String(item.id)) ? mode') @@ -408,13 +409,22 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain('item.calculatedOuterLengthCm, item.calculatedOuterWidthCm, item.calculatedOuterHeightCm') expect(source).toContain('if (dimensions.every((dimension) => dimension == null)) return true') expect(source).toContain('已保存并绑定计算规则,体积已重新计算') - expect(source).not.toContain('feeConfirmVolume.value = Number(summary.measurement?.actualVolumeCbm)') + expect(source).toContain('const measuredGrossWeight = Number(measurementDetail.value?.actualGrossWeightKg)') + expect(source).toContain('const measuredNetWeight = Number(measurementDetail.value?.actualNetWeightKg)') + expect(source).toContain('const actualVolume = Number(measurementDetail.value?.actualVolumeCbm)') + expect(source).toContain('feeConfirmWeight.value = measuredGrossWeight > 0') + expect(source).toContain('feeConfirmVolume.value = actualVolume > 0') const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange')) expect(sendFeeSource).not.toContain('saveInboundMeasurementDraft') expect(sendFeeSource).not.toContain('updateMeasurementCalculationMode') expect(source.slice(source.indexOf('async function openFeeConfirmDialog()'), source.indexOf('async function executeConfirmAndSendFee()'))).not.toContain('getLatestInboundFee') }) + it('clears an invalidated measurement loading state before refreshing order details', () => { + const detailLoaderSource = source.slice(source.indexOf('async function loadDetail(force = false)'), source.indexOf('watch([page, pageSize]')) + expect(detailLoaderSource).toContain('measurementRequestSerial += 1\n measurementLoading.value = false') + }) + it('restores the saved outer-carton rule and server-calculated dimensions on re-entry', () => { expect(source).toContain('const savedModes = [...new Set(response.items.map((item) => item.coefficientMode)') expect(source).toContain('measurementSectionModes.outer = savedModes.length === 1') @@ -502,6 +512,9 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain('InboundFeeDetailManager') expect(source).toContain(':customer-id="String(detail.summary.tenantId)"') expect(source).toContain('compact') + expect(source).toContain('display-only') + expect(source).toContain(':key="`fee-${normalizedInboundId}-${feePanelRevision}`"') + expect(source).toContain('feePanelRevision.value += 1') expect(source).toContain('头程自动初算') expect(source).toContain('其他费用明细') expect(source).toContain('@changed="loadDetail(true)"') diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index d8105839..d58d412f 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -129,6 +129,7 @@ const detailError = ref(null) const declarationEditorOpen = ref(false) const activeDetailTab = ref('basic') const automaticFee = ref(null) +const feePanelRevision = ref(0) const otherFeeSummary = ref({ currency: 'CNY', approvedReceivable: 0, approvedPayable: 0 }) const customerFeeTotal = computed(() => ( (automaticFee.value?.feeStatus === 'SENT' ? Number(automaticFee.value.totalAmount || 0) : 0) @@ -197,9 +198,13 @@ const feeConfirmWeight = ref(null) const feeConfirmVolume = ref(null) const feeConfirmRows = computed(() => { const summary = detail.value?.summary - const measurement = summary?.measurement - const actualWeight = Number(measurement?.actualGrossWeightKg) - const actualVolume = Number(measurement?.actualVolumeCbm) + const measuredGrossWeight = Number(measurementDetail.value?.actualGrossWeightKg) + const measuredNetWeight = Number(measurementDetail.value?.actualNetWeightKg) + const measuredVolume = Number(measurementDetail.value?.actualVolumeCbm) + const actualWeight = measuredGrossWeight > 0 + ? measuredGrossWeight + : measuredNetWeight > 0 ? measuredNetWeight : null + const actualVolume = measuredVolume > 0 ? measuredVolume : null const customerTotals = measurementTotals(customerMeasurementRows.value) return [ { source: '客户下单数据', count: customerTotals.count, weight: customerTotals.gross, volume: customerTotals.volume ?? summary?.expectedVolumeCbm }, @@ -434,11 +439,6 @@ const actualMeasurementRows = computed(() => (measurementDetail.value?.items || calculatedMeasurementRow(row as unknown as Record, measurementSectionModes.actual, false, false) ))) const hasActualMeasurementData = computed(() => (measurementDetail.value?.items?.length ?? 0) > 0) -const feeSendEditableStatuses = new Set([ - 'WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', - 'WAIT_PRICING', 'FEE_CONFIRMING', 'WAIT_SPLIT', -]) -const canSendFeeForCurrentStatus = computed(() => feeSendEditableStatuses.has(String(detail.value?.summary?.mainStatus || ''))) function updateMeasurementSectionMode(section: MeasurementSection, mode: MeasurementCalculationMode) { if (mode === 'MULTIPLY' && !activeMeasurementChargeRule.value) { @@ -1180,7 +1180,7 @@ async function loadMeasurementDetails() { measurementLoadedKey.value = key } catch (error) { if (serial !== measurementRequestSerial) return - measurementDetail.value = null + if (!measurementDetail.value) measurementDetail.value = null measurementError.value = getApiErrorDetails(error, '测量数据加载失败,请稍后重试') } finally { if (serial === measurementRequestSerial) measurementLoading.value = false @@ -1414,10 +1414,6 @@ async function openFeeConfirmDialog() { ElMessage.warning('暂无实际测量数据,不能确认并发送费用') return } - if (!canSendFeeForCurrentStatus.value) { - ElMessage.warning('订单已进入装柜流程,不能再修改或发送费用') - return - } if (confirmFeeSending.value) { return } @@ -1431,12 +1427,17 @@ async function openFeeConfirmDialog() { return } const actualTotals = measurementTotals(actualMeasurementRows.value) - const customerTotals = measurementTotals(customerMeasurementRows.value) - const defaultWeight = Number(outerTotals.gross) > 0 ? Number(outerTotals.gross) - : Number(actualTotals.gross) > 0 ? Number(actualTotals.gross) - : Number(customerTotals.gross) > 0 ? Number(customerTotals.gross) : null - feeConfirmWeight.value = defaultWeight - feeConfirmVolume.value = Number(outerTotals.volume) + const measuredGrossWeight = Number(measurementDetail.value?.actualGrossWeightKg) + const measuredNetWeight = Number(measurementDetail.value?.actualNetWeightKg) + const actualVolume = Number(measurementDetail.value?.actualVolumeCbm) + feeConfirmWeight.value = measuredGrossWeight > 0 + ? measuredGrossWeight + : measuredNetWeight > 0 + ? measuredNetWeight + : Number(actualTotals.gross) > 0 ? Number(actualTotals.gross) : null + feeConfirmVolume.value = actualVolume > 0 + ? actualVolume + : Number(actualTotals.volume) > 0 ? Number(actualTotals.volume) : null feeConfirmOpen.value = true } @@ -1456,13 +1457,15 @@ async function executeConfirmAndSendFee() { let snapshot = await calculateInboundFee(tenantId, inboundId, { selectedWeightKg, selectedVolumeCbm, - weightSource: 'OUTER', - volumeSource: 'OUTER', + weightSource: 'ACTUAL', + volumeSource: 'ACTUAL', }, createFeeActionIdempotencyKey('calculate')) snapshot = await confirmInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('confirm')) if (snapshot.feeStatus === 'CONFIRMED') { - await sendInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('send')) - ElMessage.success('费用已确认并发送给客户') + const sent = await sendInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('send')) + automaticFee.value = { feeStatus: sent.feeStatus, totalAmount: Number(sent.totalAmount || 0) } + feePanelRevision.value += 1 + ElMessage.success('计费重已确认并发送给客户') feeConfirmOpen.value = false await loadDetail(true) return @@ -1799,7 +1802,9 @@ async function loadDetail(force = false) { const serial = ++detailRequestSerial detailLoadingKey = requestKey measurementRequestSerial += 1 - measurementDetail.value = null + measurementLoading.value = false + // 刷新详情时保留现有测量数据,避免请求期间列表被清空。 + if (!force) measurementDetail.value = null measurementError.value = null measurementLoadedKey.value = '' measurementSectionModes.customer = 'ACTUAL' @@ -2024,11 +2029,12 @@ onBeforeUnmount(() => {

头程自动初算

其他费用明细

{ v-if="can('firstleg:inbound:confirm-send-fee')" type="primary" :loading="confirmFeeSending" - :disabled="confirmFeeSending || measurementLoading || !hasActualMeasurementData || !canSendFeeForCurrentStatus" title="确认并发送费用" data-testid="confirm-send-fee-button" @click="openFeeConfirmDialog" - >确认费用发送
+ >确认计费重发送 @@ -2125,18 +2130,7 @@ onBeforeUnmount(() => { - - - - 规则参数来源:客户管理 · 当前绑定:{{ boundOuterMeasurementChargeRule.ruleCode }} v{{ boundOuterMeasurementChargeRule.ruleVersion }} + 规则参数来源:客户管理