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

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

View File

@@ -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" && (
<Paper withBorder radius="md" p="sm">
<Stack gap={6}>
<Text size="sm" fw={600}>
How the cargo reaches the train
</Text>
<SegmentedControl
fullWidth
size="xs"
value={booking.exportHandoverMode ?? "WAREHOUSE"}
data={[
{ value: "WAREHOUSE", label: "Warehouse then train" },
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
]}
onChange={async (value) => {
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",
);
}
}}
/>
<Text size="xs" c="dimmed">
{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."}
</Text>
</Stack>
</Paper>
)}
{booking.isGovernment && booking.contractSummary && (
<Button
fullWidth

View File

@@ -458,6 +458,13 @@ export const bookingsService = {
return (unwrap(response.data) ?? []) as BookingDetail[];
},
setExportHandoverMode: async (
id: string,
exportHandoverMode: "DIRECT_TO_TRAIN" | "WAREHOUSE",
): Promise<void> => {
await client.patch(B.EXPORT_HANDOVER_MODE(id), { exportHandoverMode });
},
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
responseType: "blob",

View File

@@ -168,6 +168,11 @@ export interface BookingDetail {
contractType: string;
freightType: "CONTAINER" | "BULK";
tradeDirection: string;
/**
* EXPORT only. DIRECT_TO_TRAIN = customer truck loads straight onto the wagon
* (no warehouse, no GRN). null/WAREHOUSE = received and GRN'd first.
*/
exportHandoverMode?: "DIRECT_TO_TRAIN" | "WAREHOUSE" | null;
/** What the containers carry / bulk commodity label — entered at booking time. */
cargoFreeText?: string | null;
cargoTotalWeightVgm: number;