import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import ElementPlus, { ElDatePicker } from 'element-plus' import { ApiResponseError } from '@/shared/api/http' import { listPlatformPartners } from '@/modules/master-data' import { listPlatformWarehouses } from '@/modules/warehouses' import { createOceanBooking, getOceanBooking, listOceanBookingAgents, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft, updateOceanBookingActualCargo, updateOceanBookingEstimatedArrival, } 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()) 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/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(), updateOceanBookingEstimatedArrival: 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 = { arrivalValue: string | null arrivalDialogOpen: boolean arrivalSaving: boolean arrivalError: { message: string; code?: string } | null openArrivalDialog: () => void saveEstimatedArrival: () => Promise form: { data: import('../types').OceanBookingDraftData } saveActualCargo: () => Promise actualReason: string cargoWritable: boolean hasUnsavedActualChanges: boolean printDisabledReason: string save: () => Promise 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: { props: ['description'], template: '

{{ description }}

' }, CarrierScheduleQueryPanel: true, }, }, }) await flushPromises() return { wrapper, vm: wrapper.vm as unknown as ViewModel } } describe('OceanBookingDetailView one-click release', () => { it('放舱后仅单独保存预计到港并立即回显,保留尚未提交的实货量', async () => { vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED')) const saved = { ...booking('RELEASED', 4), estimatedArrivalAt: '2026-09-25T08:00:00' } vi.mocked(updateOceanBookingEstimatedArrival).mockResolvedValue(saved) const { wrapper, vm } = await mountView() expect(wrapper.findAll('button').some(button => button.text() === '维护预计到港')).toBe(true) vm.form.data.actualPackageCount = 499 vm.actualReason = '尚未提交' vm.openArrivalDialog() vm.arrivalValue = saved.estimatedArrivalAt await vm.saveEstimatedArrival() expect(updateOceanBookingEstimatedArrival).toHaveBeenCalledExactlyOnceWith('123', { expectedVersion: 3, estimatedArrivalAt: saved.estimatedArrivalAt }) expect(vm.detail?.estimatedArrivalAt).toBe(saved.estimatedArrivalAt) expect(vm.detail?.version).toBe(4) expect(vm.form.data.actualPackageCount).toBe(499) expect(vm.actualReason).toBe('尚未提交') expect(updateOceanBookingDraft).not.toHaveBeenCalled() const coreForm = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('booking-groups'))! expect(coreForm.props('disabled')).toBe(true) wrapper.unmount() }) it('无编辑权限不开放维护入口;版本冲突保留输入并恢复操作', async () => { vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED')) deniedPermissions.add('platform:firstleg:ocean-booking:edit') let rendered = await mountView() expect(rendered.wrapper.findAll('button').some(button => button.text() === '维护预计到港')).toBe(false) rendered.vm.openArrivalDialog() await rendered.vm.saveEstimatedArrival() expect(updateOceanBookingEstimatedArrival).not.toHaveBeenCalled() rendered.wrapper.unmount() deniedPermissions.clear() rendered = await mountView() rendered.vm.openArrivalDialog() rendered.vm.arrivalValue = '2026-09-25T08:00:00' vi.mocked(updateOceanBookingEstimatedArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化,请刷新后重试', 'A0409')) await rendered.vm.saveEstimatedArrival() expect(rendered.vm.arrivalDialogOpen).toBe(true) expect(rendered.vm.arrivalValue).toBe('2026-09-25T08:00:00') expect(rendered.vm.arrivalSaving).toBe(false) expect(rendered.vm.arrivalError?.code).toBe('A0409') rendered.wrapper.unmount() }) it('放舱后预计到港不得清空或早于预计开船', async () => { vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED')) const { wrapper, vm } = await mountView() vm.openArrivalDialog() for (const value of [null, '2026-08-01T08:00:00']) { vm.arrivalValue = value await vm.saveEstimatedArrival() expect(vm.arrivalError?.message).toBeTruthy() } expect(updateOceanBookingEstimatedArrival).not.toHaveBeenCalled() wrapper.unmount() }) beforeEach(() => { routeState.name = 'ocean-booking-detail'; routeState.params.bookingId = '123' vi.clearAllMocks() deniedPermissions.clear() vi.mocked(getOceanBooking).mockResolvedValue(booking()) 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('starts new bookings with the agreed declared defaults and no removed inputs', async () => { routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new' vi.mocked(createOceanBooking).mockResolvedValue(booking()) const { wrapper, vm } = await mountView() expect(vm.form.data).toMatchObject({ freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS', declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68, actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null }) expect(wrapper.get('[aria-label="预报件数"]').element).toHaveProperty('value', '500') expect(wrapper.get('[aria-label="预报毛重"]').element).toHaveProperty('value', '15000.000') expect(wrapper.get('[aria-label="预报体积"]').element).toHaveProperty('value', '68.000') const labels = wrapper.findAll('.el-form-item__label').map(item => item.text()) for (const removed of ['客户(选填)', '包装单位', '备注']) expect(labels).not.toContain(removed) expect(labels).toContain('预计到港') expect(wrapper.find('[aria-label="起运港名称"]').exists()).toBe(false) expect(wrapper.find('[aria-label="目的港名称"]').exists()).toBe(false) expect(wrapper.findAll('.container-table th').map(item => item.text())).toEqual(['操作', '柜型', '数量']) await vm.save() expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68, actualPackageCount: null }) })) 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('keeps warehouse and channel in a collapsed operation section with their saved bindings', async () => { const { wrapper, vm } = await mountView() const header = wrapper.get('.booking-operation-config .el-collapse-item__header') expect(header.attributes('aria-expanded')).toBe('false') await header.trigger('click') expect(header.attributes('aria-expanded')).toBe('true') expect(vm.form.data).toMatchObject({ warehouseId: '8', channelCode: 'MATSON_CLX' }) 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('blocks release without estimated arrival while still allowing a partial draft save', async () => { const saved = booking() saved.estimatedArrivalAt = null saved.oceanBooking.estimatedArrivalAt = null vi.mocked(getOceanBooking).mockResolvedValue(saved) vi.mocked(updateOceanBookingDraft).mockResolvedValue({ ...saved, version: 4 }) const { vm, wrapper } = await mountView() await vm.submitRelease() expect(vm.actionError?.message).toBe('放舱前请填写预计到港时间') expect(releaseOceanBooking).not.toHaveBeenCalled() await vm.save() expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ estimatedArrivalAt: null }) })) wrapper.unmount() }) it('loads and saves estimated arrival from the date input and includes it in release confirmation', async () => { const arrival = '2026-09-25T10:30:00' vi.mocked(updateOceanBookingDraft).mockResolvedValue({ ...booking('DRAFT', 4), estimatedArrivalAt: arrival }) const { vm, wrapper } = await mountView() const picker = wrapper.findAllComponents(ElDatePicker).find(item => item.props('placeholder') === '请选择预计到港时间')! expect(picker.props('modelValue')).toBe('2026-09-20T08:00:00') picker.vm.$emit('update:modelValue', arrival) await flushPromises() await vm.save() expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ estimatedArrivalAt: arrival }) })) await wrapper.findAll('button').find(button => button.text() === '放舱')!.trigger('click') await flushPromises() expect(wrapper.get('.release-summary').text()).toContain(arrival) wrapper.unmount() }) it('blocks an arrival before the sailing date', async () => { const { vm, wrapper } = await mountView() vm.form.data.estimatedArrivalAt = '2026-08-31T08:00:00' await vm.submitRelease() expect(vm.actionError?.message).toBe('预计到港时间不能早于预计开船时间') expect(releaseOceanBooking).not.toHaveBeenCalled() wrapper.unmount() }) it('opens operation configuration when release is missing a warehouse', async () => { const saved = booking() saved.warehouseId = null vi.mocked(getOceanBooking).mockResolvedValue(saved) const { vm, wrapper } = await mountView() await vm.submitRelease() await flushPromises() expect(vm.actionError?.message).toBe('放舱前请填写装柜仓库') expect(wrapper.get('.booking-operation-config .el-collapse-item__header').attributes('aria-expanded')).toBe('true') expect(releaseOceanBooking).not.toHaveBeenCalled() wrapper.unmount() }) it('allows release without documents 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 empty.documents = [] vi.mocked(getOceanBooking).mockResolvedValue(empty) vi.mocked(releaseOceanBooking).mockResolvedValue({ ...empty, status: 'RELEASED', version: 4 }) const { vm, wrapper } = await mountView() expect(wrapper.text()).not.toContain('订舱资料') 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() }) })