优化
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import {
|
||||
adjustPublishedInboundFee,
|
||||
getLatestInboundFee,
|
||||
} from './api'
|
||||
import type { InboundFeeSnapshot } from './types'
|
||||
import InboundFeePanel from './InboundFeePanel.vue'
|
||||
|
||||
vi.mock('./api', async () => {
|
||||
const actual = await vi.importActual<typeof import('./api')>('./api')
|
||||
return {
|
||||
...actual,
|
||||
getLatestInboundFee: vi.fn(),
|
||||
adjustPublishedInboundFee: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const snapshot: InboundFeeSnapshot = {
|
||||
feeId: '9007199254740993', feeNo: 'FBAF-88-2', feeVersion: 2, feeStatus: 'SENT',
|
||||
inboundId: '88', inboundCode: 'RK202608040088',
|
||||
chargeRuleId: '101', ruleCode: 'RULE-FBA-KG', chargeRuleVersion: 3,
|
||||
carrierRateId: '202', rateCode: 'RATE-LAX', carrierRateVersion: 5,
|
||||
customerId: '303', customerName: '测试客户', originWarehouseId: '404', originWarehouseName: '义乌仓',
|
||||
businessType: 'FBA_SEA', channelCode: 'SEA-TAX', carrierId: '505', carrierName: '测试船司',
|
||||
destinationCode: 'LAX9', taxMode: 'TAX_INCLUDED', chargeMode: 'KG',
|
||||
conversionFactor: 167, roundingScale: 2, roundingMode: 'HALF_UP',
|
||||
actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6, chargeableQuantity: 133.6,
|
||||
chargeUnit: 'KG', unitPrice: 8.5, subtotalAmount: 1135.6, additionalAmount: 100,
|
||||
totalAmount: 1235.6, currency: 'CNY',
|
||||
rateEffectiveFrom: '2026-08-01T00:00:00', rateEffectiveTo: null,
|
||||
calculatedAt: '2026-08-04T10:00:00', confirmedAt: '2026-08-04T10:01:00', sentAt: '2026-08-04T10:02:00',
|
||||
nextProcessStatus: 'INBOUNDED',
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function setupPermissions(permissions: string[]) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return pinia
|
||||
}
|
||||
|
||||
function mountPanel(permissions = ['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust']) {
|
||||
const pinia = setupPermissions(permissions)
|
||||
return mount(InboundFeePanel, {
|
||||
attachTo: document.body,
|
||||
props: { customerId: '10001', inboundId: '88' },
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
}
|
||||
|
||||
describe('InboundFeePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue(snapshot)
|
||||
vi.mocked(adjustPublishedInboundFee).mockResolvedValue({ ...snapshot, feeVersion: 3, feeNo: 'FBAF-88-3' })
|
||||
})
|
||||
|
||||
it('shows the immutable calculation basis, versions, amount, and timestamps', async () => {
|
||||
mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
expect(getLatestInboundFee).toHaveBeenCalledWith('10001', '88')
|
||||
expect(document.body.textContent).not.toContain('租户')
|
||||
expect(document.body.textContent).toContain('对客费用总额 · CNY')
|
||||
expect(document.body.textContent).toContain('¥1,235.60')
|
||||
expect(document.body.textContent).toContain('基础运费小计 ¥1,135.60')
|
||||
expect(document.body.textContent).toContain('附加费 ¥100.00')
|
||||
expect(document.body.textContent).toContain('133.6 KG')
|
||||
expect(document.body.textContent).toContain('按重量计费')
|
||||
expect(document.body.textContent).toContain('¥8.50 / KG')
|
||||
expect(document.body.textContent).toContain('RULE-FBA-KG · V3')
|
||||
expect(document.body.textContent).toContain('RATE-LAX · V5')
|
||||
expect(document.body.textContent).toContain('发布给客户')
|
||||
expect(document.body.textContent).not.toContain('重新试算')
|
||||
expect(document.body.textContent).not.toContain('发送费用')
|
||||
})
|
||||
|
||||
it('follows the admin detail interaction pattern without list-only scaffolding', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('button[aria-label="刷新费用快照"]').classes()).toContain('is-circle')
|
||||
expect(wrapper.get('.version-status .el-tag').text()).toBe('已发布')
|
||||
expect(wrapper.find('.query-toolbar').exists()).toBe(false)
|
||||
expect(wrapper.find('.data-table').exists()).toBe(false)
|
||||
expect(wrapper.find('.surface-panel').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, {
|
||||
props: { tenantId: 'legacy-10001', inboundId: '88' },
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(getLatestInboundFee).toHaveBeenCalledWith('legacy-10001', '88')
|
||||
expect(document.body.textContent).not.toContain('租户')
|
||||
})
|
||||
|
||||
it('keeps the prototype-aligned detail order and renders long values with pending timestamps', async () => {
|
||||
const longRuleCode = `RULE-${'LONG-'.repeat(20)}`
|
||||
const longCarrierName = `船司${'超长名称'.repeat(20)}`
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue({
|
||||
...snapshot,
|
||||
ruleCode: longRuleCode,
|
||||
carrierName: longCarrierName,
|
||||
confirmedAt: null,
|
||||
sentAt: null,
|
||||
})
|
||||
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
const content = wrapper.find('.fee-content').text()
|
||||
expect(wrapper.findAll('.fee-summary > div > span').map((node) => node.text())).toEqual([
|
||||
'对客费用总额 · CNY',
|
||||
'最终计费量',
|
||||
'快照版本',
|
||||
])
|
||||
expect(wrapper.findAll('.calculation-flow > div > span').map((node) => node.text())).toEqual([
|
||||
'实际重量',
|
||||
'实际体积',
|
||||
'折算量',
|
||||
'取大后的计费量',
|
||||
])
|
||||
expect(wrapper.findAll('.fee-detail-grid h4').map((node) => node.text())).toEqual([
|
||||
'客户计费规则',
|
||||
'船司 FBA 价格',
|
||||
])
|
||||
expect(content).toContain(longRuleCode)
|
||||
expect(content).toContain(longCarrierName)
|
||||
const pendingTimelineItems = wrapper.findAll('.fee-timeline li.pending')
|
||||
expect(pendingTimelineItems).toHaveLength(2)
|
||||
expect(pendingTimelineItems.map((item) => item.find('strong').text())).toEqual(['—', '—'])
|
||||
})
|
||||
|
||||
it('does not request or expose fees without the view permission', async () => {
|
||||
mountPanel([])
|
||||
await flushPromises()
|
||||
|
||||
expect(getLatestInboundFee).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('没有查看入库费用的权限')
|
||||
})
|
||||
|
||||
it('treats A0404 as a neutral not-yet-generated state', async () => {
|
||||
vi.mocked(getLatestInboundFee).mockRejectedValue(new ApiResponseError('费用快照不存在', 'A0404'))
|
||||
mountPanel(['firstleg:fba-pricing:view'])
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('尚未生成费用快照')
|
||||
expect(document.body.textContent).toContain('测量审核完成后')
|
||||
expect(document.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows adjustment only for authorized published inbound fees', async () => {
|
||||
mountPanel(['firstleg:fba-pricing:view'])
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[data-testid="adjust-fee-button"]')).toBeNull()
|
||||
|
||||
document.body.innerHTML = ''
|
||||
mountPanel()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[data-testid="adjust-fee-button"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('submits a reasoned adjustment and emits the refreshed snapshot', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
;(document.querySelector('[data-testid="adjust-fee-button"]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
|
||||
const numbers = [...document.querySelectorAll<HTMLInputElement>('.adjustment-form input')]
|
||||
numbers[0]!.value = '1200'
|
||||
numbers[0]!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
numbers[0]!.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
numbers[1]!.value = '80'
|
||||
numbers[1]!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
numbers[1]!.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
const reason = document.querySelector<HTMLTextAreaElement>('.adjustment-form textarea')!
|
||||
reason.value = '补充已确认的附加费用'
|
||||
reason.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
reason.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
|
||||
const confirm = [...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.includes('确认调整并生成新版本'))
|
||||
confirm?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(adjustPublishedInboundFee).toHaveBeenCalledWith(
|
||||
'10001',
|
||||
'88',
|
||||
{ subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费用' },
|
||||
expect.stringMatching(/^fee-adjust-/),
|
||||
)
|
||||
expect(wrapper.emitted('changed')?.[0]?.[0]).toMatchObject({ feeVersion: 3 })
|
||||
})
|
||||
|
||||
it('refreshes the latest snapshot after a conflict while retaining the dialog', async () => {
|
||||
vi.mocked(adjustPublishedInboundFee).mockRejectedValue(
|
||||
new ApiResponseError('费用已被其他操作员修改,请刷新后重试', 'A0409', 409),
|
||||
)
|
||||
mountPanel()
|
||||
await flushPromises()
|
||||
;(document.querySelector('[data-testid="adjust-fee-button"]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
|
||||
const numbers = [...document.querySelectorAll<HTMLInputElement>('.adjustment-form input')]
|
||||
numbers[0]!.value = '1200'
|
||||
numbers[0]!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
numbers[1]!.value = '80'
|
||||
numbers[1]!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
const reason = document.querySelector<HTMLTextAreaElement>('.adjustment-form textarea')!
|
||||
reason.value = '并发场景价格修正'
|
||||
reason.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
reason.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.includes('确认调整并生成新版本'))?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(getLatestInboundFee).toHaveBeenCalledTimes(2)
|
||||
expect(document.body.textContent).toContain('系统已刷新最新快照')
|
||||
expect(document.body.textContent).toContain('当前输入仍保留')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,477 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { CircleDollarSign, PencilLine, RefreshCw } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import {
|
||||
ApiResponseError,
|
||||
getApiErrorDetails,
|
||||
type ApiErrorDetails,
|
||||
} from '@/shared/api/http'
|
||||
import {
|
||||
adjustPublishedInboundFee,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
} from './api'
|
||||
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId?: string
|
||||
tenantId?: string
|
||||
inboundId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [snapshot: InboundFeeSnapshot]
|
||||
}>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const adjusting = ref(false)
|
||||
const notFound = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const adjustmentError = ref<ApiErrorDetails | null>(null)
|
||||
const conflictRefreshed = ref(false)
|
||||
const snapshot = ref<InboundFeeSnapshot | null>(null)
|
||||
const adjustOpen = ref(false)
|
||||
const adjustFormRef = ref<FormInstance>()
|
||||
let loadSequence = 0
|
||||
let adjustmentFingerprint = ''
|
||||
let adjustmentIdempotencyKey = ''
|
||||
|
||||
const adjustmentForm = reactive({
|
||||
subtotalAmount: 0,
|
||||
additionalAmount: 0,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const adjustmentRules: FormRules = {
|
||||
subtotalAmount: [{ required: true, message: '请输入调整后的基础运费', trigger: 'change' }],
|
||||
additionalAmount: [{ required: true, message: '请输入调整后的附加费', trigger: 'change' }],
|
||||
reason: [
|
||||
{ required: true, whitespace: true, message: '请填写费用调整原因', trigger: 'blur' },
|
||||
{ min: 2, max: 500, message: '调整原因需为 2–500 个字符', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
const canView = computed(() => access.can('firstleg:fba-pricing:view'))
|
||||
const canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust'))
|
||||
const customerContextId = computed(() => props.customerId || props.tenantId || '')
|
||||
const adjustmentEligible = computed(() => (
|
||||
snapshot.value?.feeStatus === 'SENT'
|
||||
&& snapshot.value.nextProcessStatus === 'INBOUNDED'
|
||||
))
|
||||
const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费')
|
||||
const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM')
|
||||
const statusInfo = computed(() => feeStatusInfo(snapshot.value?.feeStatus))
|
||||
|
||||
function feeStatusInfo(status?: string) {
|
||||
const map: Record<string, { label: string; type: 'success' | 'warning' | 'info' }> = {
|
||||
CALCULATED: { label: '已试算', type: 'warning' },
|
||||
CONFIRMED: { label: '已确认', type: 'info' },
|
||||
SENT: { label: '已发布', type: 'success' },
|
||||
SUPERSEDED: { label: '历史版本', type: 'info' },
|
||||
}
|
||||
return map[status || ''] || { label: status || '未知状态', type: 'info' as const }
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined, digits = 3) {
|
||||
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits }).format(Number(value))
|
||||
}
|
||||
|
||||
function formatMoney(value: number | null | undefined, currency?: string) {
|
||||
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
|
||||
try {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency', currency: currency || 'CNY', minimumFractionDigits: 2,
|
||||
}).format(Number(value))
|
||||
} catch {
|
||||
return `${formatNumber(value, 2)} ${currency || ''}`.trim()
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value.replace('T', ' ')
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function taxModeLabel(value: string) {
|
||||
return value === 'TAX_INCLUDED' ? '包税' : value === 'TAX_EXCLUDED' ? '不包税' : value
|
||||
}
|
||||
|
||||
function roundingModeLabel(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
UP: '远离零方向', DOWN: '向零方向', CEILING: '向上', FLOOR: '向下',
|
||||
HALF_UP: '四舍五入', HALF_DOWN: '五舍六入', HALF_EVEN: '银行家舍入',
|
||||
}
|
||||
return labels[value] || value
|
||||
}
|
||||
|
||||
async function loadFee(options: { preserveAdjustment?: boolean } = {}) {
|
||||
const sequence = ++loadSequence
|
||||
loadError.value = null
|
||||
notFound.value = false
|
||||
if (!customerContextId.value || !props.inboundId || !canView.value) {
|
||||
snapshot.value = null
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getLatestInboundFee(customerContextId.value, props.inboundId)
|
||||
if (sequence !== loadSequence) return
|
||||
snapshot.value = result
|
||||
if (!options.preserveAdjustment && adjustOpen.value) fillAdjustmentForm(result)
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
snapshot.value = null
|
||||
if (error instanceof ApiResponseError && error.code === 'A0404') notFound.value = true
|
||||
else loadError.value = getApiErrorDetails(error, '费用快照加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fillAdjustmentForm(value: InboundFeeSnapshot) {
|
||||
adjustmentForm.subtotalAmount = Number(value.subtotalAmount)
|
||||
adjustmentForm.additionalAmount = Number(value.additionalAmount)
|
||||
adjustmentForm.reason = ''
|
||||
adjustmentError.value = null
|
||||
conflictRefreshed.value = false
|
||||
adjustmentFingerprint = ''
|
||||
adjustmentIdempotencyKey = ''
|
||||
}
|
||||
|
||||
function openAdjustment() {
|
||||
if (!snapshot.value || !adjustmentEligible.value) return
|
||||
fillAdjustmentForm(snapshot.value)
|
||||
adjustOpen.value = true
|
||||
}
|
||||
|
||||
function adjustmentPayload(): AdjustPublishedInboundFeeRequest {
|
||||
return {
|
||||
subtotalAmount: Number(adjustmentForm.subtotalAmount),
|
||||
additionalAmount: Number(adjustmentForm.additionalAmount),
|
||||
reason: adjustmentForm.reason.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function getAdjustmentKey(payload: AdjustPublishedInboundFeeRequest) {
|
||||
const fingerprint = JSON.stringify(payload)
|
||||
if (!adjustmentIdempotencyKey || fingerprint !== adjustmentFingerprint) {
|
||||
adjustmentFingerprint = fingerprint
|
||||
adjustmentIdempotencyKey = createFeeAdjustmentIdempotencyKey()
|
||||
}
|
||||
return adjustmentIdempotencyKey
|
||||
}
|
||||
|
||||
async function submitAdjustment() {
|
||||
if (!snapshot.value) return
|
||||
const valid = await adjustFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
const payload = adjustmentPayload()
|
||||
adjustmentError.value = null
|
||||
conflictRefreshed.value = false
|
||||
adjusting.value = true
|
||||
try {
|
||||
const result = await adjustPublishedInboundFee(
|
||||
customerContextId.value,
|
||||
props.inboundId,
|
||||
payload,
|
||||
getAdjustmentKey(payload),
|
||||
)
|
||||
snapshot.value = result
|
||||
adjustOpen.value = false
|
||||
ElMessage.success(`费用已调整为第 ${result.feeVersion} 版`)
|
||||
emit('changed', result)
|
||||
} catch (error) {
|
||||
adjustmentError.value = getApiErrorDetails(error, '费用调整失败,请检查后重试')
|
||||
if (error instanceof ApiResponseError && error.code === 'A0409') {
|
||||
await loadFee({ preserveAdjustment: true })
|
||||
conflictRefreshed.value = true
|
||||
adjustmentFingerprint = ''
|
||||
adjustmentIdempotencyKey = ''
|
||||
}
|
||||
} finally {
|
||||
adjusting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [customerContextId.value, props.inboundId, access.permissionSignature],
|
||||
() => {
|
||||
adjustOpen.value = false
|
||||
snapshot.value = null
|
||||
void loadFee()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
defineExpose({ reload: loadFee })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="inbound-fee-panel" :aria-busy="loading" aria-labelledby="inbound-fee-title">
|
||||
<header class="fee-heading">
|
||||
<div class="fee-heading-copy">
|
||||
<span class="fee-heading-icon" aria-hidden="true"><CircleDollarSign :size="20" /></span>
|
||||
<div>
|
||||
<h3 id="inbound-fee-title">入库费用快照</h3>
|
||||
<p>展示审核完成后锁定的计费依据和对客费用,不在此重复触发试算或发布。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fee-heading-actions">
|
||||
<el-tooltip content="刷新费用快照" placement="top">
|
||||
<el-button
|
||||
circle
|
||||
aria-label="刷新费用快照"
|
||||
:loading="loading"
|
||||
:disabled="!customerContextId || !inboundId || !canView"
|
||||
@click="loadFee()"
|
||||
><RefreshCw :size="16" /></el-button>
|
||||
</el-tooltip>
|
||||
<el-button
|
||||
v-if="canAdjust && adjustmentEligible"
|
||||
data-testid="adjust-fee-button"
|
||||
type="primary"
|
||||
@click="openAdjustment"
|
||||
><PencilLine :size="16" />调整已发布费用</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
|
||||
<template #title>当前账号没有查看入库费用的权限</template>
|
||||
<p>需要权限:firstleg:fba-pricing:view</p>
|
||||
</el-alert>
|
||||
|
||||
<div v-else-if="loading && !snapshot" class="fee-loading" aria-live="polite">
|
||||
<el-skeleton :rows="6" animated />
|
||||
<span class="sr-only">正在加载费用快照</span>
|
||||
</div>
|
||||
|
||||
<el-alert v-else-if="loadError" type="error" :closable="false" show-icon role="alert">
|
||||
<template #title>费用快照加载失败</template>
|
||||
<p>{{ loadError.message }}</p>
|
||||
<ApiErrorMeta :details="loadError" />
|
||||
<el-button size="small" @click="loadFee()">重新加载</el-button>
|
||||
</el-alert>
|
||||
|
||||
<el-empty v-else-if="notFound" :image-size="64" description="尚未生成费用快照">
|
||||
<p class="empty-note">测量审核完成后,系统会自动生成并发布费用。</p>
|
||||
</el-empty>
|
||||
|
||||
<div v-else-if="snapshot" class="fee-content">
|
||||
<div class="fee-summary" aria-label="费用关键结果">
|
||||
<div class="fee-total">
|
||||
<span>对客费用总额 · {{ snapshot.currency }}</span>
|
||||
<strong>{{ formatMoney(snapshot.totalAmount, snapshot.currency) }}</strong>
|
||||
<small>基础运费小计 {{ formatMoney(snapshot.subtotalAmount, snapshot.currency) }} · 附加费 {{ formatMoney(snapshot.additionalAmount, snapshot.currency) }}</small>
|
||||
</div>
|
||||
<div class="fee-summary-item">
|
||||
<span>最终计费量</span>
|
||||
<strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong>
|
||||
<small>{{ chargeModeLabel }}</small>
|
||||
</div>
|
||||
<div class="fee-summary-item">
|
||||
<span>快照版本</span>
|
||||
<div class="version-status"><strong>V{{ snapshot.feeVersion }}</strong><el-tag :type="statusInfo.type" effect="light">{{ statusInfo.label }}</el-tag></div>
|
||||
<small>{{ snapshot.feeNo }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="calculation-section" aria-labelledby="calculation-title">
|
||||
<div class="section-title">
|
||||
<div><h4 id="calculation-title">计费过程</h4><p>实际量与折算量取大后计费,金额以服务端快照为准。</p></div>
|
||||
<el-tag effect="plain">{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
|
||||
</div>
|
||||
<div class="calculation-flow">
|
||||
<div><span>实际重量</span><strong>{{ formatNumber(snapshot.actualWeightKg, 3) }} KG</strong></div>
|
||||
<div><span>实际体积</span><strong>{{ formatNumber(snapshot.actualVolumeCbm, 4) }} CBM</strong></div>
|
||||
<div><span>折算量</span><strong>{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong><small>系数 {{ formatNumber(snapshot.conversionFactor, 4) }}</small></div>
|
||||
<div class="is-chargeable"><span>取大后的计费量</span><strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong></div>
|
||||
</div>
|
||||
<div class="amount-equation" aria-label="费用计算公式">
|
||||
<span>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</span>
|
||||
<i>×</i>
|
||||
<span>{{ formatMoney(snapshot.unitPrice, snapshot.currency) }} / {{ chargeUnitLabel }}</span>
|
||||
<i>+</i>
|
||||
<span>附加费 {{ formatMoney(snapshot.additionalAmount, snapshot.currency) }}</span>
|
||||
<i>=</i>
|
||||
<strong>{{ formatMoney(snapshot.totalAmount, snapshot.currency) }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="fee-detail-grid">
|
||||
<section aria-labelledby="rule-title">
|
||||
<div class="section-title"><div><h4 id="rule-title">客户计费规则</h4><p>{{ snapshot.ruleCode }} · V{{ snapshot.chargeRuleVersion }}</p></div></div>
|
||||
<dl>
|
||||
<div><dt>客户</dt><dd>{{ snapshot.customerName }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ snapshot.businessType }}</dd></div>
|
||||
<div><dt>计费口径</dt><dd>{{ chargeModeLabel }}</dd></div>
|
||||
<div><dt>舍入规则</dt><dd>保留 {{ snapshot.roundingScale }} 位,{{ roundingModeLabel(snapshot.roundingMode) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section aria-labelledby="rate-title">
|
||||
<div class="section-title"><div><h4 id="rate-title">船司 FBA 价格</h4><p>{{ snapshot.rateCode }} · V{{ snapshot.carrierRateVersion }}</p></div></div>
|
||||
<dl>
|
||||
<div><dt>起运仓</dt><dd>{{ snapshot.originWarehouseName }}</dd></div>
|
||||
<div><dt>渠道 / 船司</dt><dd>{{ snapshot.channelCode }} / {{ snapshot.carrierName }}</dd></div>
|
||||
<div><dt>FBA 目的仓</dt><dd>{{ snapshot.destinationCode }}</dd></div>
|
||||
<div><dt>价格有效期</dt><dd>{{ formatDateTime(snapshot.rateEffectiveFrom) }} 至 {{ formatDateTime(snapshot.rateEffectiveTo) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="fee-timeline" aria-labelledby="fee-timeline-title">
|
||||
<div class="section-title"><div><h4 id="fee-timeline-title">快照时间</h4><p>每个节点均由后端状态机记录。</p></div></div>
|
||||
<ol>
|
||||
<li><span>试算</span><strong>{{ formatDateTime(snapshot.calculatedAt) }}</strong></li>
|
||||
<li :class="{ pending: !snapshot.confirmedAt }"><span>确认</span><strong>{{ formatDateTime(snapshot.confirmedAt) }}</strong></li>
|
||||
<li :class="{ pending: !snapshot.sentAt }"><span>发布给客户</span><strong>{{ formatDateTime(snapshot.sentAt) }}</strong></li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<AppDialog
|
||||
v-model="adjustOpen"
|
||||
title="调整已发布费用"
|
||||
width="min(560px, calc(100vw - 28px))"
|
||||
confirm-text="确认调整并生成新版本"
|
||||
confirm-type="warning"
|
||||
:confirm-loading="adjusting"
|
||||
@confirm="submitAdjustment"
|
||||
>
|
||||
<el-alert type="warning" :closable="false" show-icon title="调整会生成新的已发布版本,不会回退实物入库状态。" />
|
||||
<el-alert v-if="conflictRefreshed" class="adjustment-alert" type="warning" :closable="false" show-icon>
|
||||
<template #title>费用已被其他操作员更新,系统已刷新最新快照</template>
|
||||
<p>当前输入仍保留,请先核对最新版本后再提交。</p>
|
||||
</el-alert>
|
||||
<el-alert v-if="adjustmentError" class="adjustment-alert" type="error" :closable="false" show-icon role="alert">
|
||||
<template #title>费用调整失败</template>
|
||||
<p>{{ adjustmentError.message }}</p>
|
||||
<ApiErrorMeta :details="adjustmentError" />
|
||||
</el-alert>
|
||||
<el-form ref="adjustFormRef" :model="adjustmentForm" :rules="adjustmentRules" label-position="top" class="adjustment-form" :disabled="adjusting">
|
||||
<div class="adjustment-amounts">
|
||||
<el-form-item label="调整后基础运费" prop="subtotalAmount" required>
|
||||
<el-input-number v-model="adjustmentForm.subtotalAmount" aria-label="调整后基础运费" :min="0" :max="999999999999.99" :precision="2" :step="100" controls-position="right" />
|
||||
<span>{{ snapshot?.currency }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="调整后附加费" prop="additionalAmount" required>
|
||||
<el-input-number v-model="adjustmentForm.additionalAmount" aria-label="调整后附加费" :min="0" :max="999999999999.99" :precision="2" :step="10" controls-position="right" />
|
||||
<span>{{ snapshot?.currency }}</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="adjustment-total"><span>调整后总额</span><strong>{{ formatMoney(adjustmentForm.subtotalAmount + adjustmentForm.additionalAmount, snapshot?.currency) }}</strong></div>
|
||||
<el-form-item label="调整原因" prop="reason" required>
|
||||
<el-input v-model="adjustmentForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="说明价格变更、补充费用或修正原因,记录将进入审计历史" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-fee-panel { min-width: 0; color: var(--cc-text); }
|
||||
.fee-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 16px; }
|
||||
.fee-heading-copy { display: flex; min-width: 0; align-items: flex-start; gap: 12px; }
|
||||
.fee-heading-icon { display: grid; width: 38px; height: 38px; flex: 0 0 38px; place-items: center; border-radius: 10px; background: #eef4ff; color: var(--cc-primary); }
|
||||
.fee-heading h3, .section-title h4 { margin: 0; }
|
||||
.fee-heading h3 { font-size: 16px; }
|
||||
.fee-heading p, .section-title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.fee-heading-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.fee-heading-actions .el-button { margin-left: 0; }
|
||||
.fee-loading { padding: 20px 0; }
|
||||
.inbound-fee-panel :deep(.el-alert p) { margin: 5px 0 0; line-height: 1.55; }
|
||||
.inbound-fee-panel :deep(.el-alert .el-button) { margin-top: 8px; }
|
||||
.empty-note { margin: 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.fee-content { border-top: 1px solid var(--cc-border); }
|
||||
.fee-summary { display: grid; grid-template-columns: minmax(260px, 1.35fr) repeat(2, minmax(180px, 1fr)); border-bottom: 1px solid var(--cc-border); background: #f8fafc; }
|
||||
.fee-total, .fee-summary-item { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 6px; padding: 20px; }
|
||||
.fee-summary-item { border-left: 1px solid var(--cc-border); }
|
||||
.fee-summary span, .fee-summary small { color: var(--cc-muted); font-size: 11px; }
|
||||
.fee-summary strong { font-size: 18px; line-height: 1.25; overflow-wrap: anywhere; }
|
||||
.fee-total strong { color: #0f5c49; font-size: 27px; letter-spacing: -.02em; }
|
||||
.version-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.calculation-section, .fee-timeline { padding: 22px 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.section-title h4 { font-size: 14px; }
|
||||
.calculation-flow { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 16px; border: 1px solid var(--cc-border); border-radius: 12px; overflow: hidden; }
|
||||
.calculation-flow > div { display: flex; min-width: 0; min-height: 88px; flex-direction: column; justify-content: center; gap: 6px; padding: 14px 16px; border-right: 1px solid var(--cc-border); }
|
||||
.calculation-flow > div:last-child { border-right: 0; }
|
||||
.calculation-flow span, .calculation-flow small { color: var(--cc-muted); font-size: 11px; }
|
||||
.calculation-flow strong { font-size: 15px; overflow-wrap: anywhere; }
|
||||
.calculation-flow .is-chargeable { background: #f0fdf9; }
|
||||
.calculation-flow .is-chargeable strong { color: #087d70; }
|
||||
.amount-equation { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; padding: 12px 14px; border-radius: 8px; background: #f8fafc; color: #475467; font-size: 12px; }
|
||||
.amount-equation i { color: #98a2b3; font-style: normal; }
|
||||
.amount-equation strong { color: var(--cc-text); font-size: 15px; }
|
||||
.fee-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-bottom: 1px solid var(--cc-border); }
|
||||
.fee-detail-grid > section { min-width: 0; padding: 22px 20px 22px 0; }
|
||||
.fee-detail-grid > section + section { padding-right: 0; padding-left: 20px; border-left: 1px solid var(--cc-border); }
|
||||
.fee-detail-grid dl { margin: 14px 0 0; }
|
||||
.fee-detail-grid dl > div { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 12px; padding: 9px 0; border-top: 1px solid #edf0f4; font-size: 12px; }
|
||||
.fee-detail-grid dt { color: var(--cc-muted); }
|
||||
.fee-detail-grid dd { min-width: 0; margin: 0; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.fee-timeline { border-bottom: 0; }
|
||||
.fee-timeline ol { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; margin: 18px 0 0; padding: 0; list-style: none; }
|
||||
.fee-timeline li { position: relative; display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 0 18px 0 20px; }
|
||||
.fee-timeline li::before { position: absolute; top: 4px; left: 0; width: 9px; height: 9px; border-radius: 50%; background: #12b76a; content: ''; }
|
||||
.fee-timeline li::after { position: absolute; top: 8px; right: 0; left: 10px; z-index: -1; height: 1px; background: var(--cc-border); content: ''; }
|
||||
.fee-timeline li:last-child::after { display: none; }
|
||||
.fee-timeline li.pending::before { background: #98a2b3; }
|
||||
.fee-timeline span { color: var(--cc-muted); font-size: 11px; }
|
||||
.fee-timeline strong { font-size: 12px; overflow-wrap: anywhere; }
|
||||
.adjustment-alert { margin-top: 12px; }
|
||||
.adjustment-form { margin-top: 18px; }
|
||||
.adjustment-amounts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.adjustment-amounts :deep(.el-form-item__content) { flex-wrap: nowrap; }
|
||||
.adjustment-amounts :deep(.el-input-number) { width: 100%; }
|
||||
.adjustment-amounts :deep(.el-form-item__content > span) { margin-left: 8px; color: var(--cc-muted); font-size: 12px; }
|
||||
.adjustment-total { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin: 0 0 18px; padding: 13px 14px; border-radius: 8px; background: #f8fafc; }
|
||||
.adjustment-total span { color: var(--cc-muted); font-size: 12px; }
|
||||
.adjustment-total strong { font-size: 18px; }
|
||||
.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) {
|
||||
.fee-summary { grid-template-columns: 1fr 1fr; }
|
||||
.fee-total { grid-column: 1 / -1; }
|
||||
.fee-summary-item:first-of-type { border-left: 0; }
|
||||
.calculation-flow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.calculation-flow > div:nth-child(2) { border-right: 0; }
|
||||
.calculation-flow > div:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.fee-heading { align-items: stretch; flex-direction: column; }
|
||||
.fee-heading-actions { justify-content: flex-start; }
|
||||
.fee-heading-actions .el-button:not(.is-circle) { flex: 1; min-height: 44px; }
|
||||
.fee-summary, .calculation-flow, .fee-detail-grid, .fee-timeline ol, .adjustment-amounts { grid-template-columns: 1fr; }
|
||||
.fee-summary-item { border-top: 1px solid var(--cc-border); border-left: 0; }
|
||||
.calculation-flow > div { min-height: 76px; border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.calculation-flow > div:last-child { border-bottom: 0; }
|
||||
.fee-detail-grid > section, .fee-detail-grid > section + section { padding: 20px 0; border-left: 0; }
|
||||
.fee-detail-grid > section + section { border-top: 1px solid var(--cc-border); }
|
||||
.fee-timeline ol { gap: 16px; }
|
||||
.fee-timeline li { padding-left: 20px; }
|
||||
.fee-timeline li::after { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.fee-heading-copy { gap: 8px; }
|
||||
.fee-heading-icon { width: 34px; height: 34px; flex-basis: 34px; }
|
||||
.fee-total, .fee-summary-item { padding: 16px 12px; }
|
||||
.fee-total strong { font-size: 22px; }
|
||||
.fee-detail-grid dl > div { grid-template-columns: 1fr; gap: 4px; }
|
||||
.amount-equation { align-items: flex-start; flex-direction: column; }
|
||||
.amount-equation i { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import {
|
||||
adjustPublishedInboundFee,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
} from './api'
|
||||
|
||||
vi.mock('@/shared/api/http', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http')
|
||||
return { ...actual, http: { get: vi.fn(), put: vi.fn() } }
|
||||
})
|
||||
|
||||
const snapshot = {
|
||||
feeId: '9007199254740993',
|
||||
feeNo: 'FBAF-88-2',
|
||||
feeVersion: 2,
|
||||
feeStatus: 'SENT',
|
||||
inboundId: '88',
|
||||
totalAmount: 1280,
|
||||
}
|
||||
|
||||
describe('inbound fee API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('loads the latest immutable fee snapshot with string IDs', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: snapshot })
|
||||
|
||||
await expect(getLatestInboundFee('10001', '88')).resolves.toEqual(snapshot)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/latest', {
|
||||
params: { tenantId: '10001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('adjusts a published snapshot with the required idempotency key', async () => {
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, feeVersion: 3 } })
|
||||
const payload = { subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费' }
|
||||
|
||||
await expect(adjustPublishedInboundFee('10001', '88', payload, 'fee-adjust-1'))
|
||||
.resolves.toMatchObject({ feeVersion: 3 })
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
'/platform/v1/inbounds/88/fees/published',
|
||||
payload,
|
||||
{ params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-adjust-1' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves backend conflict metadata for stale-version recovery', async () => {
|
||||
vi.mocked(http.put).mockResolvedValue({
|
||||
code: 'A0409', message: '费用已被其他操作员修改,请刷新后重试', data: null,
|
||||
requestId: 'request-fee-409', traceId: 'trace-fee-409',
|
||||
})
|
||||
|
||||
const error = await adjustPublishedInboundFee(
|
||||
'10001',
|
||||
'88',
|
||||
{ subtotalAmount: 1200, additionalAmount: 80, reason: '价格修正' },
|
||||
'fee-adjust-2',
|
||||
).catch((reason: unknown) => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(ApiResponseError)
|
||||
expect(error).toMatchObject({ code: 'A0409', requestId: 'request-fee-409', traceId: 'trace-fee-409' })
|
||||
})
|
||||
|
||||
it('creates bounded unique keys for adjustment retries', () => {
|
||||
const first = createFeeAdjustmentIdempotencyKey()
|
||||
const second = createFeeAdjustmentIdempotencyKey()
|
||||
expect(first).toMatch(/^fee-adjust-/)
|
||||
expect(first.length).toBeLessThanOrEqual(128)
|
||||
expect(second).not.toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
|
||||
|
||||
function feeBasePath(inboundId: string) {
|
||||
return `/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees`
|
||||
}
|
||||
|
||||
export async function getLatestInboundFee(tenantId: string, inboundId: string) {
|
||||
const response = await http.get<unknown, ApiEnvelope<InboundFeeSnapshot>>(
|
||||
`${feeBasePath(inboundId)}/latest`,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function adjustPublishedInboundFee(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
payload: AdjustPublishedInboundFeeRequest,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const response = await http.put<unknown, ApiEnvelope<InboundFeeSnapshot>>(
|
||||
`${feeBasePath(inboundId)}/published`,
|
||||
payload,
|
||||
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export function createFeeAdjustmentIdempotencyKey() {
|
||||
const uuid = globalThis.crypto?.randomUUID?.()
|
||||
if (uuid) return `fee-adjust-${uuid}`
|
||||
return `fee-adjust-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export { default as InboundFeePanel } from './InboundFeePanel.vue'
|
||||
export {
|
||||
adjustPublishedInboundFee,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
} from './api'
|
||||
export type {
|
||||
AdjustPublishedInboundFeeRequest,
|
||||
InboundChargeMode,
|
||||
InboundFeeSnapshot,
|
||||
InboundFeeStatus,
|
||||
} from './types'
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export type InboundFeeStatus = 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'SUPERSEDED' | string
|
||||
export type InboundChargeMode = 'KG' | 'CBM' | string
|
||||
|
||||
export interface InboundFeeSnapshot {
|
||||
feeId: string
|
||||
feeNo: string
|
||||
feeVersion: number
|
||||
feeStatus: InboundFeeStatus
|
||||
inboundId: string
|
||||
inboundCode: string | null
|
||||
chargeRuleId: string
|
||||
ruleCode: string
|
||||
chargeRuleVersion: number
|
||||
carrierRateId: string
|
||||
rateCode: string
|
||||
carrierRateVersion: number
|
||||
customerId: string
|
||||
customerName: string
|
||||
originWarehouseId: string
|
||||
originWarehouseName: string
|
||||
businessType: string
|
||||
channelCode: string
|
||||
carrierId: string
|
||||
carrierName: string
|
||||
destinationCode: string
|
||||
taxMode: string
|
||||
chargeMode: InboundChargeMode
|
||||
conversionFactor: number
|
||||
roundingScale: number
|
||||
roundingMode: string
|
||||
actualWeightKg: number
|
||||
actualVolumeCbm: number
|
||||
convertedQuantity: number
|
||||
chargeableQuantity: number
|
||||
chargeUnit: string
|
||||
unitPrice: number | null
|
||||
subtotalAmount: number
|
||||
additionalAmount: number
|
||||
totalAmount: number
|
||||
currency: string
|
||||
rateEffectiveFrom: string
|
||||
rateEffectiveTo: string | null
|
||||
calculatedAt: string
|
||||
confirmedAt: string | null
|
||||
sentAt: string | null
|
||||
nextProcessStatus: string | null
|
||||
}
|
||||
|
||||
export interface AdjustPublishedInboundFeeRequest {
|
||||
subtotalAmount: number
|
||||
additionalAmount: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user