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..1ee9d7191 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'; @@ -175,7 +182,7 @@ export class LastMileService { relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, - vehicleAssignments: { vehicle: true }, + vehicleAssignments: { vehicle: true, containers: true }, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, @@ -200,7 +207,7 @@ export class LastMileService { relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, - vehicleAssignments: { vehicle: true }, + vehicleAssignments: { vehicle: true, containers: true }, }, }); @@ -277,7 +284,7 @@ export class LastMileService { > { const [lm] = await this.lastMileRepository.findAll({ where: { bookingId }, - relations: { vehicle: true, vehicleAssignments: { vehicle: true } }, + relations: { vehicle: true, vehicleAssignments: { vehicle: true, containers: true } }, take: 1, }); if (!lm) return []; @@ -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); } } diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/EdrTruckExitPapersModal.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/EdrTruckExitPapersModal.tsx new file mode 100644 index 000000000..b35af1e7c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/operations/EdrTruckExitPapersModal.tsx @@ -0,0 +1,131 @@ +import { useState } from "react"; +import { Alert, Badge, Button, Modal, Stack, Table, Text } from "@mantine/core"; +import { FileText } from "lucide-react"; + +import { useToast } from "@/hooks/use-toast"; +import { warehouseService } from "@/services/warehouse.service"; +import type { LastMileRecord } from "@/services/last-mile.service"; +import { extractDownloadErrorMessage } from "@/components/warehouses/options"; +import { openPdfBlob } from "@/components/warehouses/pdf"; + +interface EdrTruckExitPapersModalProps { + opened: boolean; + onClose: () => void; + record: LastMileRecord | null; +} + +const fmt = (value?: string | null) => + value ? new Date(value).toLocaleString() : "—"; + +/** + * Per-truck exit papers for an EDR last-mile delivery. Each assigned truck has + * its own arrival, exit and weighed load, so each gets its own paper. + */ +export function EdrTruckExitPapersModal({ opened, onClose, record }: EdrTruckExitPapersModalProps) { + const { toast } = useToast(); + const [busyId, setBusyId] = useState(null); + const trucks = record?.vehicleAssignments ?? []; + + const download = async (assignmentId: string, plate: string) => { + setBusyId(assignmentId); + try { + const res = await warehouseService.downloadEdrTruckExitPaper(assignmentId); + openPdfBlob(res.data, `exit-${plate || assignmentId}.pdf`); + } catch (e) { + toast({ + variant: "destructive", + title: "Exit paper not ready", + description: await extractDownloadErrorMessage(e), + }); + } finally { + setBusyId(null); + } + }; + + return ( + + Truck exit papers {record?.booking?.reference ? `· ${record.booking.reference}` : ""} + + } + > + {trucks.length === 0 ? ( + + No trucks assigned to this delivery yet. + + ) : ( + + + + + Truck + Containers + Arrived + Left + Net + Exit paper + + + + {trucks.map((t) => { + const plate = t.vehicle?.powerPlateNo || t.vehicle?.plateNumber || "—"; + const load = t.containers?.length + ? t.containers.map((c) => c.containerNumber).join(", ") + : (t.containerNumber ?? "bulk"); + return ( + + + {plate} + + + {load} + + + {fmt(t.arrivedAt)} + + + {t.departedAt ? ( + {fmt(t.departedAt)} + ) : ( + + Still on site + + )} + + + + {t.netWeightTons != null ? `${t.netWeightTons} t` : "—"} + + + + + + + ); + })} + +
+ + EDR handovers are generated at delivery, so an exit paper is not gated on a + signature — warehouse-fee clearance still applies. + +
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index cd5d22c6b..3f96edd2f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -9,6 +9,7 @@ import { RefreshCw, Ruler, Trash, + FileText, Truck, X, } from "lucide-react"; @@ -56,6 +57,7 @@ import { import { vehiclesService } from "@/services/vehicles.service"; import { driversService, type Driver } from "@/services/drivers.service"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; +import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal"; import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal"; import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal"; import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; @@ -124,10 +126,22 @@ const containerCount = (record: LastMileRecord) => (sum, c) => sum + (Number(c.quantity) || 0), 0, ); -/** Trucks needed for a booking = ceil(containers / 2). 0 when no container data. */ +/** + * Trucks needed for a booking, by container SIZE: a 40ft fills a truck (1 each), + * two 20ft share one. Falls back to ceil(n / 2) when no size is recorded. + * 0 when the booking has no container data (bulk). + */ const requiredVehicles = (record: LastMileRecord) => { - const n = containerCount(record); - return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0; + const lines = record.booking?.bookingContainers ?? []; + if (!containerCount(record)) return 0; + let forty = 0; + let others = 0; + for (const c of lines) { + const qty = Number(c.quantity) || 0; + if ((c.containerSize ?? '').includes('40')) forty += qty; + else others += qty; + } + return forty + Math.ceil(others / CONTAINERS_PER_VEHICLE); }; /** Real per-physical-container numbers on a booking, in order. Prefers each @@ -581,9 +595,11 @@ const LastMilePage = () => { const [activeId, setActiveId] = useState(null); const [detentionRecord, setDetentionRecord] = useState(null); // Multi-vehicle assign: one row per truck — vehicle + the container it carries. + // One row per truck. A truck carries one 40ft or up to two 20ft, so the load + // is a list, not a single container. const [vehicleRows, setVehicleRows] = useState< - Array<{ vehicleId: string | null; containerNumber: string }> - >([{ vehicleId: null, containerNumber: "" }]); + Array<{ vehicleId: string | null; containerNumbers: string[] }> + >([{ vehicleId: null, containerNumbers: [] }]); // 2-step "Assign Mile" accept modal (arrival queue → vehicle) const [acceptOpen, setAcceptOpen] = useState(false); @@ -1000,31 +1016,59 @@ const LastMilePage = () => { return filteredRecords.slice(start, start + pagination.pageSize); }, [filteredRecords, pagination]); + // Per-truck exit papers for an EDR delivery (one paper per assigned truck). + const [exitPapersOpen, setExitPapersOpen] = useState(false); + const [exitPapersRecord, setExitPapersRecord] = useState(null); + + // Bulk drawdown: how much tonnage is still to be hauled on the booking being + // assigned. Bulk has no containers, so trucks keep going until this hits 0. + const assignBookingId = activeRecord?.booking?.id ?? null; + const { data: remainingTons } = useQuery({ + queryKey: ["last-mile", "remaining-tons", assignBookingId], + queryFn: () => lastMileService.remainingTons(assignBookingId as string).then((r) => r.data), + enabled: assignOpen && !bulkMode && Boolean(assignBookingId), + }); + const openAssign = (id: string | null) => { const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; const rec = records.find((r) => r.id === resolved); // Prefill each row's container number from the booking's container numbers // (by order) when the assignment doesn't already carry one. const nums = rec ? bookingContainerNumbers(rec) : []; + // Prefer the truck's own container list; fall back to the legacy scalar, then + // to the booking's containers by order. + const loadOf = ( + a: { containers?: Array<{ containerNumber: string }>; containerNumber?: string | null }, + i: number, + ) => + a.containers?.length + ? a.containers.map((c) => c.containerNumber) + : a.containerNumber + ? [a.containerNumber] + : nums[i] + ? [nums[i]] + : []; const rows = rec?.vehicleAssignments?.length ? rec.vehicleAssignments.map((a, i) => ({ vehicleId: a.vehicleId, - containerNumber: a.containerNumber ?? nums[i] ?? "", + containerNumbers: loadOf(a, i), })) : rec?.vehicleId - ? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }] - : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]; + ? [{ vehicleId: rec.vehicleId, containerNumbers: nums[0] ? [nums[0]] : [] }] + : [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }]; setBulkMode(false); setActiveId(resolved); - setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]); + setVehicleRows( + rows.length ? rows : [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }], + ); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); setActiveId(null); - setVehicleRows([{ vehicleId: null, containerNumber: "" }]); + setVehicleRows([{ vehicleId: null, containerNumbers: [] }]); setAssignOpen(true); }; @@ -1032,15 +1076,18 @@ const LastMilePage = () => { setAssignOpen(false); setBulkMode(false); setActiveId(null); - setVehicleRows([{ vehicleId: null, containerNumber: "" }]); + setVehicleRows([{ vehicleId: null, containerNumbers: [] }]); }; const handleAssign = () => { const seen = new Set(); const vehicles = vehicleRows - .filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId)) + .filter((r): r is { vehicleId: string; containerNumbers: string[] } => Boolean(r.vehicleId)) .filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId))) - .map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null })); + .map((r) => ({ + vehicleId: r.vehicleId, + containerNumbers: r.containerNumbers.map((n) => n.trim()).filter(Boolean), + })); const count = vehicles.length; const targetIds = bulkMode ? selectedIds @@ -1419,6 +1466,16 @@ const LastMilePage = () => { > Truck Leaving + } + disabled={!row.original.vehicleAssignments?.length} + onClick={() => { + setExitPapersRecord(row.original); + setExitPapersOpen(true); + }} + > + Truck exit papers + } @@ -1738,9 +1795,24 @@ const LastMilePage = () => { const needed = requiredVehicles(activeRecord); const picked = vehicleRows.filter((r) => r.vehicleId).length; if (needed === 0) { + // Bulk: no containers — trucks haul loose tonnage until the + // booking's total is drawn down to zero by departing trucks. + const done = remainingTons?.complete; return ( - - No container count on this booking — assign trucks as needed. + + {remainingTons + ? done + ? "Fully hauled — no tonnage left to assign trucks for." + : `Bulk booking: ${remainingTons.hauledTons} t hauled so far. Keep assigning trucks until the remaining tonnage reaches 0 — each truck's net weight is deducted when it leaves.` + : "Assign trucks as needed."} ); } @@ -1799,25 +1871,27 @@ const LastMilePage = () => { clearable disabled={assignVehicleOptions.length === 0} /> -