From 5efad71c4086ce20a088da66f1ec8edb51b27cfa Mon Sep 17 00:00:00 2001 From: Dominic Finn Date: Sat, 3 Oct 2026 22:08:56 +0100 Subject: [PATCH 1/4] Enforce FTL/LTL rules on what can share a shipment (#325) Part 2 of #325. An FTL shipment carries exactly one order and FTL/LTL orders never share a shipment, enforced inside the add, combine, convert and split commands; LTL shipments may mix customers. Shipments created from orders now take serviceLevel, tempControlled and hazmat from them, where before convert/combine/split never set them, so the rules were skipped. The conversion service reports the same checks per order, and combining mixed or multiple FTL orders is now an error, not a warning. Adds scripts/backfill-shipment-service-level.ts (dry run unless --apply). Co-Authored-By: Claude Opus 5.5 --- .../AddOrdersToShipmentCommand.test.ts | 44 ++++++++++- .../CombineOrdersIntoShipmentCommand.test.ts | 31 ++++++++ .../commands/shipmentLoadRules.test.ts | 23 ++++++ .../backfillShipmentServiceLevel.test.ts | 40 ++++++++++ .../orders/AddOrdersToShipmentCommand.ts | 7 ++ .../CombineOrdersIntoShipmentCommand.ts | 3 + .../orders/ConvertOrderToShipmentCommand.ts | 2 + .../src/commands/orders/SplitOrderCommand.ts | 2 + .../src/commands/orders/shipmentLoadRules.ts | 77 +++++++++++++++++++ .../backfill-shipment-service-level.ts | 73 ++++++++++++++++++ .../src/services/OrderConversionService.ts | 14 +++- docs/DOMAIN_BEHAVIOURS.md | 11 +++ docs/roadmap/completed.md | 1 + 13 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 backend/src/__tests__/commands/shipmentLoadRules.test.ts create mode 100644 backend/src/__tests__/scripts/backfillShipmentServiceLevel.test.ts create mode 100644 backend/src/commands/orders/shipmentLoadRules.ts create mode 100644 backend/src/scripts/backfill-shipment-service-level.ts diff --git a/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts b/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts index fa07973e..0347d251 100644 --- a/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts +++ b/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts @@ -11,6 +11,9 @@ function makeOrder(overrides: any = {}) { customerId: 'cust-1', originId: 'loc-origin', destinationId: 'loc-dest', + serviceLevel: 'LTL', + temperatureControl: 'ambient', + requiresHazmat: false, trackableUnits: [], lineItems: [], ...overrides, @@ -22,6 +25,9 @@ function makeShipment(overrides: any = {}) { id: 'ship-1', orgId: 'test-org', reference: 'SH-EXISTING', + serviceLevel: 'LTL', + tempControlled: false, + hazmat: false, items: [{ orderId: 'order-existing', orderNumber: 'ORD-EXISTING' }], ...overrides, }; @@ -34,7 +40,7 @@ function makeTx(shipment: any = makeShipment()) { update: jest.fn().mockResolvedValue({}), }, order: { findMany: jest.fn(), update: jest.fn().mockResolvedValue({}) }, - orderShipment: { create: jest.fn().mockResolvedValue({}) }, + orderShipment: { create: jest.fn().mockResolvedValue({}), count: jest.fn().mockResolvedValue(1) }, shipmentStop: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'stop-1' }), @@ -151,4 +157,40 @@ describe('AddOrdersToShipmentCommandHandler', () => { expect(result.error).toMatch(/No valid orders to add/); expect(tx.shipment.update).not.toHaveBeenCalled(); }); + + describe('load rules (#325)', () => { + async function add(shipment: any, orders: any[], existing = 1) { + const tx = makeTx(makeShipment(shipment)); + tx.orderShipment.count.mockResolvedValue(existing); + tx.order.findMany.mockResolvedValue(orders); + const handler = new AddOrdersToShipmentCommandHandler(makePrisma(tx), mockEventBus().bus); + const result = await handler.execute(createTestCommand(ADD_ORDERS_TO_SHIPMENT, { shipmentId: 'ship-1', orderIds: orders.map((o) => o.id) })); + return { tx, result }; + } + + it('refuses a second order on an FTL shipment', async () => { + const { result, tx } = await add({ serviceLevel: 'FTL' }, [makeOrder({ serviceLevel: 'FTL' })], 1); + expect(result.success).toBe(false); + expect(result.error).toMatch(/FTL shipment carries one order/); + expect(tx.orderShipment.create).not.toHaveBeenCalled(); + }); + + it('refuses an FTL order on an LTL shipment', async () => { + const { result } = await add({ serviceLevel: 'LTL' }, [makeOrder({ serviceLevel: 'FTL' })]); + expect(result.success).toBe(false); + expect(result.error).toMatch(/FTL orders can't join a LTL shipment/); + }); + + it('lets LTL orders from different customers share a shipment', async () => { + const { result } = await add({}, [makeOrder({ id: 'a', customerId: 'cust-1' }), makeOrder({ id: 'b', customerId: 'cust-2' })]); + expect(result.success).toBe(true); + }); + + it('gives a shipment with no service level the orders\' one', async () => { + const { tx, result } = await add({ serviceLevel: null }, [makeOrder({ serviceLevel: 'FTL' })], 0); + expect(result.success).toBe(true); + expect(tx.shipment.update).toHaveBeenCalledWith(expect.objectContaining({ data: { serviceLevel: 'FTL' } })); + }); + }); }); + diff --git a/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts b/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts index 56c5310a..f1ca273e 100644 --- a/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts +++ b/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts @@ -11,6 +11,9 @@ function makeOrder(overrides: any = {}) { customerId: 'cust-1', originId: 'loc-origin', destinationId: 'loc-dest-1', + serviceLevel: 'LTL', + temperatureControl: 'ambient', + requiresHazmat: false, createdAt: new Date('2026-01-01'), customer: { id: 'cust-1', name: 'Acme' }, trackableUnits: [], @@ -125,4 +128,32 @@ describe('CombineOrdersIntoShipmentCommandHandler', () => { expect(result.error).toMatch(/No valid orders found/); expect(tx.shipment.create).not.toHaveBeenCalled(); }); + + it('sets the shipment service level and handling flags from its orders (#325)', async () => { + const tx = makeTx(); + tx.order.findMany.mockResolvedValue([ + makeOrder({ id: 'a', customerId: 'cust-1' }), + makeOrder({ id: 'b', customerId: 'cust-2', temperatureControl: 'refrigerated' }), + ]); + const handler = new CombineOrdersIntoShipmentCommandHandler(makePrisma(tx), mockEventBus().bus); + + const result = await handler.execute(createTestCommand(COMBINE_ORDERS_INTO_SHIPMENT, { orderIds: ['a', 'b'] }, { orgId: 'test-org' })); + + expect(result.success).toBe(true); + expect(tx.shipment.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ serviceLevel: 'LTL', tempControlled: true, hazmat: false }), + })); + }); + + it('refuses to combine FTL orders (#325)', async () => { + const tx = makeTx(); + tx.order.findMany.mockResolvedValue([makeOrder({ id: 'a', serviceLevel: 'FTL' }), makeOrder({ id: 'b', serviceLevel: 'FTL' })]); + const handler = new CombineOrdersIntoShipmentCommandHandler(makePrisma(tx), mockEventBus().bus); + + const result = await handler.execute(createTestCommand(COMBINE_ORDERS_INTO_SHIPMENT, { orderIds: ['a', 'b'] }, { orgId: 'test-org' })); + + expect(result.success).toBe(false); + expect(tx.shipment.create).not.toHaveBeenCalled(); + }); }); + diff --git a/backend/src/__tests__/commands/shipmentLoadRules.test.ts b/backend/src/__tests__/commands/shipmentLoadRules.test.ts new file mode 100644 index 00000000..ff7ce241 --- /dev/null +++ b/backend/src/__tests__/commands/shipmentLoadRules.test.ts @@ -0,0 +1,23 @@ +import { assertCanAdd, loadProfileFor, ShipmentLoadRuleError } from '../../commands/orders/shipmentLoadRules'; + +const order = (overrides: object = {}) => ({ orderNumber: 'ORD-1', serviceLevel: 'LTL', temperatureControl: 'ambient', requiresHazmat: false, ...overrides }); + +describe('shipmentLoadRules (#325)', () => { + it('takes the service level and the strictest handling from the orders', () => { + expect(loadProfileFor([order(), order({ temperatureControl: 'frozen' }), order({ requiresHazmat: true })])) + .toEqual({ serviceLevel: 'LTL', tempControlled: true, hazmat: true }); + }); + + it('refuses mixed FTL/LTL and more than one FTL order', () => { + expect(() => loadProfileFor([order(), order({ serviceLevel: 'FTL' })])).toThrow(ShipmentLoadRuleError); + expect(() => loadProfileFor([order({ serviceLevel: 'FTL' }), order({ serviceLevel: 'FTL' })])).toThrow(/FTL shipment carries one order/); + expect(loadProfileFor([order({ serviceLevel: 'FTL' })]).serviceLevel).toBe('FTL'); + }); + + it('refuses an order needing handling the shipment is not flagged for', () => { + const shipment = { serviceLevel: 'LTL', tempControlled: false, hazmat: false }; + expect(() => assertCanAdd(shipment, 1, [order({ requiresHazmat: true })])).toThrow(/hazmat/); + expect(() => assertCanAdd(shipment, 1, [order({ temperatureControl: 'refrigerated' })])).toThrow(/temperature control/); + expect(assertCanAdd(shipment, 3, [order()])).toBe('LTL'); + }); +}); diff --git a/backend/src/__tests__/scripts/backfillShipmentServiceLevel.test.ts b/backend/src/__tests__/scripts/backfillShipmentServiceLevel.test.ts new file mode 100644 index 00000000..5ae1d046 --- /dev/null +++ b/backend/src/__tests__/scripts/backfillShipmentServiceLevel.test.ts @@ -0,0 +1,40 @@ +import { planServiceLevelBackfill, applyServiceLevelBackfill } from '../../scripts/backfill-shipment-service-level'; + +const shipment = (id: string, orgId: string, levels: string[]) => ({ + id, orgId, orderShipments: levels.map((serviceLevel) => ({ order: { serviceLevel } })), +}); + +describe('backfill-shipment-service-level (#325)', () => { + it('sets a shipment whose orders agree, and lists mixed or empty ones for review', async () => { + const prisma = { + shipment: { + findMany: jest.fn().mockResolvedValue([ + shipment('s1', 'org-a', ['LTL', 'LTL']), + shipment('s2', 'org-b', ['FTL']), + shipment('s3', 'org-a', ['FTL', 'LTL']), + shipment('s4', 'org-b', []), + ]), + }, + } as any; + + const plan = await planServiceLevelBackfill(prisma); + + expect(plan.set).toEqual([ + { shipmentId: 's1', orgId: 'org-a', serviceLevel: 'LTL' }, + { shipmentId: 's2', orgId: 'org-b', serviceLevel: 'FTL' }, + ]); + expect(plan.review.map((r) => [r.shipmentId, r.reason])).toEqual([['s3', 'orders mix FTL and LTL'], ['s4', 'no orders']]); + }); + + it('writes each shipment under its own org', async () => { + const prisma = { shipment: { update: jest.fn().mockResolvedValue({}) } } as any; + + await applyServiceLevelBackfill(prisma, { + set: [{ shipmentId: 's1', orgId: 'org-a', serviceLevel: 'LTL' }, { shipmentId: 's2', orgId: 'org-b', serviceLevel: 'FTL' }], + review: [], + }); + + expect(prisma.shipment.update).toHaveBeenCalledWith({ where: { id: 's2', orgId: 'org-b' }, data: { serviceLevel: 'FTL' } }); + expect(prisma.shipment.update).toHaveBeenCalledWith({ where: { id: 's1', orgId: 'org-a' }, data: { serviceLevel: 'LTL' } }); + }); +}); diff --git a/backend/src/commands/orders/AddOrdersToShipmentCommand.ts b/backend/src/commands/orders/AddOrdersToShipmentCommand.ts index 514dc182..bc45f3ba 100644 --- a/backend/src/commands/orders/AddOrdersToShipmentCommand.ts +++ b/backend/src/commands/orders/AddOrdersToShipmentCommand.ts @@ -18,6 +18,7 @@ import { PrismaClient } from '@prisma/client'; import { PgBossEventBus } from '../../events/PgBossEventBus.js'; import { BaseCommandHandler, TransactionClient, EmitFn } from '../BaseCommandHandler.js'; +import { assertCanAdd } from './shipmentLoadRules.js'; import { linkOrdersToShipment } from '../shipments/linkOrdersToShipment.js'; import { Command } from '../types.js'; @@ -61,6 +62,12 @@ export class AddOrdersToShipmentCommandHandler extends BaseCommandHandler 1) { + throw new ShipmentLoadRuleError(`An FTL shipment carries one order; got ${orders.length} FTL orders`); + } + return { + serviceLevel, + tempControlled: orders.some((o) => o.temperatureControl !== 'ambient'), + hazmat: orders.some((o) => o.requiresHazmat), + }; +} + +/** + * Checks that `orders` can join a shipment already carrying `existingOrderCount` orders, and returns + * the service level the shipment should have afterwards (its own, or the orders' when it has none). + */ +export function assertCanAdd( + shipment: { serviceLevel: string | null; tempControlled: boolean; hazmat: boolean }, + existingOrderCount: number, + orders: LoadOrder[], +): string { + const ordersLevel = singleServiceLevel(orders); + const serviceLevel = shipment.serviceLevel ?? ordersLevel; + if (ordersLevel !== serviceLevel) { + throw new ShipmentLoadRuleError(`${ordersLevel} orders can't join a ${serviceLevel} shipment`); + } + if (serviceLevel === 'FTL' && existingOrderCount + orders.length > 1) { + throw new ShipmentLoadRuleError('An FTL shipment carries one order and this one would have more'); + } + const needsHazmat = orders.find((o) => o.requiresHazmat && !shipment.hazmat); + if (needsHazmat) throw new ShipmentLoadRuleError(`${needsHazmat.orderNumber} requires hazmat handling, which this shipment isn't flagged for`); + const needsTemp = orders.find((o) => o.temperatureControl !== 'ambient' && !shipment.tempControlled); + if (needsTemp) throw new ShipmentLoadRuleError(`${needsTemp.orderNumber} requires temperature control, which this shipment isn't flagged for`); + return serviceLevel; +} + +function singleServiceLevel(orders: LoadOrder[]): string { + const levels = new Set(orders.map((o) => o.serviceLevel)); + if (levels.size !== 1) { + throw new ShipmentLoadRuleError(levels.size === 0 ? 'No orders given' : 'FTL and LTL orders can\'t share a shipment'); + } + return [...levels][0]; +} diff --git a/backend/src/scripts/backfill-shipment-service-level.ts b/backend/src/scripts/backfill-shipment-service-level.ts new file mode 100644 index 00000000..8783aede --- /dev/null +++ b/backend/src/scripts/backfill-shipment-service-level.ts @@ -0,0 +1,73 @@ +/** + * Backfill script (#325) — gives shipments with no service level the one their orders share. + * + * Shipments created by converting, combining or splitting orders never had `serviceLevel` set, and + * the FTL/LTL rules only bite when it is. For each such shipment: if all its orders share one + * service level, set it; if they disagree, or it has no orders, list it + * for someone to decide. Mixed shipments are never guessed. + * + * Every write is scoped by the shipment's own orgId, never one resolved for the whole run. + * + * Dry run by default; pass --apply to write. + * + * Usage: + * npx tsx --env-file=backend/.env backend/src/scripts/backfill-shipment-service-level.ts + * npx tsx --env-file=backend/.env backend/src/scripts/backfill-shipment-service-level.ts --apply + */ + +import { PrismaClient } from '@prisma/client'; + +export interface ServiceLevelPlan { + set: Array<{ shipmentId: string; orgId: string; serviceLevel: string }>; + review: Array<{ shipmentId: string; orgId: string; reason: string }>; +} + +export async function planServiceLevelBackfill(prisma: PrismaClient): Promise { + // tenancy-exempt: an org-wide maintenance scan; every row carries and is written with its own orgId. + const shipments = await prisma.shipment.findMany({ + where: { serviceLevel: null, deletedAt: null }, + select: { id: true, orgId: true, orderShipments: { select: { order: { select: { serviceLevel: true } } } } }, + }); + + const plan: ServiceLevelPlan = { set: [], review: [] }; + for (const shipment of shipments) { + const levels = new Set(shipment.orderShipments.map((os) => os.order.serviceLevel)); + if (levels.size === 1) { + plan.set.push({ shipmentId: shipment.id, orgId: shipment.orgId, serviceLevel: [...levels][0] }); + } else { + plan.review.push({ shipmentId: shipment.id, orgId: shipment.orgId, reason: levels.size === 0 ? 'no orders' : 'orders mix FTL and LTL' }); + } + } + return plan; +} + +export async function applyServiceLevelBackfill(prisma: PrismaClient, plan: ServiceLevelPlan): Promise { + for (const { shipmentId, orgId, serviceLevel } of plan.set) { + await prisma.shipment.update({ where: { id: shipmentId, orgId }, data: { serviceLevel } }); + } +} + +async function main() { + const apply = process.argv.includes('--apply'); + const prisma = new PrismaClient(); + try { + const plan = await planServiceLevelBackfill(prisma); + console.log(`[backfill-service-level] ${plan.set.length} shipment(s) to set, ${plan.review.length} to review`); + for (const r of plan.review) console.log('[backfill-service-level] review', r); + if (!apply) { + console.log('[backfill-service-level] dry run; pass --apply to write'); + return; + } + await applyServiceLevelBackfill(prisma, plan); + console.log(`[backfill-service-level] set ${plan.set.length} shipment(s)`); + } finally { + await prisma.$disconnect(); + } +} + +if (process.argv[1]?.endsWith('backfill-shipment-service-level.ts')) { + main().catch((err) => { + console.error('[backfill-service-level] failed', err); + process.exit(1); + }); +} diff --git a/backend/src/services/OrderConversionService.ts b/backend/src/services/OrderConversionService.ts index b1e1f2b4..3d1f80d1 100644 --- a/backend/src/services/OrderConversionService.ts +++ b/backend/src/services/OrderConversionService.ts @@ -156,10 +156,12 @@ export class OrderConversionService implements IOrderConversionService { ); } - // Check service level mix + // BUSINESS RULE (#325): FTL is a dedicated truck for one order, and FTL/LTL never mix. const serviceLevels = new Set(orders.map((o) => o.serviceLevel)); if (serviceLevels.size > 1) { - warnings.push('Orders have mixed service levels (FTL/LTL). Shipment will default to FTL.'); + errors.push('Orders have mixed service levels (FTL/LTL), which can\'t share a shipment.'); + } else if (serviceLevels.has('FTL') && orders.length > 1) { + errors.push('FTL orders each need their own shipment; combine LTL orders instead.'); } // Check temperature control mix @@ -386,6 +388,9 @@ export class OrderConversionService implements IOrderConversionService { if (!found.has(id)) errors.push(`Order ${id} not found`); } + // An FTL shipment carries one order: taken already if it has any. + let ftlSlotTaken = shipment.serviceLevel === 'FTL' + && (await this.prisma.orderShipment.count({ where: { shipmentId, shipment: { orgId } } })) > 0; const valid = orders.filter((o) => { if (o.status === 'assigned') { errors.push(`${o.orderNumber} is already ${o.status}`); @@ -407,6 +412,11 @@ export class OrderConversionService implements IOrderConversionService { errors.push(`${o.orderNumber} is ${o.serviceLevel} but this shipment is ${shipment.serviceLevel}`); return false; } + if (o.serviceLevel === 'FTL' && ftlSlotTaken) { + errors.push(`${o.orderNumber} is FTL, and an FTL shipment carries only one order`); + return false; + } + if (o.serviceLevel === 'FTL') ftlSlotTaken = true; if (o.requiresHazmat && !shipment.hazmat) { errors.push(`${o.orderNumber} requires hazmat handling, which this shipment isn't flagged for`); return false; diff --git a/docs/DOMAIN_BEHAVIOURS.md b/docs/DOMAIN_BEHAVIOURS.md index c1551e16..697cca25 100644 --- a/docs/DOMAIN_BEHAVIOURS.md +++ b/docs/DOMAIN_BEHAVIOURS.md @@ -125,6 +125,17 @@ null (not moving yet) → in_transit → delivered ↘ exception → (resolved) → in_transit ``` +**What can share a shipment (#325).** `commands/orders/shipmentLoadRules.ts`, enforced inside +`AddOrdersToShipmentCommand`, `CombineOrdersIntoShipmentCommand`, `ConvertOrderToShipmentCommand` and +`SplitOrderCommand`: an FTL shipment carries exactly one order; FTL and LTL orders never share a +shipment; LTL orders from different customers may (the shipment is filed under the first order's +customer); a shipment is `tempControlled` / `hazmat` if any order on it needs that. Every path that +creates a shipment from orders sets `serviceLevel`, `tempControlled` and `hazmat` from them; adding +to a shipment with no service level gives it the orders'. `OrderConversionService` runs the same +checks first only to report per order. `scripts/backfill-shipment-service-level.ts` (dry run unless +`--apply`) sets the service level on existing shipments whose orders agree and lists mixed or empty +ones for review. + **Order delivery events (#325).** Every delivery status write goes through a command, so it runs in a transaction and emits after commit: `order.delivered` (carrying `deliveredAt`, the device time for tracking), `order.exception` (with `exceptionType`), `order.exception_resolved`, or diff --git a/docs/roadmap/completed.md b/docs/roadmap/completed.md index 9ebe2390..546103f1 100644 --- a/docs/roadmap/completed.md +++ b/docs/roadmap/completed.md @@ -56,6 +56,7 @@ and priorities). Newest entries append at the bottom of their phase; new phases - **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. - **Multi-Stop Geofence Journeys (#324)** - Every stop on a shipment now completes from geofence events, not just the origin and destination: entering a delivery stop's geofence completes it and delivers its orders, in any order. The shipment only completes once every stop is done; reaching the final stop with stops unvisited raises a `stops_not_visited` exception. Repeat visits to the same location are handled as separate stops. Departing the origin moves a ready shipment to in progress, checkpoints exclude the origin and destination geofences, events carry the device id, and the timeline shows origin arrival (#307 items 1-3). - **Order Delivery Events (#325, part 1)** - Every order delivery status change (stop completion from tracking, manual mark-delivered, delivery exceptions and their resolution) now goes through a command and emits `order.delivered`, `order.exception`, `order.exception_resolved` or `order.delivery_status_changed`. Deliveries now reach the order list, customer webhooks, email and in-app notifications, with the delivery time taken from the device. Leaving the origin puts the shipment's orders in transit instead of wrongly delivering orders whose stop is the origin. +- **FTL/LTL Shipment Rules (#325, part 2)** - An FTL shipment now carries exactly one order and FTL/LTL orders can't share a shipment, enforced in the add, combine, convert and split commands. LTL shipments may mix customers. Shipments created from orders take their service level and temperature/hazmat flags from them, and a backfill script sets the service level on existing shipments, listing mixed ones for review. ### **Phase 6: Cold Chain** DONE (partial) - **Excursion Management** - IoT sensor pipeline, disposition lifecycle (monitoring to released/quarantined), auto-triage. Effective temperature/alert range derives from order temperatureControl defaults (no standalone profile entity) From b0606e988b1d56c5b4af028f1569e386d03250de Mon Sep 17 00:00:00 2001 From: Dominic Finn Date: Sat, 3 Oct 2026 22:13:50 +0100 Subject: [PATCH 2/4] Give shipments created from orders a pickup stop at the origin (#325) Convert, combine and split created only delivery stops, so leaving the origin was never tracked for those shipments: they never moved from ready to in_progress, their orders never went in transit and they never auto-completed (#324). They now start with a pickup stop at the origin, like shipments created through CreateShipmentCommand. Co-Authored-By: Claude Opus 5.5 --- .../CombineOrdersIntoShipmentCommand.test.ts | 17 +++++++++++++++-- .../orders/CombineOrdersIntoShipmentCommand.ts | 4 +++- .../orders/ConvertOrderToShipmentCommand.ts | 4 +++- .../src/commands/orders/SplitOrderCommand.ts | 4 +++- .../commands/shipments/linkOrdersToShipment.ts | 11 +++++++++++ docs/DOMAIN_BEHAVIOURS.md | 3 ++- 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts b/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts index f1ca273e..9fa47e02 100644 --- a/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts +++ b/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts @@ -66,8 +66,8 @@ describe('CombineOrdersIntoShipmentCommandHandler', () => { expect(result.data).toEqual({ shipmentId: 'ship-1' }); expect(tx.shipment.create.mock.calls[0][0].data.orgId).toBe('test-org'); - // one stop per order (each has a distinct destination and shipmentStop.findFirst always returns null here) - expect(tx.shipmentStop.create).toHaveBeenCalledTimes(2); + // the pickup, then one stop per order (each has a distinct destination and shipmentStop.findFirst always returns null here) + expect(tx.shipmentStop.create).toHaveBeenCalledTimes(3); expect(tx.order.update).toHaveBeenCalledTimes(2); const shipmentCreatedEvents = result.events.filter((e) => e.type === EVENT_TYPES.SHIPMENT_CREATED); @@ -155,5 +155,18 @@ describe('CombineOrdersIntoShipmentCommandHandler', () => { expect(result.success).toBe(false); expect(tx.shipment.create).not.toHaveBeenCalled(); }); + + it('starts the shipment with a pickup stop at the origin, before the delivery stops (#324)', async () => { + const tx = makeTx(); + tx.shipment.create.mockResolvedValue({ id: 'ship-1', reference: 'SH-BATCH-XYZ', items: [], originId: 'loc-origin' }); + tx.order.findMany.mockResolvedValue([makeOrder()]); + const handler = new CombineOrdersIntoShipmentCommandHandler(makePrisma(tx), mockEventBus().bus); + + await handler.execute(createTestCommand(COMBINE_ORDERS_INTO_SHIPMENT, { orderIds: ['order-a'] }, { orgId: 'test-org' })); + + expect(tx.shipmentStop.create.mock.calls[0][0]).toEqual({ + data: { shipmentId: 'ship-1', locationId: 'loc-origin', sequenceNumber: 1, stopType: 'pickup', status: 'pending' }, + }); + }); }); diff --git a/backend/src/commands/orders/CombineOrdersIntoShipmentCommand.ts b/backend/src/commands/orders/CombineOrdersIntoShipmentCommand.ts index d017e651..6af73d49 100644 --- a/backend/src/commands/orders/CombineOrdersIntoShipmentCommand.ts +++ b/backend/src/commands/orders/CombineOrdersIntoShipmentCommand.ts @@ -16,7 +16,7 @@ import { PrismaClient } from '@prisma/client'; import { PgBossEventBus } from '../../events/PgBossEventBus.js'; import { EVENT_TYPES } from '../../events/eventTypes.js'; import { BaseCommandHandler, TransactionClient, EmitFn } from '../BaseCommandHandler.js'; -import { linkOrdersToShipment } from '../shipments/linkOrdersToShipment.js'; +import { createPickupStop, linkOrdersToShipment } from '../shipments/linkOrdersToShipment.js'; import { Command } from '../types.js'; import { loadProfileFor } from './shipmentLoadRules.js'; @@ -90,6 +90,8 @@ export class CombineOrdersIntoShipmentCommandHandler extends BaseCommandHandler< }, })); + await createPickupStop(tx, shipment.id, shipment.originId!); + await linkOrdersToShipment( tx, shipment, diff --git a/backend/src/commands/orders/ConvertOrderToShipmentCommand.ts b/backend/src/commands/orders/ConvertOrderToShipmentCommand.ts index 25eecb19..63e2ae29 100644 --- a/backend/src/commands/orders/ConvertOrderToShipmentCommand.ts +++ b/backend/src/commands/orders/ConvertOrderToShipmentCommand.ts @@ -15,7 +15,7 @@ import { PgBossEventBus } from '../../events/PgBossEventBus.js'; import { EVENT_TYPES } from '../../events/eventTypes.js'; import { BaseCommandHandler, TransactionClient, EmitFn } from '../BaseCommandHandler.js'; import { loadProfileFor } from './shipmentLoadRules.js'; -import { linkOrdersToShipment } from '../shipments/linkOrdersToShipment.js'; +import { createPickupStop, linkOrdersToShipment } from '../shipments/linkOrdersToShipment.js'; import { Command } from '../types.js'; export interface ConvertOrderToShipmentPayload { @@ -94,6 +94,8 @@ export class ConvertOrderToShipmentCommandHandler extends BaseCommandHandler { + await tx.shipmentStop.create({ + data: { shipmentId, locationId: originId, sequenceNumber: 1, stopType: 'pickup', status: 'pending' }, + }); +} + export async function linkOrdersToShipment( tx: TransactionClient, shipment: LinkableShipment, diff --git a/docs/DOMAIN_BEHAVIOURS.md b/docs/DOMAIN_BEHAVIOURS.md index 697cca25..b13b6c7d 100644 --- a/docs/DOMAIN_BEHAVIOURS.md +++ b/docs/DOMAIN_BEHAVIOURS.md @@ -130,7 +130,8 @@ null (not moving yet) → in_transit → delivered `SplitOrderCommand`: an FTL shipment carries exactly one order; FTL and LTL orders never share a shipment; LTL orders from different customers may (the shipment is filed under the first order's customer); a shipment is `tempControlled` / `hazmat` if any order on it needs that. Every path that -creates a shipment from orders sets `serviceLevel`, `tempControlled` and `hazmat` from them; adding +creates a shipment from orders sets `serviceLevel`, `tempControlled` and `hazmat` from them, and +starts it with a pickup stop at the origin (sequence 1) so leaving the origin is tracked (#324); adding to a shipment with no service level gives it the orders'. `OrderConversionService` runs the same checks first only to report per order. `scripts/backfill-shipment-service-level.ts` (dry run unless `--apply`) sets the service level on existing shipments whose orders agree and lists mixed or empty From fe8e793048cdeb627d61742459ddf01bc83a90bf Mon Sep 17 00:00:00 2001 From: Dominic Finn Date: Sat, 3 Oct 2026 22:17:28 +0100 Subject: [PATCH 3/4] End a combined shipment at its last drop, not the first order's (#325) Combining orders bound for different places set the shipment's destination to the first order's, so the route header and journey checkpoints pointed at a middle stop. The destination is now the last drop. Co-Authored-By: Claude Opus 5.5 --- .../CombineOrdersIntoShipmentCommand.test.ts | 15 +++++++++++++++ .../orders/CombineOrdersIntoShipmentCommand.ts | 11 +++++++++++ docs/DOMAIN_BEHAVIOURS.md | 3 ++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts b/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts index 9fa47e02..b0486623 100644 --- a/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts +++ b/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts @@ -168,5 +168,20 @@ describe('CombineOrdersIntoShipmentCommandHandler', () => { data: { shipmentId: 'ship-1', locationId: 'loc-origin', sequenceNumber: 1, stopType: 'pickup', status: 'pending' }, }); }); + + it('ends the shipment at its last drop when orders go to different places (#324)', async () => { + const tx = makeTx(); + tx.shipment.create.mockResolvedValue({ id: 'ship-1', reference: 'SH-BATCH-XYZ', items: [], originId: 'loc-origin', destinationId: 'loc-dest-1' }); + tx.shipmentStop.findFirst.mockImplementation((args: any) => Promise.resolve(args?.orderBy ? { locationId: 'loc-dest-2' } : null)); + tx.order.findMany.mockResolvedValue([ + makeOrder({ id: 'a', destinationId: 'loc-dest-1' }), + makeOrder({ id: 'b', destinationId: 'loc-dest-2' }), + ]); + const handler = new CombineOrdersIntoShipmentCommandHandler(makePrisma(tx), mockEventBus().bus); + + await handler.execute(createTestCommand(COMBINE_ORDERS_INTO_SHIPMENT, { orderIds: ['a', 'b'] }, { orgId: 'test-org' })); + + expect(tx.shipment.update).toHaveBeenCalledWith(expect.objectContaining({ data: { destinationId: 'loc-dest-2' } })); + }); }); diff --git a/backend/src/commands/orders/CombineOrdersIntoShipmentCommand.ts b/backend/src/commands/orders/CombineOrdersIntoShipmentCommand.ts index 6af73d49..acd8b81d 100644 --- a/backend/src/commands/orders/CombineOrdersIntoShipmentCommand.ts +++ b/backend/src/commands/orders/CombineOrdersIntoShipmentCommand.ts @@ -107,6 +107,17 @@ export class CombineOrdersIntoShipmentCommandHandler extends BaseCommandHandler< { batchOrderIds: orderIds }, ); + // With orders bound for different places, the shipment ends at its last drop, not at the first + // order's destination: checkpoints and the route header measure towards it (#324). + const finalStop = await tx.shipmentStop.findFirst({ + where: { shipmentId: shipment.id, shipment: { orgId: command.orgId } }, + orderBy: { sequenceNumber: 'desc' }, + select: { locationId: true }, + }); + if (finalStop && finalStop.locationId !== shipment.destinationId) { + await tx.shipment.update({ where: { id: shipment.id, orgId: command.orgId }, data: { destinationId: finalStop.locationId } }); + } + return { shipmentId: shipment.id }; } } diff --git a/docs/DOMAIN_BEHAVIOURS.md b/docs/DOMAIN_BEHAVIOURS.md index b13b6c7d..5218a48d 100644 --- a/docs/DOMAIN_BEHAVIOURS.md +++ b/docs/DOMAIN_BEHAVIOURS.md @@ -131,7 +131,8 @@ null (not moving yet) → in_transit → delivered shipment; LTL orders from different customers may (the shipment is filed under the first order's customer); a shipment is `tempControlled` / `hazmat` if any order on it needs that. Every path that creates a shipment from orders sets `serviceLevel`, `tempControlled` and `hazmat` from them, and -starts it with a pickup stop at the origin (sequence 1) so leaving the origin is tracked (#324); adding +starts it with a pickup stop at the origin (sequence 1) so leaving the origin is tracked (#324); +combining orders bound for different places sets the shipment's destination to its last drop; adding to a shipment with no service level gives it the orders'. `OrderConversionService` runs the same checks first only to report per order. `scripts/backfill-shipment-service-level.ts` (dry run unless `--apply`) sets the service level on existing shipments whose orders agree and lists mixed or empty From 52e1ea120c91f9589e53e2984eb9707896dcf952 Mon Sep 17 00:00:00 2001 From: Dominic Finn Date: Sun, 4 Oct 2026 10:11:50 +0100 Subject: [PATCH 4/4] Keep a shipment to one customer (#325) Several customers' freight on one truck will be a consolidation of per-customer shipments, so each customer keeps their own bill of lading, invoice and portal view. Until then, adding or combining orders for a different customer is refused, and the compatibility check reports mixed customers as an error instead of a warning. Co-Authored-By: Claude Opus 5.5 --- .../AddOrdersToShipmentCommand.test.ts | 8 +++++--- .../CombineOrdersIntoShipmentCommand.test.ts | 15 +++++++++++++-- .../commands/shipmentLoadRules.test.ts | 11 +++++++++-- .../services/OrderConversionService.test.ts | 6 +++--- .../orders/CombineOrdersIntoShipmentCommand.ts | 1 - .../src/commands/orders/shipmentLoadRules.ts | 18 ++++++++++++++++-- backend/src/services/OrderConversionService.ts | 6 +++--- docs/DOMAIN_BEHAVIOURS.md | 4 ++-- docs/roadmap/completed.md | 2 +- 9 files changed, 52 insertions(+), 19 deletions(-) diff --git a/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts b/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts index 0347d251..24bf57be 100644 --- a/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts +++ b/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts @@ -25,6 +25,7 @@ function makeShipment(overrides: any = {}) { id: 'ship-1', orgId: 'test-org', reference: 'SH-EXISTING', + customerId: 'cust-1', serviceLevel: 'LTL', tempControlled: false, hazmat: false, @@ -181,9 +182,10 @@ describe('AddOrdersToShipmentCommandHandler', () => { expect(result.error).toMatch(/FTL orders can't join a LTL shipment/); }); - it('lets LTL orders from different customers share a shipment', async () => { - const { result } = await add({}, [makeOrder({ id: 'a', customerId: 'cust-1' }), makeOrder({ id: 'b', customerId: 'cust-2' })]); - expect(result.success).toBe(true); + it('refuses an order for another customer, even on LTL', async () => { + const { result } = await add({ customerId: 'cust-1' }, [makeOrder({ id: 'b', customerId: 'cust-2' })]); + expect(result.success).toBe(false); + expect(result.error).toMatch(/another customer/); }); it('gives a shipment with no service level the orders\' one', async () => { diff --git a/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts b/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts index b0486623..83b76960 100644 --- a/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts +++ b/backend/src/__tests__/commands/CombineOrdersIntoShipmentCommand.test.ts @@ -132,8 +132,8 @@ describe('CombineOrdersIntoShipmentCommandHandler', () => { it('sets the shipment service level and handling flags from its orders (#325)', async () => { const tx = makeTx(); tx.order.findMany.mockResolvedValue([ - makeOrder({ id: 'a', customerId: 'cust-1' }), - makeOrder({ id: 'b', customerId: 'cust-2', temperatureControl: 'refrigerated' }), + makeOrder({ id: 'a' }), + makeOrder({ id: 'b', temperatureControl: 'refrigerated' }), ]); const handler = new CombineOrdersIntoShipmentCommandHandler(makePrisma(tx), mockEventBus().bus); @@ -183,5 +183,16 @@ describe('CombineOrdersIntoShipmentCommandHandler', () => { expect(tx.shipment.update).toHaveBeenCalledWith(expect.objectContaining({ data: { destinationId: 'loc-dest-2' } })); }); + + it('refuses to combine orders for different customers (#325)', async () => { + const tx = makeTx(); + tx.order.findMany.mockResolvedValue([makeOrder({ id: 'a', customerId: 'cust-1' }), makeOrder({ id: 'b', customerId: 'cust-2' })]); + const handler = new CombineOrdersIntoShipmentCommandHandler(makePrisma(tx), mockEventBus().bus); + + const result = await handler.execute(createTestCommand(COMBINE_ORDERS_INTO_SHIPMENT, { orderIds: ['a', 'b'] }, { orgId: 'test-org' })); + + expect(result.success).toBe(false); + expect(tx.shipment.create).not.toHaveBeenCalled(); + }); }); diff --git a/backend/src/__tests__/commands/shipmentLoadRules.test.ts b/backend/src/__tests__/commands/shipmentLoadRules.test.ts index ff7ce241..fdea776d 100644 --- a/backend/src/__tests__/commands/shipmentLoadRules.test.ts +++ b/backend/src/__tests__/commands/shipmentLoadRules.test.ts @@ -1,6 +1,6 @@ import { assertCanAdd, loadProfileFor, ShipmentLoadRuleError } from '../../commands/orders/shipmentLoadRules'; -const order = (overrides: object = {}) => ({ orderNumber: 'ORD-1', serviceLevel: 'LTL', temperatureControl: 'ambient', requiresHazmat: false, ...overrides }); +const order = (overrides: object = {}) => ({ orderNumber: 'ORD-1', customerId: 'cust-1', serviceLevel: 'LTL', temperatureControl: 'ambient', requiresHazmat: false, ...overrides }); describe('shipmentLoadRules (#325)', () => { it('takes the service level and the strictest handling from the orders', () => { @@ -15,9 +15,16 @@ describe('shipmentLoadRules (#325)', () => { }); it('refuses an order needing handling the shipment is not flagged for', () => { - const shipment = { serviceLevel: 'LTL', tempControlled: false, hazmat: false }; + const shipment = { customerId: 'cust-1', serviceLevel: 'LTL', tempControlled: false, hazmat: false }; expect(() => assertCanAdd(shipment, 1, [order({ requiresHazmat: true })])).toThrow(/hazmat/); expect(() => assertCanAdd(shipment, 1, [order({ temperatureControl: 'refrigerated' })])).toThrow(/temperature control/); expect(assertCanAdd(shipment, 3, [order()])).toBe('LTL'); }); + + it('keeps a shipment to one customer', () => { + expect(() => loadProfileFor([order(), order({ customerId: 'cust-2' })])).toThrow(/different customers/); + const shipment = { customerId: 'cust-1', serviceLevel: 'LTL', tempControlled: false, hazmat: false }; + expect(() => assertCanAdd(shipment, 1, [order({ customerId: 'cust-2' })])).toThrow(/another customer/); + }); }); + diff --git a/backend/src/__tests__/services/OrderConversionService.test.ts b/backend/src/__tests__/services/OrderConversionService.test.ts index ee56ecf9..1d852b3f 100644 --- a/backend/src/__tests__/services/OrderConversionService.test.ts +++ b/backend/src/__tests__/services/OrderConversionService.test.ts @@ -316,7 +316,7 @@ describe('OrderConversionService', () => { }); describe('checkCompatibility', () => { - it('flags different origins as an error and different customers as a warning only', async () => { + it('flags different origins and different customers as errors (#325)', 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,8 +335,8 @@ describe('OrderConversionService', () => { expect.objectContaining({ where: expect.objectContaining({ orgId: 'test-org' }) }), ); expect(check.compatible).toBe(false); - expect(check.errors[0]).toMatch(/different origins/); - expect(check.warnings.some((w) => w.includes('different customers'))).toBe(true); + expect(check.errors.some((e) => /different origins/.test(e))).toBe(true); + expect(check.errors.some((e) => /different customers/.test(e))).toBe(true); }); }); }); diff --git a/backend/src/commands/orders/CombineOrdersIntoShipmentCommand.ts b/backend/src/commands/orders/CombineOrdersIntoShipmentCommand.ts index acd8b81d..c38199b9 100644 --- a/backend/src/commands/orders/CombineOrdersIntoShipmentCommand.ts +++ b/backend/src/commands/orders/CombineOrdersIntoShipmentCommand.ts @@ -71,7 +71,6 @@ export class CombineOrdersIntoShipmentCommandHandler extends BaseCommandHandler< destinationId: firstOrder.destinationId!, items: [], status: 'draft', - // LTL may mix customers; the shipment is filed under the first order's. ...loadProfileFor(orders), }, }); diff --git a/backend/src/commands/orders/shipmentLoadRules.ts b/backend/src/commands/orders/shipmentLoadRules.ts index 2425d1c2..3bf9c305 100644 --- a/backend/src/commands/orders/shipmentLoadRules.ts +++ b/backend/src/commands/orders/shipmentLoadRules.ts @@ -4,7 +4,9 @@ * BUSINESS RULES: * - FTL is a dedicated truck: an FTL shipment carries exactly one order. * - FTL and LTL orders never share a shipment. - * - LTL consolidates freight, so its orders may belong to different customers. + * - A shipment belongs to one customer. Several customers' freight on one truck is a consolidation + * of per-customer shipments (planned), not one shared shipment: each customer needs their own + * bill of lading, invoice and portal view. * - A shipment is temperature-controlled / hazmat if any order on it needs that. * * Enforced inside the add/combine/convert/split commands, so a race between two requests can't @@ -13,6 +15,7 @@ export interface LoadOrder { orderNumber: string; + customerId: string; serviceLevel: string; temperatureControl: string; requiresHazmat: boolean; @@ -33,6 +36,7 @@ export class ShipmentLoadRuleError extends Error { /** The profile a new shipment takes from the orders it's created for. */ export function loadProfileFor(orders: LoadOrder[]): ShipmentLoadProfile { + singleCustomer(orders); const serviceLevel = singleServiceLevel(orders); if (serviceLevel === 'FTL' && orders.length > 1) { throw new ShipmentLoadRuleError(`An FTL shipment carries one order; got ${orders.length} FTL orders`); @@ -49,10 +53,14 @@ export function loadProfileFor(orders: LoadOrder[]): ShipmentLoadProfile { * the service level the shipment should have afterwards (its own, or the orders' when it has none). */ export function assertCanAdd( - shipment: { serviceLevel: string | null; tempControlled: boolean; hazmat: boolean }, + shipment: { customerId: string | null; serviceLevel: string | null; tempControlled: boolean; hazmat: boolean }, existingOrderCount: number, orders: LoadOrder[], ): string { + const customerId = singleCustomer(orders); + if (shipment.customerId && customerId !== shipment.customerId) { + throw new ShipmentLoadRuleError('Orders for another customer can\'t join this shipment'); + } const ordersLevel = singleServiceLevel(orders); const serviceLevel = shipment.serviceLevel ?? ordersLevel; if (ordersLevel !== serviceLevel) { @@ -68,6 +76,12 @@ export function assertCanAdd( return serviceLevel; } +function singleCustomer(orders: LoadOrder[]): string { + const customers = new Set(orders.map((o) => o.customerId)); + if (customers.size > 1) throw new ShipmentLoadRuleError('Orders for different customers can\'t share a shipment'); + return [...customers][0]; +} + function singleServiceLevel(orders: LoadOrder[]): string { const levels = new Set(orders.map((o) => o.serviceLevel)); if (levels.size !== 1) { diff --git a/backend/src/services/OrderConversionService.ts b/backend/src/services/OrderConversionService.ts index 3d1f80d1..c4138d5c 100644 --- a/backend/src/services/OrderConversionService.ts +++ b/backend/src/services/OrderConversionService.ts @@ -133,11 +133,11 @@ export class OrderConversionService implements IOrderConversionService { } // Check customer consistency + // BUSINESS RULE (#325): a shipment belongs to one customer; several customers on one truck + // will be a consolidation of per-customer shipments. const customerIds = new Set(orders.map((o) => o.customerId)); if (customerIds.size > 1) { - warnings.push( - 'Orders belong to different customers. Combined shipment will use the first order\'s customer.' - ); + errors.push('Orders belong to different customers, which can\'t share a shipment.'); } // Check origin consistency diff --git a/docs/DOMAIN_BEHAVIOURS.md b/docs/DOMAIN_BEHAVIOURS.md index 5218a48d..e6038747 100644 --- a/docs/DOMAIN_BEHAVIOURS.md +++ b/docs/DOMAIN_BEHAVIOURS.md @@ -128,8 +128,8 @@ null (not moving yet) → in_transit → delivered **What can share a shipment (#325).** `commands/orders/shipmentLoadRules.ts`, enforced inside `AddOrdersToShipmentCommand`, `CombineOrdersIntoShipmentCommand`, `ConvertOrderToShipmentCommand` and `SplitOrderCommand`: an FTL shipment carries exactly one order; FTL and LTL orders never share a -shipment; LTL orders from different customers may (the shipment is filed under the first order's -customer); a shipment is `tempControlled` / `hazmat` if any order on it needs that. Every path that +shipment; a shipment belongs to one customer (several customers' freight on one truck will be a +consolidation of per-customer shipments, not a shared shipment); a shipment is `tempControlled` / `hazmat` if any order on it needs that. Every path that creates a shipment from orders sets `serviceLevel`, `tempControlled` and `hazmat` from them, and starts it with a pickup stop at the origin (sequence 1) so leaving the origin is tracked (#324); combining orders bound for different places sets the shipment's destination to its last drop; adding diff --git a/docs/roadmap/completed.md b/docs/roadmap/completed.md index 546103f1..d065cb75 100644 --- a/docs/roadmap/completed.md +++ b/docs/roadmap/completed.md @@ -56,7 +56,7 @@ and priorities). Newest entries append at the bottom of their phase; new phases - **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. - **Multi-Stop Geofence Journeys (#324)** - Every stop on a shipment now completes from geofence events, not just the origin and destination: entering a delivery stop's geofence completes it and delivers its orders, in any order. The shipment only completes once every stop is done; reaching the final stop with stops unvisited raises a `stops_not_visited` exception. Repeat visits to the same location are handled as separate stops. Departing the origin moves a ready shipment to in progress, checkpoints exclude the origin and destination geofences, events carry the device id, and the timeline shows origin arrival (#307 items 1-3). - **Order Delivery Events (#325, part 1)** - Every order delivery status change (stop completion from tracking, manual mark-delivered, delivery exceptions and their resolution) now goes through a command and emits `order.delivered`, `order.exception`, `order.exception_resolved` or `order.delivery_status_changed`. Deliveries now reach the order list, customer webhooks, email and in-app notifications, with the delivery time taken from the device. Leaving the origin puts the shipment's orders in transit instead of wrongly delivering orders whose stop is the origin. -- **FTL/LTL Shipment Rules (#325, part 2)** - An FTL shipment now carries exactly one order and FTL/LTL orders can't share a shipment, enforced in the add, combine, convert and split commands. LTL shipments may mix customers. Shipments created from orders take their service level and temperature/hazmat flags from them, and a backfill script sets the service level on existing shipments, listing mixed ones for review. +- **FTL/LTL Shipment Rules (#325, part 2)** - An FTL shipment now carries exactly one order and FTL/LTL orders can't share a shipment, enforced in the add, combine, convert and split commands, and a shipment belongs to one customer. Shipments created from orders take their service level and temperature/hazmat flags from them, and a backfill script sets the service level on existing shipments, listing mixed ones for review. ### **Phase 6: Cold Chain** DONE (partial) - **Excursion Management** - IoT sensor pipeline, disposition lifecycle (monitoring to released/quarantined), auto-triage. Effective temperature/alert range derives from order temperatureControl defaults (no standalone profile entity)