diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts new file mode 100644 index 00000000..73ca4804 --- /dev/null +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts @@ -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 +} + +async function mountView() { + const wrapper = mount(OceanBookingDetailView, { + global: { + plugins: [ElementPlus], + stubs: { + AdminPage: { template: '
' }, + DetailPageLayout: { template: '
' }, + PageToolbar: { template: '
' }, + 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) + }) +}) diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts index c6db7ba5..c13bfb84 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts @@ -31,6 +31,7 @@ describe('OceanBookingDetailView draft and release contract', () => { it('saves incomplete DRAFT data and sends null for blank nullable fields', () => { expect(source).toContain('保存草稿') + expect(source).toContain('function draftPayload(): OceanBookingDraftData') expect(source).toContain('channelCode: form.data.channelCode?.trim() || null') expect(source).toContain('warehouseId: form.data.warehouseId?.trim() || null') expect(source).toContain('parties: form.data.parties.filter((party) => party.partyName.trim())') @@ -55,10 +56,19 @@ describe('OceanBookingDetailView draft and release contract', () => { expect(source).toContain('预计到港时间不能早于预计开船时间') expect(source).toContain("[form.data.customerId, '客户']") expect(source).toContain("if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'") - expect(source).toContain("return '船公司标准编码尚未保存,请先保存草稿后再放舱'") + expect(source).not.toContain('请先保存草稿后再放舱') expect(source).toContain('放舱前请填写${missing[1]}') expect(source).toContain('放舱后资料将同步到关联货柜,不能修改、不能删除;填写错误只能由管理员处理数据库,请确认资料无误。') - expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: detail.value.version })') + expect(source).toContain('if (hasUnsavedDraftChanges.value)') + expect(source).toContain('const saved = await persistDraft(false)') + expect(source).toContain('releaseVersion = saved.version') + expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: releaseVersion })') + expect(source.indexOf('const saved = await persistDraft(false)')).toBeLessThan(source.indexOf('releaseOceanBooking(id.value, { expectedVersion: releaseVersion })')) + expect(source).toContain('if (!saved) return') + expect(source).toContain("{{ form.data.billOfLadingNo || '—' }}") + expect(source).not.toContain("{{ detail?.billOfLadingNo || '—' }}") + expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value)') + expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"') expect(source).not.toContain('releaseReason') }) diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue index 699b6022..b36a1421 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -97,11 +97,13 @@ function blankData(): OceanBookingDraftData { } } const form = reactive({ expectedVersion: 0, data: blankData() }) +const savedDraftSignature = ref('') const rules: FormRules = {} const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT') const canSave = computed(() => editable.value && access.can(isNew.value ? 'platform:firstleg:ocean-booking:create' : 'platform:firstleg:ocean-booking:edit')) 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 workflowBusy = computed(() => saving.value || actionSaving.value) const releaseDialogOpen = ref(false) function statusLabel(status: OceanBookingStatus | string | null | undefined) { @@ -136,16 +138,25 @@ function selectCustomer(value: unknown) { const customer = customerId == null ? null : customers.value.find((item) => String(item.id) === customerId) form.data.customerName = customer?.tenantName || null } -function normalizeDraftData() { - form.data.customerId = form.data.customerId || null - form.data.customerName = form.data.customerId ? form.data.customerName : null - form.data.serviceTerm = form.data.serviceTerm?.trim() || null - form.data.freightTerm = form.data.freightTerm?.trim() || null - form.data.billOfLadingNo = form.data.billOfLadingNo.trim() - form.data.vesselName = form.data.vesselName.trim() - form.data.voyageNo = form.data.voyageNo.trim() - form.data.containerRequirements.forEach((requirement) => { requirement.quantity = Number(requirement.quantity) }) +function draftPayload(): OceanBookingDraftData { + const customerId = form.data.customerId || null + return { + ...form.data, + customerId, + customerName: customerId ? form.data.customerName : null, + channelCode: form.data.channelCode?.trim() || null, + warehouseId: form.data.warehouseId?.trim() || null, + serviceTerm: form.data.serviceTerm?.trim() || null, + freightTerm: form.data.freightTerm?.trim() || null, + billOfLadingNo: form.data.billOfLadingNo.trim(), + vesselName: form.data.vesselName.trim(), + voyageNo: form.data.voyageNo.trim(), + parties: form.data.parties.filter((party) => party.partyName.trim()).map((party) => ({ ...party, partyName: party.partyName.trim() })), + containerRequirements: form.data.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => ({ containerType, containerKind, quantity: Number(quantity), capacityCbm, maxWeightKg })), + } } +function draftSignature() { return JSON.stringify(draftPayload()) } +const hasUnsavedDraftChanges = computed(() => Boolean(detail.value?.status === 'DRAFT' && savedDraftSignature.value && draftSignature() !== savedDraftSignature.value)) function selectWarehouse(value: unknown) { const warehouseId = value == null || value === '' ? null : String(value) form.data.warehouseId = warehouseId @@ -229,6 +240,7 @@ function applyDetail(value: OceanBookingDetail) { containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg }) => ({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg })) : [createBlankContainerRequirement()], } selectedChannelName.value = value.channelName || '' + savedDraftSignature.value = draftSignature() channels.value = [] if (form.data.warehouseId) void loadChannels(form.data.warehouseId) if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode) @@ -241,47 +253,45 @@ async function load() { catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } } -async function save() { - if (!canSave.value) return - actionError.value = null - normalizeDraftData() +async function validateDraftForm() { + const valid = await formRef.value?.validate().catch(() => false) + if (valid === false) { actionError.value = { message: '请检查表单中的必填项和格式' }; return false } const requirementMessage = containerRequirementError(form.data.containerRequirements) - if (requirementMessage) { actionError.value = { message: requirementMessage }; return } - saving.value = true + if (requirementMessage) { actionError.value = { message: requirementMessage }; return false } + return true +} +async function persistDraft(showSuccess: boolean) { + if (!canSave.value || !(await validateDraftForm())) return null actionError.value = null try { - const payload = { - ...form.data, - channelCode: form.data.channelCode?.trim() || null, - warehouseId: form.data.warehouseId?.trim() || null, - parties: form.data.parties.filter((party) => party.partyName.trim()).map((party) => ({ ...party, partyName: party.partyName.trim() })), - containerRequirements: form.data.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => ({ containerType, containerKind, quantity, capacityCbm, maxWeightKg })), - } + const payload = draftPayload() const saved = isNew.value ? await createOceanBooking({ expectedVersion: 0, data: payload }) : await updateOceanBookingDraft(id.value, { expectedVersion: detail.value?.version || 0, data: payload }) - ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || saved.id}` : '订舱草稿已保存') + if (showSuccess) ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || saved.id}` : '订舱草稿已保存') if (isNew.value) { workspace.removeByIds(['ocean-booking-create']) await router.replace('/ocean-bookings') - return + return saved } applyDetail(saved) - } catch (cause) { actionError.value = getApiErrorDetails(cause) } + return saved + } catch (cause) { actionError.value = getApiErrorDetails(cause); return null } +} +async function save() { + if (!canSave.value || workflowBusy.value) return + saving.value = true + try { await persistDraft(true) } finally { saving.value = false } } -function openReleaseDialog() { - normalizeDraftData() +async function openReleaseDialog() { + if (!canRelease.value || workflowBusy.value || !(await validateDraftForm())) return const validationError = releaseValidationError() if (validationError) { actionError.value = { message: validationError }; return } releaseDialogOpen.value = true } function releaseValidationError() { if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码' - if (detail.value && (detail.value.carrierCode !== form.data.carrierCode - || String(detail.value.shippingCompanyId || '') !== String(form.data.shippingCompanyId || ''))) { - return '船公司标准编码尚未保存,请先保存草稿后再放舱' - } const requiredFields: Array<[unknown, string]> = [ [form.data.customerId, '客户'], [form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'], [form.data.billOfLadingNo, '提单号'], [form.data.vesselName, '船名'], [form.data.voyageNo, '航次'], @@ -296,13 +306,20 @@ function releaseValidationError() { return containerRequirementError(form.data.containerRequirements) } async function submitRelease() { - if (!detail.value) return - const validationError = releaseValidationError() - if (validationError) { actionError.value = { message: validationError }; releaseDialogOpen.value = false; return } + if (!detail.value || workflowBusy.value) return actionSaving.value = true actionError.value = null try { - applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version })) + if (!(await validateDraftForm())) { releaseDialogOpen.value = false; return } + const validationError = releaseValidationError() + if (validationError) { actionError.value = { message: validationError }; releaseDialogOpen.value = false; return } + let releaseVersion = detail.value.version + if (hasUnsavedDraftChanges.value) { + const saved = await persistDraft(false) + if (!saved) return + releaseVersion = saved.version + } + applyDetail(await releaseOceanBooking(id.value, { expectedVersion: releaseVersion })) releaseDialogOpen.value = false ElMessage.success('确认放舱成功') } catch (cause) { actionError.value = getApiErrorDetails(cause) } @@ -349,9 +366,9 @@ onMounted(() => { void load(); void loadOptions() })

订舱信息

草稿可反复保存;放舱后资料同步至关联货柜并锁定。

已放舱资料只读。

- 删除草稿 - 放舱 - 保存草稿 + 删除草稿 + 放舱 + 保存草稿
重新加载选项 @@ -437,15 +454,15 @@ onMounted(() => { void load(); void loadOptions() }) - + - {{ detail?.billOfLadingNo || '—' }} - {{ detail?.vesselName || '—' }} - {{ detail?.voyageNo || '—' }} - {{ detail?.estimatedSailingAt || '—' }} - {{ detail?.estimatedArrivalAt || '—' }} + {{ form.data.billOfLadingNo || '—' }} + {{ form.data.vesselName || '—' }} + {{ form.data.voyageNo || '—' }} + {{ form.data.estimatedSailingAt || '—' }} + {{ form.data.estimatedArrivalAt || '—' }}