import { BadRequestException, ConflictException } from '@nestjs/common'; import type { DataSource } from 'typeorm'; /** Two 20ft containers fit a truck bed; one 40ft fills it. */ export const MAX_CONTAINERS_PER_TRUCK = 2; /** * What one truck is being asked to carry, and the booking context to judge it * against. `sizes` are the container_size labels of `containers`, in any order — * only whether a 40ft is present matters. */ export interface TruckLoadCheck { containers: string[]; /** Every container number on the booking. Empty means nothing to validate against. */ bookingContainers: string[]; sizes: string[]; /** Containers already riding another truck on this booking. */ assignedElsewhere?: string[]; } /** * The physical rule for loading one truck, shared by both haulage paths. * * A customer's own truck and an EDR last-mile truck obey the same physics, but * the rule was implemented twice — once in CustomerTruckService, once in * LastMileService — along with a byte-identical container-size query. Two copies * of one rule drift, and that is exactly how the self-haul guard ended up * enforced on one side only. */ export function assertTruckLoad({ containers, bookingContainers, sizes, assignedElsewhere = [], }: TruckLoadCheck): void { if (containers.length > MAX_CONTAINERS_PER_TRUCK) { throw new BadRequestException( `A truck carries at most ${MAX_CONTAINERS_PER_TRUCK} containers`, ); } // With no container list on the booking there is nothing to check membership // against — bulk bookings take this path. if (!bookingContainers.length) return; for (const number of containers) { if (!bookingContainers.includes(number)) { throw new BadRequestException( `Container ${number} is not one of this booking's containers`, ); } if (assignedElsewhere.includes(number)) { throw new ConflictException(`Container ${number} is already loaded onto another truck`); } } // A 40ft fills the bed, so it travels alone. if (containers.length > 1 && sizes.some((size) => size.includes('40'))) { throw new BadRequestException( 'A 40ft container fills the truck — assign only 1 container to this truck', ); } } /** Never put more trucks on a booking than it has containers to fill them. */ export function assertTruckCountWithinContainers( truckCount: number, bookingContainerCount: number, ): void { if (bookingContainerCount > 0 && truckCount > bookingContainerCount) { throw new BadRequestException( `Cannot assign more trucks than containers — this booking has ${bookingContainerCount} container(s) and ${truckCount} truck(s) requested.`, ); } } /** * How much of a bulk booking is still to be hauled. Counts trucks from BOTH * haulage paths — a booking uses one or the other, and the rule ("trucks until * no tonnage is left") is the same either way, so a single sum keeps them from * disagreeing. * * Only departed trucks count: tonnage is known once the truck is weighed out. */ export async function remainingBulkTons( dataSource: DataSource, bookingId: string, ): Promise<{ totalTons: number; hauledTons: number; remainingTons: number; complete: boolean }> { const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> = await 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) + COALESCE(( SELECT SUM(a.net_weight_tons) FROM freight.customer_truck_assignments a WHERE a.booking_id = b.id AND a.deleted_at IS NULL AND a.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 }; } /** A fully-hauled bulk booking has nothing left for another truck to carry. */ export function assertBulkTonnageRemains(totalTons: number, remainingTons: number): void { if (totalTons > 0 && remainingTons <= 0) { throw new BadRequestException( 'This bulk booking is fully hauled — no tonnage left to assign trucks for', ); } } /** * container_size labels for the given container numbers on a booking. Shared so * the two haulage paths read sizes the same way. */ export async function bookingContainerSizes( dataSource: DataSource, bookingId: string, numbers: string[], ): Promise { if (!numbers.length) return []; const rows: Array<{ size: string | null }> = await 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((row) => (row.size ?? '').trim()); }