From 106b07a02be58c09d960310bd65742ae1f10d164 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 12:32:10 +0000 Subject: [PATCH 1/3] add staff user list endpoint --- .../modules/auth/dto/list-users-query.dto.ts | 38 ++++++++++ .../src/modules/auth/freight-auth.module.ts | 4 ++ .../src/modules/auth/list-users.controller.ts | 22 ++++++ .../src/modules/auth/list-users.service.ts | 69 +++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts create mode 100644 apps/edr-freight-api/src/modules/auth/list-users.controller.ts create mode 100644 apps/edr-freight-api/src/modules/auth/list-users.service.ts diff --git a/apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts new file mode 100644 index 000000000..2325568e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts @@ -0,0 +1,38 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { EUserStatus, EUserType } from '@tria-plc/api-common/utils/enums/user.enum'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsBoolean, IsEnum, IsIn, IsOptional } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; + +/** Query-string booleans arrive as strings; implicit conversion is off app-wide. */ +const toOptionalBoolean = ({ value }: TransformFnParams): boolean | undefined => + value === undefined || value === null || value === '' + ? undefined + : value === true || value === 'true'; + +export class ListUsersQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ enum: EUserType }) + @IsOptional() + @IsEnum(EUserType) + userType?: EUserType; + + @ApiPropertyOptional({ enum: EUserStatus }) + @IsOptional() + @IsEnum(EUserStatus) + userStatus?: EUserStatus; + + @ApiPropertyOptional({ description: 'Filter by active flag.' }) + @IsOptional() + @Transform(toOptionalBoolean) + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ + enum: ['username', 'email', 'createdAt'], + default: 'username', + }) + @IsOptional() + @IsIn(['username', 'email', 'createdAt']) + sortBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 10dbd0b37..ff8f803b9 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -19,6 +19,8 @@ import { ForgotPasswordController } from './forgot-password.controller'; import { ForgotPasswordService } from './forgot-password.service'; import { FreightMeController } from './freight-me.controller'; import { FreightMeService } from './freight-me.service'; +import { ListUsersController } from './list-users.controller'; +import { ListUsersService } from './list-users.service'; @Module({ imports: [ @@ -39,8 +41,10 @@ import { FreightMeService } from './freight-me.service'; CheckAvailabilityController, ForgotPasswordController, CustomerResetController, + ListUsersController, ], providers: [ + ListUsersService, FreightMeService, AccountService, CheckAvailabilityService, diff --git a/apps/edr-freight-api/src/modules/auth/list-users.controller.ts b/apps/edr-freight-api/src/modules/auth/list-users.controller.ts new file mode 100644 index 000000000..e7fbfd771 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/list-users.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { ListUsersQueryDto } from './dto/list-users-query.dto'; +import { ListUsersService } from './list-users.service'; +import { StaffReference } from '../../common/booking-guards'; + +@ApiTags('auth') +@Controller('staff/users') +@ApiBearerAuth() +export class ListUsersController { + constructor(private readonly service: ListUsersService) {} + + @Get() + @StaffReference() + @ApiOperation({ + summary: 'List IAM users (paginated) for backoffice pickers', + }) + findAll(@Query() query: ListUsersQueryDto) { + return this.service.findAll(query); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/list-users.service.ts b/apps/edr-freight-api/src/modules/auth/list-users.service.ts new file mode 100644 index 000000000..cf7e22be2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/list-users.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { PaginatedResponse } from '@edr/types'; +import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; +import { Repository } from 'typeorm'; + +import { ListUsersQueryDto } from './dto/list-users-query.dto'; +import { paginateQuery } from '../../common/utils/pagination.util'; + +/** + * Read-only listing of `iam.users` for backoffice pickers. + * + * Exists because `@tria-plc/iamapi-common@1.0.0`'s `GET /users/filter` pairs a + * `@QueryParams()` pagination DTO with a plain `@Query()` DTO that does not + * declare `skip`/`take`/`orderBy`; the global whitelist pipe then 400s on the + * very params the route's own paginator reads. Drop this once IAM ships a fix. + */ +@Injectable() +export class ListUsersService { + constructor( + @InjectRepository(User) private readonly users: Repository, + ) {} + + findAll(query: ListUsersQueryDto): Promise> { + const sortBy = query.sortBy ?? 'username'; + const qb = this.users + .createQueryBuilder('user') + // Explicit select: never widen this to `user` — the entity's lazy + // relations include credentials and sessions. + .select([ + 'user.id', + 'user.name', + 'user.username', + 'user.email', + 'user.phoneNumber', + 'user.userType', + 'user.status', + 'user.isActive', + 'user.createdAt', + ]) + .orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC'); + + if (query.userType) { + qb.andWhere('user.userType = :userType', { userType: query.userType }); + } + if (query.userStatus) { + qb.andWhere('user.status = :userStatus', { userStatus: query.userStatus }); + } + if (query.isActive !== undefined) { + qb.andWhere('user.isActive = :isActive', { isActive: query.isActive }); + } + if (query.search) { + // `name` is localized jsonb ({ en, am, … }), not a string — match its + // values rather than casting the whole object to text. + qb.andWhere( + `(user.username ILIKE :search + OR user.email ILIKE :search + OR user.phone_number ILIKE :search + OR EXISTS ( + SELECT 1 FROM jsonb_each_text(user.name) AS n(k, v) + WHERE n.v ILIKE :search + ))`, + { search: `%${query.search}%` }, + ); + } + + return paginateQuery(qb, query); + } +} From 9afc281d21994fc87a62f8916bb37cc2a0039405 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 13:06:43 +0000 Subject: [PATCH 2/3] fix issue: user trade access --- .../train-scheduling/booking-batch.service.ts | 1 + .../trade-scope.util.spec.ts | 64 +++++++++++++++++++ .../pages/configuration/TradeAccessPage.tsx | 10 +-- .../src/services/userTradeAccess.service.ts | 20 ++++++ 4 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.spec.ts 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 efdf4f691..33730b5ae 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 @@ -3020,6 +3020,7 @@ export class BookingBatchService implements OnModuleInit { : booking.status; await manager.getRepository(Booking).update(bookingId, { trainScheduleId: newScheduleId, + scheduledDate: schedule.scheduledDepartureDate, status: restoredStatus, // A paid booking still hunting for a wagon keeps its flag through the // move — it only clears when wagons are actually assigned. diff --git a/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.spec.ts b/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.spec.ts new file mode 100644 index 000000000..8823098ab --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.spec.ts @@ -0,0 +1,64 @@ +import { applyDirectionScope, scopedDirections } from './trade-scope.util'; + +/** + * The scope decides what a restricted user may see, so the cases that matter + * are the ones where a wrong answer widens access: an unrestricted fallback + * where a restriction was configured, or an out-of-scope explicit filter + * being honoured instead of denied. + */ +describe('scopedDirections', () => { + it('leaves an unrestricted user unfiltered', () => { + expect(scopedDirections(null)).toBeNull(); + }); + + it('honours an explicit filter for an unrestricted user', () => { + expect(scopedDirections(null, 'EXPORT')).toEqual(['EXPORT']); + }); + + it('falls back to the full scope when no filter is requested', () => { + expect(scopedDirections(['EXPORT'])).toEqual(['EXPORT']); + }); + + it('narrows to the intersection when the filter is in scope', () => { + expect(scopedDirections(['IMPORT', 'EXPORT'], 'EXPORT')).toEqual(['EXPORT']); + }); + + it('denies an out-of-scope filter instead of widening access', () => { + expect(scopedDirections(['EXPORT'], 'IMPORT')).toEqual([]); + }); +}); + +describe('applyDirectionScope', () => { + const makeQb = () => { + const calls: { sql: string; params?: object }[] = []; + const qb = { + calls, + andWhere(sql: string, params?: object) { + calls.push({ sql, params }); + return qb; + }, + }; + return qb; + }; + + it('does not touch the query when unrestricted', () => { + const qb = makeQb(); + applyDirectionScope(qb as never, 'booking.trade_direction', null); + expect(qb.calls).toHaveLength(0); + }); + + it('matches nothing on an empty scope rather than everything', () => { + const qb = makeQb(); + applyDirectionScope(qb as never, 'booking.trade_direction', []); + expect(qb.calls[0].sql).toBe('1 = 0'); + }); + + it('filters to the allowed directions', () => { + const qb = makeQb(); + applyDirectionScope(qb as never, 'booking.trade_direction', ['EXPORT']); + expect(qb.calls[0].sql).toContain('booking.trade_direction IN'); + expect(qb.calls[0].params).toEqual({ + scopeDirs_booking_trade_direction: ['EXPORT'], + }); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx b/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx index 2c447d72d..842f11064 100644 --- a/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx @@ -2,10 +2,6 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import toast from "react-hot-toast"; -import { - useAllExternalUsers, - userTypeEnum, -} from "@/super-admin/hooks/useExternalUsers"; import { ALL_TRADE_DIRECTIONS, TRADE_DIRECTION_LABELS, @@ -39,9 +35,9 @@ export default function TradeAccessPage() { const queryClient = useQueryClient(); const [search, setSearch] = useState(""); - const { data: usersResponse, isLoading: usersLoading } = useAllExternalUsers({ - userType: userTypeEnum.employee, - take: 3000, + const { data: usersResponse, isLoading: usersLoading } = useQuery({ + queryKey: ["staff-users", "employees"], + queryFn: userTradeAccessService.employees, }); const { data: configs, isLoading: configsLoading } = useQuery({ diff --git a/apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts b/apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts index 8931762d5..3570e004d 100644 --- a/apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts @@ -25,7 +25,27 @@ export interface MyTradeAccess { directions: TradeDirection[]; } +export interface StaffUser { + id: string; + /** Localized jsonb on iam.users — not a plain string. */ + name: { en?: string; am?: string } | null; + username: string; + email: string | null; +} + export const userTradeAccessService = { + /** + * Employees to assign scopes to. Served by the freight API rather than IAM's + * `/users/filter`, which 400s on its own pagination params (its @Query() DTO + * omits skip/take/orderBy while the global whitelist pipe rejects them). + */ + employees: async (): Promise<{ items: StaffUser[] }> => + ( + await client.get("/staff/users", { + params: { userType: "employee", pageSize: 100 }, + }) + ).data, + /** All configured per-user scopes (admin only). */ list: async (): Promise => (await client.get("/user-trade-access")).data, From 488c2465befef628f2269554b4010adb9e5089ba Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 19:16:28 +0000 Subject: [PATCH 3/3] feat: add per-ton cargo loading limits and update related services --- ...90000000000-AddCargoTypeTonsPerWagonMap.ts | 25 ++++++ .../bookings/booking-pricing.service.ts | 8 +- .../rule-engine/dto/create-cargo-type.dto.ts | 13 +++ .../rule-engine/entities/cargo-type.entity.ts | 11 +++ .../modules/rule-engine/rule-engine.module.ts | 4 + .../services/cargo-types.service.ts | 90 ++++++++++++++++++- .../train-scheduling/booking-batch.service.ts | 21 ++++- .../train-scheduling/fleet-plan.util.ts | 8 +- .../train-capacity.util.spec.ts | 59 ++++++++++++ .../train-scheduling/train-capacity.util.ts | 90 +++++++++++++++++++ .../train-scheduling.service.ts | 7 +- .../train-scheduling/wagon-plan-flex.util.ts | 11 +-- .../train-scheduling/wagon-plan.util.ts | 36 +++++++- .../src/pages/ruleEngine/CargoTypesPage.tsx | 32 ++++++- 14 files changed, 394 insertions(+), 21 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts diff --git a/apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts b/apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts new file mode 100644 index 000000000..04fb6c1aa --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * PER_TON (bulk) cargo can have a loading limit BELOW the wagon's rated + * capacity: sugar rides 50T on a 70T wagon (density/stowage/policy), so 200T + * needs 4 wagons, not the 3 that raw capacity implies. Stored as a jsonb map + * { [wagonTypeId]: maxTons } on cargo_types — the PER_TON mirror of + * items_per_wagon_map. Unset (or no key) means the wagon's full rated capacity, + * so existing cargo types keep their current behaviour with no backfill. + */ +export class AddCargoTypeTonsPerWagonMap3190000000000 implements MigrationInterface { + name = 'AddCargoTypeTonsPerWagonMap3190000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "tons_per_wagon_map" jsonb`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "tons_per_wagon_map"`, + ); + } +} 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 b44c1c5e0..9bbe6bed5 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 @@ -16,7 +16,7 @@ import { containersPerWagonForSize, wagonsPerUnitForSize, } from '../rule-engine/container-type.util'; -import { bulkItemWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util'; +import { bulkWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util'; import { BookingsRepository } from './bookings.repository'; import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -1190,8 +1190,10 @@ export class BookingPricingService { // Break-bulk (PER_ITEM): `tons` above is the item count; size by // indivisible items instead of pretending the count is tonnage. Best // count across allowed wagon types, each capped by its items-fit. - const byItems = bulkItemWagonsForAllowedTypes(booking, cargo, capacity); - if (byItems > 0) return byItems; + // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a + // 70T wagon) — 200T then prices 4 wagons, not 3. + const byWagons = bulkWagonsForAllowedTypes(booking, cargo, capacity); + if (byWagons > 0) return byWagons; return Math.max(1, Math.ceil(tons / capacity)); } catch { return null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 6d9cb82a2..eefc560e8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -44,6 +44,19 @@ export class CreateCargoTypeDto { @IsObject() itemsPerWagonMap?: Record | null; + @ApiPropertyOptional({ + description: + 'PER_TON cargo only: the most tons of this cargo one wagon may carry, keyed by ' + + 'wagon-type id (e.g. { "": 50 } loads sugar 50T on a 70T wagon, so 200T ' + + 'takes 4 wagons). Optional — omit a wagon type to use its full rated capacity. ' + + 'Rejected when it exceeds that wagon type\'s rated capacity.', + type: 'object', + additionalProperties: { type: 'number', minimum: 0.001 }, + }) + @IsOptional() + @IsObject() + tonsPerWagonMap?: Record | null; + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index b096613bf..e685f3a2d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -60,6 +60,17 @@ export class CargoType extends BaseEntity { @Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true }) itemsPerWagonMap?: Record | null; + /** + * PER_TON (bulk) only: the most tons of THIS cargo that may ride one wagon of + * each allowed type, keyed by wagon-type id (e.g. sugar → { NW5: 50 } on a + * 70T wagon). Caps both the wagon count and how much each wagon is loaded, so + * 200T of sugar takes 4 wagons at 50T rather than 3 at 70T. A missing key (or + * a null map) means the wagon's full rated capacity — unlike itemsPerWagonMap + * this is optional, so cargo without a loading limit is unaffected. + */ + @Column({ name: 'tons_per_wagon_map', type: 'jsonb', nullable: true }) + tonsPerWagonMap?: Record | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 691992c54..2e1fd8090 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -68,6 +68,7 @@ import { YardFacilitiesService } from './services/yard-facilities.service'; import { RuleEngineService } from './rule-engine.service'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -96,6 +97,9 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ]), // Team notifications for the priority-rule approval workflow. NotificationInboxModule, + // Rated wagon capacities — cargo types validate their per-wagon tonnage cap + // against them (a cap above the rating is a typo, not a policy). + WagonTypesModule, ], controllers: [ CargoTypesController, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 34287a023..1f25b0823 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -6,6 +6,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { In } from 'typeorm'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -13,6 +14,7 @@ import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoType } from '../entities/cargo-type.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; import { CARGO_TYPES_REPOSITORY, ICargoTypesRepository, @@ -27,6 +29,7 @@ export class CargoTypesService { private readonly repository: ICargoTypesRepository, @Inject(RATES_REPOSITORY) private readonly ratesRepository: IRatesRepository, + private readonly wagonTypesRepository: WagonTypesRepository, private readonly displayOrder: DisplayOrderService, ) {} @@ -75,6 +78,63 @@ export class CargoTypesService { return map; } + /** + * PER_TON (bulk) cargo may cap how many tons ride one wagon, BELOW that + * wagon's rated capacity: sugar at 50T on a 70T wagon means 200T takes 4 + * wagons, not 3. Unlike the PER_ITEM fit this is optional — an absent key + * means the full rated capacity, so existing cargo types are unaffected. + * + * A cap ABOVE the rated capacity is rejected: nobody loads 90T on a 70T + * wagon, so it is a typo, and silently clamping it would leave the config + * screen showing a number the trains never honour. (Allocation clamps too, via + * `bulkTonsPerWagon`, for caps left stale by a later wagon-type edit — this + * check cannot see those, since the cargo type is never re-saved.) + * + * Returns the map trimmed to the allowed wagon types, or null when the cargo + * is not PER_TON / nothing is capped. + */ + private async resolveTonsPerWagonMap(input: { + unitOfMeasure?: CargoUnitOfMeasure | null; + wagonTypeIds: string[]; + tonsPerWagonMap?: Record | null; + }): Promise | null> { + if (input.unitOfMeasure !== CargoUnitOfMeasure.PerTon || !input.wagonTypeIds.length) { + return null; + } + const capped = input.wagonTypeIds.filter( + (id) => input.tonsPerWagonMap?.[id] !== undefined && input.tonsPerWagonMap[id] !== null, + ); + if (!capped.length) return null; + + const wagonTypes = await this.wagonTypesRepository.findAll({ + where: { id: In(capped) }, + }); + const capacityById = new Map( + wagonTypes.map((wt) => [wt.id, Number(wt.capacityTons) || 0]), + ); + + const map: Record = {}; + for (const wagonTypeId of capped) { + const tons = Number(input.tonsPerWagonMap?.[wagonTypeId]); + if (!Number.isFinite(tons) || tons <= 0) { + throw new BadRequestException( + `tonsPerWagonMap for wagon type ${wagonTypeId} must be a number greater than 0`, + ); + } + const capacity = capacityById.get(wagonTypeId); + if (capacity === undefined) { + throw new BadRequestException(`Wagon type ${wagonTypeId} not found`); + } + if (capacity > 0 && tons > capacity) { + throw new BadRequestException( + `Max tons per wagon (${tons}T) exceeds wagon type ${wagonTypeId} rated capacity ${capacity}T`, + ); + } + map[wagonTypeId] = tons; + } + return map; + } + /** Create a new cargo type. */ async create(dto: CreateCargoTypeDto): Promise { const code = generateCode(dto.cargoTypeName); @@ -90,6 +150,12 @@ export class CargoTypesService { insertAfterId: dto.insertAfterId, }); + const tonsPerWagonMap = await this.resolveTonsPerWagonMap({ + unitOfMeasure: dto.unitOfMeasure ?? null, + wagonTypeIds: dto.wagonTypeIds ?? [], + tonsPerWagonMap: dto.tonsPerWagonMap, + }); + return this.repository.create({ code, cargoTypeName: dto.cargoTypeName, @@ -104,6 +170,7 @@ export class CargoTypesService { wagonTypeIds: dto.wagonTypeIds ?? [], itemsPerWagonMap: dto.itemsPerWagonMap, }), + tonsPerWagonMap, displayOrder, }); } @@ -116,12 +183,32 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } - const { wagonTypeIds, itemsPerWagonMap, insertAfterId: _insertAfterId, ...columns } = dto; + const { + wagonTypeIds, + itemsPerWagonMap, + tonsPerWagonMap, + insertAfterId: _insertAfterId, + ...columns + } = dto; // Re-validate the fit map whenever anything it depends on moves — a partial // update merges with the stored values so e.g. adding a wagon type without // its fit still 400s. Untouched fields leave the stored map alone. const touchesItemsFit = wagonTypeIds !== undefined || itemsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined; + // Same merge rule for the tonnage cap: re-resolve whenever the uom, the + // allowed wagon types, or the caps themselves move, so a wagon type added + // without a cap keeps its full rated capacity and a uom flip drops stale caps. + const touchesTonsCap = + wagonTypeIds !== undefined || tonsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined; + const resolvedTonsPerWagonMap = touchesTonsCap + ? await this.resolveTonsPerWagonMap({ + unitOfMeasure: + dto.unitOfMeasure !== undefined ? dto.unitOfMeasure : existing.unitOfMeasure, + wagonTypeIds: wagonTypeIds ?? (existing.wagonTypes ?? []).map((wt) => wt.id), + tonsPerWagonMap: + tonsPerWagonMap !== undefined ? tonsPerWagonMap : existing.tonsPerWagonMap, + }) + : undefined; const updated = await this.repository.update(id, { ...columns, ...(wagonTypeIds @@ -138,6 +225,7 @@ export class CargoTypesService { }), } : {}), + ...(touchesTonsCap ? { tonsPerWagonMap: resolvedTonsPerWagonMap } : {}), }); if (!updated) throw new NotFoundException(`Cargo type ${id} not found`); // A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the 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 33730b5ae..44f7c93b5 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 @@ -71,6 +71,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, + bulkTonsPerWagon, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, @@ -4100,8 +4101,15 @@ export class BookingBatchService implements OnModuleInit { const capacityTons = this.dimsFor(booking, wagonDims).capacityTons; const cargoTons = bookingCargoTons(booking); + // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T + // wagon), so divide by the cap where one is configured for this type. + const tonsPerWagon = bulkTonsPerWagon( + booking.cargoType, + booking.cargoType?.wagonTypes?.[0]?.id, + capacityTons, + ); const byWeight = - cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; + cargoTons > 0 && tonsPerWagon > 0 ? Math.ceil(cargoTons / tonsPerWagon) : 0; // Break-bulk (PER_ITEM): indivisible items can need more wagons than raw // tonnage suggests (floor items-per-wagon loses the fractional capacity). @@ -4169,6 +4177,13 @@ export class BookingBatchService implements OnModuleInit { .filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0) .map((o) => { const wagonTypeId = o.wagonTypeId as string; + // Each type sized on its OWN per-wagon tonnage cap, not just its rating + // — a type capped lower swallows less per wagon. + const tonsPerWagon = bulkTonsPerWagon( + booking.cargoType, + wagonTypeId, + o.dims.capacityTons, + ); const wagonsIfAlone = Math.max( 1, bulkItemWagonsRequired( @@ -4176,8 +4191,8 @@ export class BookingBatchService implements OnModuleInit { o.dims.capacityTons, bulkItemsFitFor(booking.cargoType, wagonTypeId), ) || - (o.dims.capacityTons > 0 - ? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons) + (tonsPerWagon > 0 + ? Math.ceil(bookingCargoTons(booking) / tonsPerWagon) : total), ); return { 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 bea01af0a..2769c212b 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 @@ -1,4 +1,4 @@ -import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util'; +import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util'; import type { Booking } from '../bookings/entities/booking.entity'; import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { @@ -56,8 +56,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n // holds the item count there, not tons. No wagon type is fixed yet, so use // the best count across the cargo's allowed types (per-type items-fit // respected); falls back to `capacity` when the relation isn't loaded. - const byItems = bulkItemWagonsForAllowedTypes(booking, booking.cargoType, capacity); - if (byItems > 0) return byItems; + // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T + // wagon), so tonnage divides by that cap, not by raw capacity. + const byWagons = bulkWagonsForAllowedTypes(booking, booking.cargoType, capacity); + if (byWagons > 0) return byWagons; const weight = Number(booking.cargoTotalWeightVgm ?? 0); return Math.max(1, Math.ceil(weight / capacity)); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index ead3a4e8b..ed476a6e1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -4,6 +4,10 @@ import { bookingTrainLengthMeters, bulkItemWagonsForAllowedTypes, bulkItemWagonsRequired, + bulkTonsPerWagon, + bulkTonWagonsForAllowedTypes, + bulkTonWagonsRequired, + bulkWagonsForAllowedTypes, consistUsage, consistViolations, deriveTrainCapacityFromLocomotive, @@ -135,6 +139,61 @@ describe('train-capacity.util', () => { }); }); + describe('bulkTonsPerWagon / bulkTonWagonsRequired (PER_TON loading cap)', () => { + // Sugar is loaded 50T per wagon even on a 70T wagon. + const sugar = { wagonTypes: [{ id: 'nw5', capacityTons: 70 }], tonsPerWagonMap: { nw5: 50 } }; + const bulk = (tons: number) => ({ freightType: 'BULK', cargoTotalWeightVgm: tons }); + + it('uses the configured cap instead of the rated capacity', () => { + expect(bulkTonsPerWagon(sugar, 'nw5', 70)).toBe(50); + }); + + it('falls back to rated capacity when the cargo type caps nothing', () => { + expect(bulkTonsPerWagon(null, 'nw5', 70)).toBe(70); + expect(bulkTonsPerWagon({ wagonTypes: [] }, 'nw5', 70)).toBe(70); + expect(bulkTonsPerWagon({ tonsPerWagonMap: { other: 50 } }, 'nw5', 70)).toBe(70); + }); + + it('clamps a stale cap that now exceeds the rating (wagon type edited down)', () => { + // Saved when NW5 was rated 70T; the type was later re-rated to 45T. + expect(bulkTonsPerWagon(sugar, 'nw5', 45)).toBe(45); + }); + + it('sizes 200T of capped sugar at 4 wagons, not the 3 raw capacity implies', () => { + expect(bulkTonWagonsRequired(bulk(200), sugar, 'nw5', 70)).toBe(4); + // Same booking, no cap → the old 3-wagon answer. + expect(bulkTonWagonsRequired(bulk(200), null, 'nw5', 70)).toBe(3); + }); + + it('picks the fewest-wagon allowed type, each on its own cap', () => { + const cargoType = { + wagonTypes: [ + { id: 'nw5', capacityTons: 70 }, + { id: 'nw7', capacityTons: 80 }, + ], + tonsPerWagonMap: { nw5: 50 }, + }; + // NW5 capped 50 → 4 wagons; NW7 uncapped 80 → 3 wagons. Best = 3. + expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3); + }); + + it('routes PER_ITEM and PER_TON through one call', () => { + expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4); + // PER_ITEM still wins where an item count is present. + const cars = { + wagonTypes: [{ id: 'nw5', capacityTons: 70 }], + itemsPerWagonMap: { nw5: 4 }, + }; + expect( + bulkWagonsForAllowedTypes( + { freightType: 'BULK', cargoTotalWeightVgm: 50, bulkTotalWeightTons: 1000 }, + cars, + 70, + ), + ).toBe(17); + }); + }); + describe('bookingCargoTons (break-bulk weight preference)', () => { it('prefers bulkTotalWeightTons over the item-count VGM column', () => { expect( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 867da564b..63feb6e8c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -152,8 +152,98 @@ export function bulkItemWagonsRequired( type ItemFitCargoType = { wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null; itemsPerWagonMap?: Record | null; + tonsPerWagonMap?: Record | null; } | null; +/** + * Tons of THIS cargo one wagon of this type may carry: the cargo type's + * configured loading limit when set, else the wagon's full rated capacity. + * Sugar capped at 50T rides 50T on a 70T wagon, so 200T needs 4 wagons and each + * is loaded to 50 — both the count and the fill follow from this one number. + * + * The configured cap is CLAMPED to the rated capacity rather than trusted: the + * cargo-types service rejects a cap above capacity at save time, but a wagon + * type edited DOWN afterwards would leave a stale cap that overloads the wagon. + * Clamping here means no call site can ever load past the physical rating. + */ +export function bulkTonsPerWagon( + cargoType: ItemFitCargoType | undefined, + wagonTypeId: string | null | undefined, + capacityTons: number | string | null | undefined, +): number { + const capacity = num(capacityTons); + const cap = wagonTypeId ? num(cargoType?.tonsPerWagonMap?.[wagonTypeId]) : 0; + if (!(cap > 0)) return capacity; + return capacity > 0 ? Math.min(cap, capacity) : cap; +} + +/** + * Wagons a PER_TON bulk booking needs on one wagon type, respecting the cargo + * type's per-wagon loading limit: 200T of sugar capped at 50T → 4 wagons even + * though the wagon is rated 70T. Returns 0 when there is no tonnage or no + * usable per-wagon figure, so callers can fall back as before. + */ +export function bulkTonWagonsRequired( + booking: Parameters[0], + cargoType: ItemFitCargoType | undefined, + wagonTypeId: string | null | undefined, + capacityTons: number | string | null | undefined, +): number { + const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons); + const tons = bookingCargoTons(booking); + if (!(perWagon > 0) || !(tons > 0)) return 0; + return Math.max(1, Math.ceil(tons / perWagon)); +} + +/** + * Best (fewest-wagon) PER_TON count across the cargo type's allowed wagon + * types, each sized on its OWN loading limit — the tonnage twin of + * {@link bulkItemWagonsForAllowedTypes}, for the call sites that have no single + * wagon type fixed yet. Falls back to `fallbackCapacityTons` when the cargo + * type has no usable allowed types. + */ +export function bulkTonWagonsForAllowedTypes( + booking: Parameters[0], + cargoType: ItemFitCargoType | undefined, + fallbackCapacityTons: number, +): number { + const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0); + if (!allowed.length) { + return bulkTonWagonsRequired(booking, cargoType, null, fallbackCapacityTons); + } + let best = 0; + for (const wagonType of allowed) { + const wagons = bulkTonWagonsRequired( + booking, + cargoType, + wagonType.id, + wagonType.capacityTons, + ); + if (wagons > 0 && (best === 0 || wagons < best)) best = wagons; + } + return best; +} + +/** + * Wagons a BULK booking needs, whichever way its cargo is measured: PER_ITEM + * sizes by indivisible items, everything else by tonnage under the cargo type's + * per-wagon loading limit. One call so no site has to remember both paths. + */ +export function bulkWagonsForAllowedTypes( + booking: Parameters[0] & { + freightType?: string | null; + cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; + }, + cargoType: ItemFitCargoType | undefined, + fallbackCapacityTons: number, +): number { + return ( + bulkItemWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons) || + bulkTonWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons) + ); +} + /** Configured whole-items fit of one wagon type for a cargo type; null if unset. */ export function bulkItemsFitFor( cargoType: ItemFitCargoType | undefined, 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 495feedf0..db8236927 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 @@ -138,6 +138,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, + bulkTonsPerWagon, deriveTrainCapacityFromLocomotive, combinedLocomotiveLimits, trainSetLocomotiveLimits, @@ -7347,8 +7348,10 @@ export class TrainSchedulingService { ? Math.ceil(booking.wagonsRequired) : 0; const byLength = containerWagonsForLines(booking.bookingContainers ?? []); - const byWeight = - cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0; + // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T + // wagon) — more wagons for the same cargo, so more tare to pull. + const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons); + const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0; // Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw // tonnage suggests — their tare must be pulled too (batch dimsFor parity). const byItems = bulkItemWagonsRequired( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 0810b3369..ef1e741be 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -5,7 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { bookingCargoTons, bulkItemsFitFor, - bulkItemWagonsForAllowedTypes, + bulkWagonsForAllowedTypes, } from './train-capacity.util'; import { sortBookingsForScheduling, @@ -122,10 +122,11 @@ const shortageFor = ( ? Math.max( 1, // Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map - // respected); PER_TON falls through to tonnage over the largest - // candidate. bookingCargoTons, not raw VGM — for PER_ITEM that - // column is the item count, not tons. - bulkItemWagonsForAllowedTypes( + // respected); PER_TON divides by its per-wagon tonnage cap where one + // is configured, else the largest candidate's rating. + // bookingCargoTons, not raw VGM — for PER_ITEM that column is the + // item count, not tons. + bulkWagonsForAllowedTypes( booking, booking.cargoType, Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), 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 ef3089925..9720e46c4 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 @@ -7,6 +7,8 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, + bulkTonsPerWagon, + bulkTonWagonsRequired, consistViolations, } from './train-capacity.util'; @@ -186,15 +188,29 @@ export function buildBulkWagonPlan( bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)), ); const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0); + + // PER_TON cargo with a per-wagon tonnage cap (sugar 50T on a 70T wagon) can't + // pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs + // 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on + // their own cap; only genuinely uncapped tonnage pools at rated capacity. + const cappedTonSlotsByBooking = bookings.map((b, i) => + itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity + ? 0 + : bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity), + ); + const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0); + const totalWeight = roundTons( bookings.reduce( (sum, b, i) => - itemSlotsByBooking[i] > 0 ? sum : sum + Number(b.cargoTotalWeightVgm ?? 0), + itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0 + ? sum + : sum + Number(b.cargoTotalWeightVgm ?? 0), 0, ), ); const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0; - const slots = Math.max(1, tonSlots + itemSlots); + const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots); const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ sequenceNo: index + 1, @@ -315,6 +331,7 @@ function allocateBookingsToSlots( // bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM) // bookings that column is an item COUNT, not tons. remainingWeightTons: roundTons(bookingCargoTons(booking)), + cargoType: booking.cargoType, })); let bookingIndex = 0; @@ -326,8 +343,15 @@ function allocateBookingsToSlots( while (wagonRemaining > 0 && bookingIndex < remaining.length) { const booking = remaining[bookingIndex]; + // A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well + // as the wagon count — the plan reserved a wagon per capped chunk, so + // pouring rated capacity into it would leave the last wagon empty. + const takeCap = Math.min( + wagonRemaining, + bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons), + ); const allocatedWeightTons = roundTons( - Math.min(wagonRemaining, booking.remainingWeightTons), + Math.min(takeCap, booking.remainingWeightTons), ); if (allocatedWeightTons <= 0) { @@ -350,6 +374,12 @@ function allocateBookingsToSlots( if (booking.remainingWeightTons <= 0) { bookingIndex += 1; + } else if (allocatedWeightTons >= takeCap) { + // The cap stopped this wagon short of its rating and the booking has + // more to load. The leftover room is NOT free: `buildBulkWagonPlan` + // already reserved a wagon for the rest, so backfilling another booking + // here would double-book the consist. Close the wagon. + break; } } diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index e37db97cc..57c239885 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -60,12 +60,16 @@ interface CargoNode extends RuleEngineRecord { wagonTypes?: { id: string; code?: string; name?: string }[]; /** PER_ITEM only: whole items that physically fit one wagon, keyed by wagon-type id. */ itemsPerWagonMap?: Record | null; + /** PER_TON only: max tons of this cargo per wagon, keyed by wagon-type id. */ + tonsPerWagonMap?: Record | null; isActive?: boolean; displayOrder?: number; } /** Form-value prefix for the per-wagon-type items-fit inputs (PER_ITEM cargo). */ const ITEMS_FIT_PREFIX = "itemsFit__"; +/** Form-value prefix for the per-wagon-type tonnage-cap inputs (PER_TON cargo). */ +const TONS_CAP_PREFIX = "tonsCap__"; const str = (v: unknown): string => (v == null ? "" : String(v)); const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0); @@ -159,8 +163,26 @@ const CargoTypesPage = () => { getInitialValue: (record) => (record as CargoNode).itemsPerWagonMap?.[opt.value], })); + // PER_TON cargo: an OPTIONAL "max tons per wagon" per selected wagon type — + // how much of this commodity actually rides one wagon, which can be less + // than its rating (sugar 50T on a 70T wagon, so 200T takes 4 wagons not 3). + // Left blank the wagon's full rated capacity applies, so existing cargo is + // unaffected; the API rejects a value above the rating. + const tonsCapFields: FormFieldDef[] = (wagonTypeOptions ?? []).map((opt) => ({ + name: `${TONS_CAP_PREFIX}${opt.value}`, + label: `Max tons per ${opt.label} wagon`, + type: "number", + optional: true, + placeholder: "Blank = full wagon capacity", + showIf: (values) => + values.unitOfMeasure === "PER_TON" && + Array.isArray(values.wagonTypeIds) && + (values.wagonTypeIds as string[]).includes(opt.value), + getInitialValue: (record) => + (record as CargoNode).tonsPerWagonMap?.[opt.value], + })); const wagonTypesAt = base.findIndex((field) => field.name === "wagonTypeIds"); - base.splice(wagonTypesAt + 1, 0, ...fitFields); + base.splice(wagonTypesAt + 1, 0, ...fitFields, ...tonsCapFields); return base; }, [wagonTypeOptions]); @@ -230,14 +252,22 @@ const CargoTypesPage = () => { // none are visible (not PER_ITEM) so an update clears stale fits. const payload: Record = {}; const itemsPerWagonMap: Record = {}; + const tonsPerWagonMap: Record = {}; for (const [key, value] of Object.entries(values)) { if (key.startsWith(ITEMS_FIT_PREFIX)) { itemsPerWagonMap[key.slice(ITEMS_FIT_PREFIX.length)] = Number(value); + } else if (key.startsWith(TONS_CAP_PREFIX)) { + // Blank means "no cap" (use the full rated capacity), so an empty input + // must stay OUT of the map — sending 0 would be a zero-ton wagon. + if (value !== "" && value !== null && value !== undefined) { + tonsPerWagonMap[key.slice(TONS_CAP_PREFIX.length)] = Number(value); + } } else { payload[key] = value; } } payload.itemsPerWagonMap = Object.keys(itemsPerWagonMap).length ? itemsPerWagonMap : null; + payload.tonsPerWagonMap = Object.keys(tonsPerWagonMap).length ? tonsPerWagonMap : null; // Add always attaches to the page we're on; edit keeps the node's parent. if (formMode?.kind === "create" && current) { payload.parentGroupId = current.id;