mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 20:10:56 +00:00
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:
@@ -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)' })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user