@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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Availability detection for terminal containers.
|
|
3
|
+
*
|
|
4
|
+
* Derives two business signals from the latest N4 `/unit` snapshot of a
|
|
5
|
+
* container, reusing the hold catalogue in `./holds` to decide what actually
|
|
6
|
+
* blocks a physical pickup:
|
|
7
|
+
*
|
|
8
|
+
* - **Empty-ready** — an empty container sitting in the yard with no
|
|
9
|
+
* movement-blocking holds is releasable for collection. Per Baltic Hub's
|
|
10
|
+
* Navis docs, `EMPTY PERMISSION` is applied to *every* empty regardless of
|
|
11
|
+
* direction and is cleared once the carrier's EDO/booking is provided; empties
|
|
12
|
+
* are handled under N4 category `Storage`, not `Export` — so this does not
|
|
13
|
+
* gate on category.
|
|
14
|
+
* - **Import holds-cleared** — an import container in the yard whose blocking
|
|
15
|
+
* holds have just been lifted (transition blocked → clear) has become
|
|
16
|
+
* collectable.
|
|
17
|
+
*
|
|
18
|
+
* Pure helpers — no DI, no persistence. The service owns the one-shot guard
|
|
19
|
+
* (job-level `emptyReadyAt` / `holdsClearedAt`).
|
|
20
|
+
*/
|
|
21
|
+
import { resolveHolds } from './holds'
|
|
22
|
+
import { parseTerminalStops } from './stops'
|
|
23
|
+
|
|
24
|
+
/** N4 in-yard transit state (discharged, available on the terminal). */
|
|
25
|
+
const YARD_STATE = 'S40_YARD'
|
|
26
|
+
|
|
27
|
+
/** Minimal shape both a fetched event and a persisted `TerminalEvent` satisfy. */
|
|
28
|
+
export type AvailabilityInput = {
|
|
29
|
+
transitState?: string | null
|
|
30
|
+
impediments?: string[] | null
|
|
31
|
+
rawData?: Record<string, unknown> | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Count holds that block physical movement / loading / pickup. */
|
|
35
|
+
export function blockingHoldCount(impediments: readonly string[] | null | undefined): number {
|
|
36
|
+
return resolveHolds(impediments ?? null).filter((h) => h.blocksMovement).length
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether two raw impediment lists differ as sets (order- and duplicate-
|
|
41
|
+
* insensitive, whitespace-trimmed). An absent/null prior is treated as empty,
|
|
42
|
+
* so a first-seen container carrying holds counts as a change. Drives the
|
|
43
|
+
* `equipment.holds_updated` event (fires on every poll the hold set changes).
|
|
44
|
+
*/
|
|
45
|
+
export function impedimentsChanged(
|
|
46
|
+
prior: readonly string[] | null | undefined,
|
|
47
|
+
current: readonly string[] | null | undefined,
|
|
48
|
+
): boolean {
|
|
49
|
+
const norm = (l: readonly string[] | null | undefined): string[] =>
|
|
50
|
+
Array.from(new Set((l ?? []).map((c) => c.trim()).filter((c) => c !== ''))).sort()
|
|
51
|
+
const a = norm(prior)
|
|
52
|
+
const b = norm(current)
|
|
53
|
+
if (a.length !== b.length) return true
|
|
54
|
+
return a.some((code, i) => code !== b[i])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** N4 `Category` column, normalized to import/export (null if absent/unknown). */
|
|
58
|
+
function category(e: AvailabilityInput): 'import' | 'export' | null {
|
|
59
|
+
const c = String(e.rawData?.['Category'] ?? '').trim().toLowerCase()
|
|
60
|
+
return c === 'import' || c === 'export' ? c : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether the container is empty, per the N4 `Frght Kind` column. Baltic Hub
|
|
65
|
+
* reports empties as `Empty` (confirmed against a real /unit payload, 2026-07);
|
|
66
|
+
* `MTY` is kept as a defensive alias for other N4 terminals. Full containers
|
|
67
|
+
* report `FCL`/`LCL`.
|
|
68
|
+
*/
|
|
69
|
+
function isEmpty(e: AvailabilityInput): boolean {
|
|
70
|
+
const kind = String(e.rawData?.['Frght Kind'] ?? '').trim().toUpperCase()
|
|
71
|
+
return kind === 'EMPTY' || kind === 'MTY'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isInYard(e: AvailabilityInput): boolean {
|
|
75
|
+
return e.transitState === YARD_STATE
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether the terminal has explicitly stopped road pickup for this container
|
|
80
|
+
* (Stop-Road). Only an *explicit* stop counts — an absent column (`null`, the
|
|
81
|
+
* pre-update world) or a `false` never blocks, keeping this purely additive.
|
|
82
|
+
*
|
|
83
|
+
* This gates only the **state-based** `isEmptyReady` below, where a road stop
|
|
84
|
+
* simply *delays* the one-shot until it lifts (the signal is re-evaluated every
|
|
85
|
+
* poll). The **transition-based** `isImportHoldsCleared` is deliberately NOT
|
|
86
|
+
* gated: it fires on the holds blocked→clear edge, so suppressing it during a
|
|
87
|
+
* road stop would lose the event entirely once the edge has passed, rather than
|
|
88
|
+
* defer it — the container's holds still genuinely cleared.
|
|
89
|
+
*/
|
|
90
|
+
function isRoadPickupStopped(e: AvailabilityInput): boolean {
|
|
91
|
+
return parseTerminalStops(e.rawData).road === true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Empty ready for pickup: an empty container in the yard with no movement-
|
|
96
|
+
* blocking holds (its `EMPTY PERMISSION` and any others have cleared). State-
|
|
97
|
+
* based — evaluate on the latest snapshot. Direction-agnostic, since empties
|
|
98
|
+
* sit under N4 category `Storage` rather than `Export`.
|
|
99
|
+
*/
|
|
100
|
+
export function isEmptyReady(e: AvailabilityInput): boolean {
|
|
101
|
+
return isEmpty(e) && isInYard(e) && blockingHoldCount(e.impediments) === 0 && !isRoadPickupStopped(e)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Import holds cleared: an import container in the yard whose blocking holds
|
|
106
|
+
* went from present → none this poll. Transition-based, so it never fires for a
|
|
107
|
+
* container that was never blocked.
|
|
108
|
+
*/
|
|
109
|
+
export function isImportHoldsCleared(
|
|
110
|
+
e: AvailabilityInput,
|
|
111
|
+
priorImpediments: readonly string[] | null | undefined,
|
|
112
|
+
): boolean {
|
|
113
|
+
return (
|
|
114
|
+
category(e) === 'import' &&
|
|
115
|
+
isInYard(e) &&
|
|
116
|
+
blockingHoldCount(priorImpediments) > 0 &&
|
|
117
|
+
blockingHoldCount(e.impediments) === 0
|
|
118
|
+
)
|
|
119
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { terminalLogger } from './logger'
|
|
3
|
+
|
|
4
|
+
type SchedulerLike = {
|
|
5
|
+
register: (registration: Record<string, unknown>) => Promise<void>
|
|
6
|
+
exists?: (scheduleId: string) => Promise<boolean>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type ResolveLike = { resolve: <T = unknown>(name: string) => T }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Deterministic RFC 4122 (v5-style) uuid derived from a stable seed, so the same
|
|
13
|
+
* (org, tenant) always maps to the same scheduled-job id. The scheduler upserts
|
|
14
|
+
* by id (idempotent `register`), and consumers validate with `z.uuid()`, so the
|
|
15
|
+
* version (nibble 13 = '5') and variant (nibble 17 = 8-b) bits must be set.
|
|
16
|
+
*/
|
|
17
|
+
function deterministicUuid(seed: string): string {
|
|
18
|
+
const hex = createHash('sha1').update(seed).digest('hex').slice(0, 32).split('')
|
|
19
|
+
hex[12] = '5' // version 5
|
|
20
|
+
hex[16] = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16) // variant 10xx -> 8..b
|
|
21
|
+
const s = hex.join('')
|
|
22
|
+
return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Ensure the periodic "poll all" schedule exists for an org/tenant — create-only.
|
|
27
|
+
*
|
|
28
|
+
* The schedule is seeded with the module defaults the first time only. Once it
|
|
29
|
+
* exists, this no-ops so user edits in the scheduler settings (cadence, enabled
|
|
30
|
+
* state) are the source of truth and are never overwritten — `setup.ts` and the
|
|
31
|
+
* config/job-save callers re-run this purely to self-heal orgs that predate the
|
|
32
|
+
* module. Stays a single row thanks to the deterministic id. No-ops gracefully
|
|
33
|
+
* when the optional scheduler module isn't installed.
|
|
34
|
+
*/
|
|
35
|
+
export async function ensureTerminalPollSchedule(
|
|
36
|
+
container: ResolveLike,
|
|
37
|
+
scope: { organizationId: string; tenantId: string },
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
let scheduler: SchedulerLike
|
|
40
|
+
try {
|
|
41
|
+
scheduler = container.resolve<SchedulerLike>('schedulerService')
|
|
42
|
+
} catch {
|
|
43
|
+
return // scheduler module not installed
|
|
44
|
+
}
|
|
45
|
+
if (!scheduler?.register) return
|
|
46
|
+
|
|
47
|
+
const id = deterministicUuid(`terminal_tracking:poll_all:${scope.organizationId}:${scope.tenantId}`)
|
|
48
|
+
|
|
49
|
+
// Create-only: never overwrite an existing schedule, so UI cadence/enabled edits win.
|
|
50
|
+
// (`register` is a full upsert, so without this guard every config/job save would
|
|
51
|
+
// revert the row to the defaults below.)
|
|
52
|
+
try {
|
|
53
|
+
if (scheduler.exists && (await scheduler.exists(id))) return
|
|
54
|
+
} catch {
|
|
55
|
+
// Existence check is best-effort; fall through to register on failure.
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await scheduler.register({
|
|
60
|
+
id,
|
|
61
|
+
name: 'Terminal Tracking Poll',
|
|
62
|
+
description:
|
|
63
|
+
'Polls all active terminal tracking jobs every 4 hours. Change the cadence in the scheduler settings.',
|
|
64
|
+
scopeType: 'organization',
|
|
65
|
+
organizationId: scope.organizationId,
|
|
66
|
+
tenantId: scope.tenantId,
|
|
67
|
+
scheduleType: 'cron',
|
|
68
|
+
scheduleValue: '0 */4 * * *',
|
|
69
|
+
timezone: 'UTC',
|
|
70
|
+
targetType: 'command',
|
|
71
|
+
targetCommand: 'terminal_tracking.tracking.poll_all',
|
|
72
|
+
targetPayload: { tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
73
|
+
sourceType: 'module',
|
|
74
|
+
sourceModule: 'terminal_tracking',
|
|
75
|
+
isEnabled: true,
|
|
76
|
+
})
|
|
77
|
+
} catch (err) {
|
|
78
|
+
terminalLogger.warn('Failed to ensure terminal poll schedule', {
|
|
79
|
+
organizationId: scope.organizationId,
|
|
80
|
+
message: err instanceof Error ? err.message : String(err),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit-impediment ("hold") catalogue for Navis N4 terminals (Baltic Hub / BCT).
|
|
3
|
+
*
|
|
4
|
+
* N4 returns the `Unit Impediments` column as a comma-separated list of codes,
|
|
5
|
+
* each prefixed with `!` and delimited inconsistently — some use underscores
|
|
6
|
+
* (`!TECHNICAL_FULL_CONTAINER`), others spaces (`!CUSTOMS EXPORT PERMISSION`).
|
|
7
|
+
* This module normalizes a raw code and resolves it to a human explanation plus
|
|
8
|
+
* a severity, so the UI can render a coloured badge with an informative tooltip.
|
|
9
|
+
*
|
|
10
|
+
* Source of truth for the catalogue: Baltic Hub documentation
|
|
11
|
+
* https://baltichub.com/dla-klienta/navis
|
|
12
|
+
*
|
|
13
|
+
* Pure helper — no DI, no i18n, no React. English strings by convention; the
|
|
14
|
+
* consuming component owns localization/colour mapping.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How urgently a hold needs attention.
|
|
19
|
+
* - `critical`: blocks movement/load/pickup and typically needs a third party
|
|
20
|
+
* (customs office, terminal CS) to lift — cannot be self-served.
|
|
21
|
+
* - `warning`: routine, systematic permission that is auto- or self-removed in
|
|
22
|
+
* the normal flow (MRN update, VGM upload, line PIN) but currently blocks.
|
|
23
|
+
* - `info`: technical/administrative marker that does NOT block operations.
|
|
24
|
+
*/
|
|
25
|
+
export type HoldSeverity = 'critical' | 'warning' | 'info'
|
|
26
|
+
|
|
27
|
+
/** Which container flow the hold belongs to. */
|
|
28
|
+
export type HoldCategory = 'import' | 'export' | 'general'
|
|
29
|
+
|
|
30
|
+
export type HoldDefinition = {
|
|
31
|
+
/** Canonical, normalized code (no `!`, spaced, upper-case). */
|
|
32
|
+
code: string
|
|
33
|
+
/** Short human label for the badge. */
|
|
34
|
+
label: string
|
|
35
|
+
category: HoldCategory
|
|
36
|
+
severity: HoldSeverity
|
|
37
|
+
/** Whether the hold blocks physical movement / loading / pickup. */
|
|
38
|
+
blocksMovement: boolean
|
|
39
|
+
/** What the hold means / why it is applied. */
|
|
40
|
+
description: string
|
|
41
|
+
/** How the hold is cleared and by whom. */
|
|
42
|
+
resolution: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolved hold: a known definition, or an `unknown: true` fallback. */
|
|
46
|
+
export type HoldInfo = HoldDefinition & {
|
|
47
|
+
/** Original code as received from the terminal (with `!`, original spacing). */
|
|
48
|
+
rawCode: string
|
|
49
|
+
/** True when the code was not in the catalogue. */
|
|
50
|
+
unknown: boolean
|
|
51
|
+
/**
|
|
52
|
+
* Stable slug for i18n lookup, e.g. `dgd_hold`. The `label`/`description`/
|
|
53
|
+
* `resolution` fields above are English fallbacks; the consuming component
|
|
54
|
+
* resolves `terminal_tracking.holds.<i18nKey>.{label,description,resolution}`
|
|
55
|
+
* against the active locale, falling back to those English strings.
|
|
56
|
+
*/
|
|
57
|
+
i18nKey: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Derive the i18n slug for a normalized hold code (e.g. `DGD HOLD` → `dgd_hold`). */
|
|
61
|
+
export function holdI18nKey(normalizedCode: string): string {
|
|
62
|
+
return normalizedCode.toLowerCase().replace(/ /g, '_')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Sort/threshold rank: higher = more urgent. */
|
|
66
|
+
export const HOLD_SEVERITY_RANK: Record<HoldSeverity, number> = {
|
|
67
|
+
critical: 3,
|
|
68
|
+
warning: 2,
|
|
69
|
+
info: 1,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Normalize a raw N4 impediment code into its canonical lookup key: drop the
|
|
74
|
+
* leading `!`, trim, upper-case, and collapse runs of `_`/whitespace into a
|
|
75
|
+
* single space so `TECHNICAL_FULL_CONTAINER` and `TECHNICAL FULL CONTAINER`
|
|
76
|
+
* resolve to the same entry.
|
|
77
|
+
*/
|
|
78
|
+
export function normalizeHoldCode(rawCode: string): string {
|
|
79
|
+
return rawCode
|
|
80
|
+
.replace(/^!/, '')
|
|
81
|
+
.replace(/[_\s]+/g, ' ')
|
|
82
|
+
.trim()
|
|
83
|
+
.toUpperCase()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function def(
|
|
87
|
+
code: string,
|
|
88
|
+
category: HoldCategory,
|
|
89
|
+
severity: HoldSeverity,
|
|
90
|
+
blocksMovement: boolean,
|
|
91
|
+
label: string,
|
|
92
|
+
description: string,
|
|
93
|
+
resolution: string,
|
|
94
|
+
): HoldDefinition {
|
|
95
|
+
return { code, category, severity, blocksMovement, label, description, resolution }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Catalogue keyed by normalized code. */
|
|
99
|
+
const HOLD_CATALOGUE: Record<string, HoldDefinition> = {
|
|
100
|
+
// ── Import ────────────────────────────────────────────────────────────────
|
|
101
|
+
'CFS APPOINTMENT': def(
|
|
102
|
+
'CFS APPOINTMENT',
|
|
103
|
+
'import',
|
|
104
|
+
'warning',
|
|
105
|
+
true,
|
|
106
|
+
'CFS appointment',
|
|
107
|
+
'Applied while a CFS (warehouse) import order is being accepted.',
|
|
108
|
+
'Cleared automatically once the order is completed and warehouse work is done.',
|
|
109
|
+
),
|
|
110
|
+
'CUSTOMS IMPORT PERMISSION': def(
|
|
111
|
+
'CUSTOMS IMPORT PERMISSION',
|
|
112
|
+
'import',
|
|
113
|
+
'warning',
|
|
114
|
+
true,
|
|
115
|
+
'Customs import permission',
|
|
116
|
+
'Applied systematically to every import container pending customs release.',
|
|
117
|
+
'Removed automatically after the MRN number is updated via the Polish PCS codes.',
|
|
118
|
+
),
|
|
119
|
+
'CUSTOMS IMPORT HOLD': def(
|
|
120
|
+
'CUSTOMS IMPORT HOLD',
|
|
121
|
+
'import',
|
|
122
|
+
'critical',
|
|
123
|
+
true,
|
|
124
|
+
'Customs import hold',
|
|
125
|
+
'Placed manually by a customs officer for scanning, inspection or a clearance issue.',
|
|
126
|
+
'Only a customs officer can lift it — contact the customs office.',
|
|
127
|
+
),
|
|
128
|
+
'LINE IMPORT PERMISSION': def(
|
|
129
|
+
'LINE IMPORT PERMISSION',
|
|
130
|
+
'import',
|
|
131
|
+
'warning',
|
|
132
|
+
true,
|
|
133
|
+
'Line import permission',
|
|
134
|
+
'Applied systematically to every import container until the shipping line releases it.',
|
|
135
|
+
'Removed once the line releases the container via its PIN number.',
|
|
136
|
+
),
|
|
137
|
+
'OOG HOLD': def(
|
|
138
|
+
'OOG HOLD',
|
|
139
|
+
'import',
|
|
140
|
+
'critical',
|
|
141
|
+
true,
|
|
142
|
+
'OOG hold',
|
|
143
|
+
'Applied to out-of-gauge import containers that require special handling permits.',
|
|
144
|
+
'Must be verified by Baltic Hub before pickup; the container cannot be notified for collection until then.',
|
|
145
|
+
),
|
|
146
|
+
|
|
147
|
+
// ── Export ────────────────────────────────────────────────────────────────
|
|
148
|
+
'CFS HOLD': def(
|
|
149
|
+
'CFS HOLD',
|
|
150
|
+
'export',
|
|
151
|
+
'warning',
|
|
152
|
+
true,
|
|
153
|
+
'CFS hold',
|
|
154
|
+
'Applied while a CFS (warehouse) export order is being accepted.',
|
|
155
|
+
'Cleared automatically once the order is completed and warehouse work is done.',
|
|
156
|
+
),
|
|
157
|
+
'CUSTOMS EXPORT PERMISSION': def(
|
|
158
|
+
'CUSTOMS EXPORT PERMISSION',
|
|
159
|
+
'export',
|
|
160
|
+
'warning',
|
|
161
|
+
true,
|
|
162
|
+
'Customs export permission',
|
|
163
|
+
'Applied systematically to export containers pending customs release.',
|
|
164
|
+
'Removed automatically after the MRN update; must be cleared before the customs-clearance deadline.',
|
|
165
|
+
),
|
|
166
|
+
'CUSTOMS EXPORT HOLD': def(
|
|
167
|
+
'CUSTOMS EXPORT HOLD',
|
|
168
|
+
'export',
|
|
169
|
+
'critical',
|
|
170
|
+
true,
|
|
171
|
+
'Customs export hold',
|
|
172
|
+
'Placed manually by a customs officer for scanning, inspection or a clearance issue.',
|
|
173
|
+
'Only a customs officer can lift it — contact the customs office.',
|
|
174
|
+
),
|
|
175
|
+
'UNIT VGM LOAD PERMISSION': def(
|
|
176
|
+
'UNIT VGM LOAD PERMISSION',
|
|
177
|
+
'export',
|
|
178
|
+
'warning',
|
|
179
|
+
true,
|
|
180
|
+
'VGM load permission',
|
|
181
|
+
'Applied to every full export container that has no verified gross mass yet.',
|
|
182
|
+
'Removed when the VGM weight is uploaded to Navis.',
|
|
183
|
+
),
|
|
184
|
+
'DGD HOLD': def(
|
|
185
|
+
'DGD HOLD',
|
|
186
|
+
'export',
|
|
187
|
+
'critical',
|
|
188
|
+
true,
|
|
189
|
+
'DGD hold',
|
|
190
|
+
'Applied automatically to every export container carrying IMO (dangerous) cargo.',
|
|
191
|
+
'Requires the DGD (dangerous-goods declaration) document to be submitted.',
|
|
192
|
+
),
|
|
193
|
+
'SCANNING HOLD': def(
|
|
194
|
+
'SCANNING HOLD',
|
|
195
|
+
'export',
|
|
196
|
+
'critical',
|
|
197
|
+
true,
|
|
198
|
+
'Scanning hold',
|
|
199
|
+
'Placed manually by a customs officer; the container must be presented for scanning.',
|
|
200
|
+
'Cleared by customs after scanning (DCT_CFS_SCANNING selection).',
|
|
201
|
+
),
|
|
202
|
+
|
|
203
|
+
// ── General / technical ─────────────────────────────────────────────────────
|
|
204
|
+
'EMPTY PERMISSION': def(
|
|
205
|
+
'EMPTY PERMISSION',
|
|
206
|
+
'general',
|
|
207
|
+
'warning',
|
|
208
|
+
true,
|
|
209
|
+
'Empty permission',
|
|
210
|
+
'Applied systematically to every empty container pending a release reference.',
|
|
211
|
+
'Removed once the EDO / booking number from the shipping line is provided.',
|
|
212
|
+
),
|
|
213
|
+
'MISSING AGENT PERMISSION': def(
|
|
214
|
+
'MISSING AGENT PERMISSION',
|
|
215
|
+
'general',
|
|
216
|
+
'info',
|
|
217
|
+
false,
|
|
218
|
+
'Missing agent permission',
|
|
219
|
+
'Relates to an inactive PCS payment. Does not currently block ship loading or pickup.',
|
|
220
|
+
'Removed by entering the shipping code in the Agent One field.',
|
|
221
|
+
),
|
|
222
|
+
'TECHNICAL FULL CONTAINER': def(
|
|
223
|
+
'TECHNICAL FULL CONTAINER',
|
|
224
|
+
'general',
|
|
225
|
+
'info',
|
|
226
|
+
false,
|
|
227
|
+
'Technical (full container)',
|
|
228
|
+
'Applied to every full import/export container to support OCR character recognition. Does not block movement.',
|
|
229
|
+
'No action required — informational only.',
|
|
230
|
+
),
|
|
231
|
+
'TECHNICAL LIVE REEFER': def(
|
|
232
|
+
'TECHNICAL LIVE REEFER',
|
|
233
|
+
'general',
|
|
234
|
+
'info',
|
|
235
|
+
false,
|
|
236
|
+
'Technical (live reefer)',
|
|
237
|
+
'Applied to every full reefer container to support OCR. Does not block operations.',
|
|
238
|
+
'No action required — informational only.',
|
|
239
|
+
),
|
|
240
|
+
// Baltic Hub's Navis docs spell this "REFFER" (double F); keep both spellings
|
|
241
|
+
// so the real terminal code resolves to the non-blocking definition instead of
|
|
242
|
+
// the blocking unknown-hold fallback.
|
|
243
|
+
'TECHNICAL LIVE REFFER': def(
|
|
244
|
+
'TECHNICAL LIVE REFFER',
|
|
245
|
+
'general',
|
|
246
|
+
'info',
|
|
247
|
+
false,
|
|
248
|
+
'Technical (live reefer)',
|
|
249
|
+
'Applied to every full reefer container to support OCR. Does not block operations.',
|
|
250
|
+
'No action required — informational only.',
|
|
251
|
+
),
|
|
252
|
+
'TERMINAL HOLD': def(
|
|
253
|
+
'TERMINAL HOLD',
|
|
254
|
+
'general',
|
|
255
|
+
'critical',
|
|
256
|
+
true,
|
|
257
|
+
'Terminal hold',
|
|
258
|
+
'Applied for a container irregularity (incorrect labelling, bent container beams, etc.).',
|
|
259
|
+
'Contact Baltic Hub Customer Service to resolve.',
|
|
260
|
+
),
|
|
261
|
+
'CZRM HOLD': def(
|
|
262
|
+
'CZRM HOLD',
|
|
263
|
+
'general',
|
|
264
|
+
'critical',
|
|
265
|
+
true,
|
|
266
|
+
'CZRM hold',
|
|
267
|
+
'Applied by the customs office.',
|
|
268
|
+
'Contact the customs office directly to have it lifted.',
|
|
269
|
+
),
|
|
270
|
+
'IZRX HOLD': def(
|
|
271
|
+
'IZRX HOLD',
|
|
272
|
+
'general',
|
|
273
|
+
'critical',
|
|
274
|
+
true,
|
|
275
|
+
'IZRX hold',
|
|
276
|
+
'Applied by the customs office.',
|
|
277
|
+
'Contact the customs office directly to have it lifted.',
|
|
278
|
+
),
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The canonical, normalized codes known to the catalogue, sorted by severity
|
|
283
|
+
* (most-urgent first) then alphabetically. Consumers that offer a constrained
|
|
284
|
+
* "pick a hold" UI (dropdown) or validate manual entry against the known set
|
|
285
|
+
* use this instead of hard-coding the list.
|
|
286
|
+
*/
|
|
287
|
+
export const HOLD_CODES: string[] = Object.keys(HOLD_CATALOGUE).sort((a, b) => {
|
|
288
|
+
const bySeverity = HOLD_SEVERITY_RANK[HOLD_CATALOGUE[b].severity] - HOLD_SEVERITY_RANK[HOLD_CATALOGUE[a].severity]
|
|
289
|
+
return bySeverity !== 0 ? bySeverity : a.localeCompare(b)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
/** True when `code` (normalized) is a known catalogue entry. */
|
|
293
|
+
export function isKnownHoldCode(code: string): boolean {
|
|
294
|
+
return Object.prototype.hasOwnProperty.call(HOLD_CATALOGUE, normalizeHoldCode(code))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Resolve a single raw impediment code to its explanation + severity. Unknown
|
|
299
|
+
* codes return a `warning`-severity fallback that echoes the cleaned code, so a
|
|
300
|
+
* never-before-seen hold still renders something meaningful and visible.
|
|
301
|
+
*/
|
|
302
|
+
export function getHoldInfo(rawCode: string): HoldInfo {
|
|
303
|
+
const key = normalizeHoldCode(rawCode)
|
|
304
|
+
const found = HOLD_CATALOGUE[key]
|
|
305
|
+
if (found) {
|
|
306
|
+
return { ...found, rawCode, unknown: false, i18nKey: holdI18nKey(key) }
|
|
307
|
+
}
|
|
308
|
+
// Unknown code: surface the raw hold itself as the label (just drop the `!`
|
|
309
|
+
// marker) so an operator still sees exactly what the terminal reported.
|
|
310
|
+
const rawLabel = rawCode.replace(/^!/, '').trim()
|
|
311
|
+
return {
|
|
312
|
+
code: key,
|
|
313
|
+
label: rawLabel || 'Unknown hold',
|
|
314
|
+
category: 'general',
|
|
315
|
+
severity: 'warning',
|
|
316
|
+
blocksMovement: true,
|
|
317
|
+
description: 'Unrecognised terminal hold. Refer to the terminal for details.',
|
|
318
|
+
resolution: 'Contact the terminal to determine how this hold is cleared.',
|
|
319
|
+
rawCode,
|
|
320
|
+
unknown: true,
|
|
321
|
+
i18nKey: 'unknown',
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Resolve a list of raw impediment codes, sorted most-urgent first. Empty,
|
|
327
|
+
* null, or all-whitespace entries are dropped.
|
|
328
|
+
*/
|
|
329
|
+
export function resolveHolds(rawCodes: readonly string[] | null | undefined): HoldInfo[] {
|
|
330
|
+
if (!rawCodes || rawCodes.length === 0) return []
|
|
331
|
+
return rawCodes
|
|
332
|
+
.filter((c) => c != null && c.trim() !== '')
|
|
333
|
+
.map(getHoldInfo)
|
|
334
|
+
.sort((a, b) => HOLD_SEVERITY_RANK[b.severity] - HOLD_SEVERITY_RANK[a.severity])
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The highest severity among a list of raw codes, or `null` when there are none. */
|
|
338
|
+
export function highestHoldSeverity(
|
|
339
|
+
rawCodes: readonly string[] | null | undefined,
|
|
340
|
+
): HoldSeverity | null {
|
|
341
|
+
const holds = resolveHolds(rawCodes)
|
|
342
|
+
if (holds.length === 0) return null
|
|
343
|
+
return holds[0].severity // already sorted most-urgent first
|
|
344
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin logging wrapper. Mirrors the shipment-tracking logger shape but stays
|
|
3
|
+
* dependency-light (falls back to console). The host app's logger can be wired
|
|
4
|
+
* in later via DI if needed.
|
|
5
|
+
*/
|
|
6
|
+
const PREFIX = '[terminal-tracking]'
|
|
7
|
+
|
|
8
|
+
export const terminalLogger = {
|
|
9
|
+
debug(message: string, meta?: Record<string, unknown>) {
|
|
10
|
+
if (process.env.TERMINAL_TRACKING_DEBUG) console.debug(PREFIX, message, meta ?? '')
|
|
11
|
+
},
|
|
12
|
+
info(message: string, meta?: Record<string, unknown>) {
|
|
13
|
+
console.log(PREFIX, message, meta ?? '')
|
|
14
|
+
},
|
|
15
|
+
warn(message: string, meta?: Record<string, unknown>) {
|
|
16
|
+
console.warn(PREFIX, message, meta ?? '')
|
|
17
|
+
},
|
|
18
|
+
error(message: string, meta?: Record<string, unknown>) {
|
|
19
|
+
console.error(PREFIX, message, meta ?? '')
|
|
20
|
+
},
|
|
21
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { terminalLogger } from './logger'
|
|
2
|
+
|
|
3
|
+
export type RateLimitResult = {
|
|
4
|
+
allowed: boolean
|
|
5
|
+
retryAfterSeconds?: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type CacheService = {
|
|
9
|
+
get(key: string): Promise<string | null>
|
|
10
|
+
set(key: string, value: string, ttlSeconds?: number): Promise<void>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Token-bucket rate limiter backed by a cache.
|
|
15
|
+
* Key: `tt:ratelimit:{tenantId}:{terminalCode}`
|
|
16
|
+
*/
|
|
17
|
+
export async function checkRateLimit(
|
|
18
|
+
cache: CacheService,
|
|
19
|
+
tenantId: string,
|
|
20
|
+
terminalCode: string,
|
|
21
|
+
maxRequests: number,
|
|
22
|
+
windowSeconds: number,
|
|
23
|
+
): Promise<RateLimitResult> {
|
|
24
|
+
const key = `tt:ratelimit:${tenantId}:${terminalCode}`
|
|
25
|
+
const now = Date.now()
|
|
26
|
+
|
|
27
|
+
const raw = await cache.get(key)
|
|
28
|
+
let tokens = maxRequests
|
|
29
|
+
let lastRefill = now
|
|
30
|
+
|
|
31
|
+
if (raw) {
|
|
32
|
+
try {
|
|
33
|
+
const state = JSON.parse(raw) as { tokens: number; lastRefill: number }
|
|
34
|
+
const elapsed = (now - state.lastRefill) / 1000
|
|
35
|
+
const refilled = Math.floor((elapsed / windowSeconds) * maxRequests)
|
|
36
|
+
tokens = Math.min(maxRequests, state.tokens + refilled)
|
|
37
|
+
lastRefill = refilled > 0 ? now : state.lastRefill
|
|
38
|
+
} catch {
|
|
39
|
+
// Corrupted cache entry, reset
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (tokens <= 0) {
|
|
44
|
+
const retryAfterSeconds = Math.ceil(windowSeconds / maxRequests)
|
|
45
|
+
terminalLogger.debug('Rate limited', { terminalCode, tenantId, retryAfterSeconds })
|
|
46
|
+
return { allowed: false, retryAfterSeconds }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
tokens -= 1
|
|
50
|
+
await cache.set(key, JSON.stringify({ tokens, lastRefill }), windowSeconds * 2)
|
|
51
|
+
return { allowed: true }
|
|
52
|
+
}
|