From 337ff6cfe38bef67b0e55ccd39265a8e8905709a Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 8 Aug 2026 15:15:52 +0000 Subject: [PATCH] feat(freight): support direct truck-to-train export handover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export cargo reaches a train two ways, but the platform only modelled one. Direct truck-to-train cargo loads straight onto the wagon, never enters a warehouse and so never has a GRN — yet assertExportReceivedWithGrn required one before the carriage acceptance sheet could be issued or the booking loaded from inside its schedule. Adds export_handover_mode to freight.bookings (null = WAREHOUSE, so existing bookings are unaffected) and teaches the shared gate to skip DIRECT_TO_TRAIN. Both call sites are fixed by that single early return. For direct bookings the carriage acceptance sheet builds its lines from the booking's own containers, falling back to the declared bulk tonnage, and is issuable as soon as the mode is chosen. Direct bookings are also removed from the warehouse receive queue, since that cargo is never coming to the shed. Staff choose the mode from the booking detail page via a new endpoint reusing bookings:operations. Switching to direct is refused once warehouse inventory exists, so the two flows cannot cross. Warehouse-then-train keeps every gate it had. --- .../src/common/export-received-gate.spec.ts | 23 ++++++ .../src/common/export-received-gate.ts | 13 ++++ ...3370000000000-BookingExportHandoverMode.ts | 28 ++++++++ .../modules/bookings/bookings.controller.ts | 13 ++++ .../src/modules/bookings/bookings.service.ts | 70 ++++++++++++++++++- .../dto/set-export-handover-mode.dto.ts | 15 ++++ .../bookings/entities/booking.entity.ts | 12 ++++ .../warehouses/warehouse-inventory.service.ts | 3 + .../backoffice/src/constants/URLS.ts | 2 + .../bookings/BookingRequestDetailPage.tsx | 39 +++++++++++ .../src/services/bookings.service.ts | 7 ++ .../backoffice/src/types/booking.ts | 5 ++ 12 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3370000000000-BookingExportHandoverMode.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/set-export-handover-mode.dto.ts diff --git a/apps/edr-freight-api/src/common/export-received-gate.spec.ts b/apps/edr-freight-api/src/common/export-received-gate.spec.ts index 6aaa24a26..62e7c758c 100644 --- a/apps/edr-freight-api/src/common/export-received-gate.spec.ts +++ b/apps/edr-freight-api/src/common/export-received-gate.spec.ts @@ -36,4 +36,27 @@ describe('assertExportReceivedWithGrn', () => { assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }), ).resolves.toBeUndefined(); }); + + it('never blocks direct truck-to-train export — that cargo has no GRN by design', async () => { + const source = db([]); + await expect( + assertExportReceivedWithGrn(source, { + id: 'b-1', + tradeDirection: 'EXPORT', + exportHandoverMode: 'DIRECT_TO_TRAIN', + }), + ).resolves.toBeUndefined(); + // Direct short-circuits before querying — there is no inventory to look for. + expect(source.query as jest.Mock).not.toHaveBeenCalled(); + }); + + it('still gates a warehouse export booking', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { + id: 'b-1', + tradeDirection: 'EXPORT', + exportHandoverMode: 'WAREHOUSE', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); }); diff --git a/apps/edr-freight-api/src/common/export-received-gate.ts b/apps/edr-freight-api/src/common/export-received-gate.ts index 0e1728800..5fd97480a 100644 --- a/apps/edr-freight-api/src/common/export-received-gate.ts +++ b/apps/edr-freight-api/src/common/export-received-gate.ts @@ -5,8 +5,15 @@ import type { DataSource, EntityManager } from 'typeorm'; export interface ExportLoadGateBooking { id: string; tradeDirection?: string | null; + /** 'DIRECT_TO_TRAIN' skips the gate entirely; null/'WAREHOUSE' keeps it. */ + exportHandoverMode?: string | null; } +/** Direct truck-to-train: the cargo never sees a warehouse, so it never has a GRN. */ +export const DIRECT_TO_TRAIN = 'DIRECT_TO_TRAIN'; +/** Warehouse-then-train: the existing flow. Also what a null mode means. */ +export const WAREHOUSE = 'WAREHOUSE'; + /** * Export cargo may not be loaded onto its train until it has physically reached * the warehouse and been issued a GRN — whether it got there by first-mile or by @@ -21,12 +28,18 @@ export interface ExportLoadGateBooking { * "Received with a GRN" = an inventory row that has reached the warehouse * (RECEIVED or any later stage) and carries a GRN, in the column or the notes * fallback older rows use. + * + * Export has a second, warehouse-free shape: the customer's truck loads straight + * onto the wagon. That cargo is never received and never GRN'd, so a booking + * marked DIRECT_TO_TRAIN is outside this gate by definition — its custody is + * attested by the carriage acceptance sheet instead. */ export async function assertExportReceivedWithGrn( db: DataSource | EntityManager, booking: ExportLoadGateBooking, ): Promise { if (booking.tradeDirection !== 'EXPORT') return; + if (booking.exportHandoverMode === DIRECT_TO_TRAIN) return; const [row] = await db.query( `SELECT 1 diff --git a/apps/edr-freight-api/src/migrations/3370000000000-BookingExportHandoverMode.ts b/apps/edr-freight-api/src/migrations/3370000000000-BookingExportHandoverMode.ts new file mode 100644 index 000000000..42acfc8f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3370000000000-BookingExportHandoverMode.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Export cargo reaches a train two ways, and until now only one was modelled. + * + * DIRECT_TO_TRAIN — the customer's truck pulls alongside and the cargo goes + * straight onto the wagon. It never enters a warehouse, so no GRN is ever + * raised; the Carriage Acceptance Sheet is the only document handed over. + * + * WAREHOUSE — cargo is received into the warehouse, GRN'd, then loaded. This is + * the existing flow and stays gated on the GRN. + * + * NULL means WAREHOUSE, so existing rows keep today's behaviour with no backfill. + */ +export class BookingExportHandoverMode3370000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS export_handover_mode varchar(20) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings DROP COLUMN IF EXISTS export_handover_mode + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index b7eb526a3..f10b5f021 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -77,6 +77,7 @@ import { LastMileService } from '../last-mile/last-mile.service'; import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; +import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; import { @@ -757,6 +758,18 @@ export class BookingsController { return this.customerTruckService.getLoadableContainers(id); } + @Patch(':id/export-handover-mode') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ + summary: 'Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first', + }) + setExportHandoverMode( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetExportHandoverModeDto, + ) { + return this.bookingsService.setExportHandoverMode(id, dto.exportHandoverMode); + } + @Post(':id/customer-trucks/:assignmentId/load') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 27460b923..c96c54f9b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -28,7 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; -import { assertExportReceivedWithGrn } from '../../common/export-received-gate'; +import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; import { Yard } from '../rule-engine/entities/yard.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; @@ -285,10 +285,24 @@ export class BookingsService { // Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork // and never appears on this sheet — it is only the signal that EDR has taken // the cargo, which is what the customer's sheet attests to. + const isDirectExport = + booking.tradeDirection === 'EXPORT' && booking.exportHandoverMode === DIRECT_TO_TRAIN; const pendingWagons = wagons.length === 0; if (pendingWagons) { - const receivedLines: CarriageAcceptanceReceivedRow[] = - booking.tradeDirection === 'EXPORT' + // Direct truck-to-train cargo never enters the warehouse, so there is no + // GRN'd inventory to build the sheet from. Choosing direct handover is + // itself the acceptance, so the sheet issues off the booking's own + // containers (or its VGM weight when the cargo is bulk). + const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport + ? await this.dataSource.query( + `SELECT NULL::numeric AS "allocatedWeightTons", + c.container_number AS "containerNumbers" + FROM freight.containers c + WHERE c.booking_id = $1 AND c.deleted_at IS NULL + ORDER BY c.container_number`, + [bookingId], + ) + : booking.tradeDirection === 'EXPORT' ? await this.dataSource.query( `SELECT inv.weight AS "allocatedWeightTons", c.container_number AS "containerNumbers" @@ -304,6 +318,15 @@ export class BookingsService { [bookingId], ) : []; + // Bulk direct cargo has no containers — one line carrying the booking's + // declared weight still makes a valid sheet. + if (isDirectExport && receivedLines.length === 0) { + receivedLines.push({ + allocatedWeightTons: + booking.bulkTotalWeightTons == null ? null : String(booking.bulkTotalWeightTons), + containerNumbers: null, + }); + } if (receivedLines.length === 0) { throw new BadRequestException( booking.tradeDirection === 'EXPORT' @@ -1997,6 +2020,47 @@ export class BookingsService { } /** Get a single booking by ID with files. */ + /** + * EXPORT only. Choose how the cargo reaches the train. DIRECT_TO_TRAIN takes + * the booking out of the warehouse flow entirely — no receipt, no GRN, and the + * carriage acceptance sheet becomes issuable straight away. + * + * Switching to direct is refused once the goods are already in the shed: + * inventory exists, so the cargo demonstrably went the warehouse route and its + * GRN paperwork must stand. + */ + async setExportHandoverMode( + bookingId: string, + mode: string, + ): Promise<{ bookingId: string; exportHandoverMode: string }> { + const booking = await this.bookingsRepository.findById(bookingId); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + if ((booking.tradeDirection ?? '').toUpperCase() !== 'EXPORT') { + throw new BadRequestException('Handover mode applies to export bookings only'); + } + if (mode === DIRECT_TO_TRAIN) { + const [stored]: Array<{ one: number }> = await this.dataSource.query( + `SELECT 1 AS one + FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [bookingId], + ); + if (stored) { + throw new BadRequestException( + 'This booking already has cargo in the warehouse, so it cannot be switched to direct truck-to-train', + ); + } + } + await this.dataSource.query( + `UPDATE freight.bookings SET export_handover_mode = $2, updated_at = NOW() WHERE id = $1`, + [bookingId, mode], + ); + return { bookingId, exportHandoverMode: mode }; + } + async findById(id: string): Promise { const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/set-export-handover-mode.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/set-export-handover-mode.dto.ts new file mode 100644 index 000000000..c2685cba0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/set-export-handover-mode.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; + +import { DIRECT_TO_TRAIN, WAREHOUSE } from '../../../common/export-received-gate'; + +export class SetExportHandoverModeDto { + @ApiProperty({ + enum: [DIRECT_TO_TRAIN, WAREHOUSE], + description: + 'DIRECT_TO_TRAIN — the customer truck loads straight onto the wagon (no warehouse, no GRN). ' + + 'WAREHOUSE — received at the warehouse and issued a GRN first.', + }) + @IsIn([DIRECT_TO_TRAIN, WAREHOUSE]) + exportHandoverMode!: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 3d339fa19..dbc306e3b 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -302,6 +302,18 @@ export class Booking extends BaseEntity { @Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true }) customerTruckArrivedAt?: Date | null; + /** + * EXPORT only. How the cargo reaches the train: + * - DIRECT_TO_TRAIN — the customer's truck loads straight onto the wagon. No + * warehouse, so no GRN is ever raised and the carriage acceptance sheet is + * the only document handed over. + * - WAREHOUSE (also null) — received into the warehouse and GRN'd first. + * + * Null is treated as WAREHOUSE so existing bookings keep the GRN gate. + */ + @Column({ name: 'export_handover_mode', type: 'varchar', length: 20, nullable: true }) + exportHandoverMode?: string | null; + /** * Did the goods need re-handling in the warehouse? Recorded by warehouse * staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule; 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 59a04f08c..2f204bd62 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 @@ -1425,6 +1425,9 @@ export class WarehouseInventoryService { WHERE b.deleted_at IS NULL AND b.payment_status = 'PAID' AND inv.id IS NULL + -- Direct truck-to-train cargo never comes to the warehouse, so never + -- offer it for receipt. + AND COALESCE(b.export_handover_mode, 'WAREHOUSE') <> 'DIRECT_TO_TRAIN' ORDER BY b.scheduled_date DESC NULLS LAST`, ); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index ba601347d..3b482571c 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -155,6 +155,8 @@ export const URL_CONSTANTS = { CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`, CARRIAGE_ACCEPTANCE_SHEET: (id: string) => `/bookings/${id}/carriage-acceptance-sheet`, + EXPORT_HANDOVER_MODE: (id: string) => + `/bookings/${id}/export-handover-mode`, SUMMARY: (id: string) => `/bookings/${id}/summary`, CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`, MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`, diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index e77b4c44b..feee70b9d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -22,6 +22,7 @@ import { Paper, Button, Box, + SegmentedControl, } from "@mantine/core"; import { PageContainer } from "@/components/page"; @@ -258,6 +259,44 @@ export default function BookingRequestDetailPage() { booking={booking} mutations={mutations} /> + {booking.tradeDirection === "EXPORT" && ( + + + + How the cargo reaches the train + + { + try { + await bookingsService.setExportHandoverMode( + booking.id, + value as "DIRECT_TO_TRAIN" | "WAREHOUSE", + ); + await refetch(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Could not change the handover mode", + ); + } + }} + /> + + {booking.exportHandoverMode === "DIRECT_TO_TRAIN" + ? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document." + : "Cargo is received at the warehouse and issued a GRN before loading."} + + + + )} {booking.isGovernment && booking.contractSummary && (