mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user