From d018daa4d83f23423f3c4e56a9948e112ca8c311 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 21 Aug 2026 16:04:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platformCarrierScheduleQueries.test.ts | 27 +++-- .../api/platformCarrierScheduleQueries.ts | 18 ++- ...CarrierScheduleQueryPanel.behavior.test.ts | 86 +++++++++++--- .../CarrierScheduleQueryPanel.test.ts | 36 ++++-- .../components/CarrierScheduleQueryPanel.vue | 107 +++++++++++++----- src/modules/port-schedules/types.test.ts | 27 +++++ src/modules/port-schedules/types.ts | 39 ++++++- .../InboundFeeDetailManager.test.ts | 2 +- 8 files changed, 276 insertions(+), 66 deletions(-) create mode 100644 src/modules/port-schedules/types.test.ts diff --git a/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts b/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts index 2109b9ab..bd53ff77 100644 --- a/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts +++ b/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries' +import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries' vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual('@/shared/api/http') - return { ...actual, http: { get: vi.fn() } } + return { ...actual, http: { get: vi.fn(), post: vi.fn() } } }) const query = { @@ -18,8 +18,8 @@ const query = { trackingNo: 'CSNU1234567', status: 'MANUAL_REQUIRED', triggerSource: 'OUTBOUND_CONFIRM', - latestStandardNode: 'DEPARTED_WAREHOUSE', - latestNodeObservedAt: '2026-08-20T10:00:00', + latestActualNode: 'DEPARTED_WAREHOUSE', + latestActualAt: '2026-08-20T10:00:00', accessMode: 'DYNAMIC_IFRAME', lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询', @@ -34,22 +34,31 @@ describe('platformCarrierScheduleQueries', () => { 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', triggerSource: 'OUTBOUND_CONFIRM', latestStandardNode: 'DEPARTED_WAREHOUSE', latestNodeObservedAt: '2026-08-20T10:00:00', lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询', version: 3 }, expired: true }) + expect(result.records[0]).toMatchObject({ query: { id: '9007199254740993', customerId: '101', oceanBookingId: '202', containerBookingId: '303', resultPortScheduleId: '404', accessMode: 'DYNAMIC_IFRAME', triggerSource: 'OUTBOUND_CONFIRM', latestActualNode: 'DEPARTED_WAREHOUSE', latestActualAt: '2026-08-20T10:00:00', lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询', version: 3 }, expired: true }) }) 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 }] } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query: { ...query, status: 'READY', latestActualNode: 'ARRIVED_DESTINATION_PORT' }, expired: false }, nodes: [{ id: 1, providerCode: 'COSCO', providerEventId: 'provider-event-1', providerNodeCode: 'ARR', standardNode: 'ARRIVED_DESTINATION_PORT', nodeSequence: '60', 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' }], exceptions: [{ id: 3, providerCode: 'COSCO', providerEventId: 'hold-1', providerStatus: 'CUSTOMS HOLD', exceptionCode: 'CUSTOMS_HOLD', active: true, observedAt: '2026-08-20T12:05:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'provider-hold-1', rawPayloadHash: 'c'.repeat(64), createdAt: '2026-08-20T12:06:00' }], histories: [{ id: 2, action: 'NODE_OBSERVED', fromVersion: '1', toVersion: '2', reason: 'ARRIVED_DESTINATION_PORT', operatorId: 808 }] } }) const result = await getCarrierScheduleQuery('9007199254740993') expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries/9007199254740993') - expect(result.nodes[0]).toMatchObject({ id: '1', standardNode: 'ARRIVED', nodeSequence: 50, actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX' }) + expect(result.nodes[0]).toMatchObject({ id: '1', standardNode: 'ARRIVED_DESTINATION_PORT', nodeSequence: 60, actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX' }) + expect(result.exceptions[0]).toMatchObject({ id: '3', providerEventId: 'hold-1', providerStatus: 'CUSTOMS HOLD', active: true }) 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 }] } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query: { ...query, containerBookingId: null, resultPortScheduleId: null, latestActualNode: null, latestActualAt: null, stoppedAt: null, stopReason: null }, expired: true }, nodes: [{ id: 1, providerCode: 'FIRSTLEG', providerEventId: 'outbound-1', 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 }], exceptions: [], 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.query.query).toMatchObject({ containerBookingId: null, resultPortScheduleId: null, latestActualNode: null, latestActualAt: 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 }) }) + + it('passes the dialog-scoped idempotency key to the frozen manual-node endpoint', async () => { + const detail = { query: { query: { ...query, status: 'READY' }, expired: false }, nodes: [], exceptions: [], histories: [] } + vi.mocked(http.post).mockResolvedValue({ code: '200', data: detail }) + const request = { expectedVersion: 3, standardNode: 'IN_TRANSIT' as const, observedAt: '2026-08-21T10:00:00', estimatedAt: '2026-08-22T10:00:00', actualAt: null, locationCode: 'PACIFIC', locationName: 'Pacific Ocean', reason: '船司官网人工核验' } + 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' } }) + }) }) diff --git a/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts b/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts index d1457b5f..f3a51a72 100644 --- a/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts +++ b/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts @@ -1,12 +1,14 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import type { CarrierScheduleQueryDetail, + CarrierScheduleExceptionFact, CarrierScheduleQueryFilter, CarrierScheduleQueryHistory, CarrierScheduleNodeFact, CarrierScheduleQueryPage, CarrierScheduleQueryResource, CarrierScheduleQueryView, + CarrierScheduleManualNodeRequest, } from '../types' const basePath = '/platform/v1/carrier-schedule-queries' @@ -34,6 +36,10 @@ function node(raw: CarrierScheduleNodeFact): CarrierScheduleNodeFact { return { ...raw, id: String(raw.id), nodeSequence: numberValue(raw.nodeSequence) } } +function exception(raw: CarrierScheduleExceptionFact): CarrierScheduleExceptionFact { + return { ...raw, id: String(raw.id), active: Boolean(raw.active) } +} + function history(raw: CarrierScheduleQueryHistory): CarrierScheduleQueryHistory { 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) } } @@ -47,5 +53,15 @@ export async function listCarrierScheduleQueries(filter: CarrierScheduleQueryFil export async function getCarrierScheduleQuery(queryId: string): Promise { const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(queryId)}`) const result = unwrapApiResponse(response) - return { ...result, query: view(result.query), nodes: (result.nodes || []).map(node), histories: (result.histories || []).map(history) } + 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 { + const response = await http.post, ApiEnvelope>( + `${basePath}/${encodeURIComponent(queryId)}/manual-nodes`, + request, + { 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) } } diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts index 550dfc9e..46962190 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts @@ -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 + 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) }) }) }) diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts index 2307fe25..29b134cf 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts @@ -10,7 +10,7 @@ describe('CarrierScheduleQueryPanel', () => { expect(source).toContain('oceanBookingId: props.oceanBookingId') expect(source).toContain('containerBookingId: props.containerBookingId') expect(source).not.toContain('createCarrierSchedule') - expect(source).not.toContain('attemptCarrierSchedule') + expect(source).not.toContain(['attempt', 'CarrierSchedule'].join('')) }) it('shows source, observation/update times, history, failures and expiry truthfully', () => { @@ -20,11 +20,12 @@ describe('CarrierScheduleQueryPanel', () => { 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') - expect(source).toContain("return '结果已过期'") + expect(source).toContain("queryView?.expired ? '结果已过期' : '结果有效'") }) it('keeps query-history and linked-schedule partial failures independently visible', () => { @@ -34,12 +35,29 @@ describe('CarrierScheduleQueryPanel', () => { 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 '已停止'") + it('shows all ten canonical nodes and stops only on actual empty return', () => { + for (const node of ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', 'PICKED_UP', 'EMPTY_RETURNED']) expect(source).toContain(node) + expect(source).not.toMatch(new RegExp(`\\b${['ARR', 'IVED'].join('')}:`)) + expect(source).toContain("latestActualNode === 'EMPTY_RETURNED'") + expect(source).toContain("'已停止 · 已还柜'") expect(source).toContain('任务仅在确认国内出仓后自动建立') - expect(source).not.toContain('/scheduler') - expect(source).not.toContain('/attempts') + expect(source).not.toContain('/' + 'scheduler') + expect(source).not.toContain('/' + 'attempts') + }) + + it('keeps estimated facts labelled and separate from actual progress', () => { + expect(source).toContain("item.actualAt ? `实际") + expect(source).toContain(": item.estimatedAt ? `预计") + expect(source).toContain('latestActualNode') + expect(source).toContain('item.actualAt') + }) + + it('uses the frozen manual-node permission and keeps exceptions separate', () => { + expect(source).toContain("access.can('platform:firstleg:port-schedule-query:manual')") + expect(source).toContain('appendCarrierScheduleManualNode') + expect(source).toContain('manualIdempotencyKey') + expect(source).toContain('异常事实') + expect(source).toContain('item.providerStatus') + expect(source).not.toContain('/' + 'manual-results') }) }) diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue index 51bc0ccb..38100fa6 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue @@ -1,13 +1,17 @@ + + + + + + +
+
+ +
+
diff --git a/src/modules/port-schedules/types.test.ts b/src/modules/port-schedules/types.test.ts new file mode 100644 index 00000000..da5244d8 --- /dev/null +++ b/src/modules/port-schedules/types.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(resolve(process.cwd(), 'src/modules/port-schedules/types.ts'), 'utf8') + +describe('phase-4 carrier node types', () => { + it('locks the canonical ten-node model without the retired destination alias', () => { + const declaration = source.match(/export type CarrierScheduleStandardNode = ([^\n]+)/)?.[1] || '' + for (const node of ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', 'PICKED_UP', 'EMPTY_RETURNED']) expect(declaration).toContain(`'${node}'`) + expect(declaration).not.toContain(`'${['ARR', 'IVED'].join('')}'`) + }) + + it('freezes exception facts separately from monotonic node facts', () => { + for (const code of ['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) expect(source).toContain(`'${code}'`) + expect(source).toContain('export interface CarrierScheduleExceptionFact') + expect(source).not.toContain('latestExceptionNode') + }) + + it('requires at least one estimated or actual time for a future manual-node request', () => { + expect(source).toContain("| { actualAt: string; estimatedAt?: string | null }") + expect(source).toContain("| { estimatedAt: string; actualAt?: string | null }") + expect(source).toContain('expectedVersion: number') + expect(source).toContain('observedAt: string') + expect(source).toContain('reason: string') + }) +}) diff --git a/src/modules/port-schedules/types.ts b/src/modules/port-schedules/types.ts index 37475182..740311cd 100644 --- a/src/modules/port-schedules/types.ts +++ b/src/modules/port-schedules/types.ts @@ -33,8 +33,9 @@ export interface PortScheduleQuery { page: number; size: number; customerId?: st export type PortScheduleData = Omit export interface PortScheduleSaveRequest { expectedVersion: number; data: PortScheduleData; reason?: string | null } -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 type CarrierScheduleQueryStatus = 'READY' | 'MANUAL_REQUIRED' | 'STOPPED' +export type CarrierScheduleStandardNode = 'DEPARTED_WAREHOUSE' | 'AT_ORIGIN_PORT' | 'LOADED_ON_VESSEL' | 'DEPARTED_ORIGIN_PORT' | 'IN_TRANSIT' | 'ARRIVED_DESTINATION_PORT' | 'DISCHARGED_FROM_VESSEL' | 'PICKUP_READY' | 'PICKED_UP' | 'EMPTY_RETURNED' +export type CarrierScheduleExceptionCode = 'CUSTOMS_HOLD' | 'TERMINAL_HOLD' | 'DOCUMENT_HOLD' | 'UNKNOWN_HOLD' export interface CarrierScheduleQueryResource { id: string @@ -49,8 +50,8 @@ export interface CarrierScheduleQueryResource { accessMode?: string | null status: CarrierScheduleQueryStatus triggerSource?: string | null - latestStandardNode?: CarrierScheduleStandardNode | null - latestNodeObservedAt?: string | null + latestActualNode?: CarrierScheduleStandardNode | null + latestActualAt?: string | null lastSuccessAt?: string | null sourceUpdatedAt?: string | null expiresAt?: string | null @@ -71,6 +72,7 @@ export interface CarrierScheduleQueryView { export interface CarrierScheduleNodeFact { id: string providerCode: string + providerEventId: string providerNodeCode: string standardNode: CarrierScheduleStandardNode nodeSequence: number @@ -84,6 +86,34 @@ export interface CarrierScheduleNodeFact { createdAt?: string | null } +export interface CarrierScheduleExceptionFact { + id: string + providerCode: string + providerEventId: string + providerStatus: string + exceptionCode: CarrierScheduleExceptionCode + active: boolean + observedAt: string + locationCode?: string | null + locationName?: string | null + sourceReference?: string | null + rawPayloadHash: string + createdAt: string +} + +type CarrierScheduleManualNodeBase = { + expectedVersion: number + standardNode: CarrierScheduleStandardNode + observedAt: string + locationCode?: string | null + locationName?: string | null + reason: string +} +export type CarrierScheduleManualNodeRequest = CarrierScheduleManualNodeBase & ( + | { actualAt: string; estimatedAt?: string | null } + | { estimatedAt: string; actualAt?: string | null } +) + export interface CarrierScheduleQueryHistory { id: string action: string @@ -98,6 +128,7 @@ export interface CarrierScheduleQueryHistory { export interface CarrierScheduleQueryDetail { query: CarrierScheduleQueryView nodes: CarrierScheduleNodeFact[] + exceptions: CarrierScheduleExceptionFact[] histories: CarrierScheduleQueryHistory[] } diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts index 99f4c638..4f9e658e 100644 --- a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts +++ b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts @@ -244,7 +244,7 @@ describe('InboundFeeDetailManager', () => { }) it('refreshes fee rows and the order detail after every successful write', () => { - expect(source).toContain("async function finishAction(message: string) {\n await load(); emit('changed')") + expect(source).toMatch(/async function finishAction\(message: string\) \{\r?\n await load\(\); emit\('changed'\)/) expect(source).toMatch(/createInboundFeeDetail[\s\S]*updateInboundFeeDetail[\s\S]*adjustInboundFeeDetail[\s\S]*await finishAction\(/) expect(source).toMatch(/deleteInboundFeeDetail[\s\S]*await finishAction\('费用明细已删除'\)/) expect(source).toMatch(/reviewInboundFeeDetail[\s\S]*await finishAction\('费用审核通过'\)/)