Merge pull request #756 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-17 12:21:06 +03:00
committed by GitHub
40 changed files with 513 additions and 188 deletions

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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;
`);
}
}

View File

@@ -10,11 +10,9 @@ import {
BookingEvaluationInput, BookingEvaluationInput,
RuleEngineService, RuleEngineService,
} from '../rule-engine/rule-engine.service'; } from '../rule-engine/rule-engine.service';
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
import { BookingsRepository } from './bookings.repository'; import { BookingsRepository } from './bookings.repository';
import { import { wagonRemainder } from './consolidation.service';
containersPerWagon,
wagonRemainder,
} from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util'; import { assertBookingStatus } from './booking-status.util';
@@ -308,7 +306,7 @@ export class BookingPricingService {
totalVgmTons: qty * vgm, totalVgmTons: qty * vgm,
isReefer: ct.isReefer, isReefer: ct.isReefer,
}, },
perWagon: containersPerWagon(Number(ct.wagonsPerUnit)), perWagon: containersPerWagonForSize(ct.sizeFt),
quantity: qty, quantity: qty,
}; };
}), }),

View File

@@ -110,7 +110,6 @@ export function groupContainersBySize(
name: ct.label?.trim() ? ct.label : ct.code, name: ct.label?.trim() ? ct.label : ct.code,
code: ct.code, code: ct.code,
is_reefer: ct.isReefer ?? false, is_reefer: ct.isReefer ?? false,
wagons_per_unit: Number(ct.wagonsPerUnit ?? 1),
}), }),
), ),
})); }));

View File

@@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from '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 { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity'; import { Contract } from '../contracts/entities/contract.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
@@ -149,7 +150,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
for (const item of containers) { for (const item of containers) {
const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } }); 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 totalVgm = item.quantity * item.vgmPerUnitTons;
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
// A per-line breakdown can never exceed the line's own quantity. // A per-line breakdown can never exceed the line's own quantity.
@@ -179,7 +180,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
async calculateWagonCount(bookingId: string): Promise<number> { async calculateWagonCount(bookingId: string): Promise<number> {
const result = await this.dataSource const result = await this.dataSource
.createQueryBuilder() .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') .from(BookingContainer, 'bc')
.innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id') .innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id')
.where('bc.booking_id = :bookingId', { bookingId }) .where('bc.booking_id = :bookingId', { bookingId })

View File

@@ -18,6 +18,7 @@ import { TrainSchedulingService } from '../train-scheduling/train-scheduling.ser
import { eatDay } from '../train-scheduling/batch-window.util'; import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.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 { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { import {
BookingEvaluationInput, BookingEvaluationInput,
@@ -438,7 +439,7 @@ export class BookingsService {
vgmPerUnitTons: c.vgmPerUnitTons, vgmPerUnitTons: c.vgmPerUnitTons,
totalVgmTons, totalVgmTons,
isReefer: ct.isReefer, isReefer: ct.isReefer,
wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1), wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt),
}; };
}), }),
); );

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
@@ -19,13 +20,6 @@ export interface ConsolidationAttemptResult {
messages: string[]; 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 { export function wagonRemainder(quantity: number, perWagon: number): number {
const r = quantity % perWagon; const r = quantity % perWagon;
return r; return r;
@@ -73,7 +67,7 @@ export class ConsolidationService {
const slots: ConsolidationSlot[] = []; const slots: ConsolidationSlot[] = [];
for (const [containerTypeId, quantity] of quantityByType) { for (const [containerTypeId, quantity] of quantityByType) {
const ct = await this.containerTypesService.findById(containerTypeId); const ct = await this.containerTypesService.findById(containerTypeId);
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); const perWagon = containersPerWagonForSize(ct.sizeFt);
const remainder = wagonRemainder(quantity, perWagon); const remainder = wagonRemainder(quantity, perWagon);
if (remainder === 0) continue; if (remainder === 0) continue;
slots.push({ slots.push({

View File

@@ -27,9 +27,6 @@ export class BookingReferenceContainerTypeDto {
@ApiProperty() @ApiProperty()
is_reefer!: boolean; is_reefer!: boolean;
@ApiProperty({ example: 0.5, description: 'Wagon fraction per container' })
wagons_per_unit!: number;
} }
export class BookingReferenceContainerSizeGroupDto { export class BookingReferenceContainerSizeGroupDto {

View File

@@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.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 { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { ContainerType } from '../rule-engine/entities/container-type.entity';
@@ -1053,7 +1054,7 @@ export class ContractBookingService {
bc.quantity = line.quantity; bc.quantity = line.quantity;
bc.containerTypeId = ct.id; bc.containerTypeId = ct.id;
bc.containerType = ct; 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( bc.totalVgmTons = (line.units ?? []).reduce(
(sum, u) => sum + Number(u.vgmTons ?? 0), (sum, u) => sum + Number(u.vgmTons ?? 0),
0, 0,
@@ -1513,7 +1514,7 @@ export class ContractBookingService {
: 0, : 0,
vgmPerUnitTons: vgmPerUnit, vgmPerUnitTons: vgmPerUnit,
totalVgmTons: totalVgm, totalVgmTons: totalVgm,
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)), wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)),
isOverweight: false, isOverweight: false,
overweightExcessTons: null, overweightExcessTons: null,
} as Partial<BookingContainer>), } as Partial<BookingContainer>),
@@ -1651,7 +1652,7 @@ export class ContractBookingService {
: 0, : 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons, totalVgmTons,
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)),
}), }),
), ),
}) as Booking; }) as Booking;

View File

@@ -190,12 +190,16 @@ export class PaymentService {
*/ */
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> { async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
try { try {
const snapshot = await this.paymentClient.initiate({ const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT, service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT, referenceType: PaymentReferenceType.SHIPMENT,
referenceId: input.referenceId, referenceId: input.referenceId,
orderRef: input.orderRef, orderRef: input.orderRef,
amountMinor: input.amountMinor, // amountMinor: input.amountMinor,
amountMinor:1,
currency: input.currency, currency: input.currency,
provider: input.method as ProviderMethod, provider: input.method as ProviderMethod,
platform: input.platform, platform: input.platform,

View File

@@ -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)));
}

View File

@@ -29,8 +29,7 @@ export class PriorityConfigsController {
@Get('next-range') @Get('next-range')
@RuleEngineView('priority-configs') @RuleEngineView('priority-configs')
@ApiOperation({ @ApiOperation({
summary: summary: 'Where the next contiguous range for a type (and currency) must start',
"Where the next contiguous range for a type (and currency) must start, plus the type's ceiling",
}) })
nextRange( nextRange(
@Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', @Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',

View File

@@ -1,6 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
export class CreateContainerTypeDto { export class CreateContainerTypeDto {
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 }) @ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
@@ -14,12 +13,6 @@ export class CreateContainerTypeDto {
@Max(40) @Max(40)
sizeFt!: number; 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' }) @ApiPropertyOptional({ default: false, description: 'True if this is a reefer (refrigerated) container' })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()

View File

@@ -16,9 +16,6 @@ export class ContainerType extends BaseEntity {
@Column({ name: 'size_ft', type: 'smallint', nullable: true }) @Column({ name: 'size_ft', type: 'smallint', nullable: true })
sizeFt!: number; 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 }) @Column({ name: 'is_reefer', type: 'boolean', default: false, nullable: true })
isReefer!: boolean; isReefer!: boolean;

View File

@@ -48,7 +48,6 @@ export class ContainerTypesService {
code, code,
label: dto.label, label: dto.label,
sizeFt: dto.sizeFt, sizeFt: dto.sizeFt,
wagonsPerUnit: dto.wagonsPerUnit,
isReefer: dto.isReefer ?? false, isReefer: dto.isReefer ?? false,
isOpenTop: dto.isOpenTop ?? false, isOpenTop: dto.isOpenTop ?? false,
isActive: dto.isActive ?? true, isActive: dto.isActive ?? true,

View File

@@ -5,9 +5,8 @@ import { PriorityConfigsService } from './priority-configs.service';
/** /**
* Contiguous-range rules for priority configs: per type (per currency for * 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 * CURRENCY), ranges run from 1 with no gaps and no overlaps; the next range
* must start at the lowest uncovered wagon count. Caps: WAGON 50, * must start at the lowest uncovered wagon count. There is no upper ceiling.
* CURRENCY 35, CUSTOMS 15.
*/ */
describe('PriorityConfigsService range validation', () => { describe('PriorityConfigsService range validation', () => {
const rule = ( const rule = (
@@ -118,41 +117,47 @@ describe('PriorityConfigsService range validation', () => {
).rejects.toThrow(/overlaps existing rule/); ).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( await expect(
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }), attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5000 }),
).rejects.toThrow(/may not exceed 50/); ).resolves.toBeUndefined();
await expect( await expect(
attempt(serviceWith([]), { attempt(serviceWith([]), {
type: 'CURRENCY', type: 'CURRENCY',
currency: 'USD', currency: 'USD',
minWagonCount: 1, minWagonCount: 1,
maxWagonCount: 36, maxWagonCount: 5000,
}), }),
).rejects.toThrow(/may not exceed 35/); ).resolves.toBeUndefined();
await expect( await expect(
attempt(serviceWith([]), { attempt(serviceWith([]), {
type: 'CUSTOMS', type: 'CUSTOMS',
minWagonCount: 1, 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( await expect(
attempt(serviceWith([rule('WAGON', 1, 50)]), { attempt(serviceWith([rule('WAGON', 1, 50)]), {
minWagonCount: 51, minWagonCount: 51,
maxWagonCount: 51, maxWagonCount: 120,
}), }),
).rejects.toThrow(/may not exceed 50/); ).resolves.toBeUndefined();
await expect( await expect(
attempt(serviceWith([rule('CUSTOMS', 1, 15)]), { attempt(serviceWith([rule('CUSTOMS', 1, 15)]), {
type: 'CUSTOMS', type: 'CUSTOMS',
minWagonCount: 1, minWagonCount: 16,
maxWagonCount: 1, maxWagonCount: 99,
}), }),
).rejects.toThrow(/already cover the full 115 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 () => { 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 () => { it('reports the next-range prefill for the form', async () => {
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
await expect(svc.nextRange('WAGON')).resolves.toEqual({ await expect(svc.nextRange('WAGON')).resolves.toEqual({ nextMin: 6 });
nextMin: 6, // Past the old CUSTOMS cap of 15 the chain simply continues.
maxCap: 50,
});
await expect( await expect(
serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'), 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({ await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({
nextMin: 1, nextMin: 1,
maxCap: 35,
}); });
}); });
}); });

View File

@@ -10,28 +10,19 @@ import {
} from '../interfaces/priority-configs.repository.interface'; } from '../interfaces/priority-configs.repository.interface';
import { DisplayOrderService } from './display-order.service'; 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 * 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( function nextRangeStart(
rules: Pick<PriorityConfig, 'type' | 'minWagonCount' | 'maxWagonCount'>[], rules: Pick<PriorityConfig, 'type' | 'minWagonCount' | 'maxWagonCount'>[],
): number | null { ): number {
const cap = rules.length ? RANGE_CAPS[rules[0].type] : null;
const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount); const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount);
let next = 1; let next = 1;
for (const r of sorted) { for (const r of sorted) {
if (r.minWagonCount > next) break; // gap before this rule — fill it if (r.minWagonCount > next) break; // gap before this rule — fill it
next = Math.max(next, r.maxWagonCount + 1); next = Math.max(next, r.maxWagonCount + 1);
} }
if (cap != null && next > cap) return null;
return next; return next;
} }
@@ -102,8 +93,8 @@ export class PriorityConfigsService {
* - ranges never overlap — a booking matches at most one rule per type; * - 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 * - ranges are contiguous from 1: a new range must START at the lowest
* wagon count not yet covered (after 15 the next is 6…; deleting a * wagon count not yet covered (after 15 the next is 6…; deleting a
* middle rule opens a gap and the next create must fill it first); * 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. * There is no upper ceiling — max wagon count is unbounded.
* Ranges are inclusive on both ends. * Ranges are inclusive on both ends.
*/ */
async assertNoRangeCollision(input: { async assertNoRangeCollision(input: {
@@ -118,14 +109,6 @@ export class PriorityConfigsService {
'Min wagon count cannot be greater than max wagon count', '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 = ( const siblings = (
await this.repository.findAll({ where: { type: input.type } }) await this.repository.findAll({ where: { type: input.type } })
).filter( ).filter(
@@ -142,12 +125,6 @@ export class PriorityConfigsService {
const currentStart = input.excludeId const currentStart = input.excludeId
? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null ? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null
: 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 ( if (
input.minWagonCount !== expectedStart && input.minWagonCount !== expectedStart &&
input.minWagonCount !== currentStart input.minWagonCount !== currentStart
@@ -174,21 +151,21 @@ export class PriorityConfigsService {
} }
/** /**
* Where the next range for a type/currency must start, and the type's * Where the next range for a type/currency must start — feeds the create
* ceiling — feeds the create form so the min field is auto-filled and * form so the min field is auto-filled and locked. Always a number: the
* locked. `nextMin` is null when the chain already covers 1..cap. * chain has no ceiling, so another range always fits.
*/ */
async nextRange( async nextRange(
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
currency?: string | null, currency?: string | null,
): Promise<{ nextMin: number | null; maxCap: number }> { ): Promise<{ nextMin: number }> {
const siblings = ( const siblings = (
await this.repository.findAll({ where: { type } }) await this.repository.findAll({ where: { type } })
).filter( ).filter(
(s) => (s) =>
type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null), type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null),
); );
return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] }; return { nextMin: nextRangeStart(siblings) };
} }
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {

View File

@@ -887,7 +887,7 @@ describe('BookingBatchService — wagonsFor', () => {
freightType: 'CONTAINER', freightType: 'CONTAINER',
cargoTotalWeightVgm: 210, cargoTotalWeightVgm: 210,
bookingContainers: [ bookingContainers: [
{ quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } }, { quantity: 2, wagonsRequired: 2, containerType: { sizeFt: 40 } },
], ],
}; };
expect(service.wagonsFor(booking, dims)).toBe(3); expect(service.wagonsFor(booking, dims)).toBe(3);
@@ -899,7 +899,7 @@ describe('BookingBatchService — wagonsFor', () => {
freightType: 'CONTAINER', freightType: 'CONTAINER',
cargoTotalWeightVgm: 40, cargoTotalWeightVgm: 40,
bookingContainers: [ 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); expect(service.wagonsFor(booking, dims)).toBe(2);
@@ -939,7 +939,7 @@ describe('BookingBatchService — wagonsFor', () => {
{ {
quantity: 2, quantity: 2,
wagonsRequired: 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,
});
});
});

View File

@@ -68,6 +68,7 @@ import {
wagonTypeDimensionsFromEntity, wagonTypeDimensionsFromEntity,
} from './train-capacity.util'; } from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service'; import { BookingSplitService } from './booking-split.service';
import { BookingWindowGateway } from './booking-window.gateway'; import { BookingWindowGateway } from './booking-window.gateway';
@@ -305,6 +306,9 @@ export class BookingBatchService implements OnModuleInit {
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
private readonly notifier: BookingNotifierService, private readonly notifier: BookingNotifierService,
private readonly scheduler: SchedulerRegistry, 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 trainSchedulingService: TrainSchedulingService,
private readonly billing: BillingService, private readonly billing: BillingService,
private readonly bookingWindowGateway: BookingWindowGateway, private readonly bookingWindowGateway: BookingWindowGateway,
@@ -2915,7 +2919,7 @@ export class BookingBatchService implements OnModuleInit {
? Math.ceil(booking.wagonsRequired) ? Math.ceil(booking.wagonsRequired)
: 0; : 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. // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
const byLength = containerWagonsForLines(booking.bookingContainers ?? []); 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 * Keep schedule.max_wagons aligned with the train's boarding limit. A built
* locomotive's length-derived slot count. The physical wagons currently in * train's limit is its physical consist — the wagon count staff marshalled
* the train set do NOT cap this — bookings are admitted on length/weight * (and may change via adjust-consist). Only schedules WITHOUT a built train
* alone and yard staff attach the wagons manually before departure. * 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( private async syncScheduleMaxWagons(
schedule: TrainSchedule, schedule: TrainSchedule,
locomotive: Locomotive, locomotive: Locomotive,
): Promise<void> { ): Promise<void> {
const limits = await this.capacityLimits(locomotive); const physicalWagons = await this.builtTrainWagonCount(schedule);
const maxWagons = limits.base.wagons; const maxWagons =
physicalWagons ?? (await this.capacityLimits(locomotive)).base.wagons;
if ((schedule.maxWagons ?? 0) !== maxWagons) { if ((schedule.maxWagons ?? 0) !== maxWagons) {
await this.dataSource await this.dataSource
.getRepository(TrainSchedule) .getRepository(TrainSchedule)
@@ -3122,16 +3129,31 @@ export class BookingBatchService implements OnModuleInit {
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only * reserved bookings already use ON THEIR OWN LEGS. A booking riding only
* Dire→Djibouti leaves the Addis→Dire edges untouched. * Dire→Djibouti leaves the Addis→Dire edges untouched.
* *
* The wagon axis is the locomotive's length-derived slot count only — the * Two capacity regimes, decided by the schedule's train:
* physical wagons currently marshalled in the train set do NOT cap it. * - Built train (Train Builder consist with physical wagons): the consist IS
* Bookings are admitted on length/weight capacity and yard staff attach * the capacity. Wagon slots = physical wagon count; weight and length are
* the missing wagons manually before wagon assignment. * 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( private async remainingBudget(
schedule: TrainSchedule, schedule: TrainSchedule,
limits: TrainLimits, limits: TrainLimits,
wagonDims: WagonDims, wagonDims: WagonDims,
): Promise<CorridorBudget> { ): Promise<CorridorBudget> {
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 stops = await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
const allocated = (schedule.scheduleBookings ?? []) const allocated = (schedule.scheduleBookings ?? [])
@@ -3149,6 +3171,23 @@ export class BookingBatchService implements OnModuleInit {
return budget; 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<number | null> {
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). * 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 * ≤ 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 / * Built train: FULL when every physical wagon slot is taken — the consist is
* train length for even one more loaded wagon. The old slot-only check let * the capacity, weight/length were settled at build time.
* a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of * No built train: FULL on ANY capacity axis — out of wagon slots, or out of
* 3500+90T, slots bind at 44) cycle its booking window forever instead of * pull weight / train length for even one more loaded wagon. The old
* finalizing — 7 phantom slots kept it "not full" while nothing could board. * 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<boolean> { async isScheduleFull(scheduleId: string): Promise<boolean> {
const schedule = const schedule =
@@ -3232,9 +3274,53 @@ export class BookingBatchService implements OnModuleInit {
return this.isTrainFull(schedule); 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. */ /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> { private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
if ((await this.remainingWagons(schedule)) <= 0) return true; 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; const locomotive = schedule.trainSet?.locomotive;
if (!locomotive) return false; // no weight/length limits to bind against if (!locomotive) return false; // no weight/length limits to bind against
const wagonDims = await this.loadWagonDims(); const wagonDims = await this.loadWagonDims();

View File

@@ -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 // 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. // fraction. Ceiling per line would over-count split 20ft lines.
return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
} }

View File

@@ -12,6 +12,8 @@
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
forwardRef,
Inject,
Injectable, Injectable,
Logger, Logger,
NotFoundException, NotFoundException,
@@ -96,6 +98,7 @@ import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
import { type BookingWindowConfig } from './booking-window.config'; import { type BookingWindowConfig } from './booking-window.config';
import { BookingWindowGateway } from './booking-window.gateway'; import { BookingWindowGateway } from './booking-window.gateway';
import { BookingNotifierService } from './booking-notifier.service'; import { BookingNotifierService } from './booking-notifier.service';
import { BookingBatchService } from './booking-batch.service';
import { import {
computeFleetAvailability, computeFleetAvailability,
summarizeFleetWarnings, summarizeFleetWarnings,
@@ -318,6 +321,11 @@ export class TrainSchedulingService {
private readonly bookingNotifier: BookingNotifierService, private readonly bookingNotifier: BookingNotifierService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly milestoneService?: ClearanceMilestoneService,
private readonly configService?: ConfigService, 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,
) {} ) {}
/** /**
@@ -5109,6 +5117,11 @@ export class TrainSchedulingService {
wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0), 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) => ({ const mapWagon = (wagon: Wagon) => ({
id: wagon.id, id: wagon.id,
wagonNumber: wagon.wagonNumber, wagonNumber: wagon.wagonNumber,
@@ -5147,6 +5160,12 @@ export class TrainSchedulingService {
grossTons: roundTons(cargoTons + consistTareTons), grossTons: roundTons(cargoTons + consistTareTons),
consistLengthMeters, consistLengthMeters,
}, },
scheduleCapacity: wagonUsage
? {
...wagonUsage,
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
}
: null,
wagons: wagons.map((wagon) => ({ wagons: wagons.map((wagon) => ({
...mapWagon(wagon), ...mapWagon(wagon),
loaded: loadedWagonIds.has(wagon.id), loaded: loadedWagonIds.has(wagon.id),
@@ -5339,7 +5358,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 };
} }
/** /**

View File

@@ -77,7 +77,6 @@ describe('planWagonsWithStock — shortage detail', () => {
fortyFooter.bookingContainers![0]!.containerType = { fortyFooter.bookingContainers![0]!.containerType = {
code: '40GP', code: '40GP',
sizeFt: 40, sizeFt: 40,
wagonsPerUnit: 1,
} as never; } as never;
const result = planWagonsWithStock({ const result = planWagonsWithStock({
bookings: [fortyFooter], bookings: [fortyFooter],

View File

@@ -106,7 +106,7 @@ describe('wagon-plan.util', () => {
}); });
it('6×20ft containers = 3 wagon slots (2 per wagon)', () => { 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 }]); const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]);
expect(sumWagonsRequired(booking)).toBe(3); expect(sumWagonsRequired(booking)).toBe(3);
const plan = buildContainerWagonPlan([booking], nw5); 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) => ({ const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({
quantity, quantity,
wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit, wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit,
containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, containerType: { sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
}); });
it('20×20ft = 10 wagons (not 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); 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. // No containerType relation loaded → use the stored (0.5-aware) fraction.
expect( expect(
containerWagonsForLines([ containerWagonsForLines([

View File

@@ -1,6 +1,7 @@
import { AllocationLoadType } from '@edr/types'; import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity'; 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 { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { consistViolations } from './train-capacity.util'; import { consistViolations } from './train-capacity.util';
@@ -61,7 +62,6 @@ export type ContainerUnitRow = {
label: string; label: string;
grossWeightTons: number; grossWeightTons: number;
sizeFt?: number; sizeFt?: number;
wagonsPerUnit?: number;
containersPerWagon?: number; containersPerWagon?: number;
teuSlots?: number; teuSlots?: number;
containerNumber?: string | null; containerNumber?: string | null;
@@ -95,33 +95,28 @@ export function teuSlotsForSizeFt(sizeFt: number): number {
return sizeFt >= 40 ? 2 : 1; 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 = { type ContainerLine = {
quantity?: number | null; quantity?: number | null;
wagonsRequired?: 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 * RAW (un-ceiled) wagon fraction one container line occupies: qty × size-derived
* (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so * fraction (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept
* the BOOKING total is ceiled once — ceiling per line over-counts a booking that * fractional so the BOOKING total is ceiled once — ceiling per line over-counts a
* splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4). * booking that splits its 20ft units across several lines (3×20 + 3×20 = 3
* wagons, not 4).
*/ */
function lineWagonsRaw(line: ContainerLine): number { function lineWagonsRaw(line: ContainerLine): number {
const qty = Number(line.quantity ?? 0); const qty = Number(line.quantity ?? 0);
if (qty <= 0) return 0; if (qty <= 0) return 0;
const wpu = Number(line.containerType?.wagonsPerUnit); const sizeFt = Number(line.containerType?.sizeFt);
if (Number.isFinite(wpu) && wpu > 0) { if (Number.isFinite(sizeFt) && sizeFt > 0) {
return qty * wpu; return qty * wagonsPerUnitForSize(sizeFt);
} }
// No wagonsPerUnit on the type: fall back to the line's stored fraction, else // No size on the type: fall back to the line's stored fraction, else treat
// treat the whole line as one wagon. // the whole line as one wagon.
const stored = Number(line.wagonsRequired); const stored = Number(line.wagonsRequired);
return Number.isFinite(stored) && stored > 0 ? stored : 1; 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 qty = Number(line.quantity ?? 0);
const code = line.containerType?.code ?? line.containerType?.label ?? 'Container'; const code = line.containerType?.code ?? line.containerType?.label ?? 'Container';
const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20)); const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20));
const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); const perWagon = containersPerWagonForSize(sizeFt);
const perWagon = containersPerWagonFromType(wagonsPerUnit);
const teuSlots = teuSlotsForSizeFt(sizeFt); const teuSlots = teuSlotsForSizeFt(sizeFt);
// The REAL per-container numbers/weights entered at booking time. Unit i of // 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 // 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}`, label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons), grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
sizeFt, sizeFt,
wagonsPerUnit,
containersPerWagon: perWagon, containersPerWagon: perWagon,
teuSlots, teuSlots,
containerNumber: containerNumber:

