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..805f4210 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; 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 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 +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..10342842 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, 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: [], @@ -106,3 +106,66 @@ 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' }]); + }); +}); + +describe('the ordered stop list (#345)', () => { + 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 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, 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: ['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: { 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 b7b3899a..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; @@ -18,6 +21,11 @@ export interface OrderForShipment { requestedDeliveryDate?: string | null; } +export interface OtherStopDetail { + purpose: string; + label: string; +} + export interface RouteForm { customerId: string; mode: string; @@ -28,6 +36,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 +63,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 +173,90 @@ 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; + }); +} + +// ─── The ordered stop list on the create page (#345) ───────────────────── + +/** A stop's type on the page: a pickup, a drop, or what an other stop is for. */ +export type RouteStopType = 'pickup' | 'drop' | OtherStopPurpose; + +export interface RouteStopRow { + locationId: string; + type: RouteStopType; + /** Only used when the type is `other`: the stop's own name. */ + label: string; +} + +export interface RouteStops { + pickupWaypoints: string[]; + waypoints: string[]; + otherStops: Record; +} + +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[] { + 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 setStopType / canSwap), so the split is exact. + */ +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, + }; +} + +/** + * 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; + const types = new Set([a.type, b.type]); + return !(types.has('pickup') && types.has('drop')); +} 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..64121b7a 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,8 +26,10 @@ import { import { API_URL } from '../api'; import { describeProFormat, checkProFormat } from '../lib/proNumberFormat'; import { - OrderForShipment, RouteForm, applyOrders, canJoin, orderConflicts, orderDrops, orderPickups, + 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, 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'; @@ -107,6 +111,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,28 +150,38 @@ async function findMatchingLane(orders: OrderForShipment[]): Promise<{ id: strin } } -function StopList({ label, hint, emptyText, addLabel, stops, onChange, locations }: { - label: string; - hint: string; - emptyText: string; - addLabel: string; - stops: string[]; - onChange: (next: string[]) => void; +const STOP_TYPE_LABELS: Record = { pickup: 'Pickup', drop: 'Drop', ...OTHER_STOP_PURPOSE_LABELS }; +const STOP_TYPES: RouteStopType[] = ['pickup', 'drop', ...OTHER_STOP_PURPOSES]; + +/** + * 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 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]]; + onChange(next); + }; + return ( -
-
- -

{hint}

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

{emptyText}

- ) : ( - stops.map((locationId, idx) => ( -
+
+

+ 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) => ( +
+
{idx + 1} - update(idx, { locationId: v })}> @@ -167,15 +191,42 @@ function StopList({ label, hint, emptyText, addLabel, stops, onChange, locations ))} - + +
- )) - )} -
+ ))} +
); @@ -262,9 +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([]); + // 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); @@ -278,13 +332,14 @@ export default function VNextCreateShipment() { destinationId: destLocation, pickupWaypoints, waypoints, + otherStops, laneOriginId: laneDetail?.originId ?? null, laneDestinationId: laneDetail?.destinationId ?? null, pickupDate, deliveryDate, tempControlled, hazmat, - }), [customer, mode, useCustomRoute, originLocation, destLocation, pickupWaypoints, waypoints, laneDetail, pickupDate, deliveryDate, tempControlled, hazmat]); + }), [customer, mode, useCustomRoute, originLocation, destLocation, pickupWaypoints, waypoints, otherStops, laneDetail, pickupDate, deliveryDate, tempControlled, hazmat]); const setRouteForm = (f: RouteForm) => { setCustomer(f.customerId); @@ -292,8 +347,7 @@ export default function VNextCreateShipment() { setUseCustomRoute(f.useCustomRoute); setOriginLocation(f.originId); setDestLocation(f.destinationId); - setPickupWaypoints(f.pickupWaypoints); - setWaypoints(f.waypoints); + setStopLists({ pickupWaypoints: f.pickupWaypoints, waypoints: f.waypoints, otherStops: f.otherStops ?? {} }); setPickupDate(f.pickupDate); setDeliveryDate(f.deliveryDate); setTempControlled(f.tempControlled); @@ -366,7 +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)); + setStopLists({ pickupWaypoints: [], waypoints: laneWaypoints(detail, attachedOrders), otherStops: laneOtherStops(detail) }); }; const handleToggleCustomRoute = (checked: boolean) => { @@ -381,8 +435,7 @@ export default function VNextCreateShipment() { } else { setOriginLocation(''); setDestLocation(''); - setWaypoints([]); - setPickupWaypoints([]); + setRouteStops([]); } }; @@ -611,6 +664,7 @@ export default function VNextCreateShipment() { ...BLANK_ROUTE, useCustomRoute: false, laneOriginId: detail.originId, laneDestinationId: detail.destinationId, waypoints: laneWaypoints(detail, []), + otherStops: laneOtherStops(detail), }, orders)); } else { setRouteForm(applyOrders({ ...BLANK_ROUTE, useCustomRoute: true }, orders)); @@ -670,8 +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); - setPickupWaypoints(middle.filter((st: any) => st.stopType === 'pickup').map((st: any) => st.locationId)); - setWaypoints(middle.filter((st: any) => st.stopType !== 'pickup').map((st: any) => st.locationId)); + 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) => ({ @@ -755,8 +813,8 @@ export default function VNextCreateShipment() { properShippingName: hazmat && properShippingName ? properShippingName : null, requiredEquipmentType: equipmentType || null, }; - body.waypoints = waypoints.filter(Boolean); - body.pickupWaypoints = useCustomRoute ? pickupWaypoints.filter(Boolean) : []; + body.waypoints = waypointsForApi(waypoints, otherStops); + body.pickupWaypoints = useCustomRoute ? waypointsForApi(pickupWaypoints, otherStops) : []; if (!isEdit) body.status = 'draft'; const url = isEdit ? `${API_URL}/api/v1/shipments/${id}` : `${API_URL}/api/v1/shipments`; const res = await fetch(url, { @@ -1146,6 +1204,9 @@ export default function VNextCreateShipment() {
  • {idx + 1} {locationName(locId)} + {otherStops[locId] && ( + {otherStops[locId].label.trim() || OTHER_STOP_PURPOSE_LABELS[otherStops[locId].purpose as keyof typeof OTHER_STOP_PURPOSE_LABELS] || 'Stop'} + )}
  • ))} @@ -1296,28 +1357,11 @@ export default function VNextCreateShipment() { - Waypoints (optional stops between origin and destination, in order) + Stops (optional, between origin and destination) - - + )} diff --git a/frontend/src/vnext-design/VNextLocationOps.tsx b/frontend/src/vnext-design/VNextLocationOps.tsx index 9c2815f8..f4346b55 100644 --- a/frontend/src/vnext-design/VNextLocationOps.tsx +++ b/frontend/src/vnext-design/VNextLocationOps.tsx @@ -39,6 +39,7 @@ import { } from '@/components/ui/table'; import { cn } from '@/lib/utils'; import { getLocationTypeMeta } from './locationTypesMeta'; +import { stopTypeLabel } from '@open-tms/shared'; interface LocationOps { location: any; @@ -287,7 +288,7 @@ export default function VNextLocationOps() { {s.customerName || '--'} {s.carrierName || '--'} {s.originName}{s.originCity ? `, ${s.originCity}` : ''} - {s.stopType} + {stopTypeLabel(s)} {s.estimatedArrival ? new Date(s.estimatedArrival).toLocaleString() : '--'} ))} diff --git a/frontend/src/vnext-design/VNextShipmentDetail.tsx b/frontend/src/vnext-design/VNextShipmentDetail.tsx index f97a8e9c..f0c3ab36 100644 --- a/frontend/src/vnext-design/VNextShipmentDetail.tsx +++ b/frontend/src/vnext-design/VNextShipmentDetail.tsx @@ -118,6 +118,7 @@ import { SHIPMENT_FIELD_LABELS, SHIPMENT_EVENT_TYPES, shipmentEventLabel, + stopTypeLabel, } from '@open-tms/shared'; const EQUIPMENT_TYPE_LABELS: Record = { @@ -605,6 +606,8 @@ interface CargoManifest { sequenceNumber: number; locationName: string; stopType: string; + purpose?: string | null; + label?: string | null; status: string; expectedUnits: ManifestUnit[]; scannedUnits: ManifestUnit[]; @@ -868,7 +871,7 @@ function CargoTab({ shipmentId }: { shipmentId: string }) { Stop {stop.sequenceNumber} - {stop.locationName}
    - {stop.stopType} - {stop.status} + {stopTypeLabel(stop)} - {stop.status} {stop.expectedUnits.length > 0 && ` - ${stop.expectedUnits.length} unit${stop.expectedUnits.length !== 1 ? 's' : ''} expected`}
    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'; +}