fix: align fleet controls and protect appointment retries

This commit is contained in:
2026-10-11 13:07:52 +08:00
parent 1edb65df77
commit d77abbef7c
13 changed files with 262 additions and 36 deletions
@@ -1,21 +1,23 @@
import { inject, onBeforeUnmount } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { createFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse'
import { createFbaAppointment, getFbaAppointment, updateFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse'
import { listArrivalCalendar, listPickupCalendar } from './api'
import { listTripCalendar } from './tripCalendar'
vi.mock('./tripCalendar', async (original) => ({...(await original<typeof import('./tripCalendar')>()),listTripCalendar:vi.fn()}))
import { resolvePlanningSource, saveFollowup, listUnscheduledAppointments } from './planningApi'
const routing = vi.hoisted(() => ({ query: {} as Record<string,string>, canSourceRead: true, canPickup: true, canTrips: true }))
const routing = vi.hoisted(() => ({ query: {} as Record<string,string>, canSourceRead: true, canPickup: true, canTrips: true, canWrite: true, containerPending: false }))
vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), listPlanningCargo: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listUnscheduledAppointments: vi.fn() }))
import { listPlatformWarehouses } from '@/modules/warehouses'
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRoute: () => routing, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), listPickupCalendar: vi.fn(), deleteAppointment: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => (permission !== 'source-read' || routing.canSourceRead) && (permission !== 'platform:firstleg:terminal-drayage:list' || routing.canPickup) && (permission !== 'platform:fleet:trip:list' || routing.canTrips) }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => (permission !== 'write' || routing.canWrite) && (permission !== 'source-read' || routing.canSourceRead) && (permission !== 'platform:firstleg:terminal-drayage:list' || routing.canPickup) && (permission !== 'platform:fleet:trip:list' || routing.canTrips) }) }))
vi.mock('@/modules/overseas-warehouse', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/modules/overseas-warehouse')>()),
createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(),
listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(),
@@ -30,9 +32,13 @@ const candidates = [
function render(openCalendar = true) {
const wrapper = mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: {
AdminPage: { template: '<main><slot /></main>' },
AppointmentContainerPanel: true,
AppointmentContainerPanel: { name: 'AppointmentContainerPanel', setup() {
const register = inject<(guard: () => boolean) => () => void>('appointment-register-leave-guard')!
const unregister = register(() => !routing.containerPending)
onBeforeUnmount(unregister)
}, template: '<div>整柜跟进工作区</div>' },
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', mounted() { this.$emit('layers', { arrivals: true, trips: true }) }, props: ['events', 'incomplete', 'notices'], template: '<div><button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button><slot name="notice-actions" /></div>' },
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /><button data-save @click="$emit(\'confirm\')">保存</button></div>' },
AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'beforeClose'], template: '<div v-if="modelValue"><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button v-if="showConfirm !== false" data-save @click="$emit(\'confirm\')">保存</button></div>' },
ElForm: { template: '<form><slot /></form>' },
ElFormItem: { template: '<label><slot /></label>' },
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
@@ -40,7 +46,7 @@ function render(openCalendar = true) {
ElButton: { template: '<button><slot /></button>' },
ElSelect: { name: 'ElSelect', emits: ['update:modelValue', 'change'], props: ['modelValue', 'remoteMethod'], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value); $emit(\'change\', $event.target.value)"><option value="">请选择</option><slot /></select>' },
ElOption: { props: ['value', 'label'], template: '<option :value="value">{{ label }}</option>' },
DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot name="toolbar-actions" /><button data-select @click="$emit(\'selection-change\', data)">选择货物</button></div>' },
DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot name="toolbar-actions" /><slot v-for="row in data" name="actions" :row="row"/><button data-select @click="$emit(\'selection-change\', data)">选择货物</button></div>' },
LoadErrorState: true, ApiErrorMeta: true, AppointmentFollowupDialog: true, AppointmentCargoPanel: { name: 'AppointmentCargoPanel', props: ['externalSource'], template: '<div>合批工作区</div>' },
} } })
if (openCalendar) void wrapper.findAll('button').find(button => button.text() === '预约日历')!.trigger('click')
@@ -50,7 +56,7 @@ function render(openCalendar = true) {
describe('appointment calendar data integration', () => {
beforeEach(() => {
vi.resetAllMocks()
routing.query = {}; routing.canSourceRead = true; routing.canPickup = true; routing.canTrips = true
routing.query = {}; routing.canSourceRead = true; routing.canPickup = true; routing.canTrips = true; routing.canWrite = true; routing.containerPending = false
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: ['ONT8', 'LAX9'].map(code => ({ id: code, tenantId: 0, warehouseCode: code, warehouseName: code, warehouseType: 'FBA' as const, status: 'ACTIVE' as const, internalCode: null, countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, remark: null })), page: 1, size: 50, pages: 1, total: 2 })
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([])
vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [], total: 0})
@@ -61,6 +67,36 @@ describe('appointment calendar data integration', () => {
vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
})
it('keeps the container view mounted while a child has an unresolved command', async () => {
const wrapper = render(); await flushPromises()
const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button')
await tabs[1]!.trigger('click'); await flushPromises()
routing.containerPending = true
await tabs[0]!.trigger('click')
expect(tabs[1]!.attributes('aria-pressed')).toBe('true')
expect(wrapper.findComponent({ name: 'AppointmentContainerPanel' }).exists()).toBe(true)
routing.containerPending = false
await tabs[0]!.trigger('click')
expect(tabs[0]!.attributes('aria-pressed')).toBe('true')
expect(wrapper.findComponent({ name: 'AppointmentContainerPanel' }).exists()).toBe(false)
wrapper.unmount()
})
it('opens an unscheduled appointment read-only without allowing a save', async () => {
routing.canWrite = false
const record = { version: 0, id: '1', appointmentNo: 'AP-READ', originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ONT8', items: [] }
vi.mocked(listUnscheduledAppointments).mockResolvedValue({ records: [record], total: 1 })
vi.mocked(getFbaAppointment).mockResolvedValue(record)
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(b => b.text() === '查看 / 继续')!.trigger('click'); await flushPromises()
expect(wrapper.find('[data-save]').exists()).toBe(false)
const dialog = wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!
dialog.vm.$emit('confirm'); await flushPromises()
expect(updateFbaAppointment).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'FbaAppointmentFields' }).props('disabled')).toBe(true)
wrapper.unmount()
})
it('puts calendar first and opens it by default without reloading when switching back', async () => {
const wrapper = render(false); await flushPromises()
const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button')
@@ -163,6 +199,17 @@ describe('appointment calendar data integration', () => {
await wrapper.get('[data-select]').trigger('click')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
const first = vi.mocked(createFbaAppointment).mock.calls[0]![0]
await wrapper.get('[data-close]').trigger('click')
expect(wrapper.find('[data-save]').exists()).toBe(true)
await wrapper.get('[data-create]').trigger('click')
expect((wrapper.get('select[aria-label="预约仓库"]').element as HTMLSelectElement).value).toBe('ONT8')
const done = vi.fn()
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
expect(done).not.toHaveBeenCalled()
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
expect(guard()).toBe(false)
}
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(createFbaAppointment).mock.calls[1]![0]).toEqual(first)
expect(first.details.appointmentAt).toBeNull()
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, provide, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource, type PlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -35,6 +35,14 @@ const selectedSource = ref<PlanningSource | null>(null)
const linkError = ref<ApiErrorDetails | null>(null)
const linkLoading = ref(false)
let linkRequest = 0
const childLeaveGuards = new Set<() => boolean>()
provide('appointment-register-leave-guard', (guard: () => boolean) => {
childLeaveGuards.add(guard)
return () => { childLeaveGuards.delete(guard) }
})
function switchWorkView(next: 'calendar' | 'cargo' | 'containers') {
if (canLeave()) workView.value = next
}
const workView = ref<'calendar' | 'cargo' | 'containers'>('calendar')
const calendarVisited = ref(true)
const showTripLayer = ref(false)
@@ -203,7 +211,16 @@ function openSource(event: CalendarEvent) {
if (event.sourcePath) void router.push(event.sourcePath)
}
function canLeave() {
if (saving.value || pendingSave.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
return [...childLeaveGuards].every(guard => guard())
}
function beforeCloseAppointment(done: () => void) { if (canLeave()) done() }
function closeAppointment() { if (canLeave()) dialogOpen.value = false }
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
function openCreate(date: string) {
if (!access.can(permission.inboundOrderAppointment) || !canLeave()) return
++candidateRequest
++detailRequest
detailLoading.value = false
@@ -225,6 +242,7 @@ function openCreate(date: string) {
}
async function openEdit(event: Pick<CalendarEvent, 'key'>) {
if (!canLeave()) return
const request = ++detailRequest
dialogError.value = null
editing.value = [...appointments.value, ...unscheduled.value].find(item => item.appointmentNo === event.key) || null
@@ -294,7 +312,7 @@ async function loadCandidates() {
}
async function save() {
if (saving.value || detailLoading.value || !detailReady.value) return
if (!access.can(permission.inboundOrderAppointment) || saving.value || detailLoading.value || !detailReady.value) return
const validation = appointmentFormError(form)
if (!pendingSave.value && validation) { ElMessage.warning(validation); return }
if (!editing.value) {
@@ -382,9 +400,9 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
<AppointmentFollowupDialog v-if="linkedSource" :source="linkedSource.source" :coverage="linkedSource.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(permission.inboundOrderAppointment)" @arrange="arrangeLinkedSource" @close="linkedSource = null" />
<div class="appointment-heading">
<div class="density-status-tabs" role="group" aria-label="预约工作视图">
<button type="button" :class="{ 'is-active': workView === 'calendar' }" :aria-pressed="workView === 'calendar'" @click="workView = 'calendar'">预约日历</button>
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'containers' }" :aria-pressed="workView === 'containers'" @click="workView = 'containers'">提柜与到仓</button>
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="workView = 'cargo'">送仓与排车</button>
<button type="button" :class="{ 'is-active': workView === 'calendar' }" :aria-pressed="workView === 'calendar'" @click="switchWorkView('calendar')">预约日历</button>
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'containers' }" :aria-pressed="workView === 'containers'" @click="switchWorkView('containers')">提柜与到仓</button>
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="switchWorkView('cargo')">送仓与排车</button>
</div>
</div>
<div v-if="cargoVisited" v-show="workView === 'cargo'"><AppointmentCargoPanel :external-source="selectedSource" @source-accepted="selectedSource = null" @created="load(); loadUnscheduled()" /></div>
@@ -405,7 +423,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
<template #issues="{ row }">{{ tripIssues(row as TripCalendarEntry) }}</template>
</DataTable>
</AppDialog>
<AppDialog v-model="dialogOpen" class="admin-density-dialog" :title="editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingSave ? '重试本次保存' : '保存'" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
<AppDialog :model-value="dialogOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :show-confirm="access.can(permission.inboundOrderAppointment)" class="admin-density-dialog" :title="editing ? (access.can(permission.inboundOrderAppointment) ? '编辑预约' : '查看预约') : '新增预约'" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingSave ? '重试本次保存' : '保存'" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
<LoadErrorState v-if="dialogError" title="预约操作失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
<div v-loading="detailLoading">
<el-form label-position="top" class="calendar-form density-form">
@@ -418,7 +436,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
</el-form>
<LoadErrorState v-if="!editing && warehouseError" title="仓库加载失败" :message="warehouseError.message" @retry="searchWarehouses(warehouseKeyword)" />
<p v-if="!editing && warehouseTotal > warehouseOptions.length && !warehouseError" class="appointment-hint">输入更完整的仓库编码或名称,缩小搜索范围。</p>
<FbaAppointmentFields :model="form" :appointment-no="editing?.appointmentNo" :timezone="editing?.timezone || warehouseOptions.find(item => item.warehouseCode === warehouse)?.timezone" :disabled="saving || !!pendingSave" />
<FbaAppointmentFields :model="form" :appointment-no="editing?.appointmentNo" :timezone="editing?.timezone || warehouseOptions.find(item => item.warehouseCode === warehouse)?.timezone" :disabled="!access.can(permission.inboundOrderAppointment) || saving || !!pendingSave" />
<template v-if="!editing && warehouse">
<DataTable :key="selectionEpoch" v-model:page="candidatePage" v-model:page-size="candidateSize" :data="candidates" :columns="columns" :total="candidateTotal" :loading="candidateLoading" row-key="lineId" :results-visible="!candidateError" @selection-change="selectCandidates" @update:page="loadCandidates()" @update:page-size="candidatePage = 1; loadCandidates()">
<template #customer="{ row }">{{ row.customerName?.trim() || '客户名称缺失' }}</template>
@@ -4,7 +4,7 @@ import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import { listPlanningCargo } from './planningApi'
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
function render() {
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, reactive } from 'vue'
import { inject, computed, onBeforeUnmount, onMounted, ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
import AppDialog from '@/shared/components/AppDialog.vue'
import { useRouter } from 'vue-router'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -61,7 +61,7 @@ function toggle(row: PlanningCargo, checked: unknown) {
}
function selectPage() { for (const row of rows.value) if (selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row }
async function previewBatch() {
if (!selectedRows.value.length || batchLoading.value) return
if (!selectedRows.value.length || batchLoading.value || creating.value || pendingCreate.value) return
const current = ++batchRequest
batchLoading.value = true; batchError.value = null
try {
@@ -133,6 +133,17 @@ async function openFollowup(row: Pick<PlanningCargo, 'sourceKey' | 'customerId'
if (request === followupRequest) following.value = result
} catch (cause) { if (request === followupRequest) followupError.value = getApiErrorDetails(cause, '来源核对失败') }
}
function canLeave() {
if (creating.value || pendingCreate.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
return true
}
function closeBatch() { if (canLeave()) batchPreview.value = null }
function beforeClose(done: () => void) { if (canLeave()) done() }
const registerLeaveGuard = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
const unregisterLeaveGuard = registerLeaveGuard?.(canLeave)
onBeforeUnmount(() => unregisterLeaveGuard?.())
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
onMounted(() => load())
onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller?.abort() })
</script>
@@ -188,7 +199,7 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
</template>
</DataTable>
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" @arrange="arrangeFollowing" @close="following = null" />
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="batchPreview = null">
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
<p>{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
@@ -1,15 +1,17 @@
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type PlanningSource, type ContainerFollowupContext } from './planningApi'
vi.mock('./planningApi', () => ({ getFollowups: vi.fn(), saveFollowup: vi.fn(), getContainerFollowups: vi.fn(), saveContainerFollowup: vi.fn() }))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
const history = { history: { records: [], page: 1, size: 10, total: 0, pages: 0 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 } }
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', canFollowup: true, blockReasons: [], orderId: '1', inboundCode: 'IN-1', bookingNo: null, containerNo: null, originWarehouseId: 'US-1', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null }
function render(canWrite = true, container?: ContainerFollowupContext) {
return mount(AppointmentFollowupDialog, { props: container ? { container, canWrite } : { source, coverage: { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }, canWrite }, global: { stubs: {
AppDialog: { props: ['confirmDisabled'], template: '<div><slot/><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></div>' },
AppDialog: { name: 'AppDialog', props: ['confirmDisabled', 'beforeClose'], template: '<div><slot/><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></div>' },
ElForm: { template: '<form><slot/></form>' }, ElFormItem: { template: '<label><slot/></label>' },
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :value="modelValue" :placeholder="placeholder" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
ElSelect: true, ElOption: true, ElDatePicker: true,
@@ -33,6 +35,12 @@ describe('appointment followup recovery', () => {
expect(saveFollowup).not.toHaveBeenCalled()
wrapper.unmount()
})
it('describes FBA followup independently of batch action visibility', async () => {
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('FBA收货跟进')
expect(wrapper.text()).not.toContain('同柜多客户共用提柜')
wrapper.unmount()
})
it('saves contact facts without requiring appointment, inbound plan or driver fields', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('已联系,等待回复')
@@ -48,6 +56,15 @@ describe('appointment followup recovery', () => {
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('等待码头开放')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
const first = vi.mocked(saveFollowup).mock.calls[0]![2]
await wrapper.get('[data-close]').trigger('click')
expect(wrapper.emitted('close')).toBeUndefined()
const done = vi.fn()
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
expect(done).not.toHaveBeenCalled()
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
expect(guard()).toBe(false)
}
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(saveFollowup).mock.calls[1]![2]).toEqual(first)
expect(first.idempotencyKey).toBeTruthy()
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { inject, computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -73,15 +74,28 @@ async function save() {
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(saveError.value.code || '')) { pending.value = null; await load() }
} finally { saving.value = false }
}
function canLeave() {
if (saving.value || pending.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
return true
}
function close() { if (canLeave()) emit('close') }
function arrange() { if (canLeave()) emit('arrange') }
function beforeClose(done: () => void) { if (canLeave()) done() }
const registerLeaveGuard = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
const unregisterLeaveGuard = registerLeaveGuard?.(canLeave)
onBeforeUnmount(() => unregisterLeaveGuard?.())
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
onMounted(load)
onBeforeUnmount(() => { ++generation })
</script>
<template>
<AppDialog :model-value="true" class="admin-density-dialog" :title="`预约跟进 · ${title}`" width="min(1080px, calc(100vw - 48px))" :confirm-text="pending ? '重试本次保存' : '保存跟进'" :confirm-loading="saving" :confirm-disabled="!canWrite || !canFollowup || !ready || loading" @confirm="save" @update:model-value="emit('close')">
<AppDialog :model-value="true" class="admin-density-dialog" :title="`预约跟进 · ${title}`" width="min(1080px, calc(100vw - 48px))" :confirm-text="pending ? '重试本次保存' : '保存跟进'" :confirm-loading="saving" :confirm-disabled="!canWrite || !canFollowup || !ready || loading" @confirm="save" @update:model-value="close" :before-close="beforeClose">
<p v-if="source">{{ source.containerNo || '—' }} · {{ source.originWarehouseName }} → {{ source.destinationWarehouseCode }}</p>
<el-button v-if="source && canArrange && source.canFollowup && !coverage?.appointments.length" :disabled="saving" @click="emit('arrange')">加入预约合批</el-button>
<p v-else>同柜多客户共用{{ container?.warehouseId ? '此接仓点' : '提柜' }}跟进记录;保存联系不自动确认预约或推进收货。</p>
<el-button v-if="source && canArrange && source.canFollowup && !coverage?.appointments.length" :disabled="saving || !!pending" @click="arrange">加入预约合批</el-button>
<p v-if="container">同柜多客户共用{{ container?.warehouseId ? '此接仓点' : '提柜' }}跟进记录;保存联系不自动确认预约或推进收货。</p>
<p v-else>本条记录用于此货源的FBA收货跟进;保存联系不自动确认预约或推进收货。</p>
<LoadErrorState v-if="error" title="跟进记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="saveError" title="跟进保存未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-for="reason in blockReasons" :key="reason">{{ reason }}</p>
@@ -1,3 +1,4 @@
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import ElementPlus from 'element-plus'
import { listImpacts } from './impactApi'
vi.mock('./impactApi', async original => ({ ...await original<typeof import('./impactApi')>(), listImpacts: vi.fn(), refreshImpacts: vi.fn() }))
@@ -9,7 +10,7 @@ import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { listExternalAppointmentCandidates, getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, createSourceAppointment, type PlanningCargo, type PlanningContainer, type PlanningSource } from './planningApi'
vi.mock('./planningApi', () => ({ listExternalAppointmentCandidates: vi.fn(), listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', orderId: '1', inboundCode: 'IN-A', bookingNo: null, containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'ORDER:1', canFollowup: true, blockReasons: [] }
const cargo: PlanningCargo = { sourceKey: source.sourceKey, customerId: 'A', customerName: '客户甲', orderId: '1', inboundCode: 'IN-A', containerBookingId: '2', containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseCode: 'US', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: null, shippedPackages: null, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: '2026-10-10T13:48:15', orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
@@ -46,7 +47,7 @@ describe('appointment tables use actual shared table slots', () => {
vi.mocked(listPlanningCargo).mockResolvedValue({records:[cargo],page:1,size:20,total:1,pages:1})
vi.mocked(previewPlanningBatch).mockResolvedValue({group:{originWarehouseId:'100',destinationKey:'FBA:ONT8',packages:null,sourceCount:2,customerCount:2,readyFrom:null,readyTo:null,readinessIncomplete:true,unknownQuantitySources:1},originWarehouseName:'美国仓',destinationWarehouseCode:'ONT8',destinationTimezone:null,sources:[external,source],missingFields:['CARGO_QUANTITY_REVIEW']})
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({id:'1',version:0,appointmentNo:'AP-MIXED',appointmentStatus:'PENDING',originWarehouseId:'100',fbaWarehouseCode:'ONT8',selectedCount:2,items:[]})
const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:external},global:{plugins:[ElementPlus],stubs:{AppDialog:{template:'<section><slot /><button data-save @click="$emit(\'confirm\')">Save</button></section>'}}}})
const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:external},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose'],template:'<section><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button data-save @click="$emit(\'confirm\')">Save</button></section>'}}}})
await flushPromises()
await wrapper.findAll('button').find(b=>b.text()==='加入本次选择')!.trigger('click')
await wrapper.findAll('button').find(b=>b.text()==='选择本页可合批货物')!.trigger('click')
@@ -54,6 +55,15 @@ describe('appointment tables use actual shared table slots', () => {
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{sourceKey:'BOOKING:B-1',customerId:'B',destinationKey:'FBA:ONT8'},{sourceKey:'ORDER:1',customerId:'A',destinationKey:'FBA:ONT8'}])
await wrapper.get('[data-save]').trigger('click');await flushPromises()
const first=vi.mocked(createSourceAppointment).mock.calls[0]![0]
await wrapper.get('[data-close]').trigger('click')
expect(wrapper.find('[data-save]').exists()).toBe(true)
const done = vi.fn()
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
expect(done).not.toHaveBeenCalled()
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
expect(guard()).toBe(false)
}
expect(first).toMatchObject({originWarehouseId:'100',fbaWarehouseCode:'ONT8',details:{appointmentStatus:'PENDING',confirmationNo:null,appointmentAt:null}})
expect(first.sources[0]!.sourceKey).toBe('BOOKING:B-1')
await wrapper.get('[data-save]').trigger('click');await flushPromises()
@@ -140,7 +150,7 @@ describe('appointment tables use actual shared table slots', () => {
})
it('keeps arrangement permission independent from contact-record writes and avoids duplicate active appointments', async () => {
vi.mocked(getFollowups).mockResolvedValue({history:{records:[],total:0,page:1,size:10,pages:0},versions:{DRAYAGE_CONTACT:0,TERMINAL_APPOINTMENT:0,WAREHOUSE_RECEIVING:0,FBA_RECEIVING:0}})
const wrapper=mount(AppointmentFollowupDialog,{props:{source,coverage,canWrite:false,canArrange:true},global:{plugins:[ElementPlus],stubs:{AppDialog:{template:'<section><slot /></section>'}}}})
const wrapper=mount(AppointmentFollowupDialog,{props:{source,coverage,canWrite:false,canArrange:true},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose'],template:'<section><slot /></section>'}}}})
await flushPromises();await wrapper.findAll('button').find(b=>b.text()==='加入预约合批')!.trigger('click')
expect(wrapper.emitted('arrange')).toHaveLength(1)
await wrapper.setProps({canArrange:false});expect(wrapper.findAll('button').some(b=>b.text()==='加入预约合批')).toBe(false)
@@ -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')
})