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

@@ -12,6 +12,13 @@ import {
SELF_HAUL_CONFLICT_MESSAGE,
usesEdrMileService,
} from '../../common/mile-haulage.util';
import {
assertBulkTonnageRemains,
assertTruckCountWithinContainers,
assertTruckLoad,
bookingContainerSizes,
remainingBulkTons,
} from '../../common/truck-load.util';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { SmsClientService } from '../notifications/sms-client.service';
@@ -621,20 +628,6 @@ export class LastMileService {
}
/** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
if (!numbers.length) return [];
const rows: Array<{ size: string | null }> = await this.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((r) => (r.size ?? '').trim());
}
/**
* Bulk drawdown: how much of the booking's tonnage is still to be hauled —
@@ -647,26 +640,9 @@ export class LastMileService {
remainingTons: number;
complete: boolean;
}> {
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
await this.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) 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 };
// Counts customer trucks as well as EDR ones — a booking hauls by one path
// or the other, and "until no tonnage is left" means the same either way.
return remainingBulkTons(this.dataSource, bookingId);
}
/**
@@ -691,11 +667,7 @@ export class LastMileService {
);
if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
if (totalTons > 0 && remainingTons <= 0) {
throw new BadRequestException(
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
);
}
assertBulkTonnageRemains(totalTons, remainingTons);
return;
}
@@ -705,33 +677,41 @@ export class LastMileService {
const seen = new Set<string>();
for (const vehicleId of desired) {
const load = loads.get(vehicleId) ?? [];
if (load.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
for (const n of load) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
if (seen.has(n)) {
throw new ConflictException(`Container ${n} is already assigned to another truck`);
}
seen.add(n);
}
// A 40ft container fills the truck; only two 20ft share one.
if (load.length > 1) {
const sizes = await this.containerSizes(bookingId, load);
if (sizes.some((s) => s.includes('40'))) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
);
}
}
assertTruckLoad({
containers: load,
bookingContainers: bookingNumbers,
sizes: await bookingContainerSizes(this.dataSource, bookingId, load),
assignedElsewhere: [...seen],
});
load.forEach((n) => seen.add(n));
}
if (desired.length > bookingNumbers.length) {
throw new BadRequestException(
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${desired.length} truck(s) requested.`,
assertTruckCountWithinContainers(desired.length, bookingNumbers.length);
}
/**
* A truck that has already reached the customer cannot have its load rewritten
* — the containers on it are a delivered fact, not a plan. The customer side
* has locked this since it was built (`Cannot edit a truck that has already
* arrived`); the EDR side let a reassignment silently rewrite history.
*/
private async assertNoArrivedVehicleChanged(
current: LastMileVehicleAssignment[],
desiredMap: Map<string, string[]>,
): Promise<void> {
const loadKey = (list: string[]) => [...list].sort().join('|');
for (const assignment of current) {
if (!assignment.arrivedAt) continue;
const stillPresent = desiredMap.has(assignment.vehicleId);
const load = desiredMap.get(assignment.vehicleId) ?? [];
const currentLoad = (assignment.containers ?? []).map((c) =>
c.containerNumber.trim().toUpperCase(),
);
if (!stillPresent || loadKey(load) !== loadKey(currentLoad)) {
throw new ConflictException(
'This truck has already arrived — its load can no longer be changed or removed',
);
}
}
}
@@ -766,6 +746,8 @@ export class LastMileService {
where: { lastMileId: id },
relations: { containers: true },
});
await this.assertNoArrivedVehicleChanged(current, desiredMap);
const junctionSet = new Set(current.map((a) => a.vehicleId));
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
// old single-vehicle path has no junction row but must still be freed.