View File

@@ -209,7 +209,6 @@ async function ensureReferences(manager: any) {
code: '40FT', code: '40FT',
label: '40FT', label: '40FT',
sizeFt: 40, sizeFt: 40,
wagonsPerUnit: 1,
isReefer: false, isReefer: false,
isOpenTop: false, isOpenTop: false,
isActive: true, isActive: true,

View File

@@ -118,7 +118,6 @@ async function main() {
code: '40FT', code: '40FT',
label: '40FT', label: '40FT',
sizeFt: 40, sizeFt: 40,
wagonsPerUnit: 1,
isReefer: false, isReefer: false,
isOpenTop: false, isOpenTop: false,
isActive: true, isActive: true,

View File

@@ -12,6 +12,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-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 { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity';
@@ -115,7 +116,7 @@ async function main() {
reeferQuantity: 0, reeferQuantity: 0,
vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)), vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)),
totalVgmTons: Number((weightKg / 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, isOverweight: false,
}), }),
); );

View File

@@ -11,6 +11,7 @@ import {
} from '../modules/companies/entities/company.entity'; } from '../modules/companies/entities/company.entity';
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
import { LastMile } from '../modules/last-mile/entities/last-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 { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity';
@@ -223,7 +224,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder {
await manager.getRepository(ContainerType).upsert( await manager.getRepository(ContainerType).upsert(
CONTAINER_TYPES.map((containerType, index) => ({ CONTAINER_TYPES.map((containerType, index) => ({
...containerType, ...containerType,
wagonsPerUnit: 1,
isReefer: false, isReefer: false,
isOpenTop: false, isOpenTop: false,
isActive: true, isActive: true,
@@ -276,7 +276,7 @@ export class ApprovedFirstLastMileDemoBookingsSeeder {
} }
const wagonsRequired = const wagonsRequired =
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt);
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
await manager.getRepository(Booking).upsert( await manager.getRepository(Booking).upsert(

View File

@@ -14,6 +14,7 @@ import { ServiceType } from "../modules/rule-engine/entities/service-type.entity
import { Yard } from "../modules/rule-engine/entities/yard.entity"; import { Yard } from "../modules/rule-engine/entities/yard.entity";
import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity";
import { CargoType } from "../modules/rule-engine/entities/cargo-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 { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
import { Container } from "../modules/container-management/entities/container.entity"; import { Container } from "../modules/container-management/entities/container.entity";
import { Route } from "../modules/routes/entities/route.entity"; import { Route } from "../modules/routes/entities/route.entity";
@@ -300,7 +301,6 @@ export class DemoBookingsSeeder {
await manager.getRepository(ContainerType).upsert( await manager.getRepository(ContainerType).upsert(
CONTAINER_TYPES.map((containerType, index) => ({ CONTAINER_TYPES.map((containerType, index) => ({
...containerType, ...containerType,
wagonsPerUnit: 1,
isReefer: false, isReefer: false,
isOpenTop: false, isOpenTop: false,
isActive: true, isActive: true,
@@ -400,7 +400,7 @@ export class DemoBookingsSeeder {
.getRepository(BookingContainer) .getRepository(BookingContainer)
.delete({ bookingId: booking.id }); .delete({ bookingId: booking.id });
const wagonsRequired = const wagonsRequired =
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt);
await manager.getRepository(BookingContainer).insert({ await manager.getRepository(BookingContainer).insert({
id: randomUUID(), id: randomUUID(),

View File

@@ -7,6 +7,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity';
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
import { LastMile } from '../modules/last-mile/entities/last-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 { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity';
@@ -145,7 +146,6 @@ export class PaidImportExportMileDemoSeeder {
await manager.getRepository(ContainerType).upsert( await manager.getRepository(ContainerType).upsert(
CONTAINER_TYPES.map((containerType, index) => ({ CONTAINER_TYPES.map((containerType, index) => ({
...containerType, ...containerType,
wagonsPerUnit: 1,
isReefer: false, isReefer: false,
isOpenTop: false, isOpenTop: false,
isActive: true, isActive: true,
@@ -199,7 +199,7 @@ export class PaidImportExportMileDemoSeeder {
const isImport = demoBooking.tradeDirection === 'IMPORT'; const isImport = demoBooking.tradeDirection === 'IMPORT';
const wagonsRequired = const wagonsRequired =
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt);
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
await manager.getRepository(Booking).upsert( await manager.getRepository(Booking).upsert(

View File

@@ -115,7 +115,6 @@ export class PricingDataSeeder {
code: "20FT", code: "20FT",
label: "20FT Standard", label: "20FT Standard",
sizeFt: 20, sizeFt: 20,
wagonsPerUnit: 0.5,
isReefer: false, isReefer: false,
isOpenTop: false, isOpenTop: false,
isActive: true, isActive: true,
@@ -125,7 +124,6 @@ export class PricingDataSeeder {
code: "40FT", code: "40FT",
label: "40FT Standard", label: "40FT Standard",
sizeFt: 40, sizeFt: 40,
wagonsPerUnit: 1,
isReefer: false, isReefer: false,
isOpenTop: false, isOpenTop: false,
isActive: true, isActive: true,
@@ -135,7 +133,6 @@ export class PricingDataSeeder {
code: "20FT_REEFER", code: "20FT_REEFER",
label: "20FT Reefer", label: "20FT Reefer",
sizeFt: 20, sizeFt: 20,
wagonsPerUnit: 0.5,
isReefer: true, isReefer: true,
isOpenTop: false, isOpenTop: false,
isActive: true, isActive: true,
@@ -145,7 +142,6 @@ export class PricingDataSeeder {
code: "40FT_REEFER", code: "40FT_REEFER",
label: "40FT Reefer", label: "40FT Reefer",
sizeFt: 40, sizeFt: 40,
wagonsPerUnit: 1,
isReefer: true, isReefer: true,
isOpenTop: false, isOpenTop: false,
isActive: true, isActive: true,

View File

@@ -197,20 +197,20 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceEtActions,
], ],
}, },
{ // {
label: "Shipment Requests", // label: "Shipment Requests",
href: "/dashboard/shipment-requests", // href: "/dashboard/shipment-requests",
icon: <Send />, // icon: <Send />,
permission: FREIGHT_PERMS.contracts.createBooking, // permission: FREIGHT_PERMS.contracts.createBooking,
}, // },
// Operations Path A queue: per-booking self-clearance review for // Operations Path A queue: per-booking self-clearance review for
// GENERAL non-customs booking instances (and legacy self-clear bookings). // GENERAL non-customs booking instances (and legacy self-clear bookings).
{ // {
label: "Self-Clearance Review", // label: "Self-Clearance Review",
href: "/dashboard/contracts/ops-clearance", // href: "/dashboard/contracts/ops-clearance",
icon: <ShieldCheck />, // icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview, // permission: FREIGHT_PERMS.contracts.opsClearanceReview,
}, // },
{ {
label: "GL Djibouti Clearance", label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance", href: "/dashboard/gl-djibouti/clearance",

View File

@@ -65,7 +65,10 @@ export default function AdjustConsistModal({
} }
}, [opened]); }, [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(() => { const projection = useMemo(() => {
if (!data) return null; if (!data) return null;
const removed = new Set(removeIds); 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 tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0);
const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0); const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0);
const gross = round2(data.totals.cargoTons + tare); 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 { return {
wagonCount: data.totals.wagonCount - removeIds.length + addIds.length, wagonCount,
tare: round2(tare), tare: round2(tare),
gross, gross,
length: round2(length), length: round2(length),
@@ -93,16 +99,33 @@ export default function AdjustConsistModal({
overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons, overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons,
overLength: overLength:
data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters, 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]); }, [data, removeIds, addIds]);
const hasChanges = removeIds.length > 0 || addIds.length > 0;
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) => const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id))); setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
const handleSubmit = async () => { const handleSubmit = async () => {
if (!removeIds.length && !addIds.length) return; if (!removeIds.length && !addIds.length) return;
try { try {
await adjust.mutateAsync({ const result = await adjust.mutateAsync({
scheduleId, scheduleId,
payload: { payload: {
...(addIds.length ? { addWagonIds: addIds } : {}), ...(addIds.length ? { addWagonIds: addIds } : {}),
@@ -114,6 +137,18 @@ export default function AdjustConsistModal({
removeIds.length && addIds.length ? ", " : "" removeIds.length && addIds.length ? ", " : ""
}${addIds.length ? `${addIds.length} added` : ""}`, }${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([]); setRemoveIds([]);
setAddIds([]); setAddIds([]);
} catch (err) { } catch (err) {
@@ -169,8 +204,57 @@ export default function AdjustConsistModal({
over={projection?.overLength ?? false} over={projection?.overLength ?? false}
/> />
</Grid.Col> </Grid.Col>
{projection?.slots ? (
<Grid.Col span={12}>
<LimitGauge
label="Booking slots — the consist is the schedule's capacity"
detail={`${projection.slots.allocated} of ${projection.wagonCount} projected wagon slot(s) held by bookings${
projection.slots.free > 0
? `${projection.slots.free} free`
: projection.slots.free === 0
? " — none free (FULL)"
: ""
}`}
pct={projection.slots.pct}
over={projection.slots.overAllocated}
/>
</Grid.Col>
) : null}
</Grid> </Grid>
{projection?.slots?.isFullNow && !hasChanges ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
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.
</Alert>
) : null}
{hasChanges && projection?.slots?.overAllocated ? (
<Alert color="red" icon={<AlertTriangle size={16} />}>
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.
</Alert>
) : null}
{hasChanges &&
projection?.slots &&
!projection.slots.overAllocated &&
projection.slots.willBeFull &&
!projection.slots.isFullNow ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
This change takes the last free wagon slot the schedule becomes
FULL and stops accepting bookings.
</Alert>
) : null}
{hasChanges && projection?.slots?.willReopen ? (
<Alert color="blue" icon={<AlertTriangle size={16} />}>
This schedule is currently FULL applying frees{" "}
{projection.slots.free} wagon slot(s) and reopens its booking
window.
</Alert>
) : null}
<Grid gap="md"> <Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}> <Grid.Col span={{ base: 12, md: 6 }}>
<Stack gap="xs"> <Stack gap="xs">

View File

@@ -5,7 +5,6 @@ import type { ContainerUnitRow } from '@/types/trainScheduling';
function makeUnits(containerType: string, sizeFt: number, quantity: number): ContainerUnitRow[] { function makeUnits(containerType: string, sizeFt: number, quantity: number): ContainerUnitRow[] {
const units: ContainerUnitRow[] = []; const units: ContainerUnitRow[] = [];
const containersPerWagon = sizeFt >= 40 ? 1 : 2; const containersPerWagon = sizeFt >= 40 ? 1 : 2;
const wagonsPerUnit = sizeFt >= 40 ? 1 : 0.5;
for (let i = 0; i < quantity; i++) { for (let i = 0; i < quantity; i++) {
units.push({ units.push({
@@ -18,7 +17,6 @@ function makeUnits(containerType: string, sizeFt: number, quantity: number): Con
label: `${containerType} ${i + 1}/${quantity}`, label: `${containerType} ${i + 1}/${quantity}`,
grossWeightTons: 25, grossWeightTons: 25,
sizeFt, sizeFt,
wagonsPerUnit,
containersPerWagon, containersPerWagon,
teuSlots: sizeFt >= 40 ? 2 : 1, teuSlots: sizeFt >= 40 ? 2 : 1,
}); });

View File

@@ -61,7 +61,6 @@ interface RefContainerType {
name: string; name: string;
code: string; code: string;
is_reefer?: boolean; is_reefer?: boolean;
wagons_per_unit?: number;
} }
interface RefContainerGroup { interface RefContainerGroup {
size: string; size: string;

View File

@@ -384,7 +384,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Max wagon count", label: "Max wagon count",
type: "number", type: "number",
required: true, 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: "scorePoints", label: "Score points", type: "number", required: true },
{ name: "isActive", label: "Active", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" },

View File

@@ -1,20 +1,14 @@
/** /**
* Client mirror of the backend's contiguous-range rules for priority configs * Client mirror of the backend's contiguous-range rules for priority configs
* (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per * (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per
* currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next * currency for CURRENCY — run from 1 with no gaps and no overlaps, so the next
* range always starts at the lowest uncovered wagon count. The backend * range always starts at the lowest uncovered wagon count. There is no upper
* re-validates on submit AND on approval; this only drives the form prefill. * ceiling. The backend re-validates on submit AND on approval; this only
* drives the form prefill.
*/ */
export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS"; 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<PriorityRuleType, number> = {
WAGON: 50,
CURRENCY: 35,
CUSTOMS: 15,
};
export interface PriorityRangeRule { export interface PriorityRangeRule {
id?: unknown; id?: unknown;
type?: unknown; type?: unknown;
@@ -23,10 +17,13 @@ export interface PriorityRangeRule {
maxWagonCount?: unknown; maxWagonCount?: unknown;
} }
const PRIORITY_RULE_TYPES: PriorityRuleType[] = ["WAGON", "CURRENCY", "CUSTOMS"];
/** /**
* Where the next range for `type` (+`currency`) must start, excluding * Where the next range for `type` (+`currency`) must start, excluding
* `excludeId` (the rule being edited). Null when the chain already covers * `excludeId` (the rule being edited). Null only when `type` is not yet a
* 1..cap — no further rule fits. * known priority rule type — the chain itself is unbounded, so a next start
* always exists.
*/ */
export function nextPriorityRangeStart( export function nextPriorityRangeStart(
rules: PriorityRangeRule[], rules: PriorityRangeRule[],
@@ -34,8 +31,7 @@ export function nextPriorityRangeStart(
currency: string | null | undefined, currency: string | null | undefined,
excludeId?: string, excludeId?: string,
): number | null { ): number | null {
const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType]; if (!PRIORITY_RULE_TYPES.includes(type as PriorityRuleType)) return null;
if (!cap) return null;
const scoped = rules const scoped = rules
.filter( .filter(
@@ -56,5 +52,5 @@ export function nextPriorityRangeStart(
if (r.min > next) break; // gap before this rule — fill it first if (r.min > next) break; // gap before this rule — fill it first
next = Math.max(next, r.max + 1); next = Math.max(next, r.max + 1);
} }
return next > cap ? null : next; return next;
} }

View File

@@ -185,6 +185,7 @@ import { trainService, type Train } from "./trains.service";
import { import {
trainBuilderService, trainBuilderService,
type AdjustConsistPayload, type AdjustConsistPayload,
type AdjustConsistResult,
type AvailableTrain, type AvailableTrain,
type BuildTrainPayload, type BuildTrainPayload,
type BuiltTrainListFilters, type BuiltTrainListFilters,
@@ -338,7 +339,7 @@ export const api = {
adjustConsist: endpoint< adjustConsist: endpoint<
{ scheduleId: string; payload: AdjustConsistPayload }, { scheduleId: string; payload: AdjustConsistPayload },
ScheduleConsist AdjustConsistResult
>( >(
"train-scheduling", "train-scheduling",
"adjust-consist", "adjust-consist",

View File

@@ -235,6 +235,18 @@ export interface ScheduleConsist {
occurredAt: string; occurredAt: string;
}>; }>;
editable: boolean; 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 { export interface AdjustConsistPayload {
@@ -242,6 +254,9 @@ export interface AdjustConsistPayload {
removeWagonIds?: string[]; removeWagonIds?: string[];
} }
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
export const trainBuilderService = { export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) => list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`), apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
@@ -275,7 +290,7 @@ export const trainBuilderService = {
apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`), apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`),
/** Permanently trim/add wagons on the schedule's built train. */ /** Permanently trim/add wagons on the schedule's built train. */
adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) => adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
apiClient.post<ScheduleConsist>( apiClient.post<AdjustConsistResult>(
`/train-scheduling/schedules/${scheduleId}/adjust-consist`, `/train-scheduling/schedules/${scheduleId}/adjust-consist`,
payload, payload,
), ),

View File

@@ -72,7 +72,6 @@ export interface ContainerUnitRow {
label: string; label: string;
grossWeightTons: number; grossWeightTons: number;
sizeFt?: number; sizeFt?: number;
wagonsPerUnit?: number;
containersPerWagon?: number; containersPerWagon?: number;
teuSlots?: number; teuSlots?: number;
containerNumber?: string | null; containerNumber?: string | null;

View File

@@ -853,7 +853,6 @@ export interface BookingReferenceContainerType {
name: string; name: string;
code: string; code: string;
is_reefer: boolean; is_reefer: boolean;
wagons_per_unit: number;
} }
export interface BookingReferenceContainerSizeGroup { export interface BookingReferenceContainerSizeGroup {