diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts index f297b23f..650f0e5a 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -20,7 +20,7 @@ vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ p vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '
' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded'], template: '
{{ detail?.planNo }}
' }, PalletLabelPrintDialog: { template: '
' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() })) vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() })) vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() })) -vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
' } })) +vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
' }, ContainerPortSchedulePanel: { name: 'ContainerPortSchedulePanel', props: ['container'], emits: ['saved'], template: '
' } })) vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), linkContainerOceanBooking: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() })) vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn() })) vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: vi.fn() })) @@ -44,6 +44,16 @@ function accessibleText(wrapper: { element: Element } | undefined) { } describe('ContainerDetailView empty return', () => { + it('passes the original container to schedule maintenance and reloads its projection after save', async () => { + const wrapper = await mountDetail('DOMESTIC') + const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }) + expect(panel.props('container').id).toBe('9001') + vi.mocked(getContainer).mockResolvedValue({ ...base, vesselName: 'REASSIGNED', voyageNo: 'V2', currentPortScheduleId: 'S1' } as never) + panel.vm.$emit('saved'); await flushPromises() + expect(getContainer).toHaveBeenCalledTimes(2) + expect(wrapper.text()).toContain('REASSIGNED / V2') + expect(wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }).props('container').currentPortScheduleId).toBe('S1') + }) beforeEach(() => { routeState.query = {} routerPush.mockReset() diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 2393b935..a3c24f88 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -11,7 +11,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import AppDialog from '@/shared/components/AppDialog.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' -import { CarrierScheduleQueryPanel } from '@/modules/port-schedules' +import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules' import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingDetail, type LoadingListMode } from '@/modules/loading-lists' import { confirmContainerOutboundDispatch, @@ -549,6 +549,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action

船名航次信息

+
船公司
{{ detail.shippingCompanyName || '—' }}
船名 / 航次
{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}
预计开船
{{ formatTime(detail.estimatedSailingAt) }}
预计到港
{{ formatTime(detail.estimatedPortArrivalAt) }}
diff --git a/src/modules/container-loading/types.ts b/src/modules/container-loading/types.ts index be31b11c..2bc833ea 100644 --- a/src/modules/container-loading/types.ts +++ b/src/modules/container-loading/types.ts @@ -16,6 +16,7 @@ export interface ContainerBookingResource { channelCode: string | null channelName: string | null oceanBookingId?: string | null + currentPortScheduleId?: string | null oceanBookingRequirementId?: string | null customerId?: string | null customerName?: string | null diff --git a/src/modules/port-schedules/api/platformPortSchedules.test.ts b/src/modules/port-schedules/api/platformPortSchedules.test.ts index 20fc0643..13d89bbc 100644 --- a/src/modules/port-schedules/api/platformPortSchedules.test.ts +++ b/src/modules/port-schedules/api/platformPortSchedules.test.ts @@ -4,3 +4,16 @@ import { createPortSchedule, listPortSchedules, updatePortSchedule } from './pla vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } } }) const data = { customerId: '1', oceanBookingId: '2', sourceType: 'MANUAL', sourceName: '美森', sourceRef: 'web', vesselName: 'MATSON', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', observedAt: '2026-08-11T10:00:00', expiresAt: '2026-08-14T10:00:00' } describe('platformPortSchedules', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes long ids in pages', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ schedule: { ...data, id: 9, scheduleNo: 'PS1', customerId: 1, oceanBookingId: 2, version: '3' }, expired: false }], page: 1, size: 20, total: 1, pages: 1 } }); const result = await listPortSchedules({ page: 1, size: 20 }); expect(result.records[0]?.schedule).toMatchObject({ id: '9', customerId: '1', oceanBookingId: '2', version: 3 }) }); it('uses idempotency and expectedVersion for writes', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { portSchedule: { schedule: { ...data, id: 9, scheduleNo: 'PS1', version: 0 }, expired: false }, histories: [] } }); vi.mocked(http.put).mockResolvedValue({ code: '200', data: { portSchedule: { schedule: { ...data, id: 9, scheduleNo: 'PS1', version: 2 }, expired: false }, histories: [] } }); await createPortSchedule(data); await updatePortSchedule('9', { expectedVersion: 1, data }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }); expect(vi.mocked(http.put).mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 1 }) }) }) + +it('passes exact container filters and caller-frozen keys and creation reason', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [], page: 1, size: 1, total: 0, pages: 0 } }) + const query = { page: 1, size: 1, customerId: '1', oceanBookingId: '2', containerBookingId: '3' } + await listPortSchedules(query) + expect(http.get).toHaveBeenLastCalledWith('/platform/v1/port-schedules', { params: query }) + const response = { code: '200', data: { portSchedule: { schedule: { ...data, id: '9', scheduleNo: 'PS1', version: 1 }, expired: false }, histories: [] } } + vi.mocked(http.post).mockResolvedValue(response); vi.mocked(http.put).mockResolvedValue(response) + await createPortSchedule({ ...data, containerBookingId: '3' }, 'create-frozen', '人工确认') + expect(http.post).toHaveBeenLastCalledWith('/platform/v1/port-schedules', { expectedVersion: 0, data: { ...data, containerBookingId: '3' }, reason: '人工确认' }, { headers: { 'Idempotency-Key': 'create-frozen' } }) + await updatePortSchedule('9', { expectedVersion: 1, data, reason: '甩柜改配' }, 'edit-frozen') + expect(http.put).toHaveBeenLastCalledWith('/platform/v1/port-schedules/9', { expectedVersion: 1, data, reason: '甩柜改配' }, { headers: { 'Idempotency-Key': 'edit-frozen' } }) +}) diff --git a/src/modules/port-schedules/api/platformPortSchedules.ts b/src/modules/port-schedules/api/platformPortSchedules.ts index 16d95d8c..9eac8ac7 100644 --- a/src/modules/port-schedules/api/platformPortSchedules.ts +++ b/src/modules/port-schedules/api/platformPortSchedules.ts @@ -20,5 +20,5 @@ export async function listPortSchedules(query: PortScheduleQuery) { return { ...data, records: (data.records || []).map(view), page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) } } export async function getPortSchedule(id: string) { const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) } -export async function createPortSchedule(data: PortScheduleData) { const response = await http.post, ApiEnvelope>(basePath, { expectedVersion: 0, data }, { headers: { 'Idempotency-Key': idem() } }); return detail(unwrapApiResponse(response)) } -export async function updatePortSchedule(id: string, request: PortScheduleSaveRequest) { const response = await http.put, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`, request, { headers: { 'Idempotency-Key': idem() } }); return detail(unwrapApiResponse(response)) } +export async function createPortSchedule(data: PortScheduleData, idempotencyKey = idem(), reason?: string) { const response = await http.post, ApiEnvelope>(basePath, { expectedVersion: 0, data, ...(reason ? { reason } : {}) }, { headers: { 'Idempotency-Key': idempotencyKey } }); return detail(unwrapApiResponse(response)) } +export async function updatePortSchedule(id: string, request: PortScheduleSaveRequest, idempotencyKey = idem()) { const response = await http.put, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`, request, { headers: { 'Idempotency-Key': idempotencyKey } }); return detail(unwrapApiResponse(response)) } diff --git a/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts b/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts new file mode 100644 index 00000000..dc0ef1f5 --- /dev/null +++ b/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import Panel from './ContainerPortSchedulePanel.vue' +import { getPortSchedule, createPortSchedule, updatePortSchedule } from '../api/platformPortSchedules' +import { ApiResponseError } from '@/shared/api/http' +const can = vi.hoisted(() => vi.fn(() => true)) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) +vi.mock('../api/platformPortSchedules', () => ({ getPortSchedule: vi.fn(), createPortSchedule: vi.fn(), updatePortSchedule: vi.fn() })) +const container = { id: 'C1', customerId: '0', customerName: '平台订舱', oceanBookingId: 'B1', currentPortScheduleId: 'S1', bookingNo: 'BOOK1', cabinetNo: 'D1', containerNo: 'TEST', vesselName: 'OLD', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', estimatedSailingAt: '2026-10-04T01:00:00', estimatedPortArrivalAt: '2026-10-20T01:00:00' } +const original = { id: 'S1', scheduleNo: 'PS1', version: 5, customerId: '0', oceanBookingId: 'B1', containerBookingId: 'C1', sourceType: 'CARRIER', sourceName: '官网', sourceRef: 'REF', vesselName: 'OLD', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', etdAt: container.estimatedSailingAt, etaAt: container.estimatedPortArrivalAt, observedAt: '2026-10-01T00:00:00', expiresAt: '2026-10-10T00:00:00', portCutoffAt: '2026-10-03T00:00:00', remark: '原备注' } +const result = () => ({ portSchedule: { schedule: { ...original }, expired: false }, histories: [] }) +interface VM { begin(): Promise; save(): Promise; form: Record; error: string; open: boolean; schedule: ReturnType | null } +function setup(value = container) { + const wrapper = mount(Panel, { props: { container: value }, global: { plugins: [ElementPlus], stubs: { AppDialog: { props: ['modelValue'], template: '
' } } } }) + return { wrapper, vm: wrapper.vm as unknown as VM } +} +describe('original container schedule', () => { + beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(getPortSchedule).mockResolvedValue(result()); vi.mocked(updatePortSchedule).mockResolvedValue(result()); vi.mocked(createPortSchedule).mockResolvedValue(result()) }) + it('loads only the server selected ID, preserves original fields, and keeps key/body after failure', async () => { + const { wrapper, vm } = setup(); await vm.begin() + expect(getPortSchedule).toHaveBeenCalledExactlyOnceWith('S1') + vm.form.vesselName = 'NEW'; vm.form.voyageNo = 'V2'; vm.form.reason = '原船满载改配' + vi.mocked(updatePortSchedule).mockRejectedValueOnce(new Error('timeout')) + await vm.save(); expect(vm.form.vesselName).toBe('NEW'); expect(vm.error).toContain('timeout') + await vm.save() + expect(vi.mocked(updatePortSchedule).mock.calls[1]).toEqual(vi.mocked(updatePortSchedule).mock.calls[0]) + expect(updatePortSchedule).toHaveBeenLastCalledWith('S1', { expectedVersion: 5, reason: '原船满载改配', data: expect.objectContaining({ vesselName: 'NEW', voyageNo: 'V2', sourceType: 'CARRIER', sourceName: '官网', sourceRef: 'REF', observedAt: original.observedAt, expiresAt: original.expiresAt, portCutoffAt: original.portCutoffAt, remark: '原备注', containerBookingId: 'C1' }) }, expect.any(String)) + expect(createPortSchedule).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() + }) + it('does not edit a shared booking schedule or another container even when ID is supplied', async () => { + vi.mocked(getPortSchedule).mockResolvedValue({ ...result(), portSchedule: { schedule: { ...original, containerBookingId: 'C2' }, expired: false } }) + const { vm, wrapper } = setup(); await vm.begin(); vm.form.reason = '改配'; await vm.save() + expect(vm.error).toContain('不能修改共享或其他柜船期'); expect(updatePortSchedule).not.toHaveBeenCalled(); wrapper.unmount() + }) + it('list-only account can see four-field history but cannot write', async () => { + can.mockImplementation((...args: unknown[]) => args[0] === 'platform:firstleg:port-schedule:list') + vi.mocked(getPortSchedule).mockResolvedValue({ ...result(), histories: [{ id: 'H1', action: 'UPDATE', reason: '改配', operatorName: '操作员', createdAt: '2026-10-03T00:00:00', beforeSnapshot: JSON.stringify(original), afterSnapshot: JSON.stringify({ ...original, vesselName: 'NEW' }) }] }) + const { wrapper, vm } = setup(); await vm.begin(); await flushPromises(); await vm.save() + expect(wrapper.text()).toContain('NEW'); expect(wrapper.text()).toContain('操作员'); expect(wrapper.text()).toContain('改配') + expect(updatePortSchedule).not.toHaveBeenCalled(); wrapper.unmount() + }) + it('creation needs explicit source and dates and locks original ownership', async () => { + const { wrapper, vm } = setup({ ...container, currentPortScheduleId: '' }); await vm.begin(); vm.form.reason = '首次登记' + await vm.save(); expect(createPortSchedule).not.toHaveBeenCalled(); expect(vm.error).toContain('来源') + Object.assign(vm.form, { sourceName: '平台人工确认', sourceRef: '船司邮件号', observedAt: '2026-10-03T00:00:00', expiresAt: '2026-10-05T00:00:00', portCutoffAt: '2026-10-03T01:00:00' }) + await vm.save(); expect(getPortSchedule).not.toHaveBeenCalled() + expect(createPortSchedule).toHaveBeenCalledWith(expect.objectContaining({ customerId: '0', oceanBookingId: 'B1', containerBookingId: 'C1', sourceType: 'MANUAL', sourceRef: '船司邮件号', expiresAt: '2026-10-05T00:00:00', portCutoffAt: '2026-10-03T01:00:00' }), expect.any(String), '首次登记') + wrapper.unmount() + }) + it('missing original ownership blocks initialization and late read cannot populate another container', async () => { + const first = setup({ ...container, currentPortScheduleId: '', oceanBookingId: '' }); await first.vm.begin(); expect(getPortSchedule).not.toHaveBeenCalled(); expect(first.wrapper.text()).toContain('不能维护船期'); first.wrapper.unmount() + let resolve!: (value: ReturnType) => void + vi.mocked(getPortSchedule).mockReturnValue(new Promise(done => { resolve = done })) + const { wrapper, vm } = setup(); const pending = vm.begin() + await wrapper.setProps({ container: { ...container, id: 'C2', currentPortScheduleId: 'S2' } }); resolve(result()); await pending + expect(vm.open).toBe(false); expect(vm.schedule).toBe(null); wrapper.unmount() + }) + it('retains edited input and original version on conflict and does not silently refresh it', async () => { + const { wrapper, vm } = setup(); await vm.begin(); vm.form.vesselName = 'CONFLICT'; vm.form.reason = '改配' + vi.mocked(updatePortSchedule).mockRejectedValue(new ApiResponseError('版本冲突', 'A0409')) + await vm.save(); expect(vm.form.vesselName).toBe('CONFLICT'); expect(vm.error).toContain('输入已保留') + expect(getPortSchedule).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toBeUndefined(); wrapper.unmount() + }) + it('separates create and edit permissions and rejects missing reason or impossible dates', async () => { + can.mockImplementation((...args: unknown[]) => args[0] !== 'platform:firstleg:port-schedule:create') + const first = setup({ ...container, currentPortScheduleId: '' }); await first.vm.begin(); await first.vm.save(); expect(createPortSchedule).not.toHaveBeenCalled(); first.wrapper.unmount() + const { wrapper, vm } = setup(); await vm.begin(); await vm.save(); expect(updatePortSchedule).not.toHaveBeenCalled() + vm.form.reason = '改配'; vm.form.etaAt = '2026-01-01T00:00:00'; await vm.save(); expect(vm.error).toContain('不能早于'); expect(updatePortSchedule).not.toHaveBeenCalled(); wrapper.unmount() + }) + it('ignores late save after closing and renders no internal IDs in snapshots', async () => { + let resolve!: (value: ReturnType) => void + vi.mocked(updatePortSchedule).mockReturnValue(new Promise(done => { resolve = done })) + const { wrapper, vm } = setup(); await vm.begin(); vm.form.vesselName = 'NEW'; vm.form.reason = '改配'; const saved = vm.save() + vm.open = false; await flushPromises(); resolve(result()); await saved + expect(wrapper.emitted('saved')).toBeUndefined(); wrapper.unmount() + }) + it('does not send another update when transport values have not changed', async () => { + const { wrapper, vm } = setup(); await vm.begin(); vm.form.reason = '重复点击' + await vm.save(); expect(updatePortSchedule).not.toHaveBeenCalled(); expect(vm.error).toContain('未变更') + expect(wrapper.text()).not.toContain('(UTC)'); wrapper.unmount() + }) +}) diff --git a/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue b/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue new file mode 100644 index 00000000..5cfdca85 --- /dev/null +++ b/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue @@ -0,0 +1,137 @@ + + + diff --git a/src/modules/port-schedules/index.ts b/src/modules/port-schedules/index.ts index 08215876..167e96a3 100644 --- a/src/modules/port-schedules/index.ts +++ b/src/modules/port-schedules/index.ts @@ -1,3 +1,4 @@ export { portScheduleRoutes } from './routes' export { default as CarrierScheduleQueryPanel } from './components/CarrierScheduleQueryPanel.vue' +export { default as ContainerPortSchedulePanel } from './components/ContainerPortSchedulePanel.vue' export * from './types' diff --git a/src/modules/port-schedules/types.ts b/src/modules/port-schedules/types.ts index 0228a2c5..84b95ecc 100644 --- a/src/modules/port-schedules/types.ts +++ b/src/modules/port-schedules/types.ts @@ -26,10 +26,10 @@ export interface PortScheduleResource { } export interface PortScheduleView { schedule: PortScheduleResource; expired: boolean } -export interface PortScheduleHistory { id: string; action: string; fromVersion?: number | null; toVersion?: number | null; reason?: string | null; operatorName?: string | null; createdAt: string } +export interface PortScheduleHistory { id: string; action: string; fromVersion?: number | null; toVersion?: number | null; reason?: string | null; operatorName?: string | null; createdAt: string; beforeSnapshot?: string | null; afterSnapshot?: string | null } export interface PortScheduleDetail { portSchedule: PortScheduleView; histories: PortScheduleHistory[] } export interface PortSchedulePage { records: PortScheduleView[]; page: number; size: number; total: number; pages: number } -export interface PortScheduleQuery { page: number; size: number; customerId?: string; keyword?: string; expired?: boolean } +export interface PortScheduleQuery { page: number; size: number; customerId?: string; oceanBookingId?: string; containerBookingId?: string; keyword?: string; expired?: boolean } export type PortScheduleData = Omit export interface PortScheduleSaveRequest { expectedVersion: number; data: PortScheduleData; reason?: string | null }