fix(admin): save dirty ocean booking before release
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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 {
|
||||
getOceanBooking,
|
||||
listOceanBookingAgents,
|
||||
listWarehouseLogisticsChannels,
|
||||
releaseOceanBooking,
|
||||
updateOceanBookingDraft,
|
||||
} from '../api/platformOceanBookings'
|
||||
import type { OceanBookingDetail, OceanBookingStatus } from '../types'
|
||||
import OceanBookingDetailView from './OceanBookingDetailView.vue'
|
||||
|
||||
const routerReplace = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ name: 'ocean-booking-detail', params: { bookingId: '123' } }),
|
||||
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,
|
||||
channelCode: 'SEA_FCL', 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', serviceTerm: 'CY-CY', freightTerm: 'PREPAID',
|
||||
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: '40HC', containerKind: 'FCL', quantity: 2, remainingQuantity: 2, capacityCbm: 76, maxWeightKg: 28000 }],
|
||||
containers: [], histories: [], statusHistory: [],
|
||||
}
|
||||
}
|
||||
|
||||
type ViewModel = {
|
||||
form: { data: { billOfLadingNo: string } }
|
||||
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(() => {
|
||||
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 })
|
||||
vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([{ channelCode: 'SEA_FCL', channelName: '海运整箱' }])
|
||||
vi.mocked(listOceanBookingAgents).mockResolvedValue([{ supplierId: '20', name: '订舱代理', carrierCodes: ['MATSON'] }])
|
||||
})
|
||||
|
||||
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('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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user