This commit is contained in:
2026-08-21 16:04:24 +08:00
parent 3c4db20ebb
commit d018daa4d8
8 changed files with 276 additions and 66 deletions
@@ -1,13 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { 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 { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { getPortSchedule } from '../api/platformPortSchedules'
import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
vi.mock('../api/platformCarrierScheduleQueries', () => ({
listCarrierScheduleQueries: vi.fn(),
getCarrierScheduleQuery: vi.fn(),
appendCarrierScheduleManualNode: vi.fn(),
}))
vi.mock('../api/platformPortSchedules', () => ({ getPortSchedule: vi.fn() }))
@@ -17,7 +21,7 @@ const queryView = {
carrierCode: 'COSCO', trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567',
officialUrl: 'https://elines.coscoshipping.com/ebusiness/', accessMode: 'DYNAMIC_IFRAME',
status: 'MANUAL_REQUIRED' as const, triggerSource: 'OUTBOUND_CONFIRM',
latestStandardNode: 'DEPARTED_WAREHOUSE' as const, latestNodeObservedAt: '2026-08-20T10:00:00',
latestActualNode: 'DEPARTED_WAREHOUSE' as const, latestActualAt: '2026-08-20T10:00:00',
lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询',
resultPortScheduleId: '21', version: 1,
},
@@ -25,14 +29,15 @@ const queryView = {
}
describe('CarrierScheduleQueryPanel loading states', () => {
beforeEach(() => vi.clearAllMocks())
let pinia: ReturnType<typeof createPinia>
beforeEach(() => { vi.clearAllMocks(); pinia = createPinia(); setActivePinia(pinia); useAuthStore().permissions = ['platform:firstleg:port-schedule-query:manual'] })
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] } })
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' })
@@ -46,7 +51,7 @@ describe('CarrierScheduleQueryPanel loading states', () => {
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' }, global: { plugins: [ElementPlus] } })
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
expect(wrapper.text()).toContain('本页面不会创建或模拟任务')
@@ -56,8 +61,8 @@ describe('CarrierScheduleQueryPanel loading states', () => {
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: [], histories: [] })
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3', portScheduleId: '99' }, global: { plugins: [ElementPlus] } })
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')
@@ -65,21 +70,70 @@ describe('CarrierScheduleQueryPanel loading states', () => {
expect(getPortSchedule).not.toHaveBeenCalled()
})
it('renders canonical node location, actual time, source and stopped facts from detail DTO', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, status: 'STOPPED', latestStandardNode: 'PICKUP_READY', stoppedAt: '2026-08-20T18:00:00', stopReason: '目的港可提柜后停止', resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
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', latestStandardNode: 'PICKUP_READY', stoppedAt: '2026-08-20T18:00:00', stopReason: '目的港可提柜后停止', resultPortScheduleId: null }, expired: false },
nodes: [{ id: '31', providerCode: 'COSCO', providerNodeCode: 'PICKUP', standardNode: 'PICKUP_READY', nodeSequence: 60, 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' }],
histories: [{ id: '41', action: 'STOP', fromVersion: 4, toVersion: 5, reason: '目的港可提柜后停止', operatorId: null, operatorName: '系统', createdAt: '2026-08-20T18:00:00' }],
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] } })
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
expect(wrapper.text()).toContain('已停止')
expect(wrapper.text()).toContain('可提柜')
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('目的港可提柜后停止')
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')
})
it('keeps the form context and dialog-scoped idempotency key when a conflict is retried', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
const currentDetail = { query: { ...queryView, query: { ...queryView.query, resultPortScheduleId: null } }, nodes: [], exceptions: [], histories: [] }
vi.mocked(getCarrierScheduleQuery).mockResolvedValue(currentDetail)
vi.mocked(appendCarrierScheduleManualNode)
.mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', 409, null, 'req-409', 'trace-409'))
.mockResolvedValueOnce(currentDetail)
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, 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')
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(wrapper.text()).toContain('补录未成功,已保留当前填写内容')
expect(wrapper.text()).toContain('req-409'); expect(wrapper.text()).toContain('trace-409')
await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(appendCarrierScheduleManualNode).mock.calls[0]!
const second = vi.mocked(appendCarrierScheduleManualNode).mock.calls[1]!
expect(first[1]).toBeTruthy(); expect(second[1]).toBe(first[1])
expect(second[2]).toEqual(first[2])
expect(second[2]).toMatchObject({ expectedVersion: 1, standardNode: 'IN_TRANSIT', estimatedAt: '2026-08-22T10:00:00', actualAt: null, locationCode: 'PACIFIC', locationName: 'Pacific Ocean', reason: '船司官网人工核验', observedAt: expect.any(String) })
})
})