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
11 changes: 11 additions & 0 deletions backend/prisma/migrations/20261005_02_other_stops/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Stops that are neither pickups nor drops (#345): stopType 'other' with a purpose and an optional
-- name, on shipment stops and on the consolidation stops that serve them. No index change.

-- AlterTable
ALTER TABLE "ConsolidationStop" ADD COLUMN "label" TEXT,
ADD COLUMN "purpose" TEXT;

-- AlterTable
ALTER TABLE "ShipmentStop" ADD COLUMN "label" TEXT,
ADD COLUMN "purpose" TEXT;

10 changes: 8 additions & 2 deletions backend/prisma/schema/tms.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,10 @@ model ShipmentStop {
sequenceNumber Int

// Type of stop
stopType String @default("delivery") // pickup, delivery, both
stopType String @default("delivery") // pickup, delivery, both, other
// For `other` stops (#345): fuel, rest, customs, cross_dock, hub, inspection, other; and an optional name
purpose String?
label String?

// Timing
estimatedArrival DateTime?
Expand Down Expand Up @@ -247,7 +250,10 @@ model ConsolidationStop {
location Location @relation(fields: [locationId], references: [id])
locationId String
sequenceNumber Int
stopType String // pickup, delivery
stopType String // pickup, delivery, other
// For `other` stops (#345), copied from the shipment stops it serves
purpose String?
label String?
status String @default("pending") // pending, arrived, completed, skipped
shipmentStops ShipmentStop[]
createdAt DateTime @default(now())
Expand Down
17 changes: 17 additions & 0 deletions backend/src/__tests__/commands/OrderDeliveryCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,23 @@ describe('RecordStopOrdersDeliveryCommandHandler (#325)', () => {
});
});

describe('RecordStopOrdersDeliveryCommandHandler on an other stop (#345)', () => {
it.each(['arrived', 'completed'])('records the stop as %s and changes no orders', async (status) => {
const { tx, prisma } = mockTx();
tx.shipmentStop.findFirst.mockResolvedValue(stopRow('stop-mid', 'other'));
const handler = new RecordStopOrdersDeliveryCommandHandler(prisma, mockEventBus().bus);

const result = await handler.execute(createTestCommand(RECORD_STOP_ORDERS_DELIVERY, {
stopId: 'stop-mid', status, method: 'geofence', occurredAt: OCCURRED,
}));

expect(result.data).toEqual({ ordersUpdated: 0, shipmentId: 'ship-1' });
expect(tx.shipmentStop.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status }) }));
expect(tx.order.findMany).not.toHaveBeenCalled();
expect(result.events).toEqual([]);
});
});

describe('ChangeOrderDeliveryStatusCommandHandler (#325)', () => {
const order = { id: 'o1', orderNumber: 'ORD-1', orgId: 'test-org', deliveryStatus: 'in_transit' };

Expand Down
25 changes: 25 additions & 0 deletions backend/src/__tests__/commands/consolidations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,3 +382,28 @@ describe('Carrier rate (#329)', () => {
expect(db.tx.charge.findMany).not.toHaveBeenCalled();
});
});

describe('Other stops on a run (#345)', () => {
const withFuel = (): FakeShipment[] => [
{ id: 'ship-a', reference: 'SH-A', status: 'draft', archived: false, orgId: 'test-org', stops: [stop('a1', 'GB', 1, 'pickup'), { ...stop('a2', 'FUEL', 2, 'other'), purpose: 'fuel' } as any, stop('a3', 'ATL', 3)] },
{ id: 'ship-b', reference: 'SH-B', status: 'draft', archived: false, orgId: 'test-org', stops: [stop('b1', 'CHI', 1, 'pickup'), stop('b2', 'MEM', 2), { ...stop('b3', 'REST', 3, 'other'), purpose: 'rest' } as any, stop('b4', 'ATL', 4)] },
];

it('keeps other stops on the run: before the drops when visited before any, after them otherwise', async () => {
const db = fakeDb(withFuel());
await create(db, ['ship-a', 'ship-b']);
expect(db.route()).toEqual(['1:pickup:GB', '2:other:FUEL', '3:pickup:CHI', '4:delivery:ATL', '5:delivery:MEM', '6:other:REST']);
expect(db.linkOf('a2')).toBe('other:FUEL');
// B now ends at its rest stop, but its destination stays its last drop.
expect(db.shipmentRoute('ship-b')).toEqual(['CHI', 'ATL', 'MEM', 'REST']);
expect(db.shipments[1].destinationId).toBe('MEM');
expect(db.tx.consolidationStop.create).toHaveBeenCalledWith({ data: expect.objectContaining({ locationId: 'FUEL', stopType: 'other', purpose: 'fuel' }) });
});

it('lets other stops go anywhere when reordering, but never a pickup after a drop', () => {
const stops = [{ id: 'p1', stopType: 'pickup' }, { id: 'o1', stopType: 'other' }, { id: 'd1', stopType: 'delivery' }];
expect(reorderProblem(stops, ['o1', 'p1', 'd1'])).toBeNull();
expect(reorderProblem(stops, ['p1', 'd1', 'o1'])).toBeNull();
expect(reorderProblem(stops, ['d1', 'o1', 'p1'])).toBe('Every pickup has to come before every drop.');
});
});
29 changes: 27 additions & 2 deletions backend/src/__tests__/commands/syncShipmentStops.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { syncShipmentStops, StopStillHasOrdersError } from '../../commands/shipments/syncShipmentStops';

