Merge pull request #731 from Tria-plc/wh-dashboard

EDR truck last mile conatiner and bulk pick up ,

Per truck exit and handover paper
This commit is contained in:
Hagernesh Tadesse
2026-07-16 12:28:08 +03:00
committed by GitHub
13 changed files with 814 additions and 67 deletions

View File

@@ -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<void> {
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<void> {
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
`);
}
}

View File

@@ -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 })

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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' })

View File

@@ -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,

View File

@@ -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<string[]> {
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<string[]> {
if (!numbers.length) return [];
const rows: Array<{ size: string | null }> = await this.dataSource.query(
`SELECT bc.container_size AS "size"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND UPPER(bcu.container_number) = ANY($2)
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
return rows.map((r) => (r.size ?? '').trim());
}
/**
* Bulk drawdown: how much of the booking's tonnage is still to be hauled —
* 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<string, string[]>,
): Promise<void> {
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<string>();
for (const vehicleId of desired) {
const load = loads.get(vehicleId) ?? [];
if (load.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
for (const n of load) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
if (seen.has(n)) {
throw new ConflictException(`Container ${n} is already assigned to another truck`);
}
seen.add(n);
}
// A 40ft container fills the truck; only two 20ft share one.
if (load.length > 1) {
const sizes = await this.containerSizes(bookingId, load);
if (sizes.some((s) => s.includes('40'))) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
);
}
}
}
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<LastMile> {
const existing = await this.findById(id);
// Dedupe by vehicleId, keeping the container number; preserve order.
const desiredMap = new Map<string, string | null>();
// Dedupe by vehicleId, keeping the container load; preserve order. Accepts
// the legacy single `containerNumber` as a one-element load.
const desiredMap = new Map<string, string[]>();
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,
})),
);
}
}
});

View File

@@ -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' })

View File

@@ -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<void> {
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);
}
}

View File

@@ -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<string | null>(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 (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="lg"
title={
<Text fw={600}>
Truck exit papers {record?.booking?.reference ? `· ${record.booking.reference}` : ""}
</Text>
}
>
{trucks.length === 0 ? (
<Alert variant="light" color="gray">
No trucks assigned to this delivery yet.
</Alert>
) : (
<Stack gap="sm">
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Truck</Table.Th>
<Table.Th>Containers</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th>Left</Table.Th>
<Table.Th ta="right">Net</Table.Th>
<Table.Th ta="right">Exit paper</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{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 (
<Table.Tr key={t.id}>
<Table.Td>
<Text fw={600} size="sm">{plate}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{load}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{fmt(t.arrivedAt)}</Text>
</Table.Td>
<Table.Td>
{t.departedAt ? (
<Text size="sm">{fmt(t.departedAt)}</Text>
) : (
<Badge size="sm" variant="light" color="gray">
Still on site
</Badge>
)}
</Table.Td>
<Table.Td ta="right">
<Text size="sm">
{t.netWeightTons != null ? `${t.netWeightTons} t` : "—"}
</Text>
</Table.Td>
<Table.Td ta="right">
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<FileText size={13} />}
loading={busyId === t.id}
onClick={() => download(t.id, plate)}
>
Exit Paper
</Button>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
<Text size="xs" c="dimmed">
EDR handovers are generated at delivery, so an exit paper is not gated on a
signature warehouse-fee clearance still applies.
</Text>
</Stack>
)}
</Modal>
);
}

View File

@@ -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<string | null>(null);
const [detentionRecord, setDetentionRecord] = useState<LastMileRecord | null>(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<LastMileRecord | null>(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<string>();
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
</Menu.Item>
<Menu.Item
leftSection={<FileText size={15} />}
disabled={!row.original.vehicleAssignments?.length}
onClick={() => {
setExitPapersRecord(row.original);
setExitPapersOpen(true);
}}
>
Truck exit papers
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Eye size={15} />}
@@ -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 (
<Alert variant="light" color="gray" title="One truck (with trailer) carries 2 containers">
No container count on this booking assign trucks as needed.
<Alert
variant="light"
color={done ? "green" : remainingTons ? "blue" : "gray"}
title={
remainingTons
? `${remainingTons.remainingTons} t remaining of ${remainingTons.totalTons} t`
: "No container count on this booking"
}
>
{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."}
</Alert>
);
}
@@ -1799,25 +1871,27 @@ const LastMilePage = () => {
clearable
disabled={assignVehicleOptions.length === 0}
/>
<Select
<MultiSelect
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
label={i === 0 ? "Containers (1x40ft or 2x20ft)" : undefined}
placeholder={containerOptions.length ? "Select containers" : "No container numbers"}
// A truck takes at most two containers; a 40ft fills it (the
// API rejects a 40ft paired with anything).
maxValues={2}
data={[
...containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
row.containerNumbers.includes(n) ||
// a container rides exactly one truck
!vehicleRows.some((r, idx) => idx !== i && r.containerNumbers.includes(n)),
),
// keep a manual/legacy value selectable even if not in the booking
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
? [row.containerNumber]
: []),
// keep manual/legacy values selectable even if not in the booking
...row.containerNumbers.filter((n) => !containerOptions.includes(n)),
]}
value={row.containerNumber || null}
value={row.containerNumbers}
onChange={(value) =>
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
prev.map((x, idx) => (idx === i ? { ...x, containerNumbers: value } : x)),
)
}
searchable
@@ -1840,14 +1914,14 @@ const LastMilePage = () => {
size="xs"
leftSection={<Plus size={14} />}
onClick={() =>
setVehicleRows((prev) => [
...prev,
{
vehicleId: null,
containerNumber:
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
},
])
setVehicleRows((prev) => {
// Suggest the next unassigned container for the new truck.
const taken = new Set(prev.flatMap((r) => r.containerNumbers));
const next = (activeRecord ? bookingContainerNumbers(activeRecord) : []).find(
(n) => !taken.has(n),
);
return [...prev, { vehicleId: null, containerNumbers: next ? [next] : [] }];
})
}
disabled={
assignVehicleOptions.length === 0 ||
@@ -2141,6 +2215,12 @@ const LastMilePage = () => {
truckPrefill={releaseTruckPrefill}
/>
<EdrTruckExitPapersModal
opened={exitPapersOpen}
onClose={() => setExitPapersOpen(false)}
record={exitPapersRecord}
/>
<TruckDetentionModal
opened={Boolean(detentionRecord)}
onClose={() => setDetentionRecord(null)}

View File

@@ -67,8 +67,16 @@ export interface LastMileRecord {
vehicleAssignments?: Array<{
id: string;
vehicleId: string;
/** @deprecated Legacy single container — `containers` is authoritative. */
containerNumber?: string | null;
/** Containers riding this truck: one 40ft, or up to two 20ft. */
containers?: Array<{ id: string; containerNumber: string }>;
distanceKm?: number | null;
/** Per-truck arrival / exit, stamped by the warehouse weighing steps. */
arrivedAt?: string | null;
departedAt?: string | null;
grossWeightTons?: number | null;
netWeightTons?: number | null;
vehicle?: LastMileVehicle | null;
}>;
/** Present only when an invoice has actually been generated (not on distance). */
@@ -99,8 +107,13 @@ export const lastMileService = {
api.delete<void>(LM.BY_ID(id)),
setVehicles: (
id: string,
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
vehicles: Array<{ vehicleId: string; containerNumbers?: string[] }>,
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicles }),
/** Bulk drawdown: tonnage still to be hauled on this booking. */
remainingTons: (bookingId: string) =>
api.get<{ totalTons: number; hauledTons: number; remainingTons: number; complete: boolean }>(
`${LM.BASE}/booking/${bookingId}/remaining-tons`,
),
setDistances: (
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,

View File

@@ -323,6 +323,11 @@ export const warehouseService = {
apiClient.get<Blob>(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, {
responseType: 'blob',
}),
/** Per-truck exit paper PDF for an EDR last-mile truck. */
downloadEdrTruckExitPaper: (assignmentId: string) =>
apiClient.get<Blob>(`/warehouse-inventory/edr-truck-exit-paper/${assignmentId}`, {
responseType: 'blob',
}),
deliver: (id: string, payload: DeliverInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),