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 AppDialog from '@/shared/components/AppDialog.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', billOfLadingNo: 'BL1', 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, realDialog = false) { const wrapper = mount(Panel, { props: { container: value }, global: { plugins: [ElementPlus], stubs: realDialog ? {} : { 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 unlocks editing and uses a fresh key after failure', async () => { const { wrapper, vm } = setup(container, true); 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 flushPromises() const dialog = wrapper.getComponent(AppDialog) expect(dialog.props('title')).toBe('原柜船期与修改历史 · D1') expect(dialog.getComponent({ name: 'ElForm' }).props('disabled')).toBe(false) await dialog.get('[aria-label="展开全屏"]').trigger('click') expect(dialog.find('.el-dialog.is-fullscreen').exists()).toBe(true) expect(dialog.get('input[placeholder="请输入船名"]').element.value).toBe('NEW') await vm.save() expect(vi.mocked(updatePortSchedule).mock.calls[1]![1]).toEqual(vi.mocked(updatePortSchedule).mock.calls[0]![1]); expect(vi.mocked(updatePortSchedule).mock.calls[1]![2]).not.toBe(vi.mocked(updatePortSchedule).mock.calls[0]![2]) 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(container, true); await vm.begin(); await flushPromises(); await vm.save() expect(wrapper.text()).toContain('NEW'); expect(wrapper.text()).toContain('操作员'); expect(wrapper.text()).toContain('改配') const dialog = wrapper.getComponent(AppDialog) expect(dialog.get('table[aria-label="当前船期只读资料"]').text()).toContain('OLD') expect(dialog.findAll('input, textarea')).toHaveLength(0) expect(dialog.props('showConfirm')).toBe(false) expect(updatePortSchedule).not.toHaveBeenCalled(); wrapper.unmount() }) it('creation needs explicit source and dates and locks original ownership', async () => { const { wrapper, vm } = setup({ ...container, currentPortScheduleId: '' }, true); await vm.begin(); vm.form.reason = '首次登记' await flushPromises() const dialog = wrapper.getComponent(AppDialog) expect(dialog.props('title')).toBe('登记原柜船期 · D1') expect(dialog.findAllComponents({ name: 'ElFormItem' })).toHaveLength(12) expect(dialog.get('table[aria-label="原柜船期归属"]').text()).toContain('平台订舱') await dialog.get('[aria-label="展开全屏"]').trigger('click') expect(vm.form.reason).toBe('首次登记') 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()).not.toContain('不能维护船期'); expect(first.wrapper.find('button').attributes('disabled')).toBeDefined(); 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() }) })