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

@@ -16,6 +16,13 @@ import {
EDR_HAULAGE_CONFLICT_MESSAGE,
usesEdrMileService,
} from '../../common/mile-haulage.util';
import {
assertBulkTonnageRemains,
assertTruckCountWithinContainers,
assertTruckLoad,
bookingContainerSizes,
remainingBulkTons,
} from '../../common/truck-load.util';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
@@ -71,39 +78,27 @@ export class CustomerTruckService {
if (!isBulk && requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
if (requested.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
// Bulk is capped by tonnage, not container count: trucks may be added until
// the booking's declared weight has been hauled away. Container bookings are
// capped below by #trucks <= #containers.
if (isBulk) {
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
assertBulkTonnageRemains(totalTons, remainingTons);
}
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
// Never assign more trucks than the booking has containers.
const existingTrucks = await this.dataSource
.getRepository(CustomerTruckAssignment)
.count({ where: { bookingId } });
if (existingTrucks + 1 > bookingNumbers.length) {
throw new BadRequestException(
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
);
}
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
const alreadyAssigned = await this.assignedContainerNumbers(bookingId);
for (const n of requested) {
if (alreadyAssigned.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
// Size cap: a 40ft container fills the truck.
const sizes = await this.containerSizes(bookingId, requested);
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
);
}
assertTruckCountWithinContainers(existingTrucks + 1, bookingNumbers.length);
assertTruckLoad({
containers: requested,
bookingContainers: bookingNumbers,
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
assignedElsewhere: await this.assignedContainerNumbers(bookingId),
});
}
await this.dataSource.transaction(async (manager) => {
@@ -195,28 +190,13 @@ export class CustomerTruckService {
if (requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
if (requested.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
// Exclude THIS truck's own containers so re-saving the same set is allowed.
const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
for (const n of requested) {
if (assignedElsewhere.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
const sizes = await this.containerSizes(bookingId, requested);
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
);
}
assertTruckLoad({
containers: requested,
bookingContainers: await this.bookingContainerNumbers(bookingId),
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
// Exclude THIS truck's own containers so re-saving the same set is allowed.
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
});
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
@@ -344,27 +324,12 @@ export class CustomerTruckService {
}
// Capacity is size-based: a truck carries at most 2 containers, and a 40ft
// container fills the truck (max 1) — mirror the addTruck/updateTruck rule.
if (requested.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
for (const n of requested) {
if (elsewhere.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
const sizes = await this.containerSizes(bookingId, requested);
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
throw new BadRequestException(
'A 40ft container fills the truck — load only 1 container onto this truck',
);
}
assertTruckLoad({
containers: requested,
bookingContainers: await this.bookingContainerNumbers(bookingId),
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
});
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
@@ -611,18 +576,4 @@ export class CustomerTruckService {
}
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container 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());
}
}

View File

@@ -39,6 +39,18 @@ export class CustomerTruckAssignment extends BaseEntity {
@Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true })
grossWeightKg?: number | null;
/** Empty truck weight at the gate, in tonnes. Null until the truck departs. */
@Column({ name: 'tare_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
tareWeightTons?: number | null;
/**
* Cargo actually taken (gross tare), in tonnes. Drives the bulk drawdown:
* a bulk booking is hauled until the sum of this across departed trucks
* reaches its declared VGM. Mirrors last_mile_vehicle_assignments.
*/
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
netWeightTons?: number | null;
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
departedAt?: Date | null;