Merge pull request #1343 from Tria-plc/dev

dev
This commit is contained in:
marshal
2026-08-19 09:25:24 +03:00
committed by GitHub
5 changed files with 84 additions and 19 deletions

View File

@@ -81,6 +81,28 @@ export class BookingTransitionService {
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
private async assert20ftPairable(booking: Booking): Promise<void> { private async assert20ftPairable(booking: Booking): Promise<void> {
// Parity gate. 20ft ride two per wagon, so an odd total leaves one container
// that cannot be placed. Consolidation (pairing it with another customer's
// odd booking) is built end to end but switched off for now, so an odd total
// is rejected here rather than parked for a partner.
// containerSize is not always populated (some rows carry only the container
// type), so fall back to the type's sizeFt rather than silently skipping
// those lines and letting an odd booking through.
const ft20Quantity = (booking.bookingContainers ?? [])
.filter((bc) =>
bc.containerSize
? bc.containerSize.includes("20")
: Number(bc.containerType?.sizeFt) === 20,
)
.reduce((sum, bc) => sum + Number(bc.quantity || 0), 0);
if (ft20Quantity % 2 === 1) {
throw new BadRequestException(
`20ft containers travel two per wagon, so they must be booked in even ` +
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
);
}
const violations = const violations =
await this.containerValidationService.validate20ftPairing(booking); await this.containerValidationService.validate20ftPairing(booking);
if (violations.length) { if (violations.length) {

View File

@@ -2458,6 +2458,22 @@ export class ContractBookingService {
private async assert20ftPairableAtCreate( private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto, dto: CreateBookingUnderContractDto,
): Promise<void> { ): Promise<void> {
// Parity gate. 20ft containers ride two per wagon, so an odd total leaves
// one container that cannot be placed. Consolidation (pairing it with
// another customer's odd booking) is built end to end but switched off for
// now, so an odd total is rejected outright — server-side, because the
// frontend block alone is not a guarantee.
const ft20Quantity = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20'))
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
if (ft20Quantity % 2 === 1) {
throw new BadRequestException(
`20ft containers travel two per wagon, so they must be booked in even ` +
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
);
}
const twentyFtUnits = (dto.containers ?? []) const twentyFtUnits = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20')) .filter((line) => (line.containerSize ?? '').includes('20'))
.flatMap((line, lineIdx) => .flatMap((line, lineIdx) =>

View File

@@ -874,9 +874,14 @@ export default function GlCreateBookingForm() {
// Only a customs (Path B) instance being COMPLETED by GL can use the shared // Only a customs (Path B) instance being COMPLETED by GL can use the shared
// wagon: it is GL, not the customer, who links the two bookings. Anything else // wagon: it is GL, not the customer, who links the two bookings. Anything else
// keeps the historical hard block on odd 20ft. // keeps the historical hard block on odd 20ft.
const oddConsolidationAvailable = Boolean( //
completeBookingId && isContainer && contract?.customsClearingEnabled, // Switched OFF for now: consolidation is built end to end (toggle, parent
); // picker, split entry, paired pricing, approval gate) but not in use, so an
// odd 20ft total is rejected outright instead of offering the shared wagon.
// Drop the `false &&` to bring the whole flow back.
const oddConsolidationAvailable =
false &&
Boolean(completeBookingId && isContainer && contract?.customsClearingEnabled);
// Auto-on: entering an odd 20ft total opens the consolidation panel by itself, // Auto-on: entering an odd 20ft total opens the consolidation panel by itself,
// once. GL can still switch it off — then odd is blocked exactly as before. // once. GL can still switch it off — then odd is blocked exactly as before.
@@ -965,10 +970,11 @@ export default function GlCreateBookingForm() {
!cargoDescriptionError !cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
// The unpaired 20ft container is resolved by the shared wagon, so with an // Consolidation (sharing the wagon with another customer's odd booking) is
// active consolidation an odd total stops being a blocker; without one it // built but switched off for now, so an odd 20ft total always blocks — the
// blocks exactly as before. // shared wagon no longer resolves the unpaired container. Flip this back to
const oddBlocksSubmit = hasOdd20ft && !consolidationActive; // `hasOdd20ft && !consolidationActive` to re-enable the shared-wagon path.
const oddBlocksSubmit = hasOdd20ft;
// Partner side: a linked partner must be picked, carry an odd 20ft count of // Partner side: a linked partner must be picked, carry an odd 20ft count of
// its own (odd + odd = even fills the wagon) and have complete unit details. // its own (odd + odd = even fills the wagon) and have complete unit details.
@@ -2168,7 +2174,23 @@ export default function GlCreateBookingForm() {
}} }}
> >
<Box maw={896} mx="auto"> <Box maw={896} mx="auto">
{showErrors && !formValid ? ( {/* The review button is disabled on an odd 20ft total, so the click
that would surface the errors never lands — state the reason here
rather than leaving it in a tooltip nobody hovers. */}
{oddBlocksSubmit ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
title={`Odd number of 20ft containers (${ft20Total})`}
>
20ft containers travel two per wagon, so they must be booked in
even numbers. Add one more 20ft container or remove one — book{" "}
{ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}.
</Alert>
) : showErrors && !formValid ? (
<Alert <Alert
color="red" color="red"
variant="light" variant="light"

View File

@@ -172,8 +172,11 @@ export function Step8Review({
values.cargoType === "container" values.cargoType === "container"
? calcWagons(values.containers ?? []) ? calcWagons(values.containers ?? [])
: { hasOddUnit: false, ft20Wagons: 0 }; : { hasOddUnit: false, ft20Wagons: 0 };
const oddPairsViaCustoms = hasOdd20ft && Boolean(values.customsClearingEnabled); // Consolidation (pairing an odd 20ft with another customer's odd booking) is
const oddBlocksSubmit = hasOdd20ft && !oddPairsViaCustoms; // built but switched off for now, so an odd total blocks submit on every path
// — customs included.
const oddPairsViaCustoms = false;
const oddBlocksSubmit = hasOdd20ft;
const isGeneralContract = values.bookingType === "general_contract"; const isGeneralContract = values.bookingType === "general_contract";
// Both one-time and general contracts take the bulk amount from the cargo step // Both one-time and general contracts take the bulk amount from the cargo step

View File

@@ -106,14 +106,15 @@ export default function NewShipmentRequestPage() {
contract.cargoScope?.[0]; contract.cargoScope?.[0];
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM"; const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
// 20ft containers ride two per wagon. An odd total leaves one unpaired, which // 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// is allowed here: on a customs contract GL completes the booking and links it // the request cannot be planned. Consolidation (pairing the odd container with
// to another customer's odd booking so the two share the wagon. The request is // another customer's odd booking) is built but switched off for now, so an odd
// therefore informational only, not a block. // request is blocked here rather than dead-ending downstream.
const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0; const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0;
const hasOdd20ft = ft20Requested % 2 === 1; const hasOdd20ft = ft20Requested % 2 === 1;
const handleSubmit = () => { const handleSubmit = () => {
if (hasOdd20ft) return;
const dto: Freight.CreateBookingRequestDto = { const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id, contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined, scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
@@ -220,17 +221,17 @@ export default function NewShipmentRequestPage() {
{hasOdd20ft ? ( {hasOdd20ft ? (
<Alert <Alert
color="blue" color="red"
variant="light" variant="light"
radius="md" radius="md"
icon={<AlertCircle size={16} />} icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Requested})`} title={`Odd number of 20ft containers (${ft20Requested})`}
> >
<Text fz={13}> <Text fz={13}>
20ft containers travel two per wagon, so one of yours will 20ft containers travel two per wagon, so they must be requested
share a wagon with another shipment. Global Logistics arranges in even numbers. Please add one more 20ft container or remove
the pairing when completing your booking you are billed only one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "}
for your own containers. instead of {ft20Requested}).
</Text> </Text>
</Alert> </Alert>
) : null} ) : null}
@@ -281,6 +282,7 @@ export default function NewShipmentRequestPage() {
leftSection={<Send size={16} />} leftSection={<Send size={16} />}
loading={submit.isPending} loading={submit.isPending}
onClick={handleSubmit} onClick={handleSubmit}
disabled={hasOdd20ft}
> >
Submit shipment request Submit shipment request
</Button> </Button>