Files
admin-web/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts
T

378 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import CarrierScheduleQueryPanel from './CarrierScheduleQueryPanel.vue'
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { appendContainerShippingNode, getContainerShippingNodes } from '../api/platformContainerShippingNodes'
import { getPortSchedule } from '../api/platformPortSchedules'
import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
import { h } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
enableAutoUnmount(afterEach)
vi.mock('../api/platformCarrierScheduleQueries', () => ({
listCarrierScheduleQueries: vi.fn(),
getCarrierScheduleQuery: vi.fn(),
}))
vi.mock('../api/platformContainerShippingNodes', () => ({ getContainerShippingNodes: vi.fn(), appendContainerShippingNode: vi.fn() }))
vi.mock('../api/platformPortSchedules', () => ({ getPortSchedule: vi.fn() }))
const queryView = {
query: {
id: '11', queryNo: 'CSQ11', customerId: '1', oceanBookingId: '2', containerBookingId: '3',
carrierCode: 'COSCO', trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567',
officialUrl: 'https://elines.coscoshipping.com/ebusiness/', accessMode: 'DYNAMIC_IFRAME',
status: 'MANUAL_REQUIRED' as const, triggerSource: 'OUTBOUND_CONFIRM',
latestActualNode: 'DEPARTED_WAREHOUSE' as const, latestActualAt: '2026-08-20T10:00:00',
lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询',
resultPortScheduleId: '21', version: 1,
},
expired: true,
}
describe('CarrierScheduleQueryPanel loading states', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => { vi.clearAllMocks(); pinia = createPinia(); setActivePinia(pinia); useAuthStore().permissions = ['platform:firstleg:port-schedule-query:manual']; vi.mocked(getContainerShippingNodes).mockResolvedValue({ containerBookingId: '3', currentNode: null, currentNodeAt: null, nodes: [] }) })
it('keeps list facts visible when history and linked schedule both fail', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [queryView], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getCarrierScheduleQuery).mockRejectedValue(new Error('detail unavailable'))
vi.mocked(getPortSchedule).mockRejectedValue(new Error('schedule unavailable'))
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
expect(listCarrierScheduleQueries).toHaveBeenCalledWith({ page: 1, size: 200, oceanBookingId: '2', containerBookingId: '3' })
expect(getCarrierScheduleQuery).toHaveBeenCalledWith('11')
expect(getPortSchedule).toHaveBeenCalledWith('21')
expect(wrapper.text()).toContain('CSQ11')
expect(wrapper.text()).toContain('标准节点与变更历史暂不可用')
expect(wrapper.text()).toContain('关联船期结果暂不可用')
expect(wrapper.text()).toContain('DYNAMIC_IFRAME_REQUIRED')
})
it('shows an explicit non-fabricated empty state and performs no detail reads', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', warehouseTimeline: [{ eventType: 'PDA_COMPLETE_LOADING', detail: 'complete-key-technical', operatorName: '仓库员', occurredAt: '2026-08-20T08:00:00' }] }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
expect(wrapper.text()).toContain('货柜履约轨迹')
expect(wrapper.text()).toContain('完成装柜')
expect(wrapper.text()).toContain('仓内操作')
expect(wrapper.text()).toContain('当前未开启跟踪任务')
expect(wrapper.findAll('.tracking-prompt')).toHaveLength(1)
expect(getCarrierScheduleQuery).not.toHaveBeenCalled()
expect(getPortSchedule).not.toHaveBeenCalled()
})
it('keeps complete ordered facts in the dense table and preserves the non-dense timeline', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
const summary = '长说明:核验装柜结果及相关业务记录,保留全部内容。'.repeat(4)
const wrapper = mount(CarrierScheduleQueryPanel, {
props: { dense: true, oceanBookingId: '2', warehouseTimeline: [
{ eventType: 'PDA_SUBMIT_PICK', detail: summary, operatorName: '仓库作业员', occurredAt: '2026-08-20T08:00:00' },
{ eventType: 'PDA_COMPLETE_LOADING', detail: '完成装柜', operatorName: '复核员', occurredAt: '2026-08-20T09:00:00' },
] },
global: { plugins: [ElementPlus, pinia] },
})
await flushPromises()
const table = wrapper.get('table[aria-label="货柜履约轨迹"]')
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['时间', '类型', '事件', '说明', '操作人 / 来源'])
const rows = table.findAll('tbody tr')
expect(rows).toHaveLength(2)
expect(rows[0]!.text()).toContain('2026-08-20 09:00')
expect(rows[1]!.findAll('td')[3]!.text()).toBe(summary)
expect(rows[1]!.findAll('td')[4]!.text()).toBe('仓库作业员')
expect(wrapper.find('.fulfillment-timeline').exists()).toBe(false)
await wrapper.setProps({ dense: false })
expect(wrapper.find('table[aria-label="货柜履约轨迹"]').exists()).toBe(false)
expect(wrapper.findAll('.fulfillment-timeline li')).toHaveLength(2)
wrapper.unmount()
})
it.each([false, true])('isolates nested manual entry from its parent and reopens in ordinary mode (dense=%s)', async (dense) => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
const host = mount(AppDialog, {
props: { modelValue: true, title: '船期详情', showConfirm: false },
attrs: { class: 'parent-schedule-dialog admin-density-dialog admin-workbench is-dialog' },
slots: { default: () => h(CarrierScheduleQueryPanel, { oceanBookingId: '2', containerBookingId: '3', dense }) },
global: { plugins: [ElementPlus, pinia] },
attachTo: document.body,
})
try {
await flushPromises()
const panel = host.findComponent(CarrierScheduleQueryPanel)
const open = async () => {
await panel.findAll('button').find(button => button.text().includes('人工补录节点'))!.trigger('click')
await flushPromises()
}
await open()
const child = panel.findComponent(AppDialog)
const dialog = document.body.querySelector<HTMLElement>('.el-dialog:not(.parent-schedule-dialog)')!
expect(dialog).not.toBeNull()
expect(dialog.parentElement?.closest('.parent-schedule-dialog')).toBeNull()
expect(document.body.contains(dialog)).toBe(true)
expect(dialog.querySelector(':scope > .el-dialog__body .app-dialog__body')).not.toBeNull()
expect(dialog.querySelector(':scope > .el-dialog__footer')).not.toBeNull()
expect(child.props('width')).toContain(dense ? '780px' : '620px')
if (dense) {
dialog.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click()
await flushPromises()
expect(dialog.classList.contains('is-fullscreen')).toBe(true)
}
Array.from(dialog.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')).find(button => button.textContent?.trim() === '取消')!.click()
await flushPromises()
expect(child.props('modelValue')).toBe(false)
expect(host.props('modelValue')).toBe(true)
await open()
expect(child.props('modelValue')).toBe(true)
expect(dialog.classList.contains('is-fullscreen')).toBe(false)
expect(appendContainerShippingNode).not.toHaveBeenCalled()
} finally {
host.unmount()
}
})
it('shows manual entry without creating a tracking task', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
const button = wrapper.findAll('button').find((item) => item.text().includes('人工补录节点'))
expect(button).toBeTruthy()
await button!.trigger('click')
await flushPromises()
expect(getContainerShippingNodes).toHaveBeenCalledWith('3')
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
const pickers = wrapper.findAllComponents({ name: 'ElDatePicker' })
expect(pickers).toHaveLength(2)
expect(pickers[0]!.props('modelValue')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/)
expect(pickers[1]!.props('modelValue')).toBe(pickers[0]!.props('modelValue'))
})
it('filters large no-op status batches, keeps real actions, deduplicates only adjacent explicit event keys and sorts newest first', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
const noops = Array.from({ length: 40 }, (_, index) => ({
eventType: 'STATUS_UPDATE', action: 'STATUS_UPDATE', fromStatus: 'EDITING', toStatus: 'EDITING',
detail: index % 2 ? null : '预装柜中→预装柜中', eventId: `2092438843630398${String(index).padStart(3, '0')}`,
operatorName: '系统', occurredAt: `2026-08-27T09:${String(index).padStart(2, '0')}:00`,
}))
const timeline = [
...noops,
{ eventType: 'STATUS_UPDATE', action: 'STATUS_UPDATE', fromStatus: 'EDITING', toStatus: 'LOADING', detail: null, eventId: '2092438843630399001', operatorName: '调度员', occurredAt: '2026-08-27T10:00:00' },
{ eventType: 'STATUS_UPDATE', action: 'STATUS_UPDATE', fromStatus: 'LOADING', toStatus: 'LOADING', detail: null, changedFields: ['containerNo'], eventId: '2092438843630399002', operatorName: '调度员', occurredAt: '2026-08-27T10:01:00' },
{ eventType: 'STATUS_UPDATE', action: 'PUSH_PRELOAD', fromStatus: 'EDITING', toStatus: 'EDITING', detail: null, eventId: '2092438843630399003', operatorName: '调度员', occurredAt: '2026-08-27T10:02:00' },
{ eventType: 'PDA_BATCH', action: 'PDA_BATCH', fromStatus: 'LOADING', toStatus: 'LOADING', detail: '提交拣货结果', eventId: '2092438843630399004', operatorName: '仓库员', occurredAt: '2026-08-27T10:03:00' },
{ eventType: 'PDA_BATCH', action: 'PDA_BATCH', fromStatus: 'LOADING', toStatus: 'LOADING', detail: '提交拣货结果', eventId: '2092438843630399004', operatorName: '仓库员', occurredAt: '2026-08-27T10:04:00' },
{ eventType: 'STATUS_UPDATE', action: 'CONFIRM_PRELOAD', fromStatus: 'EDITING', toStatus: 'EDITING', detail: null, idempotencyKey: '2092438843630399005', operatorName: '复核员', occurredAt: '2026-08-27T10:05:00' },
{ eventType: 'TRANSPORT_UPDATE', action: 'TRANSPORT_UPDATE', detail: '更新集装箱号', eventId: '2092438843630399006', operatorName: '操作员甲', occurredAt: '2026-08-27T10:06:00' },
{ eventType: 'TRANSPORT_UPDATE', action: 'TRANSPORT_UPDATE', detail: '更新集装箱号', eventId: '2092438843630399007', operatorName: '操作员乙', occurredAt: '2026-08-27T10:07:00' },
]
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', warehouseTimeline: timeline }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
expect(wrapper.find('.history-count').text()).toBe('仓内 7 条 · 共 7 条')
const rows = wrapper.findAll('.fulfillment-timeline li')
expect(rows).toHaveLength(7)
expect(rows[0]?.text()).toContain('操作员乙')
expect(rows[6]?.text()).toContain('预装柜中 → 拣货中')
expect(rows.filter((row) => row.text().includes('提交拣货结果'))).toHaveLength(1)
expect(wrapper.text()).toContain('推送预装清单')
expect(wrapper.text()).toContain('确认预装清单')
expect(wrapper.text()).toContain('业务字段已更新')
expect(wrapper.text()).not.toContain('2092438843630398000')
expect(rows.filter((row) => row.text().includes('更新集装箱号'))).toHaveLength(2)
})
it('falls back to the booking/container task when an automatic task has no linked port schedule', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null } }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: { ...queryView.query, resultPortScheduleId: null } }, nodes: [], exceptions: [], histories: [] })
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3', portScheduleId: '99' }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
expect(wrapper.text()).toContain('CSQ11')
expect(getCarrierScheduleQuery).toHaveBeenCalledWith('11')
expect(getPortSchedule).not.toHaveBeenCalled()
})
it('does not expose a circuit-breaker exception name stored by an earlier scheduler attempt', async () => {
const failed = { ...queryView.query, lastErrorCode: 'PROVIDER_CALL_FAILED', lastErrorMessage: 'NoFallbackAvailableException', resultPortScheduleId: null }
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: failed }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: failed }, nodes: [], exceptions: [], histories: [] })
const wrapper = mount(CarrierScheduleQueryPanel, { props: { containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
expect(wrapper.text()).toContain('轨迹供应商暂时不可用,请稍后重试或人工补录节点。')
expect(wrapper.text()).not.toContain('NoFallbackAvailableException')
})
it('renders canonical node location, actual time, source and empty-return stop facts from detail DTO', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, status: 'STOPPED', latestActualNode: 'EMPTY_RETURNED', latestActualAt: '2026-08-20T17:45:00', stoppedAt: '2026-08-20T18:00:00', stopReason: '已还柜', resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({
query: { query: { ...queryView.query, status: 'STOPPED', latestActualNode: 'EMPTY_RETURNED', latestActualAt: '2026-08-20T17:45:00', stoppedAt: '2026-08-20T18:00:00', stopReason: '已还柜', resultPortScheduleId: null }, expired: false },
nodes: [{ id: '31', providerCode: 'COSCO', providerEventId: 'provider-event-31', providerNodeCode: 'EMPTY_RETURN', standardNode: 'EMPTY_RETURNED', nodeSequence: 100, observedAt: '2026-08-20T17:50:00', estimatedAt: null, actualAt: '2026-08-20T17:45:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'provider-event-31', rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T17:51:00' }],
exceptions: [],
histories: [{ id: '41', action: 'STOP', fromVersion: 4, toVersion: 5, reason: '已还柜', operatorId: null, operatorName: '系统', createdAt: '2026-08-20T18:00:00' }],
})
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
expect(wrapper.text()).toContain('已停止 · 已还柜')
expect(wrapper.text()).toContain('USLAX · Los Angeles')
expect(wrapper.text()).toContain('2026-08-20 17:45')
expect(wrapper.text()).toContain('COSCO')
expect(wrapper.text()).toContain('已还柜')
})
it('labels forecast-only facts as estimated without promoting latest actual progress', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, latestActualNode: 'DEPARTED_WAREHOUSE', latestActualAt: '2026-08-20T08:55:00', resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({
query: { query: { ...queryView.query, latestActualNode: 'DEPARTED_WAREHOUSE', latestActualAt: '2026-08-20T08:55:00', resultPortScheduleId: null }, expired: false },
nodes: [
{ id: '11', providerCode: 'FIRSTLEG', providerEventId: 'outbound-11', providerNodeCode: 'OUTBOUND_CONFIRM', standardNode: 'DEPARTED_WAREHOUSE', nodeSequence: 10, observedAt: '2026-08-20T09:00:00', estimatedAt: null, actualAt: '2026-08-20T08:55:00', locationCode: null, locationName: null, sourceReference: null, rawPayloadHash: 'b'.repeat(64), createdAt: '2026-08-20T09:00:01' },
{ id: '12', providerCode: 'HEDE', providerEventId: 'hede-estimate-12', providerNodeCode: '预抵', standardNode: 'ARRIVED_DESTINATION_PORT', nodeSequence: 60, observedAt: '2026-08-20T10:00:00', estimatedAt: '2026-08-28T12:00:00', actualAt: null, locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'hede-estimate', rawPayloadHash: 'c'.repeat(64), createdAt: '2026-08-20T10:00:01' },
], exceptions: [{ id: '51', providerCode: 'HEDE', providerEventId: 'hold-51', providerStatus: 'Customs hold', exceptionCode: 'CUSTOMS_HOLD', active: true, observedAt: '2026-08-20T11:00:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'hede-hold', rawPayloadHash: 'd'.repeat(64), createdAt: '2026-08-20T11:00:01' }], histories: [],
})
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
expect(wrapper.text()).toContain('最新实际节点已离开国内仓库')
expect(wrapper.text()).toContain('预计 2026-08-28 12:00')
expect(wrapper.text()).not.toContain('最新实际节点船舶已到目的港')
expect(wrapper.text()).toContain('异常')
expect(wrapper.text()).toContain('海关扣留 · 发生')
expect(wrapper.text()).toContain('Customs hold')
expect(wrapper.text()).toContain('船运跟踪 · HEDE · hede-hold')
})
it('merges warehouse, actual, estimated, manual and exception facts with stable ordering and deduplication', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
const actual = { id: '61', providerCode: 'COSCO', providerEventId: 'actual-61', providerNodeCode: 'LOAD', standardNode: 'LOADED_ON_VESSEL' as const, nodeSequence: 30, observedAt: '2026-08-21T10:05:00', estimatedAt: null, actualAt: '2026-08-21T10:00:00', locationCode: 'CNNGB', locationName: '宁波港', sourceReference: 'actual-source', rawPayloadHash: 'e'.repeat(64), createdAt: '2026-08-21T10:06:00' }
const estimated = { id: '62', providerCode: 'COSCO', providerEventId: 'estimate-62', providerNodeCode: 'ETA', standardNode: 'ARRIVED_DESTINATION_PORT' as const, nodeSequence: 60, observedAt: '2026-08-20T10:00:00', estimatedAt: '2026-08-21T10:00:00', actualAt: null, locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'estimate-source', rawPayloadHash: 'f'.repeat(64), createdAt: '2026-08-20T10:01:00' }
const manual = { id: '63', providerCode: 'MANUAL', providerEventId: 'manual-63', providerNodeCode: 'TRANSIT', standardNode: 'IN_TRANSIT' as const, nodeSequence: 50, observedAt: '2026-08-21T09:00:00', estimatedAt: null, actualAt: '2026-08-21T09:00:00', locationCode: null, locationName: '太平洋', sourceReference: '人工核验', rawPayloadHash: 'g'.repeat(64), createdAt: '2026-08-21T09:01:00' }
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }, nodes: [estimated, actual, { ...actual, id: '64' }, manual], exceptions: [{ id: '65', providerCode: 'COSCO', providerEventId: 'hold-65', providerStatus: 'Terminal hold', exceptionCode: 'TERMINAL_HOLD', active: true, observedAt: '2026-08-21T08:30:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'hold-source', rawPayloadHash: 'h'.repeat(64), createdAt: '2026-08-21T08:31:00' }], histories: [{ id: '66', action: 'MANUAL_NODE', fromVersion: 1, toVersion: 2, reason: '人工核验', operatorId: '1', operatorName: '管理员', createdAt: '2026-08-21T09:01:00' }] })
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', warehouseTimeline: [{ eventType: 'PDA_COMPLETE_LOADING', detail: '装柜完成', operatorName: '仓库员', occurredAt: '2026-08-20T08:00:00' }] }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
const rows = wrapper.findAll('.fulfillment-timeline li')
expect(rows.filter((row) => row.text().includes('已装船'))).toHaveLength(1)
expect(rows[0]?.text()).toContain('已装船')
expect(rows[1]?.text()).toContain('预计 2026-08-21 10:00')
expect(rows[1]?.text()).toContain('船运节点')
expect(rows.map((row) => row.text()).join('|')).toContain('人工补录')
expect(rows.map((row) => row.text()).join('|')).toContain('码头扣留 · 发生')
expect(wrapper.text()).toContain('查询任务摘要、异常与变更历史')
expect(wrapper.text()).toContain('人工补录标准节点')
})
it('shows only the latest duplicate actual node in the primary trajectory and keeps the older fact expandable', async () => {
const task = { ...queryView.query, latestActualNode: 'PICKED_UP' as const, latestActualAt: '2026-09-24T10:00:00', resultPortScheduleId: null }
const older = { id: 'pickup-1', providerCode: 'MANUAL', providerEventId: 'pickup-old', providerNodeCode: 'PICKED_UP', standardNode: 'PICKED_UP' as const, nodeSequence: 90, observedAt: '2026-09-09T10:05:00', estimatedAt: null, actualAt: '2026-09-09T10:00:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: '人工提柜-旧', rawPayloadHash: '1'.repeat(64), createdAt: '2026-09-09T10:06:00' }
const latest = { ...older, id: 'pickup-2', providerEventId: 'pickup-latest', observedAt: '2026-09-24T10:05:00', actualAt: '2026-09-24T10:00:00', sourceReference: '人工提柜-最新', rawPayloadHash: '2'.repeat(64), createdAt: '2026-09-24T10:06:00' }
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: task, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: task, expired: false }, nodes: [older, latest], exceptions: [], histories: [] })
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
const primaryPickupRows = wrapper.findAll('.fulfillment-timeline li').filter((row) => row.text().includes('已提柜'))
expect(primaryPickupRows).toHaveLength(1)
expect(primaryPickupRows[0]!.text()).toContain('2026-09-24 10:00')
expect(primaryPickupRows[0]!.text()).not.toContain('2026-09-09 10:00')
expect(wrapper.find('.duplicate-node-history').text()).toContain('历史重复实际节点(1)')
expect(wrapper.find('.duplicate-node-history').text()).toContain('2026-09-09 10:00')
expect(wrapper.find('.duplicate-node-history').text()).toContain('人工提柜-旧')
})
it('allows correcting the current node without sequence restrictions', async () => {
const task = { ...queryView.query, latestActualNode: 'PICKED_UP' as const, latestActualAt: '2026-09-24T10:00:00', resultPortScheduleId: null }
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: task, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: task, expired: false }, nodes: [], exceptions: [], histories: [] })
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('人工补录节点'))!.trigger('click')
const pickers = wrapper.findAllComponents({ name: 'ElDatePicker' })
pickers[1]!.vm.$emit('update:modelValue', '2026-09-25T10:00:00')
await flushPromises()
const options = wrapper.findAllComponents({ name: 'ElOption' })
expect(options.find((option) => option.props('value') === 'DEPARTED_WAREHOUSE')!.props('disabled')).toBeFalsy()
expect(options.find((option) => option.props('value') === 'PICKED_UP')!.props('disabled')).toBeFalsy()
expect(options.find((option) => option.props('value') === 'EMPTY_RETURNED')!.props('disabled')).toBe(false)
wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'PICKED_UP')
wrapper.findAllComponents({ name: 'ElInput' }).find((input) => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '重复提柜事实')
await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(appendContainerShippingNode).toHaveBeenCalled()
})
it('hides the manual action without the frozen permission while retaining fulfillment facts', async () => {
useAuthStore().permissions = []
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }, nodes: [], exceptions: [], histories: [] })
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', warehouseTimeline: [{ eventType: 'PDA_BATCH', detail: '拣货结果已提交', operatorName: '仓库员', occurredAt: '2026-08-20T07:00:00' }] }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
expect(wrapper.text()).toContain('提交拣货结果')
expect(wrapper.findAll('button').some((button) => button.text().includes('人工补录节点'))).toBe(false)
})
it.each([false, true])('keeps form inputs and the dialog-scoped retry key with dense=%s', async (dense) => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
const currentDetail = { containerBookingId: '3', currentNode: null, currentNodeAt: null, nodes: [] }
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: { ...queryView.query, resultPortScheduleId: null } }, nodes: [], exceptions: [], histories: [] })
vi.mocked(appendContainerShippingNode)
.mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', 409, null, 'req-409', 'trace-409'))
.mockResolvedValueOnce(currentDetail)
const wrapper = mount(CarrierScheduleQueryPanel, { props: { dense, cabinetNo: 'CAB-3', containerNo: 'CONT-3', oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('人工补录节点'))!.trigger('click')
const select = wrapper.findComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', 'IN_TRANSIT')
const pickers = wrapper.findAllComponents({ name: 'ElDatePicker' }); pickers[0]!.vm.$emit('update:modelValue', '2026-08-22T10:00:00'); pickers[1]!.vm.$emit('update:modelValue', '')
const inputs = wrapper.findAllComponents({ name: 'ElInput' })
inputs.find((input) => input.props('placeholder') === '可选,如 USLAX')!.vm.$emit('update:modelValue', 'PACIFIC')
inputs.find((input) => String(input.props('maxlength')) === '128')!.vm.$emit('update:modelValue', 'Pacific Ocean')
inputs.find((input) => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '船司官网人工核验')
await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(document.body.querySelector('.el-dialog')?.textContent).toContain('补录未成功,已保留当前填写内容')
expect(document.body.querySelector('.el-dialog')?.textContent).toContain('req-409'); expect(document.body.querySelector('.el-dialog')?.textContent).toContain('trace-409')
const dialog = wrapper.findComponent({ name: 'AppDialog' })
expect(dialog.props('title')).toBe('人工补录船节点 · CAB-3')
if (dense) {
document.body.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click()
await flushPromises()
expect(document.body.querySelector('.el-dialog.is-fullscreen')).not.toBeNull()
expect(document.body.querySelector<HTMLTextAreaElement>('.el-dialog textarea')!.value).toBe('船司官网人工核验')
}
await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(appendContainerShippingNode).mock.calls[0]!
const second = vi.mocked(appendContainerShippingNode).mock.calls[1]!
expect(first[1]).toBeTruthy(); expect(second[1]).toBe(first[1])
expect(second[2]).toEqual(first[2])
expect(second[2]).not.toHaveProperty('cabinetNo')
wrapper.unmount()
expect(second[2]).toMatchObject({ standardNode: 'IN_TRANSIT', estimatedAt: '2026-08-22T10:00:00', actualAt: null, locationCode: 'PACIFIC', locationName: 'Pacific Ocean', reason: '船司官网人工核验', observedAt: expect.any(String) })
})
it('requires an estimated or actual time before calling the manual-node adapter', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: { ...queryView.query, resultPortScheduleId: null } }, nodes: [], exceptions: [], histories: [] })
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('人工补录节点'))!.trigger('click')
wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'PICKUP_READY')
for (const picker of wrapper.findAllComponents({ name: 'ElDatePicker' })) picker.vm.$emit('update:modelValue', '')
wrapper.findAllComponents({ name: 'ElInput' }).find((input) => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '人工核验可提柜')
await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(document.body.querySelector('.el-dialog')?.textContent).toContain('预计时间和实际时间至少填写一项')
expect(appendContainerShippingNode).not.toHaveBeenCalled()
})
})