Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
26 changes: 26 additions & 0 deletions backend/prisma/schema/tms.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
53 changes: 53 additions & 0 deletions backend/src/__tests__/commands/SetShipmentRouteCommand.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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<string, { lat: number; lng: number }> = {
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down Expand Up @@ -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' })]]);
});
});

106 changes: 106 additions & 0 deletions backend/src/__tests__/services/ShipmentRoutePlanner.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
80 changes: 80 additions & 0 deletions backend/src/commands/shipments/SetShipmentRouteCommand.ts
Original file line number Diff line number Diff line change
@@ -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<SetShipmentRoutePayload, { changed: boolean }> {
readonly commandType = SET_SHIPMENT_ROUTE;
constructor(prisma: PrismaClient, eventBus: PgBossEventBus) { super(prisma, eventBus); }

protected async handle(command: Command<SetShipmentRoutePayload>, 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 };
}
}
7 changes: 7 additions & 0 deletions backend/src/di/modules/tms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
});
Expand Down Expand Up @@ -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));

Expand Down
1 change: 1 addition & 0 deletions backend/src/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading
Loading