From 67dcffce86602d18fe90b5b2d7b86b4cd88f7f21 Mon Sep 17 00:00:00 2001 From: Dominic Finn Date: Mon, 5 Oct 2026 10:01:52 +0100 Subject: [PATCH 1/3] Add other stops: neither pickups nor drops (#345) A waypoint can be an other stop (stopType 'other') with a purpose (fuel, rest, customs, cross-dock, hub, inspection, other) and an optional name. Tracking arrives it on entry and completes it on departure, so the dwell is recorded, but it never moves an order; orders are never linked to one and stop sync never converts a stop into or out of one. Lane stops tagged with a pass-through purpose become other stops on shipments using the lane. Consolidation runs carry them, let them be reordered anywhere, and never make one a shipment's origin or destination. The customer portal hides them. The create and edit page let any pickup or drop waypoint be marked as an other stop, and stop labels show the name or purpose wherever stops are listed. A ping that departs a stop no longer also infers a second departure for it on the same ping. Co-Authored-By: Claude Opus 5.5 --- .../20261005_02_other_stops/migration.sql | 11 ++ backend/prisma/schema/tms.prisma | 10 +- .../commands/OrderDeliveryCommands.test.ts | 17 ++ .../__tests__/commands/consolidations.test.ts | 25 +++ .../commands/syncShipmentStops.test.ts | 29 +++- .../ArrivalCriteriaEvaluationService.test.ts | 38 ++++- .../services/customerShipmentView.test.ts | 7 + .../src/__tests__/shared/stopPurposes.test.ts | 25 +++ .../ReorderConsolidationStopsCommand.ts | 3 +- .../consolidations/consolidationMembership.ts | 62 +++++-- .../orders/RecordStopOrdersDeliveryCommand.ts | 4 +- .../shipments/CreateShipmentCommand.ts | 6 +- .../shipments/UpdateShipmentCommand.ts | 6 +- .../commands/shipments/syncShipmentStops.ts | 73 ++++++--- .../repositories/CargoTrackingRepository.ts | 5 + .../repositories/ConsolidationRepository.ts | 2 + backend/src/routes/shipments.ts | 20 ++- .../ArrivalCriteriaEvaluationService.ts | 39 +++-- .../services/portal/customerShipmentView.ts | 2 + docs/DOMAIN_BEHAVIOURS.md | 17 +- docs/roadmap/completed.md | 1 + .../lib/__tests__/shipmentFromOrders.test.ts | 15 +- frontend/src/lib/shipmentFromOrders.ts | 27 ++- .../CustomerShipmentDetail.tsx | 3 +- .../src/vnext-design/ConsolidationRunMap.tsx | 11 +- frontend/src/vnext-design/VNextBolView.tsx | 3 +- .../vnext-design/VNextConsolidationDetail.tsx | 23 ++- .../src/vnext-design/VNextCreateShipment.tsx | 154 +++++++++++++++--- .../src/vnext-design/VNextLocationOps.tsx | 3 +- .../src/vnext-design/VNextShipmentDetail.tsx | 5 +- packages/shared/src/index.ts | 1 + packages/shared/src/stopPurposes.ts | 41 +++++ 32 files changed, 567 insertions(+), 121 deletions(-) create mode 100644 backend/prisma/migrations/20261005_02_other_stops/migration.sql create mode 100644 backend/src/__tests__/shared/stopPurposes.test.ts create mode 100644 packages/shared/src/stopPurposes.ts diff --git a/backend/prisma/migrations/20261005_02_other_stops/migration.sql b/backend/prisma/migrations/20261005_02_other_stops/migration.sql new file mode 100644 index 00000000..baac7915 --- /dev/null +++ b/backend/prisma/migrations/20261005_02_other_stops/migration.sql @@ -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; + diff --git a/backend/prisma/schema/tms.prisma b/backend/prisma/schema/tms.prisma index 9212aa08..5ee38709 100644 --- a/backend/prisma/schema/tms.prisma +++ b/backend/prisma/schema/tms.prisma @@ -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? @@ -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()) diff --git a/backend/src/__tests__/commands/OrderDeliveryCommands.test.ts b/backend/src/__tests__/commands/OrderDeliveryCommands.test.ts index 4a4ddc4e..e32b1a7f 100644 --- a/backend/src/__tests__/commands/OrderDeliveryCommands.test.ts +++ b/backend/src/__tests__/commands/OrderDeliveryCommands.test.ts @@ -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' }; diff --git a/backend/src/__tests__/commands/consolidations.test.ts b/backend/src/__tests__/commands/consolidations.test.ts index 54b56484..95c903cc 100644 --- a/backend/src/__tests__/commands/consolidations.test.ts +++ b/backend/src/__tests__/commands/consolidations.test.ts @@ -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.'); + }); +}); diff --git a/backend/src/__tests__/commands/syncShipmentStops.test.ts b/backend/src/__tests__/commands/syncShipmentStops.test.ts index 07e4e369..93056abe 100644 --- a/backend/src/__tests__/commands/syncShipmentStops.test.ts +++ b/backend/src/__tests__/commands/syncShipmentStops.test.ts @@ -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: { @@ -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' }) })); + }); + }); }); diff --git a/backend/src/__tests__/services/ArrivalCriteriaEvaluationService.test.ts b/backend/src/__tests__/services/ArrivalCriteriaEvaluationService.test.ts index 0b5821d1..e33040f9 100644 --- a/backend/src/__tests__/services/ArrivalCriteriaEvaluationService.test.ts +++ b/backend/src/__tests__/services/ArrivalCriteriaEvaluationService.test.ts @@ -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[] => [ + { 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' })]]); + }); + }); +}); diff --git a/backend/src/__tests__/services/customerShipmentView.test.ts b/backend/src/__tests__/services/customerShipmentView.test.ts index 8c85b50c..3a859dfa 100644 --- a/backend/src/__tests__/services/customerShipmentView.test.ts +++ b/backend/src/__tests__/services/customerShipmentView.test.ts @@ -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']); + }); +}); diff --git a/backend/src/__tests__/shared/stopPurposes.test.ts b/backend/src/__tests__/shared/stopPurposes.test.ts new file mode 100644 index 00000000..d3b98cd2 --- /dev/null +++ b/backend/src/__tests__/shared/stopPurposes.test.ts @@ -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); + }); +}); diff --git a/backend/src/commands/consolidations/ReorderConsolidationStopsCommand.ts b/backend/src/commands/consolidations/ReorderConsolidationStopsCommand.ts index 82a10a1d..c43cbbb1 100644 --- a/backend/src/commands/consolidations/ReorderConsolidationStopsCommand.ts +++ b/backend/src/commands/consolidations/ReorderConsolidationStopsCommand.ts @@ -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.'; diff --git a/backend/src/commands/consolidations/consolidationMembership.ts b/backend/src/commands/consolidations/consolidationMembership.ts index 3c951a75..fd688198 100644 --- a/backend/src/commands/consolidations/consolidationMembership.ts +++ b/backend/src/commands/consolidations/consolidationMembership.ts @@ -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. @@ -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(); - 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(); + 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 }); } } @@ -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); } @@ -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 } } } }, }, }, }, @@ -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) { diff --git a/backend/src/commands/orders/RecordStopOrdersDeliveryCommand.ts b/backend/src/commands/orders/RecordStopOrdersDeliveryCommand.ts index 6b5ca720..e7eb01b8 100644 --- a/backend/src/commands/orders/RecordStopOrdersDeliveryCommand.ts +++ b/backend/src/commands/orders/RecordStopOrdersDeliveryCommand.ts @@ -76,7 +76,9 @@ export class RecordStopOrdersDeliveryCommandHandler extends BaseCommandHandler { const { orgId, shipmentId, originId, pickupWaypoints, waypoints, destinationId } = opts; - const rows: Array<{ - shipmentId: string; - locationId: string; - sequenceNumber: number; - stopType: string; - status: string; - }> = []; - let seq = 1; - if (originId) { - rows.push({ shipmentId, locationId: originId, sequenceNumber: seq++, stopType: 'pickup', status: 'pending' }); - } - for (const pickup of pickupWaypoints ?? []) { - if (pickup) rows.push({ shipmentId, locationId: pickup, sequenceNumber: seq++, stopType: 'pickup', status: 'pending' }); - } - for (const wp of waypoints ?? []) { - if (wp) rows.push({ shipmentId, locationId: wp, sequenceNumber: seq++, stopType: 'delivery', status: 'pending' }); - } - if (destinationId) { - rows.push({ shipmentId, locationId: destinationId, sequenceNumber: seq++, stopType: 'delivery', status: 'pending' }); - } + const rows: StopRow[] = []; + const add = (wp: WaypointInput, listType: 'pickup' | 'delivery') => { + const row = toRow(shipmentId, wp, rows.length + 1, listType); + if (row) rows.push(row); + }; + if (originId) add(originId, 'pickup'); + for (const wp of pickupWaypoints ?? []) add(wp, 'pickup'); + for (const wp of waypoints ?? []) add(wp, 'delivery'); + if (destinationId) add(destinationId, 'delivery'); await reconcileStops(tx, orgId, shipmentId, rows); } @@ -68,7 +81,7 @@ async function reconcileStops( tx: TransactionClient, orgId: string, shipmentId: string, - rows: Array<{ shipmentId: string; locationId: string; sequenceNumber: number; stopType: string; status: string }>, + rows: StopRow[], ): Promise { const existing = await tx.shipmentStop.findMany({ where: { shipmentId, shipment: { orgId } }, @@ -83,7 +96,13 @@ async function reconcileStops( return i >= 0 ? unmatched.splice(i, 1)[0] : null; }; const sameKind = rows.map((row) => take((st) => st.locationId === row.locationId && st.stopType === row.stopType)); - const matches = rows.map((row, i) => ({ row, stop: sameKind[i] ?? take((st) => st.locationId === row.locationId) })); + // A pickup may become a drop at the same place, but a stop never turns into or out of an `other` + // stop: orders linked to a pickup or drop would be left on a stop that moves no orders (#345). + const convertible = (a: string, b: string) => a !== 'other' && b !== 'other'; + const matches = rows.map((row, i) => ({ + row, + stop: sameKind[i] ?? take((st) => st.locationId === row.locationId && convertible(st.stopType, row.stopType)), + })); const stranded = unmatched.filter((st) => st._count.orders + st._count.pickupOrders > 0); if (stranded.length > 0) throw new StopStillHasOrdersError(stranded.map((st) => st.locationId)); @@ -97,7 +116,7 @@ async function reconcileStops( if (stop) { await tx.shipmentStop.update({ where: { id: stop.id, shipment: { orgId } }, - data: { sequenceNumber: row.sequenceNumber, stopType: row.stopType }, + data: { sequenceNumber: row.sequenceNumber, stopType: row.stopType, purpose: row.purpose, label: row.label }, }); } else { await tx.shipmentStop.create({ data: row }); diff --git a/backend/src/repositories/CargoTrackingRepository.ts b/backend/src/repositories/CargoTrackingRepository.ts index 38182788..2d9cbd10 100644 --- a/backend/src/repositories/CargoTrackingRepository.ts +++ b/backend/src/repositories/CargoTrackingRepository.ts @@ -27,6 +27,9 @@ export interface CargoManifestStop { sequenceNumber: number; locationName: string; stopType: string; + /** For other stops (#345): what the stop is for, and its own name. */ + purpose: string | null; + label: string | null; status: string; expectedUnits: ManifestUnit[]; scannedUnits: ManifestUnit[]; @@ -231,6 +234,8 @@ export class CargoTrackingRepository implements ICargoTrackingRepository { sequenceNumber: stop.sequenceNumber, locationName: stop.location.name, stopType: stop.stopType, + purpose: stop.purpose, + label: stop.label, status: stop.status, expectedUnits, scannedUnits, diff --git a/backend/src/repositories/ConsolidationRepository.ts b/backend/src/repositories/ConsolidationRepository.ts index e567c636..04001b61 100644 --- a/backend/src/repositories/ConsolidationRepository.ts +++ b/backend/src/repositories/ConsolidationRepository.ts @@ -17,6 +17,8 @@ const detailSelect = { id: true, sequenceNumber: true, stopType: true, + purpose: true, + label: true, status: true, location: { select: { id: true, name: true, city: true, state: true, lat: true, lng: true } }, shipmentStops: { select: { shipmentId: true } }, diff --git a/backend/src/routes/shipments.ts b/backend/src/routes/shipments.ts index 9efe53ff..07a00e88 100644 --- a/backend/src/routes/shipments.ts +++ b/backend/src/routes/shipments.ts @@ -11,6 +11,7 @@ import { TRANSITION_SHIPMENT_STATUS } from '../commands/shipments/TransitionShip import { SOFT_DELETE_SHIPMENT } from '../commands/shipments/SoftDeleteShipmentCommand.js'; import { UNARCHIVE_SHIPMENT } from '../commands/shipments/UnarchiveShipmentCommand.js'; import { + OTHER_STOP_PURPOSES, SHIPMENT_LIFECYCLE, allowedTransitions, validateShipmentReadiness, @@ -32,6 +33,17 @@ const flexibleDate = z.string().trim().min(1).transform((v, ctx) => { return parsed.toISOString(); }); +// A route stop: a location id, or a stop that is neither pickup nor drop with its purpose (#345). +const waypointSchema = z.union([ + z.string().uuid(), + z.object({ + locationId: z.string().uuid(), + stopType: z.literal('other'), + purpose: z.enum(OTHER_STOP_PURPOSES), + label: z.string().max(100).nullish(), + }), +]); + export async function shipmentRoutes(server: FastifyInstance) { const commandBus = container.resolve(TOKENS.ICommandBus); const conversionService = container.resolve(TOKENS.IOrderConversionService); @@ -186,8 +198,8 @@ export async function shipmentRoutes(server: FastifyInstance) { carrierId: z.string().uuid().optional(), originId: z.string().uuid().optional(), destinationId: z.string().uuid().optional(), - waypoints: z.array(z.string().uuid()).optional(), - pickupWaypoints: z.array(z.string().uuid()).optional(), + waypoints: z.array(waypointSchema).optional(), + pickupWaypoints: z.array(waypointSchema).optional(), originData: addressSchema.optional(), destinationData: addressSchema.optional(), pickupDate: flexibleDate.optional(), @@ -630,8 +642,8 @@ export async function shipmentRoutes(server: FastifyInstance) { serviceLevel: z.enum(['FTL', 'LTL']).nullable().optional(), originId: z.string().uuid().optional(), destinationId: z.string().uuid().optional(), - waypoints: z.array(z.string().uuid()).optional(), - pickupWaypoints: z.array(z.string().uuid()).optional(), + waypoints: z.array(waypointSchema).optional(), + pickupWaypoints: z.array(waypointSchema).optional(), items: z.array(z.object({ sku: z.string(), description: z.string().optional(), diff --git a/backend/src/services/ArrivalCriteriaEvaluationService.ts b/backend/src/services/ArrivalCriteriaEvaluationService.ts index faee4779..d65d55fa 100644 --- a/backend/src/services/ArrivalCriteriaEvaluationService.ts +++ b/backend/src/services/ArrivalCriteriaEvaluationService.ts @@ -172,25 +172,31 @@ export class ArrivalCriteriaEvaluationService implements IArrivalCriteriaEvaluat const bleBeacons = extractBleBeacons(ctx.rawPayload); const matches: ArrivalCriteriaMatch[] = []; let arrivedThisPing = false; + // Stops this same ping saw the vehicle leave, so a later arrival doesn't infer it again. + const departedThisPing = new Set(); for (const stop of actionableStops(shipment.stops)) { const criteria = stop.location.arrivalCriteria; if (criteria.length === 0) continue; const entry = { locationLat: stop.location.lat ?? undefined, locationLng: stop.location.lng ?? undefined }; // Pickups (the origin and any further pickup, #329) arrive on entry and complete on departure. + // So do other stops (fuel, rest, customs…, #345), so both times are recorded; they move no orders. const isPickup = stop.stopType === 'pickup' || stop.id === originStop?.id; + const departsOnExit = isPickup || stop.stopType === 'other'; const match = this.firstMatch(criteria, ctx, entry, wifiNetworks, bleBeacons); if (match) { matches.push({ criteriaId: match.criteria.id, criteriaType: match.criteria.criteriaType, locationId: stop.locationId, stopId: stop.id, matchDetail: match.detail }); if (stop.status === 'pending') { const method = match.criteria.criteriaType === 'geofence' ? 'geofence' : 'geofence_iot'; - const unfinishedPickups = isPickup ? [] : shipment.stops.filter((st) => - (st.stopType === 'pickup' || st.id === originStop?.id) && st.sequenceNumber < stop.sequenceNumber && st.status !== 'completed'); - arrivedThisPing = await this.arriveAtStop(shipment.orgId, ctx, stop, isPickup, unfinishedPickups, eventTime, method) || arrivedThisPing; + const unfinishedEarlier = isPickup ? [] : shipment.stops.filter((st) => st.sequenceNumber < stop.sequenceNumber && !departedThisPing.has(st.id) && ( + ((st.stopType === 'pickup' || st.id === originStop?.id) && st.status !== 'completed') + // An other stop the vehicle was seen at, but never seen leaving. + || (st.stopType === 'other' && st.status === 'arrived'))); + arrivedThisPing = await this.arriveAtStop(shipment.orgId, ctx, stop, departsOnExit, unfinishedEarlier, eventTime, method) || arrivedThisPing; } - } else if (isPickup && stop.status === 'arrived') { - await this.departPickupIfOutside(shipment.orgId, ctx, stop, criteria, eventTime); + } else if (departsOnExit && stop.status === 'arrived') { + if (await this.departPickupIfOutside(shipment.orgId, ctx, stop, criteria, eventTime)) departedThisPing.add(stop.id); } } @@ -251,24 +257,24 @@ export class ArrivalCriteriaEvaluationService implements IArrivalCriteriaEvaluat } /** - * Arrival at a pending stop. A pickup only arrives; a drop completes on entry (#324). Reaching a - * drop proves the vehicle left the pickups before it, so a departure no ping ever showed is - * recorded first, as inferred, for each of them (#329). + * Arrival at a pending stop. A pickup or other stop only arrives; a drop completes on entry (#324, + * #345). Reaching a later stop proves the vehicle left the pickups and visited other stops before + * it, so a departure no ping ever showed is recorded first, as inferred, for each of them (#329). */ private async arriveAtStop( - orgId: string, ctx: DeviceEventContext, stop: { id: string; locationId: string }, isPickup: boolean, - unfinishedPickups: Array<{ id: string; locationId: string }>, eventTime: string, method: string, + orgId: string, ctx: DeviceEventContext, stop: { id: string; locationId: string }, departsOnExit: boolean, + unfinishedEarlier: Array<{ id: string; locationId: string }>, eventTime: string, method: string, ): Promise { - for (const pickup of unfinishedPickups) { + for (const pickup of unfinishedEarlier) { const departed = await this.recordDeparture(orgId, ctx, pickup.id, pickup.locationId, eventTime, true); if (departed) { await this.deliveryService.updateOrdersForStop(orgId, pickup.id, 'completed', method, new Date(eventTime)); } } - const arrived = await this.recordArrival(orgId, ctx, stop.id, stop.locationId, eventTime, !isPickup); + const arrived = await this.recordArrival(orgId, ctx, stop.id, stop.locationId, eventTime, !departsOnExit); if (arrived) { - await this.deliveryService.updateOrdersForStop(orgId, stop.id, isPickup ? 'arrived' : 'completed', method, new Date(eventTime)); + await this.deliveryService.updateOrdersForStop(orgId, stop.id, departsOnExit ? 'arrived' : 'completed', method, new Date(eventTime)); } return arrived; } @@ -276,13 +282,14 @@ export class ArrivalCriteriaEvaluationService implements IArrivalCriteriaEvaluat /** Departure needs a geofence (WiFi/BLE presence has no "outside") and a GPS position. */ private async departPickupIfOutside( orgId: string, ctx: DeviceEventContext, stop: { id: string; locationId: string }, criteria: any[], eventTime: string, - ): Promise { - if (ctx.lat == null || ctx.lng == null) return; - if (!criteria.some((c: any) => c.criteriaType === 'geofence')) return; + ): Promise { + if (ctx.lat == null || ctx.lng == null) return false; + if (!criteria.some((c: any) => c.criteriaType === 'geofence')) return false; const departed = await this.recordDeparture(orgId, ctx, stop.id, stop.locationId, eventTime, false); if (departed) { await this.deliveryService.updateOrdersForStop(orgId, stop.id, 'completed', 'geofence', new Date(eventTime)); } + return departed; } private matchDestinationWithoutStop( diff --git a/backend/src/services/portal/customerShipmentView.ts b/backend/src/services/portal/customerShipmentView.ts index 7a176f67..efa08d0b 100644 --- a/backend/src/services/portal/customerShipmentView.ts +++ b/backend/src/services/portal/customerShipmentView.ts @@ -29,7 +29,9 @@ export function scopeShipmentToCustomer(shipment const { orderShipments, stops, ...rest } = shipment; const ownOrderIds = new Set(orderShipments.filter((os) => os.order.customerId === customerId).map((os) => os.order.id)); + // Other stops (fuel, rest, customs…) are operational detail, not part of the customer's view (#345). const visibleStops = stops + .filter((s) => s.stopType !== 'other') .filter((s) => s.stopType === 'pickup' || s.orders.length === 0 || s.orders.some((o) => o.customerId === customerId)) // The run's stop id would point a customer at the consolidation, which they never see (#329). .map(({ orders: _orders, consolidationStopId: _run, ...stop }) => stop); diff --git a/docs/DOMAIN_BEHAVIOURS.md b/docs/DOMAIN_BEHAVIOURS.md index 7a1eae74..1beb5d44 100644 --- a/docs/DOMAIN_BEHAVIOURS.md +++ b/docs/DOMAIN_BEHAVIOURS.md @@ -166,6 +166,21 @@ delivered, #307); **arriving at a delivery stop** puts its unmoved orders in tra **completing a delivery stop** delivers its active orders. The order read model, customer webhooks, email and in-app notifications now see deliveries, which they didn't when these were silent writes. +**Other stops (#345).** A stop that is neither a pickup nor a drop (fuel, rest, customs, a hub, an +inspection, a labelled cross-dock pass-through) has `stopType: 'other'`, a `purpose` +(`OTHER_STOP_PURPOSES` in `@open-tms/shared`: fuel, rest, customs, cross_dock, hub, inspection, +other) and an optional `label` (its own name, shown instead of the purpose). `POST`/`PUT +/api/v1/shipments` take one in either waypoint list as `{ locationId, stopType: 'other', purpose, +label }`, so it can sit before, between or after pickups and drops. Tracking treats it like a pickup +for timing (arrived on entry, completed on departure, so its dwell is recorded, and inferred as +left once a later stop is reached) but **it never moves an order**: `RecordStopOrdersDeliveryCommand` +changes no orders at an other stop, and orders are never linked to one. `syncShipmentStops` never +turns a stop into or out of an other stop, so orders can't be stranded on one. A lane stop tagged +fuel, rest, customs, cross_dock, hub or other becomes an other stop on a shipment using the lane; +untagged, pickup and dropoff lane stops stay drops. On a consolidation an other stop goes before the +drops if its shipment visits it before any drop, can be reordered anywhere, and never becomes a +shipment's origin or destination. The customer portal leaves other stops out. + **Multi-pickup shipments (#329).** Orders on one shipment may come from different origins: each origin is a pickup stop (`stopType: pickup`), and every pickup comes before every drop. An order links to its pickup through `Order.pickupStopId` as well as to its drop through `deliveryStopId`; @@ -658,7 +673,7 @@ location in the UI today; the API itself supports several per entity. ### Lane Stops -Each intermediate hub-and-spoke stop (`LaneStop`) has an optional `purpose` tag describing what the stop is for: `pickup`, `dropoff`, `cross_dock`, `fuel`, `rest`, `hub`, `customs`, or `other`. It's informational only and doesn't affect routing, rating, or the planned-route (`LaneRoute`) waypoints. Set on the create/edit lane form alongside the stop's location and order. +Each intermediate hub-and-spoke stop (`LaneStop`) has an optional `purpose` tag describing what the stop is for: `pickup`, `dropoff`, `cross_dock`, `fuel`, `rest`, `hub`, `customs`, or `other`. It doesn't affect routing, rating, or the planned-route (`LaneRoute`) waypoints, but a pass-through purpose (`cross_dock`, `fuel`, `rest`, `hub`, `customs`, `other`) makes the stop an other stop on shipments that use the lane (#345). Set on the create/edit lane form alongside the stop's location and order. --- diff --git a/docs/roadmap/completed.md b/docs/roadmap/completed.md index 5df3006e..cacc0080 100644 --- a/docs/roadmap/completed.md +++ b/docs/roadmap/completed.md @@ -60,6 +60,7 @@ and priorities). Newest entries append at the bottom of their phase; new phases - **Consolidation Tracking (#329, part 3)** - A tracking device can be put on a consolidation; each ping updates every shipment on the run through its own journey, so each customer's stops, orders, telemetry and portal position stay separate, and the run's stops and status follow its shipments to completion. Runs are marked ready once every shipment is, stops can be reordered within pickups or drops (each shipment's own stops follow), the run's carrier is set on its shipments, and the run page has a map of its stops and position. - **Consolidation Costs (#329, part 4)** - A consolidation takes the carrier's rate for the whole run and splits it across its shipments by the weight of their orders (evenly when nothing is weighed), as a cost charge on each shipment, so each customer's shipment shows its own margin. The split follows the rate and the shipments until a share is approved. The run page shows each shipment's weight, cost share and margin. - **Ship Several Customers' Orders Together (#329, part 5)** - "Ship together" on the orders list works across customers: one click makes one shipment per customer, all on a new consolidation. Each customer's portal shows only their own shipment and its live position, never the run or other customers' freight. +- **Other Stops (#345)** - Waypoints can be other stops, neither pickup nor drop: fuel, rest, customs, cross-dock, hub, inspection or other, with an optional name. Tracking records when the truck arrived and left without moving any orders, lane stops tagged with those purposes carry over to shipments, consolidation runs reorder them freely, and customers don't see them. - **Events Reach Handlers in Every Deployment (#327)** - With the API's embedded workers turned off, events it published used to be recorded and then delivered to nothing, so projections, notifications and webhooks never ran. A process that runs no handlers now hands events to a dispatch queue that the worker fans out. The standalone worker uses the same DI container as the API, and both register the command-driven handlers (issue engine, auto-replenishment, route planning) with the full command bus, whether or not an LLM is configured. - **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. diff --git a/frontend/src/lib/__tests__/shipmentFromOrders.test.ts b/frontend/src/lib/__tests__/shipmentFromOrders.test.ts index bde88b40..1e9fa193 100644 --- a/frontend/src/lib/__tests__/shipmentFromOrders.test.ts +++ b/frontend/src/lib/__tests__/shipmentFromOrders.test.ts @@ -1,4 +1,4 @@ -import { applyOrders, canJoin, orderConflicts, shipTogetherMode, shipTogetherProblem, RouteForm, OrderForShipment } from '../shipmentFromOrders'; +import { applyOrders, canJoin, dropStops, orderConflicts, shipTogetherMode, shipTogetherProblem, waypointsForApi, RouteForm, OrderForShipment } from '../shipmentFromOrders'; const emptyForm: RouteForm = { customerId: '', mode: '', useCustomRoute: true, originId: '', destinationId: '', pickupWaypoints: [], waypoints: [], @@ -106,3 +106,16 @@ describe('shipTogetherMode (#329)', () => { .toEqual({ kind: 'consolidation', customers: 3 }); }); }); + +describe('other stops (#345)', () => { + it('never counts a fuel or rest stop as somewhere an order can drop', () => { + const form = { ...emptyForm, originId: 'origin', destinationId: 'drop-b', waypoints: ['fuel-1', 'drop-a'], otherStops: { 'fuel-1': { purpose: 'fuel', label: '' } } }; + expect(dropStops(form)).toEqual(['drop-a', 'drop-b']); + expect(orderConflicts(form, [order({ destinationId: 'fuel-1' })])).toEqual(["ORD-1's drop isn't a stop on this route."]); + }); + + it('sends other stops to the API with their purpose and name', () => { + expect(waypointsForApi(['a', '', 'f'], { f: { purpose: 'customs', label: ' Laredo ' } })) + .toEqual(['a', { locationId: 'f', stopType: 'other', purpose: 'customs', label: 'Laredo' }]); + }); +}); diff --git a/frontend/src/lib/shipmentFromOrders.ts b/frontend/src/lib/shipmentFromOrders.ts index b7b3899a..59e76151 100644 --- a/frontend/src/lib/shipmentFromOrders.ts +++ b/frontend/src/lib/shipmentFromOrders.ts @@ -18,6 +18,11 @@ export interface OrderForShipment { requestedDeliveryDate?: string | null; } +export interface OtherStopDetail { + purpose: string; + label: string; +} + export interface RouteForm { customerId: string; mode: string; @@ -28,6 +33,11 @@ export interface RouteForm { pickupWaypoints: string[]; /** Intermediate drops, in order. With a lane: the lane's stops plus any extra drops. */ waypoints: string[]; + /** + * Waypoints that are neither pickups nor drops (fuel, rest, customs…, #345), by location id. + * Orders are never collected or dropped at these. + */ + otherStops?: Record; /** The selected lane's endpoints, when not on a custom route. */ laneOriginId?: string | null; laneDestinationId?: string | null; @@ -50,13 +60,15 @@ export function orderPickups(orders: OrderForShipment[]): string[] { /** The stops the form's route collects at, origin first. */ export function pickupStops(form: RouteForm): string[] { const origin = form.useCustomRoute ? form.originId : form.laneOriginId ?? ''; - return [origin, ...(form.useCustomRoute ? form.pickupWaypoints : [])].filter(Boolean); + const others = form.otherStops ?? {}; + return [origin, ...(form.useCustomRoute ? form.pickupWaypoints.filter((id) => !others[id]) : [])].filter(Boolean); } /** The stops the form's route drops at, destination last. */ export function dropStops(form: RouteForm): string[] { const destination = form.useCustomRoute ? form.destinationId : form.laneDestinationId ?? ''; - return [...form.waypoints, destination].filter(Boolean); + const others = form.otherStops ?? {}; + return [...form.waypoints.filter((id) => !others[id]), destination].filter(Boolean); } /** Every stop the form's route visits: the pickups, then the drops. */ @@ -158,3 +170,14 @@ export function shipTogetherMode(orders: ShippableOrder[]): { kind: 'shipment' } const customers = new Set(orders.map((o) => o.customerId)).size; return customers > 1 ? { kind: 'consolidation', customers } : { kind: 'shipment' }; } + +/** + * A waypoint list as the API takes it: a plain location id for a pickup or drop, or an object for + * an other stop with its purpose and name (#345). + */ +export function waypointsForApi(ids: string[], otherStops: Record = {}) { + return ids.filter(Boolean).map((id) => { + const other = otherStops[id]; + return other ? { locationId: id, stopType: 'other' as const, purpose: other.purpose, label: other.label.trim() || null } : id; + }); +} diff --git a/frontend/src/pages/customer-portal/CustomerShipmentDetail.tsx b/frontend/src/pages/customer-portal/CustomerShipmentDetail.tsx index c2c00904..de25b8ec 100644 --- a/frontend/src/pages/customer-portal/CustomerShipmentDetail.tsx +++ b/frontend/src/pages/customer-portal/CustomerShipmentDetail.tsx @@ -7,6 +7,7 @@ import { customerFetch } from './CustomerDashboard'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { stopTypeLabel } from '@open-tms/shared'; type StatusVariant = 'success' | 'info' | 'warning' | 'destructive' | 'muted' | 'secondary'; @@ -153,7 +154,7 @@ export default function CustomerShipmentDetail() {
{stop.location?.name || 'Unknown'}
- {stop.location?.city}, {stop.location?.state} - {stop.stopType} + {stop.location?.city}, {stop.location?.state} - {stopTypeLabel(stop)}
{stop.status} diff --git a/frontend/src/vnext-design/ConsolidationRunMap.tsx b/frontend/src/vnext-design/ConsolidationRunMap.tsx index 74b91c6e..8ab3354a 100644 --- a/frontend/src/vnext-design/ConsolidationRunMap.tsx +++ b/frontend/src/vnext-design/ConsolidationRunMap.tsx @@ -2,12 +2,14 @@ import React, { useMemo } from 'react'; import MapView from '../maps/Map'; import type { MapMarker, MapPolyline } from '../maps/types'; +import { stopTypeLabel } from '@open-tms/shared'; // Hex colors used inside map HTML strings (cannot use Tailwind/var(--*)); same palette as the // shipment detail map. const COLOR_PICKUP = '#3b82f6'; const COLOR_DROP = '#22c55e'; const COLOR_DONE = '#94a3b8'; +const COLOR_OTHER = '#eab308'; const COLOR_LINE = '#a855f7'; const COLOR_POSITION = '#6366f1'; @@ -15,6 +17,8 @@ interface RunStop { id: string; sequenceNumber: number; stopType: string; + purpose?: string | null; + label?: string | null; status: string; location: { name: string; lat: number | null; lng: number | null }; } @@ -43,9 +47,12 @@ export default function ConsolidationRunMap({ reference, stops, position }: Prop const out: MapMarker[] = located.map((s) => ({ id: `stop-${s.id}`, position: s.point, - html: numberedPin(s.status === 'completed' ? COLOR_DONE : s.stopType === 'pickup' ? COLOR_PICKUP : COLOR_DROP, s.sequenceNumber), + html: numberedPin( + s.status === 'completed' ? COLOR_DONE : s.stopType === 'pickup' ? COLOR_PICKUP : s.stopType === 'other' ? COLOR_OTHER : COLOR_DROP, + s.sequenceNumber, + ), size: { width: 24, height: 24 }, - popupHtml: `${s.sequenceNumber}. ${escape(s.location.name)}
${s.stopType === 'pickup' ? 'Pickup' : 'Drop'} · ${escape(s.status)}`, + popupHtml: `${s.sequenceNumber}. ${escape(s.location.name)}
${escape(stopTypeLabel(s))} · ${escape(s.status)}`, zIndex: 10, })); if (position) { diff --git a/frontend/src/vnext-design/VNextBolView.tsx b/frontend/src/vnext-design/VNextBolView.tsx index c0a61552..437fd275 100644 --- a/frontend/src/vnext-design/VNextBolView.tsx +++ b/frontend/src/vnext-design/VNextBolView.tsx @@ -16,6 +16,7 @@ import { toast } from 'sonner'; import { API_URL } from '../api'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { stopTypeLabel } from '@open-tms/shared'; /** * Online Bill of Lading view. @@ -267,7 +268,7 @@ export default function VNextBolView() { {stops.map((s: any, i: number) => ( {s.sequenceNumber} - {s.stopType} + {stopTypeLabel(s)} {s.location?.name} {s.location?.city}, {s.location?.state} {s.estimatedArrival || '-'} diff --git a/frontend/src/vnext-design/VNextConsolidationDetail.tsx b/frontend/src/vnext-design/VNextConsolidationDetail.tsx index d0dc3e9d..24632186 100644 --- a/frontend/src/vnext-design/VNextConsolidationDetail.tsx +++ b/frontend/src/vnext-design/VNextConsolidationDetail.tsx @@ -14,11 +14,14 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import ConsolidationShipmentPicker from './ConsolidationShipmentPicker'; import ConsolidationRunMap from './ConsolidationRunMap'; import { consolidationStatusVariant } from './consolidationStatus'; +import { stopTypeLabel } from '@open-tms/shared'; interface ConsolidationStop { id: string; sequenceNumber: number; stopType: string; + purpose: string | null; + label: string | null; status: string; location: { id: string; name: string; city: string | null; state: string | null; lat: number | null; lng: number | null }; shipmentStops: Array<{ shipmentId: string }>; @@ -62,6 +65,13 @@ interface Consolidation { } const NO_CARRIER = 'none'; + +/** Whether two neighbouring run stops can trade places: anything but a pickup and a drop. */ +function canSwap(a?: { stopType: string }, b?: { stopType: string }): boolean { + if (!a || !b) return false; + const types = new Set([a.stopType, b.stopType]); + return !(types.has('pickup') && types.has('delivery')); +} const day = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : '—'); const money = (cents: number, currency: string) => new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(cents / 100); @@ -139,14 +149,13 @@ export default function VNextConsolidationDetail() { if (problem) setActionError(problem); } - /** Moves a stop one place within its own section, so pickups always stay before drops. */ + /** Moves a stop one place, never a pickup past a drop. Other stops (#345) go anywhere. */ function move(stopId: string, delta: -1 | 1) { if (!consolidation) return; const order = consolidation.stops.map((s) => s.id); const i = order.indexOf(stopId); const j = i + delta; - const typeAt = (k: number) => consolidation.stops[k]?.stopType; - if (j < 0 || j >= order.length || typeAt(i) !== typeAt(j)) return; + if (!canSwap(consolidation.stops[i], consolidation.stops[j])) return; [order[i], order[j]] = [order[j], order[i]]; act('/stops/order', { stopIds: order }); } @@ -257,13 +266,13 @@ export default function VNextConsolidationDetail() {
{stop.location.name} - {stop.stopType} + {stopTypeLabel(stop)} {stop.status}
{[stop.location.city, stop.location.state].filter(Boolean).join(', ')} {stop.shipmentStops.length > 0 && ( - <> · {stop.stopType === 'pickup' ? 'collects' : 'drops'} {stop.shipmentStops.map((s) => referenceOf.get(s.shipmentId) ?? s.shipmentId).join(', ')} + <> · {stop.stopType === 'pickup' ? 'collects' : stop.stopType === 'other' ? 'on the way for' : 'drops'} {stop.shipmentStops.map((s) => referenceOf.get(s.shipmentId) ?? s.shipmentId).join(', ')} )}
@@ -274,7 +283,7 @@ export default function VNextConsolidationDetail() { size="sm" className="h-7 px-2" aria-label={`Move ${stop.location.name} earlier`} - disabled={busy || consolidation.stops[i - 1]?.stopType !== stop.stopType} + disabled={busy || !canSwap(consolidation.stops[i - 1], stop)} onClick={() => move(stop.id, -1)} > @@ -284,7 +293,7 @@ export default function VNextConsolidationDetail() { size="sm" className="h-7 px-2" aria-label={`Move ${stop.location.name} later`} - disabled={busy || consolidation.stops[i + 1]?.stopType !== stop.stopType} + disabled={busy || !canSwap(stop, consolidation.stops[i + 1])} onClick={() => move(stop.id, 1)} > diff --git a/frontend/src/vnext-design/VNextCreateShipment.tsx b/frontend/src/vnext-design/VNextCreateShipment.tsx index aacfcda0..377f5cc8 100644 --- a/frontend/src/vnext-design/VNextCreateShipment.tsx +++ b/frontend/src/vnext-design/VNextCreateShipment.tsx @@ -24,8 +24,9 @@ import { import { API_URL } from '../api'; import { describeProFormat, checkProFormat } from '../lib/proNumberFormat'; import { - OrderForShipment, RouteForm, applyOrders, canJoin, orderConflicts, orderDrops, orderPickups, + OrderForShipment, OtherStopDetail, RouteForm, applyOrders, canJoin, orderConflicts, orderDrops, orderPickups, waypointsForApi, } from '../lib/shipmentFromOrders'; +import { OTHER_STOP_PURPOSES, OTHER_STOP_PURPOSE_LABELS, laneStopOtherPurpose } from '@open-tms/shared'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { DatePicker } from '@/components/ui/date-picker'; @@ -107,6 +108,16 @@ const BLANK_ROUTE: RouteForm = { pickupDate: '', deliveryDate: '', tempControlled: false, hazmat: false, }; +/** The lane's stops tagged fuel, rest, customs and the like, which become other stops on the shipment (#345). */ +function laneOtherStops(detail: any): Record { + const out: Record = {}; + for (const st of detail?.stops || []) { + const purpose = laneStopOtherPurpose(st.purpose); + if (purpose) out[st.locationId] = { purpose, label: '' }; + } + return out; +} + /** A lane's intermediate stops, then any of the orders' drops the lane doesn't already visit. */ function laneWaypoints(detail: any, orders: OrderForShipment[]): string[] { const laneStops: string[] = (detail?.stops || []).map((st: any) => st.locationId); @@ -136,15 +147,38 @@ async function findMatchingLane(orders: OrderForShipment[]): Promise<{ id: strin } } -function StopList({ label, hint, emptyText, addLabel, stops, onChange, locations }: { +const OTHER_KIND = 'other'; + +function StopList({ label, hint, emptyText, addLabel, kindLabel, stops, onChange, locations, otherStops, onOtherStopsChange }: { label: string; hint: string; emptyText: string; addLabel: string; + /** What a stop in this list is unless marked as an other stop: "Pickup" or "Drop". */ + kindLabel: string; stops: string[]; onChange: (next: string[]) => void; locations: any[]; + otherStops: Record; + onOtherStopsChange: (next: Record) => void; }) { + const setOther = (locationId: string, detail: OtherStopDetail | null) => { + const next = { ...otherStops }; + if (detail) next[locationId] = detail; + else delete next[locationId]; + onOtherStopsChange(next); + }; + const moveLocation = (idx: number, locationId: string) => { + const previous = stops[idx]; + onChange(stops.map((st, i) => (i === idx ? locationId : st))); + // The other-stop details follow the stop to its new location. + if (previous && otherStops[previous]) { + const next = { ...otherStops, [locationId]: otherStops[previous] }; + delete next[previous]; + onOtherStopsChange(next); + } + }; + return (
@@ -154,24 +188,71 @@ function StopList({ label, hint, emptyText, addLabel, stops, onChange, locations {stops.length === 0 ? (

{emptyText}

) : ( - stops.map((locationId, idx) => ( -
- {idx + 1} - - -
- )) + stops.map((locationId, idx) => { + const other = locationId ? otherStops[locationId] : undefined; + return ( +
+
+ {idx + 1} + + + +
+ {other && ( +
+ + setOther(locationId, { ...other, label: e.target.value })} + /> +
+ )} +
+ ); + }) )}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 25d2fd80..30481dc7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,6 +2,7 @@ export * from './shipmentTypeValidator.js'; export * from './shipmentEventTypes.js'; export * from './shipmentShareSections.js'; export * from './geofenceValidation.js'; +export * from './stopPurposes.js'; export type ID = string; diff --git a/packages/shared/src/stopPurposes.ts b/packages/shared/src/stopPurposes.ts new file mode 100644 index 00000000..c05b46e1 --- /dev/null +++ b/packages/shared/src/stopPurposes.ts @@ -0,0 +1,41 @@ +/** + * Stops a vehicle makes without loading or unloading anything (#345). A shipment stop of type + * `other` carries one of these purposes and, optionally, its own name. + */ +export const OTHER_STOP_PURPOSES = ['fuel', 'rest', 'customs', 'cross_dock', 'hub', 'inspection', 'other'] as const; +export type OtherStopPurpose = (typeof OTHER_STOP_PURPOSES)[number]; + +export const OTHER_STOP_PURPOSE_LABELS: Record = { + fuel: 'Fuel', + rest: 'Rest', + customs: 'Customs', + cross_dock: 'Cross-dock', + hub: 'Hub', + inspection: 'Inspection', + other: 'Other', +}; + +export function isOtherStopPurpose(value: unknown): value is OtherStopPurpose { + return typeof value === 'string' && (OTHER_STOP_PURPOSES as readonly string[]).includes(value); +} + +/** + * The purpose a lane stop gives the shipment stop it becomes. A lane stop tagged as a pass-through + * (fuel, rest, customs, cross-dock, hub, other) becomes an `other` stop; untagged, pickup or + * dropoff lane stops stay drops, as before. + */ +export function laneStopOtherPurpose(lanePurpose: string | null | undefined): OtherStopPurpose | null { + return isOtherStopPurpose(lanePurpose) ? lanePurpose : null; +} + +/** How a stop is named on screen: its own name, else its purpose, for `other` stops. */ +export function stopTypeLabel(stop: { stopType?: string | null; purpose?: string | null; label?: string | null }): string { + if (stop.stopType === 'other') { + const name = stop.label?.trim(); + if (name) return name; + return isOtherStopPurpose(stop.purpose) ? OTHER_STOP_PURPOSE_LABELS[stop.purpose] : 'Stop'; + } + if (stop.stopType === 'pickup') return 'Pickup'; + if (stop.stopType === 'both') return 'Pickup and drop'; + return 'Drop'; +} From 4b8b7073167adfa3cfc1b85346345bf9a5c10051 Mon Sep 17 00:00:00 2001 From: Dominic Finn Date: Mon, 5 Oct 2026 10:15:05 +0100 Subject: [PATCH 2/3] Show a custom route's stops as one ordered list (#345) The create and edit page's Pickups and Drops lists become one Stops list in visiting order. Each stop is a Pickup, Drop or Other stop, with up/down arrows; the list never lets a pickup sit after a drop, and other stops move freely. Underneath, the page still keeps pickup and drop lists, so orders, lanes, editing and saving are unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/DOMAIN_BEHAVIOURS.md | 2 +- .../lib/__tests__/shipmentFromOrders.test.ts | 37 ++- frontend/src/lib/shipmentFromOrders.ts | 53 ++++ .../src/vnext-design/VNextCreateShipment.tsx | 242 +++++++++--------- 4 files changed, 211 insertions(+), 123 deletions(-) diff --git a/docs/DOMAIN_BEHAVIOURS.md b/docs/DOMAIN_BEHAVIOURS.md index 1beb5d44..9fd66db1 100644 --- a/docs/DOMAIN_BEHAVIOURS.md +++ b/docs/DOMAIN_BEHAVIOURS.md @@ -171,7 +171,7 @@ inspection, a labelled cross-dock pass-through) has `stopType: 'other'`, a `purp (`OTHER_STOP_PURPOSES` in `@open-tms/shared`: fuel, rest, customs, cross_dock, hub, inspection, other) and an optional `label` (its own name, shown instead of the purpose). `POST`/`PUT /api/v1/shipments` take one in either waypoint list as `{ locationId, stopType: 'other', purpose, -label }`, so it can sit before, between or after pickups and drops. Tracking treats it like a pickup +label }`, so it can sit before, between or after pickups and drops. On the create and edit page a custom route's stops are one ordered list, each a Pickup, Drop or Other stop; the list never lets a pickup sit after a drop, and other stops move freely (`toRows`/`fromRows`/`canBe`/`canSwap` in `lib/shipmentFromOrders.ts`). Tracking treats it like a pickup for timing (arrived on entry, completed on departure, so its dwell is recorded, and inferred as left once a later stop is reached) but **it never moves an order**: `RecordStopOrdersDeliveryCommand` changes no orders at an other stop, and orders are never linked to one. `syncShipmentStops` never diff --git a/frontend/src/lib/__tests__/shipmentFromOrders.test.ts b/frontend/src/lib/__tests__/shipmentFromOrders.test.ts index 1e9fa193..26996c58 100644 --- a/frontend/src/lib/__tests__/shipmentFromOrders.test.ts +++ b/frontend/src/lib/__tests__/shipmentFromOrders.test.ts @@ -1,4 +1,4 @@ -import { applyOrders, canJoin, dropStops, orderConflicts, shipTogetherMode, shipTogetherProblem, waypointsForApi, RouteForm, OrderForShipment } from '../shipmentFromOrders'; +import { applyOrders, canBe, canJoin, canSwap, dropStops, fromRows, toRows, orderConflicts, shipTogetherMode, shipTogetherProblem, waypointsForApi, RouteForm, OrderForShipment } from '../shipmentFromOrders'; const emptyForm: RouteForm = { customerId: '', mode: '', useCustomRoute: true, originId: '', destinationId: '', pickupWaypoints: [], waypoints: [], @@ -119,3 +119,38 @@ describe('other stops (#345)', () => { .toEqual(['a', { locationId: 'f', stopType: 'other', purpose: 'customs', label: 'Laredo' }]); }); }); + +describe('the ordered stop list (#345)', () => { + const others = { fuel: { purpose: 'fuel', label: '' } }; + + it('reads the pickup and drop lists as one list, other stops in their place', () => { + expect(toRows({ pickupWaypoints: ['p1', 'fuel'], waypoints: ['d1'], otherStops: others }).map((r) => `${r.kind}:${r.locationId}`)) + .toEqual(['pickup:p1', 'other:fuel', 'drop:d1']); + }); + + it('splits the list back after the last pickup, keeping other stops where they are', () => { + const rows = [{ locationId: 'p1', kind: 'pickup' as const }, { locationId: 'fuel', kind: 'other' as const }, { locationId: 'p2', kind: 'pickup' as const }, { locationId: 'rest', kind: 'other' as const }, { locationId: 'd1', kind: 'drop' as const }]; + expect(fromRows(rows, { ...others, rest: { purpose: 'rest', label: '' }, gone: { purpose: 'hub', label: '' } })).toEqual({ + pickupWaypoints: ['p1', 'fuel', 'p2'], + waypoints: ['rest', 'd1'], + otherStops: { fuel: others.fuel, rest: { purpose: 'rest', label: '' } }, + }); + }); + + it('round-trips without moving anything', () => { + const value = { pickupWaypoints: ['p1'], waypoints: ['fuel', 'd1'], otherStops: others }; + expect(fromRows(toRows(value), others)).toEqual(value); + }); + + it('never lets a pickup land after a drop', () => { + const rows = toRows({ pickupWaypoints: ['p1'], waypoints: ['fuel', 'd1', 'd2'], otherStops: others }); + expect(canBe(rows, 2, 'pickup')).toBe(true); + expect(canBe(rows, 3, 'pickup')).toBe(false); + expect(canBe(rows, 0, 'drop')).toBe(true); + expect(canBe(toRows({ pickupWaypoints: ['p1', 'p2'], waypoints: [], otherStops: {} }), 0, 'drop')).toBe(false); + expect(canSwap(rows[0], rows[1])).toBe(true); + expect(canSwap(rows[1], rows[2])).toBe(true); + expect(canSwap({ locationId: 'p', kind: 'pickup' }, { locationId: 'd', kind: 'drop' })).toBe(false); + expect(canSwap(rows[3], undefined)).toBe(false); + }); +}); diff --git a/frontend/src/lib/shipmentFromOrders.ts b/frontend/src/lib/shipmentFromOrders.ts index 59e76151..afdf0f5a 100644 --- a/frontend/src/lib/shipmentFromOrders.ts +++ b/frontend/src/lib/shipmentFromOrders.ts @@ -181,3 +181,56 @@ export function waypointsForApi(ids: string[], otherStops: Record; +} + +/** The stops between origin and destination as one list, in visiting order (#345). */ +export function toRows({ pickupWaypoints, waypoints, otherStops }: RouteStops): RouteStopRow[] { + return [ + ...pickupWaypoints.map((locationId) => ({ locationId, kind: (otherStops[locationId] ? 'other' : 'pickup') as RouteStopKind })), + ...waypoints.map((locationId) => ({ locationId, kind: (otherStops[locationId] ? 'other' : 'drop') as RouteStopKind })), + ]; +} + +/** + * Back to the pickup and drop lists the rest of the page works with. Every stop up to the last + * pickup is in the pickup list and the rest in the drop list, so other stops keep their place. + * The list never has a pickup after a drop (see canBe / canSwap), so the split is exact. + */ +export function fromRows(rows: RouteStopRow[], otherStops: Record): RouteStops { + const lastPickup = rows.map((r) => r.kind).lastIndexOf('pickup'); + const keptOthers = Object.fromEntries(rows.filter((r) => r.kind === 'other' && otherStops[r.locationId]) + .map((r) => [r.locationId, otherStops[r.locationId]])); + return { + pickupWaypoints: rows.slice(0, lastPickup + 1).map((r) => r.locationId), + waypoints: rows.slice(lastPickup + 1).map((r) => r.locationId), + otherStops: keptOthers, + }; +} + +/** Whether the stop at `index` can take `kind` without a pickup ending up after a drop. */ +export function canBe(rows: RouteStopRow[], index: number, kind: RouteStopKind): boolean { + if (kind === 'pickup') return !rows.slice(0, index).some((r) => r.kind === 'drop'); + if (kind === 'drop') return !rows.slice(index + 1).some((r) => r.kind === 'pickup'); + return true; +} + +/** Whether neighbouring stops can trade places: anything but a pickup and a drop. */ +export function canSwap(a?: RouteStopRow, b?: RouteStopRow): boolean { + if (!a || !b) return false; + return !(new Set([a.kind, b.kind]).has('pickup') && new Set([a.kind, b.kind]).has('drop')); +} + diff --git a/frontend/src/vnext-design/VNextCreateShipment.tsx b/frontend/src/vnext-design/VNextCreateShipment.tsx index 377f5cc8..5aff061e 100644 --- a/frontend/src/vnext-design/VNextCreateShipment.tsx +++ b/frontend/src/vnext-design/VNextCreateShipment.tsx @@ -2,7 +2,9 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { + ArrowDown, ArrowLeft, + ArrowUp, Ban, ChevronDown, ChevronRight, @@ -24,7 +26,8 @@ import { import { API_URL } from '../api'; import { describeProFormat, checkProFormat } from '../lib/proNumberFormat'; import { - OrderForShipment, OtherStopDetail, RouteForm, applyOrders, canJoin, orderConflicts, orderDrops, orderPickups, waypointsForApi, + OrderForShipment, OtherStopDetail, RouteForm, RouteStopKind, RouteStopRow, RouteStops, + applyOrders, canBe, canJoin, canSwap, fromRows, orderConflicts, orderDrops, orderPickups, toRows, waypointsForApi, } from '../lib/shipmentFromOrders'; import { OTHER_STOP_PURPOSES, OTHER_STOP_PURPOSE_LABELS, laneStopOtherPurpose } from '@open-tms/shared'; import { Button } from '@/components/ui/button'; @@ -147,117 +150,129 @@ async function findMatchingLane(orders: OrderForShipment[]): Promise<{ id: strin } } -const OTHER_KIND = 'other'; +const KIND_LABELS: Record = { pickup: 'Pickup', drop: 'Drop', other: 'Other stop' }; -function StopList({ label, hint, emptyText, addLabel, kindLabel, stops, onChange, locations, otherStops, onOtherStopsChange }: { - label: string; - hint: string; - emptyText: string; - addLabel: string; - /** What a stop in this list is unless marked as an other stop: "Pickup" or "Drop". */ - kindLabel: string; - stops: string[]; - onChange: (next: string[]) => void; +function RouteStopList({ value, onChange, locations }: { + value: RouteStops; + onChange: (next: RouteStops) => void; locations: any[]; - otherStops: Record; - onOtherStopsChange: (next: Record) => void; }) { - const setOther = (locationId: string, detail: OtherStopDetail | null) => { - const next = { ...otherStops }; - if (detail) next[locationId] = detail; - else delete next[locationId]; - onOtherStopsChange(next); - }; - const moveLocation = (idx: number, locationId: string) => { - const previous = stops[idx]; - onChange(stops.map((st, i) => (i === idx ? locationId : st))); + const rows = toRows(value); + const others = value.otherStops; + const commit = (next: RouteStopRow[], nextOthers: Record = others) => onChange(fromRows(next, nextOthers)); + + const setLocation = (index: number, locationId: string) => { + const previous = rows[index].locationId; + const nextOthers = { ...others }; // The other-stop details follow the stop to its new location. - if (previous && otherStops[previous]) { - const next = { ...otherStops, [locationId]: otherStops[previous] }; - delete next[previous]; - onOtherStopsChange(next); + if (rows[index].kind === 'other' && previous && others[previous]) { + nextOthers[locationId] = others[previous]; + delete nextOthers[previous]; } + commit(rows.map((r, i) => (i === index ? { ...r, locationId } : r)), nextOthers); + }; + const setKind = (index: number, kind: RouteStopKind) => { + const row = rows[index]; + const nextOthers = { ...others }; + if (kind === 'other') nextOthers[row.locationId] = others[row.locationId] ?? { purpose: 'fuel', label: '' }; + else delete nextOthers[row.locationId]; + commit(rows.map((r, i) => (i === index ? { ...r, kind } : r)), nextOthers); + }; + const setDetail = (locationId: string, detail: OtherStopDetail) => onChange({ ...value, otherStops: { ...others, [locationId]: detail } }); + const move = (index: number, delta: -1 | 1) => { + const j = index + delta; + if (!canSwap(rows[Math.min(index, j)], rows[Math.max(index, j)])) return; + const next = [...rows]; + [next[index], next[j]] = [next[j], next[index]]; + commit(next); + }; + const remove = (index: number) => { + const nextOthers = { ...others }; + if (rows[index].kind === 'other') delete nextOthers[rows[index].locationId]; + commit(rows.filter((_, i) => i !== index), nextOthers); + }; + const addPickup = () => { + const lastPickup = rows.map((r) => r.kind).lastIndexOf('pickup'); + const next = [...rows]; + next.splice(lastPickup + 1, 0, { locationId: '', kind: 'pickup' }); + commit(next); }; return ( -
-
- -

{hint}

-
- {stops.length === 0 ? ( -

{emptyText}

- ) : ( - stops.map((locationId, idx) => { - const other = locationId ? otherStops[locationId] : undefined; - return ( -
-
- {idx + 1} - - setLocation(idx, v)}> + + + + + {locations.map((l: any) => ( + {l.name} - {l.city}, {l.state} + ))} + + + + + + +
+ {other && ( +
+ - + setDetail(row.locationId, { ...other, label: e.target.value })} + />
- {other && ( -
- - setOther(locationId, { ...other, label: e.target.value })} - /> -
- )} -
- ); - }) - )} - + )} +
+ ); + })} +
+ + +
); } @@ -1392,33 +1407,18 @@ export default function VNextCreateShipment() { - Waypoints (optional stops between origin and destination, in order) + Stops (optional, between origin and destination) - - { + setPickupWaypoints(next.pickupWaypoints); + setWaypoints(next.waypoints); + setOtherStops(next.otherStops); + }} locations={locations} - otherStops={otherStops} - onOtherStopsChange={setOtherStops} /> From 1894c0805d78b20288cd51b50bf877569ec299cc Mon Sep 17 00:00:00 2001 From: Dominic Finn Date: Mon, 5 Oct 2026 10:26:15 +0100 Subject: [PATCH 3/3] One "Add stop" with a single stop type picker (#345) Each stop on a custom route now has a location and one type: Pickup, Drop, Fuel, Rest, Customs, Cross-dock, Hub, Inspection or Other. Only Other asks for a name. The stop rows are the page's own state, so a stop can take any type before it has a location (the old other-stop details were keyed by location and couldn't). Choosing Pickup moves a stop up before the first drop and Drop moves it after the last pickup, instead of disabling those options. Co-Authored-By: Claude Opus 5.5 --- docs/DOMAIN_BEHAVIOURS.md | 4 +- .../lib/__tests__/shipmentFromOrders.test.ts | 63 +++-- frontend/src/lib/shipmentFromOrders.ts | 66 ++++-- .../src/vnext-design/VNextCreateShipment.tsx | 220 +++++++----------- 4 files changed, 168 insertions(+), 185 deletions(-) diff --git a/docs/DOMAIN_BEHAVIOURS.md b/docs/DOMAIN_BEHAVIOURS.md index 9fd66db1..805f4210 100644 --- a/docs/DOMAIN_BEHAVIOURS.md +++ b/docs/DOMAIN_BEHAVIOURS.md @@ -169,9 +169,9 @@ email and in-app notifications now see deliveries, which they didn't when these **Other stops (#345).** A stop that is neither a pickup nor a drop (fuel, rest, customs, a hub, an inspection, a labelled cross-dock pass-through) has `stopType: 'other'`, a `purpose` (`OTHER_STOP_PURPOSES` in `@open-tms/shared`: fuel, rest, customs, cross_dock, hub, inspection, -other) and an optional `label` (its own name, shown instead of the purpose). `POST`/`PUT +other) and an optional `label` (its own name, shown instead of the purpose; the create page only asks for one when the purpose is `other`). `POST`/`PUT /api/v1/shipments` take one in either waypoint list as `{ locationId, stopType: 'other', purpose, -label }`, so it can sit before, between or after pickups and drops. On the create and edit page a custom route's stops are one ordered list, each a Pickup, Drop or Other stop; the list never lets a pickup sit after a drop, and other stops move freely (`toRows`/`fromRows`/`canBe`/`canSwap` in `lib/shipmentFromOrders.ts`). Tracking treats it like a pickup +label }`, so it can sit before, between or after pickups and drops. On the create and edit page a custom route's stops are one ordered list with a single "Add stop": each stop has a location and a type (Pickup, Drop, or one of the purposes), and only Other asks for a name. Making a stop a pickup moves it up before the first drop, and making it a drop moves it down after the last pickup, so pickups always come first; other stops move freely (`toRows`/`fromRows`/`setStopType`/`canSwap` in `lib/shipmentFromOrders.ts`). Tracking treats it like a pickup for timing (arrived on entry, completed on departure, so its dwell is recorded, and inferred as left once a later stop is reached) but **it never moves an order**: `RecordStopOrdersDeliveryCommand` changes no orders at an other stop, and orders are never linked to one. `syncShipmentStops` never diff --git a/frontend/src/lib/__tests__/shipmentFromOrders.test.ts b/frontend/src/lib/__tests__/shipmentFromOrders.test.ts index 26996c58..10342842 100644 --- a/frontend/src/lib/__tests__/shipmentFromOrders.test.ts +++ b/frontend/src/lib/__tests__/shipmentFromOrders.test.ts @@ -1,4 +1,4 @@ -import { applyOrders, canBe, canJoin, canSwap, dropStops, fromRows, toRows, orderConflicts, shipTogetherMode, shipTogetherProblem, waypointsForApi, RouteForm, OrderForShipment } from '../shipmentFromOrders'; +import { applyOrders, canJoin, canSwap, dropStops, fromRows, setStopType, toRows, RouteStopRow, orderConflicts, shipTogetherMode, shipTogetherProblem, waypointsForApi, RouteForm, OrderForShipment } from '../shipmentFromOrders'; const emptyForm: RouteForm = { customerId: '', mode: '', useCustomRoute: true, originId: '', destinationId: '', pickupWaypoints: [], waypoints: [], @@ -121,36 +121,51 @@ describe('other stops (#345)', () => { }); describe('the ordered stop list (#345)', () => { - const others = { fuel: { purpose: 'fuel', label: '' } }; + const row = (locationId: string, type: RouteStopRow['type'], label = ''): RouteStopRow => ({ locationId, type, label }); + const types = (rows: RouteStopRow[]) => rows.map((r) => `${r.type}:${r.locationId}`); - it('reads the pickup and drop lists as one list, other stops in their place', () => { - expect(toRows({ pickupWaypoints: ['p1', 'fuel'], waypoints: ['d1'], otherStops: others }).map((r) => `${r.kind}:${r.locationId}`)) - .toEqual(['pickup:p1', 'other:fuel', 'drop:d1']); + it('reads the pickup and drop lists as one list, other stops by their purpose', () => { + const others = { fuel: { purpose: 'fuel', label: '' }, yard: { purpose: 'other', label: 'Yard 4' } }; + expect(toRows({ pickupWaypoints: ['p1', 'fuel'], waypoints: ['yard', 'd1'], otherStops: others })) + .toEqual([row('p1', 'pickup'), row('fuel', 'fuel'), row('yard', 'other', 'Yard 4'), row('d1', 'drop')]); }); - it('splits the list back after the last pickup, keeping other stops where they are', () => { - const rows = [{ locationId: 'p1', kind: 'pickup' as const }, { locationId: 'fuel', kind: 'other' as const }, { locationId: 'p2', kind: 'pickup' as const }, { locationId: 'rest', kind: 'other' as const }, { locationId: 'd1', kind: 'drop' as const }]; - expect(fromRows(rows, { ...others, rest: { purpose: 'rest', label: '' }, gone: { purpose: 'hub', label: '' } })).toEqual({ + it('splits the list back after the last pickup, other stops keeping their place', () => { + const rows = [row('p1', 'pickup'), row('fuel', 'fuel'), row('p2', 'pickup'), row('yard', 'other', 'Yard 4'), row('d1', 'drop')]; + expect(fromRows(rows)).toEqual({ pickupWaypoints: ['p1', 'fuel', 'p2'], - waypoints: ['rest', 'd1'], - otherStops: { fuel: others.fuel, rest: { purpose: 'rest', label: '' } }, + waypoints: ['yard', 'd1'], + otherStops: { fuel: { purpose: 'fuel', label: '' }, yard: { purpose: 'other', label: 'Yard 4' } }, }); }); it('round-trips without moving anything', () => { - const value = { pickupWaypoints: ['p1'], waypoints: ['fuel', 'd1'], otherStops: others }; - expect(fromRows(toRows(value), others)).toEqual(value); - }); - - it('never lets a pickup land after a drop', () => { - const rows = toRows({ pickupWaypoints: ['p1'], waypoints: ['fuel', 'd1', 'd2'], otherStops: others }); - expect(canBe(rows, 2, 'pickup')).toBe(true); - expect(canBe(rows, 3, 'pickup')).toBe(false); - expect(canBe(rows, 0, 'drop')).toBe(true); - expect(canBe(toRows({ pickupWaypoints: ['p1', 'p2'], waypoints: [], otherStops: {} }), 0, 'drop')).toBe(false); - expect(canSwap(rows[0], rows[1])).toBe(true); - expect(canSwap(rows[1], rows[2])).toBe(true); - expect(canSwap({ locationId: 'p', kind: 'pickup' }, { locationId: 'd', kind: 'drop' })).toBe(false); - expect(canSwap(rows[3], undefined)).toBe(false); + const value = { pickupWaypoints: ['p1'], waypoints: ['fuel', 'd1'], otherStops: { fuel: { purpose: 'fuel', label: '' } } }; + expect(fromRows(toRows(value))).toEqual(value); + }); + + it('keeps a stop with no location yet, whatever its type', () => { + const rows = setStopType([row('', 'drop')], 0, 'customs'); + expect(rows).toEqual([row('', 'customs')]); + expect(fromRows(rows).waypoints).toEqual(['']); + }); + + it('moves a stop made a pickup up before the drops, and one made a drop down after the pickups', () => { + const rows = [row('p1', 'pickup'), row('d1', 'drop'), row('fuel', 'fuel'), row('d2', 'drop')]; + expect(types(setStopType(rows, 3, 'pickup'))).toEqual(['pickup:p1', 'pickup:d2', 'drop:d1', 'fuel:fuel']); + expect(types(setStopType([row('p1', 'pickup'), row('p2', 'pickup'), row('d1', 'drop')], 0, 'drop'))).toEqual(['pickup:p2', 'drop:p1', 'drop:d1']); + expect(types(setStopType(rows, 1, 'rest'))).toEqual(['pickup:p1', 'rest:d1', 'fuel:fuel', 'drop:d2']); + }); + + it('only asks other stops for a name, and drops it when the type changes', () => { + expect(setStopType([row('x', 'other', 'Yard 4')], 0, 'fuel')).toEqual([row('x', 'fuel')]); + expect(fromRows([row('x', 'fuel', 'stale')]).otherStops).toEqual({ x: { purpose: 'fuel', label: '' } }); + }); + + it('never lets a pickup and a drop trade places', () => { + expect(canSwap(row('p', 'pickup'), row('f', 'fuel'))).toBe(true); + expect(canSwap(row('d', 'drop'), row('d2', 'drop'))).toBe(true); + expect(canSwap(row('p', 'pickup'), row('d', 'drop'))).toBe(false); + expect(canSwap(row('p', 'pickup'), undefined)).toBe(false); }); }); diff --git a/frontend/src/lib/shipmentFromOrders.ts b/frontend/src/lib/shipmentFromOrders.ts index afdf0f5a..a11faf90 100644 --- a/frontend/src/lib/shipmentFromOrders.ts +++ b/frontend/src/lib/shipmentFromOrders.ts @@ -5,6 +5,9 @@ // locked, so `orderConflicts` checks the form against the orders before saving. Its rules are the // same ones the backend enforces when the orders are attached (#325). +// Type-only: the runtime package isn't loadable from the frontend's Jest setup, and this file only needs the type. +import type { OtherStopPurpose } from '@open-tms/shared'; + export interface OrderForShipment { id: string; orderNumber: string; @@ -184,11 +187,14 @@ export function waypointsForApi(ids: string[], otherStops: Record; } -/** The stops between origin and destination as one list, in visiting order (#345). */ +export const isOtherType = (type: RouteStopType) => type !== 'pickup' && type !== 'drop'; + +/** The pickup and drop lists as one list in visiting order, other stops in their place. */ export function toRows({ pickupWaypoints, waypoints, otherStops }: RouteStops): RouteStopRow[] { - return [ - ...pickupWaypoints.map((locationId) => ({ locationId, kind: (otherStops[locationId] ? 'other' : 'pickup') as RouteStopKind })), - ...waypoints.map((locationId) => ({ locationId, kind: (otherStops[locationId] ? 'other' : 'drop') as RouteStopKind })), - ]; + const row = (locationId: string, positional: 'pickup' | 'drop'): RouteStopRow => { + // Purposes are checked where they enter the page (a lane's tags, a saved shipment's stops). + const other = otherStops[locationId]; + return other + ? { locationId, type: other.purpose as OtherStopPurpose, label: other.purpose === 'other' ? other.label : '' } + : { locationId, type: positional, label: '' }; + }; + return [...pickupWaypoints.map((id) => row(id, 'pickup')), ...waypoints.map((id) => row(id, 'drop'))]; } /** * Back to the pickup and drop lists the rest of the page works with. Every stop up to the last * pickup is in the pickup list and the rest in the drop list, so other stops keep their place. - * The list never has a pickup after a drop (see canBe / canSwap), so the split is exact. + * The list never has a pickup after a drop (see setStopType / canSwap), so the split is exact. */ -export function fromRows(rows: RouteStopRow[], otherStops: Record): RouteStops { - const lastPickup = rows.map((r) => r.kind).lastIndexOf('pickup'); - const keptOthers = Object.fromEntries(rows.filter((r) => r.kind === 'other' && otherStops[r.locationId]) - .map((r) => [r.locationId, otherStops[r.locationId]])); +export function fromRows(rows: RouteStopRow[]): RouteStops { + const lastPickup = rows.map((r) => r.type).lastIndexOf('pickup'); + const otherStops: Record = {}; + for (const r of rows) { + if (r.locationId && isOtherType(r.type)) otherStops[r.locationId] = { purpose: r.type, label: r.type === 'other' ? r.label : '' }; + } return { pickupWaypoints: rows.slice(0, lastPickup + 1).map((r) => r.locationId), waypoints: rows.slice(lastPickup + 1).map((r) => r.locationId), - otherStops: keptOthers, + otherStops, }; } -/** Whether the stop at `index` can take `kind` without a pickup ending up after a drop. */ -export function canBe(rows: RouteStopRow[], index: number, kind: RouteStopKind): boolean { - if (kind === 'pickup') return !rows.slice(0, index).some((r) => r.kind === 'drop'); - if (kind === 'drop') return !rows.slice(index + 1).some((r) => r.kind === 'pickup'); - return true; +/** + * The list after giving the stop at `index` a new type. A pickup below a drop moves up to just + * before the first drop, and a drop above a pickup moves down to just after the last pickup, so + * every pickup always comes before every drop. Other stops stay where they are. + */ +export function setStopType(rows: RouteStopRow[], index: number, type: RouteStopType): RouteStopRow[] { + const changed: RouteStopRow = { ...rows[index], type, label: type === 'other' ? rows[index].label : '' }; + const rest = rows.filter((_, i) => i !== index); + let at = index; + if (type === 'pickup') { + const firstDrop = rest.findIndex((r) => r.type === 'drop'); + if (firstDrop !== -1 && firstDrop < index) at = firstDrop; + } else if (type === 'drop') { + const lastPickup = rest.map((r) => r.type).lastIndexOf('pickup'); + if (lastPickup >= index) at = lastPickup + 1; + } + return [...rest.slice(0, at), changed, ...rest.slice(at)]; } /** Whether neighbouring stops can trade places: anything but a pickup and a drop. */ export function canSwap(a?: RouteStopRow, b?: RouteStopRow): boolean { if (!a || !b) return false; - return !(new Set([a.kind, b.kind]).has('pickup') && new Set([a.kind, b.kind]).has('drop')); + const types = new Set([a.type, b.type]); + return !(types.has('pickup') && types.has('drop')); } - diff --git a/frontend/src/vnext-design/VNextCreateShipment.tsx b/frontend/src/vnext-design/VNextCreateShipment.tsx index 5aff061e..64121b7a 100644 --- a/frontend/src/vnext-design/VNextCreateShipment.tsx +++ b/frontend/src/vnext-design/VNextCreateShipment.tsx @@ -26,10 +26,10 @@ import { import { API_URL } from '../api'; import { describeProFormat, checkProFormat } from '../lib/proNumberFormat'; import { - OrderForShipment, OtherStopDetail, RouteForm, RouteStopKind, RouteStopRow, RouteStops, - applyOrders, canBe, canJoin, canSwap, fromRows, orderConflicts, orderDrops, orderPickups, toRows, waypointsForApi, + OrderForShipment, OtherStopDetail, RouteForm, RouteStopRow, RouteStopType, RouteStops, + applyOrders, canJoin, canSwap, fromRows, orderConflicts, orderDrops, orderPickups, setStopType, toRows, waypointsForApi, } from '../lib/shipmentFromOrders'; -import { OTHER_STOP_PURPOSES, OTHER_STOP_PURPOSE_LABELS, laneStopOtherPurpose } from '@open-tms/shared'; +import { OTHER_STOP_PURPOSES, OTHER_STOP_PURPOSE_LABELS, isOtherStopPurpose, laneStopOtherPurpose } from '@open-tms/shared'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { DatePicker } from '@/components/ui/date-picker'; @@ -150,129 +150,84 @@ async function findMatchingLane(orders: OrderForShipment[]): Promise<{ id: strin } } -const KIND_LABELS: Record = { pickup: 'Pickup', drop: 'Drop', other: 'Other stop' }; +const STOP_TYPE_LABELS: Record = { pickup: 'Pickup', drop: 'Drop', ...OTHER_STOP_PURPOSE_LABELS }; +const STOP_TYPES: RouteStopType[] = ['pickup', 'drop', ...OTHER_STOP_PURPOSES]; -function RouteStopList({ value, onChange, locations }: { - value: RouteStops; - onChange: (next: RouteStops) => void; +/** + * The stops between origin and destination, in visiting order (#345). Each has a location and a + * type: pickup, drop, or what an other stop is for. Only "Other" asks for a name. + */ +function RouteStopList({ rows, onChange, locations }: { + rows: RouteStopRow[]; + onChange: (next: RouteStopRow[]) => void; locations: any[]; }) { - const rows = toRows(value); - const others = value.otherStops; - const commit = (next: RouteStopRow[], nextOthers: Record = others) => onChange(fromRows(next, nextOthers)); - - const setLocation = (index: number, locationId: string) => { - const previous = rows[index].locationId; - const nextOthers = { ...others }; - // The other-stop details follow the stop to its new location. - if (rows[index].kind === 'other' && previous && others[previous]) { - nextOthers[locationId] = others[previous]; - delete nextOthers[previous]; - } - commit(rows.map((r, i) => (i === index ? { ...r, locationId } : r)), nextOthers); - }; - const setKind = (index: number, kind: RouteStopKind) => { - const row = rows[index]; - const nextOthers = { ...others }; - if (kind === 'other') nextOthers[row.locationId] = others[row.locationId] ?? { purpose: 'fuel', label: '' }; - else delete nextOthers[row.locationId]; - commit(rows.map((r, i) => (i === index ? { ...r, kind } : r)), nextOthers); - }; - const setDetail = (locationId: string, detail: OtherStopDetail) => onChange({ ...value, otherStops: { ...others, [locationId]: detail } }); + const update = (index: number, patch: Partial) => onChange(rows.map((r, i) => (i === index ? { ...r, ...patch } : r))); const move = (index: number, delta: -1 | 1) => { const j = index + delta; if (!canSwap(rows[Math.min(index, j)], rows[Math.max(index, j)])) return; const next = [...rows]; [next[index], next[j]] = [next[j], next[index]]; - commit(next); - }; - const remove = (index: number) => { - const nextOthers = { ...others }; - if (rows[index].kind === 'other') delete nextOthers[rows[index].locationId]; - commit(rows.filter((_, i) => i !== index), nextOthers); - }; - const addPickup = () => { - const lastPickup = rows.map((r) => r.kind).lastIndexOf('pickup'); - const next = [...rows]; - next.splice(lastPickup + 1, 0, { locationId: '', kind: 'pickup' }); - commit(next); + onChange(next); }; return (

- In visiting order, between the origin and the destination. Pickups come before drops. Other stops (fuel, rest, customs and the like, where nothing is loaded or unloaded) can go anywhere. + In visiting order, between the origin and the destination. Pickups come before drops; fuel, rest, customs and the other stop types (where nothing is loaded or unloaded) can go anywhere.

{rows.length === 0 &&

No stops between the origin and the destination.

} - {rows.map((row, idx) => { - const other = row.kind === 'other' ? others[row.locationId] : undefined; - return ( -
-
- {idx + 1} - - - - - -
- {other && ( -
- - setDetail(row.locationId, { ...other, label: e.target.value })} - /> -
- )} + {rows.map((row, idx) => ( +
+
+ {idx + 1} + + + + +
- ); - })} -
- - -
+ {row.type === 'other' && ( +
+ update(idx, { label: e.target.value })} + aria-label={`Stop ${idx + 1} name`} + /> +
+ )} +
+ ))} +
); } @@ -358,11 +313,12 @@ export default function VNextCreateShipment() { const [laneSearch, setLaneSearch] = useState(''); const [laneOpen, setLaneOpen] = useState(false); const [carriers, setCarriers] = useState([]); - const [waypoints, setWaypoints] = useState([]); - // Further pickups after the origin, on a custom route (#329). - const [pickupWaypoints, setPickupWaypoints] = useState([]); - // Waypoints that are fuel, rest, customs and similar stops rather than pickups or drops (#345). - const [otherStops, setOtherStops] = useState>({}); + // The stops between origin and destination, in visiting order: pickups (#329), drops, and fuel, + // rest, customs and similar stops (#345). The pickup and drop lists the order rules and the API + // work with are read off it. + const [routeStops, setRouteStops] = useState([]); + const { pickupWaypoints, waypoints, otherStops } = useMemo(() => fromRows(routeStops), [routeStops]); + const setStopLists = (lists: RouteStops) => setRouteStops(toRows(lists)); const [loading, setLoading] = useState(false); const [submitError, setSubmitError] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -391,9 +347,7 @@ export default function VNextCreateShipment() { setUseCustomRoute(f.useCustomRoute); setOriginLocation(f.originId); setDestLocation(f.destinationId); - setPickupWaypoints(f.pickupWaypoints); - setWaypoints(f.waypoints); - setOtherStops(f.otherStops ?? {}); + setStopLists({ pickupWaypoints: f.pickupWaypoints, waypoints: f.waypoints, otherStops: f.otherStops ?? {} }); setPickupDate(f.pickupDate); setDeliveryDate(f.deliveryDate); setTempControlled(f.tempControlled); @@ -466,8 +420,7 @@ export default function VNextCreateShipment() { const detail = await loadLaneDetail(newLaneId); const assigned = detail?.laneCarriers?.find((lc: any) => lc.assigned); setCarrierId(assigned ? assigned.carrierId : ''); - setWaypoints(laneWaypoints(detail, attachedOrders)); - setOtherStops(laneOtherStops(detail)); + setStopLists({ pickupWaypoints: [], waypoints: laneWaypoints(detail, attachedOrders), otherStops: laneOtherStops(detail) }); }; const handleToggleCustomRoute = (checked: boolean) => { @@ -482,9 +435,7 @@ export default function VNextCreateShipment() { } else { setOriginLocation(''); setDestLocation(''); - setWaypoints([]); - setPickupWaypoints([]); - setOtherStops({}); + setRouteStops([]); } }; @@ -773,13 +724,12 @@ export default function VNextCreateShipment() { // Intermediate stops (between the origin and destination stops). if (Array.isArray(s.stops) && s.stops.length > 2) { const middle = [...s.stops].sort((a: any, b: any) => a.sequenceNumber - b.sequenceNumber).slice(1, -1); - // Other stops sit in whichever list keeps their place: with the pickups if a pickup follows (#345). - const lastPickup = middle.map((st: any) => st.stopType).lastIndexOf('pickup'); - const inPickups = (st: any, i: number) => st.stopType === 'pickup' || (st.stopType === 'other' && i < lastPickup); - setPickupWaypoints(middle.filter(inPickups).map((st: any) => st.locationId)); - setWaypoints(middle.filter((st: any, i: number) => !inPickups(st, i)).map((st: any) => st.locationId)); - setOtherStops(Object.fromEntries(middle.filter((st: any) => st.stopType === 'other') - .map((st: any) => [st.locationId, { purpose: st.purpose || 'other', label: st.label || '' }]))); + setRouteStops(middle.map((st: any): RouteStopRow => { + if (st.stopType === 'pickup') return { locationId: st.locationId, type: 'pickup', label: '' }; + if (st.stopType !== 'other') return { locationId: st.locationId, type: 'drop', label: '' }; + const purpose = isOtherStopPurpose(st.purpose) ? st.purpose : 'other'; + return { locationId: st.locationId, type: purpose, label: purpose === 'other' ? st.label || '' : '' }; + })); } if (Array.isArray(s.deviceAssignments)) { const initial = s.deviceAssignments.map((a: any) => ({ @@ -1411,15 +1361,7 @@ export default function VNextCreateShipment() { - { - setPickupWaypoints(next.pickupWaypoints); - setWaypoints(next.waypoints); - setOtherStops(next.otherStops); - }} - locations={locations} - /> + )}