diff --git a/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts b/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts index e8599c99..dce67e21 100644 --- a/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts +++ b/src/modules/port-schedules/api/platformCarrierScheduleQueries.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { appendCarrierScheduleManualNode, ensureCarrierScheduleQueryForContainer, getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries' +import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries' vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual('@/shared/api/http') @@ -62,10 +62,4 @@ describe('platformCarrierScheduleQueries', () => { 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' } }) - }) }) diff --git a/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts b/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts index bb62e2d9..f3a51a72 100644 --- a/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts +++ b/src/modules/port-schedules/api/platformCarrierScheduleQueries.ts @@ -56,16 +56,6 @@ export async function getCarrierScheduleQuery(queryId: string): Promise { - const response = await http.post, ApiEnvelope>( - `${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 { const response = await http.post, ApiEnvelope>( `${basePath}/${encodeURIComponent(queryId)}/manual-nodes`, diff --git a/src/modules/port-schedules/api/platformContainerShippingNodes.ts b/src/modules/port-schedules/api/platformContainerShippingNodes.ts new file mode 100644 index 00000000..01941fef --- /dev/null +++ b/src/modules/port-schedules/api/platformContainerShippingNodes.ts @@ -0,0 +1,18 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { ContainerManualNodeRequest, ContainerShippingNodeDetail } from '../types' + +const basePath = '/platform/v1/container-shipping-nodes' + +function normalize(raw: ContainerShippingNodeDetail): ContainerShippingNodeDetail { + return { ...raw, containerBookingId: String(raw.containerBookingId), nodes: (raw.nodes || []).map((item) => ({ ...item, id: String(item.id), nodeSequence: Number(item.nodeSequence) })) } +} + +export async function getContainerShippingNodes(containerBookingId: string): Promise { + const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(containerBookingId)}`) + return normalize(unwrapApiResponse(response)) +} + +export async function appendContainerShippingNode(containerBookingId: string, idempotencyKey: string, request: ContainerManualNodeRequest): Promise { + const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(containerBookingId)}`, request, { headers: { 'Idempotency-Key': idempotencyKey } }) + return normalize(unwrapApiResponse(response)) +} diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts index 5c48ce46..279d977b 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts @@ -3,7 +3,8 @@ import { flushPromises, mount } from '@vue/test-utils' import ElementPlus from 'element-plus' import { createPinia, setActivePinia } from 'pinia' import CarrierScheduleQueryPanel from './CarrierScheduleQueryPanel.vue' -import { appendCarrierScheduleManualNode, ensureCarrierScheduleQueryForContainer, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries' +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' @@ -11,9 +12,8 @@ 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/platformContainerShippingNodes', () => ({ getContainerShippingNodes: vi.fn(), appendContainerShippingNode: vi.fn() })) vi.mock('../api/platformPortSchedules', () => ({ getPortSchedule: vi.fn() })) const queryView = { @@ -31,7 +31,7 @@ const queryView = { describe('CarrierScheduleQueryPanel loading states', () => { let pinia: ReturnType - beforeEach(() => { vi.clearAllMocks(); pinia = createPinia(); setActivePinia(pinia); useAuthStore().permissions = ['platform:firstleg:port-schedule-query:manual'] }) + 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 }) @@ -58,15 +58,14 @@ describe('CarrierScheduleQueryPanel loading states', () => { expect(wrapper.text()).toContain('货柜履约轨迹') 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('shows manual entry without an existing task and ensures the task on click', async () => { + it('shows manual entry without creating a tracking task', 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() @@ -75,7 +74,7 @@ describe('CarrierScheduleQueryPanel loading states', () => { await button!.trigger('click') await flushPromises() - expect(ensureCarrierScheduleQueryForContainer).toHaveBeenCalledWith('3', expect.any(String)) + expect(getContainerShippingNodes).toHaveBeenCalledWith('3') expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true) }) @@ -199,7 +198,7 @@ describe('CarrierScheduleQueryPanel loading states', () => { 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' }, global: { plugins: [ElementPlus, pinia] } }) + 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('已提柜')) @@ -211,11 +210,11 @@ describe('CarrierScheduleQueryPanel loading states', () => { expect(wrapper.find('.duplicate-node-history').text()).toContain('人工提柜-旧') }) - it('disables same and earlier actual nodes and rejects an invalid actual submission defensively', async () => { + 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' }, global: { plugins: [ElementPlus, pinia] } }) + 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') @@ -223,15 +222,14 @@ describe('CarrierScheduleQueryPanel loading states', () => { 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')).toBe(true) - expect(options.find((option) => option.props('value') === 'PICKED_UP')!.props('disabled')).toBe(true) + 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(wrapper.text()).toContain('实际补录只能选择严格晚于当前实际进度的节点') - expect(appendCarrierScheduleManualNode).not.toHaveBeenCalled() + expect(appendContainerShippingNode).toHaveBeenCalled() }) it('hides the manual action without the frozen permission while retaining fulfillment facts', async () => { @@ -246,12 +244,12 @@ describe('CarrierScheduleQueryPanel loading states', () => { 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) + 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: { oceanBookingId: '2' }, global: { plugins: [ElementPlus, pinia] } }) + 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') @@ -266,17 +264,17 @@ describe('CarrierScheduleQueryPanel loading states', () => { 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]! + 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]).toMatchObject({ expectedVersion: 1, standardNode: 'IN_TRANSIT', estimatedAt: '2026-08-22T10:00:00', actualAt: null, locationCode: 'PACIFIC', locationName: 'Pacific Ocean', reason: '船司官网人工核验', observedAt: expect.any(String) }) + 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' }, global: { plugins: [ElementPlus, pinia] } }) + 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') @@ -285,6 +283,6 @@ describe('CarrierScheduleQueryPanel loading states', () => { await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() expect(wrapper.text()).toContain('预计时间和实际时间至少填写一项') - expect(appendCarrierScheduleManualNode).not.toHaveBeenCalled() + expect(appendContainerShippingNode).not.toHaveBeenCalled() }) }) diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts index 80c88f84..4882fd9f 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts @@ -39,7 +39,7 @@ describe('CarrierScheduleQueryPanel', () => { expect(source).not.toMatch(new RegExp(`\\b${['ARR', 'IVED'].join('')}:`)) expect(source).toContain("latestActualNode === 'EMPTY_RETURNED'") expect(source).toContain("'已停止 · 已还柜'") - expect(source).toContain('确认离仓后自动开始船节点跟踪') + expect(source).toContain('当前未开启跟踪任务') expect(source).not.toContain('/' + 'scheduler') expect(source).not.toContain('/' + 'attempts') }) @@ -47,7 +47,7 @@ describe('CarrierScheduleQueryPanel', () => { it('uses a compact no-task state without the large empty illustration', () => { expect(source).toContain('class="tracking-prompt"') expect(source).not.toContain(' { @@ -57,9 +57,8 @@ describe('CarrierScheduleQueryPanel', () => { expect(source).toContain('item.actualAt') }) - it('blocks repeated actual progress and folds older immutable facts out of the primary trajectory', () => { - expect(source).toContain('isManualActualNodeAllowed') - expect(source).toContain(':disabled="Boolean(manualForm.actualAt) && !isManualActualNodeAllowed(node)"') + it('keeps historical immutable facts while allowing current-node correction', () => { + expect(source).not.toContain('isManualActualNodeAllowed') expect(source).toContain('primaryNodeFacts') expect(source).toContain('supersededActualNodes') expect(source).toContain('历史重复实际节点') @@ -67,7 +66,7 @@ describe('CarrierScheduleQueryPanel', () => { 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('appendContainerShippingNode') expect(source).toContain('manualIdempotencyKey') expect(source).toContain("type: '异常'") expect(source).toContain('船运跟踪') diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue index 237f600f..6b59ed53 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue @@ -6,9 +6,10 @@ 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, ensureCarrierScheduleQueryForContainer, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries' +import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries' +import { appendContainerShippingNode, getContainerShippingNodes } from '../api/platformContainerShippingNodes' import { getPortSchedule } from '../api/platformPortSchedules' -import type { CarrierScheduleExceptionFact, CarrierScheduleManualNodeRequest, CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, PortScheduleView } from '../types' +import type { CarrierScheduleExceptionFact, CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, ContainerManualNodeRequest, ContainerShippingNodeDetail, PortScheduleView } from '../types' type WarehouseTimelineEntry = { id?: string | null; eventId?: string | null; idempotencyKey?: string | null @@ -26,13 +27,12 @@ const queryDetailError = ref(null) const scheduleError = ref(null) const selected = ref(null) const detail = ref(null) +const containerNodes = ref(null) const latestSchedule = ref(null) const manualDialogOpen = ref(false) const manualIdempotencyKey = ref('') const manualObservedAt = ref('') const manualSaving = ref(false) -const manualPreparing = ref(false) -const manualEntryError = ref(null) const manualError = ref(null) const manualValidationError = ref('') const manualForm = reactive({ standardNode: '' as CarrierScheduleStandardNode | '', estimatedAt: '', actualAt: '', locationCode: '', locationName: '', reason: '' }) @@ -50,7 +50,7 @@ const statusType = computed<'success' | 'warning' | 'danger' | 'info'>(() => { if (queryView.value?.expired || query.value?.status === 'MANUAL_REQUIRED') return 'danger' return 'warning' }) -const nodes = computed(() => detail.value?.nodes || []) +const nodes = computed(() => containerNodes.value?.nodes?.length ? containerNodes.value.nodes : detail.value?.nodes || []) const exceptions = computed(() => [...(detail.value?.exceptions || [])].reverse()) const latestActualNodeFact = computed(() => [...nodes.value].reverse().find((item) => item.standardNode === query.value?.latestActualNode && item.actualAt === query.value?.latestActualAt) || [...nodes.value].reverse().find((item) => item.standardNode === query.value?.latestActualNode && item.actualAt) @@ -60,8 +60,6 @@ const sourceReference = computed(() => latestActualNodeFact.value?.sourceReferen const latestObservation = computed(() => latestActualNodeFact.value?.observedAt || null) const histories = computed(() => [...(detail.value?.histories || [])].reverse()) const standardNodes: 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'] -const standardNodeSequence = new Map(standardNodes.map((node, index) => [node, (index + 1) * 10])) -const latestActualNodeSequence = computed(() => query.value?.latestActualNode ? standardNodeSequence.get(query.value.latestActualNode) || 0 : 0) function compareActualFacts(left: CarrierScheduleNodeFact, right: CarrierScheduleNodeFact) { for (const field of ['actualAt', 'observedAt', 'createdAt'] as const) { @@ -83,7 +81,6 @@ const supersededActualNodes = computed(() => nodes.value .filter((item) => item.actualAt && latestActualFactsByNode.value.get(item.standardNode)?.id !== item.id) .sort((left, right) => compareActualFacts(right, left))) const primaryNodeFacts = computed(() => nodes.value.filter((item) => !item.actualAt || latestActualFactsByNode.value.get(item.standardNode)?.id === item.id)) -function isManualActualNodeAllowed(node: CarrierScheduleStandardNode) { return (standardNodeSequence.get(node) || 0) > latestActualNodeSequence.value } 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)[value] || value } @@ -207,47 +204,33 @@ function localDateTimeNow() { const now = new Date() return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 19) } -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 } - } +function openManualDialog() { + if (!props.containerBookingId) return resetManualForm(); manualIdempotencyKey.value = createIdempotencyKey(); manualObservedAt.value = localDateTimeNow(); manualDialogOpen.value = true } async function submitManualNode() { manualValidationError.value = '' - if (!query.value || !manualForm.standardNode) { manualValidationError.value = '请选择标准节点'; return } + if (!props.containerBookingId || !manualForm.standardNode) { manualValidationError.value = '请选择标准节点'; return } if (!manualForm.estimatedAt && !manualForm.actualAt) { manualValidationError.value = '预计时间和实际时间至少填写一项'; return } - if (manualForm.actualAt && !isManualActualNodeAllowed(manualForm.standardNode)) { manualValidationError.value = '实际补录只能选择严格晚于当前实际进度的节点'; return } if (!manualForm.reason.trim()) { manualValidationError.value = '请输入补录原因'; return } - const base = { expectedVersion: query.value.version, standardNode: manualForm.standardNode, observedAt: manualObservedAt.value, locationCode: manualForm.locationCode.trim() || null, locationName: manualForm.locationName.trim() || null, reason: manualForm.reason.trim() } - const request: CarrierScheduleManualNodeRequest = manualForm.actualAt + const base = { standardNode: manualForm.standardNode, observedAt: manualObservedAt.value, locationCode: manualForm.locationCode.trim() || null, locationName: manualForm.locationName.trim() || null, reason: manualForm.reason.trim() } + const request: ContainerManualNodeRequest = manualForm.actualAt ? { ...base, actualAt: manualForm.actualAt, estimatedAt: manualForm.estimatedAt || null } : { ...base, estimatedAt: manualForm.estimatedAt, actualAt: null } manualSaving.value = true; manualError.value = null try { - const result = await appendCarrierScheduleManualNode(query.value.id, manualIdempotencyKey.value, request) - detail.value = result; selected.value = result.query; queryDetailError.value = null; manualDialogOpen.value = false; manualIdempotencyKey.value = ''; manualObservedAt.value = ''; resetManualForm() + containerNodes.value = await appendContainerShippingNode(props.containerBookingId, manualIdempotencyKey.value, request) + manualDialogOpen.value = false; manualIdempotencyKey.value = ''; manualObservedAt.value = ''; resetManualForm() } catch (cause) { manualError.value = getApiErrorDetails(cause, '人工节点补录失败') } finally { manualSaving.value = false } } async function load() { - selected.value = null; detail.value = null; latestSchedule.value = null; error.value = null; queryDetailError.value = null; scheduleError.value = null + selected.value = null; detail.value = null; containerNodes.value = null; latestSchedule.value = null; error.value = null; queryDetailError.value = null; scheduleError.value = null if (!props.oceanBookingId && !props.containerBookingId) return loading.value = true try { + if (props.containerBookingId) containerNodes.value = await getContainerShippingNodes(props.containerBookingId) 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[0] || page.records[0] || null @@ -266,26 +249,22 @@ async function load() { watch(() => [props.oceanBookingId, props.containerBookingId, props.portScheduleId], () => void load(), { immediate: true }) watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualIdempotencyKey.value = ''; manualObservedAt.value = '' } }) -watch(() => manualForm.actualAt, (actualAt) => { - if (actualAt && manualForm.standardNode && !isManualActualNodeAllowed(manualForm.standardNode)) manualForm.standardNode = '' -})