@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.
Files changed (236) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/dist/generated/entities/terminal_config/index.js +53 -0
  4. package/dist/generated/entities/terminal_config/index.js.map +7 -0
  5. package/dist/generated/entities/terminal_event/index.js +59 -0
  6. package/dist/generated/entities/terminal_event/index.js.map +7 -0
  7. package/dist/generated/entities/terminal_tracking_job/index.js +37 -0
  8. package/dist/generated/entities/terminal_tracking_job/index.js.map +7 -0
  9. package/dist/generated/entities/terminal_vessel_visit/index.js +41 -0
  10. package/dist/generated/entities/terminal_vessel_visit/index.js.map +7 -0
  11. package/dist/generated/entities.ids.generated.js +26 -0
  12. package/dist/generated/entities.ids.generated.js.map +7 -0
  13. package/dist/index.js +20 -0
  14. package/dist/index.js.map +7 -0
  15. package/dist/modules/terminal_tracking/acl.js +14 -0
  16. package/dist/modules/terminal_tracking/acl.js.map +7 -0
  17. package/dist/modules/terminal_tracking/api/openapi.js +27 -0
  18. package/dist/modules/terminal_tracking/api/openapi.js.map +7 -0
  19. package/dist/modules/terminal_tracking/api/terminal-configs/route.js +140 -0
  20. package/dist/modules/terminal_tracking/api/terminal-configs/route.js.map +7 -0
  21. package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js +58 -0
  22. package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js.map +7 -0
  23. package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js +109 -0
  24. package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js.map +7 -0
  25. package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js +58 -0
  26. package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js.map +7 -0
  27. package/dist/modules/terminal_tracking/api/tracking-jobs/route.js +189 -0
  28. package/dist/modules/terminal_tracking/api/tracking-jobs/route.js.map +7 -0
  29. package/dist/modules/terminal_tracking/api/utils.js +12 -0
  30. package/dist/modules/terminal_tracking/api/utils.js.map +7 -0
  31. package/dist/modules/terminal_tracking/api/vessel-visits/route.js +83 -0
  32. package/dist/modules/terminal_tracking/api/vessel-visits/route.js.map +7 -0
  33. package/dist/modules/terminal_tracking/backend/terminal-configs/page.js +669 -0
  34. package/dist/modules/terminal_tracking/backend/terminal-configs/page.js.map +7 -0
  35. package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js +19 -0
  36. package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js.map +7 -0
  37. package/dist/modules/terminal_tracking/backend/terminal-containers/page.js +275 -0
  38. package/dist/modules/terminal_tracking/backend/terminal-containers/page.js.map +7 -0
  39. package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js +19 -0
  40. package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js.map +7 -0
  41. package/dist/modules/terminal_tracking/ce.js +35 -0
  42. package/dist/modules/terminal_tracking/ce.js.map +7 -0
  43. package/dist/modules/terminal_tracking/commands/index.js +4 -0
  44. package/dist/modules/terminal_tracking/commands/index.js.map +7 -0
  45. package/dist/modules/terminal_tracking/commands/terminal-configs.js +107 -0
  46. package/dist/modules/terminal_tracking/commands/terminal-configs.js.map +7 -0
  47. package/dist/modules/terminal_tracking/commands/terminal-tracking.js +27 -0
  48. package/dist/modules/terminal_tracking/commands/terminal-tracking.js.map +7 -0
  49. package/dist/modules/terminal_tracking/commands/tracking-jobs.js +56 -0
  50. package/dist/modules/terminal_tracking/commands/tracking-jobs.js.map +7 -0
  51. package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js +371 -0
  52. package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js.map +7 -0
  53. package/dist/modules/terminal_tracking/data/entities.js +337 -0
  54. package/dist/modules/terminal_tracking/data/entities.js.map +7 -0
  55. package/dist/modules/terminal_tracking/data/validators.js +98 -0
  56. package/dist/modules/terminal_tracking/data/validators.js.map +7 -0
  57. package/dist/modules/terminal_tracking/di.js +39 -0
  58. package/dist/modules/terminal_tracking/di.js.map +7 -0
  59. package/dist/modules/terminal_tracking/encryption.js +14 -0
  60. package/dist/modules/terminal_tracking/encryption.js.map +7 -0
  61. package/dist/modules/terminal_tracking/events.js +45 -0
  62. package/dist/modules/terminal_tracking/events.js.map +7 -0
  63. package/dist/modules/terminal_tracking/i18n/de.json +175 -0
  64. package/dist/modules/terminal_tracking/i18n/en.json +178 -0
  65. package/dist/modules/terminal_tracking/i18n/es.json +175 -0
  66. package/dist/modules/terminal_tracking/i18n/pl.json +206 -0
  67. package/dist/modules/terminal_tracking/index.js +15 -0
  68. package/dist/modules/terminal_tracking/index.js.map +7 -0
  69. package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js +164 -0
  70. package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js.map +7 -0
  71. package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js +23 -0
  72. package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js.map +7 -0
  73. package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js +146 -0
  74. package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js.map +7 -0
  75. package/dist/modules/terminal_tracking/lib/adapters/bct/types.js +1 -0
  76. package/dist/modules/terminal_tracking/lib/adapters/bct/types.js.map +7 -0
  77. package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js +106 -0
  78. package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js.map +7 -0
  79. package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js +120 -0
  80. package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js.map +7 -0
  81. package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js +77 -0
  82. package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js.map +7 -0
  83. package/dist/modules/terminal_tracking/lib/adapters/gct/types.js +1 -0
  84. package/dist/modules/terminal_tracking/lib/adapters/gct/types.js.map +7 -0
  85. package/dist/modules/terminal_tracking/lib/adapters/index.js +12 -0
  86. package/dist/modules/terminal_tracking/lib/adapters/index.js.map +7 -0
  87. package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js +121 -0
  88. package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js.map +7 -0
  89. package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js +81 -0
  90. package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js.map +7 -0
  91. package/dist/modules/terminal_tracking/lib/adapters/n4/http.js +66 -0
  92. package/dist/modules/terminal_tracking/lib/adapters/n4/http.js.map +7 -0
  93. package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js +95 -0
  94. package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js.map +7 -0
  95. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js +70 -0
  96. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js.map +7 -0
  97. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js +44 -0
  98. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js.map +7 -0
  99. package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js +15 -0
  100. package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js.map +7 -0
  101. package/dist/modules/terminal_tracking/lib/adapters/n4/types.js +1 -0
  102. package/dist/modules/terminal_tracking/lib/adapters/n4/types.js.map +7 -0
  103. package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js +27 -0
  104. package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js.map +7 -0
  105. package/dist/modules/terminal_tracking/lib/availability.js +40 -0
  106. package/dist/modules/terminal_tracking/lib/availability.js.map +7 -0
  107. package/dist/modules/terminal_tracking/lib/ensure-schedule.js +51 -0
  108. package/dist/modules/terminal_tracking/lib/ensure-schedule.js.map +7 -0
  109. package/dist/modules/terminal_tracking/lib/holds.js +240 -0
  110. package/dist/modules/terminal_tracking/lib/holds.js.map +7 -0
  111. package/dist/modules/terminal_tracking/lib/logger.js +19 -0
  112. package/dist/modules/terminal_tracking/lib/logger.js.map +7 -0
  113. package/dist/modules/terminal_tracking/lib/rate-limiter.js +30 -0
  114. package/dist/modules/terminal_tracking/lib/rate-limiter.js.map +7 -0
  115. package/dist/modules/terminal_tracking/lib/stops.js +48 -0
  116. package/dist/modules/terminal_tracking/lib/stops.js.map +7 -0
  117. package/dist/modules/terminal_tracking/lib/terminal-adapter.js +1 -0
  118. package/dist/modules/terminal_tracking/lib/terminal-adapter.js.map +7 -0
  119. package/dist/modules/terminal_tracking/lib/totp.js +41 -0
  120. package/dist/modules/terminal_tracking/lib/totp.js.map +7 -0
  121. package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js +101 -0
  122. package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js.map +7 -0
  123. package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js +39 -0
  124. package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js.map +7 -0
  125. package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js +13 -0
  126. package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js.map +7 -0
  127. package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js +15 -0
  128. package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js.map +7 -0
  129. package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js +13 -0
  130. package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js.map +7 -0
  131. package/dist/modules/terminal_tracking/services/terminalMatcherService.js +61 -0
  132. package/dist/modules/terminal_tracking/services/terminalMatcherService.js.map +7 -0
  133. package/dist/modules/terminal_tracking/services/terminalRegistry.js +24 -0
  134. package/dist/modules/terminal_tracking/services/terminalRegistry.js.map +7 -0
  135. package/dist/modules/terminal_tracking/services/terminalTrackingService.js +687 -0
  136. package/dist/modules/terminal_tracking/services/terminalTrackingService.js.map +7 -0
  137. package/dist/modules/terminal_tracking/setup.js +23 -0
  138. package/dist/modules/terminal_tracking/setup.js.map +7 -0
  139. package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js +22 -0
  140. package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js.map +7 -0
  141. package/generated/entities/terminal_config/index.ts +36 -0
  142. package/generated/entities/terminal_event/index.ts +39 -0
  143. package/generated/entities/terminal_tracking_job/index.ts +28 -0
  144. package/generated/entities/terminal_vessel_visit/index.ts +30 -0
  145. package/generated/entities.ids.generated.ts +52 -0
  146. package/package.json +80 -0
  147. package/src/index.ts +44 -0
  148. package/src/modules/terminal_tracking/__integration__/TC-TRACK-301-gct-config-test-connection.spec.ts +66 -0
  149. package/src/modules/terminal_tracking/__integration__/TC-TRACK-317-bct-config-test-connection.spec.ts +68 -0
  150. package/src/modules/terminal_tracking/__integration__/helpers.ts +148 -0
  151. package/src/modules/terminal_tracking/__integration__/meta.ts +4 -0
  152. package/src/modules/terminal_tracking/acl.ts +10 -0
  153. package/src/modules/terminal_tracking/api/openapi.ts +28 -0
  154. package/src/modules/terminal_tracking/api/terminal-configs/route.ts +140 -0
  155. package/src/modules/terminal_tracking/api/terminal-configs/test/route.ts +64 -0
  156. package/src/modules/terminal_tracking/api/tracking-jobs/details/route.ts +116 -0
  157. package/src/modules/terminal_tracking/api/tracking-jobs/poll/route.ts +64 -0
  158. package/src/modules/terminal_tracking/api/tracking-jobs/route.ts +243 -0
  159. package/src/modules/terminal_tracking/api/utils.ts +10 -0
  160. package/src/modules/terminal_tracking/api/vessel-visits/route.ts +95 -0
  161. package/src/modules/terminal_tracking/backend/terminal-configs/page.meta.ts +16 -0
  162. package/src/modules/terminal_tracking/backend/terminal-configs/page.tsx +728 -0
  163. package/src/modules/terminal_tracking/backend/terminal-containers/page.meta.ts +16 -0
  164. package/src/modules/terminal_tracking/backend/terminal-containers/page.tsx +328 -0
  165. package/src/modules/terminal_tracking/ce.ts +31 -0
  166. package/src/modules/terminal_tracking/commands/index.ts +3 -0
  167. package/src/modules/terminal_tracking/commands/terminal-configs.ts +132 -0
  168. package/src/modules/terminal_tracking/commands/terminal-tracking.ts +34 -0
  169. package/src/modules/terminal_tracking/commands/tracking-jobs.ts +69 -0
  170. package/src/modules/terminal_tracking/components/ContainerDetailsDrawer.tsx +540 -0
  171. package/src/modules/terminal_tracking/data/entities.ts +386 -0
  172. package/src/modules/terminal_tracking/data/validators.ts +113 -0
  173. package/src/modules/terminal_tracking/di.ts +46 -0
  174. package/src/modules/terminal_tracking/encryption.ts +12 -0
  175. package/src/modules/terminal_tracking/events.ts +48 -0
  176. package/src/modules/terminal_tracking/i18n/de.json +175 -0
  177. package/src/modules/terminal_tracking/i18n/en.json +178 -0
  178. package/src/modules/terminal_tracking/i18n/es.json +175 -0
  179. package/src/modules/terminal_tracking/i18n/pl.json +206 -0
  180. package/src/modules/terminal_tracking/index.ts +13 -0
  181. package/src/modules/terminal_tracking/lib/__tests__/availability.test.ts +136 -0
  182. package/src/modules/terminal_tracking/lib/__tests__/ensure-schedule.test.ts +67 -0
  183. package/src/modules/terminal_tracking/lib/__tests__/holds.test.ts +73 -0
  184. package/src/modules/terminal_tracking/lib/__tests__/stops.test.ts +80 -0
  185. package/src/modules/terminal_tracking/lib/__tests__/totp.test.ts +38 -0
  186. package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/adapter.test.ts +196 -0
  187. package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/incos-semantics.test.ts +303 -0
  188. package/src/modules/terminal_tracking/lib/adapters/bct/adapter.ts +219 -0
  189. package/src/modules/terminal_tracking/lib/adapters/bct/auth/basic.ts +34 -0
  190. package/src/modules/terminal_tracking/lib/adapters/bct/incos-semantics.ts +291 -0
  191. package/src/modules/terminal_tracking/lib/adapters/bct/types.ts +113 -0
  192. package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/adapter.test.ts +238 -0
  193. package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/gct-semantics.test.ts +133 -0
  194. package/src/modules/terminal_tracking/lib/adapters/gct/adapter.ts +152 -0
  195. package/src/modules/terminal_tracking/lib/adapters/gct/auth/token.ts +219 -0
  196. package/src/modules/terminal_tracking/lib/adapters/gct/gct-semantics.ts +136 -0
  197. package/src/modules/terminal_tracking/lib/adapters/gct/types.ts +58 -0
  198. package/src/modules/terminal_tracking/lib/adapters/index.ts +16 -0
  199. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/adapter-proxy.test.ts +69 -0
  200. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/data-table.test.ts +179 -0
  201. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/http.test.ts +98 -0
  202. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/n4-semantics.test.ts +140 -0
  203. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/proxy.test.ts +19 -0
  204. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/vessel-data-table.test.ts +67 -0
  205. package/src/modules/terminal_tracking/lib/adapters/n4/adapter.ts +163 -0
  206. package/src/modules/terminal_tracking/lib/adapters/n4/auth/ropc.ts +104 -0
  207. package/src/modules/terminal_tracking/lib/adapters/n4/http.ts +127 -0
  208. package/src/modules/terminal_tracking/lib/adapters/n4/n4-semantics.ts +156 -0
  209. package/src/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.ts +102 -0
  210. package/src/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.ts +60 -0
  211. package/src/modules/terminal_tracking/lib/adapters/n4/proxy.ts +24 -0
  212. package/src/modules/terminal_tracking/lib/adapters/n4/types.ts +56 -0
  213. package/src/modules/terminal_tracking/lib/adapters/zoned-time.ts +65 -0
  214. package/src/modules/terminal_tracking/lib/availability.ts +119 -0
  215. package/src/modules/terminal_tracking/lib/ensure-schedule.ts +83 -0
  216. package/src/modules/terminal_tracking/lib/holds.ts +344 -0
  217. package/src/modules/terminal_tracking/lib/logger.ts +21 -0
  218. package/src/modules/terminal_tracking/lib/rate-limiter.ts +52 -0
  219. package/src/modules/terminal_tracking/lib/stops.ts +124 -0
  220. package/src/modules/terminal_tracking/lib/terminal-adapter.ts +136 -0
  221. package/src/modules/terminal_tracking/lib/totp.ts +63 -0
  222. package/src/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.ts +104 -0
  223. package/src/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.ts +40 -0
  224. package/src/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.ts +11 -0
  225. package/src/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.ts +13 -0
  226. package/src/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.ts +16 -0
  227. package/src/modules/terminal_tracking/services/__tests__/availabilityEmit.test.ts +277 -0
  228. package/src/modules/terminal_tracking/services/__tests__/createJob.test.ts +59 -0
  229. package/src/modules/terminal_tracking/services/__tests__/pollAllBatch.test.ts +147 -0
  230. package/src/modules/terminal_tracking/services/__tests__/terminalMatcher.test.ts +70 -0
  231. package/src/modules/terminal_tracking/services/__tests__/vesselResolution.test.ts +453 -0
  232. package/src/modules/terminal_tracking/services/terminalMatcherService.ts +97 -0
  233. package/src/modules/terminal_tracking/services/terminalRegistry.ts +30 -0
  234. package/src/modules/terminal_tracking/services/terminalTrackingService.ts +912 -0
  235. package/src/modules/terminal_tracking/setup.ts +24 -0
  236. 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