diff --git a/backend/prisma/migrations/20261004_02_order_pickup_stop/migration.sql b/backend/prisma/migrations/20261004_02_order_pickup_stop/migration.sql new file mode 100644 index 00000000..7f406099 --- /dev/null +++ b/backend/prisma/migrations/20261004_02_order_pickup_stop/migration.sql @@ -0,0 +1,12 @@ +-- #329: an order links to the stop it's collected at as well as the one it's delivered to, so a +-- shipment can have several pickups (a milk run) and each pickup's orders go in transit when the +-- vehicle leaves that pickup. +-- +-- Table role: Order is authoritative. Index budget: no new index; Order is already well over budget. +-- Orders are only ever looked up by pickup stop within one shipment, and that query is served by the +-- OrderShipment(shipmentId) index. No backfill: a null pickupStopId means the shipment's first +-- pickup stop, which is what every existing order was collected at. + +ALTER TABLE "Order" ADD COLUMN "pickupStopId" TEXT; + +ALTER TABLE "Order" ADD CONSTRAINT "Order_pickupStopId_fkey" FOREIGN KEY ("pickupStopId") REFERENCES "ShipmentStop"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/schema/tms.prisma b/backend/prisma/schema/tms.prisma index 382d2631..28cf01a9 100644 --- a/backend/prisma/schema/tms.prisma +++ b/backend/prisma/schema/tms.prisma @@ -174,7 +174,9 @@ model ShipmentStop { status String @default("pending") // pending, arrived, in_progress, completed, skipped // Orders to be delivered at this stop - orders Order[] + orders Order[] @relation("OrderDeliveryStop") + // Orders collected at this stop (#329) + pickupOrders Order[] @relation("OrderPickupStop") // Geofencing (for automatic status updates) geofenceRadius Float? // Radius in meters for geofence trigger @@ -589,8 +591,12 @@ model Order { exceptionResolvedAt DateTime? // When the exception was resolved // Link to the specific stop where this order should be delivered (for multi-stop shipments) - deliveryStop ShipmentStop? @relation(fields: [deliveryStopId], references: [id]) + deliveryStop ShipmentStop? @relation("OrderDeliveryStop", fields: [deliveryStopId], references: [id]) deliveryStopId String? + // Where the order is collected (#329). Null on orders linked before multi-pickup: they're picked + // up at their shipment's first pickup stop. + pickupStop ShipmentStop? @relation("OrderPickupStop", fields: [pickupStopId], references: [id]) + pickupStopId String? // Trackable units (pallets, totes, boxes, etc.) trackableUnits TrackableUnit[] diff --git a/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts b/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts index a2d1a2ee..f8bcc0bd 100644 --- a/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts +++ b/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts @@ -34,6 +34,11 @@ function makeShipment(overrides: any = {}) { }; } +// The shipment already has its pickup at the orders' origin; any other stop lookup finds nothing. +const findStop = (args: any) => Promise.resolve( + args?.where?.stopType?.in?.includes('pickup') && args.where.locationId === 'loc-origin' ? { id: 'stop-pickup', sequenceNumber: 1 } : null, +); + function makeTx(shipment: any = makeShipment()) { return { shipment: { @@ -43,7 +48,7 @@ function makeTx(shipment: any = makeShipment()) { order: { findMany: jest.fn(), update: jest.fn().mockResolvedValue({}) }, orderShipment: { create: jest.fn().mockResolvedValue({}), count: jest.fn().mockResolvedValue(1) }, shipmentStop: { - findFirst: jest.fn().mockResolvedValue(null), + findFirst: jest.fn(findStop), create: jest.fn().mockResolvedValue({ id: 'stop-1' }), aggregate: jest.fn().mockResolvedValue({ _max: { sequenceNumber: null } }), findMany: jest.fn().mockResolvedValue([]), diff --git a/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts b/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts index 97f7acb8..1147c7df 100644 --- a/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts +++ b/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts @@ -22,6 +22,11 @@ function makeOrder(overrides: any = {}) { }; } +// The shipment already has its pickup at the orders' origin; any other stop lookup finds nothing. +const findStop = (args: any) => Promise.resolve( + args?.where?.stopType?.in?.includes('pickup') && args.where.locationId === 'loc-origin' ? { id: 'stop-pickup', sequenceNumber: 1 } : null, +); + function makeTx() { return { order: { findMany: jest.fn(), update: jest.fn().mockResolvedValue({}) }, @@ -31,7 +36,7 @@ function makeTx() { }, orderShipment: { create: jest.fn().mockResolvedValue({}) }, shipmentStop: { - findFirst: jest.fn().mockResolvedValue(null), + findFirst: jest.fn(findStop), create: jest.fn().mockResolvedValue({ id: 'stop-1' }), aggregate: jest.fn().mockResolvedValue({ _max: { sequenceNumber: null } }), findMany: jest.fn().mockResolvedValue([]), diff --git a/backend/src/__tests__/commands/OrderDeliveryCommands.test.ts b/backend/src/__tests__/commands/OrderDeliveryCommands.test.ts index 08efc156..4a4ddc4e 100644 --- a/backend/src/__tests__/commands/OrderDeliveryCommands.test.ts +++ b/backend/src/__tests__/commands/OrderDeliveryCommands.test.ts @@ -78,6 +78,34 @@ describe('RecordStopOrdersDeliveryCommandHandler (#325)', () => { expect(result.events.map((e) => e.type)).toEqual([EVENT_TYPES.ORDER_DELIVERY_STATUS_CHANGED]); }); + it("only moves the orders collected at a later pickup when it completes (#329)", async () => { + const { tx, prisma } = mockTx(); + tx.shipmentStop.findFirst.mockResolvedValue(stopRow('stop-mid', 'pickup')); + const handler = new RecordStopOrdersDeliveryCommandHandler(prisma, mockEventBus().bus); + + await handler.execute(createTestCommand(RECORD_STOP_ORDERS_DELIVERY, { + stopId: 'stop-mid', status: 'completed', method: 'geofence', occurredAt: OCCURRED, + })); + + expect(tx.order.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ AND: [{ OR: [{ pickupStopId: 'stop-mid' }] }] }), + })); + }); + + it('counts orders with no pickup stop as collected at the first pickup (#329)', async () => { + const { tx, prisma } = mockTx(); + tx.shipmentStop.findFirst.mockResolvedValue(stopRow('stop-origin', 'pickup')); + const handler = new RecordStopOrdersDeliveryCommandHandler(prisma, mockEventBus().bus); + + await handler.execute(createTestCommand(RECORD_STOP_ORDERS_DELIVERY, { + stopId: 'stop-origin', status: 'completed', method: 'geofence', occurredAt: OCCURRED, + })); + + expect(tx.order.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ AND: [{ OR: [{ pickupStopId: 'stop-origin' }, { pickupStopId: null }] }] }), + })); + }); + it('changes no orders when the pickup only arrives', async () => { const { tx, prisma } = mockTx(); tx.shipmentStop.findFirst.mockResolvedValue(stopRow('stop-origin', 'pickup')); diff --git a/backend/src/__tests__/commands/linkOrdersToShipment.test.ts b/backend/src/__tests__/commands/linkOrdersToShipment.test.ts new file mode 100644 index 00000000..f1e1b7c3 --- /dev/null +++ b/backend/src/__tests__/commands/linkOrdersToShipment.test.ts @@ -0,0 +1,59 @@ +import { linkOrdersToShipment } from '../../commands/shipments/linkOrdersToShipment'; + +/** An in-memory stop table for one shipment. */ +function mockTx(stops: Array<{ id: string; locationId: string; stopType: string }>) { + let rows = stops.map((s, i) => ({ ...s, sequenceNumber: i + 1 })); + let nextId = 1; + const matches = (r: any, where: any) => + (!where.locationId || r.locationId === where.locationId) + && (!where.stopType || where.stopType.in.includes(r.stopType)) + && (!where.sequenceNumber || r.sequenceNumber >= where.sequenceNumber.gte); + const sorted = (orderBy: any) => [...rows].sort((a, b) => (orderBy?.sequenceNumber === 'desc' ? b.sequenceNumber - a.sequenceNumber : a.sequenceNumber - b.sequenceNumber)); + const tx = { + shipment: { update: jest.fn().mockResolvedValue({}) }, + shipmentStop: { + findFirst: jest.fn(async ({ where, orderBy }: any) => sorted(orderBy).find((r) => matches(r, where)) ?? null), + findMany: jest.fn(async ({ where, orderBy }: any) => sorted(orderBy).filter((r) => matches(r, where))), + update: jest.fn(async ({ where, data }: any) => { Object.assign(rows.find((r) => r.id === where.id)!, data); return {}; }), + create: jest.fn(async ({ data }: any) => { const row = { id: `new-${nextId++}`, ...data }; rows.push(row); return row; }), + aggregate: jest.fn(async () => ({ _max: { sequenceNumber: Math.max(0, ...rows.map((r) => r.sequenceNumber)) } })), + }, + orderShipment: { create: jest.fn().mockResolvedValue({}) }, + order: { update: jest.fn().mockResolvedValue({}) }, + auditLog: { create: jest.fn().mockResolvedValue({}) }, + } as any; + const route = () => sorted(null).map((r) => `${r.sequenceNumber}:${r.locationId}:${r.stopType}`); + return { tx, route }; +} + +const order = (id: string, originId: string, destinationId: string) => ({ id, orderNumber: id, status: 'verified', originId, destinationId, trackableUnits: [], lineItems: [] }); + +describe('linkOrdersToShipment pickups (#329)', () => { + it("adds a pickup for an order from a new origin after the existing pickups and before the drops", async () => { + const { tx, route } = mockTx([ + { id: 'p1', locationId: 'A', stopType: 'pickup' }, + { id: 'd1', locationId: 'X', stopType: 'delivery' }, + ]); + + const { stopsCreated } = await linkOrdersToShipment(tx, { id: 'ship-1', reference: 'S', items: [] }, [order('o2', 'B', 'Y')], { orgId: 'org-1', actorId: null }, () => 'linked', jest.fn()); + + expect(stopsCreated).toBe(2); + expect(route()).toEqual(['1:A:pickup', '2:B:pickup', '3:X:delivery', '4:Y:delivery']); + expect(tx.order.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ pickupStopId: 'new-1', deliveryStopId: 'new-2' }), + })); + }); + + it('reuses the pickup and drop already at those places', async () => { + const { tx, route } = mockTx([ + { id: 'p1', locationId: 'A', stopType: 'pickup' }, + { id: 'd1', locationId: 'X', stopType: 'delivery' }, + ]); + + const { stopsCreated } = await linkOrdersToShipment(tx, { id: 'ship-1', reference: 'S', items: [] }, [order('o1', 'A', 'X')], { orgId: 'org-1', actorId: null }, () => 'linked', jest.fn()); + + expect(stopsCreated).toBe(0); + expect(route()).toEqual(['1:A:pickup', '2:X:delivery']); + expect(tx.order.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ pickupStopId: 'p1', deliveryStopId: 'd1' }) })); + }); +}); diff --git a/backend/src/__tests__/commands/syncShipmentStops.test.ts b/backend/src/__tests__/commands/syncShipmentStops.test.ts index e8c05bb9..07e4e369 100644 --- a/backend/src/__tests__/commands/syncShipmentStops.test.ts +++ b/backend/src/__tests__/commands/syncShipmentStops.test.ts @@ -8,7 +8,7 @@ function mockTx(stored: StoredStop[] = []) { let nextId = 1; const tx = { shipmentStop: { - findMany: jest.fn(async () => rows.map((r) => ({ id: r.id, locationId: r.locationId, _count: { orders: r.orders } }))), + findMany: jest.fn(async () => rows.map((r) => ({ id: r.id, locationId: r.locationId, stopType: r.stopType, _count: { orders: r.orders, pickupOrders: 0 } }))), deleteMany: jest.fn(async ({ where }: any) => { rows = rows.filter((r) => !where.id.in.includes(r.id)); return { count: 0 }; }), updateMany: jest.fn(async () => { rows.forEach((r) => { r.sequenceNumber += 100000; }); return { count: rows.length }; }), update: jest.fn(async ({ where, data }: any) => { Object.assign(rows.find((r) => r.id === where.id)!, data); return {}; }), diff --git a/backend/src/__tests__/services/ArrivalCriteriaEvaluationService.test.ts b/backend/src/__tests__/services/ArrivalCriteriaEvaluationService.test.ts index dc4e9e9a..0b5821d1 100644 --- a/backend/src/__tests__/services/ArrivalCriteriaEvaluationService.test.ts +++ b/backend/src/__tests__/services/ArrivalCriteriaEvaluationService.test.ts @@ -16,6 +16,7 @@ interface StopSpec { locationId: string; sequenceNumber: number; status?: string; + stopType?: string; actualArrival?: Date | null; actualDeparture?: Date | null; } @@ -163,5 +164,37 @@ describe('ArrivalCriteriaEvaluationService', () => { expect(dispatched()).toEqual([[RECORD_JOURNEY_CHECKPOINT, expect.objectContaining({ shipmentId: 'ship-1' })]]); }); + + describe('a second pickup (#329)', () => { + const milkRun = (mid: Partial): StopSpec[] => [ + { id: 'stop-origin', locationId: 'loc-origin', sequenceNumber: 1, stopType: 'pickup', status: 'completed' }, + { id: 'stop-mid', locationId: 'loc-mid', sequenceNumber: 2, stopType: 'pickup', ...mid }, + { id: 'stop-dest', locationId: 'loc-dest', sequenceNumber: 3, stopType: 'delivery' }, + ]; + + it('arrives at a later pickup without completing it', async () => { + const { ping, dispatched, deliveryService } = setup(milkRun({})); + await ping('loc-mid'); + expect(dispatched()).toEqual([[RECORD_GEOFENCE_ARRIVAL, expect.objectContaining({ stopId: 'stop-mid', completesStop: false })]]); + expect(deliveryService.updateOrdersForStop).toHaveBeenCalledWith('org-1', 'stop-mid', 'arrived', 'geofence', expect.any(Date)); + }); + + it('completes a later pickup when the vehicle leaves it, moving its orders', async () => { + const { ping, dispatched, deliveryService } = setup(milkRun({ status: 'arrived' })); + await ping('loc-mid', { lat: 40.75 }); + expect(dispatched()).toEqual([[RECORD_GEOFENCE_DEPARTURE, expect.objectContaining({ stopId: 'stop-mid' })]]); + expect(deliveryService.updateOrdersForStop).toHaveBeenCalledWith('org-1', 'stop-mid', 'completed', 'geofence', expect.any(Date)); + }); + + it('infers the departure from a pickup it never saw the vehicle leave when a drop is reached', async () => { + const { ping, dispatched, deliveryService } = setup(milkRun({})); + await ping('loc-dest'); + expect(dispatched()).toEqual([ + [RECORD_GEOFENCE_DEPARTURE, expect.objectContaining({ stopId: 'stop-mid', inferred: true })], + [RECORD_GEOFENCE_ARRIVAL, expect.objectContaining({ stopId: 'stop-dest', completesStop: true })], + ]); + expect(deliveryService.updateOrdersForStop).toHaveBeenCalledWith('org-1', 'stop-mid', 'completed', 'geofence', expect.any(Date)); + }); + }); }); diff --git a/backend/src/__tests__/services/OrderConversionService.test.ts b/backend/src/__tests__/services/OrderConversionService.test.ts index 1d852b3f..bfd1fab1 100644 --- a/backend/src/__tests__/services/OrderConversionService.test.ts +++ b/backend/src/__tests__/services/OrderConversionService.test.ts @@ -148,20 +148,14 @@ describe('OrderConversionService', () => { ); }); - it('rejects combining orders with different origins, without dispatching', async () => { + it('allows combining orders from different origins: each becomes a pickup (#329)', async () => { const orderA = makeOrder({ id: 'order-a', originId: 'loc-origin-1' }); const orderB = makeOrder({ id: 'order-b', originId: 'loc-origin-2' }); - const prisma = { - order: { findMany: jest.fn().mockResolvedValue([orderA, orderB]) }, - } as any; - const commandBus = makeCommandBus(); - const service = new OrderConversionService(prisma, commandBus); + const service = new OrderConversionService({ order: { findMany: jest.fn().mockResolvedValue([orderA, orderB]) } } as any, makeCommandBus()); - const result = await service.batchConvert('test-org', ['order-a', 'order-b'], { mode: 'combine' }); + const check = await service.checkCompatibility('test-org', ['order-a', 'order-b']); - expect(result.success).toBe(false); - expect(result.errors[0]).toMatch(/different origins/); - expect(commandBus.dispatch).not.toHaveBeenCalled(); + expect(check.errors.some((e) => /origin/.test(e))).toBe(false); }); }); @@ -265,21 +259,21 @@ describe('OrderConversionService', () => { expect(result.errors).toEqual(['Shipment not found']); }); - it('rejects orders with a different origin than the shipment, without dispatching', async () => { + it('accepts an order from another origin, which adds a pickup stop (#329)', async () => { const order = makeOrder({ originId: 'some-other-origin' }); const shipment = makeShipment(); const prisma = { shipment: { findFirst: jest.fn().mockResolvedValue(shipment) }, order: { findMany: jest.fn().mockResolvedValue([order]) }, + orderShipment: { count: jest.fn().mockResolvedValue(0) }, } as any; - const commandBus = makeCommandBus(); + const commandBus = makeCommandBus({ success: true, data: { shipmentId: 'ship-1', addedOrderIds: ['order-1'] }, events: [] }); const service = new OrderConversionService(prisma, commandBus); const result = await service.addOrdersToShipment('test-org', 'ship-1', ['order-1']); - expect(result.success).toBe(false); - expect(result.errors[0]).toMatch(/different origin/); - expect(commandBus.dispatch).not.toHaveBeenCalled(); + expect(result.errors).toEqual([]); + expect(commandBus.dispatch).toHaveBeenCalled(); }); it('rejects orders with a different customer than the shipment, without dispatching', async () => { @@ -316,7 +310,7 @@ describe('OrderConversionService', () => { }); describe('checkCompatibility', () => { - it('flags different origins and different customers as errors (#325)', async () => { + it('flags different customers as an error, but not different origins (#325, #329)', async () => { const orderA = makeOrder({ id: 'order-a', customerId: 'cust-a', originId: 'loc-origin-1' }); const orderB = makeOrder({ id: 'order-b', customerId: 'cust-b', originId: 'loc-origin-2' }); const prisma = { @@ -335,7 +329,7 @@ describe('OrderConversionService', () => { expect.objectContaining({ where: expect.objectContaining({ orgId: 'test-org' }) }), ); expect(check.compatible).toBe(false); - expect(check.errors.some((e) => /different origins/.test(e))).toBe(true); + expect(check.errors.some((e) => /origin/.test(e))).toBe(false); expect(check.errors.some((e) => /different customers/.test(e))).toBe(true); }); }); diff --git a/backend/src/commands/orders/RecordStopOrdersDeliveryCommand.ts b/backend/src/commands/orders/RecordStopOrdersDeliveryCommand.ts index fa6edf1c..6b5ca720 100644 --- a/backend/src/commands/orders/RecordStopOrdersDeliveryCommand.ts +++ b/backend/src/commands/orders/RecordStopOrdersDeliveryCommand.ts @@ -31,9 +31,9 @@ const ACTIVE_DELIVERY = [{ deliveryStatus: null }, { deliveryStatus: 'in_transit * Moves a shipment stop to a new status and the orders it affects with it (#325). * * BUSINESS RULES: - * - Completing the pickup (leaving the origin) puts every not-yet-moving order on the shipment in - * transit. It never delivers anything: before #325 this marked orders delivered if their - * delivery stop happened to be the origin (#307). + * - Completing a pickup (leaving it) puts the not-yet-moving orders collected there in transit + * (#329; orders with no pickup stop count as collected at the first pickup). It never delivers + * anything: before #325 this marked orders delivered if their delivery stop was the origin (#307). * - Arriving at a delivery stop puts its not-yet-moving orders in transit. * - Completing a delivery stop delivers its active orders. A refusal afterwards is a delivery * exception raised against the order, not a reversal here. @@ -74,15 +74,21 @@ export class RecordStopOrdersDeliveryCommandHandler extends BaseCommandHandler { + const existing = await tx.shipmentStop.findFirst({ + where: { shipmentId, locationId, stopType: { in: PICKUP_TYPES }, shipment: { orgId } }, + select: { id: true }, + }); + if (existing) return { id: existing.id, created: false }; + + const lastPickup = await tx.shipmentStop.findFirst({ + where: { shipmentId, stopType: { in: PICKUP_TYPES }, shipment: { orgId } }, + orderBy: { sequenceNumber: 'desc' }, + select: { sequenceNumber: true }, + }); + const sequenceNumber = (lastPickup?.sequenceNumber ?? 0) + 1; + // Make room: sequence numbers are unique per shipment, so shift the later stops up, last first. + const later = await tx.shipmentStop.findMany({ + where: { shipmentId, sequenceNumber: { gte: sequenceNumber }, shipment: { orgId } }, + orderBy: { sequenceNumber: 'desc' }, + select: { id: true, sequenceNumber: true }, + }); + for (const st of later) { + await tx.shipmentStop.update({ where: { id: st.id, shipment: { orgId } }, data: { sequenceNumber: st.sequenceNumber + 1 } }); + } + const stop = await tx.shipmentStop.create({ + data: { shipmentId, locationId, sequenceNumber, stopType: 'pickup', status: 'pending' }, + }); + return { id: stop.id, created: true }; +} + +/** The shipment's drop at `locationId`, created after the last stop if it has none. */ +async function ensureDeliveryStop( + tx: TransactionClient, orgId: string, shipmentId: string, locationId: string, +): Promise<{ id: string; created: boolean }> { + const existing = await tx.shipmentStop.findFirst({ + where: { shipmentId, locationId, stopType: { in: DELIVERY_TYPES }, shipment: { orgId } }, + select: { id: true }, + }); + if (existing) return { id: existing.id, created: false }; + + const last = await tx.shipmentStop.aggregate({ where: { shipmentId, shipment: { orgId } }, _max: { sequenceNumber: true } }); + const stop = await tx.shipmentStop.create({ + data: { shipmentId, locationId, sequenceNumber: (last._max.sequenceNumber ?? 0) + 1, stopType: 'delivery', status: 'pending' }, + }); + return { id: stop.id, created: true }; +} + diff --git a/backend/src/commands/shipments/syncShipmentStops.ts b/backend/src/commands/shipments/syncShipmentStops.ts index 630d10c0..ee5e2ed3 100644 --- a/backend/src/commands/shipments/syncShipmentStops.ts +++ b/backend/src/commands/shipments/syncShipmentStops.ts @@ -17,11 +17,14 @@ export async function syncShipmentStops( orgId: string; shipmentId: string; originId?: string | null; + /** Further pickups after the origin, in order (#329). */ + pickupWaypoints?: string[]; + /** Drops before the destination, in order. */ waypoints?: string[]; destinationId?: string | null; }, ): Promise { - const { orgId, shipmentId, originId, waypoints, destinationId } = opts; + const { orgId, shipmentId, originId, pickupWaypoints, waypoints, destinationId } = opts; const rows: Array<{ shipmentId: string; @@ -34,6 +37,9 @@ export async function syncShipmentStops( 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' }); } @@ -67,16 +73,19 @@ async function reconcileStops( const existing = await tx.shipmentStop.findMany({ where: { shipmentId, shipment: { orgId } }, orderBy: { sequenceNumber: 'asc' }, - select: { id: true, locationId: true, _count: { select: { orders: true } } }, + select: { id: true, locationId: true, stopType: true, _count: { select: { orders: true, pickupOrders: true } } }, }); + // Prefer the stop of the same kind at a location, so a pickup and a drop at one place stay apart. const unmatched = [...existing]; - const matches = rows.map((row) => { - const i = unmatched.findIndex((st) => st.locationId === row.locationId); - return { row, stop: i >= 0 ? unmatched.splice(i, 1)[0] : null }; - }); + const take = (pred: (st: (typeof existing)[number]) => boolean) => { + const i = unmatched.findIndex(pred); + 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) })); - const stranded = unmatched.filter((st) => st._count.orders > 0); + const stranded = unmatched.filter((st) => st._count.orders + st._count.pickupOrders > 0); if (stranded.length > 0) throw new StopStillHasOrdersError(stranded.map((st) => st.locationId)); if (unmatched.length > 0) { diff --git a/backend/src/routes/shipments.ts b/backend/src/routes/shipments.ts index 7c402f98..1019a71f 100644 --- a/backend/src/routes/shipments.ts +++ b/backend/src/routes/shipments.ts @@ -187,6 +187,7 @@ export async function shipmentRoutes(server: FastifyInstance) { originId: z.string().uuid().optional(), destinationId: z.string().uuid().optional(), waypoints: z.array(z.string().uuid()).optional(), + pickupWaypoints: z.array(z.string().uuid()).optional(), originData: addressSchema.optional(), destinationData: addressSchema.optional(), pickupDate: flexibleDate.optional(), @@ -316,6 +317,7 @@ export async function shipmentRoutes(server: FastifyInstance) { requestedPickupDate: true, requestedDeliveryDate: true, status: true, deliveryStatus: true, deliveredAt: true, serviceLevel: true, customerId: true, deliveryStop: { select: { id: true, sequenceNumber: true, status: true, location: { select: { name: true } } } }, + pickupStop: { select: { id: true, sequenceNumber: true, status: true, location: { select: { name: true } } } }, customer: { select: { name: true } }, origin: { select: { city: true, state: true } }, destination: { select: { city: true, state: true } }, @@ -379,7 +381,6 @@ export async function shipmentRoutes(server: FastifyInstance) { orgId, archived: false, status: 'verified', - originId: shipment.originId, customerId: shipment.customerId, orderShipments: { none: {} }, ...(shipment.serviceLevel ? { serviceLevel: shipment.serviceLevel } : {}), @@ -629,6 +630,7 @@ export async function shipmentRoutes(server: FastifyInstance) { originId: z.string().uuid().optional(), destinationId: z.string().uuid().optional(), waypoints: z.array(z.string().uuid()).optional(), + pickupWaypoints: z.array(z.string().uuid()).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 8eb2a241..faee4779 100644 --- a/backend/src/services/ArrivalCriteriaEvaluationService.ts +++ b/backend/src/services/ArrivalCriteriaEvaluationService.ts @@ -177,17 +177,20 @@ export class ArrivalCriteriaEvaluationService implements IArrivalCriteriaEvaluat const criteria = stop.location.arrivalCriteria; if (criteria.length === 0) continue; const entry = { locationLat: stop.location.lat ?? undefined, locationLng: stop.location.lng ?? undefined }; - const isOrigin = stop.id === originStop?.id; + // Pickups (the origin and any further pickup, #329) arrive on entry and complete on departure. + const isPickup = stop.stopType === 'pickup' || stop.id === originStop?.id; 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'; - arrivedThisPing = await this.arriveAtStop(shipment.orgId, ctx, stop, isOrigin, originStop, eventTime, method) || arrivedThisPing; + 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; } - } else if (isOrigin && stop.status === 'arrived') { - await this.departOriginIfOutside(shipment.orgId, ctx, stop, criteria, eventTime); + } else if (isPickup && stop.status === 'arrived') { + await this.departPickupIfOutside(shipment.orgId, ctx, stop, criteria, eventTime); } } @@ -219,7 +222,7 @@ export class ArrivalCriteriaEvaluationService implements IArrivalCriteriaEvaluat stops: { orderBy: { sequenceNumber: 'asc' }, select: { - id: true, locationId: true, sequenceNumber: true, status: true, actualArrival: true, actualDeparture: true, + id: true, locationId: true, sequenceNumber: true, status: true, stopType: true, actualArrival: true, actualDeparture: true, location: { select: { lat: true, @@ -248,27 +251,30 @@ export class ArrivalCriteriaEvaluationService implements IArrivalCriteriaEvaluat } /** - * Arrival at a pending stop. The origin only arrives; any other stop completes on entry (#324), - * and reaching it proves the vehicle left the origin, so an origin departure no ping ever showed - * is recorded first, as inferred. + * 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). */ private async arriveAtStop( - orgId: string, ctx: DeviceEventContext, stop: { id: string; locationId: string }, isOrigin: boolean, - originStop: { id: string; locationId: string; status: string } | null, eventTime: string, method: string, + orgId: string, ctx: DeviceEventContext, stop: { id: string; locationId: string }, isPickup: boolean, + unfinishedPickups: Array<{ id: string; locationId: string }>, eventTime: string, method: string, ): Promise { - if (!isOrigin && originStop && originStop.status !== 'completed') { - await this.recordDeparture(orgId, ctx, originStop.id, originStop.locationId, eventTime, true); + for (const pickup of unfinishedPickups) { + 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, !isOrigin); + const arrived = await this.recordArrival(orgId, ctx, stop.id, stop.locationId, eventTime, !isPickup); if (arrived) { - await this.deliveryService.updateOrdersForStop(orgId, stop.id, isOrigin ? 'arrived' : 'completed', method, new Date(eventTime)); + await this.deliveryService.updateOrdersForStop(orgId, stop.id, isPickup ? 'arrived' : 'completed', method, new Date(eventTime)); } return arrived; } /** Departure needs a geofence (WiFi/BLE presence has no "outside") and a GPS position. */ - private async departOriginIfOutside( + 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; diff --git a/backend/src/services/OrderConversionService.ts b/backend/src/services/OrderConversionService.ts index c4138d5c..e8459800 100644 --- a/backend/src/services/OrderConversionService.ts +++ b/backend/src/services/OrderConversionService.ts @@ -75,7 +75,7 @@ export interface IOrderConversionService { splitOrder(orgId: string, orderId: string, groups: SplitGroup[], userId?: string): Promise; /** * Manually add order(s) to an existing shipment, rather than creating a - * new one. Requires matching origin + customer with the target shipment, + * new one. Requires a matching customer (an order from another origin adds a pickup stop, #329), * and the shipment must not have already left draft/ready. */ addOrdersToShipment(orgId: string, shipmentId: string, orderIds: string[], userId?: string): Promise; @@ -140,13 +140,7 @@ export class OrderConversionService implements IOrderConversionService { errors.push('Orders belong to different customers, which can\'t share a shipment.'); } - // Check origin consistency - const originIds = new Set(orders.filter((o) => o.originId).map((o) => o.originId)); - if (originIds.size > 1) { - errors.push( - 'Orders have different origins. Cannot combine into a single shipment.' - ); - } + // Different origins are fine: each origin becomes a pickup stop (#329). // Check destination consistency const destinationIds = new Set(orders.filter((o) => o.destinationId).map((o) => o.destinationId)); @@ -400,10 +394,6 @@ export class OrderConversionService implements IOrderConversionService { errors.push(`${o.orderNumber} is missing origin or destination`); return false; } - if (o.originId !== shipment.originId) { - errors.push(`${o.orderNumber} has a different origin than this shipment`); - return false; - } if (o.customerId !== shipment.customerId) { errors.push(`${o.orderNumber} belongs to a different customer than this shipment`); return false; diff --git a/backend/src/services/tracking/journeyStops.ts b/backend/src/services/tracking/journeyStops.ts index fdffa66e..dbe29147 100644 --- a/backend/src/services/tracking/journeyStops.ts +++ b/backend/src/services/tracking/journeyStops.ts @@ -10,6 +10,7 @@ export interface JourneyStop { locationId: string; sequenceNumber: number; status: string; + stopType?: string; actualArrival: Date | null; actualDeparture: Date | null; } diff --git a/docs/DOMAIN_BEHAVIOURS.md b/docs/DOMAIN_BEHAVIOURS.md index 1d9f9494..1b4f2751 100644 --- a/docs/DOMAIN_BEHAVIOURS.md +++ b/docs/DOMAIN_BEHAVIOURS.md @@ -160,12 +160,22 @@ a transaction and emits after commit: `order.delivered` (carrying `deliveredAt`, for tracking), `order.exception` (with `exceptionType`), `order.exception_resolved`, or `order.delivery_status_changed` for anything else, one event per order. Free-text confirmer names and notes stay in the order row and audit log, never the event. `RecordStopOrdersDeliveryCommand` drives -orders from stops: **completing the pickup** (leaving the origin) puts every not-yet-moving order on -the shipment `in_transit` and never delivers anything (before, an order whose delivery stop was the -origin was marked delivered, #307); **arriving at a delivery stop** puts its unmoved orders in transit; +orders from stops: **completing a pickup** puts the not-yet-moving orders collected there `in_transit` +and never delivers anything (before, an order whose delivery stop was the origin was marked +delivered, #307); **arriving at a delivery stop** puts its unmoved orders in transit; **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. +**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`; +`linkOrdersToShipment` adds a missing pickup after the last one and a missing drop at the end. +`POST`/`PUT /api/v1/shipments` take `pickupWaypoints` (pickups after the origin) beside `waypoints` +(drops), and `syncShipmentStops` keeps both, refusing to drop a stop that orders still collect at or +drop at. Tracking: a pickup is arrived on entry and completed on departure, and completing it moves +the orders collected there (or, for orders with no pickup stop, the first pickup's) `in_transit`. +Arriving at a later stop with an earlier pickup still open records an inferred departure from it. + `deliveryStatus` is nullable and only ever gets a value once the order is `assigned` — there is no `unassigned`/`assigned`/`cancelled` delivery status; those were redundant with `Order.status`. diff --git a/docs/roadmap/completed.md b/docs/roadmap/completed.md index a2212d93..b8252576 100644 --- a/docs/roadmap/completed.md +++ b/docs/roadmap/completed.md @@ -55,6 +55,7 @@ and priorities). Newest entries append at the bottom of their phase; new phases - **Shipment Routes (#328, part 1)** - Shipments on a custom route (no lane) get their own planned road route, calculated through their stops in order when they're created or changed while draft or ready, and only when the stops change. Checkpoints, route deviation alerts and the shipment map use the shipment's own route, or its lane's. - **Create Shipments from Orders (#328, part 2)** - "Create shipment" on an order and "Ship together" on the orders list open the create page with the orders attached in an Orders card, with an "Add order" search for compatible orders. The orders fill in the form without locking anything, a matching lane is preselected, and the page refuses to save a shipment the orders can't share. - **Changing Shipments Built from Orders (#328, part 3)** - Editing a draft shipment keeps its stops, so its orders keep their drop stops (they used to be cut on every edit). Adding an order puts its drop before the destination and re-plans a custom route. Orders combined or converted through the API land on a lane only when it matches their stops exactly. A lane shipment whose stops no longer match its lane shows a warning with "Switch to custom route" or "Save as new lane". +- **Multi-Pickup Shipments (#329, part 1)** - Orders from different origins can share a shipment: each origin becomes a pickup stop before the drops, and each order is collected at its own pickup. Leaving a pickup puts only the orders collected there in transit. The create page lists pickups and drops separately, and "Ship together" no longer needs a shared origin. - **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 ada1b60a..75d8d0ec 100644 --- a/frontend/src/lib/__tests__/shipmentFromOrders.test.ts +++ b/frontend/src/lib/__tests__/shipmentFromOrders.test.ts @@ -1,7 +1,7 @@ import { applyOrders, canJoin, orderConflicts, shipTogetherProblem, RouteForm, OrderForShipment } from '../shipmentFromOrders'; const emptyForm: RouteForm = { - customerId: '', mode: '', useCustomRoute: true, originId: '', destinationId: '', waypoints: [], + customerId: '', mode: '', useCustomRoute: true, originId: '', destinationId: '', pickupWaypoints: [], waypoints: [], pickupDate: '', deliveryDate: '', tempControlled: false, hazmat: false, }; @@ -38,6 +38,15 @@ describe('applyOrders', () => { }); }); +describe('applyOrders with several origins (#329)', () => { + it('makes each further origin a pickup after the first, and accepts the result', () => { + const orders = [order(), order({ id: 'o2', orderNumber: 'ORD-2', originId: 'origin-b', destinationId: 'drop-b' })]; + const form = applyOrders(emptyForm, orders); + expect(form).toMatchObject({ originId: 'origin', pickupWaypoints: ['origin-b'], waypoints: ['drop-a'], destinationId: 'drop-b' }); + expect(orderConflicts(form, orders)).toEqual([]); + }); +}); + describe('orderConflicts', () => { const filled = applyOrders(emptyForm, [order()]); @@ -48,7 +57,7 @@ describe('orderConflicts', () => { it('flags edits that no longer fit the orders', () => { expect(orderConflicts({ ...filled, customerId: 'cust-2' }, [order()])).toEqual(["The shipment's customer isn't the orders' customer."]); expect(orderConflicts({ ...filled, mode: 'FTL' }, [order()])).toEqual(["The orders are LTL, but the shipment's mode is FTL."]); - expect(orderConflicts({ ...filled, originId: 'elsewhere' }, [order()])).toEqual(["The route doesn't start at the orders' origin."]); + expect(orderConflicts({ ...filled, originId: 'elsewhere' }, [order()])).toEqual(["ORD-1's pickup isn't a stop on this route."]); expect(orderConflicts({ ...filled, destinationId: 'elsewhere' }, [order()])).toEqual(["ORD-1's drop isn't a stop on this route."]); }); @@ -62,12 +71,12 @@ describe('orderConflicts', () => { }); describe('canJoin', () => { - it('only offers LTL orders for the same customer and origin, not already attached', () => { + it('only offers LTL orders for the same customer, not already attached, from any origin (#329)', () => { const attached = [order()]; expect(canJoin(order({ id: 'o2' }), attached)).toBe(true); expect(canJoin(order({ id: 'o1' }), attached)).toBe(false); expect(canJoin(order({ id: 'o2', customerId: 'cust-2' }), attached)).toBe(false); - expect(canJoin(order({ id: 'o2', originId: 'other' }), attached)).toBe(false); + expect(canJoin(order({ id: 'o2', originId: 'other' }), attached)).toBe(true); expect(canJoin(order({ id: 'o2', serviceLevel: 'FTL' }), [order({ serviceLevel: 'FTL' })])).toBe(false); expect(canJoin(order({ serviceLevel: 'FTL' }), [])).toBe(true); }); @@ -85,7 +94,7 @@ describe('shipTogetherProblem', () => { expect(shipTogetherProblem([available(), { ...available({ id: 'o2' }), status: 'assigned' }])).toBe('Only available orders can be shipped.'); expect(shipTogetherProblem([available(), available({ id: 'o2', serviceLevel: 'FTL' })])).toBe('FTL orders ship on their own shipment.'); expect(shipTogetherProblem([available(), available({ id: 'o2', customerId: 'cust-2' })])).toBe('The orders belong to different customers.'); - expect(shipTogetherProblem([available(), available({ id: 'o2', originId: 'other' })])).toBe('The orders are picked up from different origins.'); + expect(shipTogetherProblem([available(), available({ id: 'o2', originId: 'other' })])).toBeNull(); }); }); diff --git a/frontend/src/lib/shipmentFromOrders.ts b/frontend/src/lib/shipmentFromOrders.ts index f4f583e5..1e0b2a1f 100644 --- a/frontend/src/lib/shipmentFromOrders.ts +++ b/frontend/src/lib/shipmentFromOrders.ts @@ -24,7 +24,9 @@ export interface RouteForm { useCustomRoute: boolean; originId: string; destinationId: string; - /** Intermediate stops, in order. With a lane: the lane's stops plus any extra drops. */ + /** Further pickups after the origin, in order (#329). Only on a custom route. */ + pickupWaypoints: string[]; + /** Intermediate drops, in order. With a lane: the lane's stops plus any extra drops. */ waypoints: string[]; /** The selected lane's endpoints, when not on a custom route. */ laneOriginId?: string | null; @@ -40,11 +42,26 @@ export function orderDrops(orders: OrderForShipment[]): string[] { return [...new Set(orders.map((o) => o.destinationId).filter((d): d is string => Boolean(d)))]; } -/** Every stop the form's route visits, origin first. */ -export function routeStops(form: RouteForm): string[] { +/** The orders' pickup locations, in the order the orders were added, without repeats. */ +export function orderPickups(orders: OrderForShipment[]): string[] { + return [...new Set(orders.map((o) => o.originId).filter((d): d is string => Boolean(d)))]; +} + +/** 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); +} + +/** 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 [origin, ...form.waypoints, destination].filter(Boolean); + return [...form.waypoints, destination].filter(Boolean); +} + +/** Every stop the form's route visits: the pickups, then the drops. */ +export function routeStops(form: RouteForm): string[] { + return [...pickupStops(form), ...dropStops(form)]; } const day = (iso?: string | null) => (iso ? iso.slice(0, 10) : ''); @@ -53,13 +70,18 @@ const day = (iso?: string | null) => (iso ? iso.slice(0, 10) : ''); export function applyOrders(form: RouteForm, orders: OrderForShipment[]): RouteForm { if (orders.length === 0) return form; const first = orders[0]; - const next: RouteForm = { ...form, waypoints: [...form.waypoints] }; + const next: RouteForm = { ...form, waypoints: [...form.waypoints], pickupWaypoints: [...form.pickupWaypoints] }; next.customerId ||= first.customerId; next.mode ||= first.serviceLevel; - if (next.useCustomRoute) next.originId ||= first.originId ?? ''; + if (next.useCustomRoute) { + next.originId ||= first.originId ?? ''; + // Each further origin becomes a pickup after the first (#329). + const collected = new Set(pickupStops(next)); + next.pickupWaypoints.push(...orderPickups(orders).filter((p) => !collected.has(p))); + } - const covered = new Set(routeStops(next)); + const covered = new Set(dropStops(next)); const newDrops = orderDrops(orders).filter((d) => !covered.has(d)); if (next.useCustomRoute && !next.destinationId && newDrops.length > 0) { next.destinationId = newDrops[newDrops.length - 1]; @@ -92,14 +114,13 @@ export function orderConflicts(form: RouteForm, orders: OrderForShipment[]): str else if (form.mode && !levels.has(form.mode)) problems.push(`The orders are ${[...levels][0]}, but the shipment's mode is ${form.mode}.`); if (levels.has('FTL') && orders.length > 1) problems.push('An FTL shipment carries one order.'); - const origins = new Set(orders.map((o) => o.originId).filter(Boolean)); - const stops = routeStops(form); - if (origins.size > 1) problems.push('The orders are picked up from different origins.'); - else if (stops.length > 0 && origins.size === 1 && !origins.has(stops[0])) problems.push("The route doesn't start at the orders' origin."); - - for (const o of orders) { - if (o.destinationId && stops.length > 0 && !stops.slice(1).includes(o.destinationId)) { - problems.push(`${o.orderNumber}'s drop isn't a stop on this route.`); + // Orders from different origins are fine: each origin is a pickup on the route (#329). + const pickups = pickupStops(form); + const drops = dropStops(form); + if (pickups.length + drops.length > 0) { + for (const o of orders) { + if (o.originId && !pickups.includes(o.originId)) problems.push(`${o.orderNumber}'s pickup isn't a stop on this route.`); + if (o.destinationId && !drops.includes(o.destinationId)) problems.push(`${o.orderNumber}'s drop isn't a stop on this route.`); } } return problems; @@ -111,7 +132,6 @@ export function canJoin(candidate: OrderForShipment, orders: OrderForShipment[]) if (orders.length === 0) return true; const first = orders[0]; return candidate.customerId === first.customerId - && candidate.originId === first.originId && candidate.serviceLevel === first.serviceLevel && candidate.serviceLevel !== 'FTL'; } @@ -119,14 +139,14 @@ export function canJoin(candidate: OrderForShipment, orders: OrderForShipment[]) /** * Why the selected orders can't be shipped together, or null when they can. "Ship together" on the * orders list stays disabled until this is null, so the create page only opens for a valid set. + * Different origins are fine: each becomes a pickup (#329). */ export function shipTogetherProblem( - orders: Array<{ status?: string | null; serviceLevel?: string | null; customerId?: string | null; originId?: string | null }>, + orders: Array<{ status?: string | null; serviceLevel?: string | null; customerId?: string | null }>, ): string | null { if (orders.length < 2) return 'Select two or more orders to ship together.'; if (orders.some((o) => o.status?.toLowerCase() !== 'verified')) return 'Only available orders can be shipped.'; if (orders.some((o) => o.serviceLevel === 'FTL')) return 'FTL orders ship on their own shipment.'; if (new Set(orders.map((o) => o.customerId)).size > 1) return 'The orders belong to different customers.'; - if (new Set(orders.map((o) => o.originId)).size > 1) return 'The orders are picked up from different origins.'; return null; } diff --git a/frontend/src/vnext-design/VNextCreateShipment.tsx b/frontend/src/vnext-design/VNextCreateShipment.tsx index 413be024..aacfcda0 100644 --- a/frontend/src/vnext-design/VNextCreateShipment.tsx +++ b/frontend/src/vnext-design/VNextCreateShipment.tsx @@ -24,7 +24,7 @@ import { import { API_URL } from '../api'; import { describeProFormat, checkProFormat } from '../lib/proNumberFormat'; import { - OrderForShipment, RouteForm, applyOrders, canJoin, orderConflicts, orderDrops, + OrderForShipment, RouteForm, applyOrders, canJoin, orderConflicts, orderDrops, orderPickups, } from '../lib/shipmentFromOrders'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -103,7 +103,7 @@ interface RestrictionPreset { const BLANK_ROUTE: RouteForm = { - customerId: '', mode: '', useCustomRoute: true, originId: '', destinationId: '', waypoints: [], + customerId: '', mode: '', useCustomRoute: true, originId: '', destinationId: '', pickupWaypoints: [], waypoints: [], pickupDate: '', deliveryDate: '', tempControlled: false, hazmat: false, }; @@ -121,7 +121,8 @@ function laneWaypoints(detail: any, orders: OrderForShipment[]): string[] { async function findMatchingLane(orders: OrderForShipment[]): Promise<{ id: string } | null> { const origin = orders[0]?.originId; const drops = orderDrops(orders); - if (!origin || drops.length === 0) return null; + // A lane starts at one place; orders from several origins need a custom route with pickups (#329). + if (!origin || drops.length === 0 || orderPickups(orders).length > 1) return null; const params = new URLSearchParams({ originId: origin, destinationId: drops[drops.length - 1] }); try { const json = await fetch(`${API_URL}/api/v1/lanes/between?${params}`).then(r => r.json()); @@ -135,6 +136,51 @@ 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; + locations: any[]; +}) { + return ( +
+
+ +

