From 87fc08b06eced0f29ca13b26179efaac37bc0872 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 11:21:13 +0800 Subject: [PATCH] fix(ocean-booking): make customer optional through release --- .../OceanBookingDetailView.release.test.ts | 53 ++++++++++++++++++- .../pages/OceanBookingDetailView.test.ts | 4 +- .../pages/OceanBookingDetailView.vue | 8 +-- .../pages/OceanBookingsView.vue | 2 +- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts index ea862b8d..f70510f3 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts @@ -6,6 +6,7 @@ import { listPlatformTenants } from '@/modules/iam' import { listPlatformPartners } from '@/modules/master-data' import { listPlatformWarehouses } from '@/modules/warehouses' import { + createOceanBooking, getOceanBooking, listOceanBookingAgents, listWarehouseLogisticsChannels, @@ -16,8 +17,9 @@ import type { OceanBookingDetail, OceanBookingStatus } from '../types' import OceanBookingDetailView from './OceanBookingDetailView.vue' const routerReplace = vi.hoisted(() => vi.fn()) +const routeState = vi.hoisted(() => ({ name: 'ocean-booking-detail', params: { bookingId: '123' } })) vi.mock('vue-router', () => ({ - useRoute: () => ({ name: 'ocean-booking-detail', params: { bookingId: '123' } }), + useRoute: () => routeState, useRouter: () => ({ push: vi.fn(), replace: routerReplace }), })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) @@ -61,7 +63,10 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading } type ViewModel = { - form: { data: { billOfLadingNo: string } } + form: { data: import('../types').OceanBookingDraftData } + save: () => Promise + selectCustomer: (value: unknown) => void + canManageDocuments: boolean detail: OceanBookingDetail | null actionError: { code?: string; requestId?: string; traceId?: string; message: string } | null actionSaving: boolean @@ -86,6 +91,7 @@ async function mountView() { describe('OceanBookingDetailView one-click release', () => { beforeEach(() => { + routeState.name = 'ocean-booking-detail'; routeState.params.bookingId = '123' vi.clearAllMocks() vi.mocked(getOceanBooking).mockResolvedValue(booking()) vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) @@ -110,6 +116,49 @@ describe('OceanBookingDetailView one-click release', () => { 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() diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts index ddfc0a96..d177d2e0 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts @@ -57,7 +57,7 @@ describe('OceanBookingDetailView draft and release contract', () => { expect(source).toContain('放舱前请填写起运港代码或名称') expect(source).toContain('放舱前请填写目的港代码或名称') expect(source).toContain("[form.data.placeOfDelivery, '交货地点']") - expect(source).toContain("[form.data.customerId, '客户']") + expect(source).not.toContain("[form.data.customerId, '客户']") expect(source).toContain("if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'") expect(source).not.toContain('请先保存草稿后再放舱') expect(source).toContain('放舱前请填写${missing[1]}') @@ -88,7 +88,7 @@ describe('OceanBookingDetailView draft and release contract', () => { it('keeps warehouse-scoped channel and server-filtered booking-agent selectors accessible', () => { expect(source).toContain("warehouseType: 'DOMESTIC', status: 'ACTIVE'") expect(source).toContain('listWarehouseLogisticsChannels') - expect(source).toContain('placeholder="草稿可暂不选择"') + expect(source).toContain('placeholder="单客户可选择,多客户可留空"') expect(source).toContain('当前船公司暂无可用订舱代理') expect(source).toContain(':label="supplier.name"') expect(source).toContain('filterable clearable') diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue index 8e65973a..a306cc1a 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -108,8 +108,8 @@ const formWritable = computed(() => canSave.value) const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete')) const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release')) const canPrint = computed(() => detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:list')) -const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && Boolean(detail.value.customerId) && access.can('platform:firstleg:ocean-booking:edit')) -const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : !detail.value?.customerId ? '请先选择客户并保存订舱草稿,再上传订舱资料' : '') +const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:edit')) +const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : '') const workflowBusy = computed(() => saving.value || actionSaving.value) const releaseDialogOpen = ref(false) const printOpen = ref(false) @@ -303,7 +303,7 @@ function releaseValidationError() { if (!form.data.departurePortCode?.trim() && !form.data.departurePortName?.trim()) return '放舱前请填写起运港代码或名称' if (!form.data.destinationPortCode?.trim() && !form.data.destinationPortName?.trim()) return '放舱前请填写目的港代码或名称' const requiredFields: Array<[unknown, string]> = [ - [form.data.customerId, '客户'], [form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'], + [form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'], [form.data.billOfLadingNo, '提单号'], [form.data.vesselName, '船名'], [form.data.voyageNo, '航次'], [form.data.estimatedSailingAt, '预计开船时间'], [form.data.estimatedArrivalAt, '预计到港时间'], [form.data.serviceTerm, 'Service Term'], [form.data.freightTerm, 'Freight Term'], @@ -396,7 +396,7 @@ onMounted(() => { void load(); void loadOptions() })

基本信息

- + {{ channel.channelName }}{{ channel.channelCode }}
{{ channelsError }}重新加载
该仓库暂无有效物流渠道,请更换仓库或联系管理员。
diff --git a/src/modules/ocean-bookings/pages/OceanBookingsView.vue b/src/modules/ocean-bookings/pages/OceanBookingsView.vue index 317a7568..3963a41e 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingsView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingsView.vue @@ -41,7 +41,7 @@ const fields = computed(() => [ const columns = [ { key: 'actions', label: '操作', fixed: 'left', width: 196, slotName: 'actions', hideable: false, movable: false }, { prop: 'bookingNo', label: '订舱号', minWidth: 150 }, - { prop: 'customerName', label: '客户', minWidth: 160 }, + { prop: 'customerName', label: '客户', minWidth: 160, formatter: (row: OceanBookingResource) => row.customerName || '—' }, { prop: 'billOfLadingNo', label: '提单号', minWidth: 155, formatter: (row: OceanBookingResource) => row.billOfLadingNo || '—' }, { prop: 'shipperName', label: '提单发货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.shipperName || '—' }, { prop: 'consigneeName', label: '提单收货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.consigneeName || '—' },