diff --git a/apps/edr-freight-api/src/common/truck-load.util.spec.ts b/apps/edr-freight-api/src/common/truck-load.util.spec.ts new file mode 100644 index 000000000..fbb3a436a --- /dev/null +++ b/apps/edr-freight-api/src/common/truck-load.util.spec.ts @@ -0,0 +1,159 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; + +import { + assertBulkTonnageRemains, + assertTruckCountWithinContainers, + assertTruckLoad, + remainingBulkTons, +} from './truck-load.util'; + +/** + * One physical rule, shared by customer self-haul and EDR last-mile. It used to + * be written out three times (addTruck, updateTruck, departTruck) plus a fourth + * in LastMileService. + */ +describe('assertTruckLoad', () => { + const booking = ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111']; + + it('accepts two 20ft containers on one truck', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567', 'ABCD7654321'], + bookingContainers: booking, + sizes: ['20ft', '20ft'], + }), + ).not.toThrow(); + }); + + it('accepts a single 40ft container', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567'], + bookingContainers: booking, + sizes: ['40ft'], + }), + ).not.toThrow(); + }); + + it('rejects a 40ft sharing the truck — it fills the bed', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567', 'ABCD7654321'], + bookingContainers: booking, + sizes: ['40ft', '20ft'], + }), + ).toThrow(BadRequestException); + }); + + it('rejects more than two containers', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111'], + bookingContainers: booking, + sizes: ['20ft', '20ft', '20ft'], + }), + ).toThrow(BadRequestException); + }); + + it('rejects a container that is not on the booking', () => { + expect(() => + assertTruckLoad({ + containers: ['ZZZZ9999999'], + bookingContainers: booking, + sizes: ['20ft'], + }), + ).toThrow(BadRequestException); + }); + + it('rejects a container already riding another truck', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567'], + bookingContainers: booking, + sizes: ['20ft'], + assignedElsewhere: ['ABCD1234567'], + }), + ).toThrow(ConflictException); + }); + + it('skips membership checks when the booking has no containers (bulk)', () => { + expect(() => + assertTruckLoad({ containers: [], bookingContainers: [], sizes: [] }), + ).not.toThrow(); + }); + + it('still caps the count when the booking has no containers', () => { + expect(() => + assertTruckLoad({ + containers: ['A', 'B', 'C'], + bookingContainers: [], + sizes: [], + }), + ).toThrow(BadRequestException); + }); +}); + +describe('assertBulkTonnageRemains', () => { + it('allows another truck while tonnage is left', () => { + expect(() => assertBulkTonnageRemains(100, 40)).not.toThrow(); + }); + + it('rejects a truck once the booking is fully hauled', () => { + expect(() => assertBulkTonnageRemains(100, 0)).toThrow(BadRequestException); + }); + + it('does not cap a booking with no declared weight', () => { + // Nothing to draw down against — capping here would block every truck. + expect(() => assertBulkTonnageRemains(0, 0)).not.toThrow(); + }); +}); + +describe('remainingBulkTons', () => { + const dataSourceReturning = (totalTons: string, hauledTons: string) => + ({ query: jest.fn().mockResolvedValue([{ totalTons, hauledTons }]) }) as never; + + it('counts trucks from both haulage paths against the declared weight', async () => { + const result = await remainingBulkTons(dataSourceReturning('100', '60'), 'b-1'); + + expect(result).toEqual({ + totalTons: 100, + hauledTons: 60, + remainingTons: 40, + complete: false, + }); + }); + + it('is complete once everything is hauled', async () => { + const result = await remainingBulkTons(dataSourceReturning('100', '100'), 'b-1'); + + expect(result.remainingTons).toBe(0); + expect(result.complete).toBe(true); + }); + + it('never reports negative tonnage when trucks overshoot', async () => { + const result = await remainingBulkTons(dataSourceReturning('100', '104'), 'b-1'); + + expect(result.remainingTons).toBe(0); + expect(result.complete).toBe(true); + }); + + it('is not complete for a booking with no declared weight', async () => { + const result = await remainingBulkTons(dataSourceReturning('0', '0'), 'b-1'); + + expect(result.complete).toBe(false); + }); +}); + +describe('assertTruckCountWithinContainers', () => { + it('allows one truck per container', () => { + expect(() => assertTruckCountWithinContainers(3, 3)).not.toThrow(); + }); + + it('rejects more trucks than containers', () => { + expect(() => assertTruckCountWithinContainers(4, 3)).toThrow(BadRequestException); + }); + + it('does not cap a bulk booking, which has no container count', () => { + expect(() => assertTruckCountWithinContainers(9, 0)).not.toThrow(); + }); +}); diff --git a/apps/edr-freight-api/src/common/truck-load.util.ts b/apps/edr-freight-api/src/common/truck-load.util.ts new file mode 100644 index 000000000..b65bc12fb --- /dev/null +++ b/apps/edr-freight-api/src/common/truck-load.util.ts @@ -0,0 +1,148 @@ +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()); +} diff --git a/apps/edr-freight-api/src/migrations/2400000000000-AddCustomerTruckExitWeights.ts b/apps/edr-freight-api/src/migrations/2400000000000-AddCustomerTruckExitWeights.ts new file mode 100644 index 000000000..6cddae23d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2400000000000-AddCustomerTruckExitWeights.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-truck exit weights for customer self-haul, mirroring what + * `last_mile_vehicle_assignments` already carries for EDR trucks. + * + * A bulk booking is hauled away truck by truck until no tonnage is left, and the + * EDR side enforces that by summing `net_weight_tons` of departed trucks. The + * customer side had no net and no tare — only `gross_weight_kg`, which nothing + * in the live flow ever wrote (the release flow updated the EDR table only). So + * a self-haul bulk booking could take unlimited trucks: hauled tonnage always + * summed to zero. + * + * `gross_weight_kg` is left alone but note it holds TONNES despite its name — + * the weighing UI is in tonnes throughout. The new columns are named for the + * unit they actually hold. + */ +export class AddCustomerTruckExitWeights2400000000000 implements MigrationInterface { + name = 'AddCustomerTruckExitWeights2400000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + ADD COLUMN IF NOT EXISTS tare_weight_tons numeric(14,3) NULL, + ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14,3) NULL + `); + + // Departed trucks are what the drawdown sums, so it reads this index. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_customer_truck_departed" + ON freight.customer_truck_assignments (booking_id, departed_at) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_customer_truck_departed"`, + ); + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + DROP COLUMN IF EXISTS tare_weight_tons, + DROP COLUMN IF EXISTS net_weight_tons + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 7b0145db2..eb4699008 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -16,6 +16,13 @@ import { EDR_HAULAGE_CONFLICT_MESSAGE, usesEdrMileService, } from '../../common/mile-haulage.util'; +import { + assertBulkTonnageRemains, + assertTruckCountWithinContainers, + assertTruckLoad, + bookingContainerSizes, + remainingBulkTons, +} from '../../common/truck-load.util'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; @@ -71,39 +78,27 @@ export class CustomerTruckService { if (!isBulk && requested.length < 1) { throw new BadRequestException('Select at least one container for this truck'); } - if (requested.length > 2) { - throw new BadRequestException('A truck carries at most 2 containers'); + + // Bulk is capped by tonnage, not container count: trucks may be added until + // the booking's declared weight has been hauled away. Container bookings are + // capped below by #trucks <= #containers. + if (isBulk) { + const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId); + assertBulkTonnageRemains(totalTons, remainingTons); } if (requested.length) { const bookingNumbers = await this.bookingContainerNumbers(bookingId); - // Never assign more trucks than the booking has containers. const existingTrucks = await this.dataSource .getRepository(CustomerTruckAssignment) .count({ where: { bookingId } }); - if (existingTrucks + 1 > bookingNumbers.length) { - throw new BadRequestException( - `Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`, - ); - } - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); - } - } - const alreadyAssigned = await this.assignedContainerNumbers(bookingId); - for (const n of requested) { - if (alreadyAssigned.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); - } - } - // Size cap: a 40ft container fills the truck. - const sizes = await this.containerSizes(bookingId, requested); - if (sizes.some((s) => s.includes('40')) && requested.length > 1) { - throw new BadRequestException( - 'A 40ft container fills the truck — assign only 1 container to this truck', - ); - } + assertTruckCountWithinContainers(existingTrucks + 1, bookingNumbers.length); + assertTruckLoad({ + containers: requested, + bookingContainers: bookingNumbers, + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + assignedElsewhere: await this.assignedContainerNumbers(bookingId), + }); } await this.dataSource.transaction(async (manager) => { @@ -195,28 +190,13 @@ export class CustomerTruckService { if (requested.length < 1) { throw new BadRequestException('Select at least one container for this truck'); } - if (requested.length > 2) { - throw new BadRequestException('A truck carries at most 2 containers'); - } - const bookingNumbers = await this.bookingContainerNumbers(bookingId); - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); - } - } - // Exclude THIS truck's own containers so re-saving the same set is allowed. - const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); - for (const n of requested) { - if (assignedElsewhere.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); - } - } - const sizes = await this.containerSizes(bookingId, requested); - if (sizes.some((s) => s.includes('40')) && requested.length > 1) { - throw new BadRequestException( - 'A 40ft container fills the truck — assign only 1 container to this truck', - ); - } + assertTruckLoad({ + containers: requested, + bookingContainers: await this.bookingContainerNumbers(bookingId), + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + // Exclude THIS truck's own containers so re-saving the same set is allowed. + assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), + }); await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { @@ -344,27 +324,12 @@ export class CustomerTruckService { } // Capacity is size-based: a truck carries at most 2 containers, and a 40ft // container fills the truck (max 1) — mirror the addTruck/updateTruck rule. - if (requested.length > 2) { - throw new BadRequestException('A truck carries at most 2 containers'); - } - const bookingNumbers = await this.bookingContainerNumbers(bookingId); - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); - } - } - const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); - for (const n of requested) { - if (elsewhere.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); - } - } - const sizes = await this.containerSizes(bookingId, requested); - if (sizes.some((s) => s.includes('40')) && requested.length > 1) { - throw new BadRequestException( - 'A 40ft container fills the truck — load only 1 container onto this truck', - ); - } + assertTruckLoad({ + containers: requested, + bookingContainers: await this.bookingContainerNumbers(bookingId), + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), + }); const grossTons = await this.vgmTonsForContainers(bookingId, requested); await this.dataSource.transaction(async (manager) => { @@ -611,18 +576,4 @@ export class CustomerTruckService { } /** Contract container sizes (e.g. "20ft" / "40ft") for the given container 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()); - } } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts index 6eeaba963..3892d2a97 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -39,6 +39,18 @@ export class CustomerTruckAssignment extends BaseEntity { @Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true }) grossWeightKg?: number | null; + /** Empty truck weight at the gate, in tonnes. Null until the truck departs. */ + @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + tareWeightTons?: number | null; + + /** + * Cargo actually taken (gross − tare), in tonnes. Drives the bulk drawdown: + * a bulk booking is hauled until the sum of this across departed trucks + * reaches its declared VGM. Mirrors last_mile_vehicle_assignments. + */ + @Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + netWeightTons?: number | null; + @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) departedAt?: Date | null; 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 aa293759c..b31a2ecbb 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 @@ -12,6 +12,13 @@ import { SELF_HAUL_CONFLICT_MESSAGE, usesEdrMileService, } from '../../common/mile-haulage.util'; +import { + assertBulkTonnageRemains, + assertTruckCountWithinContainers, + assertTruckLoad, + bookingContainerSizes, + remainingBulkTons, +} from '../../common/truck-load.util'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { SmsClientService } from '../notifications/sms-client.service'; @@ -621,20 +628,6 @@ export class LastMileService { } /** 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 — @@ -647,26 +640,9 @@ export class LastMileService { 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 }; + // Counts customer trucks as well as EDR ones — a booking hauls by one path + // or the other, and "until no tonnage is left" means the same either way. + return remainingBulkTons(this.dataSource, bookingId); } /** @@ -691,11 +667,7 @@ export class LastMileService { ); 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', - ); - } + assertBulkTonnageRemains(totalTons, remainingTons); return; } @@ -705,33 +677,41 @@ export class LastMileService { 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', - ); - } - } + assertTruckLoad({ + containers: load, + bookingContainers: bookingNumbers, + sizes: await bookingContainerSizes(this.dataSource, bookingId, load), + assignedElsewhere: [...seen], + }); + load.forEach((n) => seen.add(n)); } - 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.`, + assertTruckCountWithinContainers(desired.length, bookingNumbers.length); + } + + /** + * A truck that has already reached the customer cannot have its load rewritten + * — the containers on it are a delivered fact, not a plan. The customer side + * has locked this since it was built (`Cannot edit a truck that has already + * arrived`); the EDR side let a reassignment silently rewrite history. + */ + private async assertNoArrivedVehicleChanged( + current: LastMileVehicleAssignment[], + desiredMap: Map, + ): Promise { + const loadKey = (list: string[]) => [...list].sort().join('|'); + for (const assignment of current) { + if (!assignment.arrivedAt) continue; + const stillPresent = desiredMap.has(assignment.vehicleId); + const load = desiredMap.get(assignment.vehicleId) ?? []; + const currentLoad = (assignment.containers ?? []).map((c) => + c.containerNumber.trim().toUpperCase(), ); + if (!stillPresent || loadKey(load) !== loadKey(currentLoad)) { + throw new ConflictException( + 'This truck has already arrived — its load can no longer be changed or removed', + ); + } } } @@ -766,6 +746,8 @@ export class LastMileService { where: { lastMileId: id }, relations: { containers: true }, }); + await this.assertNoArrivedVehicleChanged(current, desiredMap); + const junctionSet = new Set(current.map((a) => a.vehicleId)); // Fold the legacy vehicleId into the release set — a vehicle assigned via the // old single-vehicle path has no junction row but must still be freed. 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 1deedd14d..ee1438fcf 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 @@ -2978,6 +2978,31 @@ export class WarehouseInventoryService { netTons, ], ); + // Customer self-haul: the same exit record on the customer's own truck. + // Without it a self-haul bulk booking never draws down — hauled tonnage + // summed to zero and the booking could take unlimited trucks. Matched by + // plate rather than container so bulk trucks (which carry none) count. + await manager.query( + `UPDATE freight.customer_truck_assignments a + SET departed_at = COALESCE($3::timestamptz, NOW()), + arrived_at = COALESCE(a.arrived_at, NOW()), + gross_weight_kg = $4, + tare_weight_tons = $5, + net_weight_tons = $6, + updated_at = NOW() + WHERE a.booking_id = $1 + AND UPPER(a.plate_number) = UPPER($2) + AND a.departed_at IS NULL + AND a.deleted_at IS NULL`, + [ + item.bookingId, + dto.truckPlateNumber.trim(), + dto.gateOutTime ?? null, + grossTons, + tareTons, + netTons, + ], + ); } await this.activityLog.record( { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 26f2334b9..5f0a8a092 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -202,6 +202,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea ); }, [opened, truckPrefill, isExitStep, lastMileTrucks]); + // The same for a customer self-haul truck. The prefill above reads the + // booking.customer_truck_* columns, but multi-truck self-haul writes the plate + // and driver to customer_truck_assignments and leaves those columns null — so + // a booking with a truck on file still opened this form blank. Only auto-fills + // a single truck: with several, the operator picks which one is at the gate. + useEffect(() => { + if (!opened || truckPrefill || isExitStep) return; + if (customerTrucks.length !== 1) return; + const [truck] = customerTrucks; + setTruckPlateNumber((p) => p || truck.plateNumber || ''); + setDriverName((p) => p || truck.driverName || ''); + setTruckType((p) => p || truck.truckType || ''); + setContainerNumbers((prev) => { + const loaded = (truck.containers ?? []).map((c) => c.containerNumber).filter(Boolean); + return prev.every((n) => !n) && loaded.length ? loaded : prev; + }); + }, [opened, truckPrefill, isExitStep, customerTrucks]); + // Registered trucks for THIS booking, from both sources: EDR last-mile // (truckPrefill) and the customer portal (customer_truck_assignments). const assignedTruckOptions = [