diff --git a/apps/edr-freight-api/src/migrations/2260000000000-AddLastMileTruckArrivalDeparture.ts b/apps/edr-freight-api/src/migrations/2260000000000-AddLastMileTruckArrivalDeparture.ts new file mode 100644 index 000000000..052d46340 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2260000000000-AddLastMileTruckArrivalDeparture.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * EDR last-mile is multi-truck: a booking can be served by as many trucks as it + * has containers (bulk hauls until the tonnage is drawn down). Arrival/delivery + * were stamped once per `last_mile` record, so every truck shared one timestamp. + * These per-vehicle columns give each EDR truck its own arrival, leaving and + * weighed load — the same granularity self-haul trucks already have. + * + * Weights are TONNES (matching bookings.cargo_total_weight_vgm and the exit + * weighing UI). Named `*_tons` deliberately: the older + * customer_truck_assignments.gross_weight_kg is named kg but stores tonnes. + * All nullable — legacy rows predate per-truck tracking. + */ +export class AddLastMileTruckArrivalDeparture2260000000000 implements MigrationInterface { + name = 'AddLastMileTruckArrivalDeparture2260000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS arrived_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS departed_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS gross_weight_tons numeric(14, 3) NULL, + ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14, 3) NULL + `); + + // A truck carries 1x40ft OR 2x20ft, so an EDR truck needs MORE than the one + // container the legacy scalar `container_number` can hold. Mirrors the + // self-haul customer_truck_containers child table. The scalar stays in place + // (synced to the first container) for backward compatibility. + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_containers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + assignment_id uuid NOT NULL REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE CASCADE, + last_mile_id uuid NOT NULL, + container_number varchar(32) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_last_mile_vehicle_containers_assignment" + ON freight.last_mile_vehicle_containers (assignment_id) + `); + // A container rides exactly one truck per delivery. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_last_mile_vehicle_container" + ON freight.last_mile_vehicle_containers (last_mile_id, container_number) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_containers`); + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS arrived_at, + DROP COLUMN IF EXISTS departed_at, + DROP COLUMN IF EXISTS gross_weight_tons, + DROP COLUMN IF EXISTS net_weight_tons + `); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts index e07eec0b4..2659f7076 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts @@ -1,16 +1,36 @@ -import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; export class LastMileVehicleInput { @IsUUID() vehicleId!: string; + /** + * Containers this truck carries: one 40ft, or up to two 20ft. Omit for bulk + * (the truck hauls loose tonnage and is weighed out on exit). + */ + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @IsString({ each: true }) + containerNumbers?: string[]; + + /** @deprecated Single-container form — use `containerNumbers`. Still accepted. */ @IsOptional() @IsString() containerNumber?: string; } -/** Replace the full set of vehicles (with their container numbers) on a delivery. */ +/** Replace the full set of vehicles (with their containers) on a delivery. */ export class SetVehiclesDto { @IsArray() @ValidateNested({ each: true }) diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts index eee414275..e57c16b8a 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, Unique } from 'typeorm'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { LastMile } from './last-mile.entity'; +import { LastMileVehicleContainer } from './last-mile-vehicle-container.entity'; /** * One row per vehicle assigned to a last-mile delivery. A delivery can be @@ -28,12 +29,34 @@ export class LastMileVehicleAssignment extends BaseEntity { @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle; - /** Container this truck carries — auto-filled from the booking's container - * number when known, else entered manually at assignment time. */ + /** Legacy single container this truck carries. Kept in sync with the FIRST + * entry of `containers` for backward compatibility — a truck can hold 1x40ft + * or 2x20ft, so `containers` is the authoritative list. */ @Column({ name: 'container_number', type: 'varchar', nullable: true }) containerNumber?: string | null; + /** Containers riding this truck (1x40ft, or up to 2x20ft). */ + @OneToMany(() => LastMileVehicleContainer, (c) => c.assignment, { cascade: true }) + containers?: LastMileVehicleContainer[]; + /** Actual distance driven by this truck (km), entered per vehicle. */ @Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) distanceKm?: number | null; + + /** This truck reached the warehouse (stamped by the arrival weighing step). */ + @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) + arrivedAt?: Date | null; + + /** This truck left the warehouse (stamped by the exit weighing step). */ + @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) + departedAt?: Date | null; + + /** Weighed gross on exit, in TONNES (not kg — see the migration note). */ + @Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + grossWeightTons?: number | null; + + /** Cargo actually taken by this truck (gross − tare), in TONNES. Drives the + * bulk drawdown: remaining = booking VGM − SUM(net) over departed trucks. */ + @Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + netWeightTons?: number | null; } diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-container.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-container.entity.ts new file mode 100644 index 000000000..ffadb5a21 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-container.entity.ts @@ -0,0 +1,30 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity'; + +/** + * A container riding a specific EDR last-mile truck. A truck carries 1x40ft OR + * 2x20ft, so the assignment needs more than the single legacy `container_number` + * scalar. Mirrors the self-haul `customer_truck_containers` child table. + */ +@Entity({ schema: 'freight', name: 'last_mile_vehicle_containers' }) +@Index(['assignmentId']) +export class LastMileVehicleContainer extends BaseEntity { + @Column({ name: 'assignment_id', type: 'uuid' }) + assignmentId!: string; + + @ManyToOne(() => LastMileVehicleAssignment, (a) => a.containers, { + nullable: false, + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'assignment_id' }) + assignment?: LastMileVehicleAssignment; + + /** Denormalised for the "one container, one truck per delivery" unique index. */ + @Column({ name: 'last_mile_id', type: 'uuid' }) + lastMileId!: string; + + @Column({ name: 'container_number', type: 'varchar', length: 32 }) + containerNumber!: string; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index fa7ee59ec..29e857e2f 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -73,6 +73,12 @@ export class LastMileController { return this.lastMileService.arrivalTrucksForBooking(bookingId); } + @Get('booking/:bookingId/remaining-tons') + @ApiOperation({ summary: 'Bulk drawdown: tonnage still to be hauled (total − departed trucks)' }) + remainingTons(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.lastMileService.remainingTonsForBooking(bookingId); + } + @Post('accept/:reference') @BookingStaff(FREIGHT_PERMS.lastMile.accept) @ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index 289c2156f..f4f33dff8 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -10,6 +10,7 @@ import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; +import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity'; import { LastMileController } from './last-mile.controller'; import { LastMileInvoiceService } from './last-mile-invoice.service'; import { LastMileRepository } from './last-mile.repository'; @@ -17,7 +18,12 @@ import { LastMileService } from './last-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]), + TypeOrmModule.forFeature([ + LastMile, + LastMileContainerAllocation, + LastMileVehicleAssignment, + LastMileVehicleContainer, + ]), BillingModule, forwardRef(() => BookingsModule), VehiclesModule, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 1ee31c63a..474f943a9 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,4 +1,10 @@ -import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; @@ -12,6 +18,7 @@ import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; +import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity'; import { LastMileRepository } from './last-mile.repository'; import { FilesService } from '../files/files.service'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; @@ -552,22 +559,164 @@ export class LastMileService { * for each added/removed vehicle. The first vehicle is mirrored onto the legacy * `vehicleId` column for back-compat with single-vehicle readers. */ + /** Container numbers on the booking (upper-cased). */ + private async bookingContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber" + 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 bcu.deleted_at IS NULL`, + [bookingId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } + + /** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */ + private async containerSizes(bookingId: string, numbers: string[]): Promise { + 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 — + * the booking VGM total minus the net weighed off every EDR truck that has + * already left. Both sides are tonnes, so no conversion. + */ + async remainingTonsForBooking(bookingId: string): Promise<{ + totalTons: number; + hauledTons: number; + 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 }; + } + + /** + * Truck capacity rules for a last-mile delivery. + * - CONTAINER: a truck carries ONE 40ft or up to TWO 20ft; every container + * must belong to the booking and ride exactly one truck; never more trucks + * than containers. + * - BULK: no containers — trucks haul loose tonnage, so the only limit is + * that there is tonnage left to haul. + */ + private async assertVehicleLoads( + bookingId: string, + desired: string[], + loads: Map, + ): Promise { + if (!desired.length) return; + + const [booking]: Array<{ freightType: string | null }> = await this.dataSource.query( + `SELECT freight_type AS "freightType" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + 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', + ); + } + return; + } + + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + if (!bookingNumbers.length) return; // nothing to validate against + + const seen = new Set(); + 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', + ); + } + } + } + + 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.`, + ); + } + } + async setVehicles( id: string, - inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + inputs: Array<{ + vehicleId: string; + containerNumbers?: string[] | null; + containerNumber?: string | null; + }>, ): Promise { const existing = await this.findById(id); - // Dedupe by vehicleId, keeping the container number; preserve order. - const desiredMap = new Map(); + // Dedupe by vehicleId, keeping the container load; preserve order. Accepts + // the legacy single `containerNumber` as a one-element load. + const desiredMap = new Map(); for (const inp of inputs) { - if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); + if (!inp.vehicleId) continue; + const load = (inp.containerNumbers ?? (inp.containerNumber ? [inp.containerNumber] : [])) + .map((n) => String(n).trim().toUpperCase()) + .filter(Boolean); + desiredMap.set(inp.vehicleId, load); } const desired = [...desiredMap.keys()]; const desiredSet = new Set(desired); + // Capacity + membership rules (a truck holds one 40ft or two 20ft; bulk + // hauls tonnage until the booking is drawn down). + await this.assertVehicleLoads(existing.bookingId, desired, desiredMap); + const manager = this.dataSource.manager; const current = await manager.find(LastMileVehicleAssignment, { where: { lastMileId: id }, + relations: { containers: true }, }); const junctionSet = new Set(current.map((a) => a.vehicleId)); // Fold the legacy vehicleId into the release set — a vehicle assigned via the @@ -588,33 +737,58 @@ export class LastMileService { ); } } - // Vehicles that stay but whose container number changed. + // Vehicles that stay but whose container load changed (order-insensitive). + const loadKey = (list: string[]) => [...list].sort().join('|'); + const currentLoad = (a: LastMileVehicleAssignment) => + (a.containers ?? []).map((c) => c.containerNumber.trim().toUpperCase()); const changed = current.filter( (a) => desiredMap.has(a.vehicleId) && - (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), + loadKey(desiredMap.get(a.vehicleId) ?? []) !== loadKey(currentLoad(a)), ); await this.dataSource.transaction(async (tx) => { if (removed.length) { + // Child containers cascade on delete. await tx.delete(LastMileVehicleAssignment, { lastMileId: id, vehicleId: In(removed), }); } for (const vehicleId of added) { - await tx.insert(LastMileVehicleAssignment, { + const load = desiredMap.get(vehicleId) ?? []; + const inserted = await tx.insert(LastMileVehicleAssignment, { lastMileId: id, vehicleId, - containerNumber: desiredMap.get(vehicleId) ?? null, + // Legacy scalar stays in sync with the first container. + containerNumber: load[0] ?? null, }); + const assignmentId = inserted.identifiers[0]?.id as string | undefined; + if (assignmentId && load.length) { + await tx.insert( + LastMileVehicleContainer, + load.map((containerNumber) => ({ assignmentId, lastMileId: id, containerNumber })), + ); + } } for (const row of changed) { + const load = desiredMap.get(row.vehicleId) ?? []; await tx.update( LastMileVehicleAssignment, { lastMileId: id, vehicleId: row.vehicleId }, - { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + { containerNumber: load[0] ?? null }, ); + await tx.delete(LastMileVehicleContainer, { assignmentId: row.id }); + if (load.length) { + await tx.insert( + LastMileVehicleContainer, + load.map((containerNumber) => ({ + assignmentId: row.id, + lastMileId: id, + containerNumber, + })), + ); + } } }); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 499e40f2e..e92808274 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -421,6 +421,20 @@ export class WarehouseInventoryController { return res.send(buffer); } + @Get('edr-truck-exit-paper/:assignmentId') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ summary: 'Per-truck exit paper PDF for an EDR last-mile truck' }) + async edrTruckExitPaper( + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.inventoryService.edrTruckExitPaper(assignmentId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get(':id/grn-document') @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'View goods received note PDF' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 6dd941d26..85311f049 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2821,6 +2821,16 @@ export class WarehouseInventoryService { : dto; const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto); + // The load actually leaving on this truck, in TONNES (the weighing UI is in + // t). Null when the operator skipped weighing — containers may skip, bulk + // never does. + const grossTons = exitInspectionDto.grossWeight ?? null; + const tareTons = exitInspectionDto.tareWeight ?? null; + const netTons = + grossTons != null && tareTons != null + ? Math.round((grossTons - tareTons) * 1000) / 1000 + : (exitInspectionDto.netWeight ?? null); + await this.dataSource.transaction(async (manager) => { await manager.getRepository(WarehouseInventory).update(id, { releaseDate, @@ -2848,6 +2858,23 @@ export class WarehouseInventoryService { // an EXPORT concept (set when a truck delivers into the port). Import // load + weight are captured on truck departure, not arrival. } + // EDR last-mile: stamp THIS truck's arrival. Matched by plate rather than + // container so it works for bulk too (bulk trucks carry no container). + if (dto.truckPlateNumber?.trim()) { + await manager.query( + `UPDATE freight.last_mile_vehicle_assignments va + SET arrived_at = COALESCE(va.arrived_at, NOW()), updated_at = NOW() + FROM freight.last_mile lm, freight.vehicles v + WHERE va.last_mile_id = lm.id + AND lm.booking_id = $1 + AND lm.deleted_at IS NULL + AND v.id = va.vehicle_id + AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2)) + AND va.arrived_at IS NULL + AND va.deleted_at IS NULL`, + [item.bookingId, dto.truckPlateNumber.trim()], + ); + } // Booking-level flag stamped on the FIRST truck arrival. The import // handover is signed ONCE (before the first truck leaves), even though // trucks pick up per-container — COALESCE keeps the first timestamp. @@ -2868,6 +2895,34 @@ export class WarehouseInventoryService { await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager); } } + if (isTruckLeaving && item.bookingId && dto.truckPlateNumber?.trim()) { + // EDR last-mile: this truck is leaving — record its exit and the load it + // actually took. net_weight_tons drives the bulk drawdown (booking VGM + // minus everything already hauled away). + await manager.query( + `UPDATE freight.last_mile_vehicle_assignments va + SET departed_at = COALESCE($3::timestamptz, NOW()), + arrived_at = COALESCE(va.arrived_at, NOW()), + gross_weight_tons = $4, + net_weight_tons = $5, + updated_at = NOW() + FROM freight.last_mile lm, freight.vehicles v + WHERE va.last_mile_id = lm.id + AND lm.booking_id = $1 + AND lm.deleted_at IS NULL + AND v.id = va.vehicle_id + AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2)) + AND va.departed_at IS NULL + AND va.deleted_at IS NULL`, + [ + item.bookingId, + dto.truckPlateNumber.trim(), + dto.gateOutTime ?? null, + grossTons, + netTons, + ], + ); + } await this.activityLog.record( { activityType: 'INVENTORY_RELEASED', @@ -2886,9 +2941,56 @@ export class WarehouseInventoryService { ); }); + // Tell the customer their truck has left — one hook covers BOTH self-haul and + // EDR last-mile, since release() is the single exit path for either. Outside + // the transaction and fire-and-forget: notifying must never fail the exit. + if (isTruckLeaving && item.bookingId) { + void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons); + } + return this.findById(id); } + /** + * Best-effort truck-departure notification to the booking's company across + * every channel: in-app (portal inbox) + SMS + email. Never throws — a missing + * provider or contact must not break the exit flow. + */ + private async notifyTruckDeparture( + bookingId: string, + plateNumber: string | null, + netTons: number | null, + ): Promise { + try { + const [booking]: Array<{ companyId: string | null; reference: string | null }> = + await this.dataSource.query( + `SELECT company_id AS "companyId", reference + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking?.companyId) return; + const ref = booking.reference ?? bookingId; + const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck'; + const load = netTons != null && netTons > 0 ? ` carrying ${netTons} t` : ''; + const body = `${truck} has left the warehouse for booking ${ref}${load}.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Truck left the warehouse', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, plateNumber, netTons, action: 'TRUCK_LEFT' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn( + `Truck-departure notify failed for ${bookingId}: ${(err as Error).message}`, + ); + } + } + async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, @@ -3213,6 +3315,75 @@ export class WarehouseInventoryService { }; } + /** + * Exit paper for an EDR last-mile truck (one per truck, keyed on the vehicle + * assignment). Deliberately NOT gated on the handover: EDR handovers are + * generated at delivery — i.e. after the truck has already left — so there is + * nothing to sign at exit time. Warehouse-fee clearance still applies. + */ + async edrTruckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> { + const [truck] = await this.dataSource.query( + `SELECT lm.booking_id AS "bookingId", + COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber", + COALESCE( + v.assigned_driver_name, + NULLIF(TRIM(CONCAT(d.first_name, ' ', d.last_name)), '') + ) AS "driverName", + v.vehicle_type AS "truckType", + va.gross_weight_tons AS "grossWeightKg", + va.departed_at AS "departedAt", + b.reference AS "bookingReference", + company.name AS "customerName" + 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 + JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL + JOIN freight.vehicles v ON v.id = va.vehicle_id + LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id AND d.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE va.id = $1 AND va.deleted_at IS NULL`, + [assignmentId], + ); + if (!truck) throw new NotFoundException(`EDR truck assignment ${assignmentId} not found`); + + const [inv]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`, + [truck.bookingId], + ); + if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id); + + // Bulk trucks carry no containers — the table is then empty and the paper + // stands on the weighed gross alone. + const containers: Array<{ containerNumber: string; goods: string | null }> = + await this.dataSource.query( + `SELECT vc.container_number AS "containerNumber", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods + FROM freight.last_mile_vehicle_containers vc + JOIN freight.last_mile lm ON lm.id = vc.last_mile_id AND lm.deleted_at IS NULL + JOIN freight.bookings b ON b.id = lm.booking_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + WHERE vc.assignment_id = $1 AND vc.deleted_at IS NULL + ORDER BY vc.container_number`, + [assignmentId], + ); + + const html = this.buildTruckExitPaperHtml({ + reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`, + bookingReference: truck.bookingReference, + customerName: truck.customerName, + plateNumber: truck.plateNumber, + driverName: truck.driverName ?? '-', + truckType: truck.truckType ?? '-', + grossWeightKg: Number(truck.grossWeightKg ?? 0), + gateOut: truck.departedAt, + containers, + }); + return { + filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'), + }; + } + private buildTruckExitPaperHtml(data: { reference: string; bookingReference: string; @@ -3728,20 +3899,30 @@ export class WarehouseInventoryService { ); } else { // EDR last-mile: the handover is per delivering truck. Resolve the - // vehicle that carried this item's container so each truck gets its own - // handover (falls back to a booking-level one when unresolvable). + // vehicle from the truck's own container list (the earlier lookup went + // through last_mile_container_allocations, which nothing ever writes — + // so truckPlate was always null and every booking collapsed to a single + // booking-level handover). Bulk has no container, so fall back to the + // delivery's single truck; a booking-level handover when unresolvable. let truckPlate: string | null = null; - if (item.containerId) { - const [veh]: Array<{ plate: string | null }> = await manager.query( - `SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate - FROM freight.last_mile_container_allocations lca - JOIN freight.vehicles v ON v.id = lca.vehicle_id - WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL - LIMIT 1`, - [item.containerId], - ); - truckPlate = veh?.plate ?? null; - } + const [veh]: Array<{ plate: string | null }> = await manager.query( + `SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate + 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 + JOIN freight.vehicles v ON v.id = va.vehicle_id + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + LEFT JOIN freight.containers cont + ON cont.container_number = vc.container_number AND cont.deleted_at IS NULL + WHERE lm.booking_id = $1 + AND va.deleted_at IS NULL + AND ($2::uuid IS NULL OR cont.id = $2::uuid) + ORDER BY (cont.id IS NOT NULL) DESC, va.created_at ASC + LIMIT 1`, + [item.bookingId, item.containerId ?? null], + ); + truckPlate = veh?.plate ?? null; await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager); } }