From 0d8c63328a5ccf81eab45dd764585a5167e5b79d Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 1 Aug 2026 09:55:21 +0000 Subject: [PATCH 1/2] add items per wagon map to cargo types and sync bulk rate units - Implemented in the to manage the physical item capacity for each wagon type. - Added a new migration to create the column in the table. - Introduced method in to update rate units when cargo type unit of measure changes. - Updated booking calculations to consider items per wagon for break-bulk cargo. - Refactored various components to utilize the new items fit logic and ensure consistent date formatting across the application. - Added tests for the new display timezone functionality to ensure consistent date/time representation across different user settings. --- ...0000000000-AddCargoTypeItemsPerWagonMap.ts | 24 ++++++ ...40000000000-SyncBulkRateUnitsToCargoUom.ts | 36 +++++++++ .../bookings/booking-pricing.service.ts | 7 +- .../rule-engine/dto/create-cargo-type.dto.ts | 14 +++- .../rule-engine/entities/cargo-type.entity.ts | 10 +++ .../interfaces/rates.repository.interface.ts | 7 ++ .../repositories/rates.repository.ts | 12 +++ .../services/cargo-types.service.ts | 77 ++++++++++++++++++- .../train-scheduling/booking-batch.service.ts | 9 ++- .../train-scheduling/fleet-plan.util.ts | 8 +- .../train-capacity.util.spec.ts | 57 ++++++++++++++ .../train-scheduling/train-capacity.util.ts | 55 ++++++++++++- .../train-scheduling/wagon-plan.util.ts | 13 +++- .../components/contracts/GlExchangePanel.tsx | 10 ++- .../src/lib/display-timezone.test.ts | 41 ++++++++++ apps/edr-freight-web/backoffice/src/main.tsx | 3 + .../backoffice/src/pages/AuditLog.tsx | 3 +- .../src/pages/ruleEngine/CargoTypesPage.tsx | 54 ++++++++++--- .../components/ReminderList.tsx | 9 ++- .../components/activity-log/activity-card.tsx | 12 ++- .../shared/pages/audit-log/audit-log-page.tsx | 21 +++-- apps/edr-freight-web/portal/src/main.tsx | 3 + .../MyPortalPage/components/ActivityRow.tsx | 6 +- .../components/BookingPaymentPanel.tsx | 3 +- .../src/pages/bookings/NewBookingPage.tsx | 1 + .../new-booking-form/step8-review.tsx | 8 +- packages/ui-common/package.json | 1 + .../ui-common/src/lib/display-timezone.ts | 48 ++++++++++++ 28 files changed, 506 insertions(+), 46 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts create mode 100644 apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts create mode 100644 apps/edr-freight-web/backoffice/src/lib/display-timezone.test.ts create mode 100644 packages/ui-common/src/lib/display-timezone.ts diff --git a/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts b/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts new file mode 100644 index 000000000..153928b04 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Break-bulk (PER_ITEM) cargo needs a physical items-fit per allowed wagon + * type (e.g. cars → NW5: 4, NW7: 6): a wagon runs out of floor space before it + * runs out of rated tonnage, so allocation must respect BOTH limits. Stored as + * a jsonb map { [wagonTypeId]: itemsFit } on cargo_types — keys mirror the + * cargo_type_wagon_types join rows, kept in sync by the cargo-types service. + */ +export class AddCargoTypeItemsPerWagonMap3120000000000 implements MigrationInterface { + name = 'AddCargoTypeItemsPerWagonMap3120000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "items_per_wagon_map" jsonb`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "items_per_wagon_map"`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts b/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts new file mode 100644 index 000000000..b2a931542 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Rates created before their commodity's unit_of_measure was flipped kept the + * old bulk-quantity unit, so bookings of a PER_ITEM commodity (e.g. Machinery) + * quoted "per ton". PER_TON and PER_ITEM bill the same stored quantity — only + * the name differs — so renaming is safe. Going forward the cargo-types + * service syncs rates on every uom change; this backfills the drift. + */ +export class SyncBulkRateUnitsToCargoUom3140000000000 implements MigrationInterface { + name = 'SyncBulkRateUnitsToCargoUom3140000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "freight"."rates" r + SET "rate_unit" = 'PER_ITEM' + FROM "freight"."cargo_types" ct + WHERE ct."id" = r."cargo_type_id" + AND ct."unit_of_measure" = 'PER_ITEM' + AND r."rate_unit" = 'PER_TON'`, + ); + await queryRunner.query( + `UPDATE "freight"."rates" r + SET "rate_unit" = 'PER_TON' + FROM "freight"."cargo_types" ct + WHERE ct."id" = r."cargo_type_id" + AND ct."unit_of_measure" = 'PER_TON' + AND r."rate_unit" = 'PER_ITEM'`, + ); + } + + public async down(): Promise { + // Irreversible rename-by-join: the pre-sync unit is not recorded. Both + // units bill identically, so rolling back the code needs no data change. + } +} 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 0eb65bf5d..b44c1c5e0 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 { bulkItemWagonsRequired } from '../train-scheduling/train-capacity.util'; +import { bulkItemWagonsForAllowedTypes } 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'; @@ -1188,8 +1188,9 @@ export class BookingPricingService { ); if (!(capacity > 0)) return null; // Break-bulk (PER_ITEM): `tons` above is the item count; size by - // indivisible items instead of pretending the count is tonnage. - const byItems = bulkItemWagonsRequired(booking, capacity); + // 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; return Math.max(1, Math.ceil(tons / capacity)); } catch { 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 6f7882c76..6d9cb82a2 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 @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CargoUnitOfMeasure } from '@edr/types'; -import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsArray, IsBoolean, IsEnum, IsInt, IsObject, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateCargoTypeDto { @ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) @@ -32,6 +32,18 @@ export class CreateCargoTypeDto { @IsUUID('4', { each: true }) wagonTypeIds?: string[]; + @ApiPropertyOptional({ + description: + 'PER_ITEM cargo only: items that physically fit one wagon, keyed by wagon-type id ' + + '(e.g. { "": 4, "": 6 }). Required for every wagonTypeId when ' + + 'unitOfMeasure is PER_ITEM.', + type: 'object', + additionalProperties: { type: 'integer', minimum: 1 }, + }) + @IsOptional() + @IsObject() + itemsPerWagonMap?: 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 037bf08be..b096613bf 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 @@ -50,6 +50,16 @@ export class CargoType extends BaseEntity { }) wagonTypes?: WagonType[]; + /** + * PER_ITEM (break-bulk) only: how many whole items physically fit each + * allowed wagon type, keyed by wagon-type id (e.g. cars → { NW5: 4, NW7: 6 }). + * Allocation loads min(this fit, floor(capacityTons / perItemTons)) per + * wagon — floor space and rated tonnage bind independently. Keys are kept a + * subset of the wagonTypes join rows by the cargo-types service. + */ + @Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true }) + itemsPerWagonMap?: Record | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index b3ee54d20..1570e0d23 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -28,6 +28,13 @@ export interface IRatesRepository { create(data: Partial): Promise; update(id: string, data: Partial): Promise; softDelete(id: string): Promise; + /** + * Flip a commodity's PER_TON↔PER_ITEM rates to match its unit of measure. + * Both units bill the same stored quantity — only the name differs — so a + * uom change must rename the units or bookings keep quoting "per ton" for + * counted cargo. Returns the number of rates flipped. + */ + syncBulkQuantityUnit(cargoTypeId: string, unitOfMeasure: 'PER_TON' | 'PER_ITEM'): Promise; } export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index abd10cd98..43c716a3b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -177,4 +177,16 @@ export class RatesRepository implements IRatesRepository { async softDelete(id: string): Promise { await this.repo.softDelete(id); } + + async syncBulkQuantityUnit( + cargoTypeId: string, + unitOfMeasure: 'PER_TON' | 'PER_ITEM', + ): Promise { + const from = unitOfMeasure === 'PER_ITEM' ? 'PER_TON' : 'PER_ITEM'; + const result = await this.repo.update( + { cargoTypeId, rateUnit: from }, + { rateUnit: unitOfMeasure }, + ); + return result.affected ?? 0; + } } 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 8a72cf1f8..34287a023 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 @@ -1,5 +1,11 @@ -import { PaginatedResponse } from '@edr/types'; -import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CargoUnitOfMeasure, PaginatedResponse } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; 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'; @@ -11,6 +17,7 @@ import { CARGO_TYPES_REPOSITORY, ICargoTypesRepository, } from '../interfaces/cargo-types.repository.interface'; +import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; import { DisplayOrderService } from './display-order.service'; @Injectable() @@ -18,6 +25,8 @@ export class CargoTypesService { constructor( @Inject(CARGO_TYPES_REPOSITORY) private readonly repository: ICargoTypesRepository, + @Inject(RATES_REPOSITORY) + private readonly ratesRepository: IRatesRepository, private readonly displayOrder: DisplayOrderService, ) {} @@ -38,6 +47,34 @@ export class CargoTypesService { return this.repository.findByCode(code); } + /** + * PER_ITEM (break-bulk) cargo must carry a whole-items-fit for EVERY allowed + * wagon type — allocation caps each wagon at min(fit, tonnage) and a missing + * fit would silently fall back to tonnage-only loading. Returns the map + * trimmed to the allowed ids (stale keys from a removed wagon type drop out); + * null when the cargo is not PER_ITEM or has no wagon types. + */ + private resolveItemsPerWagonMap(input: { + unitOfMeasure?: CargoUnitOfMeasure | null; + wagonTypeIds: string[]; + itemsPerWagonMap?: Record | null; + }): Record | null { + if (input.unitOfMeasure !== CargoUnitOfMeasure.PerItem || !input.wagonTypeIds.length) { + return null; + } + const map: Record = {}; + for (const wagonTypeId of input.wagonTypeIds) { + const fit = Number(input.itemsPerWagonMap?.[wagonTypeId]); + if (!Number.isInteger(fit) || fit < 1) { + throw new BadRequestException( + `itemsPerWagonMap must define how many items fit wagon type ${wagonTypeId} (integer >= 1) for PER_ITEM cargo`, + ); + } + map[wagonTypeId] = fit; + } + return map; + } + /** Create a new cargo type. */ async create(dto: CreateCargoTypeDto): Promise { const code = generateCode(dto.cargoTypeName); @@ -62,26 +99,58 @@ export class CargoTypesService { unitOfMeasure: dto.unitOfMeasure ?? null, // Join rows are written by the save (RESTRICT FK rejects unknown ids). wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType), + itemsPerWagonMap: this.resolveItemsPerWagonMap({ + unitOfMeasure: dto.unitOfMeasure ?? null, + wagonTypeIds: dto.wagonTypeIds ?? [], + itemsPerWagonMap: dto.itemsPerWagonMap, + }), displayOrder, }); } /** Update an existing cargo type. */ async update(id: string, dto: UpdateCargoTypeDto): Promise { - await this.findById(id); + const existing = await this.findById(id); if (dto.parentGroupId) { if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent'); const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } - const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto; + const { wagonTypeIds, itemsPerWagonMap, 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; const updated = await this.repository.update(id, { ...columns, ...(wagonTypeIds ? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) } : {}), + ...(touchesItemsFit + ? { + itemsPerWagonMap: this.resolveItemsPerWagonMap({ + unitOfMeasure: + dto.unitOfMeasure !== undefined ? dto.unitOfMeasure : existing.unitOfMeasure, + wagonTypeIds: wagonTypeIds ?? (existing.wagonTypes ?? []).map((wt) => wt.id), + itemsPerWagonMap: + itemsPerWagonMap !== undefined ? itemsPerWagonMap : existing.itemsPerWagonMap, + }), + } + : {}), }); if (!updated) throw new NotFoundException(`Cargo type ${id} not found`); + // A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the + // same stored quantity) — sync them or bookings keep quoting "per ton" for + // counted cargo. + if ( + dto.unitOfMeasure !== undefined && + dto.unitOfMeasure !== existing.unitOfMeasure && + (dto.unitOfMeasure === CargoUnitOfMeasure.PerTon || + dto.unitOfMeasure === CargoUnitOfMeasure.PerItem) + ) { + await this.ratesRepository.syncBulkQuantityUnit(id, dto.unitOfMeasure); + } return updated; } 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 5ce75abd2..5203c3a14 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 @@ -69,6 +69,7 @@ import { LocomotiveLimits, WagonTypeDimensions, bookingCargoTons, + bulkItemsFitFor, bulkItemWagonsRequired, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, @@ -4060,7 +4061,13 @@ export class BookingBatchService implements OnModuleInit { // Break-bulk (PER_ITEM): indivisible items can need more wagons than raw // tonnage suggests (floor items-per-wagon loses the fractional capacity). - const byItems = bulkItemWagonsRequired(booking, capacityTons); + // `dimsFor` resolved dims from the first allowed wagon type, so charge that + // same type's configured items-fit alongside its capacity. + const byItems = bulkItemWagonsRequired( + booking, + capacityTons, + bulkItemsFitFor(booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id), + ); return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems); } 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 631078ef6..bea01af0a 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, bulkItemWagonsRequired } from './train-capacity.util'; +import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util'; import type { Booking } from '../bookings/entities/booking.entity'; import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { @@ -53,8 +53,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n if (booking.freightType === 'BULK') { const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1; // Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm` - // holds the item count there, not tons. - const byItems = bulkItemWagonsRequired(booking, capacity); + // 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; 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 fd5fe05ed..ead3a4e8b 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 @@ -2,6 +2,7 @@ import { bookingCargoTons, bookingGrossWeightTons, bookingTrainLengthMeters, + bulkItemWagonsForAllowedTypes, bulkItemWagonsRequired, consistUsage, consistViolations, @@ -76,6 +77,62 @@ describe('train-capacity.util', () => { expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0); expect(bulkItemWagonsRequired(breakBulk(400, 0), 69)).toBe(0); }); + + describe('configured items-fit (floor space vs tonnage)', () => { + it('weight binds: 50 cars × 20T on a 70T wagon that fits 4 → 3 per wagon → 17', () => { + // floor(70/20) = 3 by tonnage < 4 by floor space. + expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 4)).toBe(17); + }); + + it('floor space binds: 50 cars × 10T on a 70T wagon that fits 4 → 4 per wagon → 13', () => { + // floor(70/10) = 7 by tonnage, but only 4 fit physically. + expect(bulkItemWagonsRequired(breakBulk(50, 500), 70, 4)).toBe(13); + }); + + it('ignores an absent/invalid fit (legacy cargo types): tonnage-only', () => { + expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, null)).toBe(17); + expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 0)).toBe(17); + // floor(70/10) = 7 per wagon → ceil(50/7) = 8 wagons. + expect(bulkItemWagonsRequired(breakBulk(50, 500), 70)).toBe(8); + }); + }); + }); + + describe('bulkItemWagonsForAllowedTypes', () => { + const breakBulk = (quantity: number, weightTons: number) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: quantity, + bulkTotalWeightTons: weightTons, + }); + + it('picks the fewest-wagon allowed type, each capped by its own fit', () => { + const cargoType = { + wagonTypes: [ + { id: 'nw5', capacityTons: 70 }, + { id: 'nw7', capacityTons: 80 }, + ], + itemsPerWagonMap: { nw5: 4, nw7: 6 }, + }; + // 50 cars × 20T: NW5 → min(4, floor(70/20)=3) = 3/wagon = 17 wagons; + // NW7 → min(6, floor(80/20)=4) = 4/wagon = 13 wagons. Best = 13. + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 70)).toBe(13); + }); + + it('equals the old max-capacity estimate when no fits are configured', () => { + const cargoType = { + wagonTypes: [ + { id: 'a', capacityTons: 50 }, + { id: 'b', capacityTons: 70 }, + ], + }; + // Tonnage-only best = biggest wagon: floor(70/20) = 3/wagon → 17. + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 1)).toBe(17); + }); + + it('falls back to the given capacity when the relation is missing', () => { + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), null, 70)).toBe(17); + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), { wagonTypes: [] }, 70)).toBe(17); + }); }); describe('bookingCargoTons (break-bulk weight preference)', () => { 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 061dc0dbd..867da564b 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 @@ -120,6 +120,12 @@ export function bookingCargoTons(booking: { * 400 items / 800T on 69T wagons → 2T per item → 34 items per wagon → 12 wagons. * Returns 0 when the booking is not item-counted (PER_TON bulk, containers) — * callers then fall back to the pooled-tonnage math. + * + * `itemsFit` is the wagon type's PHYSICAL item capacity (floor space — from + * cargoType.itemsPerWagonMap). It binds independently of tonnage: a 70T wagon + * that fits 4 cars takes 3 cars of 20T (weight binds) but only 4 cars of 10T + * (floor binds, 30T of rated capacity ride empty). Absent/invalid fit falls + * back to tonnage-only (legacy cargo types without a configured fit). */ export function bulkItemWagonsRequired( booking: { @@ -128,6 +134,7 @@ export function bulkItemWagonsRequired( bulkTotalWeightTons?: number | string | null; }, capacityTons: number, + itemsFit?: number | null, ): number { if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0; const quantity = num(booking.cargoTotalWeightVgm); @@ -136,10 +143,56 @@ export function bulkItemWagonsRequired( const perItemTons = totalWeightTons / quantity; // ponytail: an item heavier than a whole wagon still charges 1 wagon per // item; reject such bookings at creation time if the case turns real. - const itemsPerWagon = Math.max(1, Math.floor(capacityTons / perItemTons)); + const byTonnage = Math.max(1, Math.floor(capacityTons / perItemTons)); + const byFloor = num(itemsFit) >= 1 ? Math.floor(num(itemsFit)) : Infinity; + const itemsPerWagon = Math.min(byTonnage, byFloor); return Math.max(1, Math.ceil(quantity / itemsPerWagon)); } +type ItemFitCargoType = { + wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null; + itemsPerWagonMap?: Record | null; +} | null; + +/** Configured whole-items fit of one wagon type for a cargo type; null if unset. */ +export function bulkItemsFitFor( + cargoType: ItemFitCargoType | undefined, + wagonTypeId: string | null | undefined, +): number | null { + const fit = wagonTypeId ? Number(cargoType?.itemsPerWagonMap?.[wagonTypeId]) : NaN; + return Number.isFinite(fit) && fit >= 1 ? fit : null; +} + +/** + * Break-bulk wagon count when no single wagon type is fixed yet: the best + * (fewest-wagon) count across the cargo type's allowed wagon types, each + * respecting its own items-fit. With no fits configured this equals the old + * max-capacity estimate; with no allowed types it degrades to + * `fallbackCapacityTons` tonnage-only. + */ +export function bulkItemWagonsForAllowedTypes( + booking: { + freightType?: string | null; + cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; + }, + cargoType: ItemFitCargoType | undefined, + fallbackCapacityTons: number, +): number { + const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0); + if (!allowed.length) return bulkItemWagonsRequired(booking, fallbackCapacityTons); + let best = 0; + for (const wagonType of allowed) { + const wagons = bulkItemWagonsRequired( + booking, + num(wagonType.capacityTons), + bulkItemsFitFor(cargoType, wagonType.id), + ); + if (wagons > 0 && (best === 0 || wagons < best)) best = wagons; + } + return best; +} + /** Gross weight of one loaded wagon: it hauls itself plus its cargo. */ export function grossWagonWeightTons(slot: Pick): number { return num(slot.tareWeightTons) + num(slot.cargoTons); 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 619ebbde6..ef3089925 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 @@ -3,7 +3,12 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { bookingCargoTons, bulkItemWagonsRequired, consistViolations } from './train-capacity.util'; +import { + bookingCargoTons, + bulkItemsFitFor, + bulkItemWagonsRequired, + consistViolations, +} from './train-capacity.util'; export const MAX_TRAIN_WEIGHT_TONS = 3500; export const MAX_TRAIN_LENGTH_METERS = 760; @@ -175,7 +180,11 @@ export function buildBulkWagonPlan( // Break-bulk (PER_ITEM) bookings size by indivisible items per booking — // their tonnage must NOT pool with PER_TON cargo (an item can't split // across wagons the way loose tonnage can). - const itemSlotsByBooking = bookings.map((b) => bulkItemWagonsRequired(b, capacity)); + const itemSlotsByBooking = bookings.map((b) => + // The plan fixed THIS wagon type, so its configured items-fit binds — not + // the best fit across the cargo's allowed types. + bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)), + ); const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0); const totalWeight = roundTons( bookings.reduce( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx index 8f213e4ec..852a89cc1 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx @@ -29,7 +29,6 @@ import { Upload, UserCheck, } from "lucide-react"; -import dayjs from "dayjs"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; import { isViewable } from "@edr/ui-common"; @@ -299,7 +298,14 @@ function DocumentRow({ {doc.file.name} · {formatBytes(doc.file.size)} ·{" "} {doc.uploadedByName ?? "Global Logistics"} ·{" "} - {dayjs(doc.uploadedAt).format("D MMM YYYY, HH:mm")} + {new Date(doc.uploadedAt).toLocaleString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + })} diff --git a/apps/edr-freight-web/backoffice/src/lib/display-timezone.test.ts b/apps/edr-freight-web/backoffice/src/lib/display-timezone.test.ts new file mode 100644 index 000000000..33ac36437 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/display-timezone.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import "@edr/ui-common/display-timezone"; + +// The pin must hold on ANY machine timezone — these assertions are the bug: +// before the patch they only passed on a PC already set to UTC+3. +describe("display-timezone pin (EAT, UTC+3)", () => { + const utcMidnight = new Date("2026-01-01T00:00:00Z"); + + it("formats Date.toLocale* in EAT regardless of machine timezone", () => { + expect(utcMidnight.toLocaleTimeString("en-GB", { hour12: false })).toBe( + "03:00:00", + ); + // 22:00 UTC is already the NEXT day in EAT. + expect(new Date("2026-01-01T22:00:00Z").toLocaleDateString("en-CA")).toBe( + "2026-01-02", + ); + }); + + it("formats Intl.DateTimeFormat in EAT and keeps instanceof/statics", () => { + const fmt = new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + expect(fmt.format(utcMidnight)).toBe("03:00"); + expect(fmt).toBeInstanceOf(Intl.DateTimeFormat); + expect(Intl.DateTimeFormat.supportedLocalesOf(["en-GB"])).toContain( + "en-GB", + ); + }); + + it("respects an explicit timeZone option", () => { + expect( + utcMidnight.toLocaleTimeString("en-GB", { + hour12: false, + timeZone: "UTC", + }), + ).toBe("00:00:00"); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx index aad3bbb89..3de483c5b 100644 --- a/apps/edr-freight-web/backoffice/src/main.tsx +++ b/apps/edr-freight-web/backoffice/src/main.tsx @@ -1,3 +1,6 @@ +// Must stay the first import: pins all date/time display to EAT before any +// module can create a formatter in the PC's local timezone. +import "@edr/ui-common/display-timezone"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; diff --git a/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx b/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx index 2fe61e56a..5374bcee7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState } from "react"; -import { format } from "date-fns"; import { Input } from "@/shared/common/ui/input"; import { Select, @@ -219,7 +218,7 @@ const buildQuery = (): CollectionQueryDTO => { {log.message} {log.timestamp && !isNaN(new Date(log.timestamp).getTime()) - ? format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss") + ? new Date(log.timestamp).toLocaleString("sv-SE") : "N/A"} 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 a515e5614..e37db97cc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -58,10 +58,15 @@ interface CargoNode extends RuleEngineRecord { unitOfMeasure?: string | null; /** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */ 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; isActive?: boolean; displayOrder?: number; } +/** Form-value prefix for the per-wagon-type items-fit inputs (PER_ITEM cargo). */ +const ITEMS_FIT_PREFIX = "itemsFit__"; + const str = (v: unknown): string => (v == null ? "" : String(v)); const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0); @@ -131,15 +136,33 @@ const CargoTypesPage = () => { // Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list). const { data: wagonTypeOptions } = useWagonTypeOptions(canCreate || canUpdate); - const formFields = useMemo( - () => - FORM_FIELDS.map((field) => - field.name === "wagonTypeIds" - ? { ...field, options: wagonTypeOptions ?? [] } - : field, - ), - [wagonTypeOptions], - ); + const formFields = useMemo(() => { + const base = FORM_FIELDS.map((field) => + field.name === "wagonTypeIds" + ? { ...field, options: wagonTypeOptions ?? [] } + : field, + ); + // PER_ITEM cargo: one "items per wagon" input per SELECTED wagon type — how + // many whole items physically fit that wagon (floor space binds before + // tonnage). Shown only while the wagon type is picked; the API requires a + // fit for every selected type on PER_ITEM cargo. + const fitFields: FormFieldDef[] = (wagonTypeOptions ?? []).map((opt) => ({ + name: `${ITEMS_FIT_PREFIX}${opt.value}`, + label: `Items per ${opt.label} wagon`, + type: "number", + required: true, + placeholder: "e.g. 4", + showIf: (values) => + values.unitOfMeasure === "PER_ITEM" && + Array.isArray(values.wagonTypeIds) && + (values.wagonTypeIds as string[]).includes(opt.value), + getInitialValue: (record) => + (record as CargoNode).itemsPerWagonMap?.[opt.value], + })); + const wagonTypesAt = base.findIndex((field) => field.name === "wagonTypeIds"); + base.splice(wagonTypesAt + 1, 0, ...fitFields); + return base; + }, [wagonTypeOptions]); const [search, setSearch] = useState(""); const [formMode, setFormMode] = useState(null); @@ -203,7 +226,18 @@ const CargoTypesPage = () => { const countAtRoot = (childrenOf.get("__root__") ?? []).length; const handleSubmit = (values: Record) => { - const payload: Record = { ...values }; + // Fold the per-wagon-type fit inputs into the API's map shape. Null when + // none are visible (not PER_ITEM) so an update clears stale fits. + const payload: Record = {}; + const itemsPerWagonMap: 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 { + payload[key] = value; + } + } + payload.itemsPerWagonMap = Object.keys(itemsPerWagonMap).length ? itemsPerWagonMap : 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; diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/ReminderList.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/ReminderList.tsx index 20ed30fad..4bba1d713 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/ReminderList.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/ReminderList.tsx @@ -107,7 +107,14 @@ const ReminderList = () => { Remind {dayjs(reminder.remindAt).fromNow()}

- ({dayjs(reminder.remindAt).format("MMM D, h:mm A")}) + ( + {new Date(reminder.remindAt).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })} + ) diff --git a/apps/edr-freight-web/backoffice/src/shared/components/activity-log/activity-card.tsx b/apps/edr-freight-web/backoffice/src/shared/components/activity-log/activity-card.tsx index 92d4b624a..78ca90a56 100644 --- a/apps/edr-freight-web/backoffice/src/shared/components/activity-log/activity-card.tsx +++ b/apps/edr-freight-web/backoffice/src/shared/components/activity-log/activity-card.tsx @@ -1,9 +1,7 @@ -import * as React from "react"; import { useTranslation } from "react-i18next"; import { Card, CardContent } from "@/shared/common/ui/card"; import { Badge } from "@/shared/common/ui/badge"; import { Avatar, AvatarFallback, AvatarImage } from "@/shared/common/ui/avatar"; -import { format } from "date-fns"; import { Clock, User, @@ -101,8 +99,14 @@ export function ActivityCard({ activity }: { activity: ActivityCardProps }) { const iconBg = "bg-gray-100 dark:bg-gray-800"; const whiteBg = "bg-white dark:bg-gray-900"; - const formattedDate = format(new Date(activity.timestamp), "MMM d, yyyy"); - const formattedTime = format(new Date(activity.timestamp), "HH:mm:ss"); + const formattedDate = new Date(activity.timestamp).toLocaleDateString( + "en-US", + { month: "short", day: "numeric", year: "numeric" }, + ); + const formattedTime = new Date(activity.timestamp).toLocaleTimeString( + "en-GB", + { hour12: false }, + ); return ( { switch (severity) { @@ -472,10 +471,14 @@ export default function AuditLogPageShared({ textSubtle, )}> - {format(new Date(log.timestamp), "MMM d, yyyy")} + {new Date(log.timestamp).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} - {format(new Date(log.timestamp), "HH:mm:ss")} + {new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })} @@ -817,13 +820,17 @@ export default function AuditLogPageShared({
- {format( - new Date(log.timestamp), - "MMM d, yyyy", + {new Date(log.timestamp).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + year: "numeric", + }, )} - {format(new Date(log.timestamp), "HH:mm:ss")} + {new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx index 983aa5b7a..49fec0edf 100644 --- a/apps/edr-freight-web/portal/src/main.tsx +++ b/apps/edr-freight-web/portal/src/main.tsx @@ -1,3 +1,6 @@ +// Must stay the first import: pins all date/time display to EAT before any +// module can create a formatter in the PC's local timezone. +import "@edr/ui-common/display-timezone"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx index ea06ae3e5..bba2b5696 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx @@ -1,5 +1,4 @@ import { Box, Group, Text } from "@mantine/core"; -import { format } from "date-fns"; import { memo } from "react"; import { STATUS_CONFIG, cv } from "../constants"; @@ -56,7 +55,10 @@ export const ActivityRow = memo(function ActivityRow({ - {format(new Date(booking.createdAt), "MMM d")} + {new Date(booking.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx index 1cb46d156..d921308d0 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -106,7 +106,8 @@ function Countdown({ day: "numeric", hour: "2-digit", minute: "2-digit", - })} + })}{" "} + EAT {onPay && (