add Group 9 delivery scenarios for self-haul and last-mile paths

- Implemented G9·S38 tests for customer self-haul truck assignments, ensuring compliance with container limits and truck assignments.
- Added G9·S39 tests for last-mile delivery, self-haul, and yard pickup, verifying independent paths for multiple bookings on the same train.
- Created seed data for Group 1 and Group 2 scenarios, ensuring proper setup for weight and capacity tests.
- Updated booking interface to deprecate  in favor of  for better clarity in allocations.
This commit is contained in:
Marshal
2026-08-01 12:14:37 +00:00
parent 3424dacd5f
commit 123f925cc1
32 changed files with 6928 additions and 80 deletions

View File

@@ -1,6 +1,7 @@
import "reflect-metadata";
import * as dotenv from "dotenv";
dotenv.config();
import { createRequire } from "node:module";
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
@@ -20,6 +21,62 @@ import { AppModule } from "./app.module";
*/
const JSON_BODY_LIMIT = '20mb';
/**
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
* `host=ip,host2=ip2`. Some internal hosts (MinIO) resolve only inside the
* deployment network, so dev machines get ENOTFOUND on every upload. Patching
* `dns.lookup` keeps the real hostname on the wire — the IP is used for the
* connection only — so TLS still validates against the certificate's CN.
*
* The module is loaded through `createRequire`, NOT `import * as dns`: an ESM
* namespace object is frozen, so assigning to it is silently dropped and the
* patch becomes a no-op. `require` returns the live module object every other
* caller (minio's http agent included) reads `lookup` off.
*/
function applyDnsHostOverrides(): void {
const raw = process.env.DNS_HOST_OVERRIDES?.trim();
if (!raw) return;
const overrides = new Map<string, string>();
for (const entry of raw.split(",")) {
const [host, ip] = entry.split("=").map((part) => part?.trim());
if (host && ip) overrides.set(host.toLowerCase(), ip);
}
if (overrides.size === 0) return;
const dns = createRequire(__filename)("node:dns") as typeof import("node:dns");
const originalLookup = dns.lookup.bind(dns);
// `dns.lookup` is overloaded (options optional, all/family variants); the
// cast keeps that surface intact while we intercept only mapped hostnames.
(dns as { lookup: unknown }).lookup = ((
hostname: string,
options: unknown,
callback?: unknown,
) => {
const ip = overrides.get(hostname?.toLowerCase?.());
if (!ip) return (originalLookup as Function)(hostname, options, callback);
const done = (typeof options === "function" ? options : callback) as (
err: NodeJS.ErrnoException | null,
address: string | { address: string; family: number }[],
family?: number,
) => void;
const family = ip.includes(":") ? 6 : 4;
const wantsAll =
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
process.nextTick(() =>
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
);
}) as typeof dns.lookup;
console.log(
`[DNS] Host overrides active: ${[...overrides].map(([h, ip]) => `${h}->${ip}`).join(", ")}`,
);
}
applyDnsHostOverrides();
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);

View File

@@ -9,6 +9,7 @@ import {
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Box,
Button,
@@ -665,6 +666,17 @@ export default function GlCreateBookingForm() {
),
);
// Drop one container row and shrink quantity to match — the inverse of
// syncUnits growing the array when quantity goes up.
const removeUnit = (lineIdx: number, unitIdx: number) =>
setContainerLines((prev) =>
prev.map((l, i) => {
if (i !== lineIdx) return l;
const units = l.units.filter((_, j) => j !== unitIdx);
return withDerivedCounts({ ...l, quantity: String(units.length), units });
}),
);
// Same client-side validation as the customer portal shipment form
// (new-shipment-form/schema.ts): ISO container numbers unique within the
// shipment, positive VGM per unit, hazardous/reefer counts bounded by the
@@ -1362,6 +1374,8 @@ export default function GlCreateBookingForm() {
}
onChange={(e) => {
patchLine(lineIdx, { quantity: e.currentTarget.value });
}}
onBlur={(e) => {
syncUnits(lineIdx, Number(e.currentTarget.value || 0));
}}
radius={10}
@@ -1495,6 +1509,14 @@ export default function GlCreateBookingForm() {
/>
</Box>
))}
<ActionIcon
variant="subtle"
color="red"
aria-label={`Remove container ${unitIdx + 1}`}
onClick={() => removeUnit(lineIdx, unitIdx)}
>
<X size={16} />
</ActionIcon>
</Group>
))}
</Stack>

View File

