Files
edr-platform/apps/edr-freight-api/src/common/export-received-gate.ts
Hagernesh 337ff6cfe3 feat(freight): support direct truck-to-train export handover
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.
2026-08-08 15:16:31 +00:00

64 lines
2.6 KiB
TypeScript

import { BadRequestException } from '@nestjs/common';
import type { DataSource, EntityManager } from 'typeorm';
/** The booking fields the gate needs. */
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
* the customer's own truck, and even though a wagon is already allocated. An
* allocation is a plan; the GRN is the proof the goods are actually in hand.
*
* Several loading paths (per-yard load, workspace confirm-loaded) marked cargo
* loaded straight off the allocation, skipping the warehouse, so a booking could
* ride the train with nothing ever received. This closes that for export; import
* loads off a train and is unaffected.
*
* "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<void> {
if (booking.tradeDirection !== 'EXPORT') return;
if (booking.exportHandoverMode === DIRECT_TO_TRAIN) return;
const [row] = await db.query(
`SELECT 1
FROM freight.warehouse_inventory inv
WHERE inv.booking_id = $1
AND inv.deleted_at IS NULL
AND inv.status IN ('RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED')
AND COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) IS NOT NULL
LIMIT 1`,
[booking.id],
);
if (!row) {
throw new BadRequestException(
'This export booking has not been received at the warehouse yet — receive its cargo and generate a GRN before loading it onto the train.',
);
}
}