diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index d27daf98..63c8ec64 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -112,10 +112,10 @@ describe('platform container loading API', () => { container: { id: 9007199254740993, warehouseId: 9007199254740994, version: '4' }, loadingPlanId: 9007199254740995, pda: { taskId: 9007199254740996, loadingPlanId: 9007199254740995, containerBookingId: 9007199254740993, taskVersion: '2', itemCount: '4', completedItemCount: '3', unresolvedItemCount: '1', photoCounts: [{ evidenceType: 'EMPTY_CONTAINER', count: '2' }], photos: [{ evidenceId: 20, tenantId: 21, inboundId: 22, fileId: 23, downloadPath: '/api/platform/v1/containers/9007199254740993/pda/photos/23/download' }], exceptionReasons: [] }, documents: [{ id: 9007199254740997, documentType: 'DELIVERY_ORDER', fileId: 9007199254740998, uploadedBy: 9, version: '2' }], - exception: { active: false, markedBy: null }, exceptionHistory: [{ id: 10, operatorId: 11 }], timeline: [{ operatorId: 12 }], + exception: { active: false, markedBy: null }, exceptionHistory: [{ id: 10, operatorId: 11 }], timeline: [{ id: '2092438843630397001', eventId: '2092438843630397002', idempotencyKey: '2092438843630397003', operatorId: 12, eventType: 'PUSH_PRELOAD', detail: '推送预装清单' }], } }) const result = await getContainerOverview('7') - expect(result).toMatchObject({ container: { id: expect.any(String), warehouseId: expect.any(String), version: 4 }, loadingPlanId: expect.any(String), pda: { taskId: expect.any(String), itemCount: 4, photoCounts: [{ count: 2 }] }, documents: [{ fileId: expect.any(String), version: 2 }] }) + expect(result).toMatchObject({ container: { id: expect.any(String), warehouseId: expect.any(String), version: 4 }, loadingPlanId: expect.any(String), pda: { taskId: expect.any(String), itemCount: 4, photoCounts: [{ count: 2 }] }, documents: [{ fileId: expect.any(String), version: 2 }], timeline: [{ id: '2092438843630397001', eventId: '2092438843630397002', idempotencyKey: '2092438843630397003', operatorId: '12' }] }) expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/overview') vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: null }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index c1aa5393..c63bc4a3 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -60,7 +60,13 @@ function overview(raw: ContainerOverview): ContainerOverview { documents: (raw.documents || []).map(overviewDocument), exception: { ...raw.exception, markedBy: optionalId(raw.exception?.markedBy) }, exceptionHistory: (raw.exceptionHistory || []).map((entry) => ({ ...entry, id: id(entry.id), operatorId: optionalId(entry.operatorId) })), - timeline: (raw.timeline || []).map((entry) => ({ ...entry, operatorId: optionalId(entry.operatorId) })), + timeline: (raw.timeline || []).map((entry) => ({ + ...entry, + id: optionalId(entry.id), + eventId: optionalId(entry.eventId), + idempotencyKey: optionalId(entry.idempotencyKey), + operatorId: optionalId(entry.operatorId), + })), } } diff --git a/src/modules/container-loading/types.ts b/src/modules/container-loading/types.ts index e2cdd977..88646730 100644 --- a/src/modules/container-loading/types.ts +++ b/src/modules/container-loading/types.ts @@ -149,8 +149,20 @@ export interface ContainerExceptionHistory { } export interface ContainerTimelineEntry { + id?: string | null + eventId?: string | null + idempotencyKey?: string | null eventType: string + action?: string | null + fromStatus?: string | null + toStatus?: string | null detail: string | null + summary?: string | null + changeSummary?: string | null + reason?: string | null + changedFields?: unknown + fieldChanges?: unknown + changes?: unknown operatorId: string | null operatorName: string | null occurredAt: string | null diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts index 1865e974..14fe2989 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts @@ -63,6 +63,40 @@ describe('CarrierScheduleQueryPanel loading states', () => { expect(getPortSchedule).not.toHaveBeenCalled() }) + it('filters large no-op status batches, keeps real actions, deduplicates only adjacent explicit event keys and sorts newest first', async () => { + vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) + const noops = Array.from({ length: 40 }, (_, index) => ({ + eventType: 'STATUS_UPDATE', action: 'STATUS_UPDATE', fromStatus: 'EDITING', toStatus: 'EDITING', + detail: index % 2 ? null : '预装柜中→预装柜中', eventId: `2092438843630398${String(index).padStart(3, '0')}`, + operatorName: '系统', occurredAt: `2026-08-27T09:${String(index).padStart(2, '0')}:00`, + })) + const timeline = [ + ...noops, + { eventType: 'STATUS_UPDATE', action: 'STATUS_UPDATE', fromStatus: 'EDITING', toStatus: 'LOADING', detail: null, eventId: '2092438843630399001', operatorName: '调度员', occurredAt: '2026-08-27T10:00:00' }, + { eventType: 'STATUS_UPDATE', action: 'STATUS_UPDATE', fromStatus: 'LOADING', toStatus: 'LOADING', detail: null, changedFields: ['containerNo'], eventId: '2092438843630399002', operatorName: '调度员', occurredAt: '2026-08-27T10:01:00' }, + { eventType: 'STATUS_UPDATE', action: 'PUSH_PRELOAD', fromStatus: 'EDITING', toStatus: 'EDITING', detail: null, eventId: '2092438843630399003', operatorName: '调度员', occurredAt: '2026-08-27T10:02:00' }, + { eventType: 'PDA_BATCH', action: 'PDA_BATCH', fromStatus: 'LOADING', toStatus: 'LOADING', detail: '提交拣货结果', eventId: '2092438843630399004', operatorName: '仓库员', occurredAt: '2026-08-27T10:03:00' }, + { eventType: 'PDA_BATCH', action: 'PDA_BATCH', fromStatus: 'LOADING', toStatus: 'LOADING', detail: '提交拣货结果', eventId: '2092438843630399004', operatorName: '仓库员', occurredAt: '2026-08-27T10:04:00' }, + { eventType: 'STATUS_UPDATE', action: 'CONFIRM_PRELOAD', fromStatus: 'EDITING', toStatus: 'EDITING', detail: null, idempotencyKey: '2092438843630399005', operatorName: '复核员', occurredAt: '2026-08-27T10:05:00' }, + { eventType: 'TRANSPORT_UPDATE', action: 'TRANSPORT_UPDATE', detail: '更新集装箱号', eventId: '2092438843630399006', operatorName: '操作员甲', occurredAt: '2026-08-27T10:06:00' }, + { eventType: 'TRANSPORT_UPDATE', action: 'TRANSPORT_UPDATE', detail: '更新集装箱号', eventId: '2092438843630399007', operatorName: '操作员乙', occurredAt: '2026-08-27T10:07:00' }, + ] + const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', warehouseTimeline: timeline }, global: { plugins: [ElementPlus, pinia] } }) + await flushPromises() + + expect(wrapper.find('.history-count').text()).toBe('仓内 7 条 · 共 7 条') + const rows = wrapper.findAll('.fulfillment-timeline li') + expect(rows).toHaveLength(7) + expect(rows[0]?.text()).toContain('操作员乙') + expect(rows[6]?.text()).toContain('预装柜中 → 拣货中') + expect(rows.filter((row) => row.text().includes('提交拣货结果'))).toHaveLength(1) + expect(wrapper.text()).toContain('推送预装清单') + expect(wrapper.text()).toContain('确认预装清单') + expect(wrapper.text()).toContain('业务字段已更新') + expect(wrapper.text()).not.toContain('2092438843630398000') + expect(rows.filter((row) => row.text().includes('更新集装箱号'))).toHaveLength(2) + }) + 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: [], exceptions: [], histories: [] }) diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue index 4e61f504..17f1dc10 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue @@ -10,7 +10,13 @@ import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierSc import { getPortSchedule } from '../api/platformPortSchedules' import type { CarrierScheduleExceptionFact, CarrierScheduleManualNodeRequest, CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, PortScheduleView } from '../types' -type WarehouseTimelineEntry = { eventType: string; detail?: string | null; operatorName?: string | null; occurredAt?: string | null } +type WarehouseTimelineEntry = { + id?: string | null; eventId?: string | null; idempotencyKey?: string | null + eventType: string; action?: string | null; fromStatus?: string | null; toStatus?: string | null + detail?: string | null; summary?: string | null; changeSummary?: string | null; reason?: string | null + changedFields?: unknown; fieldChanges?: unknown; changes?: unknown + operatorName?: string | null; occurredAt?: string | null +} type FulfillmentEvent = { key: string; time: string; type: '仓内操作' | '确认离仓' | '船运节点' | '人工补录' | '异常'; title: string; summary: string; source: string; actual: boolean } const props = defineProps<{ oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null; warehouseTimeline?: WarehouseTimelineEntry[] }>() const access = useAccessPreviewStore() @@ -86,17 +92,78 @@ function exceptionLocation(item: CarrierScheduleExceptionFact) { return [item.lo function nodeTimeText(item: CarrierScheduleNodeFact) { return item.actualAt ? `实际 ${formatTime(item.actualAt)}` : item.estimatedAt ? `预计 ${formatTime(item.estimatedAt)}` : `观测 ${formatTime(item.observedAt)}` } function exceptionText(value: CarrierScheduleExceptionFact['exceptionCode']) { return ({ CUSTOMS_HOLD: '海关扣留', TERMINAL_HOLD: '码头扣留', DOCUMENT_HOLD: '单证扣留', UNKNOWN_HOLD: '其他扣留' } as Record)[value] || value } function historyText(action: string) { return ({ AUTO_CREATE: '国内出仓自动建任务', CREATE: '人工建立查询任务', NODE_OBSERVED: '记录标准节点', PROVIDER_QUERY: '船司查询', MANUAL_NODE: '人工补录标准节点', STOP: '停止查询' } as Record)[action] || action } -function warehouseEventText(value: string) { return ({ TRANSPORT_UPDATE: '更新运输信息', PDA_CLAIM: '领取 PDA 任务', PDA_BATCH: '提交拣货结果', PDA_COMPLETE_LOADING: '完成装柜' } as Record)[value] || value || '状态更新' } +function warehouseEventText(value: string) { return ({ STATUS_UPDATE: '状态更新', PUSH_PRELOAD: '推送预装清单', CONFIRM_PRELOAD: '确认预装清单', TRANSPORT_UPDATE: '更新运输信息', PDA_CLAIM: '领取 PDA 任务', PDA_BATCH: '提交拣货结果', PDA_COMPLETE_LOADING: '完成装柜' } as Record)[value] || value || '状态更新' } +function nonBlank(value: unknown) { return typeof value === 'string' ? value.trim() : '' } +function hasStructuredChanges(value: unknown) { + if (Array.isArray(value)) return value.length > 0 + if (value && typeof value === 'object') return Object.keys(value as Record).length > 0 + return Boolean(nonBlank(value)) +} +function statusLabel(value?: string | null) { + return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' } as Record)[value || ''] || nonBlank(value) +} +function isStatusEcho(detail: string, fromStatus?: string | null, toStatus?: string | null) { + const from = nonBlank(fromStatus); const to = nonBlank(toStatus) + if (!from || !to) return false + const compact = detail.replace(/\s+/g, '') + return [[from, to], [statusLabel(from), statusLabel(to)]].some(([left, right]) => + [`${left}→${right}`, `${left}->${right}`].includes(compact)) +} +function warehouseAction(entry: WarehouseTimelineEntry) { return nonBlank(entry.action) || nonBlank(entry.eventType) } +function hasEffectiveWarehouseChange(entry: WarehouseTimelineEntry) { + if (hasStructuredChanges(entry.changedFields) || hasStructuredChanges(entry.fieldChanges) || hasStructuredChanges(entry.changes)) return true + if ([entry.summary, entry.changeSummary, entry.reason].some((value) => nonBlank(value))) return true + const detail = nonBlank(entry.detail) + return Boolean(detail && !isStatusEcho(detail, entry.fromStatus, entry.toStatus)) +} +function isMeaningfulWarehouseAction(entry: WarehouseTimelineEntry) { + return [entry.action, entry.eventType].some((value) => /^(?:PUSH_PRELOAD|PDA(?:_|$)|CONFIRM(?:_|$))/.test(nonBlank(value))) +} +function isNoopWarehouseStatus(entry: WarehouseTimelineEntry) { + if (isMeaningfulWarehouseAction(entry)) return false + return warehouseAction(entry) === 'STATUS_UPDATE' + && nonBlank(entry.fromStatus) !== '' + && nonBlank(entry.fromStatus) === nonBlank(entry.toStatus) + && !hasEffectiveWarehouseChange(entry) +} +function eventIdentity(entry: WarehouseTimelineEntry) { return nonBlank(entry.eventId) || nonBlank(entry.idempotencyKey) } +function eventSignature(entry: WarehouseTimelineEntry) { + return JSON.stringify([warehouseAction(entry), entry.fromStatus || null, entry.toStatus || null, entry.summary || null, entry.changeSummary || null, entry.reason || null, entry.detail || null, entry.changedFields || null, entry.fieldChanges || null, entry.changes || null]) +} +function sameExplicitEvent(left: WarehouseTimelineEntry, right: WarehouseTimelineEntry) { + const sameId = nonBlank(left.eventId) && nonBlank(left.eventId) === nonBlank(right.eventId) + const sameKey = nonBlank(left.idempotencyKey) && nonBlank(left.idempotencyKey) === nonBlank(right.idempotencyKey) + return Boolean((sameId || sameKey) && eventSignature(left) === eventSignature(right)) +} +const filteredWarehouseTimeline = computed(() => { + const sorted = (props.warehouseTimeline || []).map((entry, index) => ({ entry, index })) + .filter(({ entry }) => entry.occurredAt && !['MARK', 'CANCEL'].includes(warehouseAction(entry)) && !isNoopWarehouseStatus(entry)) + .sort((left, right) => { + const time = Date.parse(right.entry.occurredAt || '') - Date.parse(left.entry.occurredAt || '') + return time || left.index - right.index + }) + const result: WarehouseTimelineEntry[] = [] + for (const { entry } of sorted) { + const previous = result[result.length - 1] + if (previous && sameExplicitEvent(previous, entry)) continue + result.push(entry) + } + return result +}) function warehouseEventSummary(entry: WarehouseTimelineEntry) { - const value = String(entry.detail || '').trim() - return !value || value.length > 120 || /(?:operation|idempot|claim-|pda-submit|complete-)/i.test(value) ? '操作已记录' : value + const value = nonBlank(entry.summary) || nonBlank(entry.changeSummary) || nonBlank(entry.reason) || nonBlank(entry.detail) + if (value && value.length <= 120 && !/(?:operation|idempot|claim-|pda-submit|complete-)/i.test(value)) return value + const from = nonBlank(entry.fromStatus); const to = nonBlank(entry.toStatus) + if (from && to && from !== to) return `${statusLabel(from)} → ${statusLabel(to)}` + if (hasStructuredChanges(entry.changedFields) || hasStructuredChanges(entry.fieldChanges) || hasStructuredChanges(entry.changes)) return '业务字段已更新' + return '操作已记录' } const fulfillmentEvents = computed(() => { const values: FulfillmentEvent[] = [] - for (const [index, item] of (props.warehouseTimeline || []).entries()) { - if (!item.occurredAt || ['MARK', 'CANCEL'].includes(item.eventType)) continue - const departure = /OUTBOUND|DISPATCH|DEPARTED_WAREHOUSE/.test(item.eventType) - 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 }) + for (const [index, item] of filteredWarehouseTimeline.value.entries()) { + const action = warehouseAction(item) + const departure = /OUTBOUND|DISPATCH|DEPARTED_WAREHOUSE/.test(action) + values.push({ key: `warehouse-${eventIdentity(item) || `${action}-${item.occurredAt}-${index}`}`, time: item.occurredAt!, type: departure ? '确认离仓' : '仓内操作', title: warehouseEventText(action), summary: warehouseEventSummary(item), source: item.operatorName || '系统', actual: true }) } const nodeKeys = new Set() for (const item of primaryNodeFacts.value) { @@ -179,7 +246,7 @@ watch(() => manualForm.actualAt, (actualAt) => {