diff --git a/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts b/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts new file mode 100644 index 000000000..b1218a9b3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add a hard per-unit weight ceiling to weight limit rules. + * + * maxVgmTons stays the soft "overweight" threshold (surcharge + warning); + * max_capacity_tons is the absolute ceiling above which a booking cannot be + * created at all. Null means no ceiling (existing behavior). + */ +export class AddMaxCapacityToWeightLimitRules1930000000000 + implements MigrationInterface +{ + name = "AddMaxCapacityToWeightLimitRules1930000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.weight_limit_rules + ADD COLUMN IF NOT EXISTS max_capacity_tons numeric(8, 3); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.weight_limit_rules + DROP COLUMN IF EXISTS max_capacity_tons; + `); + } +} 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 e8469e627..d63106c2b 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 @@ -431,7 +431,7 @@ export class BookingPricingService { const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); - const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); + const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD'); @@ -571,6 +571,23 @@ export class BookingPricingService { return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } + /** + * Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate; + * an unsaved preview booking (no id) sums the wagonsRequired already computed + * on its in-memory container lines — same math, no DB row needed. + */ + private async resolveWagonCount(booking: Booking): Promise { + if (!booking.id) { + return Math.ceil( + (booking.bookingContainers ?? []).reduce( + (sum, bc) => sum + Number(bc.wagonsRequired ?? 0), + 0, + ), + ); + } + return this.bookingsRepository.calculateWagonCount(booking.id); + } + /** Friendly container-type label for the per-unit card; degrades to "Container". */ private async containerTypeLabel(containerTypeId: string): Promise { try { 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 8423e9606..35fc7fd54 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 @@ -1051,7 +1051,22 @@ export class BookingTransitionService { // paid → auto-allocated by the settle/paid pipeline. Consolidated bookings // only reserve once both partners are FULLY_EXECUTED (handled inside). const fresh = await this.bookingsService.findById(booking.id); - await this.bookingBatchService.acceptExportBooking(fresh); + try { + await this.bookingBatchService.acceptExportBooking(fresh); + } catch (err) { + // The status update above already committed. Without compensation the + // client gets an error for a booking that reads as accepted after a + // refresh — half-applied state. Put the request back so staff can retry. + await this.bookingsRepository.update(booking.id, { + status: "OPERATION_REQUEST_PENDING", + fullyExecutedAt: null, + lockedAt: booking.lockedAt ?? null, + } as never); + this.logger.warn( + `Export accept failed post-commit for ${booking.reference}:${booking.id}; reverted to OPERATION_REQUEST_PENDING: ${(err as Error).message}`, + ); + throw err; + } } // IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the // batch runs after the window closes + staff document review, never at accept @@ -1073,23 +1088,59 @@ export class BookingTransitionService { } | null; } > { - const note = await this.bookingsRepository.findLatestReviewNote( - booking.id, - "CHANGES_REQUESTED", - ); - const summary = - booking.contractSummary ?? - this.contractService.buildContractSummary(booking); - const nextPending = - booking.status === "PENDING_APPROVAL" || - booking.status === "APPROVED_PENDING_SIGNATURE" - ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) - : null; - const nextStep = computeNextStep(booking, nextPending); - const activeBatchOffer = - booking.status === "SELECTED_FOR_BATCH" - ? await this.bookingBatchService.getOpenOfferSummary(booking.id) - : null; + // This enrichment runs AFTER the transition has committed. A failure here + // must never 500 the response — the client would report "failed" for a + // transition that actually succeeded (visible only after a refresh). + // Degrade each fragile field to null instead. + let note: Awaited< + ReturnType + > = null; + try { + note = await this.bookingsRepository.findLatestReviewNote( + booking.id, + "CHANGES_REQUESTED", + ); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let summary: string | null = booking.contractSummary ?? null; + try { + summary = + booking.contractSummary ?? + this.contractService.buildContractSummary(booking); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: contract summary failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let nextStep: BookingNextStep | null = null; + try { + const nextPending = + booking.status === "PENDING_APPROVAL" || + booking.status === "APPROVED_PENDING_SIGNATURE" + ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) + : null; + nextStep = computeNextStep(booking, nextPending); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let activeBatchOffer: Awaited< + ReturnType + > = null; + try { + activeBatchOffer = + booking.status === "SELECTED_FOR_BATCH" + ? await this.bookingBatchService.getOpenOfferSummary(booking.id) + : null; + } catch (err) { + this.logger.warn( + `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } return { ...booking, latestChangeRequestNote: note?.note ?? null, 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 913565f70..4803f988c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -587,6 +587,10 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.serviceType', 'serviceType') .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') + .addSelect('contract.reference', 'contract_reference') .where('booking.deleted_at IS NULL'); this.applyListFilters(qb, options); @@ -605,10 +609,24 @@ export class BookingsRepository extends BaseRepository { qb.orderBy(sortField, options.sortOrder ?? 'DESC'); } - const [items, total] = await qb + const total = await qb.getCount(); + const { entities: items, raw } = await qb .skip((page - 1) * pageSize) .take(pageSize) - .getManyAndCount(); + .getRawAndEntities(); + + // The joined contract.reference comes back on the raw rows only (entity has no + // contract relation) — map it onto each booking by position. + const contractRefByBooking = new Map(); + for (const row of raw as Array<{ booking_id: string; contract_reference: string | null }>) { + if (row.booking_id && !contractRefByBooking.has(row.booking_id)) { + contractRefByBooking.set(row.booking_id, row.contract_reference ?? null); + } + } + for (const item of items) { + (item as Booking & { contractReference?: string | null }).contractReference = + contractRefByBooking.get(item.id) ?? null; + } if (items.length) { const links = await this.dataSource.getRepository(TrainScheduleBooking).find({ @@ -1059,7 +1077,9 @@ export class BookingsRepository extends BaseRepository { company: true, originYard: true, destinationYard: true, - bookingContainers: { containerType: true }, + // units carry the real per-container numbers entered at booking time — + // the wagon plan shows those instead of generated placeholders. + bookingContainers: { containerType: true, units: true }, cargoType: true, }, order: { priorityScore: 'DESC', createdAt: 'ASC' }, 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 01b35fc71..c1eb4b4f8 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 @@ -8,13 +8,13 @@ import { forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { ExchangeService } from '@edr/api-common'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; 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'; @@ -66,7 +66,6 @@ export class ContractBookingService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly dataSource: DataSource, - private readonly exchangeService: ExchangeService, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, ) {} @@ -134,6 +133,13 @@ export class ContractBookingService { }); } + // Hard capacity gate: a container line whose total weight exceeds the + // container type's max capacity can never be booked — no surcharge path, + // no override. Checked before any row is written. + if (freightType === 'CONTAINER') { + await this.assertWithinMaxCapacity(contract, dto); + } + // Denormalize route/direction/freight onto the booking for the scheduling engine. const booking = await this.bookingsRepository.create({ reference, @@ -587,11 +593,14 @@ 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. + * Pre-create validation + authoritative price preview for the shipment form: + * build an UNSAVED booking shaped exactly like {@link createUnderContract} + * would persist it and run the same BookingPricingService compute over it — + * base rail freight, first/last-mile trucking, and every rule-engine surcharge + * (overweight, hazard, reefer, consolidation, …). The portal and the GL + * backoffice form call this from the price-confirm modal, so the breakdown the + * user confirms is line-for-line what the booking will be charged. Also runs + * the 20ft weight-pairing rule, which hard-blocks creation. */ async validateShipment( contractId: string, @@ -606,22 +615,28 @@ export class ContractBookingService { overweightSurchargeAmount: number; currency: string | null; pairingErrors: string[]; + capacityErrors: string[]; + lineItems: PriceLineItemDto[]; + totalAmount: number; }> { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); const lines = dto.containers ?? []; - if (!lines.length) { + if (contract.freightType === 'CONTAINER' && !lines.length) { return { overweightLines: [], overweightSurchargeAmount: 0, currency: null, pairingErrors: [], + capacityErrors: [], + lineItems: [], + totalAmount: 0, }; } - // 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). + // Resolve each container line's type + total VGM (sum of unit weights) — + // mirrors persistContainers so the preview lines match the persisted ones. const resolved = await Promise.all( lines.map(async (line) => { const ct = await this.resolveContainerTypeForSize( @@ -636,46 +651,44 @@ export class ContractBookingService { }), ); - const ruleResult = await this.ruleEngineService.evaluate({ - freightType: 'CONTAINER', - cargoTypeId: null, - serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + // The unsaved twin of the booking createUnderContract would write: same + // denormalized contract fields, same container-line math. No id → the + // pricing service derives wagon counts from the in-memory lines. + const route = await this.resolveRoute(contract, dto.contractRouteId); + const previewBooking = Object.assign(new Booking(), { + freightType: contract.freightType, tradeDirection: contract.tradeDirection, - isHazardous: false, - isReefer: contract.isReefer ?? false, - isGovernment: false, - allowConsolidation: false, + paymentCurrency: contract.paymentCurrency, + serviceTypeId: contract.serviceTypeId, + cargoTypeId: this.resolveCargoTypeId(contract, dto), + isHazardous: contract.isHazardous, + isReefer: contract.isReefer, + isGovernment: contract.isGovernment, 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); + contractRouteId: route?.id ?? null, + cargoTotalWeightVgm: this.resolveBulkTons(dto), + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + bookingContainers: resolved.map(({ line, ct, totalVgmTons }) => + Object.assign(new BookingContainer(), { + containerTypeId: ct.id, + containerSize: line.containerSize, + quantity: line.quantity, + hazardousQuantity: line.hazardousQuantity ?? 0, + reeferQuantity: line.reeferQuantity ?? 0, + vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, + totalVgmTons, + wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), + }), + ), + }) as Booking; - 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, - }); - } + const computed = await this.bookingPricingService.computePriceForBooking(previewBooking); + + // The overweight surcharge line is already currency-converted; surface its + // amount separately so the warning alert can reference the exact charge. + const overweightSurchargeAmount = + computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0; // 20ft weight-pairing: gather every 20ft unit weight and check the pair rule. const twentyFtUnits = resolved @@ -691,30 +704,63 @@ export class ContractBookingService { (v) => v.message, ); - // Real overweight surcharge (same rate the rule engine bills at booking-create - // time) so the confirm-modal total isn't missing the charge the warning refers to. - // Rates are stored in USD; convert to the contract's payment currency the same - // way BookingPricingService does so this preview matches the eventual booking total. - const overweightModifier = ruleResult.appliedModifiers.find( - (m) => m.surchargeCode === 'OVERWEIGHT_PER_TON', + // Hard capacity ceiling — a non-empty result means the create call will be + // rejected, so the form can block submit up front. + const capacityErrors = await this.ruleEngineService.capacityViolations( + resolved.map(({ line, ct, totalVgmTons }) => ({ + containerTypeId: ct.id, + quantity: line.quantity, + totalVgmTons, + })), + contract.tradeDirection, ); - let overweightSurchargeAmount = 0; - if (overweightModifier) { - const isEtb = contract.paymentCurrency === 'ETB'; - const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - overweightSurchargeAmount = isEtb - ? Math.round(overweightModifier.calculatedAmount * usdToEtb) - : overweightModifier.calculatedAmount; - } return { - overweightLines, + overweightLines: computed.overweightLines, overweightSurchargeAmount, - currency: overweightLines.length ? contract.paymentCurrency : null, + currency: computed.currency, pairingErrors, + capacityErrors, + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, }; } + /** + * Throws when any container line's total weight exceeds the hard capacity + * ceiling of its weight limit rule. Mirrors validateShipment's line + * resolution so the gate matches what the form preview reported. + */ + private async assertWithinMaxCapacity( + contract: Contract, + dto: CreateBookingUnderContractDto, + ): Promise { + const lines = dto.containers ?? []; + if (!lines.length) return; + + const containers = 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 { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons }; + }), + ); + + const violations = await this.ruleEngineService.capacityViolations( + containers, + contract.tradeDirection, + ); + if (violations.length) { + throw new BadRequestException(violations.join('; ')); + } + } + private async max20ftPairDiffTons(): Promise { const row = await this.dataSource .getRepository(TrainSchedulingGlobalRules) 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 09e4a7ffd..06ac31d68 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -791,7 +791,7 @@ 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).', + 'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).', }) validateShipment( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts index eea223ae3..d60a56944 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -1,6 +1,6 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsUUID, Min } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; @@ -21,4 +21,15 @@ export class CreateWeightLimitRuleDto { @Min(0) @Transform(({ value }) => Number(value)) maxVgmTons!: number; + + @ApiPropertyOptional({ + description: + 'Hard per-unit weight ceiling in tons — above this the booking cannot be created. Null/omitted = no ceiling.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => (value === null || value === undefined || value === '' ? null : Number(value))) + maxCapacityTons?: number | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts index b6b87b285..7a30cc20d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts @@ -18,4 +18,12 @@ export class WeightLimitRule extends BaseEntity { @Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) maxVgmTons!: number; + + /** + * Absolute per-unit weight ceiling in tons. Weight above maxVgmTons but at or + * below this is "overweight" (surcharge + warning); weight above this hard- + * blocks booking creation entirely. Null = no ceiling (overweight only). + */ + @Column({ name: 'max_capacity_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) + maxCapacityTons!: number | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 4b082e5fd..0fee8e75a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -136,6 +136,10 @@ export class RuleEngineService { } } + hardBlocked.push( + ...(await this.capacityViolations(input.containers, input.tradeDirection)), + ); + for (const container of input.containers) { const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( container.containerTypeId, @@ -296,6 +300,40 @@ export class RuleEngineService { }; } + /** + * Messages for container lines whose total weight exceeds the hard capacity + * ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking + * must not be created at all. Overweight (above maxVgmTons but within + * capacity) is NOT reported here — that is a surcharge, not a block. + */ + async capacityViolations( + containers: Array<{ + containerTypeId: string; + quantity: number; + totalVgmTons: number; + }>, + tradeDirection: string, + ): Promise { + const violations: string[] = []; + for (const container of containers) { + const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( + container.containerTypeId, + tradeDirection, + ); + const rule = rules[0]; + if (!rule || rule.maxCapacityTons == null) continue; + const perUnit = Number(rule.maxCapacityTons); + const maxTotal = perUnit * container.quantity; + if (container.totalVgmTons > maxTotal) { + const label = rule.containerType?.code ?? container.containerTypeId; + violations.push( + `${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`, + ); + } + } + return violations; + } + /** * Ensure ITMLS default approval chains exist (container + bulk). Idempotent. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts index bbd042296..44f5332f2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts @@ -1,4 +1,10 @@ -import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; @@ -62,13 +68,31 @@ export class WeightLimitRulesService { } } + /** + * Capacity is the hard ceiling; the VGM limit is the soft overweight + * threshold. A ceiling below the threshold would make every overweight + * booking impossible to create, which is never what the operator means. + */ + private assertCapacityAboveVgmLimit( + maxVgmTons: number, + maxCapacityTons: number | null | undefined, + ): void { + if (maxCapacityTons != null && Number(maxCapacityTons) < Number(maxVgmTons)) { + throw new BadRequestException( + 'Max capacity must be greater than or equal to the max VGM limit.', + ); + } + } + /** Create a new weight limit rule. */ async create(dto: CreateWeightLimitRuleDto): Promise { await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection); + this.assertCapacityAboveVgmLimit(dto.maxVgmTons, dto.maxCapacityTons); return this.repository.create({ containerTypeId: dto.containerTypeId, tradeDirection: dto.tradeDirection, maxVgmTons: dto.maxVgmTons, + maxCapacityTons: dto.maxCapacityTons ?? null, }); } @@ -79,6 +103,12 @@ export class WeightLimitRulesService { if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId; if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection; if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons; + if (dto.maxCapacityTons !== undefined) patch.maxCapacityTons = dto.maxCapacityTons; + + this.assertCapacityAboveVgmLimit( + patch.maxVgmTons ?? Number(existing.maxVgmTons), + patch.maxCapacityTons !== undefined ? patch.maxCapacityTons : existing.maxCapacityTons, + ); // Re-check uniqueness when the identity (container/direction) changes. if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 22e322953..773e4738a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -83,6 +83,16 @@ export class TrainSchedulingController { return this.trainSchedulingService.getBookingWindowsForContract(contractId); } + @Get("booking-windows") + @TrainSchedulingView() + @ApiOperation({ + summary: + "All announced booking windows across lanes (import cycle + export FCFS), for staff dashboards", + }) + listBookingWindows() { + return this.trainSchedulingService.listAllBookingWindows(); + } + @Get("global-rules") @TrainSchedulingView() @ApiOperation({ summary: "Get global train scheduling rules (singleton)" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index bac226c0a..a10847c17 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -605,16 +605,22 @@ export class TrainSchedulingService { ); if (!validation.valid) { + // Put the violation detail in the message itself — global exception + // filters flatten the body, and "Booking validation failed" alone tells + // staff nothing (e.g. which wagon type is missing at the yard). throw new BadRequestException({ - message: 'Booking validation failed', + message: `Booking validation failed: ${validation.violations.join('; ')}`, violations: validation.violations, warnings: validation.warnings, }); } if (!validation.bookings.length) { + const shortfall = validation.deferredBookings + .map((d) => `${d.reference}: ${d.reason}`) + .join('; '); throw new BadRequestException({ - message: 'No bookings fit on available fleet wagons', + message: `No wagons available for the selected bookings${shortfall ? ` — ${shortfall}` : ''}`, violations: ['Insufficient fleet wagons for the selected bookings'], warnings: validation.warnings, deferredBookings: validation.deferredBookings, @@ -628,12 +634,14 @@ export class TrainSchedulingService { if (!limitLoco) { throw new BadRequestException('Schedule train set has no locomotives'); } - if (limitLoco.maxPullWeightTons < totalWeightTons) { + // forceAssign lets staff overload the locomotive set knowingly — the + // validator has already surfaced it as a warning in that case. + if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) { throw new BadRequestException( `Train set locomotives cannot pull ${totalWeightTons}T`, ); } - if (limitLoco.maxTrainLengthMeters < totalLengthMeters) { + if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) { throw new BadRequestException( `Train set locomotives cannot support ${totalLengthMeters}m`, ); @@ -2251,9 +2259,16 @@ export class TrainSchedulingService { max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, }; + // With forceAssign, capacity-shaped rules (train limits, total weight, + // locomotive capability) become warnings — staff owns the override. Physical + // impossibilities (no wagon of the required type at the yard, wrong route, + // wrong status) can never be forced and stay violations. + const pushLimit = (issues: string[]) => + forceAssign ? warnings.push(...issues) : violations.push(...issues); + if (resolvedMode === 'MIXED') { - violations.push( - ...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), + pushLimit( + validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), ); if (requireContainerPlacements) { const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); @@ -2270,7 +2285,7 @@ export class TrainSchedulingService { ); } } else { - violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits)); + pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits)); if (requireContainerPlacements && resolvedMode === 'CONTAINER') { violations.push( @@ -2293,8 +2308,8 @@ export class TrainSchedulingService { ); if (totalWeightTons > trainLimits.maxWeightTons) { const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; - if (!violations.includes(message)) { - violations.push(message); + if (!violations.includes(message) && !warnings.includes(message)) { + pushLimit([message]); } } @@ -2321,9 +2336,9 @@ export class TrainSchedulingService { (setLimits.maxPullWeightTons < totalWeightTons || setLimits.maxTrainLengthMeters < totalLengthMeters) ) { - violations.push( + pushLimit([ 'Assigned locomotives cannot support the total train weight and length', - ); + ]); } } else { const inServiceLocomotives = await this.locomotivesRepository.findAll({ @@ -2341,7 +2356,7 @@ export class TrainSchedulingService { Number(l.maxTrainLengthMeters) >= totalLengthMeters, ) ) { - violations.push('No locomotive can support the total train weight and length'); + pushLimit(['No locomotive can support the total train weight and length']); } } @@ -3220,6 +3235,50 @@ export class TrainSchedulingService { return rows.map((r) => this.mapBookingWindowRow(r)); } + /** + * All announced booking windows across every lane — import window cycles AND + * export FCFS lead windows — for staff dashboards (GL clearance queue). Same + * phase filter as the customer-facing lists, no contract scoping. + */ + async listAllBookingWindows() { + const rows: Array< + Omit & { + train_number: string | null; + } + > = await this.dataSource.query( + `SELECT ts.id AS schedule_id, + ts.train_number, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, + ts.booking_window_status, + ts.booking_cycle_no, + ts.scheduled_departure_date, + oy.label AS origin_label, oy.code AS origin_code, + dy.label AS destination_label, dy.code AS destination_code + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.window_phase IS NOT NULL + AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') + AND ts.scheduled_departure_date >= now() + ORDER BY ts.window_opens_at ASC NULLS LAST`, + ); + return rows.map((r) => ({ + ...this.mapBookingWindowRow({ + ...r, + contract_id: null, + contract_kind: null, + }), + trainNumber: r.train_number, + })); + } + private mapBookingWindowRow(r: BookingWindowRow) { return { scheduleId: r.schedule_id, @@ -3696,7 +3755,7 @@ export class TrainSchedulingService { if (!validation.valid) { throw new BadRequestException({ - message: 'Booking validation failed', + message: `Booking validation failed: ${validation.violations.join('; ')}`, violations: validation.violations, warnings: validation.warnings, }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 8c3199461..35d5ce185 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -210,7 +210,14 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); const perWagon = containersPerWagonFromType(wagonsPerUnit); const teuSlots = teuSlotsForSizeFt(sizeFt); + // The REAL per-container numbers/weights entered at booking time. Unit i of + // the line maps to units[i] (sortOrder order); the line-level number is only + // a legacy fallback — never invent numbers here. + const units = [...(line.units ?? [])].sort( + (a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0), + ); for (let i = 0; i < qty; i += 1) { + const unit = units[i]; rows.push({ bookingId: booking.id, bookingReference: booking.reference, @@ -219,12 +226,13 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR containerTypeId: line.containerTypeId ?? '', containerTypeCode: code, label: `${booking.reference} · ${i + 1}/${qty} · ${code}`, - grossWeightTons: Number(line.vgmPerUnitTons), + grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons), sizeFt, wagonsPerUnit, containersPerWagon: perWagon, teuSlots, - containerNumber: line.containerNumber ?? null, + containerNumber: + unit?.containerNumber?.trim() || line.containerNumber || null, }); } } 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 ef9b2453f..d967ffc3f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -4,7 +4,7 @@ import { useParams, useSearchParams, } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Alert, Box, @@ -25,6 +25,7 @@ import { } from "@mantine/core"; import { AlertCircle, + AlertTriangle, CalendarDays, CheckCircle2, ChevronLeft, @@ -365,8 +366,11 @@ export default function GlCreateBookingForm() { (!needsRouteSelect || Boolean(contractRouteId)) && (isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0); - const handleSubmit = () => { - if (!scheduledDate || !contract || !windowOpen) return; + /** The create-booking DTO from the current form state — shared by the + * authoritative price preview and the actual submit so what GL confirms is + * exactly what gets booked. */ + const buildPayload = (): Freight.CreateBookingUnderContractDto | null => { + if (!scheduledDate || !contract) return null; const payload: Freight.CreateBookingUnderContractDto = { scheduledDate, @@ -408,6 +412,59 @@ export default function GlCreateBookingForm() { })); } + return payload; + }; + + // Authoritative price preview (same pricing pass the booking persists at + // create): rail freight + first/last mile + overweight + every surcharge. + // Fired when the price modal opens; the modal falls back to the contract + // unit-rate estimate while it loads. + const validateShipmentMutation = useMutation({ + mutationFn: (dto: Freight.CreateBookingUnderContractDto) => + contractsService.validateShipment(id ?? "", dto), + }); + const validation = validateShipmentMutation.data ?? null; + + const serverTotal = useMemo(() => { + const items = validation?.lineItems; + if (!items?.length) return null; + return { + currency: validation?.currency ?? priceTotal?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [validation, priceTotal]); + + const displayTotal = serverTotal ?? priceTotal; + const pairingErrors = validation?.pairingErrors ?? []; + const capacityErrors = validation?.capacityErrors ?? []; + const overweightLines = validation?.overweightLines ?? []; + + const openPriceModal = () => { + setPriceOpen(true); + const payload = buildPayload(); + if (payload) { + validateShipmentMutation.reset(); + validateShipmentMutation.mutate(payload); + } + }; + + const handleSubmit = () => { + if (!contract || !windowOpen) return; + // Never book past unresolved 20ft pairing hard-blocks. + if (pairingErrors.length > 0) return; + // A line above the container type's max capacity can never book. + if (capacityErrors.length > 0) return; + const payload = buildPayload(); + if (!payload) return; + mutations.createBooking.mutate(payload, { onSuccess: async (booking) => { if (requestId) { @@ -819,7 +876,7 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} disabled={!canSubmit} - onClick={() => setPriceOpen(true)} + onClick={openPriceModal} > Review price & book @@ -850,11 +907,89 @@ export default function GlCreateBookingForm() { } > - {priceTotal ? ( + {displayTotal ? ( + {validateShipmentMutation.isPending && ( + + + + Computing the final price breakdown and checking container + weights… + + + )} + + {pairingErrors.length > 0 && ( + } + 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. + + + + )} + + {capacityErrors.length > 0 && ( + } + title="Cannot create booking — over maximum capacity" + > + + {capacityErrors.map((msg, i) => ( + + {msg} + + ))} + + Reduce the cargo weight or split it across more containers + to book this shipment. + + + + )} + + {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 (included in the total + below). + + + + )} + - {priceTotal.lines.map((line, i) => ( + {displayTotal.lines.map((line, i) => ( @@ -862,16 +997,16 @@ export default function GlCreateBookingForm() { {line.quantity.toLocaleString()} ×{" "} - {line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "} + {line.unitPrice.toLocaleString()} {displayTotal.currency} ·{" "} {formatRateUnit(line.unit)} - {line.amount.toLocaleString()} {priceTotal.currency} + {line.amount.toLocaleString()} {displayTotal.currency} ))} - {priceTotal.lines.length === 0 && ( + {displayTotal.lines.length === 0 && ( No priced lines — check the cargo details. @@ -889,9 +1024,9 @@ export default function GlCreateBookingForm() { Total - {priceTotal.total.toLocaleString()}{" "} + {displayTotal.total.toLocaleString()}{" "} - {priceTotal.currency} + {displayTotal.currency} @@ -912,6 +1047,11 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} loading={mutations.createBooking.isPending} + disabled={ + validateShipmentMutation.isPending || + pairingErrors.length > 0 || + capacityErrors.length > 0 + } onClick={handleSubmit} > Confirm & book diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index 9d9573909..e5e998721 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -1,14 +1,31 @@ -import { useMemo } from "react"; -import { Badge, Box, Card, Group, ScrollArea, Skeleton, Stack, Text } from "@mantine/core"; +import { useMemo, useState } from "react"; +import { + ActionIcon, + Badge, + Box, + Card, + Group, + SimpleGrid, + Skeleton, + Stack, + Text, +} from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; -import { ArrowRight, CalendarClock } from "lucide-react"; +import { + ArrowRight, + CalendarClock, + ChevronLeft, + ChevronRight, +} from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import { api } from "@/services/api"; -import type { BatchBoardSchedule } from "@/types/trainScheduling"; +import type { StaffBookingWindow } from "@/types/trainScheduling"; /** All window times are communicated in East Africa Time. */ const TZ = "Africa/Addis_Ababa"; +/** Cards visible per carousel page. */ +const PER_PAGE = 3; function fmtDay(iso: string): string { return new Date(iso).toLocaleDateString("en-GB", { @@ -28,7 +45,7 @@ function fmtTime(iso: string): string { }); } -function windowLabel(w: BatchBoardSchedule): string { +function windowLabel(w: StaffBookingWindow): string { if (w.windowOpensAt && w.windowClosesAt) { return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime( w.windowClosesAt, @@ -42,36 +59,33 @@ function windowLabel(w: BatchBoardSchedule): string { /** * The countdown for whichever phase the window is currently in, mirroring the - * customer portal. Phases run pre-window (opens at windowOpensAt) → open (closes - * at windowClosesAt) → document review (docReviewEndsAt) → payment - * (paymentPhaseEndsAt). `expiredText` names the NEXT step so a deadline that - * lapses between the 60s refetches announces what comes next rather than the - * bare word "Expired". Returns null when no phase is timing down. + * customer portal. `expiredText` names the NEXT step so a deadline that lapses + * between refetches announces what comes next rather than the bare "Expired". */ function phaseCountdown( - w: BatchBoardSchedule, + w: StaffBookingWindow, ): { label: string; deadline: string; expiredText: string } | null { switch (w.windowPhase) { case "PRE_WINDOW": return w.windowOpensAt ? { - label: "Booking opens in", + label: "Opens in", deadline: w.windowOpensAt, - expiredText: "Booking opening now…", + expiredText: "Opening now…", } : null; case "OPEN": return w.windowClosesAt ? { - label: "Window closes in", + label: "Closes in", deadline: w.windowClosesAt, - expiredText: "Document review starting…", + expiredText: "Review starting…", } : null; case "DOC_REVIEW": return w.docReviewEndsAt ? { - label: "Document review ends in", + label: "Doc review ends in", deadline: w.docReviewEndsAt, expiredText: "Payment starting…", } @@ -79,9 +93,9 @@ function phaseCountdown( case "PAYMENT": return w.paymentPhaseEndsAt ? { - label: "Payment window ends in", + label: "Payment ends in", deadline: w.paymentPhaseEndsAt, - expiredText: "Payment window closing…", + expiredText: "Closing…", } : null; default: @@ -89,15 +103,11 @@ function phaseCountdown( } } -function isOpenNow(w: BatchBoardSchedule): boolean { - return w.windowPhase === "OPEN" && w.bookingWindowStatus === "OPEN"; -} - /** Drop windows whose booking window (or the train itself) has already passed. */ -function isPast(w: BatchBoardSchedule): boolean { +function isPast(w: StaffBookingWindow): boolean { const now = Date.now(); const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null; - const departs = w.scheduleDate ? new Date(w.scheduleDate).getTime() : null; + const departs = w.departureDate ? new Date(w.departureDate).getTime() : null; // Still live while in a post-close staff phase (doc review / payment). if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false; if (departs != null && departs <= now) return true; @@ -105,16 +115,121 @@ function isPast(w: BatchBoardSchedule): boolean { return false; } +function WindowCard({ w }: { w: StaffBookingWindow }) { + const cd = phaseCountdown(w); + const open = w.isOpenNow; + const isImport = w.direction === "IMPORT"; + + return ( + + + + + {w.direction ? ( + + {isImport ? "Import" : "Export"} + + ) : ( + + )} + + {open + ? "Open now" + : (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")} + + + + + + {w.origin ?? "—"} + + + + {w.destination ?? "—"} + + + {w.trainNumber ? ( + + Train {w.trainNumber} + + ) : null} + + + + + {windowLabel(w)} + + + {w.departureDate ? ( + + Departs {fmtDay(w.departureDate)} + + ) : null} + + + {cd ? ( + + + + ) : null} + + + ); +} + /** - * Upcoming / open import booking windows across all train schedules, shown to GL - * ET on the clearance queue so they can see which lanes are accepting bookings - * (mirrors the customer's portal "Booking Windows" card). Hidden when nothing is - * pending. Windows already past close/departure are dropped. + * All announced booking windows (import cycles + export FCFS) across every lane, + * shown to GL ET on the clearance queue as a paged carousel — three lanes per + * page, arrows to flip. Mirrors the customer's portal "Booking Windows" card. + * Hidden when nothing is pending. */ export function GlUpcomingWindowsSection() { const { data, isLoading } = useQuery( - api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 60_000 }), + api.trainScheduling.allBookingWindows.queryOptions({ + refetchInterval: 60_000, + }), ); + const [page, setPage] = useState(0); const windows = useMemo(() => { const rows = (data ?? []).filter( @@ -122,7 +237,7 @@ export function GlUpcomingWindowsSection() { ); // Open lanes first, then by opening time. return rows.sort((a, b) => { - const openDiff = Number(isOpenNow(b)) - Number(isOpenNow(a)); + const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); if (openDiff !== 0) return openDiff; const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; @@ -130,120 +245,87 @@ export function GlUpcomingWindowsSection() { }); }, [data]); + const pageCount = Math.max(1, Math.ceil(windows.length / PER_PAGE)); + const safePage = Math.min(page, pageCount - 1); + const visible = windows.slice( + safePage * PER_PAGE, + safePage * PER_PAGE + PER_PAGE, + ); + if (!isLoading && windows.length === 0) return null; return ( - - - - - Booking windows - - - Upcoming and open import booking windows across all lanes (EAT) - - + + + + + + Booking windows + + + Import and export booking windows across all lanes (EAT) + + + + + {pageCount > 1 ? ( + + setPage((p) => Math.max(0, p - 1))} + > + + + + {Array.from({ length: pageCount }, (_, i) => ( + setPage(i)} + style={{ + width: i === safePage ? 18 : 7, + height: 7, + borderRadius: 999, + cursor: "pointer", + background: + i === safePage + ? "var(--mantine-color-edr-green-6)" + : "var(--mantine-color-gray-3)", + transition: "width 200ms ease, background 200ms ease", + }} + /> + ))} + + = pageCount - 1} + onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + > + + + + ) : null} {isLoading ? ( - - {[1, 2].map((i) => ( - + + {[1, 2, 3].map((i) => ( + ))} - + ) : ( - - - {windows.map((w) => { - const open = isOpenNow(w); - const cd = phaseCountdown(w); - return ( - - - - - {w.origin ?? "—"} - - - - {w.destination ?? "—"} - - {w.trainNumber ? ( - - · {w.trainNumber} - - ) : null} - - - {windowLabel(w)} - {w.scheduleDate ? ` · Departs ${fmtDay(w.scheduleDate)}` : ""} - - {cd ? ( - - - - ) : null} - - - - {w.direction ? ( - - {w.direction === "IMPORT" ? "Import" : "Export"} - - ) : null} - - {open - ? "Open now" - : w.windowPhase === "PRE_WINDOW" && w.windowOpensAt - ? `Opens ${fmtTime(w.windowOpensAt)} EAT` - : (w.windowPhase ?? w.bookingWindowStatus).replace( - /_/g, - " ", - )} - - - - ); - })} - - + + {visible.map((w) => ( + + ))} + )} ); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 4c2ccb57e..2a7689582 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { isAxiosError } from "axios"; import { Badge, Box, @@ -47,6 +48,18 @@ interface ScheduleWorkspacePanelProps { const GREEN = "var(--mantine-color-edr-green-6)"; +/** Pull the API's violation detail out of an error (e.g. "No CW3 wagon available…"). */ +function apiErrorMessage(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const data = error.response?.data as Record | undefined; + const violations = data?.violations; + if (Array.isArray(violations) && violations.length) return violations.join(", "); + if (typeof data?.message === "string") return data.message; + if (Array.isArray(data?.message)) return (data.message as string[]).join(", "); + } + return fallback; +} + /** * Deadline + label for the window phase this schedule is currently in. * Phases run: window open (windowClosesAt) → document review (docReviewEndsAt) @@ -198,8 +211,12 @@ export function ScheduleWorkspacePanel({ onChanged(); void poolQuery.refetch(); }) - .catch(() => - toast({ title: "Could not add booking", variant: "destructive" }), + .catch((error) => + toast({ + title: "Could not add booking", + description: apiErrorMessage(error, "Validation failed — check capacity and status."), + variant: "destructive", + }), ); }; @@ -211,8 +228,12 @@ export function ScheduleWorkspacePanel({ onChanged(); void poolQuery.refetch(); }) - .catch(() => - toast({ title: "Could not remove booking", variant: "destructive" }), + .catch((error) => + toast({ + title: "Could not remove booking", + description: apiErrorMessage(error, "Please try again."), + variant: "destructive", + }), ); }; @@ -226,8 +247,12 @@ export function ScheduleWorkspacePanel({ onChanged(); void poolQuery.refetch(); }) - .catch(() => - toast({ title: "Could not reassign booking", variant: "destructive" }), + .catch((error) => + toast({ + title: "Could not reassign booking", + description: apiErrorMessage(error, "Target train may be closed or full."), + variant: "destructive", + }), ); }; diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 351c95e7e..d0f09f508 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -201,6 +201,7 @@ export const URL_CONSTANTS = { CLEARANCE_HISTORY: "/contracts/clearance/history", OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history", BOOKINGS: (id: string) => `/contracts/${id}/bookings`, + VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`, CAPACITY: (id: string) => `/contracts/${id}/capacity`, // Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking. BOOKING_REQUEST_QUEUE: "/contracts/booking-requests/queue", @@ -295,6 +296,7 @@ export const URL_CONSTANTS = { MOVE_BOOKING_SCHEDULE: (bookingId: string) => `/train-scheduling/bookings/${bookingId}/move-schedule`, GLOBAL_RULES: "/train-scheduling/global-rules", + BOOKING_WINDOWS: "/train-scheduling/booking-windows", PREVIEW: "/train-scheduling/preview", ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`, diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts index ec4708868..a5bc34170 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts @@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow { return { id: booking.id, reference: booking.reference, + contractReference: booking.contractReference ?? null, approvalSteps: booking.approvalSteps, customerLabel: booking.isGovernment ? (booking.governmentInstitution ?? "Government") diff --git a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts index 8ea6f6f27..1dc2b13ef 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts @@ -78,7 +78,13 @@ export function useBookingMutations(bookingId: string) { note?: string; }) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }), onSuccess: (data) => onSuccess(data, "Operation request reviewed"), - onError: () => toast.error("Failed to review operation request"), + onError: (error) => { + toast.error(parseApiError(error, "Failed to review operation request")); + // The transition may have committed even when the response errored (e.g. + // a post-accept step failed). Refetch so the UI shows the true state + // instead of requiring a manual refresh. + void invalidateBookingDetail(qc, bookingId); + }, }); const approveStep = useMutation({ diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index e45d64c50..477900ee1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -149,7 +149,8 @@ export default function BookingRequestsPage() { return items.filter( (b) => b.reference.toLowerCase().includes(q) || - b.customerLabel.toLowerCase().includes(q), + b.customerLabel.toLowerCase().includes(q) || + (b.contractReference?.toLowerCase().includes(q) ?? false), ); }, [data?.items, query]); @@ -196,6 +197,22 @@ export default function BookingRequestsPage() { ); }, }, + { + id: "contract", + header: () => Contract, + cell: ({ row }) => { + const ref = row.original.contractReference; + return ( +
+ {ref ? ( + {ref} + ) : ( + + )} +
+ ); + }, + }, { id: "route", header: () => Route, @@ -373,7 +390,7 @@ export default function BookingRequestsPage() { } value={query} onChange={(e) => setQuery(e.target.value)} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 99260946f..297d67050 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -341,6 +341,10 @@ const RuleEngineResourcePage = () => { } else if (config.slug === "priority-configs") { // Label is required by the backend but hidden in the UI for now. payload = { ...values, label: String(Date.now()) }; + } else if (config.slug === "weight-limit-rules") { + // Empty max capacity means "no ceiling" — send null explicitly so an + // edit can clear a previously-set ceiling (omitting the key keeps it). + payload = { ...values, maxCapacityTons: values.maxCapacityTons ?? null }; } if (editing?.id) { diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index ff4a15868..faa26036b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -374,6 +374,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ }, { id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" }, { id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" }, + { + id: "maxCapacityTons", + header: "Max capacity (t)", + accessorKey: "maxCapacityTons", + format: "number", + }, ], formFields: [ { @@ -391,6 +397,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ options: TRADE_DIRECTIONS, }, { name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true }, + { + name: "maxCapacityTons", + label: "Max capacity (tons)", + type: "number", + optional: true, + description: + "Hard ceiling — a booking whose line weight exceeds this cannot be created at all. Leave empty for no ceiling (overweight surcharge only).", + }, ], }, { diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 91685ef8a..7c6746ec7 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -54,6 +54,7 @@ import type { LocomotiveRecord, PinWagonsPayload, RecordCheckpointPayload, + StaffBookingWindow, TrainScheduleDetail, TrainScheduleFilters, TrainScheduleListItem, @@ -223,6 +224,13 @@ export const api = { () => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), ), + allBookingWindows: endpoint( + "train-scheduling", + "all-booking-windows", + () => trainSchedulingService.getAllBookingWindows(), + () => ["train-scheduling", "all-booking-windows"], + ), + batchBoardDetail: endpoint< { scheduleId: string }, BatchBoardScheduleDetail diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 369e53ea8..8860df62a 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -27,6 +27,41 @@ export interface PaginatedContracts { total: number; } +/** One line of the server-priced booking breakdown (mirrors PriceLineItemDto). */ +export interface ShipmentPriceLine { + code: string; + description: string; + amount: number; + unitAmount: number; + /** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */ + unit: string; + quantity: number; + currency: string; +} + +/** + * Pre-create validation + authoritative price preview for a booking under a + * contract. `lineItems`/`totalAmount` are the full server-computed breakdown — + * the same pricing pass the booking persists at create (rail freight, + * first/last mile, overweight and every other surcharge). `pairingErrors` and + * `capacityErrors` are HARD BLOCKS; `overweightLines` are warnings. + */ +export interface ShipmentValidation { + overweightLines: Array<{ + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; + }>; + overweightSurchargeAmount: number; + currency: string | null; + pairingErrors: string[]; + /** Lines above the container type's hard max capacity — booking cannot be created. */ + capacityErrors?: string[]; + lineItems?: ShipmentPriceLine[]; + totalAmount?: number; +} + export interface ContractListSummaryMetrics { inQueue: number; needsAction: number; @@ -471,6 +506,18 @@ export const contractsService = { payload: Freight.CreateBookingUnderContractDto, ) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload), + /** + * Pre-create validation + authoritative price preview: the same + * BookingPricingService pass that prices the booking on create (rail + + * first/last mile + every surcharge), plus overweight warnings and 20ft + * pairing hard-blocks. Shown in the GL price-confirm modal. + */ + validateShipment: ( + id: string, + payload: Freight.CreateBookingUnderContractDto, + ) => + postContract(C.VALIDATE_SHIPMENT(id), payload), + /** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */ getCapacity: async (id: string): Promise => { const response = await client.get(C.CAPACITY(id)); diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 8ec6a5783..aa178d4b7 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -21,6 +21,7 @@ import type { LocomotiveRecord, PinWagonsPayload, RecordCheckpointPayload, + StaffBookingWindow, TrainScheduleDetail, TrainScheduleFilters, TrainScheduleListItem, @@ -543,6 +544,13 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getAllBookingWindows: async (): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WINDOWS, + ); + return unwrap(response.data); + }, + updateGlobalRules: async ( payload: Partial>, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 92c9116a8..71d9fd793 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -191,6 +191,9 @@ export interface BookingDetail { customsClearingEnabled?: boolean; customsClearingAgent?: string | null; contractKind?: "ONE_TIME" | "GENERAL" | null; + contractId?: string | null; + /** Reference of the contract this booking was created under (list column + search). */ + contractReference?: string | null; contractSummary?: string | null; latestChangeRequestNote?: string | null; nextStep?: BookingNextStep | null; @@ -218,6 +221,7 @@ export interface BookingDetail { export interface BookingListRow { id: string; reference: string; + contractReference?: string | null; customerLabel: string; approvalSteps?: BookingApprovalStep[]; status: BookingStatus; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 3316117f7..d629311ee 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -226,6 +226,27 @@ export interface BatchBoardBooking { state: BatchBoardBookingState; } +/** + * An announced booking window on any lane (import cycle or export FCFS), for + * staff dashboards. Mirrors the customer portal's MyBookingWindow. + */ +export interface StaffBookingWindow { + scheduleId: string; + trainNumber: string | null; + direction: "IMPORT" | "EXPORT" | null; + windowPhase: BookingWindowPhase | string | null; + isOpenNow: boolean; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingWindowStatus: string; + bookingCycleNo: number; + departureDate: string; + origin: string | null; + destination: string | null; +} + export interface BatchBoardSchedule { scheduleId: string; trainNumber: string | null; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx index 300ff643f..6dcece7ef 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx @@ -1,7 +1,11 @@ -import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core"; -import { memo } from "react"; -import { useNavigate } from "react-router-dom"; -import { ArrowRight, CalendarClock, PackagePlus } from "lucide-react"; +import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core"; +import { memo, useMemo, useState } from "react"; +import { + ArrowRight, + CalendarClock, + ChevronLeft, + ChevronRight, +} from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import type { MyBookingWindow } from "@/services/bookings.service"; import { Card } from "./Card"; @@ -175,11 +179,32 @@ interface UpcomingWindowsSectionProps { * lane the customer has an active contract for carry a "Book now" action; * others route to the contract list. Hidden entirely when nothing is announced. */ +/** Rows shown per carousel page. */ +const PER_PAGE = 3; + export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ windows, isLoading, }: UpcomingWindowsSectionProps) { - const navigate = useNavigate(); + const [page, setPage] = useState(0); + + // Open lanes first, then by opening time — the ones the customer can act on + // lead the carousel. + const sorted = useMemo( + () => + [...windows].sort((a, b) => { + const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); + if (openDiff !== 0) return openDiff; + const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; + const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; + return at - bt; + }), + [windows], + ); + + const pageCount = Math.max(1, Math.ceil(sorted.length / PER_PAGE)); + const safePage = Math.min(page, pageCount - 1); + const visible = sorted.slice(safePage * PER_PAGE, safePage * PER_PAGE + PER_PAGE); // Nothing upcoming — keep the dashboard uncluttered. if (!isLoading && windows.length === 0) return null; @@ -195,17 +220,58 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ Upcoming and open booking windows across all lanes + + {pageCount > 1 ? ( + + setPage((p) => Math.max(0, p - 1))} + > + + + + {Array.from({ length: pageCount }, (_, i) => ( + setPage(i)} + style={{ + width: i === safePage ? 18 : 7, + height: 7, + borderRadius: 999, + cursor: "pointer", + background: i === safePage ? "#0A6F4D" : "#D8E2EB", + transition: "width 200ms ease, background 200ms ease", + }} + /> + ))} + + = pageCount - 1} + onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + > + + + + ) : null} {isLoading ? ( - {[1, 2].map((i) => ( + {[1, 2, 3].map((i) => ( ))} ) : ( - - {windows.map((w) => ( + + {visible.map((w) => ( + {/* Windows are informational here — booking is done from the + contract page while a window is open, not via a home CTA. */} - {/* ONE_TIME contracts book via their own single-shipment flow, - not window drawdown — show the window + countdown but no - "Book now" entry. */} - {w.isOpenNow && w.contractKind !== "ONE_TIME" && ( - - )} ))} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 80ed05eb0..afcd2f777 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -524,11 +524,10 @@ export default function NewBookingPage() { } : { customsClearingEnabled: false }), ...(cargoFreeText ? { cargoFreeText } : {}), - // Multi-route general contracts: routes are pure origin→destination lanes - // the contract covers — they carry NO quantity. Route #1 is the primary - // origin/destination; the rest come from the extra-routes step. The - // contracted quantity lives in a single shared pool (the container - // quantities / bulk total), drawn down per order against a chosen lane. + // A general contract covers exactly ONE route — the same single + // origin→destination pair as a one-time booking (multi-route on bookings + // was dropped). The contracted quantity lives in a single shared pool + // (container quantities / bulk total), drawn down per order. ...(isContract ? { routes: [ @@ -536,12 +535,6 @@ export default function NewBookingPage() { originYardId: data.originYard, destinationYardId: data.destinationYard, }, - ...(data.extraRoutes ?? []) - .filter((r) => r.originYard && r.destinationYard) - .map((r) => ({ - originYardId: r.originYard, - destinationYardId: r.destinationYard, - })), ], } : {}), diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 2b902bdc8..b137635ae 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -144,10 +144,9 @@ export const bookingFormSchema = z // The contracted quantity now comes from the cargo step (cargoWeight), the // same as a one-time booking, so per-route quantity is no longer entered. primaryRouteQuantity: z.string().default(""), - // Additional routes for a GENERAL contract (the primary origin/destination - // above is route #1). Each route is just an (origin, destination) pair — - // identical to the one-time route — so a contract can cover several routes. - // Ignored for one-time bookings. quantity/km kept for payload back-compat. + // LEGACY — multi-route general contracts were dropped; a contract booking + // now covers exactly one route, like a one-time booking. Field retained only + // so previously saved drafts still hydrate; never collected or sent anymore. extraRoutes: z .array( z.object({ @@ -434,7 +433,6 @@ export const stepFields: Record>> = { "originYard", "destinationYard", "primaryRouteQuantity", - "extraRoutes", // Estimated shipment date now lives in the Route step (one-time bookings only). "scheduledDate", ], diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 783162a35..15b2c5a8e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -1,26 +1,9 @@ import type { Freight } from "@edr/types"; -import { - Box, - Button, - Group, - Skeleton, - Stack, - Text, -} from "@mantine/core"; +import { Box, Skeleton, Stack } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; -import { - CalendarDays, - MapPin, - Plus, - Route as RouteIcon, - Trash2, -} from "lucide-react"; +import { CalendarDays, MapPin, Route as RouteIcon } from "lucide-react"; import { useCallback, useEffect, useMemo } from "react"; -import { - Controller, - useFieldArray, - type UseFormReturn, -} from "react-hook-form"; +import { Controller, type UseFormReturn } from "react-hook-form"; import { BookingFormInputValues, type BookingFormValues, @@ -70,16 +53,6 @@ export function Step4Route({ } }, [operationType]); - const { - fields: extraRoutes, - append: appendRoute, - remove: removeRoute, - } = useFieldArray({ control: form.control, name: "extraRoutes" }); - - // useFieldArray's `fields` don't re-render on value change, so watch the live - // route values to filter each row's yard options by what it has selected. - const watchedExtraRoutes = form.watch("extraRoutes") ?? []; - const yardOptions = useMemo(() => { if (!referenceData?.yard) return []; return referenceData.yard.map((y) => ({ value: y.id, label: y.name })); @@ -129,25 +102,6 @@ export function Step4Route({ } }, [destinationCountry, dest, form]); - // Same cleanup for the extra contract routes: when the operation type changes, - // clear any extra-route yard whose country no longer matches the required side - // so an added route can't contradict the operation either. - useEffect(() => { - watchedExtraRoutes.forEach((route, i) => { - const ro = referenceData?.yard.find((y) => y.id === route?.originYard); - if (originCountry && ro && ro.country !== originCountry) { - form.setValue(`extraRoutes.${i}.originYard`, ""); - } - const rd = referenceData?.yard.find( - (y) => y.id === route?.destinationYard, - ); - if (destinationCountry && rd && rd.country !== destinationCountry) { - form.setValue(`extraRoutes.${i}.destinationYard`, ""); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [originCountry, destinationCountry, referenceData, form]); - const directionStyle: Record = { EXPORT: "bg-sky-50 text-sky-800 border-sky-200", IMPORT: "bg-amber-50 text-amber-800 border-amber-200", @@ -161,11 +115,10 @@ export function Step4Route({ const stationSelectDisabled = yardOptions.length === 0; - // A general contract can cover several routes, but each route is just an - // (origin, destination) pair — the same shape as the one-time route. The - // contracted quantity comes from the cargo step, so no per-route quantity or - // distance is collected here. Cargo handling (hazardous / refrigerated) also - // lives in the Cargo step now, not here. + // A general contract covers exactly ONE route — the same single + // origin/destination pair as a one-time booking. (Multi-route contracts were + // dropped; the multi-lane concept lives on the contracts module, not on + // bookings.) The contracted quantity comes from the cargo step. // Earliest selectable shipment date (today, local) for the date input's `min`. const todayISODate = useMemo(() => { @@ -256,103 +209,6 @@ export function Step4Route({ )} - {isGeneralContract && !isLoading && ( - - - Additional contract routes - - - - A general contract can cover several routes. The route above is your - primary route; add more origin–destination routes the contract should - cover. - - - {extraRoutes.map((rf, i) => { - // Each extra route is constrained by the SAME operation type as the - // primary route: its origin must sit in originCountry and its - // destination in destinationCountry. Watch this row's current values - // so each side also excludes the yard picked on the other side. - const rowOrigin = watchedExtraRoutes[i]?.originYard ?? ""; - const rowDestination = - watchedExtraRoutes[i]?.destinationYard ?? ""; - const rowOriginData = yardsForSide(originCountry, rowDestination); - const rowDestData = yardsForSide(destinationCountry, rowOrigin); - return ( - - - ( - - )} - /> - - - ( - - )} - /> - - - - ); - })} - - - )} - ); } 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 b2542881a..a2bc985c0 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -218,8 +218,6 @@ function NewShipmentBookingForm({ mode: "onChange", }); - const isContainerContract = contract.freightType === "CONTAINER"; - const submitMutation = useMutation({ mutationFn: (dto: Freight.CreateBookingUnderContractDto) => api.contracts.createBookingUnderContract.call({ id: contractId, dto }), @@ -287,21 +285,22 @@ function NewShipmentBookingForm({ } // 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. + // confirmation. The server-side shipment validation also returns the + // authoritative price breakdown (rail + first/last mile + every surcharge) — + // run it for every freight type; container contracts additionally get + // overweight warnings + 20ft pairing hard-blocks surfaced in the modal. const handleReview = form.handleSubmit((values) => { setPendingValues(values); - if (isContainerContract) { - validateMutation.reset(); - validateMutation.mutate(buildDto(values)); - } + 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; + // Guard: a line above the container type's max capacity can never book. + if ((validateMutation.data?.capacityErrors?.length ?? 0) > 0) return; submitMutation.mutate(buildDto(pendingValues)); }; @@ -447,14 +446,37 @@ function PriceConfirmModal({ const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0; const pairingErrors = validation?.pairingErrors ?? []; const hasPairingBlock = pairingErrors.length > 0; - const confirmDisabled = loading || validationLoading || hasPairingBlock; + const capacityErrors = validation?.capacityErrors ?? []; + const hasCapacityBlock = capacityErrors.length > 0; + const confirmDisabled = + loading || validationLoading || hasPairingBlock || hasCapacityBlock; - // The contract's frozen unit rates (computeShipmentTotal) don't carry an - // overweight line — that surcharge only exists in the live rule engine. Fold - // the real amount from validateShipment into the displayed total so the - // customer sees the actual charge the overweight warning refers to, not just - // the warning text. + // Authoritative server breakdown — the SAME BookingPricingService pass that + // prices the booking on create, so it carries every line the booking will be + // charged: rail freight, first/last mile trucking, overweight, hazard/reefer + // and any other rule-engine surcharge. + const serverTotal = useMemo(() => { + const items = validation?.lineItems; + if (!items?.length) return null; + return { + currency: validation?.currency ?? baseTotal?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [validation, baseTotal]); + + // Fallback while the server preview loads: the contract's frozen unit rates + // (container/bulk + hazard/reefer only) with the overweight surcharge folded + // in. Replaced by the full server breakdown the moment it arrives. const total = useMemo(() => { + if (serverTotal) return serverTotal; if (!baseTotal) return null; if (!(overweightSurchargeAmount > 0)) return baseTotal; return { @@ -471,7 +493,7 @@ function PriceConfirmModal({ ], total: baseTotal.total + overweightSurchargeAmount, }; - }, [baseTotal, overweightSurchargeAmount]); + }, [serverTotal, baseTotal, overweightSurchargeAmount]); return ( - Checking container weights and wagon pairing… + Computing the final price breakdown and checking container + weights… )} @@ -532,6 +555,28 @@ function PriceConfirmModal({ )} + {hasCapacityBlock && ( + } + title="Cannot create booking — over maximum capacity" + > + + {capacityErrors.map((msg, i) => ( + + {msg} + + ))} + + Reduce the cargo weight or split it across more containers to + book this shipment. + + + + )} + {overweightLines.length > 0 && (