@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,687 @@
|
|
|
1
|
+
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
2
|
+
import { TerminalConfig, TerminalTrackingJob, TerminalEvent, TerminalVesselVisit } from "../data/entities.js";
|
|
3
|
+
import { checkRateLimit } from "../lib/rate-limiter.js";
|
|
4
|
+
import { semanticEventIdFor, pickVesselVisit, parseCargoCategory } from "../lib/adapters/n4/n4-semantics.js";
|
|
5
|
+
import { impedimentsChanged, isEmptyReady, isImportHoldsCleared } from "../lib/availability.js";
|
|
6
|
+
import { parseTerminalStops, stopsChanged } from "../lib/stops.js";
|
|
7
|
+
import { terminalLogger } from "../lib/logger.js";
|
|
8
|
+
const DEFAULT_VESSEL_CACHE_TTL_SECONDS = 3600;
|
|
9
|
+
const MAX_CONTAINERS_PER_UNIT_REQUEST = 100;
|
|
10
|
+
function chunk(items, size) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
function isFullyDeparted(events) {
|
|
16
|
+
return events.length > 0 && events.every((e) => e.eventCode === "DEPA");
|
|
17
|
+
}
|
|
18
|
+
const MAX_ERROR_HISTORY = 20;
|
|
19
|
+
const MAX_RETRIES = 10;
|
|
20
|
+
function eventSignature(e) {
|
|
21
|
+
return JSON.stringify({
|
|
22
|
+
cls: e.eventClassifierCode ?? null,
|
|
23
|
+
dt: e.eventDateTime ? new Date(e.eventDateTime).toISOString() : null,
|
|
24
|
+
transitState: e.transitState ?? null,
|
|
25
|
+
visitState: e.visitState ?? null,
|
|
26
|
+
facilityCode: e.facilityCode ?? null,
|
|
27
|
+
facilityCodeListProvider: e.facilityCodeListProvider ?? null,
|
|
28
|
+
unlocode: e.unlocode ?? null,
|
|
29
|
+
visitRefIn: e.visitRefIn ?? null,
|
|
30
|
+
visitRefOut: e.visitRefOut ?? null,
|
|
31
|
+
vesselName: e.vesselName ?? null,
|
|
32
|
+
voyageNumber: e.voyageNumber ?? null,
|
|
33
|
+
modeOfTransport: e.modeOfTransport ?? null,
|
|
34
|
+
seals: e.seals ?? null,
|
|
35
|
+
vgmWeightKg: e.vgmWeightKg ?? null,
|
|
36
|
+
impediments: e.impediments ?? null,
|
|
37
|
+
loadedAt: e.loadedAt ? new Date(e.loadedAt).toISOString() : null
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function vesselSignature(v) {
|
|
41
|
+
const d = (x) => x ? x.toISOString() : null;
|
|
42
|
+
return JSON.stringify({
|
|
43
|
+
vesselName: v.vesselName ?? null,
|
|
44
|
+
ibVoyage: v.ibVoyage ?? null,
|
|
45
|
+
obVoyage: v.obVoyage ?? null,
|
|
46
|
+
line: v.line ?? null,
|
|
47
|
+
phase: v.phase ?? null,
|
|
48
|
+
eta: d(v.eta),
|
|
49
|
+
etd: d(v.etd),
|
|
50
|
+
ata: d(v.ata),
|
|
51
|
+
atd: d(v.atd),
|
|
52
|
+
beginReceive: d(v.beginReceive),
|
|
53
|
+
dryCutoff: d(v.dryCutoff)
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
class TerminalTrackingService {
|
|
57
|
+
constructor(deps) {
|
|
58
|
+
this.deps = deps;
|
|
59
|
+
}
|
|
60
|
+
toResolvedConfig(config) {
|
|
61
|
+
return {
|
|
62
|
+
terminalCode: config.terminalCode,
|
|
63
|
+
adapterType: config.adapterType,
|
|
64
|
+
displayName: config.displayName,
|
|
65
|
+
baseUrl: config.baseUrl,
|
|
66
|
+
proxyUrl: config.proxyUrl ?? null,
|
|
67
|
+
endpoints: config.endpoints,
|
|
68
|
+
authType: config.authType,
|
|
69
|
+
tokenUrl: config.tokenUrl ?? null,
|
|
70
|
+
scope: config.scope ?? null,
|
|
71
|
+
clientId: config.clientId ?? null,
|
|
72
|
+
authConfig: config.authConfig ?? null,
|
|
73
|
+
rateLimitRequests: config.rateLimitRequests,
|
|
74
|
+
rateLimitWindowSeconds: config.rateLimitWindowSeconds,
|
|
75
|
+
unlocode: config.unlocode ?? null,
|
|
76
|
+
facilityCode: (config.smdgCodes ?? [])[0] ?? (config.bicCodes ?? [])[0] ?? null,
|
|
77
|
+
facilityCodeListProvider: (config.smdgCodes ?? []).length ? "SMDG" : (config.bicCodes ?? []).length ? "BIC" : null
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async testTerminalConfig(input) {
|
|
81
|
+
const em = this.deps.em().fork();
|
|
82
|
+
const config = await findOneWithDecryption(em, TerminalConfig, {
|
|
83
|
+
id: input.id,
|
|
84
|
+
organizationId: input.organizationId,
|
|
85
|
+
tenantId: input.tenantId,
|
|
86
|
+
deletedAt: null
|
|
87
|
+
});
|
|
88
|
+
if (!config) return { success: false, message: "Terminal config not found" };
|
|
89
|
+
const adapter = this.deps.terminalRegistry.get(config.adapterType);
|
|
90
|
+
if (!adapter) return { success: false, message: `No adapter registered for type '${config.adapterType}'` };
|
|
91
|
+
return adapter.testConnection(this.toResolvedConfig(config));
|
|
92
|
+
}
|
|
93
|
+
async createJob(input) {
|
|
94
|
+
const em = this.deps.em().fork();
|
|
95
|
+
const existing = await em.findOne(TerminalTrackingJob, {
|
|
96
|
+
organizationId: input.organizationId,
|
|
97
|
+
tenantId: input.tenantId,
|
|
98
|
+
terminalCode: input.terminalCode,
|
|
99
|
+
containerNumber: input.containerNumber,
|
|
100
|
+
// Reuse only a still-open job (avoid duplicate in-flight tracking). A
|
|
101
|
+
// 'completed' job is NOT reused: a container that returns later gets a
|
|
102
|
+
// fresh tracking job with clean history.
|
|
103
|
+
status: { $in: ["active", "paused"] },
|
|
104
|
+
deletedAt: null
|
|
105
|
+
});
|
|
106
|
+
if (existing) {
|
|
107
|
+
const result = await this.pollJob(existing.id);
|
|
108
|
+
return { trackingJobId: existing.id, newEvents: result.newEvents };
|
|
109
|
+
}
|
|
110
|
+
const job = em.create(TerminalTrackingJob, {
|
|
111
|
+
organizationId: input.organizationId,
|
|
112
|
+
tenantId: input.tenantId,
|
|
113
|
+
terminalCode: input.terminalCode,
|
|
114
|
+
containerNumber: input.containerNumber,
|
|
115
|
+
status: "active",
|
|
116
|
+
schedule: input.schedule ?? null,
|
|
117
|
+
nextPollAt: null
|
|
118
|
+
});
|
|
119
|
+
await em.flush();
|
|
120
|
+
await this.deps.eventBus.emit("terminal_tracking.tracking_job.created", {
|
|
121
|
+
id: job.id,
|
|
122
|
+
terminalCode: job.terminalCode,
|
|
123
|
+
containerNumber: job.containerNumber,
|
|
124
|
+
tenantId: job.tenantId,
|
|
125
|
+
organizationId: job.organizationId
|
|
126
|
+
});
|
|
127
|
+
return { trackingJobId: job.id, newEvents: 0 };
|
|
128
|
+
}
|
|
129
|
+
async pollJob(jobId) {
|
|
130
|
+
const em = this.deps.em().fork();
|
|
131
|
+
const job = await em.findOne(TerminalTrackingJob, { id: jobId, deletedAt: null });
|
|
132
|
+
if (!job) throw new Error("Terminal tracking job not found");
|
|
133
|
+
try {
|
|
134
|
+
const config = await findOneWithDecryption(em, TerminalConfig, {
|
|
135
|
+
organizationId: job.organizationId,
|
|
136
|
+
tenantId: job.tenantId,
|
|
137
|
+
terminalCode: job.terminalCode,
|
|
138
|
+
isActive: true,
|
|
139
|
+
deletedAt: null
|
|
140
|
+
});
|
|
141
|
+
if (!config) throw new Error(`No active terminal config for '${job.terminalCode}'`);
|
|
142
|
+
const limit = await checkRateLimit(
|
|
143
|
+
this.deps.cacheService,
|
|
144
|
+
job.tenantId,
|
|
145
|
+
job.terminalCode,
|
|
146
|
+
config.rateLimitRequests,
|
|
147
|
+
config.rateLimitWindowSeconds
|
|
148
|
+
);
|
|
149
|
+
if (!limit.allowed) {
|
|
150
|
+
terminalLogger.debug("Rate limited, skipping poll", { jobId, terminalCode: job.terminalCode });
|
|
151
|
+
return { newEvents: 0, updatedEvents: 0 };
|
|
152
|
+
}
|
|
153
|
+
const adapter = this.deps.terminalRegistry.get(config.adapterType);
|
|
154
|
+
if (!adapter) throw new Error(`No adapter registered for type '${config.adapterType}'`);
|
|
155
|
+
const resolved = this.toResolvedConfig(config);
|
|
156
|
+
const result = await adapter.fetchEvents({ containerNumber: job.containerNumber, config: resolved });
|
|
157
|
+
const changedVesselRefs = await this.enrichWithVesselVisits(
|
|
158
|
+
em,
|
|
159
|
+
{ organizationId: job.organizationId, tenantId: job.tenantId, terminalCode: job.terminalCode },
|
|
160
|
+
config,
|
|
161
|
+
resolved,
|
|
162
|
+
adapter,
|
|
163
|
+
result.events
|
|
164
|
+
);
|
|
165
|
+
const { created, updated, currentByKey, priorImpedimentsByKey, priorStopsByKey } = await this.persistEvents(
|
|
166
|
+
em,
|
|
167
|
+
job,
|
|
168
|
+
result.events,
|
|
169
|
+
changedVesselRefs
|
|
170
|
+
);
|
|
171
|
+
job.lastPollAt = /* @__PURE__ */ new Date();
|
|
172
|
+
job.nextPollAt = this.computeNextPoll(job.schedule);
|
|
173
|
+
job.retryCount = 0;
|
|
174
|
+
job.status = isFullyDeparted(result.events) ? "completed" : "active";
|
|
175
|
+
await em.flush();
|
|
176
|
+
const vesselByEventId = new Map(
|
|
177
|
+
result.events.map((e) => [e.sourceEventId, e.vesselVisit ?? null])
|
|
178
|
+
);
|
|
179
|
+
for (const event of created) {
|
|
180
|
+
await this.emitCreated(event, vesselByEventId.get(event.sourceEventId) ?? null);
|
|
181
|
+
}
|
|
182
|
+
for (const event of updated) {
|
|
183
|
+
await this.emitUpdated(event, vesselByEventId.get(event.sourceEventId) ?? null);
|
|
184
|
+
}
|
|
185
|
+
await this.evaluateAndEmitAvailability(em, job, currentByKey, priorImpedimentsByKey, priorStopsByKey, vesselByEventId);
|
|
186
|
+
return { newEvents: created.length, updatedEvents: updated.length };
|
|
187
|
+
} catch (err) {
|
|
188
|
+
await this.recordJobError(em, job, err);
|
|
189
|
+
return { newEvents: 0, updatedEvents: 0 };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async pollAllActiveJobs(tenantId, organizationId) {
|
|
193
|
+
const em = this.deps.em().fork();
|
|
194
|
+
const now = /* @__PURE__ */ new Date();
|
|
195
|
+
const jobs = await em.find(TerminalTrackingJob, {
|
|
196
|
+
tenantId,
|
|
197
|
+
...organizationId ? { organizationId } : {},
|
|
198
|
+
status: "active",
|
|
199
|
+
deletedAt: null,
|
|
200
|
+
$or: [{ nextPollAt: null }, { nextPollAt: { $lte: now } }]
|
|
201
|
+
});
|
|
202
|
+
const groups = /* @__PURE__ */ new Map();
|
|
203
|
+
for (const job of jobs) {
|
|
204
|
+
const key = `${job.organizationId}::${job.terminalCode}`;
|
|
205
|
+
const arr = groups.get(key);
|
|
206
|
+
if (arr) arr.push(job);
|
|
207
|
+
else groups.set(key, [job]);
|
|
208
|
+
}
|
|
209
|
+
let newEvents = 0;
|
|
210
|
+
let updatedEvents = 0;
|
|
211
|
+
let failed = 0;
|
|
212
|
+
for (const group of groups.values()) {
|
|
213
|
+
try {
|
|
214
|
+
const r = await this.pollTerminalGroup(em, group);
|
|
215
|
+
newEvents += r.newEvents;
|
|
216
|
+
updatedEvents += r.updatedEvents;
|
|
217
|
+
failed += r.failed;
|
|
218
|
+
} catch {
|
|
219
|
+
failed += group.length;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { polled: jobs.length, newEvents, updatedEvents, failed };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Poll one terminal group (same org + terminalCode) in batches. Each batch of
|
|
226
|
+
* up to MAX_CONTAINERS_PER_UNIT_REQUEST containers is a single /unit call, and
|
|
227
|
+
* vessel visits are resolved once across the whole batch (deduped + cached),
|
|
228
|
+
* minimising API requests. Adapters without batch support fall back to the
|
|
229
|
+
* per-container path.
|
|
230
|
+
*/
|
|
231
|
+
async pollTerminalGroup(em, jobs) {
|
|
232
|
+
const first = jobs[0];
|
|
233
|
+
const scope = {
|
|
234
|
+
organizationId: first.organizationId,
|
|
235
|
+
tenantId: first.tenantId,
|
|
236
|
+
terminalCode: first.terminalCode
|
|
237
|
+
};
|
|
238
|
+
let config = null;
|
|
239
|
+
try {
|
|
240
|
+
config = await findOneWithDecryption(em, TerminalConfig, {
|
|
241
|
+
organizationId: scope.organizationId,
|
|
242
|
+
tenantId: scope.tenantId,
|
|
243
|
+
terminalCode: scope.terminalCode,
|
|
244
|
+
isActive: true,
|
|
245
|
+
deletedAt: null
|
|
246
|
+
});
|
|
247
|
+
} catch {
|
|
248
|
+
config = null;
|
|
249
|
+
}
|
|
250
|
+
const adapter = config ? this.deps.terminalRegistry.get(config.adapterType) : void 0;
|
|
251
|
+
if (!config || !adapter) {
|
|
252
|
+
const err = new Error(`No active terminal config/adapter for '${scope.terminalCode}'`);
|
|
253
|
+
for (const job of jobs) await this.recordJobError(em, job, err);
|
|
254
|
+
return { newEvents: 0, updatedEvents: 0, failed: jobs.length };
|
|
255
|
+
}
|
|
256
|
+
if (typeof adapter.fetchEventsBatch !== "function") {
|
|
257
|
+
let newEvents2 = 0;
|
|
258
|
+
let updatedEvents2 = 0;
|
|
259
|
+
let failed2 = 0;
|
|
260
|
+
for (const job of jobs) {
|
|
261
|
+
try {
|
|
262
|
+
const r = await this.pollJob(job.id);
|
|
263
|
+
newEvents2 += r.newEvents;
|
|
264
|
+
updatedEvents2 += r.updatedEvents;
|
|
265
|
+
} catch {
|
|
266
|
+
failed2 += 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { newEvents: newEvents2, updatedEvents: updatedEvents2, failed: failed2 };
|
|
270
|
+
}
|
|
271
|
+
const resolved = this.toResolvedConfig(config);
|
|
272
|
+
let newEvents = 0;
|
|
273
|
+
let updatedEvents = 0;
|
|
274
|
+
let failed = 0;
|
|
275
|
+
for (const batch of chunk(jobs, MAX_CONTAINERS_PER_UNIT_REQUEST)) {
|
|
276
|
+
const limit = await checkRateLimit(
|
|
277
|
+
this.deps.cacheService,
|
|
278
|
+
scope.tenantId,
|
|
279
|
+
scope.terminalCode,
|
|
280
|
+
config.rateLimitRequests,
|
|
281
|
+
config.rateLimitWindowSeconds
|
|
282
|
+
);
|
|
283
|
+
if (!limit.allowed) {
|
|
284
|
+
terminalLogger.debug("Rate limited, skipping unit batch", {
|
|
285
|
+
terminalCode: scope.terminalCode,
|
|
286
|
+
batch: batch.length
|
|
287
|
+
});
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const containerNumbers = batch.map((j) => j.containerNumber);
|
|
292
|
+
const { events } = await adapter.fetchEventsBatch({ containerNumbers, config: resolved });
|
|
293
|
+
const changedVesselRefs = await this.enrichWithVesselVisits(em, scope, config, resolved, adapter, events);
|
|
294
|
+
const byContainer = /* @__PURE__ */ new Map();
|
|
295
|
+
for (const e of events) {
|
|
296
|
+
const arr = byContainer.get(e.containerNumber);
|
|
297
|
+
if (arr) arr.push(e);
|
|
298
|
+
else byContainer.set(e.containerNumber, [e]);
|
|
299
|
+
}
|
|
300
|
+
for (const job of batch) {
|
|
301
|
+
const jobEvents = byContainer.get(job.containerNumber) ?? [];
|
|
302
|
+
const { created, updated, currentByKey, priorImpedimentsByKey, priorStopsByKey } = await this.persistEvents(
|
|
303
|
+
em,
|
|
304
|
+
job,
|
|
305
|
+
jobEvents,
|
|
306
|
+
changedVesselRefs
|
|
307
|
+
);
|
|
308
|
+
job.lastPollAt = /* @__PURE__ */ new Date();
|
|
309
|
+
job.nextPollAt = this.computeNextPoll(job.schedule);
|
|
310
|
+
job.retryCount = 0;
|
|
311
|
+
job.status = isFullyDeparted(jobEvents) ? "completed" : "active";
|
|
312
|
+
await em.flush();
|
|
313
|
+
const vesselByEventId = new Map(
|
|
314
|
+
jobEvents.map((e) => [e.sourceEventId, e.vesselVisit ?? null])
|
|
315
|
+
);
|
|
316
|
+
for (const ev of created) {
|
|
317
|
+
await this.emitCreated(ev, vesselByEventId.get(ev.sourceEventId) ?? null);
|
|
318
|
+
newEvents += 1;
|
|
319
|
+
}
|
|
320
|
+
for (const ev of updated) {
|
|
321
|
+
await this.emitUpdated(ev, vesselByEventId.get(ev.sourceEventId) ?? null);
|
|
322
|
+
updatedEvents += 1;
|
|
323
|
+
}
|
|
324
|
+
await this.evaluateAndEmitAvailability(em, job, currentByKey, priorImpedimentsByKey, priorStopsByKey, vesselByEventId);
|
|
325
|
+
}
|
|
326
|
+
} catch (err) {
|
|
327
|
+
for (const job of batch) await this.recordJobError(em, job, err);
|
|
328
|
+
failed += batch.length;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { newEvents, updatedEvents, failed };
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Resolve the vessel visit for each fetched event/leg and attach ETA/ETD/
|
|
335
|
+
* ATA/ATD + phase. The `TerminalVesselVisit` table is the cache: a row fresh
|
|
336
|
+
* within the TTL is reused without an API call, so the many containers that
|
|
337
|
+
* share one vessel visit cost a single /VESSEL fetch per cycle. Each fetch is
|
|
338
|
+
* rate-limit-gated against the same bucket as /unit; when the budget is spent,
|
|
339
|
+
* vessel enrichment is shed (the event still emits, sans vessel data) and
|
|
340
|
+
* filled on the next cycle.
|
|
341
|
+
*/
|
|
342
|
+
async enrichWithVesselVisits(em, scope, config, resolved, adapter, events) {
|
|
343
|
+
const changedRefs = /* @__PURE__ */ new Set();
|
|
344
|
+
if (typeof adapter.fetchVesselVisit !== "function") return changedRefs;
|
|
345
|
+
if (!resolved.endpoints.vessel) return changedRefs;
|
|
346
|
+
const picks = events.map((event) => ({ event, pick: pickVesselVisit(event) }));
|
|
347
|
+
const allRefs = [...new Set(picks.map((p) => p.pick?.ref).filter((r) => !!r))];
|
|
348
|
+
const fetchRefs = [
|
|
349
|
+
...new Set(
|
|
350
|
+
picks.filter((p) => p.pick && p.event.eventCode !== "DEPA").map((p) => p.pick.ref)
|
|
351
|
+
)
|
|
352
|
+
];
|
|
353
|
+
if (allRefs.length === 0) return changedRefs;
|
|
354
|
+
const ttlMs = (config.vesselCacheTtlSeconds ?? DEFAULT_VESSEL_CACHE_TTL_SECONDS) * 1e3;
|
|
355
|
+
const now = Date.now();
|
|
356
|
+
const existing = await em.find(TerminalVesselVisit, {
|
|
357
|
+
organizationId: scope.organizationId,
|
|
358
|
+
tenantId: scope.tenantId,
|
|
359
|
+
terminalCode: scope.terminalCode,
|
|
360
|
+
visitRef: { $in: allRefs }
|
|
361
|
+
});
|
|
362
|
+
const byRef = new Map(existing.map((r) => [r.visitRef, r]));
|
|
363
|
+
for (const ref of fetchRefs) {
|
|
364
|
+
const cached = byRef.get(ref);
|
|
365
|
+
if (cached && now - cached.updatedAt.getTime() < ttlMs) continue;
|
|
366
|
+
const limit = await checkRateLimit(
|
|
367
|
+
this.deps.cacheService,
|
|
368
|
+
scope.tenantId,
|
|
369
|
+
scope.terminalCode,
|
|
370
|
+
config.rateLimitRequests,
|
|
371
|
+
config.rateLimitWindowSeconds
|
|
372
|
+
);
|
|
373
|
+
if (!limit.allowed) {
|
|
374
|
+
terminalLogger.debug("Rate limited, skipping vessel fetch", { terminalCode: scope.terminalCode, visitRef: ref });
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const visit = await adapter.fetchVesselVisit({ visitRef: ref, config: resolved });
|
|
379
|
+
if (!visit) continue;
|
|
380
|
+
if (!cached || vesselSignature(cached) !== vesselSignature(visit)) changedRefs.add(ref);
|
|
381
|
+
const row = cached ?? em.create(TerminalVesselVisit, {
|
|
382
|
+
organizationId: scope.organizationId,
|
|
383
|
+
tenantId: scope.tenantId,
|
|
384
|
+
terminalCode: scope.terminalCode,
|
|
385
|
+
visitRef: ref
|
|
386
|
+
});
|
|
387
|
+
row.vesselName = visit.vesselName;
|
|
388
|
+
row.ibVoyage = visit.ibVoyage;
|
|
389
|
+
row.obVoyage = visit.obVoyage;
|
|
390
|
+
row.line = visit.line;
|
|
391
|
+
row.phase = visit.phase;
|
|
392
|
+
row.eta = visit.eta;
|
|
393
|
+
row.etd = visit.etd;
|
|
394
|
+
row.ata = visit.ata;
|
|
395
|
+
row.atd = visit.atd;
|
|
396
|
+
row.beginReceive = visit.beginReceive;
|
|
397
|
+
row.dryCutoff = visit.dryCutoff;
|
|
398
|
+
row.rawData = visit.rawData;
|
|
399
|
+
row.updatedAt = /* @__PURE__ */ new Date();
|
|
400
|
+
byRef.set(ref, row);
|
|
401
|
+
} catch (err) {
|
|
402
|
+
terminalLogger.debug("Vessel fetch failed", {
|
|
403
|
+
terminalCode: scope.terminalCode,
|
|
404
|
+
visitRef: ref,
|
|
405
|
+
error: err instanceof Error ? err.message : String(err)
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
await em.flush();
|
|
410
|
+
for (const { event, pick } of picks) {
|
|
411
|
+
if (!pick) continue;
|
|
412
|
+
const row = byRef.get(pick.ref);
|
|
413
|
+
if (!row) continue;
|
|
414
|
+
event.vesselName = row.vesselName ?? null;
|
|
415
|
+
event.voyageNumber = (pick.dir === "in" ? row.ibVoyage : row.obVoyage) ?? null;
|
|
416
|
+
event.vesselVisit = {
|
|
417
|
+
visitRef: row.visitRef,
|
|
418
|
+
vesselName: row.vesselName ?? null,
|
|
419
|
+
ibVoyage: row.ibVoyage ?? null,
|
|
420
|
+
obVoyage: row.obVoyage ?? null,
|
|
421
|
+
line: row.line ?? null,
|
|
422
|
+
phase: row.phase ?? null,
|
|
423
|
+
eta: row.eta ?? null,
|
|
424
|
+
etd: row.etd ?? null,
|
|
425
|
+
ata: row.ata ?? null,
|
|
426
|
+
atd: row.atd ?? null,
|
|
427
|
+
beginReceive: row.beginReceive ?? null,
|
|
428
|
+
dryCutoff: row.dryCutoff ?? null,
|
|
429
|
+
rawData: row.rawData ?? null
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return changedRefs;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Insert new events, and update existing ones whose container fields changed
|
|
436
|
+
* or whose vessel visit data changed this poll. Returns both lists so the
|
|
437
|
+
* caller can emit `.created` / `.updated` accordingly.
|
|
438
|
+
*/
|
|
439
|
+
async persistEvents(em, job, fetched, changedVesselRefs) {
|
|
440
|
+
if (fetched.length === 0) {
|
|
441
|
+
return { created: [], updated: [], currentByKey: /* @__PURE__ */ new Map(), priorImpedimentsByKey: /* @__PURE__ */ new Map(), priorStopsByKey: /* @__PURE__ */ new Map() };
|
|
442
|
+
}
|
|
443
|
+
const existing = await em.find(TerminalEvent, {
|
|
444
|
+
job,
|
|
445
|
+
sourceEventId: { $in: fetched.map((e) => e.sourceEventId) }
|
|
446
|
+
});
|
|
447
|
+
const byKey = new Map(existing.map((e) => [`${e.source}:${e.sourceEventId}`, e]));
|
|
448
|
+
const priorImpedimentsByKey = new Map(
|
|
449
|
+
existing.map((e) => [e.sourceEventId, e.impediments ?? null])
|
|
450
|
+
);
|
|
451
|
+
const priorStopsByKey = new Map(
|
|
452
|
+
existing.map((e) => [e.sourceEventId, parseTerminalStops(e.rawData)])
|
|
453
|
+
);
|
|
454
|
+
const created = [];
|
|
455
|
+
const updated = [];
|
|
456
|
+
const handled = /* @__PURE__ */ new Set();
|
|
457
|
+
for (const e of fetched) {
|
|
458
|
+
const key = `${e.source}:${e.sourceEventId}`;
|
|
459
|
+
if (handled.has(key)) continue;
|
|
460
|
+
handled.add(key);
|
|
461
|
+
const prior = byKey.get(key);
|
|
462
|
+
if (!prior) {
|
|
463
|
+
const entity = em.create(TerminalEvent, {
|
|
464
|
+
organizationId: job.organizationId,
|
|
465
|
+
tenantId: job.tenantId,
|
|
466
|
+
job,
|
|
467
|
+
source: e.source,
|
|
468
|
+
sourceEventId: e.sourceEventId,
|
|
469
|
+
eventType: e.eventType,
|
|
470
|
+
eventCode: e.eventCode,
|
|
471
|
+
eventClassifierCode: e.eventClassifierCode ?? null,
|
|
472
|
+
eventDateTime: e.eventDateTime,
|
|
473
|
+
containerNumber: e.containerNumber,
|
|
474
|
+
ufvGkey: e.ufvGkey,
|
|
475
|
+
transitState: e.transitState ?? null,
|
|
476
|
+
visitState: e.visitState ?? null,
|
|
477
|
+
facilityCode: e.facilityCode ?? null,
|
|
478
|
+
facilityCodeListProvider: e.facilityCodeListProvider ?? null,
|
|
479
|
+
unlocode: e.unlocode ?? null,
|
|
480
|
+
visitRefIn: e.visitRefIn ?? null,
|
|
481
|
+
visitRefOut: e.visitRefOut ?? null,
|
|
482
|
+
vesselName: e.vesselName ?? null,
|
|
483
|
+
voyageNumber: e.voyageNumber ?? null,
|
|
484
|
+
modeOfTransport: e.modeOfTransport ?? null,
|
|
485
|
+
seals: e.seals ?? null,
|
|
486
|
+
vgmWeightKg: e.vgmWeightKg ?? null,
|
|
487
|
+
impediments: e.impediments ?? null,
|
|
488
|
+
loadedAt: e.loadedAt ?? null,
|
|
489
|
+
rawData: e.rawData ?? null
|
|
490
|
+
});
|
|
491
|
+
created.push(entity);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const containerChanged = eventSignature(prior) !== eventSignature(e);
|
|
495
|
+
const vesselChanged = !!e.vesselVisit && changedVesselRefs.has(e.vesselVisit.visitRef);
|
|
496
|
+
const stopFlagsChanged = stopsChanged(parseTerminalStops(prior.rawData), parseTerminalStops(e.rawData));
|
|
497
|
+
if (!containerChanged && !vesselChanged && !stopFlagsChanged) continue;
|
|
498
|
+
if (containerChanged || stopFlagsChanged) {
|
|
499
|
+
prior.eventClassifierCode = e.eventClassifierCode ?? null;
|
|
500
|
+
prior.eventDateTime = e.eventDateTime;
|
|
501
|
+
prior.transitState = e.transitState ?? null;
|
|
502
|
+
prior.visitState = e.visitState ?? null;
|
|
503
|
+
prior.facilityCode = e.facilityCode ?? null;
|
|
504
|
+
prior.facilityCodeListProvider = e.facilityCodeListProvider ?? null;
|
|
505
|
+
prior.unlocode = e.unlocode ?? null;
|
|
506
|
+
prior.visitRefIn = e.visitRefIn ?? null;
|
|
507
|
+
prior.visitRefOut = e.visitRefOut ?? null;
|
|
508
|
+
prior.vesselName = e.vesselName ?? null;
|
|
509
|
+
prior.voyageNumber = e.voyageNumber ?? null;
|
|
510
|
+
prior.modeOfTransport = e.modeOfTransport ?? null;
|
|
511
|
+
prior.seals = e.seals ?? null;
|
|
512
|
+
prior.vgmWeightKg = e.vgmWeightKg ?? null;
|
|
513
|
+
prior.impediments = e.impediments ?? null;
|
|
514
|
+
prior.loadedAt = e.loadedAt ?? null;
|
|
515
|
+
prior.rawData = e.rawData ?? null;
|
|
516
|
+
}
|
|
517
|
+
updated.push(prior);
|
|
518
|
+
}
|
|
519
|
+
if (created.length || updated.length) await em.flush();
|
|
520
|
+
const currentByKey = new Map(existing.map((e) => [e.sourceEventId, e]));
|
|
521
|
+
for (const c of created) currentByKey.set(c.sourceEventId, c);
|
|
522
|
+
return { created, updated, currentByKey, priorImpedimentsByKey, priorStopsByKey };
|
|
523
|
+
}
|
|
524
|
+
buildEventPayload(event, vesselVisit = null) {
|
|
525
|
+
return {
|
|
526
|
+
id: event.id,
|
|
527
|
+
jobId: event.job.id,
|
|
528
|
+
tenantId: event.tenantId,
|
|
529
|
+
organizationId: event.organizationId,
|
|
530
|
+
source: event.source,
|
|
531
|
+
sourceEventId: event.sourceEventId,
|
|
532
|
+
eventType: event.eventType,
|
|
533
|
+
eventCode: event.eventCode,
|
|
534
|
+
eventClassifierCode: event.eventClassifierCode ?? null,
|
|
535
|
+
eventDateTime: event.eventDateTime.toISOString(),
|
|
536
|
+
containerNumber: event.containerNumber,
|
|
537
|
+
ufvGkey: event.ufvGkey,
|
|
538
|
+
transitState: event.transitState ?? null,
|
|
539
|
+
// Direction of travel through the terminal ('import' | 'export' | null).
|
|
540
|
+
// Consumers use it to decide which road leg a gate movement belongs to:
|
|
541
|
+
// an import gates OUT onto the delivery leg, an export gates IN off the
|
|
542
|
+
// pre-carriage leg. Derived from raw data, so it also holds for rows
|
|
543
|
+
// stored before this field existed.
|
|
544
|
+
cargoCategory: parseCargoCategory(event.rawData),
|
|
545
|
+
facilityCode: event.facilityCode ?? null,
|
|
546
|
+
facilityCodeListProvider: event.facilityCodeListProvider ?? null,
|
|
547
|
+
unlocode: event.unlocode ?? null,
|
|
548
|
+
vesselName: event.vesselName ?? null,
|
|
549
|
+
voyageNumber: event.voyageNumber ?? null,
|
|
550
|
+
modeOfTransport: event.modeOfTransport ?? null,
|
|
551
|
+
seals: event.seals ?? null,
|
|
552
|
+
impediments: event.impediments ?? null,
|
|
553
|
+
// When the container was loaded onto transport (N4 `Loaded`), typed datetime.
|
|
554
|
+
loadedAt: event.loadedAt ? event.loadedAt.toISOString() : null,
|
|
555
|
+
// Per-mode load/pickup STOP flags derived from the raw N4 columns.
|
|
556
|
+
stops: parseTerminalStops(event.rawData),
|
|
557
|
+
// Nested vessel visit (from /VESSEL); null when unresolved. Dates ISO.
|
|
558
|
+
vesselVisit: vesselVisit ? {
|
|
559
|
+
visitRef: vesselVisit.visitRef,
|
|
560
|
+
vesselName: vesselVisit.vesselName,
|
|
561
|
+
ibVoyage: vesselVisit.ibVoyage,
|
|
562
|
+
obVoyage: vesselVisit.obVoyage,
|
|
563
|
+
line: vesselVisit.line,
|
|
564
|
+
phase: vesselVisit.phase,
|
|
565
|
+
eta: vesselVisit.eta ? vesselVisit.eta.toISOString() : null,
|
|
566
|
+
etd: vesselVisit.etd ? vesselVisit.etd.toISOString() : null,
|
|
567
|
+
ata: vesselVisit.ata ? vesselVisit.ata.toISOString() : null,
|
|
568
|
+
atd: vesselVisit.atd ? vesselVisit.atd.toISOString() : null,
|
|
569
|
+
beginReceive: vesselVisit.beginReceive ? vesselVisit.beginReceive.toISOString() : null,
|
|
570
|
+
dryCutoff: vesselVisit.dryCutoff ? vesselVisit.dryCutoff.toISOString() : null
|
|
571
|
+
} : null,
|
|
572
|
+
rawData: event.rawData ?? null
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
/** Emit a newly-created event: the raw `.created` event plus its semantic
|
|
576
|
+
* milestone (gate_in / discharged / …). */
|
|
577
|
+
async emitCreated(event, vesselVisit = null) {
|
|
578
|
+
const payload = this.buildEventPayload(event, vesselVisit);
|
|
579
|
+
await this.deps.eventBus.emit("terminal_tracking.terminal_event.created", payload);
|
|
580
|
+
const semanticId = semanticEventIdFor(event.eventCode);
|
|
581
|
+
if (semanticId) await this.deps.eventBus.emit(semanticId, payload);
|
|
582
|
+
}
|
|
583
|
+
/** Emit `.updated` when an existing event's container or vessel data changed.
|
|
584
|
+
* Semantic milestone events are NOT re-emitted — the milestone already fired
|
|
585
|
+
* on creation; only the underlying data moved. */
|
|
586
|
+
async emitUpdated(event, vesselVisit = null) {
|
|
587
|
+
const payload = this.buildEventPayload(event, vesselVisit);
|
|
588
|
+
await this.deps.eventBus.emit("terminal_tracking.terminal_event.updated", payload);
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Evaluate the container's availability after a poll and emit the one-shot
|
|
592
|
+
* milestone when it first becomes collectable:
|
|
593
|
+
* - export empty ready for pickup (`equipment.empty_ready`)
|
|
594
|
+
* - import blocking holds cleared (`equipment.holds_cleared`)
|
|
595
|
+
*
|
|
596
|
+
* The job-level `emptyReadyAt` / `holdsClearedAt` markers guarantee each event
|
|
597
|
+
* fires at most once per container. A job may carry both an import and an
|
|
598
|
+
* export leg, so both are checked independently.
|
|
599
|
+
*/
|
|
600
|
+
async evaluateAndEmitAvailability(em, job, currentByKey, priorImpedimentsByKey, priorStopsByKey, vesselByEventId) {
|
|
601
|
+
let emptyEvent = null;
|
|
602
|
+
let holdsEvent = null;
|
|
603
|
+
const holdsChangedEvents = [];
|
|
604
|
+
const stopsChangedEvents = [];
|
|
605
|
+
for (const [sourceEventId, entity] of currentByKey) {
|
|
606
|
+
if (!job.emptyReadyAt && !emptyEvent && isEmptyReady(entity)) emptyEvent = entity;
|
|
607
|
+
if (!job.holdsClearedAt && !holdsEvent && isImportHoldsCleared(entity, priorImpedimentsByKey.get(sourceEventId) ?? null)) {
|
|
608
|
+
holdsEvent = entity;
|
|
609
|
+
}
|
|
610
|
+
if (impedimentsChanged(priorImpedimentsByKey.get(sourceEventId), entity.impediments)) {
|
|
611
|
+
holdsChangedEvents.push(entity);
|
|
612
|
+
}
|
|
613
|
+
const priorStops = priorStopsByKey.get(sourceEventId) ?? { vsl: null, road: null, rail: null };
|
|
614
|
+
if (stopsChanged(priorStops, parseTerminalStops(entity.rawData))) {
|
|
615
|
+
stopsChangedEvents.push(entity);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (!emptyEvent && !holdsEvent && holdsChangedEvents.length === 0 && stopsChangedEvents.length === 0) return;
|
|
619
|
+
if (emptyEvent || holdsEvent) {
|
|
620
|
+
const now = /* @__PURE__ */ new Date();
|
|
621
|
+
if (emptyEvent) job.emptyReadyAt = now;
|
|
622
|
+
if (holdsEvent) job.holdsClearedAt = now;
|
|
623
|
+
await em.flush();
|
|
624
|
+
}
|
|
625
|
+
if (emptyEvent) {
|
|
626
|
+
await this.deps.eventBus.emit("terminal_tracking.equipment.empty_ready", {
|
|
627
|
+
...this.buildEventPayload(emptyEvent, vesselByEventId.get(emptyEvent.sourceEventId) ?? null),
|
|
628
|
+
emptyReady: true
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
if (holdsEvent) {
|
|
632
|
+
await this.deps.eventBus.emit("terminal_tracking.equipment.holds_cleared", {
|
|
633
|
+
...this.buildEventPayload(holdsEvent, vesselByEventId.get(holdsEvent.sourceEventId) ?? null),
|
|
634
|
+
holdsCleared: true
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
for (const entity of holdsChangedEvents) {
|
|
638
|
+
await this.deps.eventBus.emit("terminal_tracking.equipment.holds_updated", {
|
|
639
|
+
...this.buildEventPayload(entity, vesselByEventId.get(entity.sourceEventId) ?? null),
|
|
640
|
+
impediments: entity.impediments ?? []
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
for (const entity of stopsChangedEvents) {
|
|
644
|
+
await this.deps.eventBus.emit("terminal_tracking.equipment.stops_updated", {
|
|
645
|
+
...this.buildEventPayload(entity, vesselByEventId.get(entity.sourceEventId) ?? null),
|
|
646
|
+
stops: parseTerminalStops(entity.rawData)
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
computeNextPoll(schedule) {
|
|
651
|
+
if (!schedule || schedule.length === 0) return null;
|
|
652
|
+
const now = Date.now();
|
|
653
|
+
const upcoming = schedule.map((s) => new Date(s).getTime()).filter((t) => Number.isFinite(t) && t > now).sort((a, b) => a - b);
|
|
654
|
+
return upcoming.length ? new Date(upcoming[0]) : null;
|
|
655
|
+
}
|
|
656
|
+
async recordJobError(em, job, err) {
|
|
657
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
658
|
+
terminalLogger.warn("Poll failed", { jobId: job.id, terminalCode: job.terminalCode, message });
|
|
659
|
+
const history = job.errorHistory ?? [];
|
|
660
|
+
history.unshift({ date: (/* @__PURE__ */ new Date()).toISOString(), message });
|
|
661
|
+
job.errorHistory = history.slice(0, MAX_ERROR_HISTORY);
|
|
662
|
+
job.retryCount = (job.retryCount ?? 0) + 1;
|
|
663
|
+
job.lastPollAt = /* @__PURE__ */ new Date();
|
|
664
|
+
if (job.retryCount >= MAX_RETRIES) job.status = "failed";
|
|
665
|
+
try {
|
|
666
|
+
await em.flush();
|
|
667
|
+
} catch (flushErr) {
|
|
668
|
+
terminalLogger.error("Failed to persist job error", {
|
|
669
|
+
jobId: job.id,
|
|
670
|
+
message: flushErr instanceof Error ? flushErr.message : String(flushErr)
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
await this.deps.eventBus.emit("terminal_tracking.tracking_job.poll_failed", {
|
|
674
|
+
id: job.id,
|
|
675
|
+
terminalCode: job.terminalCode,
|
|
676
|
+
containerNumber: job.containerNumber,
|
|
677
|
+
tenantId: job.tenantId,
|
|
678
|
+
organizationId: job.organizationId,
|
|
679
|
+
message,
|
|
680
|
+
retryCount: job.retryCount
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
export {
|
|
685
|
+
TerminalTrackingService
|
|
686
|
+
};
|
|
687
|
+
//# sourceMappingURL=terminalTrackingService.js.map
|