This commit is contained in:
2026-10-22 11:56:54 +08:00
parent 090431b409
commit 3e77c3976e
40 changed files with 976 additions and 241 deletions
@@ -12,10 +12,12 @@ import {
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' } }))
@@ -23,7 +25,8 @@ vi.mock('vue-router', () => ({
useRoute: () => routeState,
useRouter: () => ({ push: vi.fn(), replace: routerReplace }),
}))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
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() }))
@@ -32,7 +35,7 @@ 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(),
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(), updateOceanBookingActualCargo: vi.fn(),
}))
function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLadingNo = 'BL-001'): OceanBookingDetail {
@@ -66,6 +69,11 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading
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
@@ -95,6 +103,7 @@ 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 })
@@ -103,6 +112,23 @@ describe('OceanBookingDetailView one-click release', () => {
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
@@ -137,7 +163,7 @@ describe('OceanBookingDetailView one-click release', () => {
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(wrapper.text()).toContain('不能修改、不能删除')
expect(dialog.find('.density-dialog-note').exists()).toBe(false)
expect(wrapper.text()).not.toContain('处理数据库')
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
expect(releaseOceanBooking).not.toHaveBeenCalled()
@@ -281,4 +307,80 @@ describe('OceanBookingDetailView one-click release', () => {
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()
const actualInput = wrapper.find('[aria-label="实际件数"]')
expect(actualInput.attributes('disabled')).toBeUndefined()
vm.form.data.actualPackageCount = 480
vm.form.data.actualGrossWeightKg = 14320.125
vm.form.data.actualVolumeCbm = 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)
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()
})
})