feat: 完善头程审核与计费信息

This commit is contained in:
2026-09-08 17:38:09 +08:00
parent cb7bb90eb6
commit dd9b8f7a63
16 changed files with 273 additions and 31 deletions
@@ -35,9 +35,11 @@ const snapshot: InboundFeeSnapshot = {
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',
destinationCode: 'LAX9', taxMode: 'TAX_REFUND', pricingTaxMode: 'TAX_INCLUDED', chargeMode: 'KG',
conversionFactor: 167, roundingScale: 2, roundingMode: 'HALF_UP',
actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6, chargeableQuantity: 133.6,
actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6,
baselineChargeableQuantity: 133.6, lowerChargeableQuantity: 120.24, upperChargeableQuantity: 146.96,
tolerancePercent: 10, 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,
@@ -88,6 +90,8 @@ describe('InboundFeePanel', () => {
await flushPromises()
expect(getLatestInboundFee).toHaveBeenCalledWith('10001', '88')
expect(document.body.textContent).toContain('订单报关:退税报关')
expect(document.body.textContent).toContain('报价税制:包税')
expect(document.body.textContent).not.toContain('租户')
expect(document.body.textContent).toContain('头程自动费用 · CNY')
expect(document.body.textContent).toContain('¥1,235.60')
@@ -237,6 +241,18 @@ describe('InboundFeePanel', () => {
expect(wrapper.find('[data-testid="adjust-fee-button"]').exists()).toBe(true)
})
it('allows a published fee adjustment until the customer confirms it', async () => {
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.get('[data-testid="adjust-fee-button"]').text()).toContain('调整已发布费用')
document.body.innerHTML = ''
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, customerConfirmationStatus: 'CONFIRMED' })
const confirmed = mountPanel()
await flushPromises()
expect(confirmed.find('[data-testid="adjust-fee-button"]').exists()).toBe(false)
})
it('confirms and then sends a calculated fee through the implemented backend transitions', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
@@ -294,7 +310,7 @@ describe('InboundFeePanel', () => {
expect(adjustPublishedInboundFee).toHaveBeenCalledWith(
'10001',
'88',
{ expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费用' },
{ expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费用', toleranceConfirmed: true },
expect.stringMatching(/^fee-adjust-/),
)
expect(wrapper.emitted('changed')?.[0]?.[0]).toMatchObject({ feeVersion: 3 })
@@ -307,6 +323,11 @@ describe('InboundFeePanel', () => {
expect(source).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))')
expect(source).toContain('class="adjustment-unit"')
expect(source).toContain('white-space: nowrap')
expect(source).toContain('snapshot.value?.lowerChargeableQuantity')
expect(source).toContain('snapshot.value?.upperChargeableQuantity')
expect(source).toContain('toleranceConfirmed: true')
expect(source).toContain("'再次确认计费量调整'")
expect(source).toContain('nextQuantity < adjustmentMinimum.value || nextQuantity > adjustmentMaximum.value')
})
it('refreshes the latest snapshot after a conflict while retaining the dialog', async () => {
+42 -5
View File
@@ -86,11 +86,17 @@ const normalizedInboundId = computed(() => {
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
})
const adjustmentEligible = computed(() => (
snapshot.value?.feeStatus === 'CALCULATED'
['CALCULATED', 'CONFIRMED', 'SENT'].includes(snapshot.value?.feeStatus || '')
&& snapshot.value?.nextProcessStatus === 'WAIT_SPLIT'
&& snapshot.value?.customerConfirmationStatus !== 'CONFIRMED'
))
const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费')
const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM')
const convertedQuantityLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '体积重量' : '重量折算体积')
const adjustmentBaseline = computed(() => Number(snapshot.value?.baselineChargeableQuantity || 0))
const adjustmentMinimum = computed(() => Number(snapshot.value?.lowerChargeableQuantity || 0))
const adjustmentMaximum = computed(() => Number(snapshot.value?.upperChargeableQuantity || 0))
const adjustmentQuantityChanged = computed(() => Math.abs(Number(adjustmentForm.chargeableQuantity) - adjustmentBaseline.value) > 1e-8)
const statusInfo = computed(() => feeStatusInfo(snapshot.value?.feeStatus))
const sourceOptions = computed(() => {
const value = measurement.value
@@ -145,6 +151,12 @@ function taxModeLabel(value: string) {
return firstLegTaxModeLabel(value)
}
function pricingTaxModeLabel(value: string | null | undefined) {
if (value === 'TAX_INCLUDED') return '包税'
if (value === 'TAX_EXCLUDED') return '不包税'
return '—'
}
function roundingModeLabel(value: string) {
const labels: Record<string, string> = {
UP: '远离零方向', DOWN: '向零方向', CEILING: '向上', FLOOR: '向下',
@@ -275,6 +287,7 @@ function adjustmentPayload(): AdjustPublishedInboundFeeRequest {
unitPrice: Number(adjustmentForm.unitPrice),
additionalAmount: Number(adjustmentForm.additionalAmount),
reason: adjustmentForm.reason.trim(),
toleranceConfirmed: true,
}
}
@@ -291,6 +304,22 @@ async function submitAdjustment() {
if (!snapshot.value) return
const valid = await adjustFormRef.value?.validate().catch(() => false)
if (!valid) return
const nextQuantity = Number(adjustmentForm.chargeableQuantity)
if (nextQuantity < adjustmentMinimum.value || nextQuantity > adjustmentMaximum.value) {
ElMessage.warning(`调整后计费量须在基准值的 ±10% 范围内(${formatNumber(adjustmentMinimum.value, 4)}–${formatNumber(adjustmentMaximum.value, 4)} ${chargeUnitLabel.value})`)
return
}
if (adjustmentQuantityChanged.value) {
try {
await ElMessageBox.confirm(
`计费量将由 ${formatNumber(adjustmentBaseline.value, 4)} 调整为 ${formatNumber(nextQuantity, 4)} ${chargeUnitLabel.value},并生成新费用版本。是否继续?`,
'再次确认计费量调整',
{ type: 'warning', confirmButtonText: '确认调整', cancelButtonText: '返回检查' },
)
} catch {
return
}
}
const payload = adjustmentPayload()
adjustmentError.value = null
conflictRefreshed.value = false
@@ -364,7 +393,7 @@ defineExpose({ reload: loadFee })
data-testid="adjust-fee-button"
type="primary"
@click="openAdjustment"
><PencilLine :size="16" />调整初算费用</el-button>
><PencilLine :size="16" />{{ snapshot?.feeStatus === 'CALCULATED' ? '调整初算费用' : '调整已发布费用' }}</el-button>
<el-button
v-if="!displayOnly && canConfirm && snapshot?.feeStatus === 'CALCULATED'"
data-testid="confirm-fee-button"
@@ -452,12 +481,15 @@ defineExpose({ reload: loadFee })
<section class="calculation-section" aria-labelledby="calculation-title">
<div class="section-title">
<div><h4 id="calculation-title">计费依据</h4><p v-if="!compact">实际重量与体积重量取大后计费,金额以服务端快照为准。</p></div>
<el-tag effect="plain">{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
<div class="calculation-tags">
<el-tag effect="plain">订单报关:{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
<el-tag effect="plain">报价税制:{{ pricingTaxModeLabel(snapshot.pricingTaxMode) }}</el-tag>
</div>
</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><span>{{ convertedQuantityLabel }}</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="费用计算公式">
@@ -517,6 +549,10 @@ defineExpose({ reload: loadFee })
@confirm="submitAdjustment"
>
<el-alert type="warning" :closable="false" show-icon title="调整会生成新的费用版本;小计和总额由服务端按计费量、单价和附加费重算。" />
<el-alert class="adjustment-alert" type="info" :closable="false" show-icon>
<template #title>计费量允许在系统原始基准值 ±{{ formatNumber(snapshot?.tolerancePercent, 2) }}% 内调整</template>
<p>系统基准 {{ formatNumber(adjustmentBaseline, 4) }} {{ chargeUnitLabel }},服务端允许范围 {{ formatNumber(adjustmentMinimum, 4) }}–{{ formatNumber(adjustmentMaximum, 4) }} {{ chargeUnitLabel }}(包含边界);超出范围将无法提交。</p>
</el-alert>
<el-alert v-if="conflictRefreshed" class="adjustment-alert" type="warning" :closable="false" show-icon>
<template #title>费用已被其他操作员更新,系统已刷新最新快照</template>
<p>当前输入仍保留,请先核对最新版本后再提交。</p>
@@ -524,7 +560,7 @@ defineExpose({ reload: loadFee })
<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="chargeableQuantity" required>
<el-input-number v-model="adjustmentForm.chargeableQuantity" aria-label="调整后计费量" :min="0.0001" :max="999999999999.9999" :precision="4" :step="1" controls-position="right" placeholder="请输入调整后计费量" />
<el-input-number v-model="adjustmentForm.chargeableQuantity" aria-label="调整后计费量" :min="adjustmentMinimum" :max="adjustmentMaximum" :precision="4" :step="1" controls-position="right" placeholder="请输入调整后计费量" />
<span class="adjustment-unit">{{ chargeUnitLabel }}</span>
</el-form-item>
<el-form-item label="调整后单价" prop="unitPrice" required>
@@ -571,6 +607,7 @@ defineExpose({ reload: loadFee })
.version-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.calculation-section, .fee-timeline { padding: 10px 0; border-bottom: 1px solid var(--cc-border); }
.section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.calculation-tags { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.section-title h4 { font-size: 14px; }
.calculation-flow { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 7px; border-top: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.calculation-flow > div { display: flex; min-width: 0; min-height: 46px; flex-direction: row; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 10px; border-right: 1px solid var(--cc-border); }
+2 -2
View File
@@ -42,7 +42,7 @@ describe('inbound fee API', () => {
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 = { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费' }
const payload = { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费', toleranceConfirmed: true as const }
await expect(adjustPublishedInboundFee('10001', '88', payload, 'fee-adjust-1'))
.resolves.toMatchObject({ feeVersion: 3 })
@@ -78,7 +78,7 @@ describe('inbound fee API', () => {
const error = await adjustPublishedInboundFee(
'10001',
'88',
{ expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '价格修正' },
{ expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '价格修正', toleranceConfirmed: true },
'fee-adjust-2',
).catch((reason: unknown) => reason)
+6
View File
@@ -26,6 +26,7 @@ export interface InboundFeeSnapshot {
carrierName: string
destinationCode: string
taxMode: string
pricingTaxMode: string | null
chargeMode: InboundChargeMode
conversionFactor: number
roundingScale: number
@@ -33,6 +34,10 @@ export interface InboundFeeSnapshot {
actualWeightKg: number
actualVolumeCbm: number
convertedQuantity: number
baselineChargeableQuantity: number
lowerChargeableQuantity: number
upperChargeableQuantity: number
tolerancePercent: number
chargeableQuantity: number
chargeUnit: string
unitPrice: number | null
@@ -58,4 +63,5 @@ export interface AdjustPublishedInboundFeeRequest {
unitPrice: number
additionalAmount: number
reason: string
toleranceConfirmed: true
}
@@ -89,6 +89,9 @@ function measurement(overrides: Partial<InboundMeasurementDetail> = {}): Inbound
items: [{
id: '9101',
inboundDetailId: '8101',
inboundCode: 'RK202608040001',
fbaShipmentNo: 'FBA18ABC1234',
scannedShipmentId: 'FBA18SCAN5678',
boxCode: 'RK202608040001#10-1',
itemNo: '001',
boxSpecification: '50×40×30cm',
@@ -168,6 +171,8 @@ describe('InboundMeasurementPanel', () => {
expect(getInboundMeasurement).toHaveBeenCalledWith('10001', '7001')
expect(wrapper.text()).toContain('RK202608040001')
expect(wrapper.find('.measurement-table').text()).toContain('FBA18ABC1234')
expect(wrapper.find('.measurement-table').text()).toContain('FBA18SCAN5678')
expect(wrapper.find('.measurement-table').text()).toContain('0.6')
expect(wrapper.find('.measurement-table').text()).not.toContain('周长')
await wrapper.find('.el-table__expand-icon').trigger('click')
@@ -32,6 +32,9 @@ const emit = defineEmits<{
type EditableMeasurementItem = Omit<SaveInboundMeasurementItemRequest, 'coefficientMode'> & {
itemId: string
clientKey: string
inboundCode: string
fbaShipmentNo: string | null
scannedShipmentId: string | null
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
chargeRuleId: string | null
coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY'
@@ -266,6 +269,9 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
itemId: String(item.id),
inboundDetailId: String(item.inboundDetailId || item.id),
clientKey: nextItemKey(),
inboundCode: item.inboundCode || '—',
fbaShipmentNo: item.fbaShipmentNo || null,
scannedShipmentId: item.scannedShipmentId || null,
boxCode: item.boxCode,
itemNo: item.itemNo,
boxSpecification: item.boxSpecification,
@@ -701,6 +707,15 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
</template>
</el-table-column>
<el-table-column prop="itemNo" label="#" width="70" align="center" />
<el-table-column label="入仓码" min-width="154" show-overflow-tooltip>
<template #default="{ row }">{{ row.inboundCode || '—' }}</template>
</el-table-column>
<el-table-column label="FBA 货件号" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.fbaShipmentNo || '—' }}</template>
</el-table-column>
<el-table-column label="设备实扫货件号" min-width="164" show-overflow-tooltip>
<template #default="{ row }">{{ row.scannedShipmentId || '—' }}</template>
</el-table-column>
<el-table-column label="箱号" min-width="156" show-overflow-tooltip>
<template #default="{ row }"><span class="measurement-box-code">{{ row.boxCode || '—' }}</span></template>
</el-table-column>
@@ -13,7 +13,10 @@ export type InboundMeasurementProcessStatus =
export interface InboundMeasurementItem {
id: MeasurementIdentifier
inboundCode?: string | null
inboundDetailId?: MeasurementIdentifier | null
fbaShipmentNo?: string | null
scannedShipmentId?: string | null
boxCode: string | null
itemNo: string
boxSpecification: string