@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,453 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ // Mock the rate limiter and the decryption-aware config lookup so we can drive
4
+ // the orchestration without a DB. Hoisted so the vi.mock factories can see them.
5
+ const { checkRateLimit, findOneWithDecryption } = vi.hoisted(() => ({
6
+ checkRateLimit: vi.fn(),
7
+ findOneWithDecryption: vi.fn(),
8
+ }))
9
+ vi.mock('../../lib/rate-limiter', () => ({ checkRateLimit }))
10
+ vi.mock('@open-mercato/shared/lib/encryption/find', () => ({ findOneWithDecryption }))
11
+
12
+ import { TerminalTrackingService } from '../terminalTrackingService'
13
+
14
+ const JOB = {
15
+ id: 'job-1',
16
+ organizationId: 'org-1',
17
+ tenantId: 'ten-1',
18
+ terminalCode: 'bct',
19
+ containerNumber: 'GCXU5598460',
20
+ schedule: null,
21
+ status: 'active',
22
+ }
23
+
24
+ const CONFIG = {
25
+ terminalCode: 'bct',
26
+ adapterType: 'n4',
27
+ displayName: 'Baltic Hub',
28
+ baseUrl: 'https://api2.baltichub.com/V1/',
29
+ endpoints: { unit: '/unit', vessel: '/VESSEL' },
30
+ authType: 'oauth2_password',
31
+ tokenUrl: null,
32
+ scope: null,
33
+ clientId: null,
34
+ authConfig: null,
35
+ rateLimitRequests: 200,
36
+ rateLimitWindowSeconds: 60,
37
+ unlocode: 'PLGDN',
38
+ smdgCodes: ['GDNBCT'],
39
+ bicCodes: [],
40
+ vesselCacheTtlSeconds: null,
41
+ }
42
+
43
+ // One active import leg (DISC): vessel on the inbound side (visitRefIn); O/B is a truck.
44
+ const FETCHED_EVENT = {
45
+ source: 'terminal',
46
+ sourceEventId: 'bct:11341499051:DISC',
47
+ eventType: 'EQUIPMENT',
48
+ eventCode: 'DISC',
49
+ eventClassifierCode: 'ACT',
50
+ eventDateTime: new Date('2026-06-03T01:58:00Z'),
51
+ containerNumber: 'GCXU5598460',
52
+ ufvGkey: '11341499051',
53
+ visitRefIn: '26GIRO622',
54
+ visitRefOut: 'POS75955',
55
+ rawData: { Category: 'Import' }, // import leg → vessel on I/B
56
+ }
57
+
58
+ const FETCHED_VISIT = {
59
+ visitRef: '26GIRO622',
60
+ vesselName: 'MAERSK NUBA',
61
+ ibVoyage: '623N',
62
+ obVoyage: '625S',
63
+ line: 'MAE',
64
+ phase: 'Inbound',
65
+ eta: new Date('2026-06-14T10:00:00Z'),
66
+ etd: new Date('2026-06-15T18:00:00Z'),
67
+ ata: null,
68
+ atd: null,
69
+ beginReceive: new Date('2026-06-07T10:00:00Z'),
70
+ dryCutoff: null,
71
+ rawData: { Line: 'MAE' },
72
+ }
73
+
74
+ // A stored TerminalEvent whose container signature matches FETCHED_EVENT once
75
+ // enriched (vessel on the I/B side). Override fields to force a change.
76
+ function storedEvent(overrides: Record<string, any> = {}) {
77
+ return {
78
+ id: 'evt-1',
79
+ job: { id: 'job-1' },
80
+ tenantId: 'ten-1',
81
+ organizationId: 'org-1',
82
+ source: 'terminal',
83
+ sourceEventId: 'bct:11341499051:DISC',
84
+ eventType: 'EQUIPMENT',
85
+ eventCode: 'DISC',
86
+ eventClassifierCode: 'ACT',
87
+ eventDateTime: new Date('2026-06-03T01:58:00Z'),
88
+ containerNumber: 'GCXU5598460',
89
+ ufvGkey: '11341499051',
90
+ transitState: null,
91
+ visitState: null,
92
+ facilityCode: null,
93
+ facilityCodeListProvider: null,
94
+ unlocode: null,
95
+ visitRefIn: '26GIRO622',
96
+ visitRefOut: 'POS75955',
97
+ vesselName: 'MAERSK NUBA',
98
+ voyageNumber: '623N',
99
+ modeOfTransport: null,
100
+ seals: null,
101
+ vgmWeightKg: null,
102
+ impediments: null,
103
+ rawData: null,
104
+ ...overrides,
105
+ }
106
+ }
107
+
108
+ function makeHarness(opts: { vesselRows?: any[]; events?: any[]; existingEvents?: any[]; job?: any } = {}) {
109
+ // Model /VESSEL: real vessel visits (NN<letters>…) return data; gate/truck
110
+ // refs return null (empty envelope).
111
+ const fetchVesselVisit = vi.fn(async ({ visitRef }: { visitRef: string }) =>
112
+ /^\d{2}[A-Z]/.test(visitRef) ? { ...FETCHED_VISIT, visitRef } : null,
113
+ )
114
+ const events = opts.events ?? [{ ...FETCHED_EVENT }]
115
+ const adapter = {
116
+ adapterType: 'n4',
117
+ defaultEndpoints: { unit: '/unit', vessel: '/VESSEL' },
118
+ fetchEvents: vi.fn(async () => ({ containerNumber: JOB.containerNumber, events })),
119
+ fetchVesselVisit,
120
+ testConnection: vi.fn(),
121
+ }
122
+
123
+ const job: any = { ...JOB, ...(opts.job ?? {}) }
124
+ const em = {
125
+ fork() {
126
+ return this
127
+ },
128
+ async findOne() {
129
+ return job
130
+ },
131
+ async find(_entity: any, where: any) {
132
+ // Vessel-visit cache lookup carries a `visitRef` filter; the persistEvents
133
+ // existing-events lookup carries `sourceEventId`.
134
+ if (where && 'visitRef' in where) return opts.vesselRows ?? []
135
+ if (where && 'sourceEventId' in where) return opts.existingEvents ?? []
136
+ return []
137
+ },
138
+ create(_entity: any, data: any) {
139
+ return { ...data }
140
+ },
141
+ async flush() {},
142
+ }
143
+
144
+ const eventBus = { emit: vi.fn(async () => {}) }
145
+ const terminalRegistry = { get: () => adapter }
146
+ const cacheService = { get: async () => null, set: async () => {} }
147
+
148
+ const service = new TerminalTrackingService({
149
+ em: () => em as never,
150
+ eventBus: eventBus as never,
151
+ terminalRegistry: terminalRegistry as never,
152
+ cacheService: cacheService as never,
153
+ })
154
+
155
+ return { service, adapter, eventBus, fetchVesselVisit, job }
156
+ }
157
+
158
+ function createdPayload(eventBus: { emit: ReturnType<typeof vi.fn> }) {
159
+ const call = eventBus.emit.mock.calls.find((c) => c[0] === 'terminal_tracking.terminal_event.created')
160
+ return call?.[1] as any
161
+ }
162
+
163
+ function emitted(eventBus: { emit: ReturnType<typeof vi.fn> }, id: string) {
164
+ return eventBus.emit.mock.calls.filter((c) => c[0] === id)
165
+ }
166
+ function updatedPayload(eventBus: { emit: ReturnType<typeof vi.fn> }) {
167
+ return emitted(eventBus, 'terminal_tracking.terminal_event.updated')[0]?.[1] as any
168
+ }
169
+
170
+ beforeEach(() => {
171
+ vi.clearAllMocks()
172
+ checkRateLimit.mockResolvedValue({ allowed: true })
173
+ findOneWithDecryption.mockResolvedValue({ ...CONFIG })
174
+ })
175
+
176
+ describe('vessel enrichment in pollJob', () => {
177
+ it('fetches and nests the vessel visit, with the direction-correct voyage', async () => {
178
+ const { service, fetchVesselVisit, eventBus } = makeHarness({ vesselRows: [] })
179
+
180
+ const res = await service.pollJob('job-1')
181
+ expect(res.newEvents).toBe(1)
182
+ expect(fetchVesselVisit).toHaveBeenCalledTimes(1)
183
+
184
+ const payload = createdPayload(eventBus)
185
+ expect(payload.vesselName).toBe('MAERSK NUBA')
186
+ expect(payload.voyageNumber).toBe('623N') // import leg → inbound voyage
187
+ expect(payload.vesselVisit).toMatchObject({
188
+ visitRef: '26GIRO622',
189
+ line: 'MAE',
190
+ phase: 'Inbound',
191
+ eta: '2026-06-14T10:00:00.000Z',
192
+ etd: '2026-06-15T18:00:00.000Z',
193
+ ata: null,
194
+ beginReceive: '2026-06-07T10:00:00.000Z',
195
+ dryCutoff: null,
196
+ })
197
+ })
198
+
199
+ it('uses the O/B vessel for an export leg', async () => {
200
+ // Export leg: vessel is on the outbound side (26OOFX010); the I/B is a gate
201
+ // ref that must NOT be queried.
202
+ const event = {
203
+ ...FETCHED_EVENT,
204
+ sourceEventId: 'bct:99:DISC',
205
+ eventCode: 'DISC',
206
+ visitRefIn: 'GDA56850',
207
+ visitRefOut: '26OOFX010',
208
+ rawData: { Category: 'Export' },
209
+ }
210
+ const { service, fetchVesselVisit, eventBus } = makeHarness({ vesselRows: [], events: [event] })
211
+
212
+ await service.pollJob('job-1')
213
+
214
+ // Only the O/B side is queried (one call), not the I/B gate ref.
215
+ expect(fetchVesselVisit).toHaveBeenCalledTimes(1)
216
+ expect(fetchVesselVisit.mock.calls[0][0].visitRef).toBe('26OOFX010')
217
+ const payload = createdPayload(eventBus)
218
+ expect(payload.vesselVisit.visitRef).toBe('26OOFX010')
219
+ expect(payload.voyageNumber).toBe('625S') // outbound voyage
220
+ })
221
+
222
+ it('serves a fresh cache row without calling the API', async () => {
223
+ const freshRow = {
224
+ visitRef: '26GIRO622',
225
+ terminalCode: 'bct',
226
+ vesselName: 'CACHED VESSEL',
227
+ ibVoyage: '111N',
228
+ obVoyage: '222S',
229
+ line: 'MSC',
230
+ phase: 'Inbound',
231
+ eta: new Date('2026-07-01T00:00:00Z'),
232
+ etd: null,
233
+ ata: null,
234
+ atd: null,
235
+ beginReceive: null,
236
+ dryCutoff: null,
237
+ rawData: null,
238
+ updatedAt: new Date(), // now → fresh
239
+ }
240
+ const { service, fetchVesselVisit, eventBus } = makeHarness({ vesselRows: [freshRow] })
241
+
242
+ await service.pollJob('job-1')
243
+ expect(fetchVesselVisit).not.toHaveBeenCalled()
244
+
245
+ const payload = createdPayload(eventBus)
246
+ expect(payload.vesselName).toBe('CACHED VESSEL')
247
+ expect(payload.voyageNumber).toBe('111N')
248
+ expect(payload.vesselVisit.visitRef).toBe('26GIRO622')
249
+ })
250
+
251
+ it('refetches a stale cache row', async () => {
252
+ const staleRow = {
253
+ visitRef: '26GIRO622',
254
+ terminalCode: 'bct',
255
+ vesselName: 'OLD',
256
+ ibVoyage: '000N',
257
+ updatedAt: new Date(Date.now() - 2 * 3600 * 1000), // 2h ago > 1h TTL
258
+ }
259
+ const { service, fetchVesselVisit, eventBus } = makeHarness({ vesselRows: [staleRow] })
260
+
261
+ await service.pollJob('job-1')
262
+ expect(fetchVesselVisit).toHaveBeenCalledTimes(1)
263
+ expect(createdPayload(eventBus).vesselName).toBe('MAERSK NUBA')
264
+ })
265
+
266
+ it('emits terminal_event.updated when container data changes (not created)', async () => {
267
+ // Existing event has an old impediment; the fresh poll has none → container
268
+ // changed. Fresh vessel cache → no vessel fetch, no vessel change.
269
+ const freshRow = {
270
+ visitRef: '26GIRO622',
271
+ terminalCode: 'bct',
272
+ vesselName: 'MAERSK NUBA',
273
+ ibVoyage: '623N',
274
+ obVoyage: '625S',
275
+ line: 'MAE',
276
+ phase: 'Inbound',
277
+ eta: new Date('2026-06-14T10:00:00Z'),
278
+ etd: new Date('2026-06-15T18:00:00Z'),
279
+ ata: null,
280
+ atd: null,
281
+ beginReceive: new Date('2026-06-07T10:00:00Z'),
282
+ dryCutoff: null,
283
+ rawData: null,
284
+ updatedAt: new Date(),
285
+ }
286
+ const { service, fetchVesselVisit, eventBus } = makeHarness({
287
+ vesselRows: [freshRow],
288
+ existingEvents: [storedEvent({ impediments: ['!CUSTOMS EXPORT HOLD'] })],
289
+ })
290
+
291
+ const res = await service.pollJob('job-1')
292
+
293
+ expect(fetchVesselVisit).not.toHaveBeenCalled() // fresh cache
294
+ expect(res).toEqual({ newEvents: 0, updatedEvents: 1 })
295
+ expect(emitted(eventBus, 'terminal_tracking.terminal_event.created')).toHaveLength(0)
296
+ expect(emitted(eventBus, 'terminal_tracking.terminal_event.updated')).toHaveLength(1)
297
+ expect(updatedPayload(eventBus).impediments).toBeNull() // new state persisted
298
+ })
299
+
300
+ it('emits terminal_event.updated when vessel data (ETA) changes', async () => {
301
+ // Container unchanged; the cached vessel row is stale with a different ETA →
302
+ // refetched, ETA differs → vessel changed.
303
+ const staleRow = {
304
+ visitRef: '26GIRO622',
305
+ terminalCode: 'bct',
306
+ vesselName: 'MAERSK NUBA',
307
+ ibVoyage: '623N',
308
+ obVoyage: '625S',
309
+ line: 'MAE',
310
+ phase: 'Inbound',
311
+ eta: new Date('2026-01-01T00:00:00Z'), // old ETA
312
+ etd: new Date('2026-06-15T18:00:00Z'),
313
+ ata: null,
314
+ atd: null,
315
+ beginReceive: new Date('2026-06-07T10:00:00Z'),
316
+ dryCutoff: null,
317
+ rawData: null,
318
+ updatedAt: new Date(Date.now() - 2 * 3600 * 1000), // stale
319
+ }
320
+ const { service, fetchVesselVisit, eventBus } = makeHarness({
321
+ vesselRows: [staleRow],
322
+ existingEvents: [storedEvent()],
323
+ })
324
+
325
+ const res = await service.pollJob('job-1')
326
+
327
+ expect(fetchVesselVisit).toHaveBeenCalledTimes(1)
328
+ expect(res).toEqual({ newEvents: 0, updatedEvents: 1 })
329
+ expect(emitted(eventBus, 'terminal_tracking.terminal_event.updated')).toHaveLength(1)
330
+ expect(updatedPayload(eventBus).vesselVisit.eta).toBe('2026-06-14T10:00:00.000Z') // new ETA
331
+ })
332
+
333
+ it('does not emit when nothing changed', async () => {
334
+ const freshRow = {
335
+ visitRef: '26GIRO622',
336
+ terminalCode: 'bct',
337
+ vesselName: 'MAERSK NUBA',
338
+ ibVoyage: '623N',
339
+ obVoyage: '625S',
340
+ line: 'MAE',
341
+ phase: 'Inbound',
342
+ eta: new Date('2026-06-14T10:00:00Z'),
343
+ etd: new Date('2026-06-15T18:00:00Z'),
344
+ ata: null,
345
+ atd: null,
346
+ beginReceive: new Date('2026-06-07T10:00:00Z'),
347
+ dryCutoff: null,
348
+ rawData: null,
349
+ updatedAt: new Date(),
350
+ }
351
+ const { service, eventBus } = makeHarness({
352
+ vesselRows: [freshRow],
353
+ existingEvents: [storedEvent()],
354
+ })
355
+
356
+ const res = await service.pollJob('job-1')
357
+
358
+ expect(res).toEqual({ newEvents: 0, updatedEvents: 0 })
359
+ expect(emitted(eventBus, 'terminal_tracking.terminal_event.updated')).toHaveLength(0)
360
+ expect(emitted(eventBus, 'terminal_tracking.terminal_event.created')).toHaveLength(0)
361
+ })
362
+
363
+ it('degrades gracefully when rate-limited: emits without vessel data', async () => {
364
+ // /unit allowed, /VESSEL denied.
365
+ checkRateLimit
366
+ .mockResolvedValueOnce({ allowed: true })
367
+ .mockResolvedValueOnce({ allowed: false, retryAfterSeconds: 1 })
368
+ const { service, fetchVesselVisit, eventBus } = makeHarness({ vesselRows: [] })
369
+
370
+ const res = await service.pollJob('job-1')
371
+ expect(res.newEvents).toBe(1)
372
+ expect(fetchVesselVisit).not.toHaveBeenCalled()
373
+
374
+ const payload = createdPayload(eventBus)
375
+ expect(payload.vesselVisit).toBeNull()
376
+ expect(payload.vesselName).toBeNull()
377
+ })
378
+ })
379
+
380
+ const departedEvent = (overrides: Record<string, any> = {}) => ({
381
+ ...FETCHED_EVENT,
382
+ sourceEventId: 'bct:dep:DEPA',
383
+ eventType: 'TRANSPORT',
384
+ eventCode: 'DEPA',
385
+ ufvGkey: 'dep',
386
+ ...overrides,
387
+ })
388
+
389
+ const cachedRow = (visitRef: string) => ({
390
+ visitRef,
391
+ terminalCode: 'bct',
392
+ vesselName: 'MAERSK NUBA',
393
+ ibVoyage: '623N',
394
+ obVoyage: '625S',
395
+ line: 'MAE',
396
+ phase: 'Inbound',
397
+ eta: new Date('2026-06-14T10:00:00Z'),
398
+ etd: new Date('2026-06-15T18:00:00Z'),
399
+ ata: new Date('2026-06-14T11:00:00Z'),
400
+ atd: null,
401
+ beginReceive: null,
402
+ dryCutoff: null,
403
+ rawData: null,
404
+ updatedAt: new Date(),
405
+ })
406
+
407
+ describe('departed legs and completed status', () => {
408
+ it('skips /VESSEL for a departed leg but still attaches cached vessel data', async () => {
409
+ const { service, fetchVesselVisit, eventBus } = makeHarness({
410
+ events: [departedEvent()],
411
+ vesselRows: [cachedRow('26GIRO622')],
412
+ })
413
+
414
+ await service.pollJob('job-1')
415
+
416
+ expect(fetchVesselVisit).not.toHaveBeenCalled() // departed → no fetch
417
+ expect(createdPayload(eventBus).vesselVisit.visitRef).toBe('26GIRO622') // cached attached
418
+ })
419
+
420
+ it('marks the job completed when every current leg is departed', async () => {
421
+ const { service, fetchVesselVisit, job } = makeHarness({ events: [departedEvent()], vesselRows: [] })
422
+
423
+ const res = await service.pollJob('job-1')
424
+
425
+ expect(fetchVesselVisit).not.toHaveBeenCalled()
426
+ expect(res.newEvents).toBe(1)
427
+ expect(job.status).toBe('completed')
428
+ })
429
+
430
+ it('stays active when at least one leg is not departed', async () => {
431
+ // A departed leg + an active DISC leg (distinct ufvGkey).
432
+ const { service, job } = makeHarness({
433
+ events: [departedEvent(), { ...FETCHED_EVENT }],
434
+ vesselRows: [],
435
+ })
436
+
437
+ await service.pollJob('job-1')
438
+
439
+ expect(job.status).toBe('active')
440
+ })
441
+
442
+ it('reverts a completed job to active when an active leg reappears', async () => {
443
+ const { service, job } = makeHarness({
444
+ job: { status: 'completed' },
445
+ events: [{ ...FETCHED_EVENT }], // active DISC leg
446
+ vesselRows: [],
447
+ })
448
+
449
+ await service.pollJob('job-1')
450
+
451
+ expect(job.status).toBe('active')
452
+ })
453
+ })
@@ -0,0 +1,97 @@
1
+ import Fuse from 'fuse.js'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { TerminalConfig } from '../data/entities'
4
+
5
+ export type FacilityMatchInput = {
6
+ name?: string | null
7
+ bic?: string | null
8
+ smdg?: string | null
9
+ unlocode?: string | null
10
+ }
11
+
12
+ export type MatchScope = {
13
+ organizationId: string
14
+ tenantId: string
15
+ }
16
+
17
+ export type TerminalMatch = {
18
+ terminalCode: string
19
+ displayName: string
20
+ matchedBy: 'bic' | 'smdg' | 'unlocode' | 'name'
21
+ score?: number
22
+ }
23
+
24
+ type Deps = {
25
+ em: () => EntityManager
26
+ }
27
+
28
+ /**
29
+ * Resolves a facility (by exact BIC / SMDG / UNLOCODE or fuzzy name) to one of
30
+ * the tenant's configured terminals. Matching fields are not encrypted, so a
31
+ * plain `em.find` is sufficient.
32
+ */
33
+ export class TerminalMatcherService {
34
+ constructor(private readonly deps: Deps) {}
35
+
36
+ private async candidates(scope: MatchScope): Promise<TerminalConfig[]> {
37
+ const em = this.deps.em().fork()
38
+ return em.find(TerminalConfig, {
39
+ organizationId: scope.organizationId,
40
+ tenantId: scope.tenantId,
41
+ isActive: true,
42
+ deletedAt: null,
43
+ })
44
+ }
45
+
46
+ async resolveFacility(input: FacilityMatchInput, scope: MatchScope): Promise<TerminalMatch | null> {
47
+ const configs = await this.candidates(scope)
48
+ if (configs.length === 0) return null
49
+
50
+ const norm = (s: string) => s.trim().toUpperCase()
51
+
52
+ // 1) Exact BIC
53
+ if (input.bic) {
54
+ const bic = norm(input.bic)
55
+ const hit = configs.find((c) => (c.bicCodes ?? []).some((x) => norm(x) === bic))
56
+ if (hit) return { terminalCode: hit.terminalCode, displayName: hit.displayName, matchedBy: 'bic' }
57
+ }
58
+
59
+ // 2) Exact SMDG
60
+ if (input.smdg) {
61
+ const smdg = norm(input.smdg)
62
+ const hit = configs.find((c) => (c.smdgCodes ?? []).some((x) => norm(x) === smdg))
63
+ if (hit) return { terminalCode: hit.terminalCode, displayName: hit.displayName, matchedBy: 'smdg' }
64
+ }
65
+
66
+ // 3) Exact UNLOCODE
67
+ if (input.unlocode) {
68
+ const unlocode = norm(input.unlocode)
69
+ const hit = configs.find((c) => c.unlocode && norm(c.unlocode) === unlocode)
70
+ if (hit) return { terminalCode: hit.terminalCode, displayName: hit.displayName, matchedBy: 'unlocode' }
71
+ }
72
+
73
+ // 4) Fuzzy name (fuse.js)
74
+ if (input.name && input.name.trim()) {
75
+ const docs = configs.flatMap((c) =>
76
+ [c.displayName, ...(c.nameAliases ?? [])].map((alias) => ({ config: c, alias })),
77
+ )
78
+ const fuse = new Fuse(docs, {
79
+ keys: ['alias'],
80
+ includeScore: true,
81
+ threshold: 0.3,
82
+ ignoreLocation: true,
83
+ })
84
+ const [best] = fuse.search(input.name.trim())
85
+ if (best) {
86
+ return {
87
+ terminalCode: best.item.config.terminalCode,
88
+ displayName: best.item.config.displayName,
89
+ matchedBy: 'name',
90
+ score: best.score,
91
+ }
92
+ }
93
+ }
94
+
95
+ return null
96
+ }
97
+ }
@@ -0,0 +1,30 @@
1
+ import type { TerminalAdapter } from '../lib/terminal-adapter'
2
+
3
+ /**
4
+ * Singleton registry for terminal adapters, keyed by `adapterType` (e.g. 'n4').
5
+ * One adapter serves all terminals of that type; per-terminal config lives in
6
+ * TerminalConfig rows.
7
+ */
8
+ export class TerminalRegistry {
9
+ private adapters = new Map<string, TerminalAdapter>()
10
+
11
+ register(adapter: TerminalAdapter): void {
12
+ this.adapters.set(adapter.adapterType.toLowerCase(), adapter)
13
+ }
14
+
15
+ get(adapterType: string): TerminalAdapter | undefined {
16
+ return this.adapters.get(adapterType.toLowerCase())
17
+ }
18
+
19
+ has(adapterType: string): boolean {
20
+ return this.adapters.has(adapterType.toLowerCase())
21
+ }
22
+
23
+ list(): TerminalAdapter[] {
24
+ return Array.from(this.adapters.values())
25
+ }
26
+
27
+ types(): string[] {
28
+ return Array.from(this.adapters.keys())
29
+ }
30
+ }