diff --git a/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts b/backend/src/__tests__/commands/AddOrdersToShipmentCommand.test.ts index fa07973e..24bf57be 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,10 @@ function makeShipment(overrides: any = {}) { id: 'ship-1', orgId: 'test-org', reference: 'SH-EXISTING', + customerId: 'cust-1', + serviceLevel: 'LTL', + tempControlled: false, + hazmat: false, items: [{ orderId: 'order-existing', orderNumber: 'ORD-EXISTING' }], ...overrides, }; @@ -34,7 +41,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 +158,41 @@ 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('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 () => { + 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..83b76960 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: [], @@ -63,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); @@ -125,4 +128,71 @@ 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' }), + makeOrder({ id: 'b', 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(); + }); + + 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' }, + }); + }); + + 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' } })); + }); + + 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 new file mode 100644 index 00000000..fdea776d --- /dev/null +++ b/backend/src/__tests__/commands/shipmentLoadRules.test.ts @@ -0,0 +1,30 @@ +import { assertCanAdd, loadProfileFor, ShipmentLoadRuleError } from '../../commands/orders/shipmentLoadRules'; + +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', () => { + 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 = { 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__/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/__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/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: { 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) { + 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 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) { + 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/commands/shipments/linkOrdersToShipment.ts b/backend/src/commands/shipments/linkOrdersToShipment.ts index 100d9f4d..c934e266 100644 --- a/backend/src/commands/shipments/linkOrdersToShipment.ts +++ b/backend/src/commands/shipments/linkOrdersToShipment.ts @@ -29,6 +29,17 @@ export interface LinkOrdersToShipmentContext { source?: string; } +/** + * The pickup stop at the shipment's origin, sequence 1. A shipment created from orders needs it so + * leaving the origin is tracked (#324): it is what moves a ready shipment to in_progress and puts + * its orders in transit. Call before linking orders, whose delivery stops then follow it. + */ +export async function createPickupStop(tx: TransactionClient, shipmentId: string, originId: string): Promise { + 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/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..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 @@ -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..e6038747 100644 --- a/docs/DOMAIN_BEHAVIOURS.md +++ b/docs/DOMAIN_BEHAVIOURS.md @@ -125,6 +125,19 @@ 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; 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 +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..d065cb75 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, 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)