{hint}

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

{emptyText}

+ ) : ( + stops.map((locationId, idx) => ( +
+ {idx + 1} + + +
+ )) + )} + +
+ ); +} + export default function VNextCreateShipment() { const { id } = useParams(); const navigate = useNavigate(); @@ -217,6 +263,8 @@ export default function VNextCreateShipment() { 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([]); const [loading, setLoading] = useState(false); const [submitError, setSubmitError] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -228,6 +276,7 @@ export default function VNextCreateShipment() { useCustomRoute, originId: originLocation, destinationId: destLocation, + pickupWaypoints, waypoints, laneOriginId: laneDetail?.originId ?? null, laneDestinationId: laneDetail?.destinationId ?? null, @@ -235,7 +284,7 @@ export default function VNextCreateShipment() { deliveryDate, tempControlled, hazmat, - }), [customer, mode, useCustomRoute, originLocation, destLocation, waypoints, laneDetail, pickupDate, deliveryDate, tempControlled, hazmat]); + }), [customer, mode, useCustomRoute, originLocation, destLocation, pickupWaypoints, waypoints, laneDetail, pickupDate, deliveryDate, tempControlled, hazmat]); const setRouteForm = (f: RouteForm) => { setCustomer(f.customerId); @@ -243,6 +292,7 @@ export default function VNextCreateShipment() { setUseCustomRoute(f.useCustomRoute); setOriginLocation(f.originId); setDestLocation(f.destinationId); + setPickupWaypoints(f.pickupWaypoints); setWaypoints(f.waypoints); setPickupDate(f.pickupDate); setDeliveryDate(f.deliveryDate); @@ -332,6 +382,7 @@ export default function VNextCreateShipment() { setOriginLocation(''); setDestLocation(''); setWaypoints([]); + setPickupWaypoints([]); } }; @@ -569,11 +620,6 @@ export default function VNextCreateShipment() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialOrderIds]); - const addWaypoint = () => setWaypoints(w => [...w, '']); - const removeWaypoint = (idx: number) => setWaypoints(w => w.filter((_, i) => i !== idx)); - const updateWaypoint = (idx: number, value: string) => - setWaypoints(w => w.map((wp, i) => i === idx ? value : wp)); - useEffect(() => { if (!id) return; @@ -623,7 +669,9 @@ export default function VNextCreateShipment() { setEquipmentType(s.requiredEquipmentType || ''); // Intermediate stops (between the origin and destination stops). if (Array.isArray(s.stops) && s.stops.length > 2) { - setWaypoints(s.stops.slice(1, -1).map((st: any) => st.locationId).filter(Boolean)); + 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)); } if (Array.isArray(s.deviceAssignments)) { const initial = s.deviceAssignments.map((a: any) => ({ @@ -708,6 +756,7 @@ export default function VNextCreateShipment() { requiredEquipmentType: equipmentType || null, }; body.waypoints = waypoints.filter(Boolean); + body.pickupWaypoints = useCustomRoute ? pickupWaypoints.filter(Boolean) : []; if (!isEdit) body.status = 'draft'; const url = isEdit ? `${API_URL}/api/v1/shipments/${id}` : `${API_URL}/api/v1/shipments`; const res = await fetch(url, { @@ -1247,36 +1296,28 @@ export default function VNextCreateShipment() { - Waypoints (optional intermediate stops, in order) + Waypoints (optional stops between origin and destination, in order) - - {waypoints.length === 0 ? ( -

No waypoints. Add intermediate stops between origin and destination.

- ) : ( - waypoints.map((wp, idx) => ( -
- {idx + 1} - - -
- )) - )} - + + + )} diff --git a/frontend/src/vnext-design/VNextShipmentDetail.tsx b/frontend/src/vnext-design/VNextShipmentDetail.tsx index 6b294e36..c737fb1f 100644 --- a/frontend/src/vnext-design/VNextShipmentDetail.tsx +++ b/frontend/src/vnext-design/VNextShipmentDetail.tsx @@ -2985,6 +2985,12 @@ export default function VNextShipmentDetail() { {order.requestedDeliveryDate ? new Date(order.requestedDeliveryDate).toLocaleDateString() : '-'} )} + {order.pickupStop && order.pickupStop.sequenceNumber > 1 && ( + + Picks up at stop {order.pickupStop.sequenceNumber} + {order.pickupStop.location?.name ? ` · ${order.pickupStop.location.name}` : ''} + + )} {order.deliveryStop && ( Drops at stop {order.deliveryStop.sequenceNumber}