From d77abbef7c2790724faf141640c71e20990ec55e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 11 Oct 2026 13:07:52 +0800 Subject: [PATCH] fix: align fleet controls and protect appointment retries --- .../AppointmentCalendarView.test.ts | 63 ++++++++++++++++--- .../AppointmentCalendarView.vue | 34 +++++++--- .../AppointmentCargoPanel.test.ts | 2 +- .../AppointmentCargoPanel.vue | 19 ++++-- .../AppointmentFollowupDialog.test.ts | 19 +++++- .../AppointmentFollowupDialog.vue | 22 +++++-- .../AppointmentTableRendering.test.ts | 16 ++++- .../components/CapacityInquiryDialog.vue | 7 +++ .../components/CapacityInquiryTable.vue | 7 ++- .../TripBookingFulfillmentDialog.test.ts | 42 ++++++++++++- .../TripBookingFulfillmentDialog.vue | 29 ++++++++- .../components/TripBookingPanel.test.ts | 3 +- .../pages/CapacityOffersBehavior.test.ts | 35 +++++++++++ 13 files changed, 262 insertions(+), 36 deletions(-) diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts index 8c85a47c..b03f0205 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts @@ -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()),listTripCalendar:vi.fn()})) import { resolvePlanningSource, saveFollowup, listUnscheduledAppointments } from './planningApi' -const routing = vi.hoisted(() => ({ query: {} as Record, canSourceRead: true, canPickup: true, canTrips: true })) +const routing = vi.hoisted(() => ({ query: {} as Record, 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()), 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: '
' }, - AppointmentContainerPanel: true, + AppointmentContainerPanel: { name: 'AppointmentContainerPanel', setup() { + const register = inject<(guard: () => boolean) => () => void>('appointment-register-leave-guard')! + const unregister = register(() => !routing.containerPending) + onBeforeUnmount(unregister) + }, template: '
整柜跟进工作区
' }, AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', mounted() { this.$emit('layers', { arrivals: true, trips: true }) }, props: ['events', 'incomplete', 'notices'], template: '
' }, - AppDialog: { props: ['modelValue'], template: '
' }, + AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'beforeClose'], template: '
' }, ElForm: { template: '
' }, ElFormItem: { template: '' }, ElInput: { props: ['modelValue', 'placeholder'], template: '' }, @@ -40,7 +46,7 @@ function render(openCalendar = true) { ElButton: { template: '' }, ElSelect: { name: 'ElSelect', emits: ['update:modelValue', 'change'], props: ['modelValue', 'remoteMethod'], template: '' }, ElOption: { props: ['value', 'label'], template: '' }, - DataTable: { name: 'DataTable', props: ['data'], template: '
' }, + DataTable: { name: 'DataTable', props: ['data'], template: '
' }, LoadErrorState: true, ApiErrorMeta: true, AppointmentFollowupDialog: true, AppointmentCargoPanel: { name: 'AppointmentCargoPanel', props: ['externalSource'], template: '
合批工作区
' }, } } }) 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() diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue index 22d25403..fbfeb373 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue @@ -1,7 +1,7 @@ @@ -188,7 +199,7 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller - +

{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}

{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱

货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。

diff --git a/src/modules/appointment-calendar/AppointmentFollowupDialog.test.ts b/src/modules/appointment-calendar/AppointmentFollowupDialog.test.ts index d2016ce1..05fec2f9 100644 --- a/src/modules/appointment-calendar/AppointmentFollowupDialog.test.ts +++ b/src/modules/appointment-calendar/AppointmentFollowupDialog.test.ts @@ -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: '
' }, + AppDialog: { name: 'AppDialog', props: ['confirmDisabled', 'beforeClose'], template: '
' }, ElForm: { template: '
' }, ElFormItem: { template: '' }, ElInput: { props: ['modelValue', 'placeholder'], template: '' }, 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() diff --git a/src/modules/appointment-calendar/AppointmentFollowupDialog.vue b/src/modules/appointment-calendar/AppointmentFollowupDialog.vue index 5e764230..0219510a 100644 --- a/src/modules/appointment-calendar/AppointmentFollowupDialog.vue +++ b/src/modules/appointment-calendar/AppointmentFollowupDialog.vue @@ -1,5 +1,6 @@ - + diff --git a/src/modules/overseas-master-data/components/TripBookingPanel.test.ts b/src/modules/overseas-master-data/components/TripBookingPanel.test.ts index 14df5257..41737b27 100644 --- a/src/modules/overseas-master-data/components/TripBookingPanel.test.ts +++ b/src/modules/overseas-master-data/components/TripBookingPanel.test.ts @@ -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('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] }) diff --git a/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts index 6cbbb897..efbc586b 100644 --- a/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts +++ b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts @@ -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: '
' }, + RowActions: { name: 'RowActions', props: ['actions', 'maxVisible'], template: '
' }, + } } }) + 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') +})