diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 44be07550..bdb4c5ff5 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/CLAUDE_NEW.md b/CLAUDE_NEW.md
index 11075e9f0..a99f64d0a 100644
--- a/CLAUDE_NEW.md
+++ b/CLAUDE_NEW.md
@@ -154,13 +154,32 @@ no timeout and will wait forever.
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
already come from it.
-- `migrationsRun: true` — **migrations run automatically on API boot**, with
- `migrationsTransactionMode: 'each'`.
+- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate
+ one-shot step, via the Dockerfile's `migration` build target (`docker build --target
+ migration`), with `migrationsTransactionMode: 'each'`.
+ - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
+ (`docker run --rm --env-file ...`) *before* building/deploying the app image.
+ - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
+ `freight-api-e2e` depends on it (`condition: service_completed_successfully`).
+ - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
+ migrations yourself before `docker compose up freight-api`, e.g.
+ `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
+ then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
+ use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
+ output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
+ It silently applies zero freight migrations while exiting 0.
- Consequences you must design for:
- - Running several `nest start --watch` instances races `migrationsRun`. A non-idempotent
- data migration can execute twice. Keep one instance.
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
code reads, apply it to the dev database yourself (idempotently) or fully restart.
+ - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
+ hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
+ notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
+ `forFeature()` registrations), but the standalone migration `DataSource`
+ (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
+ entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
+ single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
+ for this to break again** — diff the package's entity classes against `iamEntities`
+ when bumping it.
- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or
more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before
adding one, check the filename prefix is unused *and* higher than the newest recorded row.
diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile
index 3c05c8a9f..a1d86723c 100644
--- a/apps/edr-freight-api/Dockerfile
+++ b/apps/edr-freight-api/Dockerfile
@@ -29,7 +29,12 @@ 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 base AS runner
+RUN apk add --no-cache libc6-compat
ENV NODE_ENV=production
WORKDIR /app
diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index 20bfa1bad..b3ed1c802 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",
@@ -36,6 +37,7 @@
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"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": {
@@ -57,8 +59,8 @@
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^11.1.27",
- "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
- "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.15.tgz",
+ "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
+ "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",
diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts
index dcf09fbd9..0591cd0b1 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,
@@ -44,8 +45,30 @@ import {
NotificationTemplate,
} from "@tria-plc/iamapi-common";
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
+import { UnitSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-setting.entity";
+import { UnitDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-detail.entity";
+import { OrganizationDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-detail.entity";
+import { UnitCluster } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-cluster.entity";
+import { Location } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location.entity";
+import { LocationType } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location-type.entity";
+import { DelegationTerminationReason } from "@tria-plc/iamapi-common/entities/iam/organization-structure/delegation-termination-reason.entity";
+import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee-position-active-period.entity";
+import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
+import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
+import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
const iamEntities = [
+ UnitSetting,
+ UnitDetail,
+ OrganizationDetail,
+ UnitCluster,
+ Location,
+ LocationType,
+ DelegationTerminationReason,
+ EmployeePositionActivePeriod,
+ UnitConfiguration,
+ Site,
+ SiteSetting,
DefaultPosition,
DefaultUnit,
EmployeePosition,
@@ -95,7 +118,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 +134,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..4b46912b7 100644
--- a/apps/edr-freight-api/src/data-source.ts
+++ b/apps/edr-freight-api/src/data-source.ts
@@ -1,21 +1,8 @@
// 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 } 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',
-});
+export const AppDataSource = new DataSource(buildDataSourceOptions());
-// Optional: call ensurePostgresSchemas before initializing
-// But you can also run it separately.
+export default AppDataSource;
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-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;
+});
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);
+});
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/package.json b/apps/edr-freight-web/portal/package.json
index 1e7b7155d..9582058ef 100644
--- a/apps/edr-freight-web/portal/package.json
+++ b/apps/edr-freight-web/portal/package.json
@@ -14,7 +14,7 @@
"dependencies": {
"@edr/types": "workspace:*",
"@edr/ui-common": "workspace:*",
- "@hookform/resolvers": "^5.4.0",
+ "@hookform/resolvers": "^5.6.0",
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0",
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/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 && (