From d832e83b4a15a2dfcd3b29be3402247b7ca7a443 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 08:13:51 +0000 Subject: [PATCH 1/2] update --- .../train-schedules/entities/train-schedule-booking.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts index 4ffecea26..951cdaa80 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -11,7 +11,7 @@ export class TrainScheduleBooking extends BaseEntity { @Column({ name: 'train_schedule_id', type: 'uuid' }) trainScheduleId!: string; - @ManyToOne(() => TrainSchedule, (trainSchedule) => trainSchedule.scheduleBookings, { + @ManyToOne(() => TrainSchedule, (trainSchedule) => trainScmahedule.scheduleBookings, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'train_schedule_id' }) From 35cb20da0bd2d0c558564ac5f6025d12f28b34fb Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 09:26:27 +0000 Subject: [PATCH 2/2] feat: implement 20ft container weight-pairing validation - Added ContainerValidationService to handle 20ft weight-pairing logic. - Introduced validate20ftWeightPairing utility function to check weight differences. - Updated BookingPricingService to include overweight line details and pairing errors in price response. - Enhanced BookingTransitionService to reject submissions with unpairable 20ft containers. - Created ShipmentValidation interface for pre-submit validation of container contracts. - Integrated shipment validation into the contract booking process, providing warnings for overweight containers and hard blocks for pairing errors. - Updated front-end components to display validation results and prevent submission when errors are present. --- .../bookings/booking-pricing.service.spec.ts | 1 + .../bookings/booking-pricing.service.ts | 47 ++++++++ .../booking-transition.accept.spec.ts | 1 + .../booking-transition.clearance.spec.ts | 3 + .../booking-transition.operation.spec.ts | 1 + .../bookings/booking-transition.service.ts | 21 ++++ .../src/modules/bookings/bookings.module.ts | 2 + .../bookings/container-pairing.util.spec.ts | 55 +++++++++ .../bookings/container-pairing.util.ts | 64 ++++++++++ .../bookings/container-validation.service.ts | 76 ++++++++++++ .../dto/generate-price-response.dto.ts | 26 ++++ .../contracts/contract-booking.service.ts | 111 ++++++++++++++++++ .../modules/contracts/contracts.controller.ts | 12 ++ .../entities/train-schedule-booking.entity.ts | 2 +- apps/edr-freight-web/backoffice/src/App.tsx | 64 +++++++++- .../backoffice/src/lib/permissions.ts | 32 +++++ .../portal/src/constants/URLS.ts | 2 + .../src/pages/contracts/NewShipmentPage.tsx | 90 +++++++++++++- .../portal/src/services/api.ts | 8 ++ .../portal/src/services/contracts.service.ts | 33 ++++++ 20 files changed, 646 insertions(+), 5 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/container-validation.service.ts diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 1c1b490dd..db6b70eae 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -45,6 +45,7 @@ describe('BookingPricingService — domestic corridor', () => { {} as never, ratesService as never, exchangeService as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index c5e5b710e..e8469e627 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -17,6 +17,14 @@ import { import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; +import { ContainerValidationService } from './container-validation.service'; + +export interface OverweightLine { + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; +} export interface ComputedPriceResult { lineItems: PriceLineItemDto[]; @@ -27,6 +35,7 @@ export interface ComputedPriceResult { priorityScore: number; warnings: string[]; hardBlocked: string[]; + overweightLines: OverweightLine[]; } type StoredPricingBreakdown = { @@ -67,6 +76,7 @@ export class BookingPricingService { private readonly containerTypesService: ContainerTypesService, private readonly ratesService: RatesService, private readonly exchangeService: ExchangeService, + private readonly containerValidationService: ContainerValidationService, ) {} async generatePrice(bookingId: string): Promise { @@ -94,12 +104,19 @@ export class BookingPricingService { }, } as never); + // 20ft weight-pairing preview: surfaced now so the customer sees the problem + // (and the overweight warning + surcharge) at the confirm step, before submit. + // Submit re-runs this and HARD-BLOCKS on a non-empty result. + const pairing = await this.containerValidationService.validate20ftPairing(booking); + return { bookingId, totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, warnings: computed.warnings, + overweightLines: computed.overweightLines, + pairingErrors: pairing.map((p) => p.message), }; } @@ -169,6 +186,35 @@ export class BookingPricingService { if (rate) usedRatesMap.set(rate.id, rate); } + // Overweight detail for the customer: map the engine's per-line results back + // to the booking's container lines (same order) for code + weights. maxAllowed + // is derived from the line total minus the excess the engine computed. + const overweightLines: OverweightLine[] = []; + const containerLines = (booking.bookingContainers ?? []).filter( + (bc) => bc.containerTypeId != null, + ); + for (let i = 0; i < ruleResult.containerWeightResults.length; i++) { + const wr = ruleResult.containerWeightResults[i]; + if (!wr?.isOverweight) continue; + const line = containerLines[i]; + const totalVgmTons = Number(line?.totalVgmTons ?? 0); + const excessTons = Number(wr.overweightExcessTons ?? 0); + let code = line?.containerSize ?? ''; + if (line?.containerTypeId) { + try { + code = (await this.containerTypesService.findById(line.containerTypeId)).code; + } catch { + // fall back to the container size label + } + } + overweightLines.push({ + containerTypeCode: code, + totalVgmTons, + maxAllowedTons: Math.max(0, totalVgmTons - excessTons), + excessTons, + }); + } + return { lineItems, totalAmount: total, @@ -178,6 +224,7 @@ export class BookingPricingService { priorityScore: ruleResult.priorityScore, warnings: ruleResult.warnings, hardBlocked: ruleResult.hardBlocked, + overweightLines, }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index f9b160672..3c535f450 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -37,6 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository, ruleEngineService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index d62883d53..9f9aa5713 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -48,6 +48,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => { bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository }; } @@ -132,6 +133,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository }; } @@ -202,6 +204,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository, filesService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 66df02ac4..ea3618a08 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -40,6 +40,7 @@ describe('BookingTransitionService — operation review', () => { bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository, bookingBatchService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 227de08fe..3e1ea3cd4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -16,6 +16,7 @@ import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; +import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; import { clearanceCodesForBooking } from './clearance.util'; @@ -51,6 +52,7 @@ export class BookingTransitionService { @Inject(forwardRef(() => ClearanceWorkflowService)) private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, + private readonly containerValidationService: ContainerValidationService, ) {} @@ -58,6 +60,19 @@ export class BookingTransitionService { return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking); } + /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ + private async assert20ftPairable(booking: Booking): Promise { + const violations = + await this.containerValidationService.validate20ftPairing(booking); + if (violations.length) { + throw new BadRequestException( + `Cannot submit — 20ft containers cannot be paired on wagons: ${violations + .map((v) => v.message) + .join(' ')}`, + ); + } + } + async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]); @@ -78,6 +93,11 @@ export class BookingTransitionService { requiresDirectorApproval: false, }); + // 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap. + // If no balanced pairing exists the booking cannot proceed (overweight only + // warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation. + await this.assert20ftPairable(booking); + const stored = booking.pricingBreakdown as { lineItems?: PriceLineItemDto[]; totalAmount?: number; @@ -158,6 +178,7 @@ export class BookingTransitionService { hardBlocked: computed.hardBlocked, requiresDirectorApproval: false, }); + await this.assert20ftPairable(booking); await this.pricingService.createPricingSnapshots( bookingId, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 2cc23b3fa..50bd090b5 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -23,6 +23,7 @@ import { BookingsController } from './bookings.controller'; // import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { ContainerValidationService } from './container-validation.service'; import { BookingsService } from './bookings.service'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -78,6 +79,7 @@ import { BookingContainerAllocation } from "./entities/booking-container-allocat BookingsService, BookingsRepository, ConsolidationService, + ContainerValidationService, BookingReferenceDataService, BookingPricingService, BookingTransitionService, diff --git a/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts new file mode 100644 index 000000000..6aed9bf1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts @@ -0,0 +1,55 @@ +import { validate20ftWeightPairing } from './container-pairing.util'; + +describe('validate20ftWeightPairing', () => { + const MAX_DIFF = 10; + + it('passes when a balanced pairing exists (adjacent diffs within cap)', () => { + // sorted: 8, 15, 18, 24 → pairs (8,15) diff 7, (18,24) diff 6 — both ≤ 10. + const units = [ + { label: 'A', grossWeightTons: 24 }, + { label: 'B', grossWeightTons: 8 }, + { label: 'C', grossWeightTons: 18 }, + { label: 'D', grossWeightTons: 15 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('flags a pair whose weight difference exceeds the cap', () => { + // sorted: 5, 25 → single pair diff 20 > 10. + const units = [ + { label: 'HEAVY', grossWeightTons: 25 }, + { label: 'LIGHT', grossWeightTons: 5 }, + ]; + const result = validate20ftWeightPairing(units, MAX_DIFF); + expect(result).toHaveLength(1); + expect(result[0].labels).toEqual(['LIGHT', 'HEAVY']); + expect(result[0].diffTons).toBe(20); + }); + + it('allows an odd leftover unit (goes to consolidation, not a violation)', () => { + // sorted: 10, 12, 30 → pair (10,12) diff 2 ok; 30 is the odd leftover. + const units = [ + { label: 'A', grossWeightTons: 10 }, + { label: 'B', grossWeightTons: 12 }, + { label: 'C', grossWeightTons: 30 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('adjacent-by-weight pairing succeeds where a naive input order would fail', () => { + // Input order (20, 12, 22, 10) naively pairs (20,12)=8 and (22,10)=12 (fail), + // but sorted (10,12,20,22) pairs (10,12)=2 and (20,22)=2 — valid, so no violation. + const units = [ + { label: 'A', grossWeightTons: 20 }, + { label: 'B', grossWeightTons: 12 }, + { label: 'C', grossWeightTons: 22 }, + { label: 'D', grossWeightTons: 10 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('returns nothing for fewer than two units', () => { + expect(validate20ftWeightPairing([{ label: 'A', grossWeightTons: 30 }], MAX_DIFF)).toEqual([]); + expect(validate20ftWeightPairing([], MAX_DIFF)).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts new file mode 100644 index 000000000..cf1cfe944 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts @@ -0,0 +1,64 @@ +/** + * Booking-time 20ft weight-pairing rule. + * + * A container wagon holds two 20ft containers (2 TEU). When two 20ft ride the + * same wagon their gross-weight difference must not exceed `maxPairDiffTons` + * (global rule `max20ftPairWeightDiffTons`, default 10t) so the wagon load stays + * balanced. 40ft containers occupy a whole wagon alone and never pair. + * + * At booking time the customer enters every 20ft container's weight but not its + * wagon slot, so we auto-pair: sort the 20ft weights ascending and pair adjacent + * (0-1, 2-3, …). Adjacent pairing minimises the diff of every pair, so if ANY + * valid pairing exists this one finds it — a violation here means no balanced + * pairing is possible and the booking must be blocked. An odd leftover 20ft is + * fine: it has no partner in this booking and flows to consolidation. + */ + +export interface Container20ftUnit { + /** Human label for messages, e.g. the container number. */ + label: string; + grossWeightTons: number; +} + +export interface PairingViolation { + message: string; + /** The two container labels whose pairing exceeds the diff cap. */ + labels: [string, string]; + diffTons: number; +} + +const round2 = (n: number): number => Math.round(n * 100) / 100; + +/** + * Validate that the given 20ft units can all be paired onto wagons within the + * weight-difference cap. Returns one violation per over-cap adjacent pair (empty + * when every wagon pair is balanced or there is nothing to pair). A single + * leftover unit (odd count) is not a violation. + */ +export function validate20ftWeightPairing( + units: Container20ftUnit[], + maxPairDiffTons: number, +): PairingViolation[] { + if (units.length < 2 || maxPairDiffTons == null) return []; + + // Ascending by weight: adjacent pairs have the smallest possible diffs. + const sorted = [...units].sort((a, b) => a.grossWeightTons - b.grossWeightTons); + const violations: PairingViolation[] = []; + + for (let i = 0; i + 1 < sorted.length; i += 2) { + const a = sorted[i]; + const b = sorted[i + 1]; + const diff = Math.abs(a.grossWeightTons - b.grossWeightTons); + if (diff > maxPairDiffTons) { + violations.push({ + message: + `20ft containers ${a.label} (${round2(a.grossWeightTons)}T) and ` + + `${b.label} (${round2(b.grossWeightTons)}T) cannot share a wagon: ` + + `weight difference ${round2(diff)}T exceeds the ${maxPairDiffTons}T limit.`, + labels: [a.label, b.label], + diffTons: round2(diff), + }); + } + } + return violations; +} diff --git a/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts new file mode 100644 index 000000000..de4dca661 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, In } from 'typeorm'; + +import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; +import { Booking } from './entities/booking.entity'; +import { BookingContainerUnit } from './entities/booking-container-unit.entity'; +import { + Container20ftUnit, + PairingViolation, + validate20ftWeightPairing, +} from './container-pairing.util'; + +/** Default 20ft pair weight-difference cap when no global rules row exists (matches the entity default). */ +const DEFAULT_MAX_20FT_PAIR_DIFF_TONS = 10; + +/** + * Booking-time container validations that need the customer-entered per-unit + * weights (`BookingContainerUnit`): the 20ft weight-pairing rule. Kept out of the + * rule engine (which works on line totals) because pairing is per physical unit. + */ +@Injectable() +export class ContainerValidationService { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + private async maxPairDiffTons(): Promise { + const row = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .find({ order: { createdAt: 'ASC' }, take: 1 }) + .then((rows) => rows[0] ?? null) + .catch(() => null); + const v = row?.max20ftPairWeightDiffTons; + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) ? n : DEFAULT_MAX_20FT_PAIR_DIFF_TONS; + } + + /** Load every 20ft container UNIT weight for a booking (customer-entered VGM). */ + private async load20ftUnits(booking: Booking): Promise { + const lines = (booking.bookingContainers ?? []).filter( + (bc) => (bc.containerSize ?? '').includes('20'), + ); + if (!lines.length) return []; + + const units = await this.dataSource + .getRepository(BookingContainerUnit) + .find({ + where: { bookingContainerId: In(lines.map((l) => l.id)) }, + order: { sortOrder: 'ASC' }, + }); + + return units.map((u) => ({ + label: u.containerNumber || u.id.slice(0, 8), + grossWeightTons: Number(u.vgmTons ?? 0), + })); + } + + /** + * Validate the 20ft weight-pairing rule for a booking. Returns one message per + * pair whose weight difference exceeds the cap; empty when all 20ft can be + * balanced onto wagons (or there is nothing to pair). A lone odd 20ft is fine — + * it flows to consolidation. Callers hard-block a non-empty result. + */ + async validate20ftPairing(booking: Booking): Promise { + // Only bookings whose 20ft lines actually carry per-unit weights can be + // checked; contract-drawdown bookings do (units are required there). + const containerLines = booking.bookingContainers ?? []; + const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20')); + if (!has20ft) return []; + + const units = await this.load20ftUnits(booking); + if (units.length < 2) return []; + + const maxDiff = await this.maxPairDiffTons(); + return validate20ftWeightPairing(units, maxDiff); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts index 532d6b1a7..0ee77aeed 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -27,6 +27,20 @@ export class PriceLineItemDto { currency!: string; } +export class OverweightLineDto { + @ApiProperty() + containerTypeCode!: string; + + @ApiProperty() + totalVgmTons!: number; + + @ApiProperty() + maxAllowedTons!: number; + + @ApiProperty() + excessTons!: number; +} + export class GeneratePriceResponseDto { @ApiProperty() bookingId!: string; @@ -42,4 +56,16 @@ export class GeneratePriceResponseDto { @ApiProperty({ type: [String] }) warnings!: string[]; + + /** Overweight container lines (VGM over the weight-limit rule) — surcharge already in lineItems. */ + @ApiProperty({ type: [OverweightLineDto] }) + overweightLines!: OverweightLineDto[]; + + /** + * 20ft weight-pairing violations. Non-empty means the booking cannot be + * balanced onto wagons and submit is HARD-BLOCKED — the customer must fix + * container weights/quantities. (Overweight, by contrast, only warns.) + */ + @ApiProperty({ type: [String] }) + pairingErrors!: string[]; } 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 b0a5cc636..ef36ea5eb 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 @@ -13,6 +13,8 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; +import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; +import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @@ -563,6 +565,115 @@ export class ContractBookingService { } } + /** + * Pre-create validation for the shipment form: run the overweight rule + the + * 20ft weight-pairing rule against the entered containers WITHOUT persisting a + * booking. The portal calls this from the price-confirm modal so the customer + * sees the overweight warning (+ surcharge basis) and is blocked on an + * un-pairable 20ft set before the booking is created. + */ + async validateShipment( + contractId: string, + dto: CreateBookingUnderContractDto, + ): Promise<{ + overweightLines: Array<{ + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; + }>; + pairingErrors: string[]; + }> { + const contract = await this.contractsRepository.findByIdWithRelations(contractId); + if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); + + const lines = dto.containers ?? []; + if (!lines.length) return { overweightLines: [], pairingErrors: [] }; + + // Resolve each line's container type + total VGM (sum of unit weights) so the + // rule engine can flag overweight per line (maxVgmTons × quantity vs total). + const resolved = await Promise.all( + lines.map(async (line) => { + const ct = await this.resolveContainerTypeForSize( + line.containerSize, + contract.isReefer || (line.reeferQuantity ?? 0) > 0, + ); + const totalVgmTons = (line.units ?? []).reduce( + (s, u) => s + Number(u.vgmTons ?? 0), + 0, + ); + return { line, ct, totalVgmTons }; + }), + ); + + const ruleResult = await this.ruleEngineService.evaluate({ + freightType: 'CONTAINER', + cargoTypeId: null, + serviceTypeId: contract.serviceTypeId, + paymentCurrency: contract.paymentCurrency, + tradeDirection: contract.tradeDirection, + isHazardous: false, + isReefer: contract.isReefer ?? false, + isGovernment: false, + allowConsolidation: false, + shippingLineId: null, + totalWagons: 0, + bulkTons: 0, + containers: resolved.map((r) => ({ + containerTypeId: r.ct.id, + quantity: r.line.quantity, + vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0, + totalVgmTons: r.totalVgmTons, + isReefer: r.ct.isReefer, + })), + } as never); + + const overweightLines: Array<{ + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; + }> = []; + for (let i = 0; i < ruleResult.containerWeightResults.length; i++) { + const wr = ruleResult.containerWeightResults[i]; + if (!wr?.isOverweight) continue; + const r = resolved[i]; + const excessTons = Number(wr.overweightExcessTons ?? 0); + overweightLines.push({ + containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '', + totalVgmTons: r?.totalVgmTons ?? 0, + maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons), + excessTons, + }); + } + + // 20ft weight-pairing: gather every 20ft unit weight and check the pair rule. + const twentyFtUnits = resolved + .filter((r) => (r.line.containerSize ?? '').includes('20')) + .flatMap((r) => + (r.line.units ?? []).map((u, idx) => ({ + label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`, + grossWeightTons: Number(u.vgmTons ?? 0), + })), + ); + const maxDiff = await this.max20ftPairDiffTons(); + const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map( + (v) => v.message, + ); + + return { overweightLines, pairingErrors }; + } + + private async max20ftPairDiffTons(): Promise { + const row = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .find({ order: { createdAt: 'ASC' }, take: 1 }) + .then((rows) => rows[0] ?? null) + .catch(() => null); + const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons); + return Number.isFinite(n) ? n : 10; + } + /** Pick the default container type for a size; prefer reefer when requested. */ private async resolveContainerTypeForSize( size: string, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 6ded65ae9..09e4a7ffd 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -788,6 +788,18 @@ export class ContractsController { ); } + @Post(':id/validate-shipment') + @ApiOperation({ + summary: + 'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).', + }) + validateShipment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CreateBookingUnderContractDto, + ) { + return this.contractBookingService.validateShipment(id, dto); + } + @Get(':id/capacity') @ApiOperation({ summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)', diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts index 951cdaa80..4ffecea26 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -11,7 +11,7 @@ export class TrainScheduleBooking extends BaseEntity { @Column({ name: 'train_schedule_id', type: 'uuid' }) trainScheduleId!: string; - @ManyToOne(() => TrainSchedule, (trainSchedule) => trainScmahedule.scheduleBookings, { + @ManyToOne(() => TrainSchedule, (trainSchedule) => trainSchedule.scheduleBookings, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'train_schedule_id' }) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8c2453e4d..f9a273bfb 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -61,7 +61,13 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; -import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; +import { + FREIGHT_PERMS, + hasPermission as hasFreightPermission, + isDjiboutiGl, + isEthiopianGl, + isSuperAdmin, +} from "./lib/permissions"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; @@ -435,12 +441,35 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ]; -/** Keep only items the user is permitted to see; drop now-empty sections. */ +/** Hrefs of the two document-clearance menu items (stable identifiers). */ +const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; +const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; + +const isEtClearanceItem = (item: SidebarItem): boolean => + item.href === ET_CLEARANCE_HREF; +const isDjClearanceItem = (item: SidebarItem): boolean => + item.href === DJ_CLEARANCE_HREF; +const isClearanceItem = (item: SidebarItem): boolean => + isEtClearanceItem(item) || isDjClearanceItem(item); + +/** + * Keep only items the user is permitted to see; drop now-empty sections. + * + * Position-scoped visibility (super_admin bypasses all of this): + * - Ethiopian GL → sees ONLY the ET document-clearance page. + * - Djibouti GL → sees ONLY the DJ clearance page. + * - Everyone else → sees everything they have permission for, EXCEPT the two + * clearance pages (those are GL-only). + */ const filterSidebarByPermission = ( sections: SidebarSection[], user: ReturnType["user"], ): SidebarSection[] => { - const itemAllowed = (item: SidebarItem): boolean => { + const superAdmin = isSuperAdmin(user); + const etGl = !superAdmin && isEthiopianGl(user); + const djGl = !superAdmin && isDjiboutiGl(user); + + const permissionAllowed = (item: SidebarItem): boolean => { if (!item.permission) return true; const keys = Array.isArray(item.permission) ? item.permission @@ -448,6 +477,19 @@ const filterSidebarByPermission = ( return keys.some((key) => hasFreightPermission(user, key)); }; + const itemAllowed = (item: SidebarItem): boolean => { + if (superAdmin) return true; + + // GL positions are locked to their single clearance page. + if (etGl) return isEtClearanceItem(item); + if (djGl) return isDjClearanceItem(item); + + // Everyone else: hide the GL-only clearance pages entirely. + if (isClearanceItem(item)) return false; + + return permissionAllowed(item); + }; + return sections .map((section) => ({ ...section, @@ -469,6 +511,22 @@ const DashboardShell = () => { ); const displayName = user?.name?.en || user?.username || user?.email || "User"; + // GL positions are locked to their single clearance page: if they navigate + // (or deep-link) anywhere else, send them back to their clearance hub. + // Super admin is exempt. Allow the clearance path + its detail sub-routes. + const superAdmin = isSuperAdmin(user); + const glClearanceHome = !superAdmin + ? isEthiopianGl(user) + ? ET_CLEARANCE_HREF + : isDjiboutiGl(user) + ? DJ_CLEARANCE_HREF + : null + : null; + + if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) { + return ; + } + return ( (); + for (const emp of user.employee ?? []) { + for (const pos of emp.positions ?? []) { + if (pos.key) keys.add(pos.key); + } + } + return [...keys]; +} + +export function hasPosition( + user: AuthUser | null | undefined, + positionKey: string, +): boolean { + return getPositionKeys(user).includes(positionKey); +} + +export const POSITION_KEYS = { + ethiopianGl: "ethiopian_gl", + djiboutiGl: "djibouti_gl", +} as const; + +export function isEthiopianGl(user: AuthUser | null | undefined): boolean { + return hasPosition(user, POSITION_KEYS.ethiopianGl); +} + +export function isDjiboutiGl(user: AuthUser | null | undefined): boolean { + return hasPosition(user, POSITION_KEYS.djiboutiGl); +} + export function isSuperAdmin(user: AuthUser | null | undefined): boolean { if (user?.isSuperAdmin) return true; return Boolean(user?.roles?.some((r) => r.key === "super_admin")); diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index ff78046e1..ce2c35c1a 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -126,6 +126,8 @@ export const URL_CONSTANTS = { `/api/contracts/${id}/clearance/documents`, CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`, BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`, + VALIDATE_SHIPMENT: (id: string) => + `/api/contracts/${id}/validate-shipment`, MILESTONES: (id: string) => `/api/contracts/${id}/milestones`, BOOKING_MILESTONES: (bookingId: string) => `/api/contracts/bookings/${bookingId}/milestones`, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index b72244368..d245cd20c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -22,6 +22,7 @@ import { } from "@mantine/core"; import { AlertCircle, + AlertTriangle, CalendarDays, CheckCircle2, ChevronLeft, @@ -34,6 +35,7 @@ import { import type { Freight } from "@edr/types"; import { OperationDatePicker } from "@edr/ui-common"; import { api } from "@/services/api"; +import type { ShipmentValidation } from "@/services/contracts.service"; import { SelectField, StepCard, @@ -149,6 +151,8 @@ function NewShipmentBookingForm({ mode: "onChange", }); + const isContainerContract = contract.freightType === "CONTAINER"; + const submitMutation = useMutation({ mutationFn: (dto: Freight.CreateBookingUnderContractDto) => api.contracts.createBookingUnderContract.call({ id: contractId, dto }), @@ -161,6 +165,14 @@ function NewShipmentBookingForm({ }, }); + // Pre-submit validation (container contracts only): warns on overweight + // containers and HARD-BLOCKS on 20ft wagon-pairing errors. Runs each time the + // price modal opens so re-reviewing after an edit re-checks. + const validateMutation = useMutation({ + mutationFn: (dto: Freight.CreateBookingUnderContractDto) => + api.contracts.validateShipment.call({ id: contractId, dto }), + }); + function buildDto( values: ShipmentFormValues, ): Freight.CreateBookingUnderContractDto { @@ -207,13 +219,22 @@ function NewShipmentBookingForm({ }; } - // Submit validates the whole form, then opens the price modal for confirmation. + // Submit validates the whole form, then opens the price modal for + // confirmation. For container contracts we also run the server-side shipment + // validation (overweight warnings + 20ft pairing hard-blocks) so the modal + // can surface them before the booking is created. const handleReview = form.handleSubmit((values) => { setPendingValues(values); + if (isContainerContract) { + validateMutation.reset(); + validateMutation.mutate(buildDto(values)); + } }); const handleConfirm = () => { if (!pendingValues) return; + // Guard: never let a booking with unresolved 20ft pairing errors submit. + if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return; submitMutation.mutate(buildDto(pendingValues)); }; @@ -221,6 +242,7 @@ function NewShipmentBookingForm({ const handleReject = () => { if (submitMutation.isPending) return; setPendingValues(null); + validateMutation.reset(); }; const routes = contract.routes ?? []; @@ -323,6 +345,8 @@ function NewShipmentBookingForm({ contract={contract} values={pendingValues} loading={submitMutation.isPending} + validation={validateMutation.data ?? null} + validationLoading={validateMutation.isPending} onConfirm={handleConfirm} onReject={handleReject} /> @@ -334,12 +358,16 @@ function PriceConfirmModal({ contract, values, loading, + validation, + validationLoading, onConfirm, onReject, }: { contract: Freight.IContract; values: ShipmentFormValues | null; loading: boolean; + validation: ShipmentValidation | null; + validationLoading: boolean; onConfirm: () => void; onReject: () => void; }) { @@ -348,6 +376,11 @@ function PriceConfirmModal({ [contract, values], ); + const overweightLines = validation?.overweightLines ?? []; + const pairingErrors = validation?.pairingErrors ?? []; + const hasPairingBlock = pairingErrors.length > 0; + const confirmDisabled = loading || validationLoading || hasPairingBlock; + return ( {total ? ( + {validationLoading && ( + + + + Checking container weights and wagon pairing… + + + )} + + {hasPairingBlock && ( + } + title="Cannot create booking — 20ft wagon pairing" + > + + {pairingErrors.map((msg, i) => ( + + {msg} + + ))} + + Adjust the 20ft container weights or quantities so pairs differ + by no more than 10 tons. + + + + )} + + {overweightLines.length > 0 && ( + } + title="Overweight containers" + > + + {overweightLines.map((line, i) => ( + + {line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "} + {line.maxAllowedTons}t (+{line.excessTons}t overweight) + + ))} + + An overweight surcharge applies. You can still submit, or go + back and adjust weights. + + + + )} + {total.lines.map((line, i) => ( @@ -442,6 +529,7 @@ function PriceConfirmModal({ leftSection={} onClick={onConfirm} loading={loading} + disabled={confirmDisabled} > Confirm & book diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index a09bd4b4e..92256b989 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -24,6 +24,7 @@ import { ContractDocuments, GenerateContractPriceResponse, SubmitContractResponse, + ShipmentValidation, } from "./contracts.service"; import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema"; import { @@ -462,6 +463,13 @@ export const api = { contractsService.createBookingUnderContract(id, dto), ), + validateShipment: endpoint< + { id: string; dto: Freight.CreateBookingUnderContractDto }, + ShipmentValidation + >("contracts", "validateShipment", ({ id, dto }) => + contractsService.validateShipment(id, dto), + ), + getContractMilestones: endpoint< { id: string }, Freight.IClearanceMilestone[] diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index 600c14860..3af87ee42 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -33,6 +33,25 @@ export interface SubmitContractResponse { message?: string; } +/** A container line whose total VGM exceeds the weight-limit rule. */ +export interface OverweightLine { + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; +} + +/** + * Pre-submit validation for a shipment booking under a CONTAINER contract. + * `overweightLines` are WARNINGS only (an overweight surcharge applies — the + * customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers + * that cannot be balanced onto wagons) and must prevent booking. + */ +export interface ShipmentValidation { + overweightLines: OverweightLine[]; + pairingErrors: string[]; +} + export interface ContractListFilter { status?: string; statuses?: string; @@ -285,6 +304,20 @@ export const contractsService = { return data.data.booking ?? data.data; }, + /** + * Pre-submit validation of a shipment booking (same DTO as + * `createBookingUnderContract`). Returns overweight warnings and hard-block + * 20ft wagon-pairing errors so the customer can be warned/blocked before the + * booking is created. + */ + validateShipment: async ( + id: string, + dto: Freight.CreateBookingUnderContractDto, + ): Promise => { + const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto); + return data.data ?? data; + }, + // ── Milestones ── getContractMilestones: async ( id: string,