diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 4803f988c..580150eb0 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { Contract } from '../contracts/entities/contract.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -588,8 +589,10 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') // Contract reference for the list column + search (no entity relation on - // Booking → contract, so join by id and select just the reference). - .leftJoin('freight.contracts', 'contract', 'contract.id = booking.contract_id') + // Booking → contract, so join the entity by id and select just the + // reference — a schema-qualified table string is parsed as alias.relation + // by TypeORM and crashes). + .leftJoin(Contract, 'contract', 'contract.id = booking.contract_id') .addSelect('contract.reference', 'contract_reference') .where('booking.deleted_at IS NULL'); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index c1eb4b4f8..125cf5f3d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -138,6 +138,11 @@ export class ContractBookingService { // no override. Checked before any row is written. if (freightType === 'CONTAINER') { await this.assertWithinMaxCapacity(contract, dto); + // 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ + // ≤ the cap, and drawdown bookings never pass through submit — so this is + // their only chance to hard-block an unbalanceable set. Entry order is + // irrelevant (the check sorts by weight before pairing). + await this.assert20ftPairableAtCreate(dto); } // Denormalize route/direction/freight onto the booking for the scheduling engine. @@ -761,6 +766,35 @@ export class ContractBookingService { } } + /** + * Hard-block booking creation when the 20ft container weights cannot be + * balanced onto wagons (pair diff over the global cap). Same rule the + * shipment-form preview reports as `pairingErrors`, enforced server-side. + */ + private async assert20ftPairableAtCreate( + dto: CreateBookingUnderContractDto, + ): Promise { + const twentyFtUnits = (dto.containers ?? []) + .filter((line) => (line.containerSize ?? '').includes('20')) + .flatMap((line, lineIdx) => + (line.units ?? []).map((u, idx) => ({ + label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`, + grossWeightTons: Number(u.vgmTons ?? 0), + })), + ); + if (twentyFtUnits.length < 2) return; + + const maxDiff = await this.max20ftPairDiffTons(); + const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff); + if (violations.length) { + throw new BadRequestException( + `Cannot create booking — 20ft containers cannot be paired on wagons: ${violations + .map((v) => v.message) + .join(' ')}`, + ); + } + } + private async max20ftPairDiffTons(): Promise { const row = await this.dataSource .getRepository(TrainSchedulingGlobalRules) diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index d967ffc3f..6f52d287f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -7,6 +7,7 @@ import { import { useMutation, useQuery } from "@tanstack/react-query"; import { Alert, + Badge, Box, Button, Center, @@ -18,6 +19,7 @@ import { Paper, Select, Stack, + Switch, Text, Textarea, TextInput, @@ -82,12 +84,13 @@ interface UnitDraft { containerNumber: string; sealNumber: string; vgmTons: number | string; + /** Per-unit flags — the line's hazardous/reefer counts are derived from these. */ + hazardous: boolean; + reefer: boolean; } interface ContainerLineDraft { containerSize: string; - hazardousQuantity: number | string; - reeferQuantity: number | string; units: UnitDraft[]; } @@ -100,7 +103,13 @@ interface BulkLineDraft { } function emptyUnit(): UnitDraft { - return { containerNumber: "", sealNumber: "", vgmTons: "" }; + return { + containerNumber: "", + sealNumber: "", + vgmTons: "", + hazardous: false, + reefer: false, + }; } function bulkUnitOfMeasure( @@ -199,11 +208,13 @@ export default function GlCreateBookingForm() { setContainerLines( lines.containers.map((c) => ({ containerSize: c.containerSize, - hazardousQuantity: c.hazardousQuantity ?? "0", - reeferQuantity: c.reeferQuantity ?? "", - units: Array.from({ length: Math.max(1, c.quantity) }, () => - emptyUnit(), - ), + // The request carries counts; pre-toggle the first N units so GL sees + // the customer's declared hazardous/reefer split and can adjust it. + units: Array.from({ length: Math.max(1, c.quantity) }, (_, i) => ({ + ...emptyUnit(), + hazardous: i < Number(c.hazardousQuantity ?? 0), + reefer: i < Number(c.reeferQuantity ?? 0), + })), })), ); } else if (lines.bulk) { @@ -229,8 +240,6 @@ export default function GlCreateBookingForm() { setContainerLines( containerSizes.map((size) => ({ containerSize: size, - hazardousQuantity: "0", - reeferQuantity: "0", units: [emptyUnit()], })), ); @@ -261,8 +270,8 @@ export default function GlCreateBookingForm() { containers: containerLines.map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, - hazardousQuantity: Number(l.hazardousQuantity || 0), - reeferQuantity: Number(l.reeferQuantity || 0), + hazardousQuantity: l.units.filter((u) => u.hazardous).length, + reeferQuantity: l.units.filter((u) => u.reefer).length, })), bulkQuantity: bulkLines.reduce( (s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0), @@ -384,12 +393,10 @@ export default function GlCreateBookingForm() { .map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, - ...(l.hazardousQuantity !== "" - ? { hazardousQuantity: Number(l.hazardousQuantity) } - : {}), - ...(l.reeferQuantity !== "" - ? { reeferQuantity: Number(l.reeferQuantity) } - : {}), + // Counts are derived from the per-unit toggles — they can never + // exceed the line quantity. + hazardousQuantity: l.units.filter((u) => u.hazardous).length, + reeferQuantity: l.units.filter((u) => u.reefer).length, units: l.units.map((u) => ({ containerNumber: u.containerNumber, ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), @@ -652,7 +659,7 @@ export default function GlCreateBookingForm() { {line.containerSize} containers - + syncUnits(lineIdx, Number(v) || 0)} radius={10} styles={fieldStyles} + w={160} /> {contract.isHazardous ? ( - - patchLine(lineIdx, { hazardousQuantity: v }) - } - radius={10} - styles={fieldStyles} - /> + + {line.units.filter((u) => u.hazardous).length} hazardous + ) : null} {contract.isReefer ? ( - - patchLine(lineIdx, { reeferQuantity: v }) - } - radius={10} - styles={fieldStyles} - /> + + {line.units.filter((u) => u.reefer).length} refrigerated + ) : null} Per-container details {line.units.map((unit, unitIdx) => ( - + + {/* Per-unit flags: toggle exactly the containers that are + hazardous / refrigerated; line counts derive from these. */} + {contract.isHazardous ? ( + + {unitIdx === 0 ? ( + + Hazardous + + ) : null} + + patchUnit(lineIdx, unitIdx, { + hazardous: e.currentTarget.checked, + }) + } + /> + + ) : null} + {contract.isReefer ? ( + + {unitIdx === 0 ? ( + + Reefer + + ) : null} + + patchUnit(lineIdx, unitIdx, { + reefer: e.currentTarget.checked, + }) + } + /> + + ) : null} ))}