fix(mile): apply one truck-load rule to customer and EDR haulage alike

A truck holds one 40ft or two 20ft, a container booking takes no more trucks
than it has containers, and a bulk booking takes trucks until its tonnage is
hauled away. The same physics whoever drives, but the rule was written out
four times — addTruck, updateTruck, departTruck and LastMileService — beside
a byte-identical container-size query. Copies drift: that is how the
self-haul guard ended up enforced on one side only.

The bulk cap was the real gap. EDR summed net_weight_tons of departed trucks
and refused another once the booking was drawn down. The customer side had
no cap at all: for bulk it skipped straight past every check, so a self-haul
bulk booking could take unlimited trucks.

It could not simply reuse the EDR sum. customer_truck_assignments had no net
and no tare, only a gross_weight_kg that holds tonnes despite its name and
that nothing in the live flow ever wrote — release() recorded exit weights
against the EDR table alone, which is why all five customer trucks on dev
have neither weight nor departure. Any drawdown keyed on it would have
summed zero forever and never fired.

So the customer table now carries tare_weight_tons and net_weight_tons to
match the EDR one, release() records the customer truck's exit as it already
did for EDR, and the drawdown counts both sources — a booking hauls by one
path or the other and "until no tonnage is left" means the same either way.

Also locks a load once its truck has arrived on the EDR side, which the
customer side has always done, and fills the arrival form from the customer
truck on file: the prefill read booking.customer_truck_*, which multi-truck
self-haul leaves null, so a booking with a truck assigned opened blank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-20 11:28:31 +00:00
parent 540f4a1ff6
commit c8a70593cf
8 changed files with 487 additions and 146 deletions

View File

@@ -0,0 +1,159 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import {
assertBulkTonnageRemains,
assertTruckCountWithinContainers,
assertTruckLoad,
remainingBulkTons,
} from './truck-load.util';
/**
* One physical rule, shared by customer self-haul and EDR last-mile. It used to
* be written out three times (addTruck, updateTruck, departTruck) plus a fourth
* in LastMileService.
*/
describe('assertTruckLoad', () => {
const booking = ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111'];
it('accepts two 20ft containers on one truck', () => {
expect(() =>
assertTruckLoad({
containers: ['ABCD1234567', 'ABCD7654321'],
bookingContainers: booking,
sizes: ['20ft', '20ft'],
}),
).not.toThrow();
});
it('accepts a single 40ft container', () => {
expect(() =>
assertTruckLoad({
containers: ['ABCD1234567'],
bookingContainers: booking,
sizes: ['40ft'],
}),
).not.toThrow();
});
it('rejects a 40ft sharing the truck — it fills the bed', () => {
expect(() =>
assertTruckLoad({
containers: ['ABCD1234567', 'ABCD7654321'],
bookingContainers: booking,
sizes: ['40ft', '20ft'],
}),
).toThrow(BadRequestException);
});
it('rejects more than two containers', () => {
expect(() =>
assertTruckLoad({
containers: ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111'],
bookingContainers: booking,
sizes: ['20ft', '20ft', '20ft'],
}),
).toThrow(BadRequestException);
});
it('rejects a container that is not on the booking', () => {
expect(() =>
assertTruckLoad({
containers: ['ZZZZ9999999'],
bookingContainers: booking,
sizes: ['20ft'],
}),
).toThrow(BadRequestException);
});
it('rejects a container already riding another truck', () => {
expect(() =>
assertTruckLoad({
containers: ['ABCD1234567'],
bookingContainers: booking,
sizes: ['20ft'],
assignedElsewhere: ['ABCD1234567'],
}),
).toThrow(ConflictException);
});
it('skips membership checks when the booking has no containers (bulk)', () => {
expect(() =>
assertTruckLoad({ containers: [], bookingContainers: [], sizes: [] }),
).not.toThrow();
});
it('still caps the count when the booking has no containers', () => {
expect(() =>
assertTruckLoad({
containers: ['A', 'B', 'C'],
bookingContainers: [],
sizes: [],
}),
).toThrow(BadRequestException);
});
});
describe('assertBulkTonnageRemains', () => {
it('allows another truck while tonnage is left', () => {
expect(() => assertBulkTonnageRemains(100, 40)).not.toThrow();
});
it('rejects a truck once the booking is fully hauled', () => {
expect(() => assertBulkTonnageRemains(100, 0)).toThrow(BadRequestException);
});
it('does not cap a booking with no declared weight', () => {
// Nothing to draw down against — capping here would block every truck.
expect(() => assertBulkTonnageRemains(0, 0)).not.toThrow();
});
});
describe('remainingBulkTons', () => {
const dataSourceReturning = (totalTons: string, hauledTons: string) =>
({ query: jest.fn().mockResolvedValue([{ totalTons, hauledTons }]) }) as never;
it('counts trucks from both haulage paths against the declared weight', async () => {
const result = await remainingBulkTons(dataSourceReturning('100', '60'), 'b-1');
expect(result).toEqual({
totalTons: 100,
hauledTons: 60,
remainingTons: 40,
complete: false,
});
});
it('is complete once everything is hauled', async () => {
const result = await remainingBulkTons(dataSourceReturning('100', '100'), 'b-1');
expect(result.remainingTons).toBe(0);
expect(result.complete).toBe(true);
});
it('never reports negative tonnage when trucks overshoot', async () => {
const result = await remainingBulkTons(dataSourceReturning('100', '104'), 'b-1');
expect(result.remainingTons).toBe(0);
expect(result.complete).toBe(true);
});
it('is not complete for a booking with no declared weight', async () => {
const result = await remainingBulkTons(dataSourceReturning('0', '0'), 'b-1');
expect(result.complete).toBe(false);
});
});
describe('assertTruckCountWithinContainers', () => {
it('allows one truck per container', () => {
expect(() => assertTruckCountWithinContainers(3, 3)).not.toThrow();
});
it('rejects more trucks than containers', () => {
expect(() => assertTruckCountWithinContainers(4, 3)).toThrow(BadRequestException);
});
it('does not cap a bulk booking, which has no container count', () => {
expect(() => assertTruckCountWithinContainers(9, 0)).not.toThrow();
});
});

