2026-08-31 14:52:38 +08:00
|
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import ElementPlus from 'element-plus'
|
|
|
|
|
import { ApiResponseError } from '@/shared/api/http'
|
|
|
|
|
import { listPlatformTenants } from '@/modules/iam'
|
|
|
|
|
import { listPlatformPartners } from '@/modules/master-data'
|
|
|
|
|
import { listPlatformWarehouses } from '@/modules/warehouses'
|
|
|
|
|
import {
|
2026-09-09 11:21:13 +08:00
|
|
|
createOceanBooking,
|
2026-08-31 14:52:38 +08:00
|
|
|
getOceanBooking,
|
|
|
|
|
listOceanBookingAgents,
|
|
|
|
|
listWarehouseLogisticsChannels,
|
|
|
|
|
releaseOceanBooking,
|
|
|
|
|
updateOceanBookingDraft,
|
|
|
|
|
} from '../api/platformOceanBookings'
|
|
|
|
|
import type { OceanBookingDetail, OceanBookingStatus } from '../types'
|
|
|
|
|
import OceanBookingDetailView from './OceanBookingDetailView.vue'
|
2026-10-20 10:11:31 +08:00
|
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
2026-08-31 14:52:38 +08:00
|
|
|
|
|
|
|
|
const routerReplace = vi.hoisted(() => vi.fn())
|
2026-09-09 11:21:13 +08:00
|
|
|
const routeState = vi.hoisted(() => ({ name: 'ocean-booking-detail', params: { bookingId: '123' } }))
|
2026-08-31 14:52:38 +08:00
|
|
|
vi.mock('vue-router', () => ({
|
2026-09-09 11:21:13 +08:00
|
|
|
useRoute: () => routeState,
|
2026-08-31 14:52:38 +08:00
|
|
|
useRouter: () => ({ push: vi.fn(), replace: routerReplace }),
|
|
|
|
|
}))
|
|
|
|
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
|
|
|
|
vi.mock('@/core/workspace-tabs/workspaceTabs', () => ({ useWorkspaceTabsStore: () => ({ removeByIds: vi.fn(), bumpCacheEpoch: vi.fn() }) }))
|
|
|
|
|
vi.mock('@/modules/iam', () => ({ 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(),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-15 11:54:19 +08:00
|
|
|
channelCode: 'MATSON_CLX', channelName: '美森正班', warehouseId: '8', warehouseName: '义乌仓',
|
2026-08-31 14:52:38 +08:00
|
|
|
shippingCompanyId: '10', shippingCompanyName: '测试船公司', carrierCode: 'MATSON' as const,
|
|
|
|
|
bookingAgentId: '20', bookingAgentName: '订舱代理', vesselName: 'TEST VESSEL', voyageNo: 'V001',
|
|
|
|
|
departurePortCode: 'CNSHA', departurePortName: '上海', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
2026-09-15 10:38:03 +08:00
|
|
|
placeOfDelivery: 'ONT8',
|
2026-08-31 14:52:38 +08:00
|
|
|
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: '通知人' },
|
|
|
|
|
],
|
2026-09-15 10:38:03 +08:00
|
|
|
containerRequirements: [{ requirementId: '31', containerType: '40HQ', containerKind: 'FCL', quantity: 2, remainingQuantity: 2, capacityCbm: 68, maxWeightKg: 19500 }],
|
2026-08-31 19:54:37 +08:00
|
|
|
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: [],
|
2026-08-31 14:52:38 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ViewModel = {
|
2026-09-09 11:21:13 +08:00
|
|
|
form: { data: import('../types').OceanBookingDraftData }
|
|
|
|
|
save: () => Promise<void>
|
|
|
|
|
selectCustomer: (value: unknown) => void
|
|
|
|
|
canManageDocuments: boolean
|
2026-08-31 14:52:38 +08:00
|
|
|
detail: OceanBookingDetail | null
|
|
|
|
|
actionError: { code?: string; requestId?: string; traceId?: string; message: string } | null
|
|
|
|
|
actionSaving: boolean
|
|
|
|
|
submitRelease: () => Promise<void>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function mountView() {
|
|
|
|
|
const wrapper = mount(OceanBookingDetailView, {
|
|
|
|
|
global: {
|
|
|
|
|
plugins: [ElementPlus],
|
|
|
|
|
stubs: {
|
|
|
|
|
AdminPage: { template: '<main><slot /></main>' },
|
|
|
|
|
DetailPageLayout: { template: '<section><slot name="header" /><slot /></section>' },
|
|
|
|
|
PageToolbar: { template: '<header><slot name="actions" /></header>' },
|
|
|
|
|
CarrierScheduleQueryPanel: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
await flushPromises()
|
|
|
|
|
return { wrapper, vm: wrapper.vm as unknown as ViewModel }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('OceanBookingDetailView one-click release', () => {
|
|
|
|
|
beforeEach(() => {
|
2026-09-09 11:21:13 +08:00
|
|
|
routeState.name = 'ocean-booking-detail'; routeState.params.bookingId = '123'
|
2026-08-31 14:52:38 +08:00
|
|
|
vi.clearAllMocks()
|
|
|
|
|
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 })
|
2026-09-15 11:54:19 +08:00
|
|
|
vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([{ channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡' }])
|
2026-08-31 14:52:38 +08:00
|
|
|
vi.mocked(listOceanBookingAgents).mockResolvedValue([{ supplierId: '20', name: '订舱代理', carrierCodes: ['MATSON'] }])
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-20 10:11:31 +08:00
|
|
|
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.text()).toContain('不能修改、不能删除')
|
|
|
|
|
expect(wrapper.text()).not.toContain('处理数据库')
|
|
|
|
|
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
|
|
|
|
|
expect(releaseOceanBooking).not.toHaveBeenCalled()
|
|
|
|
|
wrapper.unmount()
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-15 11:54:19 +08:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-31 14:52:38 +08:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-09 11:21:13 +08:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-31 14:52:38 +08:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
})
|