This commit is contained in:
2026-08-20 18:02:52 +08:00
parent 4ae5e7c672
commit 80947cd9ce
65 changed files with 1386 additions and 1083 deletions
@@ -17,9 +17,10 @@ const query = {
trackingType: 'CONTAINER_NO',
trackingNo: 'CSNU1234567',
status: 'MANUAL_REQUIRED',
triggerSource: 'OUTBOUND_CONFIRM',
latestStandardNode: 'DEPARTED_WAREHOUSE',
latestNodeObservedAt: '2026-08-20T10:00:00',
accessMode: 'DYNAMIC_IFRAME',
attemptCount: '2',
lastAttemptStatus: 'MANUAL_REQUIRED',
lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED',
lastErrorMessage: '必须人工查询',
resultPortScheduleId: 404,
@@ -30,17 +31,25 @@ describe('platformCarrierScheduleQueries', () => {
beforeEach(() => vi.clearAllMocks())
it('filters by real booking/container relations and preserves long ids as strings', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ query, expired: true, maxAttempts: '3', nextAllowedAt: null }], page: '1', size: '200', total: '1', pages: '1' } })
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ query, expired: true }], page: '1', size: '200', total: '1', pages: '1' } })
const result = await listCarrierScheduleQueries({ page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' })
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries', { params: { page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' } })
expect(result.records[0]).toMatchObject({ query: { id: '9007199254740993', customerId: '101', oceanBookingId: '202', containerBookingId: '303', resultPortScheduleId: '404', accessMode: 'DYNAMIC_IFRAME', attemptCount: 2, lastAttemptStatus: 'MANUAL_REQUIRED', lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询', version: 3 }, expired: true, maxAttempts: 3 })
expect(result.records[0]).toMatchObject({ query: { id: '9007199254740993', customerId: '101', oceanBookingId: '202', containerBookingId: '303', resultPortScheduleId: '404', accessMode: 'DYNAMIC_IFRAME', triggerSource: 'OUTBOUND_CONFIRM', latestStandardNode: 'DEPARTED_WAREHOUSE', latestNodeObservedAt: '2026-08-20T10:00:00', lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询', version: 3 }, expired: true })
})
it('normalizes attempts and immutable history without creating query operations', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query, expired: false, maxAttempts: 3 }, attempts: [{ id: 1, queryId: query.id, windowAttemptNo: '2', carrierCode: 'COSCO', attemptStatus: 'FAILED', coverageGap: true }], histories: [{ id: 2, queryId: query.id, action: 'ATTEMPT', fromVersion: '1', toVersion: '2' }] } })
it('normalizes standard node facts and immutable business history', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query: { ...query, status: 'ARRIVED', latestStandardNode: 'ARRIVED' }, expired: false }, nodes: [{ id: 1, providerCode: 'COSCO', providerNodeCode: 'ARR', standardNode: 'ARRIVED', nodeSequence: '50', observedAt: '2026-08-20T12:00:00', estimatedAt: null, actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'provider-event-1', rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T12:01:00' }], histories: [{ id: 2, action: 'NODE_OBSERVED', fromVersion: '1', toVersion: '2', reason: 'ARRIVED', operatorId: 808 }] } })
const result = await getCarrierScheduleQuery('9007199254740993')
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries/9007199254740993')
expect(result.attempts[0]).toMatchObject({ id: '1', queryId: '9007199254740993', windowAttemptNo: 2, attemptStatus: 'FAILED' })
expect(result.histories[0]).toMatchObject({ id: '2', fromVersion: 1, toVersion: 2 })
expect(result.nodes[0]).toMatchObject({ id: '1', standardNode: 'ARRIVED', nodeSequence: 50, actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX' })
expect(result.histories[0]).toMatchObject({ id: '2', action: 'NODE_OBSERVED', fromVersion: 1, toVersion: 2, operatorId: '808' })
})
it('preserves nullable DTO fields instead of fabricating false or zero values', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query: { ...query, containerBookingId: null, resultPortScheduleId: null, latestStandardNode: null, latestNodeObservedAt: null, stoppedAt: null, stopReason: null }, expired: true }, nodes: [{ id: 1, providerCode: 'FIRSTLEG', providerNodeCode: 'OUTBOUND_CONFIRM', standardNode: 'DEPARTED_WAREHOUSE', nodeSequence: 10, observedAt: '2026-08-20T10:00:00', estimatedAt: null, actualAt: null, locationCode: null, locationName: null, sourceReference: null, rawPayloadHash: 'b'.repeat(64), createdAt: null }], histories: [{ id: 2, action: 'AUTO_CREATE', fromVersion: null, toVersion: 0, reason: null, operatorId: null, operatorName: null, createdAt: null }] } })
const result = await getCarrierScheduleQuery('9007199254740993')
expect(result.query.query).toMatchObject({ containerBookingId: null, resultPortScheduleId: null, latestStandardNode: null, latestNodeObservedAt: null, stoppedAt: null, stopReason: null })
expect(result.nodes[0]).toMatchObject({ estimatedAt: null, actualAt: null, locationCode: null, locationName: null, sourceReference: null, createdAt: null })
expect(result.histories[0]).toMatchObject({ fromVersion: null, operatorId: null, operatorName: null, createdAt: null })
})
})
@@ -1,9 +1,9 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
CarrierScheduleQueryAttempt,
CarrierScheduleQueryDetail,
CarrierScheduleQueryFilter,
CarrierScheduleQueryHistory,
CarrierScheduleNodeFact,
CarrierScheduleQueryPage,
CarrierScheduleQueryResource,
CarrierScheduleQueryView,
@@ -22,21 +22,20 @@ function resource(raw: CarrierScheduleQueryResource): CarrierScheduleQueryResour
oceanBookingId: String(raw.oceanBookingId),
containerBookingId: optionalId(raw.containerBookingId),
resultPortScheduleId: optionalId(raw.resultPortScheduleId),
attemptCount: numberValue(raw.attemptCount),
version: numberValue(raw.version),
}
}
function view(raw: CarrierScheduleQueryView): CarrierScheduleQueryView {
return { ...raw, query: resource(raw.query), maxAttempts: numberValue(raw.maxAttempts), nextAllowedAt: raw.nextAllowedAt ?? raw.query.nextAllowedAt ?? null }
return { ...raw, query: resource(raw.query) }
}
function attempt(raw: CarrierScheduleQueryAttempt): CarrierScheduleQueryAttempt {
return { ...raw, id: String(raw.id), queryId: String(raw.queryId), windowAttemptNo: numberValue(raw.windowAttemptNo), coverageGap: Boolean(raw.coverageGap) }
function node(raw: CarrierScheduleNodeFact): CarrierScheduleNodeFact {
return { ...raw, id: String(raw.id), nodeSequence: numberValue(raw.nodeSequence) }
}
function history(raw: CarrierScheduleQueryHistory): CarrierScheduleQueryHistory {
return { ...raw, id: String(raw.id), queryId: String(raw.queryId), fromVersion: raw.fromVersion == null ? null : numberValue(raw.fromVersion), toVersion: raw.toVersion == null ? null : numberValue(raw.toVersion) }
return { ...raw, id: String(raw.id), fromVersion: raw.fromVersion == null ? null : numberValue(raw.fromVersion), toVersion: raw.toVersion == null ? null : numberValue(raw.toVersion), operatorId: optionalId(raw.operatorId) }
}
export async function listCarrierScheduleQueries(filter: CarrierScheduleQueryFilter): Promise<CarrierScheduleQueryPage> {
@@ -48,5 +47,5 @@ export async function listCarrierScheduleQueries(filter: CarrierScheduleQueryFil
export async function getCarrierScheduleQuery(queryId: string): Promise<CarrierScheduleQueryDetail> {
const response = await http.get<ApiEnvelope<CarrierScheduleQueryDetail>, ApiEnvelope<CarrierScheduleQueryDetail>>(`${basePath}/${encodeURIComponent(queryId)}`)
const result = unwrapApiResponse(response)
return { ...result, query: view(result.query), attempts: (result.attempts || []).map(attempt), histories: (result.histories || []).map(history) }
return { ...result, query: view(result.query), nodes: (result.nodes || []).map(node), histories: (result.histories || []).map(history) }
}
@@ -16,13 +16,12 @@ const queryView = {
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, attemptCount: 2, lastAttemptStatus: 'MANUAL_REQUIRED',
status: 'MANUAL_REQUIRED' as const, triggerSource: 'OUTBOUND_CONFIRM',
latestStandardNode: 'DEPARTED_WAREHOUSE' as const, latestNodeObservedAt: '2026-08-20T10:00:00',
lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询',
resultPortScheduleId: '21', version: 1,
},
expired: true,
maxAttempts: 3,
nextAllowedAt: '2026-08-22T10:00:00',
}
describe('CarrierScheduleQueryPanel loading states', () => {
@@ -40,7 +39,7 @@ describe('CarrierScheduleQueryPanel loading states', () => {
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('关联船期结果暂不可用')
expect(wrapper.text()).toContain('DYNAMIC_IFRAME_REQUIRED')
})
@@ -50,8 +49,37 @@ describe('CarrierScheduleQueryPanel loading states', () => {
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('不会模拟节点或自动创建任务')
expect(wrapper.text()).toContain('本页面不会创建或模拟任务')
expect(getCarrierScheduleQuery).not.toHaveBeenCalled()
expect(getPortSchedule).not.toHaveBeenCalled()
})
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] } })
await flushPromises()
expect(wrapper.text()).toContain('CSQ11')
expect(getCarrierScheduleQuery).toHaveBeenCalledWith('11')
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 })
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' }],
})
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus] } })
await flushPromises()
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('目的港可提柜后停止')
})
})
@@ -16,11 +16,11 @@ describe('CarrierScheduleQueryPanel', () => {
it('shows source, observation/update times, history, failures and expiry truthfully', () => {
expect(source).toContain('最新来源')
expect(source).toContain('访问方式')
expect(source).toContain('查询次数')
expect(source).toContain('观测时间')
expect(source).toContain('来源更新时间')
expect(source).toContain('任务更新时间')
expect(source).toContain('查询尝试')
expect(source).toContain('标准节点历史')
expect(source).toContain('节点位置')
expect(source).toContain('节点实际/预计时间')
expect(source).toContain('变更历史')
expect(source).toContain('query.lastErrorMessage')
expect(source).toContain('query.lastErrorCode')
@@ -30,12 +30,16 @@ describe('CarrierScheduleQueryPanel', () => {
it('keeps query-history and linked-schedule partial failures independently visible', () => {
expect(source).toContain('queryDetailError')
expect(source).toContain('scheduleError')
expect(source).toContain('查询尝试与变更历史暂不可用')
expect(source).toContain('标准节点与变更历史暂不可用')
expect(source).toContain('关联船期结果暂不可用')
})
it('keeps the missing node-event contract explicit instead of fabricating data', () => {
expect(source).toContain('待接口提供独立节点事实')
expect(source).toContain('不会模拟节点或自动创建任务')
it('shows canonical arrival, pickup-ready and stopped states without exposing scheduler actions', () => {
expect(source).toContain("PICKUP_READY: '可提柜'")
expect(source).toContain("ARRIVED: '已到目的港'")
expect(source).toContain("return '已停止'")
expect(source).toContain('任务仅在确认国内出仓后自动建立')
expect(source).not.toContain('/scheduler')
expect(source).not.toContain('/attempts')
})
})
@@ -5,7 +5,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { getPortSchedule } from '../api/platformPortSchedules'
import type { CarrierScheduleQueryDetail, CarrierScheduleQueryView, PortScheduleView } from '../types'
import type { CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, PortScheduleView } from '../types'
const props = defineProps<{ oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null }>()
const loading = ref(false)
@@ -20,21 +20,32 @@ const query = computed(() => detail.value?.query.query || selected.value?.query
const queryView = computed(() => detail.value?.query || selected.value)
const statusText = computed(() => {
if (!query.value) return '未建立'
if (query.value.status === 'STOPPED') return '已停止'
if (query.value.status === 'PICKUP_READY') return '可提柜'
if (query.value.status === 'ARRIVED') return '已到目的港'
if (queryView.value?.expired) return '结果已过期'
return ({ READY: '待查询', MANUAL_REQUIRED: '需人工补充', SUCCESS: '最近查询成功' } as Record<string, string>)[query.value.status] || query.value.status
return ({ READY: '查询中', MANUAL_REQUIRED: '需人工补充' } as Record<string, string>)[query.value.status] || query.value.status
})
const statusType = computed<'success' | 'warning' | 'danger'>(() => queryView.value?.expired || query.value?.status === 'MANUAL_REQUIRED' ? 'danger' : query.value?.status === 'SUCCESS' ? 'success' : 'warning')
const latestSource = computed(() => latestSchedule.value?.schedule.sourceName || query.value?.carrierCode || '—')
const sourceReference = computed(() => latestSchedule.value?.schedule.sourceRef || query.value?.officialUrl || '—')
const latestObservation = computed(() => latestSchedule.value?.schedule.observedAt || query.value?.sourceUpdatedAt || query.value?.lastSuccessAt || null)
const statusType = computed<'success' | 'warning' | 'danger' | 'info'>(() => {
if (query.value?.status === 'STOPPED') return 'info'
if (query.value?.status === 'ARRIVED' || query.value?.status === 'PICKUP_READY') return 'success'
if (queryView.value?.expired || query.value?.status === 'MANUAL_REQUIRED') return 'danger'
return 'warning'
})
const nodes = computed(() => detail.value?.nodes || [])
const latestNode = computed(() => nodes.value[nodes.value.length - 1] || null)
const latestSource = computed(() => latestNode.value?.providerCode || latestSchedule.value?.schedule.sourceName || query.value?.carrierCode || '—')
const sourceReference = computed(() => latestNode.value?.sourceReference || latestSchedule.value?.schedule.sourceRef || query.value?.officialUrl || '—')
const latestObservation = computed(() => latestNode.value?.observedAt || query.value?.latestNodeObservedAt || latestSchedule.value?.schedule.observedAt || query.value?.sourceUpdatedAt || null)
const histories = computed(() => [...(detail.value?.histories || [])].reverse())
const attempts = computed(() => [...(detail.value?.attempts || [])].reverse())
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function trackingTypeText(value: string) { return ({ BOOKING_NO: '订舱号', BILL_OF_LADING: '提单号', CONTAINER_NO: '集装箱号' } as Record<string, string>)[value] || value }
function attemptText(status: string) { return ({ SUCCESS: '成功', FAILED: '失败', MANUAL_REQUIRED: '需人工补充' } as Record<string, string>)[status] || status }
function historyText(action: string) { return ({ CREATE: '建立查询任务', ATTEMPT: '执行查询', MANUAL_RESULT: '关联人工船期结果' } as Record<string, string>)[action] || action }
function latestTimestamp(item: CarrierScheduleQueryView) { return Date.parse(item.query.updatedAt || item.query.createdAt || '') || 0 }
function triggerSourceText(value?: string | null) { return ({ OUTBOUND_CONFIRM: '国内出仓确认自动建立', MANUAL: '平台人工建立' } as Record<string, string>)[value || ''] || value || '—' }
function nodeText(value?: CarrierScheduleStandardNode | null) { return ({ DEPARTED_WAREHOUSE: '已离开国内仓', AT_ORIGIN_PORT: '已到起运港', DEPARTED_ORIGIN_PORT: '已离开起运港', IN_TRANSIT: '海运在途', ARRIVED: '已到目的港', PICKUP_READY: '可提柜' } as Record<string, string>)[value || ''] || value || '—' }
function nodeLocation(item?: CarrierScheduleNodeFact | null) { return item ? [item.locationCode, item.locationName].filter(Boolean).join(' · ') || '—' : '—' }
function nodePrimaryTime(item?: CarrierScheduleNodeFact | null) { return item?.actualAt || item?.estimatedAt || item?.observedAt || null }
function historyText(action: string) { return ({ AUTO_CREATE: '国内出仓自动建任务', CREATE: '人工建立查询任务', NODE_OBSERVED: '记录标准节点', PROVIDER_QUERY: '船司查询', MANUAL_RESULT: '关联人工船期结果', STOP: '停止查询' } as Record<string, string>)[action] || action }
async function load() {
selected.value = null; detail.value = null; latestSchedule.value = null; error.value = null; queryDetailError.value = null; scheduleError.value = null
@@ -43,7 +54,7 @@ async function load() {
try {
const page = await listCarrierScheduleQueries({ page: 1, size: 200, oceanBookingId: props.oceanBookingId || undefined, containerBookingId: props.containerBookingId || undefined })
const matching = props.portScheduleId ? page.records.filter((item) => item.query.resultPortScheduleId === String(props.portScheduleId)) : page.records
selected.value = [...matching].sort((left, right) => latestTimestamp(right) - latestTimestamp(left))[0] || null
selected.value = matching[0] || page.records[0] || null
if (!selected.value) return
const [queryResult] = await Promise.allSettled([getCarrierScheduleQuery(selected.value.query.id)])
if (queryResult.status === 'fulfilled') detail.value = queryResult.value
@@ -65,25 +76,26 @@ watch(() => [props.oceanBookingId, props.containerBookingId, props.portScheduleI
<header><div><h2>国内出仓后船节点</h2><p>沿用 CarrierScheduleQuery,跟踪至目的港到达;本页面不会新建轮询任务。</p></div><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></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="2" animated /></div>
<el-empty v-else-if="!query" :image-size="52" description="尚未建立关联的船节点查询任务;当前页面不会模拟节点或自动创建任务。" />
<el-empty v-else-if="!query" :image-size="52" description="尚无关联任务;任务仅在确认国内出仓后自动建立,本页面不会创建或模拟任务。" />
<template v-else>
<el-alert v-if="query.lastErrorMessage || query.status === 'MANUAL_REQUIRED'" type="error" :closable="false" show-icon :title="query.lastErrorMessage || query.manualReason || '自动查询暂不可用,需要人工补充可靠结果。'" />
<el-alert v-if="queryDetailError" type="warning" :closable="false" show-icon title="查询尝试与变更历史暂不可用"><template #default><ApiErrorMeta :details="queryDetailError" /></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>
<dl class="query-facts">
<div><dt>查询任务</dt><dd>{{ query.queryNo || query.id }}</dd></div><div><dt>跟踪标识</dt><dd>{{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}</dd></div>
<div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>访问方式</dt><dd>{{ query.accessMode || '—' }}</dd></div>
<div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div><div><dt>查询次数</dt><dd>{{ query.attemptCount }} / {{ queryView?.maxAttempts ?? '—' }}</dd></div>
<div><dt>最新节点</dt><dd>{{ latestSchedule ? '船期快照已更新' : '待接口提供独立节点事实' }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div>
<div><dt>来源更新时间</dt><dd>{{ formatTime(query.sourceUpdatedAt) }}</dd></div><div><dt>任务更新时间</dt><dd>{{ formatTime(query.updatedAt) }}</dd></div>
<div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>最近尝试</dt><dd>{{ formatTime(query.lastAttemptAt) }} · {{ query.lastAttemptStatus ? attemptText(query.lastAttemptStatus) : '—' }}</dd></div>
<div><dt>结果过期时间</dt><dd>{{ formatTime(query.expiresAt) }}</dd></div><div><dt>下次允许查询</dt><dd>{{ formatTime(queryView?.nextAllowedAt) }}</dd></div>
<div><dt>建立来源</dt><dd>{{ triggerSourceText(query.triggerSource) }}</dd></div><div><dt>船司 / 访问方式</dt><dd>{{ query.carrierCode }} · {{ query.accessMode || '—' }}</dd></div>
<div><dt>最新节点</dt><dd>{{ nodeText(query.latestStandardNode) }}</dd></div><div><dt>节点位置</dt><dd>{{ nodeLocation(latestNode) }}</dd></div>
<div><dt>节点实际/预计时间</dt><dd>{{ formatTime(nodePrimaryTime(latestNode)) }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div>
<div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div>
<div><dt>来源更新时间</dt><dd>{{ formatTime(query.sourceUpdatedAt) }}</dd></div><div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div>
<div><dt>结果过期时间</dt><dd>{{ formatTime(query.expiresAt) }}</dd></div><div><dt>停止时间</dt><dd>{{ formatTime(query.stoppedAt) }}</dd></div>
<div v-if="query.lastErrorCode"><dt>最近错误码</dt><dd>{{ query.lastErrorCode }}</dd></div>
</dl>
<div v-if="latestSchedule" class="latest-result"><strong>{{ latestSchedule.schedule.vesselName }} / {{ latestSchedule.schedule.voyageNo }}</strong><span>{{ latestSchedule.schedule.departurePortName || latestSchedule.schedule.departurePortCode }} → {{ latestSchedule.schedule.destinationPortName || latestSchedule.schedule.destinationPortCode }}</span><span>预计开船 {{ formatTime(latestSchedule.schedule.etdAt) }} · 预计到港 {{ formatTime(latestSchedule.schedule.etaAt) }}</span></div>
<div class="history-grid">
<section><h3>查询尝试</h3><p v-if="!attempts.length" class="empty-line">暂无查询尝试</p><ol v-else><li v-for="item in attempts" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ attemptText(item.attemptStatus) }}</strong><small>{{ item.errorMessage || item.manualReason || item.operatorName || item.carrierCode }}</small></li></ol></section>
<section><h3>变更历史</h3><p v-if="!histories.length" class="empty-line">暂无变更历史</p><ol v-else><li v-for="item in histories" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ historyText(item.action) }}</strong><small>{{ item.reason || item.operatorName || '系统记录' }}</small></li></ol></section>
<section><h3>标准节点历史</h3><p v-if="!nodes.length" class="empty-line">暂无标准节点</p><ol v-else class="node-list"><li v-for="item in nodes" :key="item.id"><span>{{ formatTime(nodePrimaryTime(item)) }}</span><strong>{{ nodeText(item.standardNode) }}</strong><small>{{ nodeLocation(item) }} · {{ item.providerCode }}<template v-if="item.sourceReference"> · {{ item.sourceReference }}</template></small></li></ol></section>
<section><h3>变更历史</h3><p v-if="!histories.length" class="empty-line">暂无变更历史</p><ol v-else><li v-for="item in histories" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ historyText(item.action) }}</strong><small>{{ item.operatorName || '系统' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
</div>
</template>
</section>
+21 -26
View File
@@ -33,7 +33,8 @@ export interface PortScheduleQuery { page: number; size: number; customerId?: st
export type PortScheduleData = Omit<PortScheduleResource, 'id' | 'scheduleNo' | 'version' | 'updatedAt'>
export interface PortScheduleSaveRequest { expectedVersion: number; data: PortScheduleData; reason?: string | null }
export type CarrierScheduleQueryStatus = 'READY' | 'MANUAL_REQUIRED' | 'SUCCESS'
export type CarrierScheduleQueryStatus = 'READY' | 'MANUAL_REQUIRED' | 'ARRIVED' | 'PICKUP_READY' | 'STOPPED'
export type CarrierScheduleStandardNode = 'DEPARTED_WAREHOUSE' | 'AT_ORIGIN_PORT' | 'DEPARTED_ORIGIN_PORT' | 'IN_TRANSIT' | 'ARRIVED' | 'PICKUP_READY'
export interface CarrierScheduleQueryResource {
id: string
@@ -47,62 +48,56 @@ export interface CarrierScheduleQueryResource {
officialUrl?: string | null
accessMode?: string | null
status: CarrierScheduleQueryStatus
windowStartedAt?: string | null
attemptCount: number
nextAllowedAt?: string | null
lastAttemptAt?: string | null
triggerSource?: string | null
latestStandardNode?: CarrierScheduleStandardNode | null
latestNodeObservedAt?: string | null
lastSuccessAt?: string | null
sourceUpdatedAt?: string | null
expiresAt?: string | null
resultPortScheduleId?: string | null
lastAttemptStatus?: string | null
manualReason?: string | null
lastErrorCode?: string | null
lastErrorMessage?: string | null
remark?: string | null
createdAt?: string | null
updatedAt?: string | null
stoppedAt?: string | null
stopReason?: string | null
version: number
}
export interface CarrierScheduleQueryView {
query: CarrierScheduleQueryResource
expired: boolean
maxAttempts: number
nextAllowedAt?: string | null
}
export interface CarrierScheduleQueryAttempt {
export interface CarrierScheduleNodeFact {
id: string
queryId: string
windowAttemptNo: number
carrierCode: string
accessMode?: string | null
officialUrl?: string | null
attemptStatus: string
coverageGap?: boolean | null
coverageGapCode?: string | null
manualReason?: string | null
errorCode?: string | null
errorMessage?: string | null
operatorName?: string | null
providerCode: string
providerNodeCode: string
standardNode: CarrierScheduleStandardNode
nodeSequence: number
observedAt: string
estimatedAt?: string | null
actualAt?: string | null
locationCode?: string | null
locationName?: string | null
sourceReference?: string | null
rawPayloadHash: string
createdAt?: string | null
}
export interface CarrierScheduleQueryHistory {
id: string
queryId: string
action: string
fromVersion?: number | null
toVersion?: number | null
reason?: string | null
operatorId?: string | null
operatorName?: string | null
createdAt?: string | null
}
export interface CarrierScheduleQueryDetail {
query: CarrierScheduleQueryView
attempts: CarrierScheduleQueryAttempt[]
nodes: CarrierScheduleNodeFact[]
histories: CarrierScheduleQueryHistory[]
}