interface StoredStop { id: string; locationId: string; orders?: number }
interface StoredStop { id: string; locationId: string; orders?: number; stopType?: string }

/** An in-memory stop table: tracks creates, updates and deletes so the final route can be read back. */
function mockTx(stored: StoredStop[] = []) {
let rows = stored.map((s, i) => ({ id: s.id, locationId: s.locationId, sequenceNumber: i + 1, stopType: 'delivery', orders: s.orders ?? 0 }));
let rows: any[] = stored.map((s, i) => ({ id: s.id, locationId: s.locationId, sequenceNumber: i + 1, stopType: s.stopType ?? 'delivery', orders: s.orders ?? 0 }));
let nextId = 1;
const tx = {
shipmentStop: {
Expand Down Expand Up @@ -57,4 +57,29 @@ describe('syncShipmentStops', () => {
.rejects.toBeInstanceOf(StopStillHasOrdersError);
expect(tx.shipmentStop.deleteMany).not.toHaveBeenCalled();
});

describe('other stops (#345)', () => {
const fuel = (locationId: string, label?: string) => ({ locationId, stopType: 'other' as const, purpose: 'fuel', label });

it('places other stops where they are listed, with their purpose and name', async () => {
const { tx, route } = mockTx();
await syncShipmentStops(tx, { orgId: 'org-1', shipmentId: 's1', originId: 'O', pickupWaypoints: [fuel('F1', ' Truck stop ')], waypoints: ['W1', fuel('F2')], destinationId: 'D' });
expect(route()).toEqual(['1:O:pickup', '2:F1:other', '3:W1:delivery', '4:F2:other', '5:D:delivery']);
expect(tx.shipmentStop.create).toHaveBeenCalledWith({ data: expect.objectContaining({ locationId: 'F1', stopType: 'other', purpose: 'fuel', label: 'Truck stop' }) });
expect(tx.shipmentStop.create).toHaveBeenCalledWith({ data: expect.objectContaining({ locationId: 'W1', stopType: 'delivery', purpose: null, label: null }) });
});

it('never turns a drop that orders still use into an other stop', async () => {
const { tx } = mockTx([{ id: 'stop-o', locationId: 'O' }, { id: 'stop-w', locationId: 'W1', orders: 2 }, { id: 'stop-d', locationId: 'D' }]);
await expect(syncShipmentStops(tx, { orgId: 'org-1', shipmentId: 's1', originId: 'O', waypoints: [fuel('W1')], destinationId: 'D' }))
.rejects.toBeInstanceOf(StopStillHasOrdersError);
});

it('keeps an existing other stop and updates its details', async () => {
const { tx, idAt } = mockTx([{ id: 'stop-o', locationId: 'O' }, { id: 'stop-f', locationId: 'F1', stopType: 'other' }, { id: 'stop-d', locationId: 'D' }]);
await syncShipmentStops(tx, { orgId: 'org-1', shipmentId: 's1', originId: 'O', waypoints: [{ locationId: 'F1', stopType: 'other', purpose: 'customs', label: null }], destinationId: 'D' });
expect(idAt('F1')).toBe('stop-f');
expect(tx.shipmentStop.update).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'stop-f' }), data: expect.objectContaining({ stopType: 'other', purpose: 'customs' }) }));
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -196,5 +196,41 @@ describe('ArrivalCriteriaEvaluationService', () => {
expect(deliveryService.updateOrdersForStop).toHaveBeenCalledWith('org-1', 'stop-mid', 'completed', 'geofence', expect.any(Date));
});
});
});

