From b907881740deb82ac530ad3a676fec6ad07eb1cb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 15:41:08 +0800 Subject: [PATCH] fix(admin): prevent duplicate actual carrier nodes --- ...CarrierScheduleQueryPanel.behavior.test.ts | 41 +++++++++++++++++++ .../CarrierScheduleQueryPanel.test.ts | 8 ++++ .../components/CarrierScheduleQueryPanel.vue | 35 ++++++++++++++-- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts index e9970523..1865e974 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts @@ -132,6 +132,47 @@ describe('CarrierScheduleQueryPanel loading states', () => { expect(wrapper.text()).toContain('人工补录标准节点') }) + it('shows only the latest duplicate actual node in the primary trajectory and keeps the older fact expandable', async () => { + const task = { ...queryView.query, latestActualNode: 'PICKED_UP' as const, latestActualAt: '2026-09-24T10:00:00', resultPortScheduleId: null } + const older = { id: 'pickup-1', providerCode: 'MANUAL', providerEventId: 'pickup-old', providerNodeCode: 'PICKED_UP', standardNode: 'PICKED_UP' as const, nodeSequence: 90, observedAt: '2026-09-09T10:05:00', estimatedAt: null, actualAt: '2026-09-09T10:00:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: '人工提柜-旧', rawPayloadHash: '1'.repeat(64), createdAt: '2026-09-09T10:06:00' } + 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] } }) + await flushPromises() + + const primaryPickupRows = wrapper.findAll('.fulfillment-timeline li').filter((row) => row.text().includes('已提柜')) + expect(primaryPickupRows).toHaveLength(1) + expect(primaryPickupRows[0]!.text()).toContain('2026-09-24 10:00') + expect(primaryPickupRows[0]!.text()).not.toContain('2026-09-09 10:00') + expect(wrapper.find('.duplicate-node-history').text()).toContain('历史重复实际节点(1)') + expect(wrapper.find('.duplicate-node-history').text()).toContain('2026-09-09 10:00') + expect(wrapper.find('.duplicate-node-history').text()).toContain('人工提柜-旧') + }) + + it('disables same and earlier actual nodes and rejects an invalid actual submission defensively', 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] } }) + await flushPromises() + + await wrapper.findAll('button').find((button) => button.text().includes('人工补录节点'))!.trigger('click') + const pickers = wrapper.findAllComponents({ name: 'ElDatePicker' }) + 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') === '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() + }) + it('hides the manual action without the frozen permission while retaining fulfillment facts', async () => { useAuthStore().permissions = [] vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 }) diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts index e7594136..80c88f84 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts @@ -57,6 +57,14 @@ 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)"') + expect(source).toContain('primaryNodeFacts') + expect(source).toContain('supersededActualNodes') + expect(source).toContain('历史重复实际节点') + }) + 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') diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue index 130a6803..4e61f504 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue @@ -52,6 +52,30 @@ 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) { + const compared = String(left[field] || '').localeCompare(String(right[field] || '')) + if (compared) return compared + } + return left.id.localeCompare(right.id) +} +const latestActualFactsByNode = computed(() => { + const result = new Map() + for (const item of nodes.value) { + if (!item.actualAt) continue + const current = result.get(item.standardNode) + if (!current || compareActualFacts(item, current) > 0) result.set(item.standardNode, item) + } + return result +}) +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 } @@ -75,7 +99,7 @@ const fulfillmentEvents = computed(() => { values.push({ key: `warehouse-${item.eventType}-${item.occurredAt}-${index}`, time: item.occurredAt, type: departure ? '确认离仓' : '仓内操作', title: warehouseEventText(item.eventType), summary: warehouseEventSummary(item), source: item.operatorName || '系统', actual: true }) } const nodeKeys = new Set() - for (const item of nodes.value) { + for (const item of primaryNodeFacts.value) { const actual = Boolean(item.actualAt) const time = item.actualAt || item.estimatedAt || item.observedAt const key = JSON.stringify([item.standardNode, item.actualAt || null, item.estimatedAt || null, item.locationCode || null, item.locationName || null, item.providerCode, item.sourceReference || null]) @@ -111,6 +135,7 @@ async function submitManualNode() { manualValidationError.value = '' if (!query.value || !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 @@ -147,6 +172,9 @@ 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 = '' +}) - +