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 && (