describe('an other stop (#345)', () => {
const viaFuel = (mid: Partial<StopSpec>): StopSpec[] => [
{ id: 'stop-origin', locationId: 'loc-origin', sequenceNumber: 1, stopType: 'pickup', status: 'completed' },
{ id: 'stop-fuel', locationId: 'loc-mid', sequenceNumber: 2, stopType: 'other', ...mid },
{ id: 'stop-dest', locationId: 'loc-dest', sequenceNumber: 3, stopType: 'delivery' },
];

it('arrives on entry without completing, like a pickup', async () => {
const { ping, dispatched, deliveryService } = setup(viaFuel({}));
await ping('loc-mid');
expect(dispatched()).toEqual([[RECORD_GEOFENCE_ARRIVAL, expect.objectContaining({ stopId: 'stop-fuel', completesStop: false })]]);
expect(deliveryService.updateOrdersForStop).toHaveBeenCalledWith('org-1', 'stop-fuel', 'arrived', 'geofence', expect.any(Date));
});

it('completes when the vehicle leaves, so both times are recorded', async () => {
const { ping, dispatched, deliveryService } = setup(viaFuel({ status: 'arrived' }));
await ping('loc-mid', { lat: 40.75 });
expect(dispatched()).toEqual([[RECORD_GEOFENCE_DEPARTURE, expect.objectContaining({ stopId: 'stop-fuel' })]]);
expect(deliveryService.updateOrdersForStop).toHaveBeenCalledWith('org-1', 'stop-fuel', 'completed', 'geofence', expect.any(Date));
});

it('departs an other stop once, with the position, when the next ping is already at a later stop', async () => {
const { ping, dispatched } = setup(viaFuel({ status: 'arrived' }));
await ping('loc-dest');
expect(dispatched()).toEqual([
[RECORD_GEOFENCE_DEPARTURE, expect.objectContaining({ stopId: 'stop-fuel', lat: 41 })],
[RECORD_GEOFENCE_ARRIVAL, expect.objectContaining({ stopId: 'stop-dest', completesStop: true })],
]);
});

it('does not invent a visit to an other stop the vehicle never reached', async () => {
const { ping, dispatched } = setup(viaFuel({}));
await ping('loc-dest');
expect(dispatched()).toEqual([[RECORD_GEOFENCE_ARRIVAL, expect.objectContaining({ stopId: 'stop-dest' })]]);
});
});
});
7 changes: 7 additions & 0 deletions backend/src/__tests__/services/customerShipmentView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,10 @@ describe('scopeShipmentToCustomer on a consolidation (#329)', () => {
expect(JSON.stringify(view)).not.toContain('run-stop');
});
});

describe('scopeShipmentToCustomer with other stops (#345)', () => {
it('leaves fuel, rest and customs stops out of the customer view', () => {
const view = scopeShipmentToCustomer({ ...shared, stops: [shared.stops[0], { ...stop('other', 'truck-stop', []), purpose: 'fuel' }, shared.stops[1]] }, 'nordic');
expect(view.stops.map((s) => s.locationId)).toEqual(['green-bay', 'rochester']);
});
});
25 changes: 25 additions & 0 deletions backend/src/__tests__/shared/stopPurposes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { laneStopOtherPurpose, stopTypeLabel, isOtherStopPurpose } from '@open-tms/shared';