View File

@@ -0,0 +1,148 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
/** Two 20ft containers fit a truck bed; one 40ft fills it. */
export const MAX_CONTAINERS_PER_TRUCK = 2;
/**
* What one truck is being asked to carry, and the booking context to judge it
* against. `sizes` are the container_size labels of `containers`, in any order —
* only whether a 40ft is present matters.
*/
export interface TruckLoadCheck {
containers: string[];
/** Every container number on the booking. Empty means nothing to validate against. */
bookingContainers: string[];
sizes: string[];
/** Containers already riding another truck on this booking. */
assignedElsewhere?: string[];
}
/**
* The physical rule for loading one truck, shared by both haulage paths.
*
* A customer's own truck and an EDR last-mile truck obey the same physics, but
* the rule was implemented twice — once in CustomerTruckService, once in
* LastMileService — along with a byte-identical container-size query. Two copies
* of one rule drift, and that is exactly how the self-haul guard ended up
* enforced on one side only.
*/
export function assertTruckLoad({
containers,
bookingContainers,
sizes,
assignedElsewhere = [],
}: TruckLoadCheck): void {
if (containers.length > MAX_CONTAINERS_PER_TRUCK) {
throw new BadRequestException(
`A truck carries at most ${MAX_CONTAINERS_PER_TRUCK} containers`,
);
}
// With no container list on the booking there is nothing to check membership
// against — bulk bookings take this path.
if (!bookingContainers.length) return;
for (const number of containers) {
if (!bookingContainers.includes(number)) {
throw new BadRequestException(
`Container ${number} is not one of this booking's containers`,
);
}
if (assignedElsewhere.includes(number)) {
throw new ConflictException(`Container ${number} is already loaded onto another truck`);
}
}
// A 40ft fills the bed, so it travels alone.
if (containers.length > 1 && sizes.some((size) => size.includes('40'))) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
);
}
}
/** Never put more trucks on a booking than it has containers to fill them. */
export function assertTruckCountWithinContainers(
truckCount: number,
bookingContainerCount: number,
): void {
if (bookingContainerCount > 0 && truckCount > bookingContainerCount) {
throw new BadRequestException(
`Cannot assign more trucks than containers — this booking has ${bookingContainerCount} container(s) and ${truckCount} truck(s) requested.`,
);
}
}
/**
* How much of a bulk booking is still to be hauled. Counts trucks from BOTH
* haulage paths — a booking uses one or the other, and the rule ("trucks until
* no tonnage is left") is the same either way, so a single sum keeps them from
* disagreeing.
*
* Only departed trucks count: tonnage is known once the truck is weighed out.
*/
export async function remainingBulkTons(
dataSource: DataSource,
bookingId: string,
): Promise<{ totalTons: number; hauledTons: number; remainingTons: number; complete: boolean }> {
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
await dataSource.query(
`SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons",
COALESCE((
SELECT SUM(va.net_weight_tons)
FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile lm
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
WHERE lm.booking_id = b.id
AND va.deleted_at IS NULL
AND va.departed_at IS NOT NULL
), 0)
+ COALESCE((
SELECT SUM(a.net_weight_tons)
FROM freight.customer_truck_assignments a
WHERE a.booking_id = b.id
AND a.deleted_at IS NULL
AND a.departed_at IS NOT NULL
), 0) AS "hauledTons"
FROM freight.bookings b
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
const totalTons = Number(row?.totalTons ?? 0);
const hauledTons = Number(row?.hauledTons ?? 0);
const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000);
return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 };
}
/** A fully-hauled bulk booking has nothing left for another truck to carry. */
export function assertBulkTonnageRemains(totalTons: number, remainingTons: number): void {
if (totalTons > 0 && remainingTons <= 0) {
throw new BadRequestException(
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
);
}
}
/**
* container_size labels for the given container numbers on a booking. Shared so
* the two haulage paths read sizes the same way.
*/
export async function bookingContainerSizes(
dataSource: DataSource,
bookingId: string,
numbers: string[],
): Promise<string[]> {
if (!numbers.length) return [];
const rows: Array<{ size: string | null }> = await dataSource.query(
`SELECT bc.container_size AS "size"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND UPPER(bcu.container_number) = ANY($2)
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
return rows.map((row) => (row.size ?? '').trim());
}