@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,277 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ const { checkRateLimit, findOneWithDecryption } = vi.hoisted(() => ({
4
+ checkRateLimit: vi.fn(),
5
+ findOneWithDecryption: vi.fn(),
6
+ }))
7
+ vi.mock('../../lib/rate-limiter', () => ({ checkRateLimit }))
8
+ vi.mock('@open-mercato/shared/lib/encryption/find', () => ({ findOneWithDecryption }))
9
+
10
+ import { TerminalTrackingService } from '../terminalTrackingService'
11
+
12
+ const CONFIG = {
13
+ terminalCode: 'bct',
14
+ adapterType: 'n4',
15
+ displayName: 'Baltic Hub',
16
+ baseUrl: 'https://api2.baltichub.com/V1/',
17
+ endpoints: { unit: '/unit' }, // no vessel endpoint → skip enrichment
18
+ authType: 'oauth2_password',
19
+ tokenUrl: null,
20
+ scope: null,
21
+ clientId: null,
22
+ authConfig: null,
23
+ rateLimitRequests: 200,
24
+ rateLimitWindowSeconds: 60,
25
+ unlocode: 'PLGDN',
26
+ smdgCodes: ['GDNBCT'],
27
+ bicCodes: [],
28
+ vesselCacheTtlSeconds: null,
29
+ }
30
+
31
+ const DT = new Date('2026-06-03T01:58:00Z')
32
+
33
+ function unitEvent(overrides: Record<string, unknown>) {
34
+ return {
35
+ source: 'terminal',
36
+ sourceEventId: 'bct:C1:DISC',
37
+ eventType: 'EQUIPMENT',
38
+ eventCode: 'DISC', // yard, not DEPA → job stays active
39
+ eventClassifierCode: 'ACT',
40
+ eventDateTime: DT,
41
+ containerNumber: 'C1',
42
+ ufvGkey: 'C1',
43
+ transitState: 'S40_YARD',
44
+ visitState: null,
45
+ impediments: null,
46
+ visitRefIn: null,
47
+ visitRefOut: null,
48
+ rawData: {},
49
+ ...overrides,
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Harness: one job for container C1. `events` is what /unit returns each poll;
55
+ * `existingEvents` are the persisted TerminalEvents already on the job (to model
56
+ * a prior poll's state for the holds-cleared transition).
57
+ */
58
+ function makeHarness(events: any[], existingEvents: any[] = []) {
59
+ const job = {
60
+ id: 'job-1',
61
+ organizationId: 'org-1',
62
+ tenantId: 'ten-1',
63
+ terminalCode: 'bct',
64
+ containerNumber: 'C1',
65
+ schedule: null,
66
+ status: 'active',
67
+ emptyReadyAt: null,
68
+ holdsClearedAt: null,
69
+ }
70
+
71
+ // Adapter without fetchVesselVisit → enrichment is skipped entirely.
72
+ const adapter = {
73
+ adapterType: 'n4',
74
+ defaultEndpoints: { unit: '/unit' },
75
+ fetchEvents: vi.fn(),
76
+ fetchEventsBatch: vi.fn(async () => ({ events })),
77
+ testConnection: vi.fn(),
78
+ }
79
+
80
+ const em = {
81
+ fork() {
82
+ return this
83
+ },
84
+ async find(_entity: any, where: any) {
85
+ if (where && 'status' in where) return [job] // stable job instance across polls
86
+ if (where && 'sourceEventId' in where) return existingEvents // persistEvents lookup
87
+ return []
88
+ },
89
+ create(_entity: any, data: any) {
90
+ return { ...data }
91
+ },
92
+ async flush() {},
93
+ }
94
+
95
+ const eventBus = { emit: vi.fn(async () => {}) }
96
+ const service = new TerminalTrackingService({
97
+ em: () => em as never,
98
+ eventBus: eventBus as never,
99
+ terminalRegistry: { get: () => adapter } as never,
100
+ cacheService: { get: async () => null, set: async () => {} } as never,
101
+ })
102
+ return { service, eventBus, job }
103
+ }
104
+
105
+ function emitsOf(eventBus: any, id: string) {
106
+ return eventBus.emit.mock.calls.filter((c: any[]) => c[0] === id)
107
+ }
108
+
109
+ beforeEach(() => {
110
+ vi.clearAllMocks()
111
+ checkRateLimit.mockResolvedValue({ allowed: true })
112
+ findOneWithDecryption.mockResolvedValue({ ...CONFIG })
113
+ })
114
+
115
+ describe('availability emission', () => {
116
+ it('emits empty_ready for an empty export in the yard with no blocking holds', async () => {
117
+ const { service, eventBus } = makeHarness([
118
+ unitEvent({ rawData: { Category: 'Export', 'Frght Kind': 'MTY' } }),
119
+ ])
120
+
121
+ await service.pollAllActiveJobs('ten-1')
122
+
123
+ const emits = emitsOf(eventBus, 'terminal_tracking.equipment.empty_ready')
124
+ expect(emits).toHaveLength(1)
125
+ expect(emits[0][1]).toMatchObject({ containerNumber: 'C1', emptyReady: true })
126
+ // Not an import → no holds_cleared.
127
+ expect(emitsOf(eventBus, 'terminal_tracking.equipment.holds_cleared')).toHaveLength(0)
128
+ })
129
+
130
+ it('is one-shot: does not re-emit empty_ready on a second poll', async () => {
131
+ const { service, eventBus } = makeHarness([
132
+ unitEvent({ rawData: { Category: 'Export', 'Frght Kind': 'MTY' } }),
133
+ ])
134
+
135
+ await service.pollAllActiveJobs('ten-1')
136
+ await service.pollAllActiveJobs('ten-1') // job.emptyReadyAt now set → guard holds
137
+
138
+ expect(emitsOf(eventBus, 'terminal_tracking.equipment.empty_ready')).toHaveLength(1)
139
+ })
140
+
141
+ it('does not emit empty_ready when a movement-blocking hold is present', async () => {
142
+ const { service, eventBus } = makeHarness([
143
+ unitEvent({
144
+ rawData: { Category: 'Export', 'Frght Kind': 'MTY' },
145
+ impediments: ['!EMPTY PERMISSION'],
146
+ }),
147
+ ])
148
+
149
+ await service.pollAllActiveJobs('ten-1')
150
+
151
+ expect(emitsOf(eventBus, 'terminal_tracking.equipment.empty_ready')).toHaveLength(0)
152
+ })
153
+
154
+ it('emits holds_cleared on the blocked → clear transition for an import in the yard', async () => {
155
+ const existing = [
156
+ {
157
+ source: 'terminal',
158
+ sourceEventId: 'bct:C1:DISC',
159
+ job: { id: 'job-1' },
160
+ containerNumber: 'C1',
161
+ ufvGkey: 'C1',
162
+ eventDateTime: new Date('2026-06-01T00:00:00Z'),
163
+ transitState: 'S40_YARD',
164
+ impediments: ['!CUSTOMS IMPORT PERMISSION'], // was blocked
165
+ rawData: { Category: 'Import', 'Frght Kind': 'FCL' },
166
+ },
167
+ ]
168
+ const { service, eventBus } = makeHarness(
169
+ [
170
+ unitEvent({
171
+ impediments: null, // now clear
172
+ rawData: { Category: 'Import', 'Frght Kind': 'FCL' },
173
+ }),
174
+ ],
175
+ existing,
176
+ )
177
+
178
+ await service.pollAllActiveJobs('ten-1')
179
+
180
+ const emits = emitsOf(eventBus, 'terminal_tracking.equipment.holds_cleared')
181
+ expect(emits).toHaveLength(1)
182
+ expect(emits[0][1]).toMatchObject({ containerNumber: 'C1', holdsCleared: true })
183
+ })
184
+
185
+ it('emits stops_updated when a STOP flag first appears', async () => {
186
+ const { service, eventBus } = makeHarness([
187
+ unitEvent({ rawData: { Category: 'Import', 'Frght Kind': 'FCL', 'Stop-Road': 'true' } }),
188
+ ])
189
+
190
+ await service.pollAllActiveJobs('ten-1')
191
+
192
+ const emits = emitsOf(eventBus, 'terminal_tracking.equipment.stops_updated')
193
+ expect(emits).toHaveLength(1)
194
+ expect(emits[0][1]).toMatchObject({
195
+ containerNumber: 'C1',
196
+ stops: { vsl: null, road: true, rail: null },
197
+ })
198
+ })
199
+
200
+ it('emits stops_updated on a stop-only change (rawData is not in eventSignature)', async () => {
201
+ const existing = [
202
+ {
203
+ source: 'terminal',
204
+ sourceEventId: 'bct:C1:DISC',
205
+ job: { id: 'job-1' },
206
+ containerNumber: 'C1',
207
+ ufvGkey: 'C1',
208
+ eventDateTime: DT,
209
+ eventClassifierCode: 'ACT',
210
+ transitState: 'S40_YARD',
211
+ visitState: null,
212
+ impediments: null,
213
+ rawData: { Category: 'Import', 'Frght Kind': 'FCL', 'Stop-Road': 'false' },
214
+ },
215
+ ]
216
+ const { service, eventBus } = makeHarness(
217
+ [unitEvent({ rawData: { Category: 'Import', 'Frght Kind': 'FCL', 'Stop-Road': 'true' } })],
218
+ existing,
219
+ )
220
+
221
+ await service.pollAllActiveJobs('ten-1')
222
+
223
+ const emits = emitsOf(eventBus, 'terminal_tracking.equipment.stops_updated')
224
+ expect(emits).toHaveLength(1)
225
+ expect(emits[0][1].stops).toMatchObject({ road: true })
226
+ })
227
+
228
+ it('does not re-emit stops_updated when the stop set is unchanged', async () => {
229
+ const existing = [
230
+ {
231
+ source: 'terminal',
232
+ sourceEventId: 'bct:C1:DISC',
233
+ job: { id: 'job-1' },
234
+ containerNumber: 'C1',
235
+ ufvGkey: 'C1',
236
+ eventDateTime: DT,
237
+ eventClassifierCode: 'ACT',
238
+ transitState: 'S40_YARD',
239
+ visitState: null,
240
+ impediments: null,
241
+ rawData: { Category: 'Import', 'Frght Kind': 'FCL', 'Stop-Road': 'true' },
242
+ },
243
+ ]
244
+ const { service, eventBus } = makeHarness(
245
+ [unitEvent({ rawData: { Category: 'Import', 'Frght Kind': 'FCL', 'Stop-Road': 'true' } })],
246
+ existing,
247
+ )
248
+
249
+ await service.pollAllActiveJobs('ten-1')
250
+
251
+ expect(emitsOf(eventBus, 'terminal_tracking.equipment.stops_updated')).toHaveLength(0)
252
+ })
253
+
254
+ it('does not emit holds_cleared when the container was never blocked', async () => {
255
+ const existing = [
256
+ {
257
+ source: 'terminal',
258
+ sourceEventId: 'bct:C1:DISC',
259
+ job: { id: 'job-1' },
260
+ containerNumber: 'C1',
261
+ ufvGkey: 'C1',
262
+ eventDateTime: new Date('2026-06-01T00:00:00Z'),
263
+ transitState: 'S30_ECIN', // different state → signature differs, marked updated
264
+ impediments: null, // never blocked
265
+ rawData: { Category: 'Import', 'Frght Kind': 'FCL' },
266
+ },
267
+ ]
268
+ const { service, eventBus } = makeHarness(
269
+ [unitEvent({ impediments: null, rawData: { Category: 'Import', 'Frght Kind': 'FCL' } })],
270
+ existing,
271
+ )
272
+
273
+ await service.pollAllActiveJobs('ten-1')
274
+
275
+ expect(emitsOf(eventBus, 'terminal_tracking.equipment.holds_cleared')).toHaveLength(0)
276
+ })
277
+ })
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { TerminalTrackingService } from '../terminalTrackingService'
3
+
4
+ const INPUT = {
5
+ organizationId: 'org-1',
6
+ tenantId: 'ten-1',
7
+ terminalCode: 'bct',
8
+ containerNumber: 'GCXU5598460',
9
+ }
10
+
11
+ // Mock em whose findOne respects the reuse query's status filter, simulating a
12
+ // DB that holds exactly one job for this container in `dbJob.status`.
13
+ function makeService(dbJob: { id: string; status: string } | null) {
14
+ const fork = {
15
+ findOne: vi.fn(async (_entity: any, where: any) => {
16
+ const statuses: string[] = where?.status?.$in ?? []
17
+ return dbJob && statuses.includes(dbJob.status) ? dbJob : null
18
+ }),
19
+ create: vi.fn((_entity: any, data: any) => ({ id: 'new-job', ...data })),
20
+ flush: vi.fn(async () => {}),
21
+ }
22
+ const em = { fork: () => fork }
23
+ const eventBus = { emit: vi.fn(async () => {}) }
24
+ const service = new TerminalTrackingService({
25
+ em: () => em as never,
26
+ eventBus: eventBus as never,
27
+ terminalRegistry: { get: () => undefined } as never,
28
+ cacheService: { get: async () => null, set: async () => {} } as never,
29
+ })
30
+ return { service, fork, eventBus }
31
+ }
32
+
33
+ describe('createJob reuse', () => {
34
+ it('creates a NEW job when the same container only has a completed job (returns later)', async () => {
35
+ const { service, fork, eventBus } = makeService({ id: 'old-job', status: 'completed' })
36
+ const poll = vi.spyOn(service, 'pollJob')
37
+
38
+ const res = await service.createJob(INPUT as never)
39
+
40
+ expect(poll).not.toHaveBeenCalled() // not revived
41
+ expect(fork.create).toHaveBeenCalledTimes(1) // fresh job
42
+ expect(res.trackingJobId).toBe('new-job')
43
+ expect(eventBus.emit).toHaveBeenCalledWith(
44
+ 'terminal_tracking.tracking_job.created',
45
+ expect.any(Object),
46
+ )
47
+ })
48
+
49
+ it('reuses an existing active job (no duplicate in-flight track)', async () => {
50
+ const { service, fork } = makeService({ id: 'old-job', status: 'active' })
51
+ const poll = vi.spyOn(service, 'pollJob').mockResolvedValue({ newEvents: 0, updatedEvents: 0 })
52
+
53
+ const res = await service.createJob(INPUT as never)
54
+
55
+ expect(poll).toHaveBeenCalledWith('old-job')
56
+ expect(fork.create).not.toHaveBeenCalled()
57
+ expect(res.trackingJobId).toBe('old-job')
58
+ })
59
+ })
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ const { checkRateLimit, findOneWithDecryption } = vi.hoisted(() => ({
4
+ checkRateLimit: vi.fn(),
5
+ findOneWithDecryption: vi.fn(),
6
+ }))
7
+ vi.mock('../../lib/rate-limiter', () => ({ checkRateLimit }))
8
+ vi.mock('@open-mercato/shared/lib/encryption/find', () => ({ findOneWithDecryption }))
9
+
10
+ import { TerminalTrackingService } from '../terminalTrackingService'
11
+
12
+ const CONFIG = {
13
+ terminalCode: 'bct',
14
+ adapterType: 'n4',
15
+ displayName: 'Baltic Hub',
16
+ baseUrl: 'https://api2.baltichub.com/V1/',
17
+ endpoints: { unit: '/unit', vessel: '/VESSEL' },
18
+ authType: 'oauth2_password',
19
+ tokenUrl: null,
20
+ scope: null,
21
+ clientId: null,
22
+ authConfig: null,
23
+ rateLimitRequests: 200,
24
+ rateLimitWindowSeconds: 60,
25
+ unlocode: 'PLGDN',
26
+ smdgCodes: ['GDNBCT'],
27
+ bicCodes: [],
28
+ vesselCacheTtlSeconds: null,
29
+ }
30
+
31
+ // Three containers at one terminal; c1 and c2 share vessel visit A, c3 on B.
32
+ const VISIT_BY_CONTAINER: Record<string, string> = {
33
+ C1: '26AAAA001',
34
+ C2: '26AAAA001',
35
+ C3: '26BBBB002',
36
+ }
37
+
38
+ function jobs() {
39
+ return ['C1', 'C2', 'C3'].map((cn, i) => ({
40
+ id: `job-${i + 1}`,
41
+ organizationId: 'org-1',
42
+ tenantId: 'ten-1',
43
+ terminalCode: 'bct',
44
+ containerNumber: cn,
45
+ schedule: null,
46
+ status: 'active',
47
+ }))
48
+ }
49
+
50
+ function makeHarness() {
51
+ const fetchEventsBatch = vi.fn(async ({ containerNumbers }: { containerNumbers: string[] }) => ({
52
+ events: containerNumbers.map((cn) => ({
53
+ source: 'terminal',
54
+ sourceEventId: `bct:${cn}:DEPA`,
55
+ eventType: 'EQUIPMENT',
56
+ eventCode: 'DISC',
57
+ eventClassifierCode: 'ACT',
58
+ eventDateTime: new Date('2026-06-03T01:58:00Z'),
59
+ containerNumber: cn,
60
+ ufvGkey: cn,
61
+ visitRefIn: VISIT_BY_CONTAINER[cn],
62
+ visitRefOut: 'GEN_TRUCK',
63
+ rawData: { Category: 'Import' }, // import leg → vessel on I/B
64
+ })),
65
+ }))
66
+ const fetchVesselVisit = vi.fn(async ({ visitRef }: { visitRef: string }) => ({
67
+ visitRef,
68
+ vesselName: `VESSEL ${visitRef}`,
69
+ ibVoyage: '623N',
70
+ obVoyage: '625S',
71
+ line: 'MAE',
72
+ phase: 'Inbound',
73
+ eta: new Date('2026-06-14T10:00:00Z'),
74
+ etd: null,
75
+ ata: null,
76
+ atd: null,
77
+ beginReceive: null,
78
+ dryCutoff: null,
79
+ rawData: null,
80
+ }))
81
+ const adapter = {
82
+ adapterType: 'n4',
83
+ defaultEndpoints: { unit: '/unit', vessel: '/VESSEL' },
84
+ fetchEvents: vi.fn(),
85
+ fetchEventsBatch,
86
+ fetchVesselVisit,
87
+ testConnection: vi.fn(),
88
+ }
89
+
90
+ const em = {
91
+ fork() {
92
+ return this
93
+ },
94
+ async find(_entity: any, where: any) {
95
+ if (where && 'status' in where) return jobs() // due jobs
96
+ return [] // vessel cache miss + no existing events
97
+ },
98
+ create(_entity: any, data: any) {
99
+ return { ...data }
100
+ },
101
+ async flush() {},
102
+ }
103
+
104
+ const eventBus = { emit: vi.fn(async () => {}) }
105
+ const service = new TerminalTrackingService({
106
+ em: () => em as never,
107
+ eventBus: eventBus as never,
108
+ terminalRegistry: { get: () => adapter } as never,
109
+ cacheService: { get: async () => null, set: async () => {} } as never,
110
+ })
111
+ return { service, adapter, fetchEventsBatch, fetchVesselVisit, eventBus }
112
+ }
113
+
114
+ beforeEach(() => {
115
+ vi.clearAllMocks()
116
+ checkRateLimit.mockResolvedValue({ allowed: true })
117
+ findOneWithDecryption.mockResolvedValue({ ...CONFIG })
118
+ })
119
+
120
+ describe('pollAllActiveJobs batching', () => {
121
+ it('fetches all containers in one /unit call and resolves each vessel once', async () => {
122
+ const { service, fetchEventsBatch, fetchVesselVisit, eventBus } = makeHarness()
123
+
124
+ const res = await service.pollAllActiveJobs('ten-1')
125
+
126
+ // One batched /unit call for all three containers.
127
+ expect(fetchEventsBatch).toHaveBeenCalledTimes(1)
128
+ expect(fetchEventsBatch.mock.calls[0][0].containerNumbers).toEqual(['C1', 'C2', 'C3'])
129
+
130
+ // Two unique vessel visits (A shared by C1+C2, B for C3) → two fetches.
131
+ expect(fetchVesselVisit).toHaveBeenCalledTimes(2)
132
+ const fetchedRefs = fetchVesselVisit.mock.calls.map((c) => c[0].visitRef).sort()
133
+ expect(fetchedRefs).toEqual(['26AAAA001', '26BBBB002'])
134
+
135
+ // One terminal event emitted per container.
136
+ const created = eventBus.emit.mock.calls.filter((c) => c[0] === 'terminal_tracking.terminal_event.created')
137
+ expect(created).toHaveLength(3)
138
+ expect(res).toMatchObject({ polled: 3, newEvents: 3 })
139
+ })
140
+
141
+ it('minimises rate-limit spend: 1 unit token + 2 vessel tokens, not 3 unit calls', async () => {
142
+ const { service } = makeHarness()
143
+ await service.pollAllActiveJobs('ten-1')
144
+ // 1 (unit batch) + 2 (unique vessels) = 3 — versus 3 unit + 3 vessel pre-batching.
145
+ expect(checkRateLimit).toHaveBeenCalledTimes(3)
146
+ })
147
+ })
@@ -0,0 +1,70 @@
1
+ import { TerminalMatcherService } from '../terminalMatcherService'
2
+
3
+ const SCOPE = { organizationId: 'org-1', tenantId: 'ten-1' }
4
+
5
+ const CONFIGS = [
6
+ {
7
+ terminalCode: 'bct',
8
+ displayName: 'Baltic Hub',
9
+ unlocode: 'PLGDN',
10
+ bicCodes: ['BCTGDN'],
11
+ smdgCodes: ['GDNBCT'],
12
+ nameAliases: ['BCT', 'DCT Gdansk'],
13
+ isActive: true,
14
+ },
15
+ {
16
+ terminalCode: 'gct',
17
+ displayName: 'Gdynia Container Terminal',
18
+ unlocode: 'PLGDY',
19
+ bicCodes: [],
20
+ smdgCodes: ['GDYGCT'],
21
+ nameAliases: ['GCT'],
22
+ isActive: true,
23
+ },
24
+ ]
25
+
26
+ function makeService(configs: unknown[] = CONFIGS) {
27
+ const em = () => ({ fork: () => ({ find: async () => configs }) })
28
+ return new TerminalMatcherService({ em: em as never })
29
+ }
30
+
31
+ describe('TerminalMatcherService.resolveFacility', () => {
32
+ it('matches by exact BIC (case-insensitive)', async () => {
33
+ const m = await makeService().resolveFacility({ bic: 'bctgdn' }, SCOPE)
34
+ expect(m).toMatchObject({ terminalCode: 'bct', matchedBy: 'bic' })
35
+ })
36
+
37
+ it('matches by exact SMDG', async () => {
38
+ const m = await makeService().resolveFacility({ smdg: 'GDYGCT' }, SCOPE)
39
+ expect(m).toMatchObject({ terminalCode: 'gct', matchedBy: 'smdg' })
40
+ })
41
+
42
+ it('matches by exact UNLOCODE (case-insensitive)', async () => {
43
+ const m = await makeService().resolveFacility({ unlocode: 'plgdn' }, SCOPE)
44
+ expect(m).toMatchObject({ terminalCode: 'bct', matchedBy: 'unlocode' })
45
+ })
46
+
47
+ it('matches by fuzzy display name', async () => {
48
+ const m = await makeService().resolveFacility({ name: 'baltic hub' }, SCOPE)
49
+ expect(m).toMatchObject({ terminalCode: 'bct', matchedBy: 'name' })
50
+ })
51
+
52
+ it('matches by fuzzy alias', async () => {
53
+ const m = await makeService().resolveFacility({ name: 'DCT Gdańsk'.replace('ń', 'n') }, SCOPE)
54
+ expect(m?.terminalCode).toBe('bct')
55
+ })
56
+
57
+ it('prefers exact BIC over a fuzzy name on a different terminal', async () => {
58
+ const m = await makeService().resolveFacility({ bic: 'BCTGDN', name: 'Gdynia' }, SCOPE)
59
+ expect(m).toMatchObject({ terminalCode: 'bct', matchedBy: 'bic' })
60
+ })
61
+
62
+ it('returns null when nothing matches', async () => {
63
+ expect(await makeService().resolveFacility({ name: 'Rotterdam APM Maasvlakte' }, SCOPE)).toBeNull()
64
+ expect(await makeService().resolveFacility({ bic: 'NOPE' }, SCOPE)).toBeNull()
65
+ })
66
+
67
+ it('returns null when there are no configured terminals', async () => {
68
+ expect(await makeService([]).resolveFacility({ unlocode: 'PLGDN' }, SCOPE)).toBeNull()
69
+ })
70
+ })