import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import ElementPlus, { ElSelect } from 'element-plus' import { ApiResponseError } from '@/shared/api/http' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformPartners } from '@/modules/master-data' import { listPlatformWarehouses } from '@/modules/warehouses' import { createOceanBooking, getOceanBooking, listOceanBookingAgents, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft, updateOceanBookingActualCargo, } from '../api/platformOceanBookings' import type { OceanBookingDetail, OceanBookingStatus } from '../types' import OceanBookingDetailView from './OceanBookingDetailView.vue' import AppDialog from '@/shared/components/AppDialog.vue' import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue' const routerReplace = vi.hoisted(() => vi.fn()) const routeState = vi.hoisted(() => ({ name: 'ocean-booking-detail', params: { bookingId: '123' } })) vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: vi.fn(), replace: routerReplace }), })) const deniedPermissions = vi.hoisted(() => new Set()) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !deniedPermissions.has(permission) }) })) vi.mock('@/core/workspace-tabs/workspaceTabs', () => ({ useWorkspaceTabsStore: () => ({ removeByIds: vi.fn(), bumpCacheEpoch: vi.fn() }) })) vi.mock('@/modules/iam', async importOriginal => ({ ...await importOriginal(), listPlatformTenants: vi.fn() })) vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() })) vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() })) vi.mock('../api/platformOceanBookings', () => ({ createOceanBooking: vi.fn(), deleteOceanBookingDraft: vi.fn(), getOceanBooking: vi.fn(), listOceanBookingAgents: vi.fn(), listWarehouseLogisticsChannels: vi.fn(), releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(), updateOceanBookingActualCargo: vi.fn(), })) function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLadingNo = 'BL-001'): OceanBookingDetail { const resource = { id: '123', bookingNo: 'OB-123', status, version, customerId: '456', customerName: '测试客户', billOfLadingNo, shipperName: '发货人', consigneeName: '收货人', containerCount: 2, channelCode: 'MATSON_CLX', channelName: '美森正班', warehouseId: '8', warehouseName: '义乌仓', shippingCompanyId: '10', shippingCompanyName: '测试船公司', carrierCode: 'MATSON' as const, bookingAgentId: '20', bookingAgentName: '订舱代理', vesselName: 'TEST VESSEL', voyageNo: 'V001', departurePortCode: 'CNSHA', departurePortName: '上海', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'ONT8', estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', remark: null, createdAt: null, updatedAt: null, orderNo: null, carrierName: null, bookingAgent: null, pol: '上海', pod: '洛杉矶', mblShipper: null, mblConsignee: null, soNo: null, cutoffAt: null, etd: null, atd: null, eta: null, ata: null, containerSummary: null, amount: null, currency: null, } return { ...resource, oceanBooking: resource, parties: [ { partyType: 'SHIPPER', partyName: '发货人' }, { partyType: 'CONSIGNEE', partyName: '收货人' }, { partyType: 'NOTIFY_PARTY', partyName: '通知人' }, ], containerRequirements: [{ requirementId: '31', containerType: '40HQ', containerKind: 'FCL', quantity: 2, remainingQuantity: 2, capacityCbm: 68, maxWeightKg: 19500 }], containers: [], documents: [{ id: '41', oceanBookingId: '123', fileId: '51', documentType: 'BOOKING_CONFIRMATION', fileName: '订舱确认.pdf', fileHash: null, sizeBytes: 128, contentType: 'application/pdf', valid: true, uploadedBy: '7', uploadedByName: '平台', uploadedAt: '2026-08-31T10:00:00', invalidatedBy: null, invalidatedByName: null, invalidatedAt: null }], histories: [], statusHistory: [], } } type ViewModel = { form: { data: import('../types').OceanBookingDraftData } saveActualCargo: () => Promise actualReason: string cargoWritable: boolean hasUnsavedActualChanges: boolean printDisabledReason: string save: () => Promise selectCustomer: (value: unknown) => void canManageDocuments: boolean detail: OceanBookingDetail | null actionError: { code?: string; requestId?: string; traceId?: string; message: string } | null actionSaving: boolean submitRelease: () => Promise } async function mountView() { const wrapper = mount(OceanBookingDetailView, { global: { plugins: [ElementPlus], stubs: { AdminPage: { template: '
' }, DetailPageLayout: { template: '
' }, PageToolbar: { props: ['description'], template: '

{{ description }}

' }, CarrierScheduleQueryPanel: true, }, }, }) await flushPromises() return { wrapper, vm: wrapper.vm as unknown as ViewModel } } describe('OceanBookingDetailView one-click release', () => { beforeEach(() => { routeState.name = 'ocean-booking-detail'; routeState.params.bookingId = '123' vi.clearAllMocks() deniedPermissions.clear() vi.mocked(getOceanBooking).mockResolvedValue(booking()) vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([{ channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡' }]) vi.mocked(listOceanBookingAgents).mockResolvedValue([{ supplierId: '20', name: '订舱代理', carrierCodes: ['MATSON'] }]) }) it('renders actual workflow progress and advances only on a successful matching preview', async () => { const { wrapper } = await mountView() const states = () => wrapper.findAll('.booking-workflow li').map(item => item.attributes('data-state')) expect(states()).toEqual(['complete', 'current', 'pending', 'pending']) expect(wrapper.get('[aria-current="step"]').text()).toContain('托运单') const print = wrapper.findComponent(OceanBookingPrintDialog) print.vm.$emit('previewed', { bookingId: 'another', type: 'SHIPPING_INSTRUCTION' }) await flushPromises() expect(states()).toEqual(['complete', 'current', 'pending', 'pending']) print.vm.$emit('previewed', { bookingId: '123', type: 'SHIPPING_INSTRUCTION' }) await flushPromises() expect(states()).toEqual(['complete', 'complete', 'current', 'pending']) expect(wrapper.findAll('.booking-workflow li')[1]?.text()).toContain('已预览') expect(wrapper.get('[aria-current="step"]').text()).toContain('确认放舱') wrapper.unmount() }) it('finds a customer outside the initial page and saves its actual ID and name in a new booking', async () => { routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new' const customer = { id: '9007199254740999', tenantName: '柏悦物流', status: 'ACTIVE' } as PlatformTenantResource vi.mocked(listPlatformTenants).mockImplementation(async query => ({ records: query.keyword ? [customer] : [], page: 1, size: 50, total: query.keyword ? 1 : 350, pages: query.keyword ? 1 : 7, })) vi.mocked(createOceanBooking).mockResolvedValue({ ...booking(), customerId: String(customer.id), customerName: customer.tenantName }) const { wrapper, vm } = await mountView() const selector = wrapper.findAllComponents(ElSelect).find(item => item.props('placeholder') === '单客户可选择,多客户可留空')! expect(selector.props('remote')).toBe(true) selector.vm.$emit('end-reached', 'bottom'); await flushPromises() expect(listPlatformTenants).toHaveBeenLastCalledWith({ page: 2, size: 50, status: 'ACTIVE' }) await selector.props('remoteMethod')?.('柏悦'); await flushPromises() expect(listPlatformTenants).toHaveBeenLastCalledWith({ page: 1, size: 50, status: 'ACTIVE', keyword: '柏悦' }) selector.vm.$emit('update:modelValue', customer.id); await flushPromises() expect(vm.form.data.customerId).toBe(customer.id) expect(vm.form.data.customerName).toBe('柏悦物流') await vm.save() expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ customerId: customer.id, customerName: '柏悦物流' }) })) wrapper.unmount() }) it('opens the booking-number confirmation with the edited facts before saving or releasing', async () => { const { wrapper, vm } = await mountView() vm.form.data.billOfLadingNo = 'EDITED-BL' await flushPromises() await wrapper.findAll('button').find(button => button.text() === '放舱')!.trigger('click') await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('确认放舱'))! expect(dialog.props('modelValue')).toBe(true) expect(dialog.props('title')).toBe('确认放舱 · OB-123') expect(wrapper.get('.release-summary').text()).toContain('EDITED-BL') expect(wrapper.get('.release-summary').element.closest('section')?.hasAttribute('data-tone')).toBe(false) expect(wrapper.find('.el-alert--warning').exists()).toBe(false) expect(dialog.find('.density-dialog-note').exists()).toBe(false) expect(wrapper.text()).not.toContain('处理数据库') expect(updateOceanBookingDraft).not.toHaveBeenCalled() expect(releaseOceanBooking).not.toHaveBeenCalled() wrapper.unmount() }) it('shows the matching fixed logistics group instead of the underlying price-book channel', async () => { const { wrapper } = await mountView() expect(wrapper.text()).toContain('美森正班') expect(wrapper.text()).not.toContain('美森正班海卡') wrapper.unmount() }) it('renders authoritative creation and release history timestamps from the response', async () => { const value = booking('RELEASED') value.histories = [ { id: '61', fromStatus: null, toStatus: 'DRAFT', action: 'CREATE', operatorName: '创建人', createdAt: '2026-10-20T09:12:34' }, { id: '62', fromStatus: 'DRAFT', toStatus: 'RELEASED', action: 'RELEASE', operatorName: '放舱人', createdAt: '2026-10-21T10:23:45' }, ] vi.mocked(getOceanBooking).mockResolvedValue(value) const { wrapper } = await mountView() const rows = wrapper.findAll('.booking-history-table tbody tr') expect(rows[0]!.findAll('td')[0]!.text()).toBe('2026-10-20T09:12:34') expect(rows[1]!.findAll('td')[0]!.text()).toBe('2026-10-21T10:23:45') expect(rows[0]!.text()).toContain('创建人') wrapper.unmount() }) it('shows the route alone in the heading without a single customer and keeps an actual customer name', async () => { const value = booking() value.customerId = null; value.customerName = null value.oceanBooking.customerId = null; value.oceanBooking.customerName = null vi.mocked(getOceanBooking).mockResolvedValue(value) const { wrapper } = await mountView() expect(wrapper.get('.toolbar-description').text()).toBe('上海 → 洛杉矶') expect(wrapper.text()).not.toContain('未关联客户') wrapper.unmount() vi.mocked(getOceanBooking).mockResolvedValue(booking()) const { wrapper: customerWrapper } = await mountView() expect(customerWrapper.get('.toolbar-description').text()).toBe('测试客户 · 上海 → 洛杉矶') customerWrapper.unmount() }) it('saves dirty form data and releases with the returned latest version', async () => { vi.mocked(updateOceanBookingDraft).mockResolvedValue(booking('DRAFT', 4, 'NEW-BL')) vi.mocked(releaseOceanBooking).mockResolvedValue(booking('RELEASED', 5, 'NEW-BL')) const { vm } = await mountView() vm.form.data.billOfLadingNo = ' NEW-BL ' await vm.submitRelease() expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ expectedVersion: 3, data: expect.objectContaining({ billOfLadingNo: 'NEW-BL' }) })) expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 4 }) expect(vi.mocked(updateOceanBookingDraft).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(releaseOceanBooking).mock.invocationCallOrder[0]!) expect(vm.detail?.status).toBe('RELEASED') expect(vm.detail?.version).toBe(5) }) it('saves a new booking without a customer using explicit nulls', async () => { routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new' vi.mocked(createOceanBooking).mockResolvedValue(booking()) const { vm, wrapper } = await mountView() await vm.save() expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ customerId: null, customerName: null }) })) wrapper.unmount() }) it('clears an existing customer before saving and releasing with the returned version', async () => { const saved = booking('DRAFT', 4) saved.customerId = null; saved.customerName = null; saved.oceanBooking.customerId = null; saved.oceanBooking.customerName = null vi.mocked(updateOceanBookingDraft).mockResolvedValue(saved) vi.mocked(releaseOceanBooking).mockResolvedValue({ ...saved, status: 'RELEASED', version: 5 }) const { vm, wrapper } = await mountView() vm.selectCustomer('') await vm.submitRelease() expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ customerId: null, customerName: null }) })) expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 4 }) wrapper.unmount() }) it('allows documents and release for a saved booking with no single customer', async () => { const empty = booking() empty.customerId = null; empty.customerName = null; empty.oceanBooking.customerId = null; empty.oceanBooking.customerName = null vi.mocked(getOceanBooking).mockResolvedValue(empty) vi.mocked(releaseOceanBooking).mockResolvedValue({ ...empty, status: 'RELEASED', version: 4 }) const { vm, wrapper } = await mountView() expect(vm.canManageDocuments).toBe(true) await vm.submitRelease() expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 3 }) expect(updateOceanBookingDraft).not.toHaveBeenCalled() wrapper.unmount() }) it('preserves a selected single customer when saving', async () => { vi.mocked(updateOceanBookingDraft).mockResolvedValue(booking('DRAFT', 4)) const { vm, wrapper } = await mountView() await vm.save() expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ customerId: '456', customerName: '测试客户' }) })) wrapper.unmount() }) it('does not call release when automatic draft saving fails', async () => { vi.mocked(updateOceanBookingDraft).mockRejectedValue(new ApiResponseError('保存草稿失败', 'A0409', 409, undefined, 'request-save', 'trace-save')) const { vm } = await mountView() vm.form.data.billOfLadingNo = 'NEW-BL' await vm.submitRelease() expect(releaseOceanBooking).not.toHaveBeenCalled() expect(vm.form.data.billOfLadingNo).toBe('NEW-BL') expect(vm.actionError).toMatchObject({ code: 'A0409', requestId: 'request-save', traceId: 'trace-save' }) }) it('keeps the saved draft and evidence when release fails after automatic saving', async () => { vi.mocked(updateOceanBookingDraft).mockResolvedValue(booking('DRAFT', 4, 'NEW-BL')) vi.mocked(releaseOceanBooking).mockRejectedValue(new ApiResponseError('放舱失败', 'A0409', 409, undefined, 'request-release', 'trace-release')) const { vm } = await mountView() vm.form.data.billOfLadingNo = 'NEW-BL' await vm.submitRelease() expect(vm.detail).toMatchObject({ status: 'DRAFT', version: 4, billOfLadingNo: 'NEW-BL' }) expect(vm.form.data.billOfLadingNo).toBe('NEW-BL') expect(vm.actionError).toMatchObject({ code: 'A0409', requestId: 'request-release', traceId: 'trace-release' }) }) it('releases an unchanged draft directly and ignores duplicate submissions while busy', async () => { let resolveRelease!: (value: OceanBookingDetail) => void vi.mocked(releaseOceanBooking).mockReturnValue(new Promise((resolve) => { resolveRelease = resolve })) const { vm } = await mountView() const first = vm.submitRelease() const duplicate = vm.submitRelease() await flushPromises() expect(vm.actionSaving).toBe(true) expect(updateOceanBookingDraft).not.toHaveBeenCalled() expect(releaseOceanBooking).toHaveBeenCalledTimes(1) expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 3 }) resolveRelease(booking('RELEASED', 4)) await Promise.all([first, duplicate]) expect(vm.actionSaving).toBe(false) }) it('keeps newly saved booking open and preserves partial parties and manual cargo', async () => { routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new' vi.mocked(createOceanBooking).mockResolvedValue(booking('DRAFT', 0)) const { vm, wrapper } = await mountView() vm.form.data.parties[0]!.contactEmail = 'ops@example.test' vm.form.data.declaredPackageCount = 500 vm.form.data.declaredGrossWeightKg = 15000 vm.form.data.declaredVolumeCbm = 68 await vm.save() expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS', declaredPackageCount: 500, actualPackageCount: null, parties: expect.arrayContaining([expect.objectContaining({ partyType: 'SHIPPER', partyName: '', contactEmail: 'ops@example.test' })]), }) })) expect(routerReplace).toHaveBeenCalledWith('/ocean-bookings/123') expect(vm.detail?.bookingNo).toBe('OB-123') wrapper.unmount() }) it('saves released actual cargo alone with version and reason while transport remains frozen', async () => { vi.mocked(getOceanBooking).mockResolvedValue({ ...booking('RELEASED'), declaredPackageCount: 500 }) vi.mocked(updateOceanBookingActualCargo).mockResolvedValue({ ...booking('RELEASED', 4), declaredPackageCount: 500, actualPackageCount: 480, actualGrossWeightKg: 14320.125, actualVolumeCbm: 65.321 }) const { vm, wrapper } = await mountView() expect(wrapper.find('input[placeholder="请输入船名"]').attributes('disabled')).toBeDefined() for (const label of ['实际件数', '实际毛重', '实际体积']) { const actualInput = wrapper.get(`input[aria-label="${label}"]`) expect(actualInput.attributes('disabled')).toBeUndefined() expect(actualInput.attributes('aria-disabled')).toBe('false') } for (const label of ['预报件数', '预报毛重', '预报体积']) { const declaredInput = wrapper.get(`input[aria-label="${label}"]`) expect(declaredInput.attributes('disabled')).toBeDefined() expect(declaredInput.attributes('aria-disabled')).toBe('true') } await wrapper.get('input[aria-label="实际件数"]').setValue('480') await wrapper.get('input[aria-label="实际毛重"]').setValue('14320.125') await wrapper.get('input[aria-label="实际体积"]').setValue('65.321') vm.actualReason = ' 实装后补录 ' await flushPromises() expect(vm.printDisabledReason).toContain('请先保存') await vm.saveActualCargo() expect(updateOceanBookingActualCargo).toHaveBeenCalledWith('123', { expectedVersion: 3, actualPackageCount: 480, actualGrossWeightKg: 14320.125, actualVolumeCbm: 65.321, reason: '实装后补录' }) expect(updateOceanBookingDraft).not.toHaveBeenCalled() expect(vm.detail?.version).toBe(4) expect(vm.actualReason).toBe('') expect(vm.hasUnsavedActualChanges).toBe(false) expect(vm.form.data.declaredPackageCount).toBe(500) expect((wrapper.get('input[aria-label="实际件数"]').element as HTMLInputElement).value).toBe('480') expect((wrapper.get('input[aria-label="实际毛重"]').element as HTMLInputElement).value).toBe('14320.125') expect((wrapper.get('input[aria-label="实际体积"]').element as HTMLInputElement).value).toBe('65.321') wrapper.unmount() }) it('shows the saved server values after input and blur without a change event', async () => { vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED')) vi.mocked(updateOceanBookingActualCargo).mockResolvedValue({ ...booking('RELEASED', 4), actualPackageCount: 480, actualGrossWeightKg: 14320.125, actualVolumeCbm: 65.321 }) const { vm, wrapper } = await mountView() for (const [label, value] of [['实际件数', '480'], ['实际毛重', '14320.125'], ['实际体积', '65.321']] as const) { const input = wrapper.get(`input[aria-label="${label}"]`) const element = input.element as HTMLInputElement element.value = value await input.trigger('input') await input.trigger('blur') } vm.actualReason = '补录' await vm.saveActualCargo() await flushPromises() for (const [label, value] of [['实际件数', '480'], ['实际毛重', '14320.125'], ['实际体积', '65.321']] as const) { expect((wrapper.get(`input[aria-label="${label}"]`).element as HTMLInputElement).value).toBe(value) } wrapper.unmount() }) it('preserves actual inputs and reason on a stale-version failure and blocks duplicate writes', async () => { vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED')) let rejectSave!: (cause: unknown) => void vi.mocked(updateOceanBookingActualCargo).mockReturnValue(new Promise((_, reject) => { rejectSave = reject })) const { vm, wrapper } = await mountView() vm.form.data.actualPackageCount = 480 await vm.saveActualCargo() expect(updateOceanBookingActualCargo).not.toHaveBeenCalled() expect(vm.actionError?.message).toContain('原因') vm.actualReason = '修正' const first = vm.saveActualCargo() await vm.saveActualCargo() expect(updateOceanBookingActualCargo).toHaveBeenCalledTimes(1) rejectSave(new ApiResponseError('版本已变化', 'A0409', 409)) await first expect(vm.form.data.actualPackageCount).toBe(480) expect(vm.actualReason).toBe('修正') expect(vm.detail?.version).toBe(3) expect(vm.actionError?.code).toBe('A0409') wrapper.unmount() }) it('keeps actual cargo read-only without edit permission', async () => { deniedPermissions.add('platform:firstleg:ocean-booking:edit') vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED')) const { vm, wrapper } = await mountView() expect(vm.cargoWritable).toBe(false) expect(wrapper.text()).not.toContain('保存实际货量') vm.actualReason = '补录' await vm.saveActualCargo() expect(updateOceanBookingActualCargo).not.toHaveBeenCalled() wrapper.unmount() }) })