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.
This commit is contained in:
Hagernesh
2026-08-08 15:15:52 +00:00
parent 954cbec8f0
commit 337ff6cfe3
12 changed files with 227 additions and 3 deletions

View File

@@ -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)' })

View File

@@ -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<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) {

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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`,
);