@@ -1,6 +1,6 @@
import { Group, Tabs } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -223,15 +223,27 @@ export function ReadonlyBookingView({
<Tabs
defaultValue="overview"
keepMounted={false}
color="edr-green"
styles={{
list: { gap: 6, borderBottom: "1px solid #E6ECF2" },
tab: { borderRadius: "10px 10px 0 0", fontWeight: 700, padding: "10px 16px" },
tab: {
borderRadius: "10px 10px 0 0",
fontWeight: 700,
padding: "10px 16px",
transition: "background-color 120ms ease, color 120ms ease",
},
}}
>
<Tabs.List mb="lg">
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
Overview
</Tabs.Tab>
<Tabs.Tab value="logistics" leftSection={<Truck size={15} />}>
Logistics
</Tabs.Tab>
<Tabs.Tab value="activity" leftSection={<Clock size={15} />}>
Activity
</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<FileText size={15} />}>
Documents
</Tabs.Tab>
@@ -254,23 +266,9 @@ export function ReadonlyBookingView({
<ContainersCard booking={booking} />
<WarehouseLocationCard bookingId={booking.id} />
<ContractInfoCard booking={booking} />
<ShipmentTrackingCard bookingId={booking.id} />
{canAssignCustomerTruck && (
<CustomerTruckAssignmentCard
booking={booking}
onAssigned={onBookingUpdated ?? (() => {})}
/>
)}
<WarehousePaymentsSection bookingId={booking.id} />
<ActivityCard booking={booking} />
<MileSummaryCard booking={booking} />
</>
}
right={
@@ -295,6 +293,34 @@ export function ReadonlyBookingView({
</div>
</Tabs.Panel>
<Tabs.Panel value="logistics">
<div className="flex flex-col gap-6">
<BodyGrid
left={
<>
<WarehouseLocationCard bookingId={booking.id} />
{canAssignCustomerTruck && (
<CustomerTruckAssignmentCard
booking={booking}
onAssigned={onBookingUpdated ?? (() => {})}
/>
)}
<MileSummaryCard booking={booking} />
</>
}
right={<WarehousePaymentsSection bookingId={booking.id} />}
/>
</div>
</Tabs.Panel>
<Tabs.Panel value="activity">
<div className="flex flex-col gap-6" style={{ maxWidth: 700 }}>
<ActivityCard booking={booking} />
</div>
</Tabs.Panel>
<Tabs.Panel value="documents">
<DocumentsTab booking={booking} />
</Tabs.Panel>

View File

@@ -1,10 +1,18 @@
import { Box, Group, Text } from "@mantine/core";
import type { ReactNode } from "react";
import { MapPin } from "lucide-react";
import { type ReactNode, useState } from "react";
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
import { ShipmentTrackingModal } from "@/pages/bookings/tracking/ShipmentTrackingModal";
import type { BookingDetail } from "../booking-detail-types";
import { fmtDate, isDraftLike, isNegative, serviceTypeLabel } from "../utils";
import {
fmtDate,
isDraftLike,
isNegative,
serviceTypeLabel,
yardLabel,
} from "../utils";
import { CardTitle, SectionCard } from "./layout";
type Row = { label: string; value: ReactNode; muted?: boolean };
@@ -53,14 +61,39 @@ export function ScheduleCard({
title: string;
consignment?: boolean;
}) {
const [trackingOpen, setTrackingOpen] = useState(false);
const service = serviceTypeLabel(booking);
const equipmentReturn =
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
const assignedTrain: Row = {
label: "Assigned train",
value: booking.trainId ?? "Not yet assigned",
muted: !booking.trainId,
};
const assignedTrain: Row = booking.trainScheduleId
? {
label: "Assigned train",
value: (
<button
type="button"
onClick={() => setTrackingOpen(true)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
border: "none",
background: "none",
padding: 0,
cursor: "pointer",
color: "#0A6F4D",
fontWeight: 700,
fontSize: 13,
}}
>
<MapPin size={13} /> Track shipment
</button>
),
}
: {
label: "Assigned train",
value: "Not yet assigned",
muted: true,
};
const statusRow: Row = {
label: "Status",
@@ -115,6 +148,17 @@ export function ScheduleCard({
</Group>
))}
</Box>
{booking.trainScheduleId && (
<ShipmentTrackingModal
opened={trackingOpen}
onClose={() => setTrackingOpen(false)}
bookingId={booking.id}
bookingReference={booking.reference}
originLabel={yardLabel(booking.originYard)}
destinationLabel={yardLabel(booking.destinationYard)}
/>
)}
</SectionCard>
);
}

View File

@@ -69,10 +69,7 @@ export function ShipmentDetailsCard({ booking }: { booking: BookingDetail }) {
],
["Scheduled date", fmtDate(booking.scheduledDate)],
],
[
["Shipping line", shippingLineLabel(booking)],
["Assigned train", booking.trainId ?? "Not yet assigned"],
],
[["Shipping line", shippingLineLabel(booking)]],
];
const badges: string[] = [];

View File

@@ -77,6 +77,7 @@ const STATUS_FILTERS = [
key: "all",
label: "All bookings",
statuses: undefined as string | undefined,
assignedToSchedule: undefined as "true" | "false" | undefined,
},
{
key: "active",
@@ -89,9 +90,16 @@ const STATUS_FILTERS = [
key: "payment",
label: "Awaiting payment",
statuses:
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS",
},
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT,ARRIVED" },
{
key: "allocated",
label: "Allocated to a train",
statuses: undefined as string | undefined,
assignedToSchedule: "true" as const,
},
{ key: "expired", label: "Expired", statuses: "EXPIRED" },
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
{
key: "closed",
@@ -348,7 +356,10 @@ export default function BookingsListPage() {
const [trackingBooking, setTrackingBooking] =
useState<Freight.IBooking | null>(null);
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
const activeFilter = STATUS_FILTERS.find((t) => t.key === statusFilter);
const statuses = activeFilter?.statuses;
const assignedToSchedule =
"assignedToSchedule" in activeFilter! ? activeFilter.assignedToSchedule : undefined;
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
const resetPage = () =>
@@ -372,6 +383,7 @@ export default function BookingsListPage() {
const filter: BookingListFilter = useMemo(
() => ({
statuses,
assignedToSchedule,
bookingType: typeFilter ?? undefined,
freightType: freightFilter ?? undefined,
createdFrom: createdFrom || undefined,
@@ -386,6 +398,7 @@ export default function BookingsListPage() {
}),
[
statuses,
assignedToSchedule,
typeFilter,
freightFilter,
createdFrom,
@@ -423,6 +436,8 @@ export default function BookingsListPage() {
draft: draftCount,
done: doneCount,
transit: undefined,
allocated: undefined,
expired: undefined,
closed: undefined,
};
@@ -557,7 +572,12 @@ export default function BookingsListPage() {
size: 130,
meta: hMeta,
header: () => <ColHeader label="Payment" />,
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
cell: ({ row }) => (
<PaymentBadge
status={row.original.paymentStatus}
bookingStatus={row.original.status}
/>
),
},
{
id: "scheduling",

View File

@@ -144,17 +144,31 @@ export function paymentStatusLabel(status?: string | null): string {
return PAYMENT_LABELS[status] ?? titleCaseStatus(status);
}
/** Payment status pill. */
export function PaymentBadge({ status }: { status?: string | null }) {
if (!status) return <Text fz={13} c="dimmed"></Text>;
/**
* Payment status pill. Once the booking's own lifecycle status has moved past
* payment (PAID or later — stage ≥ 3 in STATUS_CONFIG), payment is a settled
* fact: show "Paid" even if a stale/lagging `paymentStatus` value says
* otherwise, rather than surface a contradictory "Paid booking, pending
* payment" row.
*/
export function PaymentBadge({
status,
bookingStatus,
}: {
status?: string | null;
bookingStatus?: string | null;
}) {
const settled = bookingStatus ? (STATUS_CONFIG[bookingStatus]?.stage ?? 0) >= 3 : false;
const effective = settled ? "PAID" : status;
if (!effective) return <Text fz={13} c="dimmed"></Text>;
return (
<Badge
variant="light"
radius="sm"
color={PAYMENT_COLORS[status] ?? "gray"}
color={PAYMENT_COLORS[effective] ?? "gray"}
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
>
{PAYMENT_LABELS[status] ?? titleCaseStatus(status)}
{PAYMENT_LABELS[effective] ?? titleCaseStatus(effective)}
</Badge>
);
}

View File

@@ -1316,7 +1316,10 @@ export default function ContractDetailPage() {
)}
</Table.Td>
<Table.Td>
<PaymentBadge status={booking.paymentStatus} />
<PaymentBadge
status={booking.paymentStatus}
bookingStatus={booking.status}
/>
</Table.Td>
<Table.Td>
<SchedulingCell booking={booking} />

View File

@@ -11,6 +11,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import {
ActionIcon,
Alert,
Box,
Button,
@@ -515,7 +516,9 @@ function NewShipmentBookingForm({
routes={routes}
completeBookingId={completeBookingId ?? null}
/>
<NotesSection form={form} />
{/* Notes are captured when the booking is initiated — completing
a bare booking does not re-ask for them. */}
{!completeBookingId && <NotesSection form={form} />}
</Stack>
</Box>
@@ -1694,6 +1697,18 @@ function ContainerLineEditor({
syncHandlingCounts(next);
};
// Drop one container row and shrink quantity to match — the inverse of
// syncUnits growing the array when quantity goes up.
const removeUnit = (unitIdx: number) => {
const current = form.getValues(`containers.${index}.units`) ?? [];
const next = current.filter((_, j) => j !== unitIdx);
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
form.setValue(`containers.${index}.quantity`, String(next.length), {
shouldValidate: true,
});
syncHandlingCounts(next);
};
/**
* Line totals are a roll-up of the per-container switches — the count is
* however many containers ticked each service. Kept in form state so the
@@ -1781,8 +1796,10 @@ function ContainerLineEditor({
styles={fieldStyles}
onChange={(e) => {
field.onChange(e.currentTarget.value);
const qty = Number(e.currentTarget.value || 0);
syncUnits(qty);
}}
onBlur={(e) => {
field.onBlur();
syncUnits(Number(e.currentTarget.value || 0));
}}
/>
)}
@@ -1906,6 +1923,14 @@ function ContainerLineEditor({
)}
/>
))}
<ActionIcon
variant="subtle"
color="red"
aria-label={`Remove container ${u + 1}`}
onClick={() => removeUnit(u)}
>
<X size={16} />
</ActionIcon>
</Group>
))}
</Stack>

View File

@@ -1,4 +1,4 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { type UseFormReturn } from "react-hook-form";
import {
Badge,
Box,
@@ -7,13 +7,11 @@ import {
Paper,
Stack,
Text,
Textarea,
} from "@mantine/core";
import {
CheckCircle2,
Circle,
ClipboardCheck,
Coins,
FileText,
MapPin,
Package,
@@ -220,13 +218,13 @@ export function Step8Review({
// not of the stored form flag — a stale draft flag must not misreport it.
// Without bundling, the customer may still name their own clearing agent.
const ownAgent = values.customsClearingAgent?.trim();
const customsValue = isIntercity
? "Not applicable domestic transport"
const customsTag: { label: string; color: string } = isIntercity
? { label: "Not applicable · domestic", color: "gray" }
: serviceType?.includesCustoms || values.customsClearingEnabled
? "Included — Global Logistics"
? { label: "EDR handles it · Global Logistics", color: "edr-green" }
: ownAgent
? `Own agent ${ownAgent}`
: "Not requested";
? { label: `Own agent · ${ownAgent}`, color: "blue" }
: { label: "Not requested", color: "gray" };
// Mirror the step-2 gating: imports never truck the first mile, exports never
// truck the last mile, and a service that doesn't bundle a mile can't have it.
@@ -351,27 +349,26 @@ export function Step8Review({
label="Service"
value={serviceType?.serviceName ?? "—"}
/>
<SummaryItem
icon={<Coins size={18} />}
label="Quotation currency"
value={
<>
USD
<Text fz="sm" c="dimmed" mt={4}>
You choose the billing currency on each shipment.
</Text>
</>
}
/>
<SummaryItem
icon={<Route size={18} />}
label="Route"
value={`${originYardName}${destinationYardName}`}
/>
<SummaryItem
icon={<MapPin size={18} />}
label="Trade direction"
value={directionLabel}
value={
<Group gap={6} wrap="nowrap" align="center">
<Text fz={14} fw={600} c="#10202F" truncate>
{originYardName} {destinationYardName}
</Text>
<Badge
size="xs"
variant="light"
color="edr-green"
radius="sm"
leftSection={<MapPin size={11} />}
style={{ flexShrink: 0 }}
>
{directionLabel}
</Badge>
</Group>
}
/>
<SummaryItem
icon={<Package size={18} />}
@@ -420,7 +417,16 @@ export function Step8Review({
<SummaryItem
icon={<FileText size={18} />}
label="Customs clearing"
value={customsValue}
value={
<Badge
size="sm"
variant="light"
color={customsTag.color}
radius="sm"
>
{customsTag.label}
</Badge>
}
/>
{/* Step-3 toggles appear only when the customer selected them —
an off toggle is left off the summary entirely. */}
@@ -478,20 +484,6 @@ export function Step8Review({
{documentsEditor}
</Paper>
)}
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Textarea
{...field}
label="Additional notes"
placeholder="Any special instructions for EDR operations…"
rows={3}
radius="md"
/>
)}
/>
</Stack>
{/* Right — sticky actions */}

View File

@@ -172,6 +172,8 @@ export interface BookingListFilter {
status?: string;
/** Comma-separated statuses (overrides `status` when set). */
statuses?: string;
/** 'true' = has a train assigned (allocated), 'false' = not yet assigned. */
assignedToSchedule?: "true" | "false";
/** ONE_TIME or GENERAL_CONTRACT. */
bookingType?: string;
/** CONTAINER or BULK. */