describe('stop purposes (#345)', () => {
it('turns pass-through lane stops into other stops and leaves the rest as drops', () => {
expect(laneStopOtherPurpose('fuel')).toBe('fuel');
expect(laneStopOtherPurpose('cross_dock')).toBe('cross_dock');
expect(laneStopOtherPurpose('other')).toBe('other');
expect(laneStopOtherPurpose('dropoff')).toBeNull();
expect(laneStopOtherPurpose('pickup')).toBeNull();
expect(laneStopOtherPurpose(null)).toBeNull();
});

it('names an other stop by its own name, then its purpose', () => {
expect(stopTypeLabel({ stopType: 'other', purpose: 'customs', label: 'Laredo crossing' })).toBe('Laredo crossing');
expect(stopTypeLabel({ stopType: 'other', purpose: 'cross_dock', label: ' ' })).toBe('Cross-dock');
expect(stopTypeLabel({ stopType: 'other', purpose: 'nonsense' })).toBe('Stop');
expect(stopTypeLabel({ stopType: 'pickup' })).toBe('Pickup');
expect(stopTypeLabel({ stopType: 'delivery' })).toBe('Drop');
});

it('only accepts the known purposes', () => {
expect(isOtherStopPurpose('rest')).toBe(true);
expect(isOtherStopPurpose('dropoff')).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export function reorderProblem(stops: Array<{ id: string; stopType: string }>, s
if (stopIds.length !== stops.length || new Set(stopIds).size !== stopIds.length || stopIds.some((s) => !known.has(s))) {
return 'The new order must list every stop on the consolidation once.';
}
const types = stopIds.map((sid) => stops.find((s) => s.id === sid)!.stopType);
// Other stops (#345) can go anywhere; only pickups and drops are held to their order.
const types = stopIds.map((sid) => stops.find((s) => s.id === sid)!.stopType).filter((t) => t !== 'other');
const firstDrop = types.indexOf('delivery');
if (firstDrop !== -1 && types.slice(firstDrop).includes('pickup')) {
return 'Every pickup has to come before every drop.';
Expand Down
62 changes: 44 additions & 18 deletions backend/src/commands/consolidations/consolidationMembership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,23 @@ export async function releaseShipment(
});
}

type StopKind = 'pickup' | 'delivery';
type StopKind = 'pickup' | 'delivery' | 'other';

/** A shipment's first stop is always where it is collected, whatever its type says. */
/** A shipment's first stop is always where it is collected, whatever its type says. Other stops stay other (#345). */
function kindOf(stop: { stopType: string; sequenceNumber: number }): StopKind {
if (stop.stopType === 'other') return 'other';
return stop.sequenceNumber === 1 || stop.stopType === 'pickup' || stop.stopType === 'both' ? 'pickup' : 'delivery';
}

interface RunStopWanted {
kind: StopKind;
locationId: string;
purpose: string | null;
label: string | null;
/** Where a new stop joins the run: among the pickups, or after the drops. */
section: 'pickup' | 'delivery';
}

/**
* Rebuilds the consolidation's stops from its shipments' stops, links each shipment stop to the
* consolidation stop that serves it, and puts each shipment's stops in the run's order.
Expand All @@ -101,15 +111,23 @@ export async function rebuildConsolidationStops(
const members = await tx.consolidationShipment.findMany({
where: { consolidationId, shipment: { orgId } },
orderBy: { addedAt: 'asc' },
select: { shipment: { select: { stops: { orderBy: { sequenceNumber: 'asc' }, select: { id: true, locationId: true, stopType: true, sequenceNumber: true } } } } },
select: { shipment: { select: { stops: { orderBy: { sequenceNumber: 'asc' }, select: { id: true, locationId: true, stopType: true, sequenceNumber: true, purpose: true, label: true } } } } },
});
const shipmentStops = members.flatMap((m) => {
const firstDrop = m.shipment.stops.find((s) => kindOf(s) === 'delivery')?.sequenceNumber ?? Number.MAX_SAFE_INTEGER;
return m.shipment.stops.map((s) => {
const kind = kindOf(s);
// An other stop belongs with the pickups if its shipment visits it before any drop.
const section: 'pickup' | 'delivery' = kind === 'pickup' || (kind === 'other' && s.sequenceNumber < firstDrop) ? 'pickup' : 'delivery';
return { ...s, kind, section };
});
});
const shipmentStops = members.flatMap((m) => m.shipment.stops.map((s) => ({ ...s, kind: kindOf(s) })));
const key = (kind: string, locationId: string) => `${kind}:${locationId}`;

const needed = new Map<string, { kind: StopKind; locationId: string }>();
for (const kind of ['pickup', 'delivery'] as const) {
for (const s of shipmentStops.filter((st) => st.kind === kind)) {
if (!needed.has(key(kind, s.locationId))) needed.set(key(kind, s.locationId), { kind, locationId: s.locationId });
const needed = new Map<string, RunStopWanted>();
for (const s of shipmentStops) {
if (!needed.has(key(s.kind, s.locationId))) {
needed.set(key(s.kind, s.locationId), { kind: s.kind, locationId: s.locationId, purpose: s.purpose, label: s.label, section: s.section });
}
}

Expand All @@ -122,20 +140,26 @@ export async function rebuildConsolidationStops(
const unused = existing.filter((s) => !needed.has(key(s.stopType, s.locationId))).map((s) => s.id);
if (unused.length > 0) await tx.consolidationStop.deleteMany({ where: { id: { in: unused }, consolidationId, consolidation: { orgId } } });

const kept = existing.filter((s) => needed.has(key(s.stopType, s.locationId)));
const order: Array<{ kind: StopKind; locationId: string }> = [];
for (const kind of ['pickup', 'delivery'] as const) {
order.push(...kept.filter((s) => s.stopType === kind).map((s) => ({ kind, locationId: s.locationId })));
order.push(...[...needed.values()].filter((w) => w.kind === kind && !byKey.has(key(kind, w.locationId))));
// Kept stops keep their order, hand-set or not. A new pickup-section stop goes just before the
// first drop, so every pickup stays ahead of every drop; anything else goes on the end.
const order: RunStopWanted[] = existing
.filter((s) => needed.has(key(s.stopType, s.locationId)))
.map((s) => needed.get(key(s.stopType, s.locationId))!);
for (const w of needed.values()) {
if (byKey.has(key(w.kind, w.locationId))) continue;
const firstDrop = order.findIndex((o) => o.kind === 'delivery');
if (w.section === 'pickup' && firstDrop !== -1) order.splice(firstDrop, 0, w);
else order.push(w);
}

for (const [i, w] of order.entries()) {
const id = byKey.get(key(w.kind, w.locationId));
const details = w.kind === 'other' ? { purpose: w.purpose, label: w.label } : {};
if (id) {
await tx.consolidationStop.update({ where: { id, consolidationId, consolidation: { orgId } }, data: { sequenceNumber: i + 1 } });
await tx.consolidationStop.update({ where: { id, consolidationId, consolidation: { orgId } }, data: { sequenceNumber: i + 1, ...details } });
} else {
const created = await tx.consolidationStop.create({
data: { consolidationId, locationId: w.locationId, stopType: w.kind, sequenceNumber: i + 1 },
data: { consolidationId, locationId: w.locationId, stopType: w.kind, sequenceNumber: i + 1, ...details },
});
byKey.set(key(w.kind, w.locationId), created.id);
}
Expand Down Expand Up @@ -167,7 +191,7 @@ export async function alignShipmentStops(tx: TransactionClient, orgId: string, c
id: true,
originId: true,
destinationId: true,
stops: { select: { id: true, sequenceNumber: true, locationId: true, consolidationStop: { select: { sequenceNumber: true } } } },
stops: { select: { id: true, sequenceNumber: true, locationId: true, stopType: true, consolidationStop: { select: { sequenceNumber: true } } } },
},
},
},
Expand All @@ -181,8 +205,10 @@ export async function alignShipmentStops(tx: TransactionClient, orgId: string, c
if (target.length === 0) continue;

const reordered = target.some((s, i) => s.id !== current[i].id);
const originId = target[0].locationId;
const destinationId = target[target.length - 1].locationId;
// Origin and destination are where freight is loaded and unloaded, never a fuel or rest stop (#345).
const freightStops = target.filter((s) => s.stopType !== 'other');
const originId = freightStops[0]?.locationId ?? shipment.originId;
const destinationId = freightStops[freightStops.length - 1]?.locationId ?? shipment.destinationId;
if (!reordered && originId === shipment.originId && destinationId === shipment.destinationId) continue;

if (reordered) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ export class RecordStopOrdersDeliveryCommandHandler extends BaseCommandHandler<R

const firstPickup = findOriginStop(stop.shipment.stops, stop.shipment.originId);
const isPickup = stop.stopType === 'pickup' || stop.id === firstPickup?.id;
const change = ordersToChange(isPickup, status);
// BUSINESS RULE: an other stop (fuel, rest, customs…) loads and unloads nothing, so reaching or
// leaving it never changes an order (#345). Its own status and times are still recorded above.
const change = stop.stopType === 'other' ? null : ordersToChange(isPickup, status);
if (!change) return { ordersUpdated: 0, shipmentId: stop.shipmentId };

const orders = await tx.order.findMany({
Expand Down
Loading
Loading