fix: align fleet controls and protect appointment retries
This commit is contained in:
@@ -47,6 +47,7 @@ const target = computed(() => props.inquiry?.bookingNo || props.offer?.offerNo |
|
||||
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
|
||||
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
|
||||
const lock = computed(() => !writable.value || busy.value || !!pending.value)
|
||||
const selectedCapacity = computed(() => props.offer?.capacityKnown ? props.offer.destinations.find(stop => stop.destinationKey === form.cargo.destinationKey)?.remaining : null)
|
||||
const quoteReady = computed(() => !!form.customerId && form.cargo.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.cargo.currency || ''))
|
||||
const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy, computed(() => !!pending.value))
|
||||
let generation = 0, customerSequence = 0, quoteSequence = 0
|
||||
@@ -104,6 +105,12 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '客户货物跟进' : inquiry ? '补充客户货物资料' : '登记客户货物'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : confirmMode ? '确认承运并保位' : '保存登记'" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="confirmMode">核对本票货量、价格与交货资料后确认承运。成功将保留货位并创建关联本土订单,不自动安排仓库收货。</p>
|
||||
<section v-if="confirmMode" aria-label="本目的站可接余位">
|
||||
<strong>本目的站可接余位</strong>
|
||||
<p v-if="selectedCapacity">{{ selectedCapacity.palletSpaces }} 托位 · {{ selectedCapacity.weightKg }} kg · {{ selectedCapacity.volumeCbm }} m³</p>
|
||||
<p v-else>待调度核对,当前没有可靠的可接余位。</p>
|
||||
<p>托位、重量、体积须同时满足;确认时由系统重新核验,不代表已经保位。</p>
|
||||
</section>
|
||||
<p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
|
||||
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p>
|
||||
<section v-if="inquiry" aria-label="本票处理进度"><strong>{{ inquiry.progress.label }} · {{ inquiry.progress.reservation }}</strong><p>下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}</p></section>
|
||||
|
||||
@@ -10,7 +10,7 @@ const access = useAccessPreviewStore()
|
||||
function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
|
||||
const manage = access.can('platform:fleet:capacity-offer:manage')
|
||||
const write = (kind: string) => manage || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${kind}`)
|
||||
return [{ key: 'view', label: '查看进度', priority: 10 },
|
||||
const items: RowActionItem[] = [{ key: 'view', label: '查看进度', priority: 10 },
|
||||
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit'), disabled: busy },
|
||||
{ key: 'receipt', label: '查看收货并核量', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm'), disabled: busy },
|
||||
{ key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'), disabled: busy },
|
||||
@@ -18,6 +18,9 @@ function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
|
||||
{ key: 'edit', label: '补充资料', visible: row.actions.canEdit && write('edit'), disabled: busy },
|
||||
{ key: 'confirm', label: '核对并确认', visible: (row.actions.canConfirm || row.actions.canEdit) && manage, disabled: busy },
|
||||
{ key: 'cancel', label: '取消意向', visible: row.actions.canCancel && write('cancel'), disabled: busy, tone: 'danger' }]
|
||||
const primary = items.find(action => action.visible !== false && action.key.toUpperCase() === row.progress.primaryAction)
|
||||
if (primary) { primary.priority = 20; primary.tone = 'primary' }
|
||||
return items
|
||||
}
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 320, fixed: 'left', slotName: 'actions' },
|
||||
@@ -32,7 +35,7 @@ const columns: DataTableColumn[] = [
|
||||
<template v-if="$slots['toolbar-start']" #toolbar-start><slot name="toolbar-start" /></template>
|
||||
<template v-if="$slots.filters" #filters><slot name="filters" /></template>
|
||||
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
|
||||
<template #actions="{ row }"><RowActions :max-visible="1" :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
|
||||
<template #status="{ row }"><strong>{{ row.progress.label }} · {{ row.progress.reservation }}</strong><div>下一步:{{ row.progress.owner }}</div><small class="inquiry-reason">{{ row.progress.message }}</small></template>
|
||||
<template #cargo="{ row }">{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
||||
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index'
|
||||
import { ElInputNumber } from 'element-plus/es/components/input-number/index'
|
||||
import { ElInput } from 'element-plus/es/components/input/index'
|
||||
import { createPinia } from 'pinia'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { mount as mountFull, shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
|
||||
import * as api from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
@@ -73,3 +76,40 @@ it.each([
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
|
||||
expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('anchors receipt control sizing inside the real DataTable slot and disables cell ellipsis', async () => {
|
||||
const wrapper = mountFull(TripBookingFulfillmentDialog, {
|
||||
props: { modelValue: true, trip, booking, mode: 'receipt' },
|
||||
global: { plugins: [createPinia()], components: { ElTable, ElTableColumn, ElInputNumber, ElInput }, directives: { loading: () => undefined }, stubs: {
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElButton: { template: '<button><slot/></button>' },
|
||||
AppDialog: { template: '<div><slot/></div>' }, LoadErrorState: true, ApiErrorMeta: true,
|
||||
} },
|
||||
})
|
||||
wrappers.push(wrapper); await flushPromises()
|
||||
const control = wrapper.get('.el-table__body .receipt-spaces-control')
|
||||
const scope = control.element.getAttributeNames().find(name => name.startsWith('data-v-'))
|
||||
expect(scope).toBeDefined()
|
||||
expect(control.find('.el-input-number').exists()).toBe(true)
|
||||
expect(control.element.closest('td')?.classList.contains('data-table__single-line-cell')).toBe(false)
|
||||
expect(control.element.closest('.cell')?.classList.contains('el-tooltip')).toBe(false)
|
||||
// DataTable forwards classes to its inner table, which does not carry this SFC's scope.
|
||||
expect(wrapper.find(`.receipt-pallet-table[${scope}]`).exists()).toBe(false)
|
||||
const input = control.get('input')
|
||||
await input.setValue('2'); await input.trigger('change'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('2 托位')
|
||||
})
|
||||
|
||||
it('compares real receipt with the forecast, preserves unknown values, and does not invent historical forecasts', async () => {
|
||||
const wrapper = mount('receipt'); await flushPromises()
|
||||
const rows = () => wrapper.get('[aria-label="预报与实收对照"]').findAll('tbody tr').map(row => row.findAll('td').map(cell => cell.text()))
|
||||
expect(rows()).toEqual([
|
||||
['箱数', '20', '22', '+2'], ['实托数', '—', '1', '—'], ['标准托位(待核对)', '2', '1', '-1'],
|
||||
['重量(kg)', '100', '150', '+50'], ['体积(m³)', '1', '1.5', '+0.5'],
|
||||
])
|
||||
await wrapper.setProps({ booking: { ...booking, status: 'RECEIVED' } })
|
||||
expect(rows().every(row => row[1] === '—' && row[3] === '—')).toBe(true)
|
||||
expect(wrapper.text()).toContain('无法还原原始预报')
|
||||
await wrapper.setProps({ mode: 'view' }); await flushPromises()
|
||||
expect(wrapper.find('[aria-label="预报与实收对照"]').exists()).toBe(false)
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -27,6 +27,22 @@ const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMA
|
||||
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
|
||||
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
|
||||
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', RECEIVING: '仓库收货中', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
|
||||
const receiptComparison = computed(() => {
|
||||
const incoming = preview.value?.incoming
|
||||
const pallets = incoming?.pallets || []
|
||||
const forecast = props.booking?.status === 'CONFIRMED' ? props.booking.cargo : null
|
||||
const sum = (key: 'weightKg' | 'volumeCbm') => pallets.length && pallets.every(row => row[key] != null && Number.isFinite(row[key]))
|
||||
? pallets.reduce((total, row) => total + row[key]!, 0) : null
|
||||
const values = [
|
||||
{ label: '箱数', expected: forecast?.packageCount, actual: incoming?.receivedPackages },
|
||||
{ label: '实托数', expected: forecast ? props.booking?.expectedPalletCount : null, actual: pallets.length ? pallets.length : null },
|
||||
{ label: '标准托位(待核对)', expected: forecast?.palletSpaces, actual: pallets.length ? totalSpaces.value : null },
|
||||
{ label: '重量(kg)', expected: forecast?.weightKg, actual: sum('weightKg') },
|
||||
{ label: '体积(m³)', expected: forecast?.volumeCbm, actual: sum('volumeCbm') },
|
||||
]
|
||||
return values.map(row => ({ ...row, difference: row.expected == null || row.actual == null ? null : Number((row.actual - row.expected).toFixed(3)) }))
|
||||
})
|
||||
const quantityText = (value: number | null | undefined) => value == null ? '—' : String(Number(value.toFixed(3)))
|
||||
const incomingStatusLabel = computed(() => {
|
||||
const status = preview.value?.incoming?.status
|
||||
if (status === 'READY_FOR_DISPATCH' && props.booking?.status === 'BOUND') return '已确认实收,已排入本车'
|
||||
@@ -34,7 +50,7 @@ const incomingStatusLabel = computed(() => {
|
||||
})
|
||||
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
|
||||
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 },
|
||||
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 170, singleLine: false }] : [])])
|
||||
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 180, singleLine: false, showOverflowTooltip: false }] : [])])
|
||||
async function load() {
|
||||
if (!props.booking) return
|
||||
const current = generation; loading.value = true; error.value = null
|
||||
@@ -100,7 +116,14 @@ onBeforeUnmount(() => { generation++ })
|
||||
</el-form>
|
||||
<template v-if="mode === 'receipt' || mode === 'view'">
|
||||
<el-button :loading="loading" :disabled="busy || !!pending" @click="load">刷新收货进度</el-button><p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ incomingStatusLabel }}</p>
|
||||
<DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></template></DataTable>
|
||||
<DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><div class="receipt-spaces-control"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></div></template></DataTable>
|
||||
<section v-if="mode === 'receipt'" aria-label="预报与实收对照">
|
||||
<p>预报与实收对照;差异仅供核对,容量与费用仍需确认。</p>
|
||||
<p v-if="booking.status !== 'CONFIRMED'">本票已核量,当前记录无法还原原始预报,预报及差异显示“—”。</p>
|
||||
<table class="density-table"><thead><tr><th>项目</th><th>承运预报</th><th>当前实收 / 核对值</th><th>差异(实收减预报)</th></tr></thead>
|
||||
<tbody><tr v-for="item in receiptComparison" :key="item.label"><td>{{ item.label }}</td><td>{{ quantityText(item.expected) }}</td><td>{{ quantityText(item.actual) }}</td><td>{{ item.difference == null ? '—' : item.difference > 0 ? `+${quantityText(item.difference)}` : quantityText(item.difference) }}</td></tr></tbody>
|
||||
</table>
|
||||
</section>
|
||||
<el-form v-if="mode === 'receipt'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending"><el-form-item :label="booking.cargo.priceBasis === 'ORDER' ? '确认整单价格' : '确认每托位价格'"><el-input-number v-model="unitPrice" :min="0" :precision="2" placeholder="核对约定价格" /></el-form-item></el-form>
|
||||
<p v-if="mode === 'receipt'">原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。</p>
|
||||
</template>
|
||||
@@ -108,4 +131,4 @@ onBeforeUnmount(() => { generation++ })
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>.incoming-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.incoming-form :deep(.el-date-editor),.incoming-form :deep(.el-input-number){width:100%}.receipt-pallet-table :deep(.el-input-number){width:100%;min-width:0;max-width:100%}</style>
|
||||
<style scoped>.incoming-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.incoming-form :deep(.el-date-editor),.incoming-form :deep(.el-input-number){width:100%}.receipt-spaces-control{display:flex;width:100%;min-width:0}.receipt-spaces-control :deep(.el-input-number){width:100%;min-width:0;max-width:100%}</style>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ElInputNumber } from 'element-plus/es/components/input-number/index'
|
||||
import { ElInput } from 'element-plus/es/components/input/index'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripBookingPanel from './TripBookingPanel.vue'
|
||||
@@ -45,7 +47,6 @@ it('confirms using both current versions and updates the parent version without
|
||||
expect(wrapper.findAll('button').some(node => node.text() === '确认承运')).toBe(false)
|
||||
})
|
||||
it('reopens saved negotiated price in a fresh numeric control while retaining its separate quote source', async () => {
|
||||
const { ElInputNumber, ElInput } = await vi.importActual<typeof import('element-plus')>('element-plus')
|
||||
const initial = { ...row, cargo: { ...row.cargo, unitPrice: 16 }, quoteSource: { ...source, quotedUnitPrice: 16 } }
|
||||
const saved = { ...initial, cargo: { ...initial.cargo, unitPrice: 9 }, agreedAmount: 18, recordVersion: 1 }
|
||||
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [initial] })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
@@ -223,3 +224,37 @@ it('highlights only the allowed current step and keeps other actions secondary',
|
||||
await wrapper.setProps({ inquiry: { ...inquiry, progress: { ...inquiry.progress, primaryAction: 'INCOMING' } } })
|
||||
expect(section.findAll('button').filter(item => !item.element.closest('details')).map(item => item.text())).toEqual(['安排来货'])
|
||||
})
|
||||
|
||||
it('shows the selected destination capacity when confirming and never treats unknown capacity as zero', async () => {
|
||||
state.manage = true
|
||||
const knownOffer = { ...offer, capacityKnown: true, destinations: [
|
||||
{ ...offer.destinations[0]!, remaining: { palletSpaces: 0, weightKg: 12, volumeCbm: 3 } },
|
||||
{ destinationKey: 'FBA:ACY2', name: 'ACY2', deliveryIndex: 2, remaining: { palletSpaces: 7, weightKg: 800, volumeCbm: 20 } },
|
||||
] }
|
||||
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer: knownOffer, inquiry, readonly: false, confirmMode: true })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[aria-label="本目的站可接余位"]').text()).toContain('0 托位 · 12 kg · 3 m³')
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'FBA:ACY2'); await flushPromises()
|
||||
expect(wrapper.get('[aria-label="本目的站可接余位"]').text()).toContain('7 托位 · 800 kg · 20 m³')
|
||||
await wrapper.setProps({ offer: { ...knownOffer, capacityKnown: false } })
|
||||
expect(wrapper.get('[aria-label="本目的站可接余位"]').text()).toContain('待调度核对')
|
||||
expect(wrapper.get('[aria-label="本目的站可接余位"]').text()).not.toContain('800')
|
||||
expect(api.saveAndConfirmInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
it('prioritizes the allowed current list action and keeps other actions in the overflow menu', async () => {
|
||||
state.manage = true
|
||||
const wrapper = shallowMount(CapacityInquiryTable, { props: { rows: [inquiry], total: 1, loading: false }, global: { directives: { loading: () => undefined }, stubs: {
|
||||
DataTable: { props: ['data'], template: '<div><slot name="actions" :row="data[0]"/></div>' },
|
||||
RowActions: { name: 'RowActions', props: ['actions', 'maxVisible'], template: '<div/>' },
|
||||
} } })
|
||||
wrappers.push(wrapper)
|
||||
const actions = () => wrapper.findComponent({ name: 'RowActions' }).props('actions') as { key: string; visible?: boolean; priority?: number }[]
|
||||
const primary = () => actions().filter(item => item.visible !== false).sort((a,b) => (b.priority || 0) - (a.priority || 0))[0]?.key
|
||||
expect(wrapper.findComponent({ name: 'RowActions' }).props('maxVisible')).toBe(1)
|
||||
expect(primary()).toBe('edit')
|
||||
await wrapper.setProps({ rows: [{ ...inquiry, progress: { ...inquiry.progress, primaryAction: 'INCOMING' }, actions: { ...inquiry.actions, canEdit: false, canConfirm: false } }] })
|
||||
expect(primary()).toBe('view')
|
||||
state.links.add('platform:fleet:trip:booking-edit')
|
||||
await wrapper.setProps({ rows: [{ ...inquiry, progress: { ...inquiry.progress, primaryAction: 'INCOMING' } }] })
|
||||
expect(primary()).toBe('incoming')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user