fix issue

This commit is contained in:
Marshal
2026-08-27 20:58:44 +00:00
parent 1626903192
commit 8b8870e85e
8 changed files with 327 additions and 63 deletions

View File

@@ -1,6 +1,10 @@
import { BadRequestException } from '@nestjs/common';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import {
bulkTonWagonsRequired,
bulkTonsPerWagonFor,
} from '../train-scheduling/train-capacity.util';
/**
* Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking
@@ -107,3 +111,48 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
).rejects.toThrow(/already shares a wagon/i);
});
});
/**
* A NUMBER_OF_WAGONS booking pins its count in `bulkRequestedWagons`, and
* bulkTonWagonsRequired honours that verbatim. Partial cancel must shrink it
* alongside wagonsRequired/cargoTotalWeightVgm — left stale, the booking
* re-inflates to its pre-cancel count on the next allocation and each wagon
* carries tons / stale-count instead of the real even share.
*/
describe('partial cancel of a NUMBER_OF_WAGONS bulk booking', () => {
// 980T over 14 wagons (70T each), 2 wagons cancelled.
const before = { freightType: 'BULK', cargoTotalWeightVgm: 980, bulkRequestedWagons: 14 };
const droppedWeight = 140;
const wagonsCancelled = 2;
// The decrement applied in applyPaidCut's booking update.
const after = {
...before,
cargoTotalWeightVgm: before.cargoTotalWeightVgm - droppedWeight,
bulkRequestedWagons: Math.max(
0,
Math.floor(before.bulkRequestedWagons - wagonsCancelled),
),
};
it('reallocates at the reduced count, not the pre-cancel one', () => {
expect(bulkTonWagonsRequired(before, undefined, 'nw5', 70)).toBe(14);
expect(bulkTonWagonsRequired(after, undefined, 'nw5', 70)).toBe(12);
});
it('keeps tons-per-wagon at the real even share', () => {
// Stale count would spread 840T over 14 wagons → 60T each.
expect(bulkTonsPerWagonFor(after, undefined, 'nw5', 70)).toBe(70);
});
it('cancelling every wagon leaves no requested count behind', () => {
const all = Math.max(0, Math.floor(before.bulkRequestedWagons - 14));
expect(all).toBe(0);
expect(bulkTonWagonsRequired(
{ ...before, cargoTotalWeightVgm: 0, bulkRequestedWagons: all },
undefined,
'nw5',
70,
)).toBe(0);
});
});

View File

@@ -704,8 +704,21 @@ export class BookingWagonCancellationService {
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
);
const isFull = wagonsLeft <= 0;
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
// booking to its pre-cancel count on the next allocation (and shrinks
// tons-per-wagon to tons / stale-count), so shrink it with the cut.
const requestedWagonsLeft = booking.bulkRequestedWagons
? Math.max(
0,
Math.floor(Number(booking.bulkRequestedWagons) - Number(row.wagonsCancelled)),
)
: null;
await manager.getRepository(Booking).update(booking.id, {
wagonsRequired: Math.max(0, wagonsLeft),
...(requestedWagonsLeft !== null
? { bulkRequestedWagons: requestedWagonsLeft }
: {}),
cargoTotalWeightVgm: Math.max(
0,
round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),

View File

@@ -201,6 +201,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
.leftJoinAndSelect('cargoScope.cargoType', 'cargoType')
// Wagon types carry the rated capacity the booking forms need to reject
// a wagon count whose even share overloads a wagon (see maxTonsPerWagon).
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoTypeWagonTypes')
.leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots')
.leftJoinAndSelect('contract.signatures', 'signatures')
.leftJoinAndSelect('signatures.signatureFile', 'signatureFile')

View File

@@ -13,7 +13,9 @@ import { YardCountry } from '@edr/types';
//
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
@@ -858,6 +860,29 @@ export class ContractsService {
);
}
// NUMBER_OF_WAGONS booking forms need the heaviest load one wagon may take
// so they can reject a wagon count whose even share overloads a wagon —
// the client-side twin of ContractBookingService.assertWagonShareFits.
// The raw wagonTypes join rows are dropped: only the derived cap ships.
for (const scope of contract.cargoScope ?? []) {
const cargoType = scope.cargoType as
| (CargoType & { maxTonsPerWagon?: number | null })
| null
| undefined;
if (!cargoType) continue;
const allowed = (cargoType.wagonTypes ?? []).filter(
(wt) => Number(wt.capacityTons) > 0,
);
cargoType.maxTonsPerWagon = allowed.length
? Math.max(
...allowed.map((wt) =>
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)),
),
)
: null;
delete cargoType.wagonTypes;
}
// Surface the staff "request changes" note so the portal can show the
// customer what to fix. Degrade to null on lookup failure — a missing note
// must never 500 a contract fetch.

