Files
admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts
T

418 lines
23 KiB
TypeScript

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<string>())
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<typeof import('@/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(), 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<void>
actualReason: string
cargoWritable: boolean
hasUnsavedActualChanges: boolean
printDisabledReason: string
save: () => Promise<void>
selectCustomer: (value: unknown) => void
canManageDocuments: boolean
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: { props: ['description'], template: '<header><p class="toolbar-description">{{ description }}</p><slot name="actions" /></header>' },
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()
})
})