From 7b7e7c3f620c0bc1d6cc966bd36e75ba7f368c0c Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 17 Jul 2026 09:16:35 +0000 Subject: [PATCH] fix schule issue and contianer type issue --- ...290000000000-DropContainerWagonsPerUnit.ts | 29 +++++ .../bookings/booking-pricing.service.ts | 8 +- .../booking-reference-data.service.ts | 1 - .../modules/bookings/bookings.repository.ts | 8 +- .../src/modules/bookings/bookings.service.ts | 3 +- .../modules/bookings/consolidation.service.ts | 10 +- .../dto/booking-reference-data.dto.ts | 3 - .../contracts/contract-booking.service.ts | 7 +- .../rule-engine/container-type.util.ts | 15 +++ .../priority-configs.controller.ts | 3 +- .../dto/create-container-type.dto.ts | 9 +- .../entities/container-type.entity.ts | 3 - .../services/container-types.service.ts | 1 - .../services/priority-configs.range.spec.ts | 46 +++---- .../services/priority-configs.service.ts | 41 ++---- .../booking-batch.service.spec.ts | 109 +++++++++++++++- .../train-scheduling/booking-batch.service.ts | 118 +++++++++++++++--- .../train-scheduling/fleet-plan.util.ts | 2 +- .../train-scheduling.service.ts | 51 +++++++- .../wagon-plan-flex.util.spec.ts | 1 - .../train-scheduling/wagon-plan.util.spec.ts | 6 +- .../train-scheduling/wagon-plan.util.ts | 33 ++--- .../scripts/seed-gate-pass-train-scenarios.ts | 1 - .../seed-negad-indode-arrived-train.ts | 1 - .../seed-warehouse-export-receive-ready.ts | 3 +- ...ved-first-lastmile-demo-bookings.seeder.ts | 4 +- .../src/seed/demo-bookings.seeder.ts | 4 +- .../paid-import-export-mile-demo.seeder.ts | 4 +- .../src/seed/pricing-data.seeder.ts | 4 - apps/edr-freight-web/backoffice/src/App.tsx | 24 ++-- .../trainScheduling/AdjustConsistModal.tsx | 90 ++++++++++++- .../containerPlacement.util.spec.ts | 2 - .../src/pages/bookings/NewBookingPage.tsx | 1 - .../src/pages/ruleEngine/config/resources.ts | 2 +- .../src/pages/ruleEngine/priorityRuleRange.ts | 26 ++-- .../backoffice/src/services/api.ts | 3 +- .../src/services/trainBuilder.service.ts | 17 ++- .../backoffice/src/types/trainScheduling.ts | 1 - packages/types/src/freight/index.ts | 1 - 39 files changed, 508 insertions(+), 187 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts diff --git a/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts new file mode 100644 index 000000000..ed66c37d3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * container_types.wagons_per_unit is no longer stored: the wagon fraction is + * derived from size_ft everywhere (40ft = 1.00 wagon, 20ft = 0.50 — two per + * wagon; see rule-engine/container-type.util.ts). The stored value duplicated + * that rule and could silently drift from it. + */ +export class DropContainerWagonsPerUnit2290000000000 implements MigrationInterface { + name = 'DropContainerWagonsPerUnit2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD COLUMN IF NOT EXISTS wagons_per_unit numeric(4,2); + `); + // Backfill from the same size rule the code now derives from. + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = CASE WHEN size_ft >= 40 THEN 1.00 ELSE 0.50 END; + `); + } +} 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 091232611..9a1f127e8 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 @@ -10,11 +10,9 @@ import { BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; +import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import { BookingsRepository } from './bookings.repository'; -import { - containersPerWagon, - wagonRemainder, -} from './consolidation.service'; +import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; @@ -308,7 +306,7 @@ export class BookingPricingService { totalVgmTons: qty * vgm, isReefer: ct.isReefer, }, - perWagon: containersPerWagon(Number(ct.wagonsPerUnit)), + perWagon: containersPerWagonForSize(ct.sizeFt), quantity: qty, }; }), diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 6e96dc6f8..a978fd22b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -110,7 +110,6 @@ export function groupContainersBySize( name: ct.label?.trim() ? ct.label : ct.code, code: ct.code, is_reefer: ct.isReefer ?? false, - wagons_per_unit: Number(ct.wagonsPerUnit ?? 1), }), ), })); 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 72211925f..aeae7d10a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; @@ -149,7 +150,7 @@ export class BookingsRepository extends BaseRepository { for (const item of containers) { const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } }); - const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; + const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt); const totalVgm = item.quantity * item.vgmPerUnitTons; const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); // A per-line breakdown can never exceed the line's own quantity. @@ -179,7 +180,10 @@ export class BookingsRepository extends BaseRepository { async calculateWagonCount(bookingId: string): Promise { const result = await this.dataSource .createQueryBuilder() - .select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total') + .select( + 'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))', + 'total', + ) .from(BookingContainer, 'bc') .innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id') .where('bc.booking_id = :bookingId', { bookingId }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 588e4f1ed..d9dd53f94 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -18,6 +18,7 @@ import { TrainSchedulingService } from '../train-scheduling/train-scheduling.ser import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { BookingEvaluationInput, @@ -438,7 +439,7 @@ export class BookingsService { vgmPerUnitTons: c.vgmPerUnitTons, totalVgmTons, isReefer: ct.isReefer, - wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1), + wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt), }; }), ); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts index e16b97997..9a87054e2 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { Booking } from './entities/booking.entity'; @@ -19,13 +20,6 @@ export interface ConsolidationAttemptResult { messages: string[]; } -/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */ -export function containersPerWagon(wagonsPerUnit: number): number { - const wpu = Number(wagonsPerUnit); - if (!wpu || wpu <= 0) return 1; - return Math.max(1, Math.round(1 / wpu)); -} - export function wagonRemainder(quantity: number, perWagon: number): number { const r = quantity % perWagon; return r; @@ -73,7 +67,7 @@ export class ConsolidationService { const slots: ConsolidationSlot[] = []; for (const [containerTypeId, quantity] of quantityByType) { const ct = await this.containerTypesService.findById(containerTypeId); - const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); + const perWagon = containersPerWagonForSize(ct.sizeFt); const remainder = wagonRemainder(quantity, perWagon); if (remainder === 0) continue; slots.push({ diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index c930d7aa1..a793cc558 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -27,9 +27,6 @@ export class BookingReferenceContainerTypeDto { @ApiProperty() is_reefer!: boolean; - - @ApiProperty({ example: 0.5, description: 'Wagon fraction per container' }) - wagons_per_unit!: number; } export class BookingReferenceContainerSizeGroupDto { 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 a0c2b42a4..826bc33b0 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 @@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; 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'; @@ -1053,7 +1054,7 @@ export class ContractBookingService { bc.quantity = line.quantity; bc.containerTypeId = ct.id; bc.containerType = ct; - bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)); + bc.wagonsRequired = Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)); bc.totalVgmTons = (line.units ?? []).reduce( (sum, u) => sum + Number(u.vgmTons ?? 0), 0, @@ -1513,7 +1514,7 @@ export class ContractBookingService { : 0, vgmPerUnitTons: vgmPerUnit, totalVgmTons: totalVgm, - wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)), isOverweight: false, overweightExcessTons: null, } as Partial), @@ -1651,7 +1652,7 @@ export class ContractBookingService { : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, totalVgmTons, - wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)), }), ), }) as Booking; diff --git a/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts new file mode 100644 index 000000000..ed64c1edb --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts @@ -0,0 +1,15 @@ +/** + * Wagon fraction one container occupies, derived from its size: 40ft = 1 wagon, + * 20ft = 0.5 (two per wagon). Unknown size reads as a whole wagon so counts + * never under-book. + */ +export function wagonsPerUnitForSize(sizeFt?: number | null): number { + const size = Number(sizeFt); + if (!Number.isFinite(size) || size <= 0) return 1; + return size >= 40 ? 1 : 0.5; +} + +/** Containers that fit on one wagon for a given container size (inverse of the wagon fraction). */ +export function containersPerWagonForSize(sizeFt?: number | null): number { + return Math.max(1, Math.round(1 / wagonsPerUnitForSize(sizeFt))); +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 6424f013e..0b3334431 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -29,8 +29,7 @@ export class PriorityConfigsController { @Get('next-range') @RuleEngineView('priority-configs') @ApiOperation({ - summary: - "Where the next contiguous range for a type (and currency) must start, plus the type's ceiling", + summary: 'Where the next contiguous range for a type (and currency) must start', }) nextRange( @Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts index a01ba4b5b..379997e6d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; +import { IsArray, IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; export class CreateContainerTypeDto { @ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 }) @@ -14,12 +13,6 @@ export class CreateContainerTypeDto { @Max(40) sizeFt!: number; - @ApiProperty({ description: 'Wagon fraction per container: 0.50 for 20ft, 1.00 for 40ft' }) - @IsNumber() - @Min(0.01) - @Transform(({ value }) => Number(value)) - wagonsPerUnit!: number; - @ApiPropertyOptional({ default: false, description: 'True if this is a reefer (refrigerated) container' }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts index 2347426ca..642f6942e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -16,9 +16,6 @@ export class ContainerType extends BaseEntity { @Column({ name: 'size_ft', type: 'smallint', nullable: true }) sizeFt!: number; - @Column({ name: 'wagons_per_unit', type: 'numeric', precision: 4, scale: 2, nullable: true }) - wagonsPerUnit!: number; - @Column({ name: 'is_reefer', type: 'boolean', default: false, nullable: true }) isReefer!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index 42ce389e1..4c40cae4b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -48,7 +48,6 @@ export class ContainerTypesService { code, label: dto.label, sizeFt: dto.sizeFt, - wagonsPerUnit: dto.wagonsPerUnit, isReefer: dto.isReefer ?? false, isOpenTop: dto.isOpenTop ?? false, isActive: dto.isActive ?? true, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts index 53cb7ed83..0b0ef1b0d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts @@ -5,9 +5,8 @@ import { PriorityConfigsService } from './priority-configs.service'; /** * Contiguous-range rules for priority configs: per type (per currency for - * CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range - * must start at the lowest uncovered wagon count. Caps: WAGON 50, - * CURRENCY 35, CUSTOMS 15. + * CURRENCY), ranges run from 1 with no gaps and no overlaps; the next range + * must start at the lowest uncovered wagon count. There is no upper ceiling. */ describe('PriorityConfigsService range validation', () => { const rule = ( @@ -118,41 +117,47 @@ describe('PriorityConfigsService range validation', () => { ).rejects.toThrow(/overlaps existing rule/); }); - it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => { + it('imposes no upper ceiling on any type', async () => { await expect( - attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }), - ).rejects.toThrow(/may not exceed 50/); + attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5000 }), + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([]), { type: 'CURRENCY', currency: 'USD', minWagonCount: 1, - maxWagonCount: 36, + maxWagonCount: 5000, }), - ).rejects.toThrow(/may not exceed 35/); + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([]), { type: 'CUSTOMS', minWagonCount: 1, - maxWagonCount: 16, + maxWagonCount: 5000, }), - ).rejects.toThrow(/may not exceed 15/); + ).resolves.toBeUndefined(); }); - it('rejects any new rule once the chain covers the full range', async () => { + it('keeps extending the chain past the old caps', async () => { await expect( attempt(serviceWith([rule('WAGON', 1, 50)]), { minWagonCount: 51, - maxWagonCount: 51, + maxWagonCount: 120, }), - ).rejects.toThrow(/may not exceed 50/); + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([rule('CUSTOMS', 1, 15)]), { type: 'CUSTOMS', - minWagonCount: 1, - maxWagonCount: 1, + minWagonCount: 16, + maxWagonCount: 99, }), - ).rejects.toThrow(/already cover the full 1–15 range/); + ).resolves.toBeUndefined(); + }); + + it('still rejects a min greater than the max', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 9, maxWagonCount: 4 }), + ).rejects.toThrow(BadRequestException); }); it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => { @@ -214,16 +219,13 @@ describe('PriorityConfigsService range validation', () => { it('reports the next-range prefill for the form', async () => { const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); - await expect(svc.nextRange('WAGON')).resolves.toEqual({ - nextMin: 6, - maxCap: 50, - }); + await expect(svc.nextRange('WAGON')).resolves.toEqual({ nextMin: 6 }); + // Past the old CUSTOMS cap of 15 the chain simply continues. await expect( serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'), - ).resolves.toEqual({ nextMin: null, maxCap: 15 }); + ).resolves.toEqual({ nextMin: 16 }); await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({ nextMin: 1, - maxCap: 35, }); }); }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts index ff3711e42..274ce687f 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -10,28 +10,19 @@ import { } from '../interfaces/priority-configs.repository.interface'; import { DisplayOrderService } from './display-order.service'; -/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */ -export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = { - WAGON: 50, - CURRENCY: 35, - CUSTOMS: 15, -}; - /** * Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range - * must start. Null when the chain is already complete up to the type's cap. + * must start. The chain is unbounded above, so there is always a next start. */ function nextRangeStart( rules: Pick[], -): number | null { - const cap = rules.length ? RANGE_CAPS[rules[0].type] : null; +): number { const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount); let next = 1; for (const r of sorted) { if (r.minWagonCount > next) break; // gap before this rule — fill it next = Math.max(next, r.maxWagonCount + 1); } - if (cap != null && next > cap) return null; return next; } @@ -102,8 +93,8 @@ export class PriorityConfigsService { * - ranges never overlap — a booking matches at most one rule per type; * - ranges are contiguous from 1: a new range must START at the lowest * wagon count not yet covered (after 1–5 the next is 6–…; deleting a - * middle rule opens a gap and the next create must fill it first); - * - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15. + * middle rule opens a gap and the next create must fill it first). + * There is no upper ceiling — max wagon count is unbounded. * Ranges are inclusive on both ends. */ async assertNoRangeCollision(input: { @@ -118,14 +109,6 @@ export class PriorityConfigsService { 'Min wagon count cannot be greater than max wagon count', ); } - const cap = RANGE_CAPS[input.type]; - if (input.maxWagonCount > cap) { - throw new BadRequestException( - `${input.type} ranges may not exceed ${cap} — ` + - `${input.minWagonCount}–${input.maxWagonCount} goes past the ceiling.`, - ); - } - const siblings = ( await this.repository.findAll({ where: { type: input.type } }) ).filter( @@ -142,12 +125,6 @@ export class PriorityConfigsService { const currentStart = input.excludeId ? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null : null; - if (expectedStart == null && currentStart == null) { - throw new BadRequestException( - `${input.type} rules already cover the full 1–${cap} range — ` + - 'delete or shrink an existing rule first.', - ); - } if ( input.minWagonCount !== expectedStart && input.minWagonCount !== currentStart @@ -174,21 +151,21 @@ export class PriorityConfigsService { } /** - * Where the next range for a type/currency must start, and the type's - * ceiling — feeds the create form so the min field is auto-filled and - * locked. `nextMin` is null when the chain already covers 1..cap. + * Where the next range for a type/currency must start — feeds the create + * form so the min field is auto-filled and locked. Always a number: the + * chain has no ceiling, so another range always fits. */ async nextRange( type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', currency?: string | null, - ): Promise<{ nextMin: number | null; maxCap: number }> { + ): Promise<{ nextMin: number }> { const siblings = ( await this.repository.findAll({ where: { type } }) ).filter( (s) => type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null), ); - return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] }; + return { nextMin: nextRangeStart(siblings) }; } async remove(id: string): Promise { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 8c3c92193..c19140d8a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -887,7 +887,7 @@ describe('BookingBatchService — wagonsFor', () => { freightType: 'CONTAINER', cargoTotalWeightVgm: 210, bookingContainers: [ - { quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } }, + { quantity: 2, wagonsRequired: 2, containerType: { sizeFt: 40 } }, ], }; expect(service.wagonsFor(booking, dims)).toBe(3); @@ -899,7 +899,7 @@ describe('BookingBatchService — wagonsFor', () => { freightType: 'CONTAINER', cargoTotalWeightVgm: 40, bookingContainers: [ - { quantity: 4, wagonsRequired: 2, containerType: { wagonsPerUnit: 0.5, sizeFt: 20 } }, + { quantity: 4, wagonsRequired: 2, containerType: { sizeFt: 20 } }, ], }; expect(service.wagonsFor(booking, dims)).toBe(2); @@ -939,7 +939,7 @@ describe('BookingBatchService — wagonsFor', () => { { quantity: 2, wagonsRequired: 2, - containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] }, + containerType: { sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] }, }, ], }; @@ -950,3 +950,106 @@ describe('BookingBatchService — wagonsFor', () => { }); }); }); + +describe('BookingBatchService — built-train wagon capacity', () => { + // A schedule created from a built train is capped by its PHYSICAL consist: + // wagon count only. The locomotive here is deliberately tiny (1T / 1m) — the + // old weight/length math would call every one of these trains FULL, so any + // assertion below that says "not full" proves those axes are ignored. + const scheduleId = 'schedule-built'; + + const reservedBooking = (id: string) => + ({ + id, + freightType: 'BULK', + cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload + bookingContainers: [], + originYardId: 'yard-a', + destinationYardId: 'yard-b', + }) as unknown as Booking; + + const buildService = (opts: { + physicalWagons: number; + reserved: Booking[]; + maxWagons?: number; + }) => { + const schedule = { + id: scheduleId, + maxWagons: opts.maxWagons ?? 44, // stale locomotive-derived cap on purpose + bookingWindowStatus: 'OPEN', + originStationId: 'yard-a', + destinationStationId: 'yard-b', + routeId: null, + scheduleBookings: [], + trainSet: { + locomotive: { + maxPullWeightTons: 1, + maxTrainLengthMeters: 1, + overageToleranceTons: 0, + overageToleranceMeters: 0, + }, + train: { id: 'train-built-1' }, + }, + }; + const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) }; + const genericRepo = { + find: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue(undefined), + }; + const dataSource = { + getRepository: jest.fn((entity: { name?: string }) => + entity?.name === 'Wagon' ? wagonRepo : genericRepo, + ), + transaction: jest.fn(), + }; + const service = new BookingBatchService( + dataSource as never, + { + findReservedForSchedule: jest.fn().mockResolvedValue(opts.reserved), + } as never, + { + findByIdWithFullGraph: jest.fn().mockResolvedValue(schedule), + findById: jest.fn().mockResolvedValue(schedule), + } as never, + null as never, + null as never, + null as never, + null as never, + null as never, + { emitPhase: jest.fn() } as never, + null as never, + ); + return { service, wagonRepo }; + }; + + it('is FULL when bookings hold every physical wagon, even with loco-derived slots free', async () => { + const { service } = buildService({ + physicalWagons: 2, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + maxWagons: 44, // stale: the old slot cap would say 42 slots remain + }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); + }); + + it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => { + const { service } = buildService({ + physicalWagons: 3, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + }); + // 1T pull cap would have been exhausted long ago under the old math. + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); + }); + + it('reports over-allocation when the consist is trimmed below committed bookings', async () => { + const { service } = buildService({ + physicalWagons: 1, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + }); + await expect(service.scheduleWagonUsage(scheduleId)).resolves.toEqual({ + maxWagons: 1, + allocatedWagons: 2, + remainingSlots: 0, + overAllocatedBy: 1, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index ef39ab138..5beb920d3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -68,6 +68,7 @@ import { wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; import { BookingWindowGateway } from './booking-window.gateway'; @@ -305,6 +306,9 @@ export class BookingBatchService implements OnModuleInit { private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, private readonly notifier: BookingNotifierService, private readonly scheduler: SchedulerRegistry, + // forwardRef: TrainSchedulingService injects this service back (window + // refresh after adjust-consist), so the classes load in a cycle. + @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, private readonly billing: BillingService, private readonly bookingWindowGateway: BookingWindowGateway, @@ -2915,7 +2919,7 @@ export class BookingBatchService implements OnModuleInit { ? Math.ceil(booking.wagonsRequired) : 0; - // TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback + // TEU-aware: two 20ft share one wagon (half a wagon each). The old fallback // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10. const byLength = containerWagonsForLines(booking.bookingContainers ?? []); @@ -2993,17 +2997,20 @@ export class BookingBatchService implements OnModuleInit { } /** - * Keep schedule.max_wagons aligned with the train's boarding limit: the - * locomotive's length-derived slot count. The physical wagons currently in - * the train set do NOT cap this — bookings are admitted on length/weight - * alone and yard staff attach the wagons manually before departure. + * Keep schedule.max_wagons aligned with the train's boarding limit. A built + * train's limit is its physical consist — the wagon count staff marshalled + * (and may change via adjust-consist). Only schedules WITHOUT a built train + * fall back to the locomotive's length-derived slot count, where bookings + * are admitted on length/weight alone and yard staff attach the wagons + * manually before departure. */ private async syncScheduleMaxWagons( schedule: TrainSchedule, locomotive: Locomotive, ): Promise { - const limits = await this.capacityLimits(locomotive); - const maxWagons = limits.base.wagons; + const physicalWagons = await this.builtTrainWagonCount(schedule); + const maxWagons = + physicalWagons ?? (await this.capacityLimits(locomotive)).base.wagons; if ((schedule.maxWagons ?? 0) !== maxWagons) { await this.dataSource .getRepository(TrainSchedule) @@ -3122,16 +3129,31 @@ export class BookingBatchService implements OnModuleInit { * reserved bookings already use ON THEIR OWN LEGS. A booking riding only * Dire→Djibouti leaves the Addis→Dire edges untouched. * - * The wagon axis is the locomotive's length-derived slot count only — the - * physical wagons currently marshalled in the train set do NOT cap it. - * Bookings are admitted on length/weight capacity and yard staff attach - * the missing wagons manually before wagon assignment. + * Two capacity regimes, decided by the schedule's train: + * - Built train (Train Builder consist with physical wagons): the consist IS + * the capacity. Wagon slots = physical wagon count; weight and length are + * NOT re-checked here — the builder and adjust-consist already enforced the + * locomotive's pull/length limits when the consist was assembled. + * - No built train (legacy schedules): the locomotive's length-derived slot + * count plus its weight/length budgets, as before — yard staff attach the + * missing wagons manually before wagon assignment. */ private async remainingBudget( schedule: TrainSchedule, limits: TrainLimits, wagonDims: WagonDims, ): Promise { + const physicalWagons = await this.builtTrainWagonCount(schedule); + if (physicalWagons != null) { + limits = { + base: { + wagons: physicalWagons, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }; + } const stops = await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const allocated = (schedule.scheduleBookings ?? []) @@ -3149,6 +3171,23 @@ export class BookingBatchService implements OnModuleInit { return budget; } + /** + * Physical wagons marshalled in the schedule's built train, or null when the + * schedule has no built train (or the consist is still empty) and the legacy + * locomotive-derived capacity must apply. This count is what caps a built + * train's bookings: 50 wagons coupled → 50 wagon slots, no more. + */ + private async builtTrainWagonCount( + schedule: TrainSchedule, + ): Promise { + const trainId = schedule.trainSet?.train?.id; + if (!trainId) return null; + const count = await this.dataSource + .getRepository(Wagon) + .count({ where: { trainId } }); + return count > 0 ? count : null; + } + /** * Wagon slots still boardable somewhere on the corridor (most-open edge). * ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide @@ -3219,11 +3258,14 @@ export class BookingBatchService implements OnModuleInit { } /** - * FULL on ANY capacity axis: out of wagon slots, or out of pull weight / - * train length for even one more loaded wagon. The old slot-only check let - * a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of - * 3500+90T, slots bind at 44) cycle its booking window forever instead of - * finalizing — 7 phantom slots kept it "not full" while nothing could board. + * Built train: FULL when every physical wagon slot is taken — the consist is + * the capacity, weight/length were settled at build time. + * No built train: FULL on ANY capacity axis — out of wagon slots, or out of + * pull weight / train length for even one more loaded wagon. The old + * slot-only check let a weight-bound train (PW2: weight binds at 37 wagons = + * 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever + * instead of finalizing — 7 phantom slots kept it "not full" while nothing + * could board. */ async isScheduleFull(scheduleId: string): Promise { const schedule = @@ -3232,9 +3274,53 @@ export class BookingBatchService implements OnModuleInit { return this.isTrainFull(schedule); } + /** + * Wagon-slot usage snapshot for staff UIs (adjust-consist dialog): the + * schedule's slot capacity, how many slots allocated + reserved bookings + * already hold on the busiest edge, how many are still free on the most-open + * edge, and by how many slots the consist has been trimmed BELOW what is + * already committed (0 when nothing is over-allocated). + */ + async scheduleWagonUsage(scheduleId: string): Promise<{ + maxWagons: number; + allocatedWagons: number; + remainingSlots: number; + overAllocatedBy: number; + } | null> { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) return null; + const capacity = + (await this.builtTrainWagonCount(schedule)) ?? schedule.maxWagons ?? 0; + const wagonDims = await this.loadWagonDims(); + const budget = await this.remainingBudget( + schedule, + { + base: { + wagons: capacity, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }, + wagonDims, + ); + const tightest = budget.remainingFor(budget.fullLeg()).wagons; + return { + maxWagons: capacity, + allocatedWagons: capacity - tightest, + remainingSlots: Math.max(0, budget.maxRemaining().wagons), + overAllocatedBy: Math.max(0, -tightest), + }; + } + /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ private async isTrainFull(schedule: TrainSchedule): Promise { if ((await this.remainingWagons(schedule)) <= 0) return true; + // Built train: the physical consist is the only capacity axis. Weight and + // length were enforced when the consist was assembled (builder / + // adjust-consist), so a free wagon slot means the train genuinely has room. + if ((await this.builtTrainWagonCount(schedule)) != null) return false; const locomotive = schedule.trainSet?.locomotive; if (!locomotive) return false; // no weight/length limits to bind against const wagonDims = await this.loadWagonDims(); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 9cca4d213..4d408689b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -56,7 +56,7 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n } // TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1 - // wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored + // wagon). Derived from containerType.sizeFt; falls back to the line's stored // fraction. Ceiling per line would over-count split 20ft lines. return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); } 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 b92a7ea90..614c4fabd 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 @@ -12,6 +12,8 @@ import { BadRequestException, ConflictException, + forwardRef, + Inject, Injectable, Logger, NotFoundException, @@ -95,6 +97,7 @@ import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; import { BookingNotifierService } from './booking-notifier.service'; +import { BookingBatchService } from './booking-batch.service'; import { computeFleetAvailability, summarizeFleetWarnings, @@ -317,6 +320,11 @@ export class TrainSchedulingService { private readonly bookingNotifier: BookingNotifierService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, + // forwardRef: BookingBatchService injects this service back; @Optional so + // existing specs that construct the service without it keep working. + @Optional() + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService?: BookingBatchService, ) {} /** @@ -5054,6 +5062,11 @@ export class TrainSchedulingService { wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0), ); + // Wagon-slot picture for the dialog: the consist IS the schedule's booking + // capacity, so trimming/coupling wagons moves the FULL line live. + const wagonUsage = + (await this.bookingBatchService?.scheduleWagonUsage(scheduleId)) ?? null; + const mapWagon = (wagon: Wagon) => ({ id: wagon.id, wagonNumber: wagon.wagonNumber, @@ -5092,6 +5105,12 @@ export class TrainSchedulingService { grossTons: roundTons(cargoTons + consistTareTons), consistLengthMeters, }, + scheduleCapacity: wagonUsage + ? { + ...wagonUsage, + bookingWindowStatus: schedule.bookingWindowStatus ?? null, + } + : null, wagons: wagons.map((wagon) => ({ ...mapWagon(wagon), loaded: loadedWagonIds.has(wagon.id), @@ -5284,7 +5303,37 @@ export class TrainSchedulingService { ); }); - return this.getScheduleConsist(scheduleId); + // The consist IS the schedule's booking capacity, so an edit moves the + // FULL line: freeing slots on a FULL schedule reopens its window, taking + // the last slot closes it. Staff may shrink below what is already + // committed — allowed, but reported back as a warning (never silently). + const warnings: string[] = []; + const wasFull = schedule.bookingWindowStatus === 'FULL'; + const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId); + if (usage) { + const nowFull = usage.remainingSlots <= 0; + if (usage.overAllocatedBy > 0) { + warnings.push( + `The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` + + `${usage.allocatedWagons} — ${usage.overAllocatedBy} wagon(s) over capacity. ` + + 'Couple more wagons or free bookings before departure.', + ); + } + if (wasFull && !nowFull) { + await this.bookingBatchService?.refreshWindowStatus(scheduleId); + warnings.push( + `This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` + + 'so it is no longer FULL and can take bookings again.', + ); + } else if (!wasFull && nowFull) { + await this.bookingBatchService?.setWindow(scheduleId, 'FULL'); + warnings.push( + 'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.', + ); + } + } + + return { ...(await this.getScheduleConsist(scheduleId)), warnings }; } /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 157666f55..bb9adf319 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -77,7 +77,6 @@ describe('planWagonsWithStock — shortage detail', () => { fortyFooter.bookingContainers![0]!.containerType = { code: '40GP', sizeFt: 40, - wagonsPerUnit: 1, } as never; const result = planWagonsWithStock({ bookings: [fortyFooter], diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index 19e19dca3..c3d48f286 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -106,7 +106,7 @@ describe('wagon-plan.util', () => { }); it('6×20ft containers = 3 wagon slots (2 per wagon)', () => { - // 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons + // 20ft containers take half a wagon each, so 6 * 0.5 = 3 wagons const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]); expect(sumWagonsRequired(booking)).toBe(3); const plan = buildContainerWagonPlan([booking], nw5); @@ -227,7 +227,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({ quantity, wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit, - containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, + containerType: { sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, }); it('20×20ft = 10 wagons (not 20)', () => { @@ -266,7 +266,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => expect(containerWagonsForLines([line(21, 1)])).toBe(21); }); - it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => { + it('falls back to line wagonsRequired when containerType/sizeFt missing', () => { // No containerType relation loaded → use the stored (0.5-aware) fraction. expect( containerWagonsForLines([ 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 78cd8bf54..bb5ce890c 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 @@ -1,6 +1,7 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; +import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { consistViolations } from './train-capacity.util'; @@ -61,7 +62,6 @@ export type ContainerUnitRow = { label: string; grossWeightTons: number; sizeFt?: number; - wagonsPerUnit?: number; containersPerWagon?: number; teuSlots?: number; containerNumber?: string | null; @@ -95,33 +95,28 @@ export function teuSlotsForSizeFt(sizeFt: number): number { return sizeFt >= 40 ? 2 : 1; } -export function containersPerWagonFromType(wagonsPerUnit: number): number { - const wpu = Number(wagonsPerUnit); - if (!wpu || wpu <= 0) return 1; - return Math.max(1, Math.round(1 / wpu)); -} - type ContainerLine = { quantity?: number | null; wagonsRequired?: number | null; - containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null; + containerType?: { sizeFt?: number | null } | null; }; /** - * RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit - * (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so - * the BOOKING total is ceiled once — ceiling per line over-counts a booking that - * splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4). + * RAW (un-ceiled) wagon fraction one container line occupies: qty × size-derived + * fraction (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept + * fractional so the BOOKING total is ceiled once — ceiling per line over-counts a + * booking that splits its 20ft units across several lines (3×20 + 3×20 = 3 + * wagons, not 4). */ function lineWagonsRaw(line: ContainerLine): number { const qty = Number(line.quantity ?? 0); if (qty <= 0) return 0; - const wpu = Number(line.containerType?.wagonsPerUnit); - if (Number.isFinite(wpu) && wpu > 0) { - return qty * wpu; + const sizeFt = Number(line.containerType?.sizeFt); + if (Number.isFinite(sizeFt) && sizeFt > 0) { + return qty * wagonsPerUnitForSize(sizeFt); } - // No wagonsPerUnit on the type: fall back to the line's stored fraction, else - // treat the whole line as one wagon. + // No size on the type: fall back to the line's stored fraction, else treat + // the whole line as one wagon. const stored = Number(line.wagonsRequired); return Number.isFinite(stored) && stored > 0 ? stored : 1; } @@ -250,8 +245,7 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR const qty = Number(line.quantity ?? 0); const code = line.containerType?.code ?? line.containerType?.label ?? 'Container'; const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20)); - const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); - const perWagon = containersPerWagonFromType(wagonsPerUnit); + const perWagon = containersPerWagonForSize(sizeFt); 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 @@ -271,7 +265,6 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR label: `${booking.reference} · ${i + 1}/${qty} · ${code}`, grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons), sizeFt, - wagonsPerUnit, containersPerWagon: perWagon, teuSlots, containerNumber: diff --git a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts index a8abee67b..dbfd5cee6 100644 --- a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -209,7 +209,6 @@ async function ensureReferences(manager: any) { code: '40FT', label: '40FT', sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 4f801330a..9cf1ef0cd 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -118,7 +118,6 @@ async function main() { code: '40FT', label: '40FT', sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts index 367b79b44..b14ac8c15 100644 --- a/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts @@ -12,6 +12,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity'; import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -115,7 +116,7 @@ async function main() { reeferQuantity: 0, vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)), totalVgmTons: Number((weightKg / 1000).toFixed(3)), - wagonsRequired: Math.max(1, containerQuantity * Number(containerType!.wagonsPerUnit ?? 1)), + wagonsRequired: Math.max(1, containerQuantity * wagonsPerUnitForSize(containerType!.sizeFt)), isOverweight: false, }), ); diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index 989e18bf1..2e234bcf0 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -11,6 +11,7 @@ import { } from '../modules/companies/entities/company.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -223,7 +224,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -276,7 +276,7 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { } const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; await manager.getRepository(Booking).upsert( diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 3720c0e2a..20d2a3f8b 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -14,6 +14,7 @@ import { ServiceType } from "../modules/rule-engine/entities/service-type.entity import { Yard } from "../modules/rule-engine/entities/yard.entity"; import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; +import { wagonsPerUnitForSize } from "../modules/rule-engine/container-type.util"; import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; import { Container } from "../modules/container-management/entities/container.entity"; import { Route } from "../modules/routes/entities/route.entity"; @@ -300,7 +301,6 @@ export class DemoBookingsSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -400,7 +400,7 @@ export class DemoBookingsSeeder { .getRepository(BookingContainer) .delete({ bookingId: booking.id }); const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); await manager.getRepository(BookingContainer).insert({ id: randomUUID(), diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index e1c46d168..f60d7bb75 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -7,6 +7,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity'; import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -145,7 +146,6 @@ export class PaidImportExportMileDemoSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -199,7 +199,7 @@ export class PaidImportExportMileDemoSeeder { const isImport = demoBooking.tradeDirection === 'IMPORT'; const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; await manager.getRepository(Booking).upsert( diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 872e909bb..147f2ae54 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -115,7 +115,6 @@ export class PricingDataSeeder { code: "20FT", label: "20FT Standard", sizeFt: 20, - wagonsPerUnit: 0.5, isReefer: false, isOpenTop: false, isActive: true, @@ -125,7 +124,6 @@ export class PricingDataSeeder { code: "40FT", label: "40FT Standard", sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -135,7 +133,6 @@ export class PricingDataSeeder { code: "20FT_REEFER", label: "20FT Reefer", sizeFt: 20, - wagonsPerUnit: 0.5, isReefer: true, isOpenTop: false, isActive: true, @@ -145,7 +142,6 @@ export class PricingDataSeeder { code: "40FT_REEFER", label: "40FT Reefer", sizeFt: 40, - wagonsPerUnit: 1, isReefer: true, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 0da9fe3f0..03449fbb2 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -197,20 +197,20 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, - { - label: "Shipment Requests", - href: "/dashboard/shipment-requests", - icon: , - permission: FREIGHT_PERMS.contracts.createBooking, - }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, // Operations Path A queue: per-booking self-clearance review for // GENERAL non-customs booking instances (and legacy self-clear bookings). - { - label: "Self-Clearance Review", - href: "/dashboard/contracts/ops-clearance", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, + // { + // label: "Self-Clearance Review", + // href: "/dashboard/contracts/ops-clearance", + // icon: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx index 1ca91e01f..14dd60912 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx @@ -65,7 +65,10 @@ export default function AdjustConsistModal({ } }, [opened]); - // Live projection: gross = cargo + tare of (consist − trims + adds). + // Live projection: gross = cargo + tare of (consist − trims + adds), plus + // the schedule's wagon-slot picture — the consist IS the booking capacity + // (weight/length only bind while assembling the consist), so trims/adds + // move the FULL line in real time. const projection = useMemo(() => { if (!data) return null; const removed = new Set(removeIds); @@ -79,8 +82,11 @@ export default function AdjustConsistModal({ const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0); const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0); const gross = round2(data.totals.cargoTons + tare); + const wagonCount = data.totals.wagonCount - removeIds.length + addIds.length; + const cap = data.scheduleCapacity; + const freeSlots = cap ? wagonCount - cap.allocatedWagons : null; return { - wagonCount: data.totals.wagonCount - removeIds.length + addIds.length, + wagonCount, tare: round2(tare), gross, length: round2(length), @@ -93,16 +99,33 @@ export default function AdjustConsistModal({ overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons, overLength: data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters, + slots: + cap && freeSlots != null + ? { + allocated: cap.allocatedWagons, + free: freeSlots, + pct: + wagonCount > 0 + ? Math.round((cap.allocatedWagons / wagonCount) * 100) + : null, + isFullNow: cap.bookingWindowStatus === "FULL", + willBeFull: freeSlots <= 0, + overAllocated: freeSlots < 0, + willReopen: cap.bookingWindowStatus === "FULL" && freeSlots > 0, + } + : null, }; }, [data, removeIds, addIds]); + const hasChanges = removeIds.length > 0 || addIds.length > 0; + const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) => setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id))); const handleSubmit = async () => { if (!removeIds.length && !addIds.length) return; try { - await adjust.mutateAsync({ + const result = await adjust.mutateAsync({ scheduleId, payload: { ...(addIds.length ? { addWagonIds: addIds } : {}), @@ -114,6 +137,18 @@ export default function AdjustConsistModal({ removeIds.length && addIds.length ? ", " : "" }${addIds.length ? `${addIds.length} added` : ""}`, }); + // Schedule-impact warnings from the API: window reopened / now FULL / + // consist trimmed below what bookings already hold. + for (const warning of result.warnings ?? []) { + toast({ + title: "Schedule capacity", + description: warning, + duration: 8000, + ...(warning.includes("over capacity") + ? { variant: "destructive" as const } + : {}), + }); + } setRemoveIds([]); setAddIds([]); } catch (err) { @@ -169,8 +204,57 @@ export default function AdjustConsistModal({ over={projection?.overLength ?? false} /> + {projection?.slots ? ( + + 0 + ? ` — ${projection.slots.free} free` + : projection.slots.free === 0 + ? " — none free (FULL)" + : "" + }`} + pct={projection.slots.pct} + over={projection.slots.overAllocated} + /> + + ) : null} + {projection?.slots?.isFullNow && !hasChanges ? ( + }> + This schedule is FULL — all {projection.wagonCount} wagon slots are + taken. You can still edit the train: coupling wagons adds capacity + and reopens booking; trimming free wagons keeps it FULL. + + ) : null} + {hasChanges && projection?.slots?.overAllocated ? ( + }> + This change leaves {-projection.slots.free} booked wagon(s) without + a slot — bookings already hold {projection.slots.allocated} of the{" "} + {projection.wagonCount} remaining. You can apply it, but couple + wagons back or free bookings before departure. + + ) : null} + {hasChanges && + projection?.slots && + !projection.slots.overAllocated && + projection.slots.willBeFull && + !projection.slots.isFullNow ? ( + }> + This change takes the last free wagon slot — the schedule becomes + FULL and stops accepting bookings. + + ) : null} + {hasChanges && projection?.slots?.willReopen ? ( + }> + This schedule is currently FULL — applying frees{" "} + {projection.slots.free} wagon slot(s) and reopens its booking + window. + + ) : null} + diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts index 3d42c8684..5072ce34a 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts @@ -5,7 +5,6 @@ import type { ContainerUnitRow } from '@/types/trainScheduling'; function makeUnits(containerType: string, sizeFt: number, quantity: number): ContainerUnitRow[] { const units: ContainerUnitRow[] = []; const containersPerWagon = sizeFt >= 40 ? 1 : 2; - const wagonsPerUnit = sizeFt >= 40 ? 1 : 0.5; for (let i = 0; i < quantity; i++) { units.push({ @@ -18,7 +17,6 @@ function makeUnits(containerType: string, sizeFt: number, quantity: number): Con label: `${containerType} ${i + 1}/${quantity}`, grossWeightTons: 25, sizeFt, - wagonsPerUnit, containersPerWagon, teuSlots: sizeFt >= 40 ? 2 : 1, }); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index 07a6c72cd..30fdcb9c4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -61,7 +61,6 @@ interface RefContainerType { name: string; code: string; is_reefer?: boolean; - wagons_per_unit?: number; } interface RefContainerGroup { size: string; 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 c7c76b91b..902d23427 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 @@ -384,7 +384,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ label: "Max wagon count", type: "number", required: true, - description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15", + description: "No upper limit — must be at least the min wagon count", }, { name: "scorePoints", label: "Score points", type: "number", required: true }, { name: "isActive", label: "Active", type: "boolean" }, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts index f387fc8dc..9357e1967 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts @@ -1,20 +1,14 @@ /** * Client mirror of the backend's contiguous-range rules for priority configs * (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per - * currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next - * range always starts at the lowest uncovered wagon count. The backend - * re-validates on submit AND on approval; this only drives the form prefill. + * currency for CURRENCY — run from 1 with no gaps and no overlaps, so the next + * range always starts at the lowest uncovered wagon count. There is no upper + * ceiling. The backend re-validates on submit AND on approval; this only + * drives the form prefill. */ export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS"; -/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */ -export const PRIORITY_RANGE_CAPS: Record = { - WAGON: 50, - CURRENCY: 35, - CUSTOMS: 15, -}; - export interface PriorityRangeRule { id?: unknown; type?: unknown; @@ -23,10 +17,13 @@ export interface PriorityRangeRule { maxWagonCount?: unknown; } +const PRIORITY_RULE_TYPES: PriorityRuleType[] = ["WAGON", "CURRENCY", "CUSTOMS"]; + /** * Where the next range for `type` (+`currency`) must start, excluding - * `excludeId` (the rule being edited). Null when the chain already covers - * 1..cap — no further rule fits. + * `excludeId` (the rule being edited). Null only when `type` is not yet a + * known priority rule type — the chain itself is unbounded, so a next start + * always exists. */ export function nextPriorityRangeStart( rules: PriorityRangeRule[], @@ -34,8 +31,7 @@ export function nextPriorityRangeStart( currency: string | null | undefined, excludeId?: string, ): number | null { - const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType]; - if (!cap) return null; + if (!PRIORITY_RULE_TYPES.includes(type as PriorityRuleType)) return null; const scoped = rules .filter( @@ -56,5 +52,5 @@ export function nextPriorityRangeStart( if (r.min > next) break; // gap before this rule — fill it first next = Math.max(next, r.max + 1); } - return next > cap ? null : next; + return next; } diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 7a2a478c1..71db16fb5 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -185,6 +185,7 @@ import { trainService, type Train } from "./trains.service"; import { trainBuilderService, type AdjustConsistPayload, + type AdjustConsistResult, type AvailableTrain, type BuildTrainPayload, type BuiltTrainListFilters, @@ -338,7 +339,7 @@ export const api = { adjustConsist: endpoint< { scheduleId: string; payload: AdjustConsistPayload }, - ScheduleConsist + AdjustConsistResult >( "train-scheduling", "adjust-consist", diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts index 62ae9f7a3..083122a10 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts @@ -235,6 +235,18 @@ export interface ScheduleConsist { occurredAt: string; }>; editable: boolean; + /** + * Wagon-slot picture of the schedule: the consist IS the booking capacity + * (weight/length only bind while building the consist), so the dialog can + * project FULL / reopen / over-allocation live. Null on legacy schedules. + */ + scheduleCapacity: { + maxWagons: number; + allocatedWagons: number; + remainingSlots: number; + overAllocatedBy: number; + bookingWindowStatus: string | null; + } | null; } export interface AdjustConsistPayload { @@ -242,6 +254,9 @@ export interface AdjustConsistPayload { removeWagonIds?: string[]; } +/** Adjust response: fresh consist + schedule-impact warnings to surface. */ +export type AdjustConsistResult = ScheduleConsist & { warnings: string[] }; + export const trainBuilderService = { list: (filters: BuiltTrainListFilters = {}) => apiClient.get(`${BASE}${toQuery(filters)}`), @@ -275,7 +290,7 @@ export const trainBuilderService = { apiClient.get(`/train-scheduling/schedules/${scheduleId}/consist`), /** Permanently trim/add wagons on the schedule's built train. */ adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) => - apiClient.post( + apiClient.post( `/train-scheduling/schedules/${scheduleId}/adjust-consist`, payload, ), diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 31e1c6a04..b83aa33ee 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -72,7 +72,6 @@ export interface ContainerUnitRow { label: string; grossWeightTons: number; sizeFt?: number; - wagonsPerUnit?: number; containersPerWagon?: number; teuSlots?: number; containerNumber?: string | null; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index dc973d9c1..e84fa6ca1 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -853,7 +853,6 @@ export interface BookingReferenceContainerType { name: string; code: string; is_reefer: boolean; - wagons_per_unit: number; } export interface BookingReferenceContainerSizeGroup {