View File

@@ -43,6 +43,7 @@ import {
Flame,
Link2,
MapPin,
MoveRight,
Package,
Receipt,
Repeat,
@@ -204,6 +205,19 @@ function emptyLine(size: string): ContainerLineDraft {
};
}
/**
* Heaviest load one wagon of this contract's bulk cargo may take, as computed
* by the API from the cargo type's allowed wagon types. Null/undefined when no
* wagon type is configured — the wagon-count check then falls away.
*/
function bulkMaxTonsPerWagon(
contract: Freight.IContract,
): number | null | undefined {
return contract.cargoScope?.find(
(scope) => scope.cargoType?.maxTonsPerWagon != null,
)?.cargoType?.maxTonsPerWagon;
}
function bulkUnitOfMeasure(
contract: Freight.IContract,
): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" {
@@ -1000,8 +1014,20 @@ export default function GlCreateBookingForm() {
}
if (bulkUom === "NUMBER_OF_WAGONS") {
const wagons = Number(bulk.requestedWagons || 0);
const maxPerWagon = Number(
(contract && bulkMaxTonsPerWagon(contract)) || 0,
);
if (!Number.isInteger(wagons) || wagons < 1) {
errs.wagons = "Enter the number of wagons needed (at least 1).";
} else if (qty > 0 && maxPerWagon > 0 && qty / wagons > maxPerWagon) {
// Too few wagons for the tonnage can never ride: 200T across 3 wagons
// is 66.67T each on a 50T wagon. Mirrors the server's
// assertWagonShareFits so the button blocks before the API 400s.
errs.wagons =
`${qty} tons across ${wagons} wagon${wagons === 1 ? "" : "s"} loads ` +
`${Math.round((qty / wagons) * 1000) / 1000}T per wagon, but a wagon of ` +
`this cargo carries at most ${maxPerWagon}T — request at least ` +
`${Math.ceil(qty / maxPerWagon)} wagons.`;
}
}
const h = Number(bulk.hazardousQuantity || 0);
@@ -1017,7 +1043,7 @@ export default function GlCreateBookingForm() {
errs.reefer = `Can't exceed the cargo quantity (${qty}).`;
}
return errs;
}, [isContainer, bulk, bulkUom]);
}, [isContainer, bulk, bulkUom, contract]);
const dateError =
!isIntercity && !scheduledDate ? "Select a shipment date." : undefined;
@@ -1479,12 +1505,12 @@ export default function GlCreateBookingForm() {
const header = (
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" mb="lg">
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
{completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"}
<Title order={1} fw={800} fz={28} style={{ letterSpacing: "-0.01em" }}>
{completeBookingId ? "Complete shipment booking" : "New Shipment Booking"}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{completeBookingId
? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.`
? `Clearance is finalized. Enter cargo details and the binding shipment day to complete this booking under contract ${contract.reference}.`
: `Book a shipment on behalf of the customer for contract ${contract.reference}.`}
</Text>
</Box>
@@ -1603,20 +1629,49 @@ export default function GlCreateBookingForm() {
styles={fieldStyles}
/>
) : (
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Text fz={14} fw={600}>
{selectedRoute?.originYard?.label ??
selectedRoute?.originYard?.code ??
"—"}{" "}
{" "}
{selectedRoute?.destinationYard?.label ??
selectedRoute?.destinationYard?.code ??
"—"}
</Text>
<Text fz={12} c="dimmed" mt={2}>
<Group
wrap="nowrap"
gap={16}
align="center"
px={18}
py={18}
style={{
borderRadius: 14,
border: "1px solid #E6ECF2",
background: "#FBFCFD",
}}
>
<Box>
<Text fz={15} fw={700} c="#10202F">
{selectedRoute?.originYard?.label ??
selectedRoute?.originYard?.code ??
"—"}
</Text>
<Text fz={12} c="#6B7C8E" mt={2}>
Origin yard
</Text>
</Box>
<MoveRight size={20} color="#0A6F4D" style={{ flexShrink: 0 }} />
<Box>
<Text fz={15} fw={700} c="#10202F">
{selectedRoute?.destinationYard?.label ??
selectedRoute?.destinationYard?.code ??
"—"}
</Text>
<Text fz={12} c="#6B7C8E" mt={2}>
Destination yard
</Text>
</Box>
<Box style={{ flex: 1 }} />
<Badge
variant="light"
color="teal"
radius={8}
styles={{ label: { fontSize: 12, fontWeight: 600 } }}
>
{contract.tradeDirection}
</Text>
</Paper>
</Badge>
</Group>
)}
</StepCard>
@@ -2322,16 +2377,6 @@ export default function GlCreateBookingForm() {
even numbers. Add one more 20ft container or remove one — book{" "}
{ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}.
</Alert>
) : showErrors && !formValid ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
Fix the highlighted fields before reviewing the price.
</Alert>
) : partnerError ? (
// The review button is disabled while the parent booking is
// incomplete, so the click that would reveal the errors never
@@ -2346,7 +2391,35 @@ export default function GlCreateBookingForm() {
{partnerError}
</Alert>
) : null}
<Group justify="flex-end">
<Group justify="space-between" wrap="nowrap" gap="md">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
{showErrors && !formValid && !oddBlocksSubmit && (
<>
<AlertCircle
size={15}
color="#C0392B"
style={{ flexShrink: 0 }}
/>
<Text fz={13} fw={500} c="#C0392B">
Fix the highlighted fields to review the price.
</Text>
</>
)}
</Group>
<Group gap="sm" wrap="nowrap">
<Button
variant="default"
radius="md"
onClick={() =>
navigate(
completeBookingId
? `/dashboard/clearance/${completeBookingId}`
: "/dashboard/contracts/clearance",
)
}
>
Cancel
</Button>
<Tooltip
label={
oddBlocksSubmit
@@ -2376,6 +2449,7 @@ export default function GlCreateBookingForm() {
</Button>
</Box>
</Tooltip>
</Group>
</Group>
</Box>
</Box>

View File

@@ -13,6 +13,7 @@ import { useNavigate, useParams } from "react-router-dom";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Center,
@@ -41,6 +42,7 @@ import {
FileUp,
Flame,
MapPin,
MoveRight,
Package,
Receipt,
Repeat,
@@ -237,6 +239,19 @@ export default function NewShipmentPage() {
);
}
/**
* Heaviest load one wagon of this contract's bulk cargo may take, as computed
* by the API from the cargo type's allowed wagon types. Undefined when no
* wagon type is configured — the wagon-count check then falls away.
*/
function bulkMaxTonsPerWagon(
contract: Freight.IContract,
): number | null | undefined {
return contract.cargoScope?.find(
(scope) => scope.cargoType?.maxTonsPerWagon != null,
)?.cargoType?.maxTonsPerWagon;
}
function bulkUnitOfMeasure(
contract: Freight.IContract,
): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" {
@@ -434,6 +449,7 @@ function NewShipmentBookingForm({
contract.freightType === "CONTAINER" &&
contract.equipmentReturn === "WITH_RETURN",
unitOfMeasure: bulkUnitOfMeasure(contract),
maxTonsPerWagon: bulkMaxTonsPerWagon(contract),
// Intercity rides a passing train staff pick later — no date to choose.
requiresDate: contract.tradeDirection !== "DOMESTIC",
// Export completion locks onto a specific train — the pick is required
@@ -688,20 +704,20 @@ function NewShipmentBookingForm({
<Title
order={1}
fw={800}
fz={26}
fz={28}
style={{ letterSpacing: "-0.01em" }}
>
{completeBookingId
? isResubmit
? "Change Your Booking"
: "Complete Your Booking"
? "Change shipment booking"
: "Complete shipment booking"
: "New Shipment Booking"}
</Title>
<Text size="sm" c="edr-muted" mt={4}>
{completeBookingId
? isResubmit
? `Update the details below and pick a new shipment day, then resubmit your booking under contract ${contract.reference}.`
: `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
: `Clearance is finalized. Enter cargo details and the binding shipment day to complete ${completeBooking?.reference ?? "this booking"} under contract ${contract.reference}.`
: `Book a shipment against contract ${contract.reference}.`}
</Text>
</Box>
@@ -791,17 +807,6 @@ function NewShipmentBookingForm({
}}
>
<Box className="mx-auto max-w-4xl">
{showValidationSummary ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
Fix the highlighted fields before reviewing the price.
</Alert>
) : null}
{blockOdd20ft ? (
<Alert
color="red"
@@ -823,16 +828,42 @@ function NewShipmentBookingForm({
{`${ft20Total} is an odd number of 20ft containers — this booking will be paired with another customer's odd booking to share a wagon, or held until one is available.`}
</Alert>
) : null}
<Group justify="flex-end">
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
>
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
<Group justify="space-between" wrap="nowrap" gap="md">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
{showValidationSummary && (
<>
<AlertCircle
size={15}
color="#C0392B"
style={{ flexShrink: 0 }}
/>
<Text fz={13} fw={500} c="#C0392B">
Fix the highlighted fields to review the price.
</Text>
</>
)}
</Group>
<Group gap="sm" wrap="nowrap">
<Button
type="button"
variant="default"
radius="md"
onClick={() =>
navigate(`/contracts/${contract.id}`)
}
>
Cancel
</Button>
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
>
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
</Group>
</Group>
</Box>
</Box>
@@ -1212,15 +1243,45 @@ function RouteStep({
)}
/>
) : (
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Text fz={14} fw={600} c="#10202F">
{routes[0]?.originYard?.label ?? "—"} {" "}
{routes[0]?.destinationYard?.label ?? "—"}
</Text>
<Text fz={12} c="dimmed" mt={2}>
<Group
wrap="nowrap"
gap={16}
align="center"
px={18}
py={18}
style={{
borderRadius: 14,
border: "1px solid #E6ECF2",
background: "#FBFCFD",
}}
>
<Box>
<Text fz={15} fw={700} c="#10202F">
{routes[0]?.originYard?.label ?? "—"}
</Text>
<Text fz={12} c="#6B7C8E" mt={2}>
Origin yard
</Text>
</Box>
<MoveRight size={20} color="#0A6F4D" style={{ flexShrink: 0 }} />
<Box>
<Text fz={15} fw={700} c="#10202F">
{routes[0]?.destinationYard?.label ?? "—"}
</Text>
<Text fz={12} c="#6B7C8E" mt={2}>
Destination yard
</Text>
</Box>
<Box style={{ flex: 1 }} />
<Badge
variant="light"
color="teal"
radius={8}
styles={{ label: { fontSize: 12, fontWeight: 600 } }}
>
{contract.tradeDirection}
</Text>
</Paper>
</Badge>
</Group>
)}
</StepCard>
);
@@ -1300,8 +1361,16 @@ function ScheduleStep({
const selectedTrainId = form.watch("trainScheduleId");
const isExportPick =
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId);
const requestedWagonsValue = form.watch("requestedWagons");
const wagonsEstimate = useMemo(() => {
if (contract.freightType !== "CONTAINER") return undefined;
// NUMBER_OF_WAGONS bulk states its wagon count outright — pass it through
// so the train picker sizes fits/free against the real need instead of
// falling back to the server's tonnage estimate.
if (contract.freightType !== "CONTAINER") {
if (bulkUnitOfMeasure(contract) !== "NUMBER_OF_WAGONS") return undefined;
const wagons = Math.floor(Number(requestedWagonsValue || 0));
return wagons >= 1 ? wagons : undefined;
}
const lines = containerLines ?? [];
const ft20 = lines
.filter((l) => l.containerSize === "20ft")
@@ -1311,7 +1380,7 @@ function ScheduleStep({
.reduce((s, l) => s + Number(l.quantity || 0), 0);
const wagons = Math.ceil(ft20 / 2) + ft40;
return wagons > 0 ? wagons : undefined;
}, [contract.freightType, containerLines]);
}, [contract, containerLines, requestedWagonsValue]);
const exportTrainsQuery = useQuery({
...api.bookings.getExportTrains.queryOptions({
input: {

View File

@@ -25,6 +25,13 @@ export interface ShipmentValidationContext {
*/
withReturnService?: boolean;
unitOfMeasure?: "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS";
/**
* NUMBER_OF_WAGONS: the most tons one wagon of this cargo may carry. The
* requested count must spread the tonnage no heavier than this, or the
* server rejects the booking (assertWagonShareFits). Undefined when the
* cargo type has no wagon type configured — the check then falls away.
*/
maxTonsPerWagon?: number | null;
/**
* Intercity (DOMESTIC) shipments ride a passing import/export train that
* staff pick later, so no shipment day is chosen. Defaults to true.
@@ -312,6 +319,23 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
path: ["requestedWagons"],
message: "Enter the number of wagons needed (at least 1).",
});
} else {
// Too few wagons for the tonnage can never ride: 200T across 3
// wagons is 66.67T each on a 50T wagon. Mirrors the server's
// assertWagonShareFits so the button blocks before the API 400s.
const tons = Number(data.cargoWeightTons || 0);
const maxPerWagon = Number(ctx.maxTonsPerWagon || 0);
if (tons > 0 && maxPerWagon > 0 && tons / wagons > maxPerWagon) {
refineCtx.addIssue({
code: "custom",
path: ["requestedWagons"],
message:
`${tons} tons across ${wagons} wagon${wagons === 1 ? "" : "s"} loads ` +
`${Math.round((tons / wagons) * 1000) / 1000}T per wagon, but a wagon of ` +
`this cargo carries at most ${maxPerWagon}T — request at least ` +
`${Math.ceil(tons / maxPerWagon)} wagons.`,
});
}
}
}

View File

@@ -179,6 +179,13 @@ export interface IContractCargoScope {
code?: string | null;
cargoTypeName?: string | null;
unitOfMeasure?: string | null;
/**
* Most tons of this cargo one wagon may carry, across the cargo's allowed
* wagon types (rated capacity, capped by the type's loading limit). Lets
* the booking forms reject a wagon count whose even share overloads a
* wagon before the server does. Null when no wagon type is configured.
*/
maxTonsPerWagon?: number | null;
} | null;
cargoFreeText?: string | null;
/**