From 16a7aebaddc9dfc48179049260c71348e5e2c59d Mon Sep 17 00:00:00 2001
From: yonastewabe
Date: Fri, 24 Jul 2026 14:01:21 +0300
Subject: [PATCH 1/6] update migration to before deployment in freight api
---
.github/workflows/deploy.yml | 7 +++---
apps/edr-freight-api/Dockerfile | 4 ++++
apps/edr-freight-api/package.json | 1 +
.../src/config/database.config.ts | 15 +++++++-----
apps/edr-freight-api/src/data-source.ts | 24 ++++++-------------
apps/edr-freight-api/src/scripts/migrate.ts | 24 +++++++++++++++++++
6 files changed, 49 insertions(+), 26 deletions(-)
create mode 100644 apps/edr-freight-api/src/scripts/migrate.ts
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f0beccd20..c648fec09 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -144,15 +144,16 @@ jobs:
run: ./scripts/deploy/create-npmrc.sh
- name: Resolve env file path for ${{ matrix.service }}
- if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
+ if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
run: |
case "${{ matrix.service }}" in
+ freight-api) echo "SERVICE_ENV_FILE=apps/edr-freight-api/.env" >> "$GITHUB_ENV" ;;
passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;;
payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;;
esac
- name: Build migration image for ${{ matrix.service }}
- if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
+ if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
run: |
set -euo pipefail
docker build \
@@ -163,7 +164,7 @@ jobs:
.
- name: Run migrations for ${{ matrix.service }}
- if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
+ if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
run: |
set -euo pipefail
docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration"
diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile
index 984e622db..e2c4137cc 100644
--- a/apps/edr-freight-api/Dockerfile
+++ b/apps/edr-freight-api/Dockerfile
@@ -31,6 +31,10 @@ COPY --from=builder /app/ .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
+FROM deployer AS migration
+WORKDIR /deploy
+CMD ["node", "dist/scripts/migrate.js"]
+
FROM node:24.15.0-alpine AS runner
RUN apk add --no-cache libc6-compat
ENV NODE_ENV=production
diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index bca74475e..8ecd815ed 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -36,6 +36,7 @@
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
+ "migration:run": "node dist/scripts/migrate.js",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
},
"dependencies": {
diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts
index dcf09fbd9..65029819d 100644
--- a/apps/edr-freight-api/src/config/database.config.ts
+++ b/apps/edr-freight-api/src/config/database.config.ts
@@ -1,5 +1,6 @@
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
+import { DataSourceOptions } from "typeorm";
import { join, dirname } from "path";
import {
DefaultPosition,
@@ -95,7 +96,7 @@ const iamMigrationsGlob = join(
);
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
-export default registerAs("database", (): TypeOrmModuleOptions => {
+export function buildDataSourceOptions(): DataSourceOptions {
return {
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
@@ -111,17 +112,19 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
// The search_path is instead applied per-connection via a pool `connect`
// handler in app.module.ts (see setPoolSearchPath).
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
- autoLoadEntities: true,
migrations: [
- // IAM schema + tables must be created before freight migrations
iamMigrationsGlob,
freightMigrationsGlob,
],
- migrationsRun: true,
migrationsTransactionMode: "each",
- // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: false,
logging:
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
};
-});
+}
+
+export default registerAs("database", (): TypeOrmModuleOptions => ({
+ ...buildDataSourceOptions(),
+ autoLoadEntities: true,
+ migrationsRun: false,
+}));
diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts
index 2ae202ebd..03baa31e2 100644
--- a/apps/edr-freight-api/src/data-source.ts
+++ b/apps/edr-freight-api/src/data-source.ts
@@ -1,21 +1,11 @@
// apps/edr-freight-api/src/data-source.ts
-import 'dotenv/config';
-import { DataSource } from 'typeorm';
-//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
+import "dotenv/config";
+import { DataSource, DataSourceOptions } from "typeorm";
+import { buildDataSourceOptions } from "./config/database.config";
export const AppDataSource = new DataSource({
- type: 'postgres',
- host: process.env.DB_HOST ?? 'localhost',
- port: Number(process.env.DB_PORT ?? 5433),
- username: process.env.DB_USER ?? 'postgres',
- password: process.env.DB_PASSWORD ?? '',
- database: process.env.DB_NAME ?? 'edr_freight',
- schema: 'freight', // default schema for entities without an explicit schema
- entities: [__dirname + '/**/*.entity{.ts,.js}'],
- migrations: [__dirname + '/migrations/*{.ts,.js}'],
- synchronize: false,
- logging: process.env.TYPEORM_LOGGING === 'true',
-});
+ ...buildDataSourceOptions(),
+ schema: "freight",
+} as DataSourceOptions);
-// Optional: call ensurePostgresSchemas before initializing
-// But you can also run it separately.
+export default AppDataSource;
diff --git a/apps/edr-freight-api/src/scripts/migrate.ts b/apps/edr-freight-api/src/scripts/migrate.ts
new file mode 100644
index 000000000..7f331e1d3
--- /dev/null
+++ b/apps/edr-freight-api/src/scripts/migrate.ts
@@ -0,0 +1,24 @@
+import "dotenv/config";
+import { AppDataSource } from "../data-source";
+import { ensurePostgresSchemas } from "../config/ensure-postgres-schemas";
+import { buildDataSourceOptions } from "../config/database.config";
+
+async function main(): Promise {
+ await ensurePostgresSchemas(buildDataSourceOptions());
+
+ await AppDataSource.initialize();
+ try {
+ const applied = await AppDataSource.runMigrations();
+ for (const migration of applied) {
+ console.log(`applied: ${migration.name}`);
+ }
+ if (applied.length === 0) console.log("no pending migrations");
+ } finally {
+ await AppDataSource.destroy();
+ }
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
From af1d64d0bd5fe7a708d44e224d2d256389451ef6 Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Fri, 31 Jul 2026 14:27:23 +0000
Subject: [PATCH 2/6] fix
---
apps/edr-freight-api/package.json | 1 +
.../backfill-missing-unload-inventory.ts | 63 +++++++++++++++++++
2 files changed, 64 insertions(+)
create mode 100644 apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts
diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index 20bfa1bad..14c28f469 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -27,6 +27,7 @@
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
+ "backfill:missing-unload-inventory": "ts-node -r tsconfig-paths/register src/scripts/backfill-missing-unload-inventory.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts",
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
diff --git a/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts
new file mode 100644
index 000000000..9d51107fe
--- /dev/null
+++ b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts
@@ -0,0 +1,63 @@
+import 'reflect-metadata';
+import { config } from 'dotenv';
+import { resolve } from 'path';
+import { NestFactory } from '@nestjs/core';
+import { DataSource } from 'typeorm';
+
+config({ path: resolve(__dirname, '../../.env') });
+process.env.TYPEORM_LOGGING = 'false';
+
+import { AppModule } from '../app.module';
+import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service';
+
+/**
+ * One-off backfill for bookings caught by the autoArriveAtFinalYard bug
+ * (fixed in booking-journey.service.ts): the bulk final-yard arrival used to
+ * flip booking status to ARRIVED/COMPLETED without ever emitting
+ * booking.unloadedAtYard, so WarehouseInventoryService never created their
+ * warehouse_inventory row. Reuses the same idempotent listener the live
+ * event now calls, so it's safe to re-run.
+ */
+async function main() {
+ const app = await NestFactory.createApplicationContext(AppModule, {
+ logger: ['error', 'warn'],
+ });
+
+ try {
+ const dataSource = app.get(DataSource);
+ const inventory = app.get(WarehouseInventoryService);
+
+ const bookings: { id: string; tradeDirection: string }[] = await dataSource.query(
+ `SELECT b.id, b.trade_direction AS "tradeDirection"
+ FROM freight.bookings b
+ WHERE b.deleted_at IS NULL
+ AND b.trade_direction IN ('IMPORT', 'DOMESTIC')
+ AND b.status IN ('ARRIVED', 'COMPLETED')
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.warehouse_inventory wi
+ WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL
+ )`,
+ );
+
+ if (bookings.length === 0) {
+ console.log('No bookings missing their unload inventory row.');
+ return;
+ }
+
+ console.log(`Backfilling ${bookings.length} booking(s)...`);
+ for (const booking of bookings) {
+ await inventory.handleBookingUnloadedAtYard({
+ bookingId: booking.id,
+ tradeDirection: booking.tradeDirection,
+ });
+ console.log(` - ${booking.id} (${booking.tradeDirection})`);
+ }
+ } finally {
+ await app.close();
+ }
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
From 1998c74b9e5b6d0783abd887e2bf7ccccc2584a3 Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Sat, 1 Aug 2026 08:42:18 +0000
Subject: [PATCH 3/6] fix(freight-portal): add missing /api prefix to bulk
truck upload endpoint
BulkTruckUploadModal hand-built its POST URL and omitted the global
/api prefix every other booking call uses, causing a 404. Added
CUSTOMER_TRUCKS_BULK to URL_CONSTANTS.BOOKINGS and switched the modal
to use it.
---
apps/edr-freight-web/portal/src/constants/URLS.ts | 2 ++
.../BookingDetailPage/components/BulkTruckUploadModal.tsx | 3 ++-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts
index 928281042..22421400f 100644
--- a/apps/edr-freight-web/portal/src/constants/URLS.ts
+++ b/apps/edr-freight-web/portal/src/constants/URLS.ts
@@ -130,6 +130,8 @@ export const URL_CONSTANTS = {
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,
+ CUSTOMER_TRUCKS_BULK: (id: string) =>
+ `/api/bookings/${id}/customer-trucks/bulk`,
CUSTOMER_TRUCK: (id: string, assignmentId: string) =>
`/api/bookings/${id}/customer-trucks/${assignmentId}`,
},
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx
index 5f1f8a51e..eec8183af 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx
@@ -4,6 +4,7 @@ import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucid
import { useMutation } from "@tanstack/react-query";
import { client } from "@/utils/api";
+import { URL_CONSTANTS } from "@/constants/URLS";
import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template";
interface BulkTruckUploadModalProps {
@@ -32,7 +33,7 @@ export function BulkTruckUploadModal({
const uploadMutation = useMutation({
mutationFn: async () => {
- const { data } = await client.post(`/bookings/${bookingId}/customer-trucks/bulk`, {
+ const { data } = await client.post(URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(bookingId), {
trucks: parsed,
});
return data;
From 0d8c63328a5ccf81eab45dd764585a5167e5b79d Mon Sep 17 00:00:00 2001
From: Marshal
Date: Sat, 1 Aug 2026 09:55:21 +0000
Subject: [PATCH 4/6] 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 && (