feat(warehouse,last-mile): truck load size rule, dedup last-mile, driver-required + arrival prefill

Truck loading:
- loadTruck enforces max 2 containers / one 40ft (two 20ft) and auto-marks an
  assigned truck arrived on load; container-items payload + modal expose
  container size with a client-side selection cap.
- Show "#x containers pending assignment" in the portal truck card and the
  backoffice container modal.

Last-mile:
- create() is idempotent — return the existing record for a booking instead of
  inserting a duplicate delivery row (fixed the same booking showing twice in
  Assign-Mile).
- setVehicles/update reject a truck with no assigned driver; the Assign toast
  now surfaces the reason.
- New GET /last-mile/booking/:id/arrival-trucks returns assigned EDR trucks with
  driver details; ReleaseOrderModal fetches and auto-fills them so an assigned
  EDR truck no longer reads as "not assigned yet".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-14 14:14:02 +00:00
parent 5e4e439c0f
commit 7e934b3433
10 changed files with 278 additions and 25 deletions

View File

@@ -328,18 +328,21 @@ export class CustomerTruckService {
if (assignment.departedAt) {
throw new ConflictException('This truck has already left — its load is locked');
}
// Containers can only be loaded after the truck has physically arrived at the
// warehouse (arrival weighing recorded). Assignment alone is just planning.
if (!assignment.arrivedAt) {
throw new BadRequestException(
'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
);
}
// Loading a truck at the warehouse implies it is physically present, so a
// truck that is still only assigned (not yet marked arrived) is auto-arrived
// here rather than blocking the operator — the real gross is weighed on
// departure anyway.
const needsArrival = !assignment.arrivedAt;
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!requested.length) {
throw new BadRequestException('Select at least one container to load onto the truck');
}
// 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)) {
@@ -352,6 +355,12 @@ export class CustomerTruckService {
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',
);
}
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
@@ -371,9 +380,20 @@ export class CustomerTruckService {
);
// Provisional gross (tonnes) from the loaded containers' VGM — overridden
// by the weighed gross on departure. (Column is *_kg but holds tonnes.)
// Auto-stamp arrival if the truck was still only assigned.
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
grossWeightKg: grossTons,
...(needsArrival ? { arrivedAt: new Date() } : {}),
});
if (needsArrival) {
await manager.query(
`UPDATE freight.bookings
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
updated_at = NOW()
WHERE id = $1`,
[bookingId],
);
}
});
return this.listTrucks(bookingId);
}

View File

@@ -1,9 +1,11 @@
import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
export class LoadCustomerTruckDto {
@IsArray()
@ArrayMinSize(1)
// A truck carries at most 2 containers (two 20ft, or one 40ft).
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,

View File

@@ -67,6 +67,12 @@ export class LastMileController {
return this.lastMileService.findById(id);
}
@Get('booking/:bookingId/arrival-trucks')
@ApiOperation({ summary: "Assigned EDR last-mile trucks for a booking (arrival/exit weighing prefill)" })
arrivalTrucks(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.lastMileService.arrivalTrucksForBooking(bookingId);
}
@Post('accept/:reference')
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })

View File

