418 lines
23 KiB
TypeScript
418 lines
23 KiB
TypeScript
import { flushPromises, mount } from '@vue/test-utils'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import ElementPlus, { ElSelect } from 'element-plus'
|
|
import { ApiResponseError } from '@/shared/api/http'
|
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
|
import { listPlatformPartners } from '@/modules/master-data'
|
|
import { listPlatformWarehouses } from '@/modules/warehouses'
|
|
import {
|
|
createOceanBooking,
|
|
getOceanBooking,
|
|
listOceanBookingAgents,
|
|
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' } }))
|
|
vi.mock('vue-router', () => ({
|
|
useRoute: () => routeState,
|
|
useRouter: () => ({ push: vi.fn(), replace: routerReplace }),
|
|
}))
|
|
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() }))
|
|
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(),
|
|
}))
|
|
|
|
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 = {
|
|
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
|
|
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: { props: ['description'], template: '<header><p class="toolbar-description">{{ description }}</p><slot name="actions" /></header>' },
|
|
CarrierScheduleQueryPanel: true,
|
|
},
|
|
},
|
|
})
|
|
await flushPromises()
|
|
return { wrapper, vm: wrapper.vm as unknown as ViewModel }
|
|
}
|
|
|
|
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 })
|
|
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('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
|
|
vi.mocked(listPlatformTenants).mockImplementation(async query => ({
|
|
records: query.keyword ? [customer] : [], page: 1, size: 50, total: query.keyword ? 1 : 350, pages: query.keyword ? 1 : 7,
|
|
}))
|
|
vi.mocked(createOceanBooking).mockResolvedValue({ ...booking(), customerId: String(customer.id), customerName: customer.tenantName })
|
|
const { wrapper, vm } = await mountView()
|
|
const selector = wrapper.findAllComponents(ElSelect).find(item => item.props('placeholder') === '单客户可选择,多客户可留空')!
|
|
expect(selector.props('remote')).toBe(true)
|
|
selector.vm.$emit('end-reached', 'bottom'); await flushPromises()
|
|
expect(listPlatformTenants).toHaveBeenLastCalledWith({ page: 2, size: 50, status: 'ACTIVE' })
|
|
await selector.props('remoteMethod')?.('柏悦'); await flushPromises()
|
|
expect(listPlatformTenants).toHaveBeenLastCalledWith({ page: 1, size: 50, status: 'ACTIVE', keyword: '柏悦' })
|
|
selector.vm.$emit('update:modelValue', customer.id); await flushPromises()
|
|
expect(vm.form.data.customerId).toBe(customer.id)
|
|
expect(vm.form.data.customerName).toBe('柏悦物流')
|
|
await vm.save()
|
|
expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ customerId: customer.id, customerName: '柏悦物流' }) }))
|
|
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('shows the matching fixed logistics group instead of the underlying price-book channel', async () => {
|
|
const { wrapper } = await mountView()
|
|
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('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()
|
|
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()
|
|
})
|
|
|
|
})
|