From 10e6099c197e8f5e4766e888ee0c6002fad1bdb0 Mon Sep 17 00:00:00 2001 From: Dominic Finn Date: Sun, 4 Oct 2026 11:01:35 +0100 Subject: [PATCH] Plan a road route for custom-route shipments (#328) Part 1 of #328. A shipment on a custom route (no lane) gets its own planned route in a new ShipmentRoute table, calculated through its stops in sequence by ShipmentRoutePlanner when the shipment is created or changed while draft or ready, and only when the stop sequence changed. The provider call runs off the request path (ShipmentRoutePlanningHandler) and outside any transaction; SetShipmentRouteCommand stores the result and emits shipment.route_planned. With no Google Maps server key, planning is skipped and logged. Checkpoints, route deviation in the ETA monitor, the new GET /api/v1/shipments/:id/route and the shipment map all use the shipment's effective route: its own, otherwise its lane's. Co-Authored-By: Claude Opus 5.5 --- .../20261004_01_shipment_routes/migration.sql | 30 +++++ backend/prisma/schema/tms.prisma | 26 +++++ .../commands/SetShipmentRouteCommand.test.ts | 53 +++++++++ .../ArrivalCriteriaEvaluationService.test.ts | 19 +++- .../services/ShipmentRoutePlanner.test.ts | 106 ++++++++++++++++++ .../shipments/SetShipmentRouteCommand.ts | 80 +++++++++++++ backend/src/di/modules/tms.ts | 7 ++ backend/src/di/tokens.ts | 1 + backend/src/events/eventTypes.ts | 2 + .../handlers/ShipmentRoutePlanningHandler.ts | 30 +++++ backend/src/events/registerHandlers.ts | 27 +++++ backend/src/index.ts | 4 +- .../repositories/ShipmentRouteRepository.ts | 81 +++++++++++++ backend/src/routes/shipments.ts | 20 ++++ .../ArrivalCriteriaEvaluationService.ts | 5 +- .../routing/ShipmentEtaMonitorService.ts | 27 ++--- .../services/routing/ShipmentRoutePlanner.ts | 91 +++++++++++++++ backend/src/worker.ts | 3 + docs/DOMAIN_BEHAVIOURS.md | 14 +++ docs/roadmap/completed.md | 1 + .../src/vnext-design/VNextShipmentDetail.tsx | 18 ++- 21 files changed, 615 insertions(+), 30 deletions(-) create mode 100644 backend/prisma/migrations/20261004_01_shipment_routes/migration.sql create mode 100644 backend/src/__tests__/commands/SetShipmentRouteCommand.test.ts create mode 100644 backend/src/__tests__/services/ShipmentRoutePlanner.test.ts create mode 100644 backend/src/commands/shipments/SetShipmentRouteCommand.ts create mode 100644 backend/src/events/handlers/ShipmentRoutePlanningHandler.ts create mode 100644 backend/src/repositories/ShipmentRouteRepository.ts create mode 100644 backend/src/services/routing/ShipmentRoutePlanner.ts diff --git a/backend/prisma/migrations/20261004_01_shipment_routes/migration.sql b/backend/prisma/migrations/20261004_01_shipment_routes/migration.sql new file mode 100644 index 00000000..34617128 --- /dev/null +++ b/backend/prisma/migrations/20261004_01_shipment_routes/migration.sql @@ -0,0 +1,30 @@ +-- #328: a shipment on a custom route (no lane) gets its own planned road route, calculated from its +-- stops, so checkpoints, route deviation and the map work for it as they do for lane shipments. +-- +-- Table role: reference. One row per custom-route shipment, rewritten when its stops change. +-- Index budget: only the unique shipmentId index, which exists for correctness (one route per +-- shipment) and serves every lookup. No orgId index: rows are always read by shipmentId. +-- No backfill: existing custom-route shipments get a route the next time they are updated while +-- draft or ready. + +CREATE TABLE "ShipmentRoute" ( + "id" TEXT NOT NULL, + "shipmentId" TEXT NOT NULL, + "orgId" TEXT NOT NULL, + "encodedPolyline" TEXT NOT NULL, + "waypoints" JSONB NOT NULL, + "distanceMeters" INTEGER NOT NULL, + "durationSeconds" INTEGER NOT NULL, + "summary" TEXT, + "corridorMeters" INTEGER NOT NULL DEFAULT 5000, + "provider" TEXT NOT NULL DEFAULT 'google', + "stopsKey" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ShipmentRoute_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ShipmentRoute_shipmentId_key" ON "ShipmentRoute"("shipmentId"); + +ALTER TABLE "ShipmentRoute" ADD CONSTRAINT "ShipmentRoute_shipmentId_fkey" FOREIGN KEY ("shipmentId") REFERENCES "Shipment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema/tms.prisma b/backend/prisma/schema/tms.prisma index 1f9be879..382d2631 100644 --- a/backend/prisma/schema/tms.prisma +++ b/backend/prisma/schema/tms.prisma @@ -64,6 +64,7 @@ model Shipment { events ShipmentEvent[] orderShipments OrderShipment[] stops ShipmentStop[] // Multi-stop route support + route ShipmentRoute? // Own planned route for a custom-route shipment (#328) tenders Tender[] // Carrier tenders for this shipment deviceAssignments DeviceAssignment[] // IoT device assignments sensorReadings SensorReading[] // Sensor telemetry data @@ -2405,6 +2406,31 @@ model CarrierTrackingEvent { // ─── Lane Route (Planned Route Polyline) ────────────────────────────────────── +// Planned road route for a shipment on a custom route (no lane), calculated from its own stops +// (#328). A lane shipment uses its lane's LaneRoute instead and has no row here. +// Table role: reference (one row per custom-route shipment, rewritten when its stops change). +model ShipmentRoute { + id String @id @default(uuid()) + shipment Shipment @relation(fields: [shipmentId], references: [id], onDelete: Cascade) + shipmentId String @unique + orgId String + + encodedPolyline String @db.Text + waypoints Json // [{lat, lng}, ...] decoded from the polyline + distanceMeters Int + durationSeconds Int + summary String? + corridorMeters Int @default(5000) // How far off-route before a deviation alert + provider String @default("google") + + // The stop location ids, in sequence, the route was calculated for. A different value means the + // stops changed and the route is stale. + stopsKey String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model LaneRoute { id String @id @default(uuid()) lane Lane @relation(fields: [laneId], references: [id], onDelete: Cascade) diff --git a/backend/src/__tests__/commands/SetShipmentRouteCommand.test.ts b/backend/src/__tests__/commands/SetShipmentRouteCommand.test.ts new file mode 100644 index 00000000..a9822f4e --- /dev/null +++ b/backend/src/__tests__/commands/SetShipmentRouteCommand.test.ts @@ -0,0 +1,53 @@ +import { SetShipmentRouteCommandHandler, SET_SHIPMENT_ROUTE } from '../../commands/shipments/SetShipmentRouteCommand'; +import { EVENT_TYPES } from '../../events/eventTypes'; +import { createTestCommand, mockEventBus } from '../helpers/testUtils'; + +function setup(shipment: object | null = { id: 'ship-1' }) { + const tx = { + shipment: { findFirst: jest.fn().mockResolvedValue(shipment) }, + shipmentRoute: { upsert: jest.fn().mockResolvedValue({}), deleteMany: jest.fn().mockResolvedValue({ count: 1 }) }, + domainEventLog: { create: jest.fn().mockResolvedValue({}) }, + } as any; + const prisma = { $transaction: jest.fn((fn: Function) => fn(tx)), domainEventLog: { findFirst: jest.fn().mockResolvedValue(null) } } as any; + return { tx, handler: new SetShipmentRouteCommandHandler(prisma, mockEventBus().bus) }; +} + +const route = { + encodedPolyline: 'abc', waypoints: [{ lat: 1, lng: 2 }], distanceMeters: 1000, durationSeconds: 600, + summary: 'via I-90', provider: 'google', stopsKey: 'a,b,c', +}; + +describe('SetShipmentRouteCommandHandler (#328)', () => { + it('stores the route under the command org and emits shipment.route_planned', async () => { + const { tx, handler } = setup(); + const command = createTestCommand(SET_SHIPMENT_ROUTE, { shipmentId: 'ship-1', route }, { orgId: 'org-1' }); + + const result = await handler.execute(command); + + expect(result.data).toEqual({ changed: true }); + expect(tx.shipmentRoute.upsert).toHaveBeenCalledWith(expect.objectContaining({ + where: { shipmentId: 'ship-1', orgId: 'org-1' }, + create: expect.objectContaining({ shipmentId: 'ship-1', orgId: 'org-1', stopsKey: 'a,b,c' }), + })); + expect(result.events[0]).toMatchObject({ + type: EVENT_TYPES.SHIPMENT_ROUTE_PLANNED, + entityId: 'ship-1', + payload: { shipmentId: 'ship-1', distanceMeters: 1000, stopCount: 3 }, + }); + }); + + it('removes the route when given null, without an event', async () => { + const { tx, handler } = setup(); + const result = await handler.execute(createTestCommand(SET_SHIPMENT_ROUTE, { shipmentId: 'ship-1', route: null })); + expect(tx.shipmentRoute.deleteMany).toHaveBeenCalledWith({ where: { shipmentId: 'ship-1', orgId: 'test-org' } }); + expect(result.events).toHaveLength(0); + }); + + it('fails for a shipment outside the org', async () => { + const { tx, handler } = setup(null); + const result = await handler.execute(createTestCommand(SET_SHIPMENT_ROUTE, { shipmentId: 'ship-x', route })); + expect(result.success).toBe(false); + expect(result.error).toBe('Shipment not found'); + expect(tx.shipmentRoute.upsert).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/__tests__/services/ArrivalCriteriaEvaluationService.test.ts b/backend/src/__tests__/services/ArrivalCriteriaEvaluationService.test.ts index 35d9f1d2..dc4e9e9a 100644 --- a/backend/src/__tests__/services/ArrivalCriteriaEvaluationService.test.ts +++ b/backend/src/__tests__/services/ArrivalCriteriaEvaluationService.test.ts @@ -1,6 +1,8 @@ import { ArrivalCriteriaEvaluationService } from '../../services/ArrivalCriteriaEvaluationService'; import { RECORD_GEOFENCE_ARRIVAL } from '../../commands/tracking/RecordGeofenceArrivalCommand'; import { RECORD_GEOFENCE_DEPARTURE } from '../../commands/tracking/RecordGeofenceDepartureCommand'; +import { RECORD_JOURNEY_CHECKPOINT } from '../../commands/tracking/RecordJourneyCheckpointCommand'; +import { encodePolyline } from '../../services/routing/GoogleMapsDirectionsService'; // Three locations far enough apart that a ping can only be inside one 250m geofence at a time. const LOCATIONS: Record = { @@ -29,7 +31,7 @@ function stop(spec: StopSpec) { }; } -function mockPrisma(stops: StopSpec[]) { +function mockPrisma(stops: StopSpec[], route: { encodedPolyline: string } | null = null) { return { shipment: { findUnique: jest.fn().mockResolvedValue({ @@ -38,14 +40,15 @@ function mockPrisma(stops: StopSpec[]) { destinationId: 'loc-dest', destination: { ...LOCATIONS['loc-dest'], arrivalCriteria: [] }, stops: stops.map(stop), + route, lane: null, }), }, } as any; } -function setup(stops: StopSpec[]) { - const prisma = mockPrisma(stops); +function setup(stops: StopSpec[], route: { encodedPolyline: string } | null = null) { + const prisma = mockPrisma(stops, route); const deliveryService = { updateOrdersForStop: jest.fn().mockResolvedValue(1) } as any; const commandBus = { dispatch: jest.fn().mockImplementation((c: any) => Promise.resolve({ @@ -151,4 +154,14 @@ describe('ArrivalCriteriaEvaluationService', () => { expect(dispatched()).toEqual([[RECORD_GEOFENCE_ARRIVAL, expect.objectContaining({ stopId: 'stop-return', completesStop: true })]]); }); }); + + it("measures checkpoints along a custom-route shipment's own route (#328)", async () => { + const ownRoute = { encodedPolyline: encodePolyline([LOCATIONS['loc-origin'], LOCATIONS['loc-mid'], LOCATIONS['loc-dest']]) }; + const { ping, dispatched } = setup([{ ...ROUTE[0], status: 'completed' }, ROUTE[1], ROUTE[2]], ownRoute); + + await ping('loc-mid', { lat: 40.75 }); // between the middle stop and the destination, inside neither + + expect(dispatched()).toEqual([[RECORD_JOURNEY_CHECKPOINT, expect.objectContaining({ shipmentId: 'ship-1' })]]); + }); }); + diff --git a/backend/src/__tests__/services/ShipmentRoutePlanner.test.ts b/backend/src/__tests__/services/ShipmentRoutePlanner.test.ts new file mode 100644 index 00000000..9c97ec4a --- /dev/null +++ b/backend/src/__tests__/services/ShipmentRoutePlanner.test.ts @@ -0,0 +1,106 @@ +import { ShipmentRoutePlanner } from '../../services/routing/ShipmentRoutePlanner'; +import { SET_SHIPMENT_ROUTE } from '../../commands/shipments/SetShipmentRouteCommand'; +import { ShipmentRouteRepository } from '../../repositories/ShipmentRouteRepository'; +import { ShipmentRoutePlanningHandler } from '../../events/handlers/ShipmentRoutePlanningHandler'; +import { createTestEvent } from '../helpers/testUtils'; +import { EVENT_TYPES } from '../../events/eventTypes'; + +const stop = (locationId: string, lat: number | null = 40, lng: number | null = -74) => ({ locationId, lat, lng }); + +function setup(shipment: any, serverKey: string | null = 'server-key') { + const routes = { findForPlanning: jest.fn().mockResolvedValue(shipment), findEffectiveRoute: jest.fn() }; + const organizations = { getSettings: jest.fn().mockResolvedValue({ googleMapsServerKey: serverKey }) } as any; + const directions = { + computeDirections: jest.fn().mockResolvedValue({ + encodedPolyline: 'abc', distanceMeters: 1000, durationSeconds: 600, summary: 'via I-90', waypoints: [{ lat: 1, lng: 2 }], + }), + }; + const commandBus = { dispatch: jest.fn().mockResolvedValue({ success: true, events: [] }) } as any; + const planner = new ShipmentRoutePlanner(routes, organizations, directions, commandBus); + return { planner, routes, organizations, directions, commandBus }; +} + +const custom = (overrides: object = {}) => ({ + status: 'draft', laneId: null, currentStopsKey: null, + stops: [stop('a', 44, -88), stop('b', 44, -92), stop('c', 39, -104)], + ...overrides, +}); + +describe('ShipmentRoutePlanner (#328)', () => { + it('plans a custom route through the stops in order and stores it', async () => { + const { planner, directions, commandBus } = setup(custom()); + + expect(await planner.plan('org-1', 'ship-1')).toBe('planned'); + + expect(directions.computeDirections).toHaveBeenCalledWith('server-key', { + origin: { lat: 44, lng: -88 }, destination: { lat: 39, lng: -104 }, waypoints: [{ lat: 44, lng: -92 }], + }); + expect(commandBus.dispatch).toHaveBeenCalledWith(expect.objectContaining({ + type: SET_SHIPMENT_ROUTE, orgId: 'org-1', + payload: expect.objectContaining({ shipmentId: 'ship-1', route: expect.objectContaining({ encodedPolyline: 'abc', stopsKey: 'a,b,c' }) }), + })); + }); + + it('skips the provider when the stops have not changed', async () => { + const { planner, directions } = setup(custom({ currentStopsKey: 'a,b,c' })); + expect(await planner.plan('org-1', 'ship-1')).toBe('unchanged'); + expect(directions.computeDirections).not.toHaveBeenCalled(); + }); + + it('leaves a lane shipment on its lane, clearing any route of its own', async () => { + const { planner, commandBus } = setup(custom({ laneId: 'lane-1', currentStopsKey: 'a,c' })); + expect(await planner.plan('org-1', 'ship-1')).toBe('cleared'); + expect(commandBus.dispatch).toHaveBeenCalledWith(expect.objectContaining({ payload: { shipmentId: 'ship-1', route: null } })); + + const noOwnRoute = setup(custom({ laneId: 'lane-1' })); + expect(await noOwnRoute.planner.plan('org-1', 'ship-1')).toBe('uses_lane'); + expect(noOwnRoute.commandBus.dispatch).not.toHaveBeenCalled(); + }); + + it('does not re-plan a shipment that is already moving', async () => { + const { planner, directions } = setup(custom({ status: 'in_progress' })); + expect(await planner.plan('org-1', 'ship-1')).toBe('not_plannable_status'); + expect(directions.computeDirections).not.toHaveBeenCalled(); + }); + + it('needs two located stops and a server key', async () => { + expect(await setup(custom({ stops: [stop('a'), stop('b', null, null)] })).planner.plan('org-1', 's')).toBe('not_enough_stops'); + const noKey = setup(custom(), null); + expect(await noKey.planner.plan('org-1', 's')).toBe('no_server_key'); + expect(noKey.directions.computeDirections).not.toHaveBeenCalled(); + }); + + it('returns not_found for a shipment outside the org', async () => { + expect(await setup(null).planner.plan('org-2', 'ship-1')).toBe('not_found'); + }); +}); + +describe('ShipmentRouteRepository.findEffectiveRoute (#328)', () => { + const route = (polyline: string) => ({ encodedPolyline: polyline, waypoints: [], distanceMeters: 1, durationSeconds: 1, summary: null, corridorMeters: 5000 }); + + it("prefers the shipment's own route, falls back to the lane's, and is undefined outside the org", async () => { + const findFirst = jest.fn() + .mockResolvedValueOnce({ route: route('own'), lane: { route: route('lane') } }) + .mockResolvedValueOnce({ route: null, lane: { route: route('lane') } }) + .mockResolvedValueOnce({ route: null, lane: null }) + .mockResolvedValueOnce(null); + const repo = new ShipmentRouteRepository({ shipment: { findFirst } } as any); + + expect(await repo.findEffectiveRoute('org-1', 's')).toMatchObject({ source: 'shipment', encodedPolyline: 'own' }); + expect(await repo.findEffectiveRoute('org-1', 's')).toMatchObject({ source: 'lane', encodedPolyline: 'lane' }); + expect(await repo.findEffectiveRoute('org-1', 's')).toBeNull(); + expect(await repo.findEffectiveRoute('org-2', 's')).toBeUndefined(); + expect(findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 's', orgId: 'org-2' } })); + }); +}); + +describe('ShipmentRoutePlanningHandler (#328)', () => { + it('plans on shipment created/updated and rethrows provider failures so the job retries', async () => { + const planner = { plan: jest.fn().mockRejectedValue(new Error('provider down')) }; + const handler = new ShipmentRoutePlanningHandler(planner); + expect(handler.eventPatterns).toEqual([EVENT_TYPES.SHIPMENT_CREATED, EVENT_TYPES.SHIPMENT_UPDATED]); + + await expect(handler.handle(createTestEvent(EVENT_TYPES.SHIPMENT_UPDATED, 'shipment', 'ship-1', {}))).rejects.toThrow('provider down'); + expect(planner.plan).toHaveBeenCalledWith('test-org', 'ship-1'); + }); +}); diff --git a/backend/src/commands/shipments/SetShipmentRouteCommand.ts b/backend/src/commands/shipments/SetShipmentRouteCommand.ts new file mode 100644 index 00000000..f8135e5c --- /dev/null +++ b/backend/src/commands/shipments/SetShipmentRouteCommand.ts @@ -0,0 +1,80 @@ +import { PrismaClient } from '@prisma/client'; +import { PgBossEventBus } from '../../events/PgBossEventBus.js'; +import { EVENT_TYPES } from '../../events/eventTypes.js'; +import { BaseCommandHandler, TransactionClient, EmitFn } from '../BaseCommandHandler.js'; +import { Command } from '../types.js'; + +export interface PlannedRoute { + encodedPolyline: string; + waypoints: Array<{ lat: number; lng: number }>; + distanceMeters: number; + durationSeconds: number; + summary?: string | null; + provider: string; + stopsKey: string; +} + +export interface SetShipmentRoutePayload { + shipmentId: string; + /** The calculated route, or null to remove the shipment's own route (it now rides a lane). */ + route: PlannedRoute | null; +} + +export const SET_SHIPMENT_ROUTE = 'shipment.set_route'; + +export class ShipmentNotFoundError extends Error { + constructor() { + super('Shipment not found'); + this.name = 'ShipmentNotFoundError'; + } +} + +/** + * Stores or clears a shipment's own planned route (#328). The route is calculated by + * ShipmentRoutePlanner before dispatch: the call to the routing provider never happens inside this + * transaction. + */ +export class SetShipmentRouteCommandHandler extends BaseCommandHandler { + readonly commandType = SET_SHIPMENT_ROUTE; + constructor(prisma: PrismaClient, eventBus: PgBossEventBus) { super(prisma, eventBus); } + + protected async handle(command: Command, tx: TransactionClient, emit: EmitFn) { + const { shipmentId, route } = command.payload; + const shipment = await tx.shipment.findFirst({ where: { id: shipmentId, orgId: command.orgId }, select: { id: true } }); + if (!shipment) throw new ShipmentNotFoundError(); + + if (!route) { + const { count } = await tx.shipmentRoute.deleteMany({ where: { shipmentId, orgId: command.orgId } }); + return { changed: count > 0 }; + } + + const data = { + encodedPolyline: route.encodedPolyline, + waypoints: route.waypoints, + distanceMeters: route.distanceMeters, + durationSeconds: route.durationSeconds, + summary: route.summary ?? null, + provider: route.provider, + stopsKey: route.stopsKey, + }; + await tx.shipmentRoute.upsert({ + where: { shipmentId, orgId: command.orgId }, + create: { shipmentId, orgId: command.orgId, ...data }, + update: data, + }); + + emit(this.createEvent(command, { + type: EVENT_TYPES.SHIPMENT_ROUTE_PLANNED, + entityType: 'shipment', + entityId: shipmentId, + payload: { + shipmentId, + distanceMeters: route.distanceMeters, + durationSeconds: route.durationSeconds, + provider: route.provider, + stopCount: route.stopsKey.split(',').length, + }, + })); + return { changed: true }; + } +} diff --git a/backend/src/di/modules/tms.ts b/backend/src/di/modules/tms.ts index b21ef460..4a810a69 100644 --- a/backend/src/di/modules/tms.ts +++ b/backend/src/di/modules/tms.ts @@ -126,6 +126,8 @@ import { UpdateCargoDiscrepancyCommandHandler, } from '../../commands/cargoTracking/index.js'; import { RecordGeofenceArrivalCommandHandler } from '../../commands/tracking/RecordGeofenceArrivalCommand.js'; +import { SetShipmentRouteCommandHandler } from '../../commands/shipments/SetShipmentRouteCommand.js'; +import { ShipmentRouteRepository } from '../../repositories/ShipmentRouteRepository.js'; import { RecordGeofenceDepartureCommandHandler } from '../../commands/tracking/RecordGeofenceDepartureCommand.js'; import { RecordJourneyCheckpointCommandHandler } from '../../commands/tracking/RecordJourneyCheckpointCommand.js'; import { AcknowledgeExcursionCommandHandler } from '../../commands/coldChain/AcknowledgeExcursionCommand.js'; @@ -278,6 +280,10 @@ export function registerTmsDependencies(prisma: PrismaClient): void { container.singleton(TOKENS.IDeviceRepository).toFactory(() => { return new DeviceRepository(container.resolve(TOKENS.PrismaClient)); }); + container.singleton(TOKENS.IShipmentRouteRepository).toFactory(() => { + return new ShipmentRouteRepository(container.resolve(TOKENS.PrismaClient)); + }); + container.singleton(TOKENS.ISensorReadingRepository).toFactory(() => { return new SensorReadingRepository(container.resolve(TOKENS.PrismaClient)); }); @@ -702,6 +708,7 @@ export function registerTmsCommandHandlers(bus: CommandBus, deps: CommandHandler // Journey tracking commands (full-journey proof: departure/checkpoint/arrival) bus.register(new RecordGeofenceArrivalCommandHandler(prisma, eventBus)); + bus.register(new SetShipmentRouteCommandHandler(prisma, eventBus)); bus.register(new RecordGeofenceDepartureCommandHandler(prisma, eventBus)); bus.register(new RecordJourneyCheckpointCommandHandler(prisma, eventBus)); diff --git a/backend/src/di/tokens.ts b/backend/src/di/tokens.ts index 18d18ed8..876a3f59 100644 --- a/backend/src/di/tokens.ts +++ b/backend/src/di/tokens.ts @@ -26,6 +26,7 @@ export const TOKENS = { ICargoTrackingRepository: Symbol.for('ICargoTrackingRepository'), IDeviceRepository: Symbol.for('IDeviceRepository'), ISensorReadingRepository: Symbol.for('ISensorReadingRepository'), + IShipmentRouteRepository: Symbol.for('IShipmentRouteRepository'), IIotVendorRepository: Symbol.for('IIotVendorRepository'), ITelemetryService: Symbol.for('ITelemetryService'), IIotVendorSettingsService: Symbol.for('IIotVendorSettingsService'), diff --git a/backend/src/events/eventTypes.ts b/backend/src/events/eventTypes.ts index ec3ddb94..c49105f9 100644 --- a/backend/src/events/eventTypes.ts +++ b/backend/src/events/eventTypes.ts @@ -24,6 +24,7 @@ export const EVENT_TYPES = { SHIPMENT_TAMPER_LIGHT: 'shipment.tamper_light', SHIPMENT_STOP_ARRIVED: 'shipment.stop_arrived', SHIPMENT_STOP_COMPLETED: 'shipment.stop_completed', + SHIPMENT_ROUTE_PLANNED: 'shipment.route_planned', SHIPMENT_ARCHIVED: 'shipment.archived', SHIPMENT_UNARCHIVED: 'shipment.unarchived', SHIPMENT_DELETED: 'shipment.deleted', @@ -427,6 +428,7 @@ export const EVENT_SCHEMA_VERSIONS: Record = { [EVENT_TYPES.SHIPMENT_TAMPER_LIGHT]: 1, [EVENT_TYPES.SHIPMENT_STOP_ARRIVED]: 1, [EVENT_TYPES.SHIPMENT_STOP_COMPLETED]: 1, + [EVENT_TYPES.SHIPMENT_ROUTE_PLANNED]: 1, [EVENT_TYPES.SHIPMENT_ARCHIVED]: 1, [EVENT_TYPES.SHIPMENT_UNARCHIVED]: 1, [EVENT_TYPES.SHIPMENT_DELETED]: 1, diff --git a/backend/src/events/handlers/ShipmentRoutePlanningHandler.ts b/backend/src/events/handlers/ShipmentRoutePlanningHandler.ts new file mode 100644 index 00000000..8f5cf895 --- /dev/null +++ b/backend/src/events/handlers/ShipmentRoutePlanningHandler.ts @@ -0,0 +1,30 @@ +/** + * Plans a custom-route shipment's road route when it's created or changed (#328). Runs off the + * request path because it calls the routing provider; a provider failure is rethrown so pg-boss + * retries it. + */ + +import { DomainEvent } from '../DomainEvent.js'; +import { IEventHandler } from '../IEventHandler.js'; +import { EVENT_TYPES } from '../eventTypes.js'; +import { IShipmentRoutePlanner } from '../../services/routing/ShipmentRoutePlanner.js'; + +export class ShipmentRoutePlanningHandler implements IEventHandler { + readonly name = 'shipment.route_planning'; + readonly eventPatterns = [EVENT_TYPES.SHIPMENT_CREATED, EVENT_TYPES.SHIPMENT_UPDATED]; + readonly options = { concurrency: 2, retryLimit: 3, expireInSeconds: 60 }; + + constructor(private planner: IShipmentRoutePlanner) {} + + async handle(event: DomainEvent): Promise { + try { + const outcome = await this.planner.plan(event.orgId, event.entityId); + if (outcome === 'planned' || outcome === 'cleared' || outcome === 'no_server_key') { + console.log('[ShipmentRoutePlanning] Route', { outcome, shipmentId: event.entityId, orgId: event.orgId }); + } + } catch (err) { + console.error('[ShipmentRoutePlanning] Planning failed', { shipmentId: event.entityId, orgId: event.orgId, err: (err as Error).message }); + throw err; + } + } +} diff --git a/backend/src/events/registerHandlers.ts b/backend/src/events/registerHandlers.ts index 9b894e6f..799529d6 100644 --- a/backend/src/events/registerHandlers.ts +++ b/backend/src/events/registerHandlers.ts @@ -57,6 +57,11 @@ import { AutoReplenishmentHandler } from './handlers/AutoReplenishmentHandler.js import { PackAuditIssueLinkHandler } from './handlers/PackAuditIssueLinkHandler.js'; import { BolGenerationHandler } from './handlers/BolGenerationHandler.js'; import { IDocumentGenerationService } from '../services/DocumentGenerationService.js'; +import { ShipmentRoutePlanningHandler } from './handlers/ShipmentRoutePlanningHandler.js'; +import { ShipmentRoutePlanner } from '../services/routing/ShipmentRoutePlanner.js'; +import { ShipmentRouteRepository } from '../repositories/ShipmentRouteRepository.js'; +import { OrganizationRepository } from '../repositories/OrganizationRepository.js'; +import { GoogleMapsDirectionsService } from '../services/routing/GoogleMapsDirectionsService.js'; /** Read concurrency from env with a default */ function envInt(key: string, fallback: number): number { @@ -186,6 +191,7 @@ export async function registerEventHandlers( // WMS: auto-replenishment - reacts to pick line completion / inventory adjustments and fires CHECK_REPLENISHMENT if (commandBus) { + handlers.push(shipmentRoutePlanningHandler(prisma, commandBus)); handlers.push(new AutoReplenishmentHandler(prisma, commandBus)); } @@ -234,3 +240,24 @@ export async function registerEventHandlers( console.log(`[EventBus] Registered handler: ${handler.name} (concurrency: ${options.concurrency ?? 'default'}, patterns: ${handler.eventPatterns.join(', ')})`); } } + +function shipmentRoutePlanningHandler(prisma: PrismaClient, commandBus: ICommandBus): ShipmentRoutePlanningHandler { + return new ShipmentRoutePlanningHandler(new ShipmentRoutePlanner( + new ShipmentRouteRepository(prisma), + new OrganizationRepository(prisma), + new GoogleMapsDirectionsService(), + commandBus, + )); +} + +/** + * Route planning for the embedded (API process) workers, which register the other handlers without + * a command bus (#328). The standalone worker gets it through registerEventHandlers instead, so it + * is never registered twice. + */ +export async function registerShipmentRoutePlanning(eventBus: IEventBus, prisma: PrismaClient, commandBus: ICommandBus): Promise { + const handler = shipmentRoutePlanningHandler(prisma, commandBus); + await eventBus.subscribe(handler.name, handler.eventPatterns, (event) => handler.handle(event), handler.options); + console.log(`[EventBus] Registered handler: ${handler.name} (patterns: ${handler.eventPatterns.join(', ')})`); +} + diff --git a/backend/src/index.ts b/backend/src/index.ts index ff6c0b5f..db7e4fde 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -24,7 +24,8 @@ import { createOrderAutoArchiveWorker, registerOrderAutoArchiveSchedule, ORDER_A import { OrderAutoArchiveService } from './services/OrderAutoArchiveService.js'; import { createCarrierUserAnonymizeWorker, registerCarrierUserAnonymizeSchedule, CARRIER_USER_ANONYMIZE_QUEUE } from './workers/carrierUserAnonymizeWorker.js'; import { CarrierUserAnonymizationService } from './services/CarrierUserAnonymizationService.js'; -import { registerEventHandlers } from './events/registerHandlers.js'; +import { registerEventHandlers, registerShipmentRoutePlanning } from './events/registerHandlers.js'; +import { ICommandBus } from './commands/CommandBus.js'; import type { IEventBus } from './events/IEventBus.js'; import { createWebhookRetryWorker, registerWebhookRetrySchedule, WEBHOOK_RETRY_QUEUE } from './workers/webhookRetryWorker.js'; import { CustomerWebhookDeliveryService } from './services/webhooks/CustomerWebhookDeliveryService.js'; @@ -161,6 +162,7 @@ async function start() { await registerEventHandlers( eventBus, server.prisma, undefined, undefined, undefined, undefined, undefined, documentService ); + await registerShipmentRoutePlanning(eventBus, server.prisma, container.resolve(TOKENS.ICommandBus)); await eventBus.start(); server.log.info('Embedded event handlers registered (projections + audit + notifications)'); } catch (err) { diff --git a/backend/src/repositories/ShipmentRouteRepository.ts b/backend/src/repositories/ShipmentRouteRepository.ts new file mode 100644 index 00000000..a0b46f4a --- /dev/null +++ b/backend/src/repositories/ShipmentRouteRepository.ts @@ -0,0 +1,81 @@ +/** + * Planned routes for shipments (#328). + * + * A shipment's route is its own `ShipmentRoute` when it's on a custom route, otherwise its lane's + * `LaneRoute`. Everything that measures a shipment against a route (checkpoints, deviation, the map) + * reads it through `findEffectiveRoute`, so the two cases never need handling separately. + */ + +import { PrismaClient } from '@prisma/client'; + +export interface EffectiveRoute { + source: 'shipment' | 'lane'; + encodedPolyline: string; + waypoints: unknown; + distanceMeters: number; + durationSeconds: number; + summary: string | null; + corridorMeters: number; +} + +export interface PlanningStop { + locationId: string; + lat: number | null; + lng: number | null; +} + +export interface ShipmentForPlanning { + status: string; + laneId: string | null; + /** In sequence order. */ + stops: PlanningStop[]; + /** stopsKey of the route already stored for the shipment, if any. */ + currentStopsKey: string | null; +} + +export interface IShipmentRouteRepository { + /** `undefined` when the shipment isn't in the org; `null` when it has no route. */ + findEffectiveRoute(orgId: string, shipmentId: string): Promise; + findForPlanning(orgId: string, shipmentId: string): Promise; +} + +const routeFields = { + encodedPolyline: true, waypoints: true, distanceMeters: true, durationSeconds: true, summary: true, corridorMeters: true, +} as const; + +export class ShipmentRouteRepository implements IShipmentRouteRepository { + constructor(private prisma: PrismaClient) {} + + async findEffectiveRoute(orgId: string, shipmentId: string) { + const shipment = await this.prisma.shipment.findFirst({ + where: { id: shipmentId, orgId }, + select: { route: { select: routeFields }, lane: { select: { route: { select: routeFields } } } }, + }); + if (!shipment) return undefined; + if (shipment.route) return { source: 'shipment' as const, ...shipment.route }; + if (shipment.lane?.route) return { source: 'lane' as const, ...shipment.lane.route }; + return null; + } + + async findForPlanning(orgId: string, shipmentId: string): Promise { + const shipment = await this.prisma.shipment.findFirst({ + where: { id: shipmentId, orgId }, + select: { + status: true, + laneId: true, + route: { select: { stopsKey: true } }, + stops: { + orderBy: { sequenceNumber: 'asc' }, + select: { locationId: true, location: { select: { lat: true, lng: true } } }, + }, + }, + }); + if (!shipment) return null; + return { + status: shipment.status, + laneId: shipment.laneId, + currentStopsKey: shipment.route?.stopsKey ?? null, + stops: shipment.stops.map((s) => ({ locationId: s.locationId, lat: s.location.lat, lng: s.location.lng })), + }; + } +} diff --git a/backend/src/routes/shipments.ts b/backend/src/routes/shipments.ts index 40952333..c9de3da8 100644 --- a/backend/src/routes/shipments.ts +++ b/backend/src/routes/shipments.ts @@ -18,6 +18,7 @@ import { import { registerOrgScope } from '../auth/orgScopeMiddleware.js'; import { requirePermission } from '../middleware/jwtAuth.js'; import { IOrderConversionService } from '../services/OrderConversionService.js'; +import { IShipmentRouteRepository } from '../repositories/ShipmentRouteRepository.js'; // Accepts YYYY-MM-DD (HTML date input), YYYY-MM-DDTHH:mm[:ss[.sss]][Z] (datetime-local), or full ISO. // Normalizes to a full ISO string. @@ -34,6 +35,7 @@ const flexibleDate = z.string().trim().min(1).transform((v, ctx) => { export async function shipmentRoutes(server: FastifyInstance) { const commandBus = container.resolve(TOKENS.ICommandBus); const conversionService = container.resolve(TOKENS.IOrderConversionService); + const shipmentRoutesRepo = container.resolve(TOKENS.IShipmentRouteRepository); await registerOrgScope(server); @@ -502,6 +504,24 @@ export async function shipmentRoutes(server: FastifyInstance) { // Full-journey proof: origin departure, in-transit checkpoints, destination // arrival (#283). Checkpoint count is bounded (~10), so no pagination. + // The shipment's planned route: its own for a custom route, otherwise its lane's (#328). + server.get('/api/v1/shipments/:id/route', { + schema: { + tags: ['Shipments'], + summary: 'Shipment planned route', + description: "The planned road route the shipment is measured against: its own route when it's on a custom route, otherwise its lane's. Data is null when it has neither.", + params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }, async (req: FastifyRequest, reply: FastifyReply) => { + const { id } = req.params as { id: string }; + const route = await shipmentRoutesRepo.findEffectiveRoute(req.orgId!, id); + if (route === undefined) { + reply.code(404); + return { data: null, error: 'Shipment not found' }; + } + return { data: route, error: null }; + }); + server.get('/api/v1/shipments/:id/journey', { schema: { tags: ['Shipments'], diff --git a/backend/src/services/ArrivalCriteriaEvaluationService.ts b/backend/src/services/ArrivalCriteriaEvaluationService.ts index 6a0ed8d8..8eb2a241 100644 --- a/backend/src/services/ArrivalCriteriaEvaluationService.ts +++ b/backend/src/services/ArrivalCriteriaEvaluationService.ts @@ -229,6 +229,7 @@ export class ArrivalCriteriaEvaluationService implements IArrivalCriteriaEvaluat }, }, }, + route: { select: { encodedPolyline: true } }, lane: { select: { route: { select: { encodedPolyline: true } } } }, }, }); @@ -328,13 +329,15 @@ export class ArrivalCriteriaEvaluationService implements IArrivalCriteriaEvaluat orgId: string; destinationId: string | null; stops: Array; + route: { encodedPolyline: string } | null; lane: { route: { encodedPolyline: string } | null } | null; }, originStop: (JourneyStop & { location: { arrivalCriteria: any[] } }) | null, ctx: DeviceEventContext, eventTime: string, ): Promise { - const routePolyline = shipment.lane?.route?.encodedPolyline; + // A custom-route shipment's own route, otherwise its lane's (#328). + const routePolyline = shipment.route?.encodedPolyline ?? shipment.lane?.route?.encodedPolyline; const destinationStop = findDestinationStop(shipment.stops, shipment.destinationId); if (!routePolyline || !originStop || !destinationStop) return; if (originStop.status !== 'completed') return; // hasn't departed origin yet diff --git a/backend/src/services/routing/ShipmentEtaMonitorService.ts b/backend/src/services/routing/ShipmentEtaMonitorService.ts index f9a657d6..b0570e06 100644 --- a/backend/src/services/routing/ShipmentEtaMonitorService.ts +++ b/backend/src/services/routing/ShipmentEtaMonitorService.ts @@ -19,6 +19,7 @@ import { IRouteDeviationService } from './RouteDeviationService.js'; import { IEventBus } from '../../events/IEventBus.js'; import { DomainEvent } from '../../events/DomainEvent.js'; import { EVENT_TYPES, EVENT_SCHEMA_VERSIONS } from '../../events/eventTypes.js'; +import { ShipmentRouteRepository } from '../../repositories/ShipmentRouteRepository.js'; /** Configurable thresholds for the monitor */ export interface EtaMonitorConfig { @@ -402,8 +403,8 @@ export class ShipmentEtaMonitorService implements IShipmentEtaMonitorService { }); } - // Route deviation check: if the shipment has a lane with a planned route, check deviation - if (this.routeDeviationService && shipment.laneId) { + // Route deviation check against the shipment's planned route: its own, or its lane's (#328) + if (this.routeDeviationService) { await this.checkRouteDeviation(shipment); } @@ -492,27 +493,23 @@ export class ShipmentEtaMonitorService implements IShipmentEtaMonitorService { if (!this.routeDeviationService) return; try { - const laneRoute = await this.prisma.laneRoute.findUnique({ - where: { laneId: shipment.laneId, orgId: shipment.orgId }, - }); - - if (!laneRoute) return; // No planned route for this lane + const route = await new ShipmentRouteRepository(this.prisma).findEffectiveRoute(shipment.orgId, shipment.id); + if (!route) return; // No planned route const result = this.routeDeviationService.checkDeviation( { lat: shipment.currentLat, lng: shipment.currentLng }, - laneRoute.encodedPolyline, - laneRoute.corridorMeters, + route.encodedPolyline, + route.corridorMeters, ); if (result.isDeviated) { - // Get lane name for the event - const lane = await this.prisma.lane.findUnique({ - where: { id: shipment.laneId, orgId: shipment.orgId }, - select: { name: true }, - }); + // Name the route for the event: the lane's, or "custom route" for a shipment's own + const lane = shipment.laneId + ? await this.prisma.lane.findUnique({ where: { id: shipment.laneId, orgId: shipment.orgId }, select: { name: true } }) + : { name: 'custom route' }; await this.publishRouteDeviationEvent(shipment, { - laneId: shipment.laneId, + laneId: shipment.laneId ?? null, laneName: lane?.name || 'Unknown', deviationMeters: result.deviationMeters, corridorMeters: result.corridorMeters, diff --git a/backend/src/services/routing/ShipmentRoutePlanner.ts b/backend/src/services/routing/ShipmentRoutePlanner.ts new file mode 100644 index 00000000..3f2a8970 --- /dev/null +++ b/backend/src/services/routing/ShipmentRoutePlanner.ts @@ -0,0 +1,91 @@ +/** + * Plans the road route for a shipment on a custom route (#328). + * + * BUSINESS RULES: + * - A shipment on a lane uses the lane's route; any route of its own is removed. + * - A custom-route shipment's route runs through its stops in sequence, and is only planned while + * the shipment is draft or ready: once it's moving, the plan it left with is what it's measured + * against. + * - The routing provider is only called when the stops have changed since the last plan. + * + * The provider call happens here, outside any transaction; the result is stored by + * SetShipmentRouteCommand. + */ + +import { randomUUID } from 'crypto'; +import { ICommandBus } from '../../commands/CommandBus.js'; +import { SET_SHIPMENT_ROUTE, SetShipmentRoutePayload } from '../../commands/shipments/SetShipmentRouteCommand.js'; +import { IShipmentRouteRepository, PlanningStop } from '../../repositories/ShipmentRouteRepository.js'; +import { IOrganizationRepository } from '../../repositories/OrganizationRepository.js'; +import { IGoogleMapsDirectionsService } from './GoogleMapsDirectionsService.js'; + +export type PlanOutcome = + | 'planned' | 'unchanged' | 'cleared' | 'uses_lane' | 'not_plannable_status' + | 'not_enough_stops' | 'no_server_key' | 'not_found'; + +const PLANNABLE_STATUSES = new Set(['draft', 'ready']); + +export interface IShipmentRoutePlanner { + plan(orgId: string, shipmentId: string): Promise; +} + +export class ShipmentRoutePlanner implements IShipmentRoutePlanner { + constructor( + private routes: IShipmentRouteRepository, + private organizations: IOrganizationRepository, + private directions: IGoogleMapsDirectionsService, + private commandBus: ICommandBus, + ) {} + + async plan(orgId: string, shipmentId: string): Promise { + const shipment = await this.routes.findForPlanning(orgId, shipmentId); + if (!shipment) return 'not_found'; + + if (shipment.laneId) { + if (shipment.currentStopsKey === null) return 'uses_lane'; + await this.store(orgId, { shipmentId, route: null }); + return 'cleared'; + } + if (!PLANNABLE_STATUSES.has(shipment.status)) return 'not_plannable_status'; + + const located = shipment.stops.filter((s): s is PlanningStop & { lat: number; lng: number } => s.lat != null && s.lng != null); + if (located.length < 2) return 'not_enough_stops'; + const stopsKey = located.map((s) => s.locationId).join(','); + if (stopsKey === shipment.currentStopsKey) return 'unchanged'; + + const apiKey = (await this.organizations.getSettings(orgId))?.googleMapsServerKey; + if (!apiKey) return 'no_server_key'; + + const points = located.map((s) => ({ lat: s.lat, lng: s.lng })); + const result = await this.directions.computeDirections(apiKey, { + origin: points[0], + destination: points[points.length - 1], + waypoints: points.length > 2 ? points.slice(1, -1) : undefined, + }); + + await this.store(orgId, { + shipmentId, + route: { + encodedPolyline: result.encodedPolyline, + waypoints: result.waypoints, + distanceMeters: result.distanceMeters, + durationSeconds: result.durationSeconds, + summary: result.summary, + provider: 'google', + stopsKey, + }, + }); + return 'planned'; + } + + private async store(orgId: string, payload: SetShipmentRoutePayload): Promise { + const result = await this.commandBus.dispatch({ + type: SET_SHIPMENT_ROUTE, + orgId, + actorId: null, + payload, + metadata: { correlationId: randomUUID(), source: 'route_planner' }, + }); + if (!result.success) throw new Error(result.error || 'Failed to store the shipment route'); + } +} diff --git a/backend/src/worker.ts b/backend/src/worker.ts index 3c28c7e6..375a7476 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -46,6 +46,7 @@ import { PromoteDecisionCommandHandler } from './commands/agentDecisions/Promote import { CreateIssueCommandHandler } from './commands/issues/CreateIssueCommand.js'; import { UpdateIssueCommandHandler } from './commands/issues/UpdateIssueCommand.js'; import { EscalateIssueCommandHandler } from './commands/issues/EscalateIssueCommand.js'; +import { SetShipmentRouteCommandHandler } from './commands/shipments/SetShipmentRouteCommand.js'; import { DEFAULT_TRIAGE_PROMPT, DEFAULT_TRIAGE_EVENTS } from './events/handlers/TriageAgentHandler.js'; import { SkillRegistry } from './services/skills/SkillRegistry.js'; import { DocumentGenerationService, IDocumentGenerationService } from './services/DocumentGenerationService.js'; @@ -184,6 +185,8 @@ async function startWorker() { bus.register(new CreateIssueCommandHandler(prisma, eventBus)); bus.register(new UpdateIssueCommandHandler(prisma, eventBus)); bus.register(new EscalateIssueCommandHandler(prisma, eventBus)); + // The route planning handler stores routes through this bus (#328). + bus.register(new SetShipmentRouteCommandHandler(prisma, eventBus)); workerCommandBus = bus; const source = org?.llmApiKey ? 'org config' : 'env var'; diff --git a/docs/DOMAIN_BEHAVIOURS.md b/docs/DOMAIN_BEHAVIOURS.md index 375b585c..c6a342f0 100644 --- a/docs/DOMAIN_BEHAVIOURS.md +++ b/docs/DOMAIN_BEHAVIOURS.md @@ -310,6 +310,7 @@ Two independent removal states, both retaining the row for audit: | `shipment.exception` | ShipmentReadModel.hasException = true (status unchanged) | In-app + email | — | | `shipment.stop_arrived` | ShipmentReadModel.stopCount updated | In-app | Orders at stop → delivery_status_changed | | `shipment.stop_completed` | ShipmentReadModel.stopCount updated | In-app | Orders at stop → delivered | +| `shipment.route_planned` | — | — | `SetShipmentRouteCommand` stored a custom-route shipment's planned route (#328) | | `shipment.archived` | ShipmentReadModel row removed | — | — | | `shipment.unarchived` | ShipmentReadModel row re-inserted | — | — | | `shipment.deleted` | ShipmentReadModel row removed | — | — | @@ -391,6 +392,19 @@ has a `LaneRoute`, and hasn't yet arrived at its destination, its position is lo shipment's full journey via `GET /api/v1/shipments/:id/journey`. Waypoints are covered by #324 below; there is still no GPS-jitter hysteresis on the geofence boundary. +**Shipment routes (#328).** A shipment is measured against one planned route: +`ShipmentRouteRepository.findEffectiveRoute` returns its own `ShipmentRoute` when it's on a custom route +(no lane), otherwise its lane's `LaneRoute`. Journey checkpoints, route deviation in the ETA monitor, +`GET /api/v1/shipments/:id/route` and the shipment map all read it. `ShipmentRoutePlanningHandler` +(on `shipment.created` / `shipment.updated`) runs `ShipmentRoutePlanner`: a lane shipment rides its +lane and any route of its own is removed; a custom-route shipment that is `draft` or `ready` gets a +Google Directions route through its stops in sequence, stored by `SetShipmentRouteCommand`, which +emits `shipment.route_planned`. The provider is called outside any transaction and only when the stop +sequence changed (`ShipmentRoute.stopsKey`); with no Google Maps server key in the org's maps settings +planning is skipped and logged. Once a shipment is moving, its route isn't re-planned. Known gap: the +standalone worker only has a command bus when an LLM provider is configured, so without one it doesn't +run command-driven handlers, this one included. + **Multi-stop shipments (#324).** Stops are matched one at a time, not by location (`services/tracking/journeyStops.ts`). For each location, a ping can only act on that location's lowest-sequence open stop, and a repeat visit (the same location later in the route) only becomes diff --git a/docs/roadmap/completed.md b/docs/roadmap/completed.md index 3aeef491..1d899fdc 100644 --- a/docs/roadmap/completed.md +++ b/docs/roadmap/completed.md @@ -52,6 +52,7 @@ and priorities). Newest entries append at the bottom of their phase; new phases - **ETA Monitoring** - Provider-agnostic routing (TomTom/HERE/Valhalla), adaptive polling, three delay severity levels, traffic-aware ETAs, pg-boss cron, API endpoints - **Carrier Tracking API Integrations** - ICarrierTrackingProvider interface, FedEx/UPS/DHL implementations, polling worker, webhook receiver, admin setup wizard - **Route Deviation Alerts** - Planned route per lane via Google Maps, corridor-based deviation detection, real-time alerts +- **Shipment Routes (#328, part 1)** - Shipments on a custom route (no lane) get their own planned road route, calculated through their stops in order when they're created or changed while draft or ready, and only when the stops change. Checkpoints, route deviation alerts and the shipment map use the shipment's own route, or its lane's. - **Exceptions** - Exception status with type classification, resolution workflow, event-driven notifications, ETA-based auto-detection - **Normalised Tracking Pings (#323)** - Every IoT payload (System Loco or the generic webhook) is parsed into one internal ping: device timestamp, position, a readings array (temperature, battery, light, pressure) and a readings count. Generic pings now store their telemetry against the registered device (idempotent on retry) and drive arrivals, origin departure and journey checkpoints, which previously only System Loco did. Arrival, departure, checkpoint and order-delivery times use the device's timestamp instead of processing time. Checkpoints skipped between sparse pings are filled in on the planned route with interpolated times. - **Multi-Stop Geofence Journeys (#324)** - Every stop on a shipment now completes from geofence events, not just the origin and destination: entering a delivery stop's geofence completes it and delivers its orders, in any order. The shipment only completes once every stop is done; reaching the final stop with stops unvisited raises a `stops_not_visited` exception. Repeat visits to the same location are handled as separate stops. Departing the origin moves a ready shipment to in progress, checkpoints exclude the origin and destination geofences, events carry the device id, and the timeline shows origin arrival (#307 items 1-3). diff --git a/frontend/src/vnext-design/VNextShipmentDetail.tsx b/frontend/src/vnext-design/VNextShipmentDetail.tsx index b94709c3..17d75e19 100644 --- a/frontend/src/vnext-design/VNextShipmentDetail.tsx +++ b/frontend/src/vnext-design/VNextShipmentDetail.tsx @@ -2040,23 +2040,21 @@ export default function VNextShipmentDetail() { .catch(() => { }); }, [shipment?.laneId, shipment?.status, shipment?.currentLat, shipment?.currentLng]); - // Fetch the lane's planned route (if any) so it can be overlaid on the map. + // Fetch the shipment's planned route (if any) so it can be overlaid on the map: its own for a + // custom route, otherwise its lane's (#328). // laneRoute stays `undefined` while this is unresolved, then settles to - // either the route object or `null` (no lane / no saved route) — that + // either the route object or `null` (no route) — that // three-way state lets the default-visibility effect below tell "still // loading" apart from "confirmed no route". useEffect(() => { - // Wait for the shipment itself to load first — otherwise `shipment` is - // still null on mount, `shipment?.laneId` reads as undefined, and this - // would prematurely resolve laneRoute to `null` ("no route") before the - // real laneId is even known, locking in the wrong default below. - if (!shipment) return; - if (!shipment.laneId) { setLaneRoute(null); return; } - fetch(`${API_URL}/api/v1/lanes/${shipment.laneId}/route`) + // Wait for the shipment itself to load first, so laneRoute doesn't settle to `null` ("no + // route") before there's a shipment to ask about, locking in the wrong default below. + if (!shipment?.id) return; + fetch(`${API_URL}/api/v1/shipments/${shipment.id}/route`) .then(r => r.json()) .then(json => setLaneRoute(json.error ? null : (json.data ?? null))) .catch(() => setLaneRoute(null)); - }, [shipment?.laneId, !!shipment]); + }, [shipment?.id, shipment?.laneId]); // Default the map to whichever layer is more useful: once we know whether // a planned route exists, show the route (and hide the plain straight