@@ -256,7 +256,95 @@ export class LastMileService {
return this.findById(id);
}
/**
* The EDR last-mile trucks assigned to a booking, joined with driver details,
* shaped for the arrival/exit weighing prefill (plate, driver, type, container).
* Returns [] when the booking has no last-mile truck assigned. Lets the
* warehouse arrival/load modals surface an assigned EDR truck the same way the
* self-haul customer trucks are surfaced.
*/
async arrivalTrucksForBooking(bookingId: string): Promise<
Array<{
vehicleId: string;
truckPlateNumber: string | null;
trailerPlateNumber: string | null;
driverName: string | null;
driverLicense: string | null;
driverPhone: string | null;
truckType: string | null;
containerNumber: string | null;
}>
> {
const [lm] = await this.lastMileRepository.findAll({
where: { bookingId },
relations: { vehicle: true, vehicleAssignments: { vehicle: true } },
take: 1,
});
if (!lm) return [];
// Prefer the multi-truck junction; fall back to the legacy single vehicle.
const sources = lm.vehicleAssignments?.length
? lm.vehicleAssignments.map((va) => ({
vehicle: va.vehicle,
containerNumber: va.containerNumber ?? null,
}))
: lm.vehicle
? [{ vehicle: lm.vehicle, containerNumber: null }]
: [];
const out: Array<{
vehicleId: string;
truckPlateNumber: string | null;
trailerPlateNumber: string | null;
driverName: string | null;
driverLicense: string | null;
driverPhone: string | null;
truckType: string | null;
containerNumber: string | null;
}> = [];
for (const { vehicle, containerNumber } of sources) {
if (!vehicle) continue;
let driverName = vehicle.assignedDriverName ?? null;
let driverLicense: string | null = null;
let driverPhone: string | null = null;
if (vehicle.assignedDriverId) {
try {
const d = await this.driversService.findById(vehicle.assignedDriverId);
driverName = driverName || `${d.firstName ?? ''} ${d.lastName ?? ''}`.trim() || null;
driverLicense = d.licenseNumber ?? null;
driverPhone = d.phoneNumber ?? null;
} catch {
/* driver lookup is best-effort — plate still prefills */
}
}
out.push({
vehicleId: vehicle.id,
truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null,
trailerPlateNumber: vehicle.trailerPlateNo || null,
driverName,
driverLicense,
driverPhone,
truckType: vehicle.vehicleType || null,
containerNumber,
});
}
return out;
}
async create(dto: CreateLastMileDto): Promise<LastMile> {
// Idempotent: a booking gets exactly one last-mile record. Extra trucks live
// inside that record (vehicleAssignments), never as additional rows — so if a
// last-mile already exists for this booking, return it instead of inserting a
// duplicate delivery row (which is what made the same booking appear twice in
// the Assign-Mile list).
const [existing] = await this.lastMileRepository.findAll({
where: { bookingId: dto.bookingId },
take: 1,
});
if (existing) {
return existing;
}
const record = await this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',
@@ -318,6 +406,17 @@ export class LastMileService {
}
}
// A last-mile truck must have a driver before it can be assigned (same rule
// as setVehicles) — block driverless single-vehicle (re)assignment too.
if (dto.vehicleId && dto.vehicleId !== existing.vehicleId) {
const vehicle = await this.vehiclesService.findById(dto.vehicleId);
if (!vehicle?.assignedDriverId) {
throw new BadRequestException(
`Truck ${vehicle?.plateNumber ?? dto.vehicleId} has no assigned driver — assign a driver to the truck before adding it to this last-mile delivery`,
);
}
}
const dtoAny = dto as any;
const updated = await this.lastMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
@@ -478,6 +577,17 @@ export class LastMileService {
)];
const added = desired.filter((v) => !junctionSet.has(v));
const removed = releaseIds.filter((v) => !desiredSet.has(v));
// A last-mile truck must have a driver before it can be assigned — a delivery
// can't run driverless, and the arrival/exit weighing needs the driver.
for (const vehicleId of added) {
const vehicle = await this.vehiclesService.findById(vehicleId);
if (!vehicle?.assignedDriverId) {
throw new BadRequestException(
`Truck ${vehicle?.plateNumber ?? vehicleId} has no assigned driver — assign a driver to the truck before adding it to this last-mile delivery`,
);
}
}
// Vehicles that stay but whose container number changed.
const changed = current.filter(
(a) =>

View File

@@ -2830,6 +2830,7 @@ export class WarehouseInventoryService {
Array<{
containerNumber: string;
goods: string | null;
containerSize: string | null;
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
grnNumber: string | null;
truckAssignmentId: string | null;
@@ -2846,6 +2847,7 @@ export class WarehouseInventoryService {
const rows: Array<{
containerNumber: string;
goods: string | null;
containerSize: string | null;
received: boolean;
grnNumber: string | null;
truckAssignmentId: string | null;
@@ -2860,6 +2862,7 @@ export class WarehouseInventoryService {
}> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
bc.container_size AS "containerSize",
bcu.received_to_port AS received,
bcu.grn_number AS "grnNumber",
ctc.assignment_id AS "truckAssignmentId",
@@ -2896,6 +2899,7 @@ export class WarehouseInventoryService {
return rows.map((r) => ({
containerNumber: r.containerNumber,
goods: r.goods,
containerSize: r.containerSize,
// A container the customer assigned to a truck is ASSIGNED (planned); it
// only becomes LOADED once the operator loads it (loaded_at) on truck
// leaving. Departed → LEFT, delivered → DELIVERED.