@freighttech/terminal-tracking 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/generated/entities/terminal_config/index.js +53 -0
- package/dist/generated/entities/terminal_config/index.js.map +7 -0
- package/dist/generated/entities/terminal_event/index.js +59 -0
- package/dist/generated/entities/terminal_event/index.js.map +7 -0
- package/dist/generated/entities/terminal_tracking_job/index.js +37 -0
- package/dist/generated/entities/terminal_tracking_job/index.js.map +7 -0
- package/dist/generated/entities/terminal_vessel_visit/index.js +41 -0
- package/dist/generated/entities/terminal_vessel_visit/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +26 -0
- package/dist/generated/entities.ids.generated.js.map +7 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/terminal_tracking/acl.js +14 -0
- package/dist/modules/terminal_tracking/acl.js.map +7 -0
- package/dist/modules/terminal_tracking/api/openapi.js +27 -0
- package/dist/modules/terminal_tracking/api/openapi.js.map +7 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/route.js +140 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js +58 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js +109 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js +58 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/route.js +189 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/utils.js +12 -0
- package/dist/modules/terminal_tracking/api/utils.js.map +7 -0
- package/dist/modules/terminal_tracking/api/vessel-visits/route.js +83 -0
- package/dist/modules/terminal_tracking/api/vessel-visits/route.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.js +669 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js +19 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.js +275 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js +19 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js.map +7 -0
- package/dist/modules/terminal_tracking/ce.js +35 -0
- package/dist/modules/terminal_tracking/ce.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/index.js +4 -0
- package/dist/modules/terminal_tracking/commands/index.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/terminal-configs.js +107 -0
- package/dist/modules/terminal_tracking/commands/terminal-configs.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/terminal-tracking.js +27 -0
- package/dist/modules/terminal_tracking/commands/terminal-tracking.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/tracking-jobs.js +56 -0
- package/dist/modules/terminal_tracking/commands/tracking-jobs.js.map +7 -0
- package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js +371 -0
- package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js.map +7 -0
- package/dist/modules/terminal_tracking/data/entities.js +337 -0
- package/dist/modules/terminal_tracking/data/entities.js.map +7 -0
- package/dist/modules/terminal_tracking/data/validators.js +98 -0
- package/dist/modules/terminal_tracking/data/validators.js.map +7 -0
- package/dist/modules/terminal_tracking/di.js +39 -0
- package/dist/modules/terminal_tracking/di.js.map +7 -0
- package/dist/modules/terminal_tracking/encryption.js +14 -0
- package/dist/modules/terminal_tracking/encryption.js.map +7 -0
- package/dist/modules/terminal_tracking/events.js +45 -0
- package/dist/modules/terminal_tracking/events.js.map +7 -0
- package/dist/modules/terminal_tracking/i18n/de.json +175 -0
- package/dist/modules/terminal_tracking/i18n/en.json +178 -0
- package/dist/modules/terminal_tracking/i18n/es.json +175 -0
- package/dist/modules/terminal_tracking/i18n/pl.json +206 -0
- package/dist/modules/terminal_tracking/index.js +15 -0
- package/dist/modules/terminal_tracking/index.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js +164 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js +23 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js +146 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/types.js +1 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/types.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js +106 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js +120 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js +77 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/types.js +1 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/types.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/index.js +12 -0
- package/dist/modules/terminal_tracking/lib/adapters/index.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js +121 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js +81 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/http.js +66 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/http.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js +95 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js +70 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js +44 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js +15 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/types.js +1 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/types.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js +27 -0
- package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/availability.js +40 -0
- package/dist/modules/terminal_tracking/lib/availability.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/ensure-schedule.js +51 -0
- package/dist/modules/terminal_tracking/lib/ensure-schedule.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/holds.js +240 -0
- package/dist/modules/terminal_tracking/lib/holds.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/logger.js +19 -0
- package/dist/modules/terminal_tracking/lib/logger.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/rate-limiter.js +30 -0
- package/dist/modules/terminal_tracking/lib/rate-limiter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/stops.js +48 -0
- package/dist/modules/terminal_tracking/lib/stops.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/terminal-adapter.js +1 -0
- package/dist/modules/terminal_tracking/lib/terminal-adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/totp.js +41 -0
- package/dist/modules/terminal_tracking/lib/totp.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js +101 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js +39 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js +13 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js +15 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js +13 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js.map +7 -0
- package/dist/modules/terminal_tracking/services/terminalMatcherService.js +61 -0
- package/dist/modules/terminal_tracking/services/terminalMatcherService.js.map +7 -0
- package/dist/modules/terminal_tracking/services/terminalRegistry.js +24 -0
- package/dist/modules/terminal_tracking/services/terminalRegistry.js.map +7 -0
- package/dist/modules/terminal_tracking/services/terminalTrackingService.js +687 -0
- package/dist/modules/terminal_tracking/services/terminalTrackingService.js.map +7 -0
- package/dist/modules/terminal_tracking/setup.js +23 -0
- package/dist/modules/terminal_tracking/setup.js.map +7 -0
- package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js +22 -0
- package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js.map +7 -0
- package/generated/entities/terminal_config/index.ts +36 -0
- package/generated/entities/terminal_event/index.ts +39 -0
- package/generated/entities/terminal_tracking_job/index.ts +28 -0
- package/generated/entities/terminal_vessel_visit/index.ts +30 -0
- package/generated/entities.ids.generated.ts +52 -0
- package/package.json +80 -0
- package/src/index.ts +44 -0
- package/src/modules/terminal_tracking/__integration__/TC-TRACK-301-gct-config-test-connection.spec.ts +66 -0
- package/src/modules/terminal_tracking/__integration__/TC-TRACK-317-bct-config-test-connection.spec.ts +68 -0
- package/src/modules/terminal_tracking/__integration__/helpers.ts +148 -0
- package/src/modules/terminal_tracking/__integration__/meta.ts +4 -0
- package/src/modules/terminal_tracking/acl.ts +10 -0
- package/src/modules/terminal_tracking/api/openapi.ts +28 -0
- package/src/modules/terminal_tracking/api/terminal-configs/route.ts +140 -0
- package/src/modules/terminal_tracking/api/terminal-configs/test/route.ts +64 -0
- package/src/modules/terminal_tracking/api/tracking-jobs/details/route.ts +116 -0
- package/src/modules/terminal_tracking/api/tracking-jobs/poll/route.ts +64 -0
- package/src/modules/terminal_tracking/api/tracking-jobs/route.ts +243 -0
- package/src/modules/terminal_tracking/api/utils.ts +10 -0
- package/src/modules/terminal_tracking/api/vessel-visits/route.ts +95 -0
- package/src/modules/terminal_tracking/backend/terminal-configs/page.meta.ts +16 -0
- package/src/modules/terminal_tracking/backend/terminal-configs/page.tsx +728 -0
- package/src/modules/terminal_tracking/backend/terminal-containers/page.meta.ts +16 -0
- package/src/modules/terminal_tracking/backend/terminal-containers/page.tsx +328 -0
- package/src/modules/terminal_tracking/ce.ts +31 -0
- package/src/modules/terminal_tracking/commands/index.ts +3 -0
- package/src/modules/terminal_tracking/commands/terminal-configs.ts +132 -0
- package/src/modules/terminal_tracking/commands/terminal-tracking.ts +34 -0
- package/src/modules/terminal_tracking/commands/tracking-jobs.ts +69 -0
- package/src/modules/terminal_tracking/components/ContainerDetailsDrawer.tsx +540 -0
- package/src/modules/terminal_tracking/data/entities.ts +386 -0
- package/src/modules/terminal_tracking/data/validators.ts +113 -0
- package/src/modules/terminal_tracking/di.ts +46 -0
- package/src/modules/terminal_tracking/encryption.ts +12 -0
- package/src/modules/terminal_tracking/events.ts +48 -0
- package/src/modules/terminal_tracking/i18n/de.json +175 -0
- package/src/modules/terminal_tracking/i18n/en.json +178 -0
- package/src/modules/terminal_tracking/i18n/es.json +175 -0
- package/src/modules/terminal_tracking/i18n/pl.json +206 -0
- package/src/modules/terminal_tracking/index.ts +13 -0
- package/src/modules/terminal_tracking/lib/__tests__/availability.test.ts +136 -0
- package/src/modules/terminal_tracking/lib/__tests__/ensure-schedule.test.ts +67 -0
- package/src/modules/terminal_tracking/lib/__tests__/holds.test.ts +73 -0
- package/src/modules/terminal_tracking/lib/__tests__/stops.test.ts +80 -0
- package/src/modules/terminal_tracking/lib/__tests__/totp.test.ts +38 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/adapter.test.ts +196 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/incos-semantics.test.ts +303 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/adapter.ts +219 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/auth/basic.ts +34 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/incos-semantics.ts +291 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/types.ts +113 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/adapter.test.ts +238 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/gct-semantics.test.ts +133 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/adapter.ts +152 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/auth/token.ts +219 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/gct-semantics.ts +136 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/types.ts +58 -0
- package/src/modules/terminal_tracking/lib/adapters/index.ts +16 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/adapter-proxy.test.ts +69 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/data-table.test.ts +179 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/http.test.ts +98 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/n4-semantics.test.ts +140 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/proxy.test.ts +19 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/vessel-data-table.test.ts +67 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/adapter.ts +163 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/auth/ropc.ts +104 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/http.ts +127 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/n4-semantics.ts +156 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.ts +102 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.ts +60 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/proxy.ts +24 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/types.ts +56 -0
- package/src/modules/terminal_tracking/lib/adapters/zoned-time.ts +65 -0
- package/src/modules/terminal_tracking/lib/availability.ts +119 -0
- package/src/modules/terminal_tracking/lib/ensure-schedule.ts +83 -0
- package/src/modules/terminal_tracking/lib/holds.ts +344 -0
- package/src/modules/terminal_tracking/lib/logger.ts +21 -0
- package/src/modules/terminal_tracking/lib/rate-limiter.ts +52 -0
- package/src/modules/terminal_tracking/lib/stops.ts +124 -0
- package/src/modules/terminal_tracking/lib/terminal-adapter.ts +136 -0
- package/src/modules/terminal_tracking/lib/totp.ts +63 -0
- package/src/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.ts +104 -0
- package/src/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.ts +40 -0
- package/src/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.ts +11 -0
- package/src/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.ts +13 -0
- package/src/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.ts +16 -0
- package/src/modules/terminal_tracking/services/__tests__/availabilityEmit.test.ts +277 -0
- package/src/modules/terminal_tracking/services/__tests__/createJob.test.ts +59 -0
- package/src/modules/terminal_tracking/services/__tests__/pollAllBatch.test.ts +147 -0
- package/src/modules/terminal_tracking/services/__tests__/terminalMatcher.test.ts +70 -0
- package/src/modules/terminal_tracking/services/__tests__/vesselResolution.test.ts +453 -0
- package/src/modules/terminal_tracking/services/terminalMatcherService.ts +97 -0
- package/src/modules/terminal_tracking/services/terminalRegistry.ts +30 -0
- package/src/modules/terminal_tracking/services/terminalTrackingService.ts +912 -0
- package/src/modules/terminal_tracking/setup.ts +24 -0
- package/src/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.ts +32 -0
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { EventBus } from '@open-mercato/events'
|
|
3
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
+
import { TerminalConfig, TerminalTrackingJob, TerminalEvent, TerminalVesselVisit } from '../data/entities'
|
|
5
|
+
import type { ResolvedTerminalConfig, TerminalFetchedEvent, TerminalAdapter } from '../lib/terminal-adapter'
|
|
6
|
+
import type { TerminalRegistry } from './terminalRegistry'
|
|
7
|
+
import { checkRateLimit, type CacheService } from '../lib/rate-limiter'
|
|
8
|
+
import { semanticEventIdFor, pickVesselVisit, parseCargoCategory } from '../lib/adapters/n4/n4-semantics'
|
|
9
|
+
import { impedimentsChanged, isEmptyReady, isImportHoldsCleared } from '../lib/availability'
|
|
10
|
+
import { parseTerminalStops, stopsChanged, type TerminalStops } from '../lib/stops'
|
|
11
|
+
import { terminalLogger } from '../lib/logger'
|
|
12
|
+
|
|
13
|
+
/** Default TTL for a cached /VESSEL visit lookup. Vessel ETA/ATA move on the
|
|
14
|
+
* scale of hours and the shared rate bucket is mostly spent on /unit polls, so
|
|
15
|
+
* one hour balances freshness against token spend. */
|
|
16
|
+
const DEFAULT_VESSEL_CACHE_TTL_SECONDS = 3600
|
|
17
|
+
|
|
18
|
+
/** Max containers per /unit request. N4 accepts a comma-separated UNIT_NBR list
|
|
19
|
+
* (the terminal supports up to ~500); we cap lower to bound URL/response size
|
|
20
|
+
* and keep each batch to a single rate-limit token. */
|
|
21
|
+
const MAX_CONTAINERS_PER_UNIT_REQUEST = 100
|
|
22
|
+
|
|
23
|
+
function chunk<T>(items: T[], size: number): T[][] {
|
|
24
|
+
const out: T[][] = []
|
|
25
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A container's terminal lifecycle is complete when this poll returned events
|
|
30
|
+
* and every current leg has departed (`DEPA`). Empty results are NOT departed
|
|
31
|
+
* (container not found / unmapped states) — keep polling. */
|
|
32
|
+
function isFullyDeparted(events: TerminalFetchedEvent[]): boolean {
|
|
33
|
+
return events.length > 0 && events.every((e) => e.eventCode === 'DEPA')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Deps = {
|
|
37
|
+
em: () => EntityManager
|
|
38
|
+
eventBus: EventBus
|
|
39
|
+
terminalRegistry: TerminalRegistry
|
|
40
|
+
cacheService: CacheService
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MAX_ERROR_HISTORY = 20
|
|
44
|
+
const MAX_RETRIES = 10
|
|
45
|
+
|
|
46
|
+
export type CreateJobInput = {
|
|
47
|
+
organizationId: string
|
|
48
|
+
tenantId: string
|
|
49
|
+
terminalCode: string
|
|
50
|
+
containerNumber: string
|
|
51
|
+
schedule?: string[] | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type PollJobResult = { newEvents: number; updatedEvents: number }
|
|
55
|
+
export type PollAllResult = { polled: number; newEvents: number; updatedEvents: number; failed: number }
|
|
56
|
+
|
|
57
|
+
type EventLike = Pick<
|
|
58
|
+
TerminalEvent,
|
|
59
|
+
| 'eventClassifierCode'
|
|
60
|
+
| 'eventDateTime'
|
|
61
|
+
| 'transitState'
|
|
62
|
+
| 'visitState'
|
|
63
|
+
| 'facilityCode'
|
|
64
|
+
| 'facilityCodeListProvider'
|
|
65
|
+
| 'unlocode'
|
|
66
|
+
| 'visitRefIn'
|
|
67
|
+
| 'visitRefOut'
|
|
68
|
+
| 'vesselName'
|
|
69
|
+
| 'voyageNumber'
|
|
70
|
+
| 'modeOfTransport'
|
|
71
|
+
| 'seals'
|
|
72
|
+
| 'vgmWeightKg'
|
|
73
|
+
| 'impediments'
|
|
74
|
+
| 'loadedAt'
|
|
75
|
+
>
|
|
76
|
+
|
|
77
|
+
/** Stable signature of an event's mutable container fields, to detect changes
|
|
78
|
+
* between polls. Excludes the identity (key) fields, which never change. */
|
|
79
|
+
function eventSignature(e: EventLike): string {
|
|
80
|
+
return JSON.stringify({
|
|
81
|
+
cls: e.eventClassifierCode ?? null,
|
|
82
|
+
dt: e.eventDateTime ? new Date(e.eventDateTime).toISOString() : null,
|
|
83
|
+
transitState: e.transitState ?? null,
|
|
84
|
+
visitState: e.visitState ?? null,
|
|
85
|
+
facilityCode: e.facilityCode ?? null,
|
|
86
|
+
facilityCodeListProvider: e.facilityCodeListProvider ?? null,
|
|
87
|
+
unlocode: e.unlocode ?? null,
|
|
88
|
+
visitRefIn: e.visitRefIn ?? null,
|
|
89
|
+
visitRefOut: e.visitRefOut ?? null,
|
|
90
|
+
vesselName: e.vesselName ?? null,
|
|
91
|
+
voyageNumber: e.voyageNumber ?? null,
|
|
92
|
+
modeOfTransport: e.modeOfTransport ?? null,
|
|
93
|
+
seals: e.seals ?? null,
|
|
94
|
+
vgmWeightKg: e.vgmWeightKg ?? null,
|
|
95
|
+
impediments: e.impediments ?? null,
|
|
96
|
+
loadedAt: e.loadedAt ? new Date(e.loadedAt).toISOString() : null,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type VesselLike = {
|
|
101
|
+
vesselName?: string | null
|
|
102
|
+
ibVoyage?: string | null
|
|
103
|
+
obVoyage?: string | null
|
|
104
|
+
line?: string | null
|
|
105
|
+
phase?: string | null
|
|
106
|
+
eta?: Date | null
|
|
107
|
+
etd?: Date | null
|
|
108
|
+
ata?: Date | null
|
|
109
|
+
atd?: Date | null
|
|
110
|
+
beginReceive?: Date | null
|
|
111
|
+
dryCutoff?: Date | null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Stable signature of a vessel visit's meaningful fields (ETA/ATA/etc.), to
|
|
115
|
+
* detect when /VESSEL data changes between polls. */
|
|
116
|
+
function vesselSignature(v: VesselLike): string {
|
|
117
|
+
const d = (x?: Date | null) => (x ? x.toISOString() : null)
|
|
118
|
+
return JSON.stringify({
|
|
119
|
+
vesselName: v.vesselName ?? null,
|
|
120
|
+
ibVoyage: v.ibVoyage ?? null,
|
|
121
|
+
obVoyage: v.obVoyage ?? null,
|
|
122
|
+
line: v.line ?? null,
|
|
123
|
+
phase: v.phase ?? null,
|
|
124
|
+
eta: d(v.eta),
|
|
125
|
+
etd: d(v.etd),
|
|
126
|
+
ata: d(v.ata),
|
|
127
|
+
atd: d(v.atd),
|
|
128
|
+
beginReceive: d(v.beginReceive),
|
|
129
|
+
dryCutoff: d(v.dryCutoff),
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class TerminalTrackingService {
|
|
134
|
+
constructor(private readonly deps: Deps) {}
|
|
135
|
+
|
|
136
|
+
private toResolvedConfig(config: TerminalConfig): ResolvedTerminalConfig {
|
|
137
|
+
return {
|
|
138
|
+
terminalCode: config.terminalCode,
|
|
139
|
+
adapterType: config.adapterType,
|
|
140
|
+
displayName: config.displayName,
|
|
141
|
+
baseUrl: config.baseUrl,
|
|
142
|
+
proxyUrl: config.proxyUrl ?? null,
|
|
143
|
+
endpoints: config.endpoints,
|
|
144
|
+
authType: config.authType,
|
|
145
|
+
tokenUrl: config.tokenUrl ?? null,
|
|
146
|
+
scope: config.scope ?? null,
|
|
147
|
+
clientId: config.clientId ?? null,
|
|
148
|
+
authConfig: config.authConfig ?? null,
|
|
149
|
+
rateLimitRequests: config.rateLimitRequests,
|
|
150
|
+
rateLimitWindowSeconds: config.rateLimitWindowSeconds,
|
|
151
|
+
unlocode: config.unlocode ?? null,
|
|
152
|
+
facilityCode: (config.smdgCodes ?? [])[0] ?? (config.bicCodes ?? [])[0] ?? null,
|
|
153
|
+
facilityCodeListProvider: (config.smdgCodes ?? []).length
|
|
154
|
+
? 'SMDG'
|
|
155
|
+
: (config.bicCodes ?? []).length
|
|
156
|
+
? 'BIC'
|
|
157
|
+
: null,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async testTerminalConfig(input: {
|
|
162
|
+
id: string
|
|
163
|
+
organizationId: string
|
|
164
|
+
tenantId: string
|
|
165
|
+
}): Promise<{ success: boolean; message: string; latencyMs?: number }> {
|
|
166
|
+
const em = this.deps.em().fork()
|
|
167
|
+
const config = await findOneWithDecryption(em, TerminalConfig, {
|
|
168
|
+
id: input.id,
|
|
169
|
+
organizationId: input.organizationId,
|
|
170
|
+
tenantId: input.tenantId,
|
|
171
|
+
deletedAt: null,
|
|
172
|
+
})
|
|
173
|
+
if (!config) return { success: false, message: 'Terminal config not found' }
|
|
174
|
+
|
|
175
|
+
const adapter = this.deps.terminalRegistry.get(config.adapterType)
|
|
176
|
+
if (!adapter) return { success: false, message: `No adapter registered for type '${config.adapterType}'` }
|
|
177
|
+
|
|
178
|
+
return adapter.testConnection(this.toResolvedConfig(config))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async createJob(input: CreateJobInput): Promise<{ trackingJobId: string; newEvents: number }> {
|
|
182
|
+
const em = this.deps.em().fork()
|
|
183
|
+
|
|
184
|
+
const existing = await em.findOne(TerminalTrackingJob, {
|
|
185
|
+
organizationId: input.organizationId,
|
|
186
|
+
tenantId: input.tenantId,
|
|
187
|
+
terminalCode: input.terminalCode,
|
|
188
|
+
containerNumber: input.containerNumber,
|
|
189
|
+
// Reuse only a still-open job (avoid duplicate in-flight tracking). A
|
|
190
|
+
// 'completed' job is NOT reused: a container that returns later gets a
|
|
191
|
+
// fresh tracking job with clean history.
|
|
192
|
+
status: { $in: ['active', 'paused'] },
|
|
193
|
+
deletedAt: null,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
if (existing) {
|
|
197
|
+
// Re-poll the existing job so the caller gets fresh data.
|
|
198
|
+
const result = await this.pollJob(existing.id)
|
|
199
|
+
return { trackingJobId: existing.id, newEvents: result.newEvents }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const job = em.create(TerminalTrackingJob, {
|
|
203
|
+
organizationId: input.organizationId,
|
|
204
|
+
tenantId: input.tenantId,
|
|
205
|
+
terminalCode: input.terminalCode,
|
|
206
|
+
containerNumber: input.containerNumber,
|
|
207
|
+
status: 'active',
|
|
208
|
+
schedule: input.schedule ?? null,
|
|
209
|
+
nextPollAt: null,
|
|
210
|
+
})
|
|
211
|
+
await em.flush()
|
|
212
|
+
|
|
213
|
+
// The initial-poll subscriber performs the first poll on this event.
|
|
214
|
+
await this.deps.eventBus.emit('terminal_tracking.tracking_job.created', {
|
|
215
|
+
id: job.id,
|
|
216
|
+
terminalCode: job.terminalCode,
|
|
217
|
+
containerNumber: job.containerNumber,
|
|
218
|
+
tenantId: job.tenantId,
|
|
219
|
+
organizationId: job.organizationId,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
return { trackingJobId: job.id, newEvents: 0 }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async pollJob(jobId: string): Promise<PollJobResult> {
|
|
226
|
+
const em = this.deps.em().fork()
|
|
227
|
+
const job = await em.findOne(TerminalTrackingJob, { id: jobId, deletedAt: null })
|
|
228
|
+
if (!job) throw new Error('Terminal tracking job not found')
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const config = await findOneWithDecryption(em, TerminalConfig, {
|
|
232
|
+
organizationId: job.organizationId,
|
|
233
|
+
tenantId: job.tenantId,
|
|
234
|
+
terminalCode: job.terminalCode,
|
|
235
|
+
isActive: true,
|
|
236
|
+
deletedAt: null,
|
|
237
|
+
})
|
|
238
|
+
if (!config) throw new Error(`No active terminal config for '${job.terminalCode}'`)
|
|
239
|
+
|
|
240
|
+
const limit = await checkRateLimit(
|
|
241
|
+
this.deps.cacheService,
|
|
242
|
+
job.tenantId,
|
|
243
|
+
job.terminalCode,
|
|
244
|
+
config.rateLimitRequests,
|
|
245
|
+
config.rateLimitWindowSeconds,
|
|
246
|
+
)
|
|
247
|
+
if (!limit.allowed) {
|
|
248
|
+
terminalLogger.debug('Rate limited, skipping poll', { jobId, terminalCode: job.terminalCode })
|
|
249
|
+
return { newEvents: 0, updatedEvents: 0 }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const adapter = this.deps.terminalRegistry.get(config.adapterType)
|
|
253
|
+
if (!adapter) throw new Error(`No adapter registered for type '${config.adapterType}'`)
|
|
254
|
+
|
|
255
|
+
const resolved = this.toResolvedConfig(config)
|
|
256
|
+
const result = await adapter.fetchEvents({ containerNumber: job.containerNumber, config: resolved })
|
|
257
|
+
|
|
258
|
+
// Enrich events with vessel ETA/ATA from /VESSEL (cache-backed, rate-limited).
|
|
259
|
+
const changedVesselRefs = await this.enrichWithVesselVisits(
|
|
260
|
+
em,
|
|
261
|
+
{ organizationId: job.organizationId, tenantId: job.tenantId, terminalCode: job.terminalCode },
|
|
262
|
+
config,
|
|
263
|
+
resolved,
|
|
264
|
+
adapter,
|
|
265
|
+
result.events,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
const { created, updated, currentByKey, priorImpedimentsByKey, priorStopsByKey } = await this.persistEvents(
|
|
269
|
+
em,
|
|
270
|
+
job,
|
|
271
|
+
result.events,
|
|
272
|
+
changedVesselRefs,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
job.lastPollAt = new Date()
|
|
276
|
+
job.nextPollAt = this.computeNextPoll(job.schedule)
|
|
277
|
+
job.retryCount = 0
|
|
278
|
+
// Every current leg departed → terminal lifecycle complete; drop from the
|
|
279
|
+
// due-set. A re-appearing leg reverts to active on the next manual poll.
|
|
280
|
+
job.status = isFullyDeparted(result.events) ? 'completed' : 'active'
|
|
281
|
+
await em.flush()
|
|
282
|
+
|
|
283
|
+
const vesselByEventId = new Map<string, TerminalFetchedEvent['vesselVisit']>(
|
|
284
|
+
result.events.map((e) => [e.sourceEventId, e.vesselVisit ?? null]),
|
|
285
|
+
)
|
|
286
|
+
for (const event of created) {
|
|
287
|
+
await this.emitCreated(event, vesselByEventId.get(event.sourceEventId) ?? null)
|
|
288
|
+
}
|
|
289
|
+
for (const event of updated) {
|
|
290
|
+
await this.emitUpdated(event, vesselByEventId.get(event.sourceEventId) ?? null)
|
|
291
|
+
}
|
|
292
|
+
await this.evaluateAndEmitAvailability(em, job, currentByKey, priorImpedimentsByKey, priorStopsByKey, vesselByEventId)
|
|
293
|
+
|
|
294
|
+
return { newEvents: created.length, updatedEvents: updated.length }
|
|
295
|
+
} catch (err) {
|
|
296
|
+
await this.recordJobError(em, job, err)
|
|
297
|
+
return { newEvents: 0, updatedEvents: 0 }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async pollAllActiveJobs(tenantId: string, organizationId?: string): Promise<PollAllResult> {
|
|
302
|
+
const em = this.deps.em().fork()
|
|
303
|
+
const now = new Date()
|
|
304
|
+
const jobs = await em.find(TerminalTrackingJob, {
|
|
305
|
+
tenantId,
|
|
306
|
+
...(organizationId ? { organizationId } : {}),
|
|
307
|
+
status: 'active',
|
|
308
|
+
deletedAt: null,
|
|
309
|
+
$or: [{ nextPollAt: null }, { nextPollAt: { $lte: now } }],
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// Group due jobs per terminal config (org + terminalCode). Each group shares
|
|
313
|
+
// one /unit request budget and one vessel-visit cache, so we poll it in
|
|
314
|
+
// batches rather than one container at a time.
|
|
315
|
+
const groups = new Map<string, TerminalTrackingJob[]>()
|
|
316
|
+
for (const job of jobs) {
|
|
317
|
+
const key = `${job.organizationId}::${job.terminalCode}`
|
|
318
|
+
const arr = groups.get(key)
|
|
319
|
+
if (arr) arr.push(job)
|
|
320
|
+
else groups.set(key, [job])
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let newEvents = 0
|
|
324
|
+
let updatedEvents = 0
|
|
325
|
+
let failed = 0
|
|
326
|
+
for (const group of groups.values()) {
|
|
327
|
+
try {
|
|
328
|
+
const r = await this.pollTerminalGroup(em, group)
|
|
329
|
+
newEvents += r.newEvents
|
|
330
|
+
updatedEvents += r.updatedEvents
|
|
331
|
+
failed += r.failed
|
|
332
|
+
} catch {
|
|
333
|
+
failed += group.length
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return { polled: jobs.length, newEvents, updatedEvents, failed }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Poll one terminal group (same org + terminalCode) in batches. Each batch of
|
|
341
|
+
* up to MAX_CONTAINERS_PER_UNIT_REQUEST containers is a single /unit call, and
|
|
342
|
+
* vessel visits are resolved once across the whole batch (deduped + cached),
|
|
343
|
+
* minimising API requests. Adapters without batch support fall back to the
|
|
344
|
+
* per-container path.
|
|
345
|
+
*/
|
|
346
|
+
private async pollTerminalGroup(
|
|
347
|
+
em: EntityManager,
|
|
348
|
+
jobs: TerminalTrackingJob[],
|
|
349
|
+
): Promise<{ newEvents: number; updatedEvents: number; failed: number }> {
|
|
350
|
+
const first = jobs[0]
|
|
351
|
+
const scope = {
|
|
352
|
+
organizationId: first.organizationId,
|
|
353
|
+
tenantId: first.tenantId,
|
|
354
|
+
terminalCode: first.terminalCode,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let config: TerminalConfig | null = null
|
|
358
|
+
try {
|
|
359
|
+
config = await findOneWithDecryption(em, TerminalConfig, {
|
|
360
|
+
organizationId: scope.organizationId,
|
|
361
|
+
tenantId: scope.tenantId,
|
|
362
|
+
terminalCode: scope.terminalCode,
|
|
363
|
+
isActive: true,
|
|
364
|
+
deletedAt: null,
|
|
365
|
+
})
|
|
366
|
+
} catch {
|
|
367
|
+
config = null
|
|
368
|
+
}
|
|
369
|
+
const adapter = config ? this.deps.terminalRegistry.get(config.adapterType) : undefined
|
|
370
|
+
if (!config || !adapter) {
|
|
371
|
+
const err = new Error(`No active terminal config/adapter for '${scope.terminalCode}'`)
|
|
372
|
+
for (const job of jobs) await this.recordJobError(em, job, err)
|
|
373
|
+
return { newEvents: 0, updatedEvents: 0, failed: jobs.length }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Adapters lacking batch support: fall back to the per-container poll path.
|
|
377
|
+
if (typeof adapter.fetchEventsBatch !== 'function') {
|
|
378
|
+
let newEvents = 0
|
|
379
|
+
let updatedEvents = 0
|
|
380
|
+
let failed = 0
|
|
381
|
+
for (const job of jobs) {
|
|
382
|
+
try {
|
|
383
|
+
const r = await this.pollJob(job.id)
|
|
384
|
+
newEvents += r.newEvents
|
|
385
|
+
updatedEvents += r.updatedEvents
|
|
386
|
+
} catch {
|
|
387
|
+
failed += 1
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { newEvents, updatedEvents, failed }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const resolved = this.toResolvedConfig(config)
|
|
394
|
+
let newEvents = 0
|
|
395
|
+
let updatedEvents = 0
|
|
396
|
+
let failed = 0
|
|
397
|
+
|
|
398
|
+
for (const batch of chunk(jobs, MAX_CONTAINERS_PER_UNIT_REQUEST)) {
|
|
399
|
+
const limit = await checkRateLimit(
|
|
400
|
+
this.deps.cacheService,
|
|
401
|
+
scope.tenantId,
|
|
402
|
+
scope.terminalCode,
|
|
403
|
+
config.rateLimitRequests,
|
|
404
|
+
config.rateLimitWindowSeconds,
|
|
405
|
+
)
|
|
406
|
+
if (!limit.allowed) {
|
|
407
|
+
terminalLogger.debug('Rate limited, skipping unit batch', {
|
|
408
|
+
terminalCode: scope.terminalCode,
|
|
409
|
+
batch: batch.length,
|
|
410
|
+
})
|
|
411
|
+
continue // jobs stay due; retried next cycle
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const containerNumbers = batch.map((j) => j.containerNumber)
|
|
416
|
+
const { events } = await adapter.fetchEventsBatch!({ containerNumbers, config: resolved })
|
|
417
|
+
|
|
418
|
+
// Resolve vessel visits once across the whole batch (deduped + cached).
|
|
419
|
+
const changedVesselRefs = await this.enrichWithVesselVisits(em, scope, config, resolved, adapter, events)
|
|
420
|
+
|
|
421
|
+
const byContainer = new Map<string, TerminalFetchedEvent[]>()
|
|
422
|
+
for (const e of events) {
|
|
423
|
+
const arr = byContainer.get(e.containerNumber)
|
|
424
|
+
if (arr) arr.push(e)
|
|
425
|
+
else byContainer.set(e.containerNumber, [e])
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
for (const job of batch) {
|
|
429
|
+
const jobEvents = byContainer.get(job.containerNumber) ?? []
|
|
430
|
+
const { created, updated, currentByKey, priorImpedimentsByKey, priorStopsByKey } = await this.persistEvents(
|
|
431
|
+
em,
|
|
432
|
+
job,
|
|
433
|
+
jobEvents,
|
|
434
|
+
changedVesselRefs,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
job.lastPollAt = new Date()
|
|
438
|
+
job.nextPollAt = this.computeNextPoll(job.schedule)
|
|
439
|
+
job.retryCount = 0
|
|
440
|
+
job.status = isFullyDeparted(jobEvents) ? 'completed' : 'active'
|
|
441
|
+
await em.flush()
|
|
442
|
+
|
|
443
|
+
const vesselByEventId = new Map<string, TerminalFetchedEvent['vesselVisit']>(
|
|
444
|
+
jobEvents.map((e) => [e.sourceEventId, e.vesselVisit ?? null]),
|
|
445
|
+
)
|
|
446
|
+
for (const ev of created) {
|
|
447
|
+
await this.emitCreated(ev, vesselByEventId.get(ev.sourceEventId) ?? null)
|
|
448
|
+
newEvents += 1
|
|
449
|
+
}
|
|
450
|
+
for (const ev of updated) {
|
|
451
|
+
await this.emitUpdated(ev, vesselByEventId.get(ev.sourceEventId) ?? null)
|
|
452
|
+
updatedEvents += 1
|
|
453
|
+
}
|
|
454
|
+
await this.evaluateAndEmitAvailability(em, job, currentByKey, priorImpedimentsByKey, priorStopsByKey, vesselByEventId)
|
|
455
|
+
}
|
|
456
|
+
} catch (err) {
|
|
457
|
+
for (const job of batch) await this.recordJobError(em, job, err)
|
|
458
|
+
failed += batch.length
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return { newEvents, updatedEvents, failed }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Resolve the vessel visit for each fetched event/leg and attach ETA/ETD/
|
|
467
|
+
* ATA/ATD + phase. The `TerminalVesselVisit` table is the cache: a row fresh
|
|
468
|
+
* within the TTL is reused without an API call, so the many containers that
|
|
469
|
+
* share one vessel visit cost a single /VESSEL fetch per cycle. Each fetch is
|
|
470
|
+
* rate-limit-gated against the same bucket as /unit; when the budget is spent,
|
|
471
|
+
* vessel enrichment is shed (the event still emits, sans vessel data) and
|
|
472
|
+
* filled on the next cycle.
|
|
473
|
+
*/
|
|
474
|
+
private async enrichWithVesselVisits(
|
|
475
|
+
em: EntityManager,
|
|
476
|
+
scope: { organizationId: string; tenantId: string; terminalCode: string },
|
|
477
|
+
config: TerminalConfig,
|
|
478
|
+
resolved: ResolvedTerminalConfig,
|
|
479
|
+
adapter: TerminalAdapter,
|
|
480
|
+
events: TerminalFetchedEvent[],
|
|
481
|
+
): Promise<Set<string>> {
|
|
482
|
+
// Visit refs whose /VESSEL data changed (or first resolved) this poll.
|
|
483
|
+
const changedRefs = new Set<string>()
|
|
484
|
+
if (typeof adapter.fetchVesselVisit !== 'function') return changedRefs
|
|
485
|
+
if (!resolved.endpoints.vessel) return changedRefs
|
|
486
|
+
|
|
487
|
+
const picks = events.map((event) => ({ event, pick: pickVesselVisit(event) }))
|
|
488
|
+
// All picked refs (for attaching cached data); only non-departed legs are
|
|
489
|
+
// fetched — a DEPA leg's visit has sailed and /VESSEL returns empty.
|
|
490
|
+
const allRefs = [...new Set(picks.map((p) => p.pick?.ref).filter((r): r is string => !!r))]
|
|
491
|
+
const fetchRefs = [
|
|
492
|
+
...new Set(
|
|
493
|
+
picks
|
|
494
|
+
.filter((p) => p.pick && p.event.eventCode !== 'DEPA')
|
|
495
|
+
.map((p) => p.pick!.ref),
|
|
496
|
+
),
|
|
497
|
+
]
|
|
498
|
+
if (allRefs.length === 0) return changedRefs
|
|
499
|
+
|
|
500
|
+
const ttlMs = (config.vesselCacheTtlSeconds ?? DEFAULT_VESSEL_CACHE_TTL_SECONDS) * 1000
|
|
501
|
+
const now = Date.now()
|
|
502
|
+
|
|
503
|
+
const existing = await em.find(TerminalVesselVisit, {
|
|
504
|
+
organizationId: scope.organizationId,
|
|
505
|
+
tenantId: scope.tenantId,
|
|
506
|
+
terminalCode: scope.terminalCode,
|
|
507
|
+
visitRef: { $in: allRefs },
|
|
508
|
+
})
|
|
509
|
+
const byRef = new Map<string, TerminalVesselVisit>(existing.map((r) => [r.visitRef, r]))
|
|
510
|
+
|
|
511
|
+
for (const ref of fetchRefs) {
|
|
512
|
+
const cached = byRef.get(ref)
|
|
513
|
+
if (cached && now - cached.updatedAt.getTime() < ttlMs) continue // fresh cache hit
|
|
514
|
+
|
|
515
|
+
const limit = await checkRateLimit(
|
|
516
|
+
this.deps.cacheService,
|
|
517
|
+
scope.tenantId,
|
|
518
|
+
scope.terminalCode,
|
|
519
|
+
config.rateLimitRequests,
|
|
520
|
+
config.rateLimitWindowSeconds,
|
|
521
|
+
)
|
|
522
|
+
if (!limit.allowed) {
|
|
523
|
+
terminalLogger.debug('Rate limited, skipping vessel fetch', { terminalCode: scope.terminalCode, visitRef: ref })
|
|
524
|
+
continue
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
const visit = await adapter.fetchVesselVisit!({ visitRef: ref, config: resolved })
|
|
529
|
+
if (!visit) continue
|
|
530
|
+
// A first-time resolution, or any field difference, counts as a change.
|
|
531
|
+
if (!cached || vesselSignature(cached) !== vesselSignature(visit)) changedRefs.add(ref)
|
|
532
|
+
const row =
|
|
533
|
+
cached ??
|
|
534
|
+
em.create(TerminalVesselVisit, {
|
|
535
|
+
organizationId: scope.organizationId,
|
|
536
|
+
tenantId: scope.tenantId,
|
|
537
|
+
terminalCode: scope.terminalCode,
|
|
538
|
+
visitRef: ref,
|
|
539
|
+
})
|
|
540
|
+
row.vesselName = visit.vesselName
|
|
541
|
+
row.ibVoyage = visit.ibVoyage
|
|
542
|
+
row.obVoyage = visit.obVoyage
|
|
543
|
+
row.line = visit.line
|
|
544
|
+
row.phase = visit.phase
|
|
545
|
+
row.eta = visit.eta
|
|
546
|
+
row.etd = visit.etd
|
|
547
|
+
row.ata = visit.ata
|
|
548
|
+
row.atd = visit.atd
|
|
549
|
+
row.beginReceive = visit.beginReceive
|
|
550
|
+
row.dryCutoff = visit.dryCutoff
|
|
551
|
+
row.rawData = visit.rawData
|
|
552
|
+
row.updatedAt = new Date()
|
|
553
|
+
byRef.set(ref, row)
|
|
554
|
+
} catch (err) {
|
|
555
|
+
terminalLogger.debug('Vessel fetch failed', {
|
|
556
|
+
terminalCode: scope.terminalCode,
|
|
557
|
+
visitRef: ref,
|
|
558
|
+
error: err instanceof Error ? err.message : String(err),
|
|
559
|
+
})
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
await em.flush()
|
|
564
|
+
|
|
565
|
+
// Attach the resolved visit onto each event (direction-correct voyage).
|
|
566
|
+
for (const { event, pick } of picks) {
|
|
567
|
+
if (!pick) continue
|
|
568
|
+
const row = byRef.get(pick.ref)
|
|
569
|
+
if (!row) continue
|
|
570
|
+
event.vesselName = row.vesselName ?? null
|
|
571
|
+
event.voyageNumber = (pick.dir === 'in' ? row.ibVoyage : row.obVoyage) ?? null
|
|
572
|
+
event.vesselVisit = {
|
|
573
|
+
visitRef: row.visitRef,
|
|
574
|
+
vesselName: row.vesselName ?? null,
|
|
575
|
+
ibVoyage: row.ibVoyage ?? null,
|
|
576
|
+
obVoyage: row.obVoyage ?? null,
|
|
577
|
+
line: row.line ?? null,
|
|
578
|
+
phase: row.phase ?? null,
|
|
579
|
+
eta: row.eta ?? null,
|
|
580
|
+
etd: row.etd ?? null,
|
|
581
|
+
ata: row.ata ?? null,
|
|
582
|
+
atd: row.atd ?? null,
|
|
583
|
+
beginReceive: row.beginReceive ?? null,
|
|
584
|
+
dryCutoff: row.dryCutoff ?? null,
|
|
585
|
+
rawData: row.rawData ?? null,
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return changedRefs
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Insert new events, and update existing ones whose container fields changed
|
|
594
|
+
* or whose vessel visit data changed this poll. Returns both lists so the
|
|
595
|
+
* caller can emit `.created` / `.updated` accordingly.
|
|
596
|
+
*/
|
|
597
|
+
private async persistEvents(
|
|
598
|
+
em: EntityManager,
|
|
599
|
+
job: TerminalTrackingJob,
|
|
600
|
+
fetched: TerminalFetchedEvent[],
|
|
601
|
+
changedVesselRefs: Set<string>,
|
|
602
|
+
): Promise<{
|
|
603
|
+
created: TerminalEvent[]
|
|
604
|
+
updated: TerminalEvent[]
|
|
605
|
+
currentByKey: Map<string, TerminalEvent>
|
|
606
|
+
priorImpedimentsByKey: Map<string, string[] | null>
|
|
607
|
+
priorStopsByKey: Map<string, TerminalStops>
|
|
608
|
+
}> {
|
|
609
|
+
if (fetched.length === 0) {
|
|
610
|
+
return { created: [], updated: [], currentByKey: new Map(), priorImpedimentsByKey: new Map(), priorStopsByKey: new Map() }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const existing = await em.find(TerminalEvent, {
|
|
614
|
+
job,
|
|
615
|
+
sourceEventId: { $in: fetched.map((e) => e.sourceEventId) },
|
|
616
|
+
})
|
|
617
|
+
const byKey = new Map(existing.map((e) => [`${e.source}:${e.sourceEventId}`, e]))
|
|
618
|
+
// Snapshot each existing event's holds BEFORE the update loop mutates them,
|
|
619
|
+
// so availability detection can see the blocked → clear transition.
|
|
620
|
+
const priorImpedimentsByKey = new Map<string, string[] | null>(
|
|
621
|
+
existing.map((e) => [e.sourceEventId, e.impediments ?? null]),
|
|
622
|
+
)
|
|
623
|
+
// Same for the STOP flags (derived from rawData), so the availability step
|
|
624
|
+
// can detect a stop-set change and emit `stops_updated`.
|
|
625
|
+
const priorStopsByKey = new Map<string, TerminalStops>(
|
|
626
|
+
existing.map((e) => [e.sourceEventId, parseTerminalStops(e.rawData)]),
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
const created: TerminalEvent[] = []
|
|
630
|
+
const updated: TerminalEvent[] = []
|
|
631
|
+
const handled = new Set<string>()
|
|
632
|
+
for (const e of fetched) {
|
|
633
|
+
const key = `${e.source}:${e.sourceEventId}`
|
|
634
|
+
if (handled.has(key)) continue
|
|
635
|
+
handled.add(key)
|
|
636
|
+
|
|
637
|
+
const prior = byKey.get(key)
|
|
638
|
+
if (!prior) {
|
|
639
|
+
const entity = em.create(TerminalEvent, {
|
|
640
|
+
organizationId: job.organizationId,
|
|
641
|
+
tenantId: job.tenantId,
|
|
642
|
+
job,
|
|
643
|
+
source: e.source,
|
|
644
|
+
sourceEventId: e.sourceEventId,
|
|
645
|
+
eventType: e.eventType,
|
|
646
|
+
eventCode: e.eventCode,
|
|
647
|
+
eventClassifierCode: e.eventClassifierCode ?? null,
|
|
648
|
+
eventDateTime: e.eventDateTime,
|
|
649
|
+
containerNumber: e.containerNumber,
|
|
650
|
+
ufvGkey: e.ufvGkey,
|
|
651
|
+
transitState: e.transitState ?? null,
|
|
652
|
+
visitState: e.visitState ?? null,
|
|
653
|
+
facilityCode: e.facilityCode ?? null,
|
|
654
|
+
facilityCodeListProvider: e.facilityCodeListProvider ?? null,
|
|
655
|
+
unlocode: e.unlocode ?? null,
|
|
656
|
+
visitRefIn: e.visitRefIn ?? null,
|
|
657
|
+
visitRefOut: e.visitRefOut ?? null,
|
|
658
|
+
vesselName: e.vesselName ?? null,
|
|
659
|
+
voyageNumber: e.voyageNumber ?? null,
|
|
660
|
+
modeOfTransport: e.modeOfTransport ?? null,
|
|
661
|
+
seals: e.seals ?? null,
|
|
662
|
+
vgmWeightKg: e.vgmWeightKg ?? null,
|
|
663
|
+
impediments: e.impediments ?? null,
|
|
664
|
+
loadedAt: e.loadedAt ?? null,
|
|
665
|
+
rawData: e.rawData ?? null,
|
|
666
|
+
})
|
|
667
|
+
created.push(entity)
|
|
668
|
+
continue
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const containerChanged = eventSignature(prior) !== eventSignature(e)
|
|
672
|
+
const vesselChanged = !!e.vesselVisit && changedVesselRefs.has(e.vesselVisit.visitRef)
|
|
673
|
+
// The STOP flags live only in rawData, which eventSignature does not hash,
|
|
674
|
+
// so a stop-only change must be detected separately to persist rawData.
|
|
675
|
+
const stopFlagsChanged = stopsChanged(parseTerminalStops(prior.rawData), parseTerminalStops(e.rawData))
|
|
676
|
+
if (!containerChanged && !vesselChanged && !stopFlagsChanged) continue
|
|
677
|
+
|
|
678
|
+
if (containerChanged || stopFlagsChanged) {
|
|
679
|
+
prior.eventClassifierCode = e.eventClassifierCode ?? null
|
|
680
|
+
prior.eventDateTime = e.eventDateTime
|
|
681
|
+
prior.transitState = e.transitState ?? null
|
|
682
|
+
prior.visitState = e.visitState ?? null
|
|
683
|
+
prior.facilityCode = e.facilityCode ?? null
|
|
684
|
+
prior.facilityCodeListProvider = e.facilityCodeListProvider ?? null
|
|
685
|
+
prior.unlocode = e.unlocode ?? null
|
|
686
|
+
prior.visitRefIn = e.visitRefIn ?? null
|
|
687
|
+
prior.visitRefOut = e.visitRefOut ?? null
|
|
688
|
+
prior.vesselName = e.vesselName ?? null
|
|
689
|
+
prior.voyageNumber = e.voyageNumber ?? null
|
|
690
|
+
prior.modeOfTransport = e.modeOfTransport ?? null
|
|
691
|
+
prior.seals = e.seals ?? null
|
|
692
|
+
prior.vgmWeightKg = e.vgmWeightKg ?? null
|
|
693
|
+
prior.impediments = e.impediments ?? null
|
|
694
|
+
prior.loadedAt = e.loadedAt ?? null
|
|
695
|
+
prior.rawData = e.rawData ?? null
|
|
696
|
+
}
|
|
697
|
+
updated.push(prior)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (created.length || updated.length) await em.flush()
|
|
701
|
+
|
|
702
|
+
// Current persisted entity for every fetched event (existing + created), so
|
|
703
|
+
// availability detection evaluates the freshest snapshot per source event.
|
|
704
|
+
const currentByKey = new Map<string, TerminalEvent>(existing.map((e) => [e.sourceEventId, e]))
|
|
705
|
+
for (const c of created) currentByKey.set(c.sourceEventId, c)
|
|
706
|
+
|
|
707
|
+
return { created, updated, currentByKey, priorImpedimentsByKey, priorStopsByKey }
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private buildEventPayload(event: TerminalEvent, vesselVisit: TerminalFetchedEvent['vesselVisit'] = null) {
|
|
711
|
+
return {
|
|
712
|
+
id: event.id,
|
|
713
|
+
jobId: event.job.id,
|
|
714
|
+
tenantId: event.tenantId,
|
|
715
|
+
organizationId: event.organizationId,
|
|
716
|
+
source: event.source,
|
|
717
|
+
sourceEventId: event.sourceEventId,
|
|
718
|
+
eventType: event.eventType,
|
|
719
|
+
eventCode: event.eventCode,
|
|
720
|
+
eventClassifierCode: event.eventClassifierCode ?? null,
|
|
721
|
+
eventDateTime: event.eventDateTime.toISOString(),
|
|
722
|
+
containerNumber: event.containerNumber,
|
|
723
|
+
ufvGkey: event.ufvGkey,
|
|
724
|
+
transitState: event.transitState ?? null,
|
|
725
|
+
// Direction of travel through the terminal ('import' | 'export' | null).
|
|
726
|
+
// Consumers use it to decide which road leg a gate movement belongs to:
|
|
727
|
+
// an import gates OUT onto the delivery leg, an export gates IN off the
|
|
728
|
+
// pre-carriage leg. Derived from raw data, so it also holds for rows
|
|
729
|
+
// stored before this field existed.
|
|
730
|
+
cargoCategory: parseCargoCategory(event.rawData),
|
|
731
|
+
facilityCode: event.facilityCode ?? null,
|
|
732
|
+
facilityCodeListProvider: event.facilityCodeListProvider ?? null,
|
|
733
|
+
unlocode: event.unlocode ?? null,
|
|
734
|
+
vesselName: event.vesselName ?? null,
|
|
735
|
+
voyageNumber: event.voyageNumber ?? null,
|
|
736
|
+
modeOfTransport: event.modeOfTransport ?? null,
|
|
737
|
+
seals: event.seals ?? null,
|
|
738
|
+
impediments: event.impediments ?? null,
|
|
739
|
+
// When the container was loaded onto transport (N4 `Loaded`), typed datetime.
|
|
740
|
+
loadedAt: event.loadedAt ? event.loadedAt.toISOString() : null,
|
|
741
|
+
// Per-mode load/pickup STOP flags derived from the raw N4 columns.
|
|
742
|
+
stops: parseTerminalStops(event.rawData),
|
|
743
|
+
// Nested vessel visit (from /VESSEL); null when unresolved. Dates ISO.
|
|
744
|
+
vesselVisit: vesselVisit
|
|
745
|
+
? {
|
|
746
|
+
visitRef: vesselVisit.visitRef,
|
|
747
|
+
vesselName: vesselVisit.vesselName,
|
|
748
|
+
ibVoyage: vesselVisit.ibVoyage,
|
|
749
|
+
obVoyage: vesselVisit.obVoyage,
|
|
750
|
+
line: vesselVisit.line,
|
|
751
|
+
phase: vesselVisit.phase,
|
|
752
|
+
eta: vesselVisit.eta ? vesselVisit.eta.toISOString() : null,
|
|
753
|
+
etd: vesselVisit.etd ? vesselVisit.etd.toISOString() : null,
|
|
754
|
+
ata: vesselVisit.ata ? vesselVisit.ata.toISOString() : null,
|
|
755
|
+
atd: vesselVisit.atd ? vesselVisit.atd.toISOString() : null,
|
|
756
|
+
beginReceive: vesselVisit.beginReceive ? vesselVisit.beginReceive.toISOString() : null,
|
|
757
|
+
dryCutoff: vesselVisit.dryCutoff ? vesselVisit.dryCutoff.toISOString() : null,
|
|
758
|
+
}
|
|
759
|
+
: null,
|
|
760
|
+
rawData: event.rawData ?? null,
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Emit a newly-created event: the raw `.created` event plus its semantic
|
|
765
|
+
* milestone (gate_in / discharged / …). */
|
|
766
|
+
private async emitCreated(
|
|
767
|
+
event: TerminalEvent,
|
|
768
|
+
vesselVisit: TerminalFetchedEvent['vesselVisit'] = null,
|
|
769
|
+
): Promise<void> {
|
|
770
|
+
const payload = this.buildEventPayload(event, vesselVisit)
|
|
771
|
+
await this.deps.eventBus.emit('terminal_tracking.terminal_event.created', payload)
|
|
772
|
+
const semanticId = semanticEventIdFor(event.eventCode)
|
|
773
|
+
if (semanticId) await this.deps.eventBus.emit(semanticId, payload)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Emit `.updated` when an existing event's container or vessel data changed.
|
|
777
|
+
* Semantic milestone events are NOT re-emitted — the milestone already fired
|
|
778
|
+
* on creation; only the underlying data moved. */
|
|
779
|
+
private async emitUpdated(
|
|
780
|
+
event: TerminalEvent,
|
|
781
|
+
vesselVisit: TerminalFetchedEvent['vesselVisit'] = null,
|
|
782
|
+
): Promise<void> {
|
|
783
|
+
const payload = this.buildEventPayload(event, vesselVisit)
|
|
784
|
+
await this.deps.eventBus.emit('terminal_tracking.terminal_event.updated', payload)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Evaluate the container's availability after a poll and emit the one-shot
|
|
789
|
+
* milestone when it first becomes collectable:
|
|
790
|
+
* - export empty ready for pickup (`equipment.empty_ready`)
|
|
791
|
+
* - import blocking holds cleared (`equipment.holds_cleared`)
|
|
792
|
+
*
|
|
793
|
+
* The job-level `emptyReadyAt` / `holdsClearedAt` markers guarantee each event
|
|
794
|
+
* fires at most once per container. A job may carry both an import and an
|
|
795
|
+
* export leg, so both are checked independently.
|
|
796
|
+
*/
|
|
797
|
+
private async evaluateAndEmitAvailability(
|
|
798
|
+
em: EntityManager,
|
|
799
|
+
job: TerminalTrackingJob,
|
|
800
|
+
currentByKey: Map<string, TerminalEvent>,
|
|
801
|
+
priorImpedimentsByKey: Map<string, string[] | null>,
|
|
802
|
+
priorStopsByKey: Map<string, TerminalStops>,
|
|
803
|
+
vesselByEventId: Map<string, TerminalFetchedEvent['vesselVisit']>,
|
|
804
|
+
): Promise<void> {
|
|
805
|
+
let emptyEvent: TerminalEvent | null = null
|
|
806
|
+
let holdsEvent: TerminalEvent | null = null
|
|
807
|
+
// Events whose raw impediment set changed since the last poll (incl. first
|
|
808
|
+
// appearance with holds). Unlike the one-shot markers above, every one emits.
|
|
809
|
+
const holdsChangedEvents: TerminalEvent[] = []
|
|
810
|
+
// Events whose STOP flags changed since the last poll (incl. first
|
|
811
|
+
// appearance). Mirrors holdsChangedEvents.
|
|
812
|
+
const stopsChangedEvents: TerminalEvent[] = []
|
|
813
|
+
|
|
814
|
+
for (const [sourceEventId, entity] of currentByKey) {
|
|
815
|
+
if (!job.emptyReadyAt && !emptyEvent && isEmptyReady(entity)) emptyEvent = entity
|
|
816
|
+
if (
|
|
817
|
+
!job.holdsClearedAt &&
|
|
818
|
+
!holdsEvent &&
|
|
819
|
+
isImportHoldsCleared(entity, priorImpedimentsByKey.get(sourceEventId) ?? null)
|
|
820
|
+
) {
|
|
821
|
+
holdsEvent = entity
|
|
822
|
+
}
|
|
823
|
+
// `priorImpedimentsByKey` has no entry for events created this poll, so a
|
|
824
|
+
// first-seen container with holds is reported as a change (empty prior).
|
|
825
|
+
if (impedimentsChanged(priorImpedimentsByKey.get(sourceEventId), entity.impediments)) {
|
|
826
|
+
holdsChangedEvents.push(entity)
|
|
827
|
+
}
|
|
828
|
+
// Same for STOP flags: a created event has no prior entry, so an empty
|
|
829
|
+
// prior (all-null) surfaces a first-seen stop set as a change.
|
|
830
|
+
const priorStops = priorStopsByKey.get(sourceEventId) ?? { vsl: null, road: null, rail: null }
|
|
831
|
+
if (stopsChanged(priorStops, parseTerminalStops(entity.rawData))) {
|
|
832
|
+
stopsChangedEvents.push(entity)
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (!emptyEvent && !holdsEvent && holdsChangedEvents.length === 0 && stopsChangedEvents.length === 0) return
|
|
837
|
+
|
|
838
|
+
// Only the one-shot markers mutate the job; flush just for those.
|
|
839
|
+
if (emptyEvent || holdsEvent) {
|
|
840
|
+
const now = new Date()
|
|
841
|
+
if (emptyEvent) job.emptyReadyAt = now
|
|
842
|
+
if (holdsEvent) job.holdsClearedAt = now
|
|
843
|
+
await em.flush()
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (emptyEvent) {
|
|
847
|
+
await this.deps.eventBus.emit('terminal_tracking.equipment.empty_ready', {
|
|
848
|
+
...this.buildEventPayload(emptyEvent, vesselByEventId.get(emptyEvent.sourceEventId) ?? null),
|
|
849
|
+
emptyReady: true,
|
|
850
|
+
})
|
|
851
|
+
}
|
|
852
|
+
if (holdsEvent) {
|
|
853
|
+
await this.deps.eventBus.emit('terminal_tracking.equipment.holds_cleared', {
|
|
854
|
+
...this.buildEventPayload(holdsEvent, vesselByEventId.get(holdsEvent.sourceEventId) ?? null),
|
|
855
|
+
holdsCleared: true,
|
|
856
|
+
})
|
|
857
|
+
}
|
|
858
|
+
for (const entity of holdsChangedEvents) {
|
|
859
|
+
await this.deps.eventBus.emit('terminal_tracking.equipment.holds_updated', {
|
|
860
|
+
...this.buildEventPayload(entity, vesselByEventId.get(entity.sourceEventId) ?? null),
|
|
861
|
+
impediments: entity.impediments ?? [],
|
|
862
|
+
})
|
|
863
|
+
}
|
|
864
|
+
for (const entity of stopsChangedEvents) {
|
|
865
|
+
await this.deps.eventBus.emit('terminal_tracking.equipment.stops_updated', {
|
|
866
|
+
...this.buildEventPayload(entity, vesselByEventId.get(entity.sourceEventId) ?? null),
|
|
867
|
+
stops: parseTerminalStops(entity.rawData),
|
|
868
|
+
})
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private computeNextPoll(schedule?: string[] | null): Date | null {
|
|
873
|
+
if (!schedule || schedule.length === 0) return null
|
|
874
|
+
const now = Date.now()
|
|
875
|
+
const upcoming = schedule
|
|
876
|
+
.map((s) => new Date(s).getTime())
|
|
877
|
+
.filter((t) => Number.isFinite(t) && t > now)
|
|
878
|
+
.sort((a, b) => a - b)
|
|
879
|
+
return upcoming.length ? new Date(upcoming[0]) : null
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private async recordJobError(em: EntityManager, job: TerminalTrackingJob, err: unknown): Promise<void> {
|
|
883
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
884
|
+
terminalLogger.warn('Poll failed', { jobId: job.id, terminalCode: job.terminalCode, message })
|
|
885
|
+
|
|
886
|
+
const history = job.errorHistory ?? []
|
|
887
|
+
history.unshift({ date: new Date().toISOString(), message })
|
|
888
|
+
job.errorHistory = history.slice(0, MAX_ERROR_HISTORY)
|
|
889
|
+
job.retryCount = (job.retryCount ?? 0) + 1
|
|
890
|
+
job.lastPollAt = new Date()
|
|
891
|
+
if (job.retryCount >= MAX_RETRIES) job.status = 'failed'
|
|
892
|
+
|
|
893
|
+
try {
|
|
894
|
+
await em.flush()
|
|
895
|
+
} catch (flushErr) {
|
|
896
|
+
terminalLogger.error('Failed to persist job error', {
|
|
897
|
+
jobId: job.id,
|
|
898
|
+
message: flushErr instanceof Error ? flushErr.message : String(flushErr),
|
|
899
|
+
})
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
await this.deps.eventBus.emit('terminal_tracking.tracking_job.poll_failed', {
|
|
903
|
+
id: job.id,
|
|
904
|
+
terminalCode: job.terminalCode,
|
|
905
|
+
containerNumber: job.containerNumber,
|
|
906
|
+
tenantId: job.tenantId,
|
|
907
|
+
organizationId: job.organizationId,
|
|
908
|
+
message,
|
|
909
|
+
retryCount: job.retryCount,
|
|
910
|
+
})
|
|
911
|
+
}
|
|
912
|
+
}
|