This commit is contained in:
2026-09-10 11:52:30 +08:00
parent 911c6acad2
commit ae7d8fb22e
6 changed files with 238 additions and 7 deletions
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries'
import { appendCarrierScheduleManualNode, ensureCarrierScheduleQueryForContainer, getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries'
vi.mock('@/shared/api/http', async () => {
const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http')
@@ -61,4 +61,11 @@ describe('platformCarrierScheduleQueries', () => {
await appendCarrierScheduleManualNode('9007199254740993', 'manual-session-key', request)
expect(vi.mocked(http.post)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries/9007199254740993/manual-nodes', request, { headers: { 'Idempotency-Key': 'manual-session-key' } })
})
it('ensures a missing departed-container task before manual entry', async () => {
const detail = { query: { query: { ...query, customerId: 0 }, expired: false }, nodes: [], exceptions: [], histories: [] }
vi.mocked(http.post).mockResolvedValue({ code: '200', data: detail })
await expect(ensureCarrierScheduleQueryForContainer('303', 'ensure-key')).resolves.toMatchObject({ query: { query: { customerId: '0', containerBookingId: '303' } } })
expect(vi.mocked(http.post)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries/containers/303/ensure', null, { headers: { 'Idempotency-Key': 'ensure-key' } })
})
})
@@ -56,6 +56,16 @@ export async function getCarrierScheduleQuery(queryId: string): Promise<CarrierS
return { ...result, query: view(result.query), nodes: (result.nodes || []).map(node), exceptions: (result.exceptions || []).map(exception), histories: (result.histories || []).map(history) }
}
export async function ensureCarrierScheduleQueryForContainer(containerBookingId: string, idempotencyKey: string): Promise<CarrierScheduleQueryDetail> {
const response = await http.post<ApiEnvelope<CarrierScheduleQueryDetail>, ApiEnvelope<CarrierScheduleQueryDetail>>(
`${basePath}/containers/${encodeURIComponent(containerBookingId)}/ensure`,
null,
{ headers: { 'Idempotency-Key': idempotencyKey } },
)
const result = unwrapApiResponse(response)
return { ...result, query: view(result.query), nodes: (result.nodes || []).map(node), exceptions: (result.exceptions || []).map(exception), histories: (result.histories || []).map(history) }
}
export async function appendCarrierScheduleManualNode(queryId: string, idempotencyKey: string, request: CarrierScheduleManualNodeRequest): Promise<CarrierScheduleQueryDetail> {
const response = await http.post<ApiEnvelope<CarrierScheduleQueryDetail>, ApiEnvelope<CarrierScheduleQueryDetail>>(
`${basePath}/${encodeURIComponent(queryId)}/manual-nodes`,
@@ -3,7 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import CarrierScheduleQueryPanel from './CarrierScheduleQueryPanel.vue'
import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { appendCarrierScheduleManualNode, ensureCarrierScheduleQueryForContainer, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { getPortSchedule } from '../api/platformPortSchedules'
import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
@@ -11,6 +11,7 @@ import { ApiResponseError } from '@/shared/api/http'
vi.mock('../api/platformCarrierScheduleQueries', () => ({
listCarrierScheduleQueries: vi.fn(),
getCarrierScheduleQuery: vi.fn(),
ensureCarrierScheduleQueryForContainer: vi.fn(),
appendCarrierScheduleManualNode: vi.fn(),
}))
vi.mock('../api/platformPortSchedules', () => ({ getPortSchedule: vi.fn() }))
@@ -63,6 +64,21 @@ describe('CarrierScheduleQueryPanel loading states', () => {
expect(getPortSchedule).not.toHaveBeenCalled()
})
it('shows manual entry without an existing task and ensures the task on click', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(ensureCarrierScheduleQueryForContainer).mockResolvedValue({ query: { ...queryView, query: { ...queryView.query, customerId: '0', resultPortScheduleId: null } }, nodes: [], exceptions: [], histories: [] })
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(ensureCarrierScheduleQueryForContainer).toHaveBeenCalledWith('3', expect.any(String))
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
})
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) => ({
@@ -6,7 +6,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { appendCarrierScheduleManualNode, ensureCarrierScheduleQueryForContainer, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { getPortSchedule } from '../api/platformPortSchedules'
import type { CarrierScheduleExceptionFact, CarrierScheduleManualNodeRequest, CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, PortScheduleView } from '../types'
@@ -31,6 +31,8 @@ const manualDialogOpen = ref(false)
const manualIdempotencyKey = ref('')
const manualObservedAt = ref('')
const manualSaving = ref(false)
const manualPreparing = ref(false)
const manualEntryError = ref<ApiErrorDetails | null>(null)
const manualError = ref<ApiErrorDetails | null>(null)
const manualValidationError = ref('')
const manualForm = reactive({ standardNode: '' as CarrierScheduleStandardNode | '', estimatedAt: '', actualAt: '', locationCode: '', locationName: '', reason: '' })
@@ -205,7 +207,24 @@ function localDateTimeNow() {
const now = new Date()
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 19)
}
function openManualDialog() { resetManualForm(); manualIdempotencyKey.value = createIdempotencyKey(); manualObservedAt.value = localDateTimeNow(); manualDialogOpen.value = true }
async function openManualDialog() {
if (manualPreparing.value) return
manualEntryError.value = null
if (!query.value) {
if (!props.containerBookingId) { manualEntryError.value = getApiErrorDetails(new Error('当前页面缺少货柜信息,无法建立船节点任务')); return }
manualPreparing.value = true
try {
const ensured = await ensureCarrierScheduleQueryForContainer(props.containerBookingId, createIdempotencyKey())
detail.value = ensured
selected.value = ensured.query
queryDetailError.value = null
} catch (cause) {
manualEntryError.value = getApiErrorDetails(cause, '船节点任务建立失败')
return
} finally { manualPreparing.value = false }
}
resetManualForm(); manualIdempotencyKey.value = createIdempotencyKey(); manualObservedAt.value = localDateTimeNow(); manualDialogOpen.value = true
}
async function submitManualNode() {
manualValidationError.value = ''
if (!query.value || !manualForm.standardNode) { manualValidationError.value = '请选择标准节点'; return }
@@ -254,11 +273,12 @@ watch(() => manualForm.actualAt, (actualAt) => {
<template>
<section class="carrier-query" aria-label="货柜履约轨迹">
<header><div><h2>货柜履约轨迹</h2><p>统一展示仓内操作、确认离仓与船运节点;预计节点不会推进实际进度。</p></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="query && access.can('platform:firstleg:port-schedule-query:manual')" size="small" type="primary" plain @click="openManualDialog"><Plus :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
<header><div><h2>货柜履约轨迹</h2><p>统一展示仓内操作、确认离仓与船运节点;预计节点不会推进实际进度。</p></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="(query || props.containerBookingId) && access.can('platform:firstleg:port-schedule-query:manual')" size="small" type="primary" plain :loading="manualPreparing" @click="openManualDialog"><Plus v-if="!manualPreparing" :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
<LoadErrorState v-if="error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-else-if="loading" class="loading-state"><el-skeleton :rows="1" animated /></div>
<template v-else>
<el-alert v-if="query?.lastErrorMessage || query?.status === 'MANUAL_REQUIRED'" type="error" :closable="false" show-icon :title="providerErrorText(query?.lastErrorMessage || query?.manualReason)" />
<el-alert v-if="manualEntryError" type="error" :closable="false" show-icon title="无法进入人工补录"><template #default><ApiErrorMeta :details="manualEntryError" /></template></el-alert>
<el-alert v-if="query?.status === 'STOPPED'" type="info" :closable="false" show-icon :title="query.stopReason || '船节点查询已停止。'" />
<el-alert v-if="queryDetailError" type="warning" :closable="false" show-icon title="标准节点与变更历史暂不可用"><template #default><ApiErrorMeta :details="queryDetailError" /></template></el-alert>
<el-alert v-if="scheduleError" type="warning" :closable="false" show-icon title="关联船期结果暂不可用"><template #default><ApiErrorMeta :details="scheduleError" /></template></el-alert>