Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
};
Expand All @@ -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' }),
Expand Down Expand Up @@ -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' } }));
});
});
});

Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
});
});

30 changes: 30 additions & 0 deletions backend/src/__tests__/commands/shipmentLoadRules.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});

40 changes: 40 additions & 0 deletions backend/src/__tests__/scripts/backfillShipmentServiceLevel.test.ts
Original file line number Diff line number Diff line change
@@ -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' } });
});
});
6 changes: 3 additions & 3 deletions backend/src/__tests__/services/OrderConversionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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);
});
});
});
7 changes: 7 additions & 0 deletions backend/src/commands/orders/AddOrdersToShipmentCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -61,6 +62,12 @@ export class AddOrdersToShipmentCommandHandler extends BaseCommandHandler<AddOrd
});
if (orders.length === 0) throw new Error('No valid orders to add');

const existingOrderCount = await tx.orderShipment.count({ where: { shipmentId, shipment: { orgId: command.orgId } } });
const serviceLevel = assertCanAdd(shipment, existingOrderCount, orders);
if (shipment.serviceLevel !== serviceLevel) {
await tx.shipment.update({ where: { id: shipmentId, orgId: command.orgId }, data: { serviceLevel } });
}

await linkOrdersToShipment(
tx,
shipment,
Expand Down
17 changes: 16 additions & 1 deletion backend/src/commands/orders/CombineOrdersIntoShipmentCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ 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';

export interface CombineOrdersIntoShipmentPayload {
orderIds: string[];
Expand Down Expand Up @@ -70,6 +71,7 @@ export class CombineOrdersIntoShipmentCommandHandler extends BaseCommandHandler<
destinationId: firstOrder.destinationId!,
items: [],
status: 'draft',
...loadProfileFor(orders),
},
});

Expand All @@ -87,6 +89,8 @@ export class CombineOrdersIntoShipmentCommandHandler extends BaseCommandHandler<
},
}));

await createPickupStop(tx, shipment.id, shipment.originId!);

await linkOrdersToShipment(
tx,
shipment,
Expand All @@ -102,6 +106,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 };
}
}
6 changes: 5 additions & 1 deletion backend/src/commands/orders/ConvertOrderToShipmentCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ 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 { loadProfileFor } from './shipmentLoadRules.js';
import { createPickupStop, linkOrdersToShipment } from '../shipments/linkOrdersToShipment.js';
import { Command } from '../types.js';

export interface ConvertOrderToShipmentPayload {
Expand Down Expand Up @@ -75,6 +76,7 @@ export class ConvertOrderToShipmentCommandHandler extends BaseCommandHandler<Con
deliveryDate: order.requestedDeliveryDate || undefined,
items: [],
status: 'draft',
...loadProfileFor([order]),
},
});

Expand All @@ -92,6 +94,8 @@ export class ConvertOrderToShipmentCommandHandler extends BaseCommandHandler<Con
},
}));

await createPickupStop(tx, shipment.id, shipment.originId!);

await linkOrdersToShipment(
tx,
shipment,
Expand Down
6 changes: 5 additions & 1 deletion backend/src/commands/orders/SplitOrderCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { PgBossEventBus } from '../../events/PgBossEventBus.js';
import { EVENT_TYPES } from '../../events/eventTypes.js';
import { BaseCommandHandler, TransactionClient, EmitFn } from '../BaseCommandHandler.js';
import { Command } from '../types.js';
import { loadProfileFor } from './shipmentLoadRules.js';
import { createPickupStop } from '../shipments/linkOrdersToShipment.js';

export interface SplitGroupPayload {
trackableUnitIds: string[];
Expand Down Expand Up @@ -151,6 +153,7 @@ export class SplitOrderCommandHandler extends BaseCommandHandler<SplitOrderPaylo
deliveryDate: order.requestedDeliveryDate || undefined,
items,
status: 'draft',
...loadProfileFor([order]),
},
});

Expand All @@ -172,11 +175,12 @@ export class SplitOrderCommandHandler extends BaseCommandHandler<SplitOrderPaylo
data: { orderId: order.id, shipmentId: shipment.id },
});

await createPickupStop(tx, shipment.id, order.originId!);
await tx.shipmentStop.create({
data: {
shipmentId: shipment.id,
locationId: order.destinationId!,
sequenceNumber: 1,
sequenceNumber: 2,
stopType: 'delivery',
status: 'pending',
},
Expand Down
Loading
Loading