From 19c9da28ae5a027bd48173b0f85a8de1fd00fb90 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Wed, 15 Jul 2026 13:29:01 +0000
Subject: [PATCH] changes
---
.../contract-document-view-model.builder.ts | 56 ++-
.../2210000000000-ScheduleScopedWagonPins.ts | 48 ++
...20000000000-AddContractDocumentSnapshot.ts | 27 +
...0000-RenameWagonStatusRetiredToDetained.ts | 24 +
.../2240000000000-AddTransferRequestReason.ts | 24 +
...000000-CreatePriorityRuleChangeRequests.ts | 42 ++
.../bookings/booking-transition.service.ts | 22 +-
.../modules/bookings/bookings.controller.ts | 22 +
.../src/modules/bookings/bookings.service.ts | 73 ++-
.../contracts/contract-transition.service.ts | 242 ++++++++-
.../modules/contracts/contracts.controller.ts | 26 +
.../contracts/dto/accept-contract.dto.ts | 19 +-
.../contracts/dto/contract-document.dto.ts | 64 +++
.../contracts/entities/contract.entity.ts | 45 ++
...riority-rule-change-requests.controller.ts | 72 +++
.../dto/priority-rule-change-request.dto.ts | 49 ++
.../priority-rule-change-request.entity.ts | 46 ++
.../modules/rule-engine/rule-engine.module.ts | 10 +
.../services/priority-configs.service.ts | 50 ++
.../priority-rule-change-requests.service.ts | 226 +++++++++
.../dto/available-days-for-cargo-query.dto.ts | 22 +
.../train-scheduling.controller.ts | 2 +
.../train-scheduling.service.ts | 461 +++++++++++++++---
.../modules/trains/train-builder.service.ts | 27 +-
.../dto/bulk-fulfill-transfer-requests.dto.ts | 13 +
.../wagons/dto/create-transfer-request.dto.ts | 22 +-
.../entities/wagon-transfer-request.entity.ts | 7 +
.../modules/wagons/entities/wagon.entity.ts | 2 +-
.../wagon-transfer-requests.controller.ts | 17 +
.../wagons/wagon-transfer-requests.service.ts | 102 +++-
.../warehouses/scheduling-read.facade.ts | 2 +-
.../contracts/ContractActionsToolbar.tsx | 160 +++---
.../contracts/ContractDocumentEditorModal.tsx | 413 ++++++++++++++++
.../src/components/fleet/fleetFormat.tsx | 2 +-
.../wagons/WagonTransferRequestsModal.tsx | 104 +++-
.../wagons/WagonYardWorkspaceModal.tsx | 53 +-
.../backoffice/src/constants/QUERY_KEYS.ts | 1 +
.../backoffice/src/constants/URLS.ts | 3 +
.../src/hooks/contracts/useContracts.ts | 54 +-
.../src/hooks/rule-engine/useRuleEngine.ts | 68 ++-
.../src/pages/fleet/FleetCrudPages.tsx | 2 +-
.../src/pages/fleet/config/resources.ts | 2 +-
.../PriorityRuleApprovalsSection.tsx | 125 +++++
.../ruleEngine/RuleEngineResourcePage.tsx | 71 ++-
.../TrainScheduleV2DetailPage.tsx | 39 +-
.../backoffice/src/services/api.ts | 10 +
.../src/services/contracts.service.ts | 31 +-
.../services/ruleEngine/ruleEngine.service.ts | 64 +++
.../backoffice/src/services/wagon.service.ts | 15 +
.../backoffice/src/types/trainScheduling.ts | 2 +
.../src/pages/bookings/NewBookingPage.tsx | 58 +++
.../bookings/clearance/ClearanceFlow.tsx | 6 +-
.../clearance/OperationDatePicker.tsx | 19 +-
.../bookings/new-booking-form/step4-route.tsx | 34 +-
.../new-booking-form/step8-review.tsx | 18 +-
.../portal/src/services/api.ts | 6 +
.../portal/src/services/bookings.service.ts | 21 +-
packages/types/src/freight/contracts.ts | 38 ++
packages/types/src/freight/index.ts | 9 +-
59 files changed, 3008 insertions(+), 284 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts
create mode 100644 apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts
create mode 100644 apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts
create mode 100644 apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts
create mode 100644 apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts
create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts
create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts
create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts
create mode 100644 apps/edr-freight-api/src/modules/wagons/dto/bulk-fulfill-transfer-requests.dto.ts
create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ContractDocumentEditorModal.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/pages/ruleEngine/PriorityRuleApprovalsSection.tsx
diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
index 559415fd8..a298b8dcb 100644
--- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
+++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
@@ -1,7 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ContractsRepository } from '../modules/contracts/contracts.repository';
-import { Contract } from '../modules/contracts/entities/contract.entity';
+import {
+ Contract,
+ ContractDocumentSnapshot,
+} from '../modules/contracts/entities/contract.entity';
import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
import {
ContractSignature,
@@ -11,7 +14,10 @@ import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.
import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service';
import { ContractTemplateResolver } from './contract-template.resolver';
import { getTemplateMeta } from './contract-template.registry';
-import { ContractViewModel } from './contract-view-model.builder';
+import {
+ ContractDynamicTemplateView,
+ ContractViewModel,
+} from './contract-view-model.builder';
/**
* Signature row for the contract PDF. Mirrors the booking builder's
@@ -90,22 +96,36 @@ export class ContractDocumentViewModelBuilder {
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
let template = getTemplateMeta(templateKey);
- // Prefer the admin-editable DB template matching the contract's
- // direction/freight pair; fall back to the code-defined generic layout
- // when none is active.
- const dynamicSource = await this.contractTemplates.findActiveForContract(
- contract.tradeDirection,
- contract.freightType,
- );
- const dynamicTemplate = dynamicSource
- ? {
- code: dynamicSource.code,
- name: dynamicSource.name,
- documentTitle: dynamicSource.documentTitle,
- whereasClauses: dynamicSource.whereasClauses ?? [],
- articles: dynamicSource.articles ?? [],
- }
- : undefined;
+ // The document articles come, in order of preference, from:
+ // 1. this contract's frozen snapshot (staff accepted / edited it) — the
+ // shared six templates are never consulted for these contracts;
+ // 2. the admin-editable DB template matching the direction/freight pair;
+ // 3. the code-defined generic layout (handled below when none of the above).
+ const snapshot = contract.documentSnapshot as ContractDocumentSnapshot | null;
+ let dynamicTemplate: ContractDynamicTemplateView | undefined;
+ if (snapshot && (snapshot.articles?.length ?? 0) > 0) {
+ dynamicTemplate = {
+ code: snapshot.code ?? 'CONTRACT',
+ name: snapshot.name ?? template.title,
+ documentTitle: snapshot.documentTitle ?? '',
+ whereasClauses: snapshot.whereasClauses ?? [],
+ articles: snapshot.articles,
+ };
+ } else {
+ const dynamicSource = await this.contractTemplates.findActiveForContract(
+ contract.tradeDirection,
+ contract.freightType,
+ );
+ dynamicTemplate = dynamicSource
+ ? {
+ code: dynamicSource.code,
+ name: dynamicSource.name,
+ documentTitle: dynamicSource.documentTitle,
+ whereasClauses: dynamicSource.whereasClauses ?? [],
+ articles: dynamicSource.articles ?? [],
+ }
+ : undefined;
+ }
if (dynamicTemplate) {
template = {
...template,
diff --git a/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts b/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts
new file mode 100644
index 000000000..42e8f1dc8
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts
@@ -0,0 +1,48 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Schedule-scoped wagon pins.
+ *
+ * Wagon occupancy now lives ONLY on each schedule's own train_set_wagons slots
+ * (the per-schedule snapshot): pinning/releasing a wagon no longer mutates the
+ * Wagon entity, so the same physical wagon can serve many schedules (the July 17
+ * and July 20 runs of one train both use its 50 wagons). The Wagon columns
+ * `current_train_schedule_id` / `train_set_wagon_id` keep only their physical
+ * meaning — "out on this DISPATCHED train right now" (stamped at dispatch,
+ * cleared at arrive/unload/cancel).
+ *
+ * This migration erases the legacy pin-time stamps left by the old flow: any
+ * wagon pointing at a schedule that is not currently DISPATCHED (or that no
+ * longer exists) gets its pointers cleared, and — when the old flow had parked
+ * it in ASSIGNED — its status returns to the pool semantics (ASSIGNED only
+ * while coupled to a built train, otherwise AVAILABLE).
+ */
+export class ScheduleScopedWagonPins2210000000000 implements MigrationInterface {
+ name = "ScheduleScopedWagonPins2210000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ UPDATE freight.wagons w
+ SET current_train_schedule_id = NULL,
+ train_set_wagon_id = NULL,
+ status = CASE
+ WHEN w.status = 'ASSIGNED' AND w.train_id IS NULL THEN 'AVAILABLE'
+ ELSE w.status
+ END
+ WHERE w.deleted_at IS NULL
+ AND w.current_train_schedule_id IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1
+ FROM freight.train_schedules ts
+ WHERE ts.id = w.current_train_schedule_id
+ AND ts.deleted_at IS NULL
+ AND ts.status = 'DISPATCHED'
+ );
+ `);
+ }
+
+ public async down(_queryRunner: QueryRunner): Promise {
+ // Pin-time stamps cannot be reconstructed (the data was the bug); the
+ // slots on train_set_wagons still hold every live pin, so down is a no-op.
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts b/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts
new file mode 100644
index 000000000..5a8cdd035
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts
@@ -0,0 +1,27 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Adds freight.contracts.document_snapshot — a per-contract frozen copy of the
+ * contract-document template (articles + WHEREAS recitals) captured at staff
+ * accept. Staff can edit these articles for a single contract before generating
+ * its PDF; the edit never touches the shared six freight.contract_templates
+ * rows. Null on existing contracts → the PDF keeps rendering from the live
+ * template, so this is backward compatible.
+ */
+export class AddContractDocumentSnapshot2220000000000
+ implements MigrationInterface
+{
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.contracts
+ ADD COLUMN IF NOT EXISTS document_snapshot JSONB;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.contracts
+ DROP COLUMN IF EXISTS document_snapshot;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts b/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts
new file mode 100644
index 000000000..2fe7c726d
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts
@@ -0,0 +1,24 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Wagon status RETIRED is renamed DETAINED (wagons pulled from circulation).
+ * The column is a plain varchar, so this is a data-only rename. Vehicles keep
+ * their own RETIRED status — only freight.wagons rows are touched.
+ */
+export class RenameWagonStatusRetiredToDetained2230000000000
+ implements MigrationInterface
+{
+ name = 'RenameWagonStatusRetiredToDetained2230000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ UPDATE freight.wagons SET status = 'DETAINED' WHERE status = 'RETIRED'
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ UPDATE freight.wagons SET status = 'RETIRED' WHERE status = 'DETAINED'
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts b/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts
new file mode 100644
index 000000000..76a59b0f7
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts
@@ -0,0 +1,24 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Every new wagon-transfer request must state WHY the wagons are needed; the
+ * reason is shown on the OCC request queue. Nullable in the DB — legacy rows
+ * predate the requirement; the DTO enforces it for new requests.
+ */
+export class AddTransferRequestReason2240000000000 implements MigrationInterface {
+ name = 'AddTransferRequestReason2240000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.wagon_transfer_requests
+ ADD COLUMN IF NOT EXISTS reason text NULL
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.wagon_transfer_requests
+ DROP COLUMN IF EXISTS reason
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts b/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts
new file mode 100644
index 000000000..f93fc7c95
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts
@@ -0,0 +1,42 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Approval workflow for priority-rule changes: every create/update/delete of a
+ * priority config is filed here as a PENDING change request; an approver
+ * applies or rejects it. `payload` carries the proposed field values (null for
+ * DELETE), `priority_config_id` the target row (null for CREATE).
+ */
+export class CreatePriorityRuleChangeRequests2250000000000
+ implements MigrationInterface
+{
+ name = 'CreatePriorityRuleChangeRequests2250000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.priority_rule_change_requests (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ action varchar(10) NOT NULL,
+ priority_config_id uuid NULL REFERENCES freight.priority_configs (id),
+ payload jsonb NULL,
+ status varchar(10) NOT NULL DEFAULT 'PENDING',
+ requested_by_user_id uuid NULL,
+ decided_by_user_id uuid NULL,
+ decided_at timestamptz NULL,
+ decision_note text NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz NULL
+ )
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_prcr_status
+ ON freight.priority_rule_change_requests (status)
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `DROP TABLE IF EXISTS freight.priority_rule_change_requests`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
index 7b1522446..698759901 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -1003,18 +1003,26 @@ export class BookingTransitionService {
}
// The binding shipment day must have at least one OPEN departure on the
- // route — only schedule-backed days are selectable. The batch engine
- // assigns the specific train within that (route, day) pool later.
- const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
- booking.originYardId,
- booking.destinationYardId,
- eatDay(date),
- );
+ // route — only schedule-backed days are selectable — AND some departure
+ // that day must be able to physically carry this cargo type (wagon-TYPE
+ // gate; quantity never blocks — oversized bookings get a partial split
+ // offer). The batch engine assigns the specific train within that
+ // (route, day) pool later.
+ const { hasDeparture, hasCompatible } =
+ await this.bookingsService.checkDayCompatibilityForBooking(
+ booking,
+ eatDay(date),
+ );
if (!hasDeparture) {
throw new BadRequestException(
"No departures available on the selected day for this route",
);
}
+ if (!hasCompatible) {
+ throw new BadRequestException(
+ "No wagon on the selected day can carry this cargo type — please choose another day",
+ );
+ }
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
index f556751fb..53554b2e3 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
@@ -348,6 +348,28 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
+ @Get(':id/available-days')
+ @ApiOperation({
+ summary:
+ 'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
+ })
+ async availableDays(
+ @Param('id', ParseUUIDPipe) id: string,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ const booking = await this.bookingsService.findById(id);
+ if (
+ !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
+ !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
+ ) {
+ await this.bookingsService.assertCustomerCanAccessBooking(
+ user?.id,
+ booking,
+ );
+ }
+ return this.bookingsService.availableDaysForBooking(id);
+ }
+
@Get(':id/mile-summary')
@ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)',
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index dd5011df3..4aade3bf2 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -653,22 +653,37 @@ export class BookingsService {
} else if (dto.scheduledDate) {
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
// directly). Require that the route has at least one OPEN departure on
- // that EAT day. The booking wizard does NOT send scheduledDate at creation
- // — it captures a non-binding estimatedShipmentDate instead, and the
- // binding day is chosen later at the operation-request step. General
- // contracts also skip this (each drawdown order validates its own day).
+ // that EAT day AND that some departure that day can physically carry the
+ // cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get
+ // a partial split offer later). The booking wizard does NOT send
+ // scheduledDate at creation — it captures a non-binding
+ // estimatedShipmentDate instead, and the binding day is chosen later at
+ // the operation-request step. General contracts also skip this (each
+ // drawdown order validates its own day).
const day = eatDay(new Date(dto.scheduledDate));
- const hasDeparture =
- await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
+ const { hasDeparture, hasCompatible } =
+ await this.trainSchedulingService.checkDayCargoCompatibility(
dto.originYardId,
dto.destinationYardId,
day,
+ {
+ freightType: dto.freightType as 'CONTAINER' | 'BULK',
+ cargoTypeId: dto.cargoTypeId,
+ containerTypeIds: (dto.containers ?? [])
+ .map((c) => c.containerTypeId)
+ .filter((id): id is string => Boolean(id)),
+ },
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
+ if (!hasCompatible) {
+ throw new BadRequestException(
+ 'No wagon on the selected day can carry this cargo type — please choose another day',
+ );
+ }
}
const containers = dto.containers ?? [];
@@ -1149,6 +1164,52 @@ export class BookingsService {
);
}
+ /** Cargo identity of a booking for the wagon-TYPE compatibility gate. */
+ private cargoIdentityOf(booking: Booking): {
+ freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
+ containerTypeIds?: string[];
+ } {
+ return {
+ freightType: booking.freightType as 'CONTAINER' | 'BULK',
+ cargoTypeId: booking.cargoTypeId ?? null,
+ containerTypeIds: (booking.bookingContainers ?? [])
+ .map((line) => line.containerTypeId)
+ .filter((id): id is string => Boolean(id)),
+ };
+ }
+
+ /**
+ * Day gate for a specific booking: OPEN departure exists AND some departure
+ * that day can physically carry the booking's cargo/container type.
+ * Quantity never blocks — oversized bookings get a partial split offer.
+ */
+ async checkDayCompatibilityForBooking(
+ booking: Booking,
+ day: string,
+ ): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
+ return this.trainSchedulingService.checkDayCargoCompatibility(
+ booking.originYardId,
+ booking.destinationYardId,
+ day,
+ this.cargoIdentityOf(booking),
+ );
+ }
+
+ /**
+ * Days the customer may pick for THIS booking (operation-request step):
+ * cargo-aware — only days whose departures can carry the booking's cargo
+ * type. Returns days only, no capacity counts.
+ */
+ async availableDaysForBooking(bookingId: string): Promise<{ days: string[] }> {
+ const booking = await this.findById(bookingId);
+ return this.trainSchedulingService.getAvailableDaysForCargo({
+ originYardId: booking.originYardId,
+ destinationYardId: booking.destinationYardId,
+ ...this.cargoIdentityOf(booking),
+ });
+ }
+
/**
* Batched version of the findById flag: marks each page item whose booking
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
index 4da2677bd..049db2a94 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
@@ -4,6 +4,7 @@ import {
Injectable,
Logger,
} from '@nestjs/common';
+import { randomUUID } from 'node:crypto';
import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
@@ -21,16 +22,35 @@ import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.
import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service';
+import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
import { contractClearanceSettingCode } from './contract-clearance.util';
-import { Contract } from './entities/contract.entity';
+import {
+ Contract,
+ ContractDocumentArticle,
+ ContractDocumentSnapshot,
+ ContractDocumentSnapshotInput,
+} from './entities/contract.entity';
import { ContractSignerRole } from './entities/contract-signature.entity';
import { SignContractDto } from './dto/sign-contract.dto';
+/** The editable contract-document draft returned for the accept/edit dialog. */
+export interface ContractDocumentDraft {
+ documentTitle: string | null;
+ whereasClauses: string[];
+ articles: ContractDocumentArticle[];
+ code: string | null;
+ name: string | null;
+ /** True once the document may no longer be edited/regenerated. */
+ locked: boolean;
+ generatedAt: Date | null;
+ status: string;
+}
+
/**
* Dropdown-settings code holding the admin-configured contract validity options
* (each option's `value` is a day count). The staff accept dialog reads the same
@@ -68,6 +88,7 @@ export class ContractTransitionService {
private readonly minioService: MinioService,
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
+ private readonly contractTemplates: ContractTemplatesService,
) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -110,6 +131,7 @@ export class ContractTransitionService {
contractId: string,
actorId: string,
validityDays: number,
+ documentSnapshot?: ContractDocumentSnapshotInput | null,
): Promise {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SUBMITTED']);
@@ -128,6 +150,12 @@ export class ContractTransitionService {
await this.instantiateApprovalSteps(contract);
+ // Freeze the contract document for THIS contract only. Staff may have edited
+ // the articles in the accept dialog; otherwise the live template is captured
+ // as-is so later template edits never change an in-flight contract. The
+ // shared six templates are never written here.
+ const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
+
await this.contractsRepository.update(contractId, {
status: 'PENDING_APPROVAL',
approvedByStaffId: actorId,
@@ -135,12 +163,148 @@ export class ContractTransitionService {
contractValidityDays: validityDays,
contractValidFrom: validFrom,
contractValidUntil: validUntil,
+ documentSnapshot: snapshot,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.accepted(updated);
return updated;
}
+ // ── Per-contract document snapshot (US: edit articles for one contract) ─────
+
+ /**
+ * The editable document draft for the accept/edit dialog: the frozen snapshot
+ * if one exists, else the live active template resolved for this contract's
+ * direction/freight pair. `locked` flips true once the document may no longer
+ * be edited (an approver has acted, or the contract has left the pre-approval
+ * window).
+ */
+ async getContractDocumentDraft(
+ contractId: string,
+ ): Promise {
+ const contract = await this.contractsService.findById(contractId);
+ const snapshot =
+ (contract.documentSnapshot as ContractDocumentSnapshot | null) ??
+ (await this.resolveDocumentSnapshot(contract));
+ return {
+ documentTitle: snapshot?.documentTitle ?? null,
+ whereasClauses: snapshot?.whereasClauses ?? [],
+ articles: snapshot?.articles ?? [],
+ code: snapshot?.code ?? null,
+ name: snapshot?.name ?? null,
+ locked: !this.documentIsEditable(contract),
+ generatedAt: contract.contractGeneratedAt ?? null,
+ status: contract.status,
+ };
+ }
+
+ /**
+ * Replace this contract's document articles from the editor. Per-contract
+ * only — it writes the contract's own snapshot and never the shared templates.
+ * Allowed while the document is still editable (PENDING_APPROVAL, no approver
+ * has acted).
+ */
+ async updateContractDocument(
+ contractId: string,
+ input: ContractDocumentSnapshotInput,
+ ): Promise {
+ const contract = await this.contractsService.findById(contractId);
+ assertContractStatus(contract, ['PENDING_APPROVAL']);
+ this.assertDocumentEditable(contract);
+
+ const current =
+ (contract.documentSnapshot as ContractDocumentSnapshot | null) ??
+ (await this.resolveDocumentSnapshot(contract));
+ const merged: ContractDocumentSnapshotInput = {
+ code: current?.code ?? null,
+ name: input.name ?? current?.name ?? null,
+ documentTitle: input.documentTitle ?? current?.documentTitle ?? null,
+ whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
+ articles: input.articles ?? current?.articles ?? [],
+ };
+ await this.contractsRepository.update(contractId, {
+ documentSnapshot: this.normalizeSnapshot(merged),
+ } as never);
+ return this.contractsService.findById(contractId);
+ }
+
+ /**
+ * Build the per-contract document snapshot. Prefer the staff's edited articles
+ * from the dialog; otherwise freeze the active template matching the
+ * contract's direction/freight. Returns null when no active template exists
+ * (the renderer then falls back to the built-in generic layout at render time).
+ */
+ private async resolveDocumentSnapshot(
+ contract: Contract,
+ provided?: ContractDocumentSnapshotInput | null,
+ ): Promise {
+ if (provided && (provided.articles?.length ?? 0) > 0) {
+ return this.normalizeSnapshot(provided);
+ }
+ const active = await this.contractTemplates.findActiveForContract(
+ contract.tradeDirection,
+ contract.freightType,
+ );
+ if (!active) return null;
+ return {
+ code: active.code,
+ name: active.name,
+ documentTitle: active.documentTitle,
+ whereasClauses: active.whereasClauses ?? [],
+ articles: this.normalizeArticles(active.articles ?? []),
+ };
+ }
+
+ private normalizeSnapshot(
+ input: ContractDocumentSnapshotInput,
+ ): ContractDocumentSnapshot {
+ return {
+ code: input.code ?? null,
+ name: input.name ?? null,
+ documentTitle: input.documentTitle ?? null,
+ whereasClauses: Array.isArray(input.whereasClauses)
+ ? input.whereasClauses
+ .map((c) => String(c))
+ .filter((c) => c.trim().length > 0)
+ : [],
+ articles: this.normalizeArticles(input.articles ?? []),
+ };
+ }
+
+ /** Re-key ids and renumber order sequentially, dropping empty-title rows. */
+ private normalizeArticles(
+ articles: Array<{ id?: string; title?: string; body?: string; order?: number }>,
+ ): ContractDocumentArticle[] {
+ return articles
+ .filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0)
+ .map((a, index) => ({
+ id: a.id ?? randomUUID(),
+ title: (a.title ?? '').trim(),
+ body: a.body ?? '',
+ order: index + 1,
+ }));
+ }
+
+ /**
+ * The per-contract document may be edited/regenerated while the contract is at
+ * the accept stage (SUBMITTED) or in approval with NO approver having acted
+ * yet. The first approval action freezes it.
+ */
+ private documentIsEditable(contract: Contract): boolean {
+ if (contract.status === 'SUBMITTED') return true;
+ if (contract.status !== 'PENDING_APPROVAL') return false;
+ return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
+ }
+
+ private assertDocumentEditable(contract: Contract): void {
+ if (!this.documentIsEditable(contract)) {
+ throw new ConflictException(
+ 'The contract document is locked — an approver has already acted or the ' +
+ 'contract has advanced. It can no longer be edited or regenerated.',
+ );
+ }
+ }
+
/**
* Ensure the chosen validity (days) is one of the admin-configured options in
* the `contract_validity_periods` dropdown setting. If the setting is missing
@@ -303,6 +467,15 @@ export class ContractTransitionService {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
+ // Approvers review the generated contract document, so it must exist before
+ // the first approval can be recorded. Staff generate it (from the frozen,
+ // optionally-edited snapshot) at the accept stage.
+ if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) {
+ throw new BadRequestException(
+ 'Generate the contract document before it can be approved.',
+ );
+ }
+
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
@@ -350,15 +523,14 @@ export class ContractTransitionService {
const updated = await this.contractsService.findById(contractId);
if (allDone) {
this.notifier.approved(updated);
- // Final approval step also generates the contract document from the
- // template matching the contract's direction/freight pair. Best-effort:
- // a rendering hiccup must not roll back the approval — the document can
- // still be generated manually or lazily on view/download.
+ // Every step approved → CONTRACT_READY. The document was already generated
+ // (and reviewed) at the accept stage, so we reuse it rather than
+ // re-rendering. Best-effort: a hiccup must not roll back the approval.
try {
- return await this.generateContract(contractId);
+ return await this.finalizeApprovedContract(contractId);
} catch (err) {
this.logger.warn(
- `Auto contract generation after final approval failed for ${updated.reference}: ${err}`,
+ `Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
);
}
}
@@ -366,30 +538,66 @@ export class ContractTransitionService {
}
/**
- * Render the contract PDF from the Contract aggregate, store it via FilesService,
- * stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/
- * Chromium) is best-effort and must NOT block the contract from becoming ready —
- * the document is (re)rendered lazily on view/download once Chromium is available.
+ * Staff (re)generate the contract PDF. Two stages:
+ * - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
+ * snapshot so approvers review the real document. Status is UNCHANGED, and
+ * it is blocked once an approver has acted (the document is then locked).
+ * - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to
+ * CONTRACT_READY.
+ * PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the
+ * transition — the document re-renders lazily on view/download.
*/
async generateContract(contractId: string): Promise {
const contract = await this.contractsService.findById(contractId);
+
+ if (contract.status === 'PENDING_APPROVAL') {
+ this.assertDocumentEditable(contract);
+ await this.renderContractDocument(contract);
+ return this.contractsService.findById(contractId);
+ }
+
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
+ await this.renderContractDocument(contract);
+ await this.contractsRepository.update(contractId, {
+ status: 'CONTRACT_READY',
+ } as never);
+ return this.contractsService.findById(contractId);
+ }
- const { view } = await this.documentViewModelBuilder.build(contractId);
-
+ /**
+ * Render the contract PDF from the Contract aggregate (snapshot-driven), store
+ * it via FilesService, and stamp the template key + generated timestamp. Never
+ * changes status. Rendering is best-effort — a Chromium hiccup defers the file
+ * (it re-renders on view/download) but the timestamp is still stamped.
+ */
+ private async renderContractDocument(contract: Contract): Promise {
+ const { view } = await this.documentViewModelBuilder.build(contract.id);
try {
- await this.upsertContractPdf(contractId, contract.reference, view);
+ await this.upsertContractPdf(contract.id, contract.reference, view);
} catch (err) {
this.logger.warn(
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
);
}
-
- await this.contractsRepository.update(contractId, {
- status: 'CONTRACT_READY',
+ await this.contractsRepository.update(contract.id, {
contractTemplateKey: view.templateKey,
contractGeneratedAt: new Date(),
} as never);
+ }
+
+ /**
+ * Every approval step landed → CONTRACT_READY. The document was already
+ * generated (and reviewed) at the accept stage, so reuse it; render now only
+ * if it was somehow never generated. Never re-renders over an existing file.
+ */
+ private async finalizeApprovedContract(contractId: string): Promise {
+ const contract = await this.contractsService.findById(contractId);
+ if (!contract.contractGeneratedAt) {
+ await this.renderContractDocument(contract);
+ }
+ await this.contractsRepository.update(contractId, {
+ status: 'CONTRACT_READY',
+ } as never);
return this.contractsService.findById(contractId);
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
index 2b79d3274..2957124f8 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
@@ -8,6 +8,7 @@ import {
ParseUUIDPipe,
Patch,
Post,
+ Put,
Query,
Res,
UnauthorizedException,
@@ -57,6 +58,7 @@ import { UpdateContractDto } from './dto/update-contract.dto';
import { FilterContractDto } from './dto/filter-contract.dto';
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { AcceptContractDto } from './dto/accept-contract.dto';
+import { UpdateContractDocumentDto } from './dto/contract-document.dto';
import {
ApproveStepDto,
RejectContractDto,
@@ -340,9 +342,33 @@ export class ContractsController {
id,
resolveAuthUserId(user),
dto.validityDays,
+ dto.documentSnapshot,
);
}
+ @Get(':id/document/draft')
+ @BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
+ @ApiOperation({
+ summary:
+ 'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
+ })
+ getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) {
+ return this.transitionService.getContractDocumentDraft(id);
+ }
+
+ @Put(':id/document/articles')
+ @BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
+ @ApiOperation({
+ summary:
+ 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
+ })
+ updateContractDocument(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: UpdateContractDocumentDto,
+ ) {
+ return this.transitionService.updateContractDocument(id, dto);
+ }
+
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
@ApiOperation({ summary: 'Staff return contract for customer updates' })
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts
index 86e1260c4..d3eaa73a3 100644
--- a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts
+++ b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts
@@ -1,5 +1,8 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { IsInt, Max, Min } from 'class-validator';
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator';
+
+import { UpdateContractDocumentDto } from './contract-document.dto';
export class AcceptContractDto {
@ApiProperty({
@@ -14,4 +17,16 @@ export class AcceptContractDto {
@Min(1)
@Max(3650)
validityDays!: number;
+
+ /**
+ * Optional per-contract document override edited by staff in the accept
+ * dialog. When present its articles are frozen onto THIS contract; when
+ * omitted the live template is snapshotted as-is. Never edits the shared
+ * six templates.
+ */
+ @ApiPropertyOptional({ type: UpdateContractDocumentDto })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => UpdateContractDocumentDto)
+ documentSnapshot?: UpdateContractDocumentDto;
}
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts
new file mode 100644
index 000000000..7fdb8477e
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts
@@ -0,0 +1,64 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import {
+ IsArray,
+ IsInt,
+ IsOptional,
+ IsString,
+ ValidateNested,
+} from 'class-validator';
+
+/** One article of a per-contract document override sent from the editor. */
+export class ContractDocumentArticleDto {
+ @ApiPropertyOptional({ description: 'Stable id; omitted for a new article.' })
+ @IsOptional()
+ @IsString()
+ id?: string;
+
+ @ApiProperty()
+ @IsString()
+ title!: string;
+
+ @ApiProperty({ description: 'Plain multiline body; each line becomes a clause.' })
+ @IsString()
+ body!: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsInt()
+ order?: number;
+}
+
+/**
+ * The per-contract document override sent from the accept/edit editor. It edits
+ * ONLY this contract's frozen snapshot — it is never written back to the shared
+ * six {@link ContractTemplate} rows.
+ */
+export class UpdateContractDocumentDto {
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ code?: string | null;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ name?: string | null;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ documentTitle?: string | null;
+
+ @ApiPropertyOptional({ type: [String] })
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ whereasClauses?: string[];
+
+ @ApiProperty({ type: [ContractDocumentArticleDto] })
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => ContractDocumentArticleDto)
+ articles!: ContractDocumentArticleDto[];
+}
diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts
index 0b0fab41b..f29b9093b 100644
--- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts
+++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts
@@ -42,6 +42,43 @@ export const CONTRACT_STATUSES = [
export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
+/** One article on a per-contract document snapshot (mirrors the template shape). */
+export interface ContractDocumentArticle {
+ id: string;
+ title: string;
+ body: string;
+ order: number;
+}
+
+/**
+ * A per-contract copy of the resolved contract-document template, frozen when
+ * staff accept the contract for approval. Staff may edit these articles for a
+ * single contract in the accept/edit dialog — editing NEVER writes back to the
+ * shared six {@link ContractTemplate} rows. The PDF is rendered from this
+ * snapshot when present; a null snapshot renders from the live template.
+ */
+export interface ContractDocumentSnapshot {
+ code?: string | null;
+ name?: string | null;
+ documentTitle?: string | null;
+ whereasClauses: string[];
+ articles: ContractDocumentArticle[];
+}
+
+/** Loose inbound shape (article ids/order optional) — normalized before store. */
+export interface ContractDocumentSnapshotInput {
+ code?: string | null;
+ name?: string | null;
+ documentTitle?: string | null;
+ whereasClauses?: string[];
+ articles?: Array<{
+ id?: string;
+ title?: string;
+ body?: string;
+ order?: number;
+ }>;
+}
+
export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const;
export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
@@ -193,6 +230,14 @@ export class Contract extends BaseEntity {
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
contractGeneratedAt?: Date | null;
+ /**
+ * Per-contract frozen copy of the document template (articles + WHEREAS),
+ * captured at staff accept. Editing it affects only this contract, never the
+ * shared six templates. Null → the PDF renders from the live template.
+ */
+ @Column({ name: 'document_snapshot', type: 'jsonb', nullable: true })
+ documentSnapshot?: ContractDocumentSnapshot | null;
+
@Column({ name: 'contract_summary', type: 'text', nullable: true })
contractSummary?: string | null;
diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts
new file mode 100644
index 000000000..73ff94d31
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts
@@ -0,0 +1,72 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Param,
+ ParseUUIDPipe,
+ Post,
+ Query,
+} from '@nestjs/common';
+import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
+import { CurrentUser } from '@edr/api-common';
+import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
+
+import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
+import {
+ DecidePriorityRuleChangeDto,
+ SubmitPriorityRuleChangeDto,
+} from '../dto/priority-rule-change-request.dto';
+import { PriorityRuleChangeStatus } from '../entities/priority-rule-change-request.entity';
+import { PriorityRuleChangeRequestsService } from '../services/priority-rule-change-requests.service';
+
+/**
+ * Approval workflow for priority-rule changes. Anyone with the manage
+ * permission SUBMITS a change; an approver (same permission — the team decides
+ * who reviews) approves or rejects it. The team is notified at each step.
+ */
+@ApiTags('priority-rule-change-requests')
+@Controller('priority-rule-change-requests')
+@ApiBearerAuth()
+export class PriorityRuleChangeRequestsController {
+ constructor(private readonly service: PriorityRuleChangeRequestsService) {}
+
+ @Post()
+ @RuleEngineManage('priority-configs')
+ @ApiOperation({ summary: 'Submit a priority-rule change for approval' })
+ submit(
+ @Body() dto: SubmitPriorityRuleChangeDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.service.submit(dto, user?.id);
+ }
+
+ @Get()
+ @RuleEngineView('priority-configs')
+ @ApiQuery({ name: 'status', required: false, enum: ['PENDING', 'APPROVED', 'REJECTED'] })
+ @ApiOperation({ summary: 'List priority-rule change requests' })
+ list(@Query('status') status?: PriorityRuleChangeStatus) {
+ return this.service.list(status);
+ }
+
+ @Post(':id/approve')
+ @RuleEngineManage('priority-configs')
+ @ApiOperation({ summary: 'Approve and apply a pending change' })
+ approve(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: DecidePriorityRuleChangeDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.service.approve(id, user?.id, dto.decisionNote);
+ }
+
+ @Post(':id/reject')
+ @RuleEngineManage('priority-configs')
+ @ApiOperation({ summary: 'Reject a pending change' })
+ reject(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: DecidePriorityRuleChangeDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.service.reject(id, user?.id, dto.decisionNote);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts
new file mode 100644
index 000000000..31295b050
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts
@@ -0,0 +1,49 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import {
+ IsIn,
+ IsOptional,
+ IsString,
+ IsUUID,
+ MaxLength,
+ ValidateNested,
+} from 'class-validator';
+
+import { CreatePriorityConfigDto } from './create-priority-config.dto';
+import { UpdatePriorityConfigDto } from './update-priority-config.dto';
+
+/**
+ * File a priority-rule change for approval. CREATE carries a full `create`
+ * payload; UPDATE carries the target id + an `update` patch; DELETE carries
+ * only the target id.
+ */
+export class SubmitPriorityRuleChangeDto {
+ @ApiProperty({ enum: ['CREATE', 'UPDATE', 'DELETE'] })
+ @IsIn(['CREATE', 'UPDATE', 'DELETE'])
+ action!: 'CREATE' | 'UPDATE' | 'DELETE';
+
+ @ApiPropertyOptional({ description: 'Target rule id (UPDATE / DELETE)' })
+ @IsOptional()
+ @IsUUID()
+ priorityConfigId?: string;
+
+ @ApiPropertyOptional({ description: 'Proposed new rule (CREATE)' })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => CreatePriorityConfigDto)
+ create?: CreatePriorityConfigDto;
+
+ @ApiPropertyOptional({ description: 'Proposed field changes (UPDATE)' })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => UpdatePriorityConfigDto)
+ update?: UpdatePriorityConfigDto;
+}
+
+export class DecidePriorityRuleChangeDto {
+ @ApiPropertyOptional({ description: 'Optional note shown to the requester' })
+ @IsOptional()
+ @IsString()
+ @MaxLength(1000)
+ decisionNote?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts
new file mode 100644
index 000000000..ec2425745
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts
@@ -0,0 +1,46 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
+
+import { PriorityConfig } from './priority-config.entity';
+
+export type PriorityRuleChangeAction = 'CREATE' | 'UPDATE' | 'DELETE';
+export type PriorityRuleChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
+
+/**
+ * One proposed change to a priority rule, awaiting approval. Every
+ * create/update/delete of a priority config is filed here first; an approver
+ * applies (which runs the real mutation, including range-collision checks) or
+ * rejects it. `payload` holds the proposed field values (null for DELETE);
+ * `priorityConfigId` the target rule (null for CREATE).
+ */
+@Entity({ schema: 'freight', name: 'priority_rule_change_requests' })
+@Index(['status'])
+export class PriorityRuleChangeRequest extends BaseEntity {
+ @Column({ name: 'action', type: 'varchar', length: 10 })
+ action!: PriorityRuleChangeAction;
+
+ @Column({ name: 'priority_config_id', type: 'uuid', nullable: true })
+ priorityConfigId?: string | null;
+
+ @ManyToOne(() => PriorityConfig, { nullable: true })
+ @JoinColumn({ name: 'priority_config_id' })
+ priorityConfig?: PriorityConfig | null;
+
+ @Column({ name: 'payload', type: 'jsonb', nullable: true })
+ payload?: Record | null;
+
+ @Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' })
+ status!: PriorityRuleChangeStatus;
+
+ @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
+ requestedByUserId?: string | null;
+
+ @Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true })
+ decidedByUserId?: string | null;
+
+ @Column({ name: 'decided_at', type: 'timestamptz', nullable: true })
+ decidedAt?: Date | null;
+
+ @Column({ name: 'decision_note', type: 'text', nullable: true })
+ decisionNote?: string | null;
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts
index 34dc9f982..7edcf0bbf 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts
@@ -5,6 +5,7 @@ import { ApprovalRulesController } from './controllers/approval-rules.controller
import { CargoTypesController } from './controllers/cargo-types.controller';
import { ContainerTypesController } from './controllers/container-types.controller';
import { PriorityConfigsController } from './controllers/priority-configs.controller';
+import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller';
import { RatesController } from './controllers/rates.controller';
import { ServiceTypesController } from './controllers/service-types.controller';
import { ShippingLinesController } from './controllers/shipping-lines.controller';
@@ -15,6 +16,7 @@ import { ApprovalRule } from './entities/approval-rule.entity';
import { CargoType } from './entities/cargo-type.entity';
import { ContainerType } from './entities/container-type.entity';
import { PriorityConfig } from './entities/priority-config.entity';
+import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity';
import { Rate } from './entities/rate.entity';
import { ServiceType } from './entities/service-type.entity';
import { ShippingLine } from './entities/shipping-line.entity';
@@ -46,6 +48,7 @@ import { DisplayOrderService } from './services/display-order.service';
import { CargoTypesService } from './services/cargo-types.service';
import { ContainerTypesService } from './services/container-types.service';
import { PriorityConfigsService } from './services/priority-configs.service';
+import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service';
import { RatesService } from './services/rates.service';
import { ServiceTypesService } from './services/service-types.service';
import { ShippingLinesService } from './services/shipping-lines.service';
@@ -54,6 +57,8 @@ import { YardsService } from './services/yards.service';
import { RuleEngineService } from './rule-engine.service';
+import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
+
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -66,6 +71,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoType,
ContainerType,
PriorityConfig,
+ PriorityRuleChangeRequest,
ServiceType,
WeightLimitRule,
Yard,
@@ -77,11 +83,14 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
BookingApprovalStep,
BookingRateSnapshot,
]),
+ // Team notifications for the priority-rule approval workflow.
+ NotificationInboxModule,
],
controllers: [
CargoTypesController,
ContainerTypesController,
PriorityConfigsController,
+ PriorityRuleChangeRequestsController,
ServiceTypesController,
WeightLimitRulesController,
YardsController,
@@ -111,6 +120,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoTypesService,
ContainerTypesService,
PriorityConfigsService,
+ PriorityRuleChangeRequestsService,
ServiceTypesService,
WeightLimitRulesService,
YardsService,
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts
index 560a550b2..9aaf06985 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts
@@ -31,6 +31,12 @@ export class PriorityConfigsService {
async create(dto: CreatePriorityConfigDto): Promise {
this.validateCurrencyField(dto.type, dto.currency);
+ await this.assertNoRangeCollision({
+ type: dto.type,
+ currency: dto.currency ?? null,
+ minWagonCount: dto.minWagonCount,
+ maxWagonCount: dto.maxWagonCount,
+ });
const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {});
@@ -52,6 +58,13 @@ export class PriorityConfigsService {
const type = dto.type ?? existing.type;
const currency = dto.currency !== undefined ? dto.currency : existing.currency;
this.validateCurrencyField(type, currency);
+ await this.assertNoRangeCollision({
+ type,
+ currency: currency ?? null,
+ minWagonCount: dto.minWagonCount ?? existing.minWagonCount,
+ maxWagonCount: dto.maxWagonCount ?? existing.maxWagonCount,
+ excludeId: id,
+ });
const { ...patch } = dto;
const updated = await this.repository.update(id, patch);
@@ -59,6 +72,43 @@ export class PriorityConfigsService {
return updated;
}
+ /**
+ * No two rules of the same type (and, for CURRENCY rules, the same currency)
+ * may cover overlapping wagon-count ranges — a booking must match at most one
+ * rule per type. Rejects an exact duplicate (1–5 vs 1–5) and any partial
+ * overlap (1–5 vs 4–7). Ranges are inclusive on both ends.
+ */
+ async assertNoRangeCollision(input: {
+ type: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
+ currency?: string | null;
+ minWagonCount: number;
+ maxWagonCount: number;
+ excludeId?: string;
+ }): Promise {
+ if (input.minWagonCount > input.maxWagonCount) {
+ throw new BadRequestException(
+ 'Min wagon count cannot be greater than max wagon count',
+ );
+ }
+ const siblings = await this.repository.findAll({
+ where: { type: input.type },
+ });
+ const clash = siblings.find(
+ (s) =>
+ s.id !== input.excludeId &&
+ (input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) &&
+ input.minWagonCount <= s.maxWagonCount &&
+ input.maxWagonCount >= s.minWagonCount,
+ );
+ if (clash) {
+ throw new BadRequestException(
+ `Wagon range ${input.minWagonCount}–${input.maxWagonCount} overlaps existing rule ` +
+ `"${clash.label}" (${clash.minWagonCount}–${clash.maxWagonCount}). ` +
+ 'Adjust the range so rules do not collide.',
+ );
+ }
+ }
+
async remove(id: string): Promise {
await this.findById(id);
await this.repository.softDelete(id);
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts
new file mode 100644
index 000000000..bdc6a2e8f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts
@@ -0,0 +1,226 @@
+import {
+ NotificationAudience,
+ NotificationType,
+} from '@edr/types';
+import {
+ BadRequestException,
+ ConflictException,
+ Injectable,
+ Logger,
+ NotFoundException,
+} from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service';
+import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
+import { SubmitPriorityRuleChangeDto } from '../dto/priority-rule-change-request.dto';
+import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
+import {
+ PriorityRuleChangeRequest,
+ PriorityRuleChangeStatus,
+} from '../entities/priority-rule-change-request.entity';
+import { PriorityConfigsService } from './priority-configs.service';
+
+/** Backoffice rule-engine page — where both queue and rules live. */
+const RULES_LINK = '/dashboard/rules/priority-configs';
+
+/**
+ * Approval workflow for priority-rule changes. Nobody mutates priority configs
+ * directly any more: a change is SUBMITTED here (validated up front so the
+ * requester gets immediate feedback on range collisions), the team is
+ * notified, and an approver later applies or rejects it. Applying re-runs the
+ * full validation — the winning state is whatever is true at approval time.
+ */
+@Injectable()
+export class PriorityRuleChangeRequestsService {
+ private readonly logger = new Logger(PriorityRuleChangeRequestsService.name);
+
+ constructor(
+ @InjectRepository(PriorityRuleChangeRequest)
+ private readonly repo: Repository,
+ private readonly configs: PriorityConfigsService,
+ private readonly inbox: NotificationInboxService,
+ ) {}
+
+ async submit(
+ dto: SubmitPriorityRuleChangeDto,
+ userId?: string | null,
+ ): Promise {
+ const payload = await this.validateSubmission(dto);
+
+ const request = await this.repo.save(
+ this.repo.create({
+ action: dto.action,
+ priorityConfigId: dto.priorityConfigId ?? null,
+ payload,
+ status: 'PENDING',
+ requestedByUserId: userId ?? null,
+ }),
+ );
+
+ this.notifyTeam(
+ 'Priority rule change submitted',
+ `A ${dto.action.toLowerCase()} of a priority rule was submitted and awaits approval.`,
+ request,
+ );
+ return request;
+ }
+
+ async list(status?: PriorityRuleChangeStatus): Promise {
+ return this.repo.find({
+ where: status ? { status } : {},
+ relations: { priorityConfig: true },
+ order: { createdAt: 'DESC' },
+ });
+ }
+
+ async approve(
+ id: string,
+ userId?: string | null,
+ decisionNote?: string,
+ ): Promise {
+ const request = await this.findPending(id);
+
+ // Apply the change through the normal service so currency + range-collision
+ // validation runs against the CURRENT rules; a stale request that now
+ // collides fails here and stays PENDING for the approver to see the error.
+ if (request.action === 'CREATE') {
+ await this.configs.create(request.payload as unknown as CreatePriorityConfigDto);
+ } else if (request.action === 'UPDATE') {
+ await this.configs.update(
+ this.requireTarget(request),
+ request.payload as unknown as UpdatePriorityConfigDto,
+ );
+ } else {
+ await this.configs.remove(this.requireTarget(request));
+ }
+
+ request.status = 'APPROVED';
+ request.decidedByUserId = userId ?? null;
+ request.decidedAt = new Date();
+ request.decisionNote = decisionNote ?? null;
+ const saved = await this.repo.save(request);
+
+ this.notifyTeam(
+ 'Priority rule change approved',
+ `The ${request.action.toLowerCase()} priority-rule change was approved and applied.` +
+ (decisionNote ? ` Note: ${decisionNote}` : ''),
+ saved,
+ );
+ return saved;
+ }
+
+ async reject(
+ id: string,
+ userId?: string | null,
+ decisionNote?: string,
+ ): Promise {
+ const request = await this.findPending(id);
+ request.status = 'REJECTED';
+ request.decidedByUserId = userId ?? null;
+ request.decidedAt = new Date();
+ request.decisionNote = decisionNote ?? null;
+ const saved = await this.repo.save(request);
+
+ this.notifyTeam(
+ 'Priority rule change rejected',
+ `The ${request.action.toLowerCase()} priority-rule change was rejected.` +
+ (decisionNote ? ` Note: ${decisionNote}` : ''),
+ saved,
+ );
+ return saved;
+ }
+
+ /**
+ * Validate a submission the way applying it would, so bad requests are
+ * refused at the door — most importantly the wagon-range collision rule.
+ * Returns the payload to persist.
+ */
+ private async validateSubmission(
+ dto: SubmitPriorityRuleChangeDto,
+ ): Promise | null> {
+ if (dto.action === 'CREATE') {
+ if (!dto.create) {
+ throw new BadRequestException('CREATE requires the proposed rule in `create`');
+ }
+ await this.configs.assertNoRangeCollision({
+ type: dto.create.type,
+ currency: dto.create.currency ?? null,
+ minWagonCount: dto.create.minWagonCount,
+ maxWagonCount: dto.create.maxWagonCount,
+ });
+ return { ...dto.create };
+ }
+
+ if (!dto.priorityConfigId) {
+ throw new BadRequestException(`${dto.action} requires priorityConfigId`);
+ }
+ const existing = await this.configs.findById(dto.priorityConfigId);
+
+ if (dto.action === 'DELETE') return null;
+
+ if (!dto.update || Object.keys(dto.update).length === 0) {
+ throw new BadRequestException('UPDATE requires the field changes in `update`');
+ }
+ await this.configs.assertNoRangeCollision({
+ type: dto.update.type ?? existing.type,
+ currency:
+ dto.update.currency !== undefined ? dto.update.currency : existing.currency,
+ minWagonCount: dto.update.minWagonCount ?? existing.minWagonCount,
+ maxWagonCount: dto.update.maxWagonCount ?? existing.maxWagonCount,
+ excludeId: existing.id,
+ });
+ return { ...dto.update };
+ }
+
+ private async findPending(id: string): Promise {
+ const request = await this.repo.findOne({
+ where: { id },
+ relations: { priorityConfig: true },
+ });
+ if (!request) throw new NotFoundException(`Change request ${id} not found`);
+ if (request.status !== 'PENDING') {
+ throw new ConflictException(
+ `Change request is already ${request.status.toLowerCase()}`,
+ );
+ }
+ return request;
+ }
+
+ private requireTarget(request: PriorityRuleChangeRequest): string {
+ if (!request.priorityConfigId) {
+ throw new BadRequestException(
+ `${request.action} change request has no target rule`,
+ );
+ }
+ return request.priorityConfigId;
+ }
+
+ /**
+ * In-app notification to the whole backoffice team (submission AND decision
+ * both notify the team; the requester is staff, so they are included).
+ * Fire-and-forget — a notification failure never blocks the workflow.
+ */
+ private notifyTeam(
+ title: string,
+ body: string,
+ request: PriorityRuleChangeRequest,
+ ): void {
+ void this.inbox
+ .notify({
+ recipients: { allBackoffice: true },
+ audience: NotificationAudience.BACKOFFICE,
+ type: NotificationType.REQUEST_SUBMITTED,
+ title,
+ body,
+ link: RULES_LINK,
+ data: { priorityRuleChangeRequestId: request.id, action: request.action },
+ })
+ .catch((err) =>
+ this.logger.warn(
+ `Priority-rule notification failed: ${(err as Error).message}`,
+ ),
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts
index a3af6f572..165a0db20 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts
@@ -41,6 +41,28 @@ export class AvailableDaysForCargoQueryDto {
@IsString()
cargoTypeCode?: string;
+ @ApiPropertyOptional({ format: 'uuid', description: 'Bulk cargo type id (preferred over code).' })
+ @IsOptional()
+ @IsUUID()
+ cargoTypeId?: string;
+
+ @ApiPropertyOptional({
+ description:
+ 'Container type ids as a JSON string array — enables the exact wagon-type compatibility gate (falls back to containerSize matching when absent).',
+ })
+ @IsOptional()
+ @Transform(({ value }) => {
+ if (value == null || value === '') return undefined;
+ if (typeof value !== 'string') return value;
+ try {
+ return JSON.parse(value);
+ } catch {
+ return undefined;
+ }
+ })
+ @IsArray()
+ containerTypeIds?: string[];
+
@ApiPropertyOptional({ description: 'Total bulk weight in tons.' })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
index c6e22589f..e4efaab9a 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
@@ -234,9 +234,11 @@ export class TrainSchedulingController {
originYardId: query.originYardId,
destinationYardId: query.destinationYardId,
freightType: query.freightType,
+ cargoTypeId: query.cargoTypeId,
cargoTypeCode: query.cargoTypeCode,
totalWeightTons: query.totalWeightTons,
containers: query.containers,
+ containerTypeIds: query.containerTypeIds,
});
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
index 26de90298..8c2742b64 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
@@ -1391,8 +1391,6 @@ export class TrainSchedulingService {
await this.dataSource.transaction(async (manager) => {
const trainSetId = schedule.trainSetId;
- await this.releasePinnedWagonsForTrainSet(manager, trainSetId);
-
const deletedAllocationIds =
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
@@ -1528,7 +1526,6 @@ export class TrainSchedulingService {
(sb) => sb.bookingId !== bookingId,
);
if (remainingBookings.length === 0) {
- await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
schedule.trainSetId,
manager,
@@ -1768,7 +1765,18 @@ export class TrainSchedulingService {
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
}
- const slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => w.id));
+ const slots = schedule.trainSet?.wagons ?? [];
+ const slotIds = new Set(slots.map((w) => w.id));
+ const slotById = new Map(slots.map((w) => [w.id, w]));
+ const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId);
+ // Occupancy is judged against THIS schedule's own slots only — a wagon
+ // pinned on another schedule (e.g. the same train's July 17 run) stays
+ // pinnable here.
+ const slotIdByPhysicalId = new Map(
+ slots
+ .filter((w) => w.physicalWagonId)
+ .map((w) => [w.physicalWagonId as string, w.id]),
+ );
await this.dataSource.transaction(async (manager) => {
for (const assignment of dto.assignments) {
@@ -1784,29 +1792,61 @@ export class TrainSchedulingService {
if (!physicalWagon) {
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
}
- if (
- physicalWagon.status !== WagonStatus.Available &&
- physicalWagon.currentTrainScheduleId !== scheduleId
- ) {
+ const occupyingSlotId = slotIdByPhysicalId.get(assignment.physicalWagonId);
+ if (occupyingSlotId && occupyingSlotId !== assignment.trainSetWagonId) {
+ const occupyingSlot = slotById.get(occupyingSlotId);
throw new ConflictException(
- `Wagon ${physicalWagon.wagonNumber} is not available`,
+ `Wagon ${physicalWagon.wagonNumber} is already pinned to slot #${occupyingSlot?.sequenceNo ?? '?'} of this schedule`,
);
}
- if (physicalWagon.currentYardId !== schedule.originStationId) {
- throw new ConflictException(
- `Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
- );
+ if (builtTrainId) {
+ // Train-bound schedule: only the built train's own consist may be
+ // pinned — wherever the wagons currently sit, they travel with the
+ // train, so no yard/status gate applies.
+ if (physicalWagon.trainId !== builtTrainId) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is not part of this schedule's train`,
+ );
+ }
+ } else {
+ if (physicalWagon.trainId) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is coupled to a built train and cannot be pinned as a loose wagon`,
+ );
+ }
+ if (!this.isWagonPhysicallyUsable(physicalWagon)) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is not available (${physicalWagon.status})`,
+ );
+ }
+ if (
+ physicalWagon.currentTrainScheduleId &&
+ physicalWagon.currentTrainScheduleId !== scheduleId
+ ) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is out on a dispatched train`,
+ );
+ }
+ if (physicalWagon.currentYardId !== schedule.originStationId) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
+ );
+ }
}
+ // The pin lives ONLY on the schedule's slot — the Wagon entity keeps
+ // its status untouched so other schedules can still use the wagon.
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
physicalWagonId: assignment.physicalWagonId,
status: 'RESERVED',
});
- await manager.getRepository(Wagon).update(assignment.physicalWagonId, {
- trainSetWagonId: assignment.trainSetWagonId,
- currentTrainScheduleId: scheduleId,
- status: WagonStatus.Assigned,
- });
+ for (const [physicalId, slotId] of slotIdByPhysicalId) {
+ if (slotId === assignment.trainSetWagonId) {
+ slotIdByPhysicalId.delete(physicalId);
+ break;
+ }
+ }
+ slotIdByPhysicalId.set(assignment.physicalWagonId, assignment.trainSetWagonId);
}
});
@@ -1860,6 +1900,22 @@ export class TrainSchedulingService {
// at a time — block dispatch while any set locomotive is out on a dispatched train.
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
+ // Same rule for wagons: many schedules may pin the same wagon, but it can
+ // only be OUT on one dispatched train at a time.
+ const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? [])
+ .map((slot) => slot.physicalWagonId)
+ .filter((id): id is string => Boolean(id));
+ if (pinnedPhysicalIds.length) {
+ const rolling = await this.dataSource.getRepository(Wagon).find({
+ where: { id: In(pinnedPhysicalIds), currentTrainScheduleId: Not(IsNull()) },
+ });
+ const busy = rolling.filter((w) => w.currentTrainScheduleId !== scheduleId);
+ if (busy.length) {
+ throw new ConflictException(
+ `Cannot dispatch: wagon(s) ${busy.map((w) => w.wagonNumber).join(', ')} are still out on another dispatched train`,
+ );
+ }
+ }
const now = new Date();
await this.dataSource.transaction(async (manager) => {
@@ -3735,10 +3791,11 @@ export class TrainSchedulingService {
originYardId: string,
targetScheduleId?: string,
): Promise> {
- const [wagons, wagonTypes, builtTrainId] = await Promise.all([
+ const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.dataSource.getRepository(WagonType).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
+ this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]);
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map();
@@ -3750,10 +3807,20 @@ export class TrainSchedulingService {
if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue;
} else {
- const pinnedOnTarget = targetScheduleId
- ? wagon.currentTrainScheduleId === targetScheduleId
- : false;
- if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue;
+ // Schedule-scoped availability: pins held by OTHER schedules never
+ // consume a wagon here — the same physical wagon may serve the July 17
+ // and the July 20 run. A wagon is unusable only when it is coupled to a
+ // built train's consist, physically blocked, or out on a dispatched
+ // train right now.
+ const pinnedOnTarget = pinnedToTargetIds.has(wagon.id);
+ if (wagon.trainId) continue;
+ if (!this.isWagonPhysicallyUsable(wagon) && !pinnedOnTarget) continue;
+ if (
+ wagon.currentTrainScheduleId &&
+ wagon.currentTrainScheduleId !== targetScheduleId
+ ) {
+ continue;
+ }
if (wagon.currentYardId !== originYardId) continue;
}
@@ -3812,22 +3879,59 @@ export class TrainSchedulingService {
};
}
- private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
- const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
- const physicalIds = slots
- .map((slot) => slot.physicalWagonId)
- .filter((id): id is string => Boolean(id));
- if (!physicalIds.length) return;
- const wagons = await manager.getRepository(Wagon).find({ where: { id: In(physicalIds) } });
- for (const wagon of wagons) {
- await manager.getRepository(Wagon).update(wagon.id, {
- // Built-train wagons stay coupled to their train (ASSIGNED); loose
- // wagons return to the open AVAILABLE pool.
- status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
- trainSetWagonId: null,
- currentTrainScheduleId: null,
- });
- }
+ /**
+ * A wagon in a blocked physical state can never be planned or pinned.
+ * ASSIGNED no longer blocks: it only means the wagon is coupled to a built
+ * train or stamped by a live run — schedule-level occupancy is tracked on
+ * the schedule's own TrainSetWagon slots, never on the Wagon entity.
+ */
+ private isWagonPhysicallyUsable(wagon: Wagon): boolean {
+ return (
+ wagon.status === WagonStatus.Available || wagon.status === WagonStatus.Assigned
+ );
+ }
+
+ /**
+ * Physical wagons already pinned to THIS schedule's slots. Availability is
+ * schedule-scoped: only a duplicate pin within the same schedule conflicts;
+ * pins held by other schedules of the same train are irrelevant.
+ */
+ private async pinnedPhysicalWagonIdsForSchedule(
+ scheduleId: string | undefined,
+ manager?: EntityManager,
+ ): Promise> {
+ if (!scheduleId) return new Set();
+ const runner = manager ?? this.dataSource;
+ const rows: { physical_wagon_id: string }[] = await runner.query(
+ `SELECT tsw.physical_wagon_id
+ FROM freight.train_set_wagons tsw
+ JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
+ WHERE ts.id = $1
+ AND ts.deleted_at IS NULL
+ AND tsw.deleted_at IS NULL
+ AND tsw.physical_wagon_id IS NOT NULL`,
+ [scheduleId],
+ );
+ return new Set(rows.map((row) => row.physical_wagon_id));
+ }
+
+ /**
+ * Physical wagons pinned to any slot of a live (DRAFT/SCHEDULED/DISPATCHED)
+ * schedule. Used to guard consist trims — the Wagon entity itself carries no
+ * schedule-occupancy state anymore.
+ */
+ private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise> {
+ const runner = manager ?? this.dataSource;
+ const rows: { physical_wagon_id: string }[] = await runner.query(
+ `SELECT DISTINCT tsw.physical_wagon_id
+ FROM freight.train_set_wagons tsw
+ JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
+ WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
+ AND ts.deleted_at IS NULL
+ AND tsw.deleted_at IS NULL
+ AND tsw.physical_wagon_id IS NOT NULL`,
+ );
+ return new Set(rows.map((row) => row.physical_wagon_id));
}
private async autoPinWagonsForSchedule(
@@ -3839,6 +3943,10 @@ export class TrainSchedulingService {
const wagons = await manager.getRepository(Wagon).find();
const wagonTypes = await manager.getRepository(WagonType).find();
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
+ const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
+ scheduleId,
+ manager,
+ );
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
const planSlots = [...slots]
@@ -3857,6 +3965,7 @@ export class TrainSchedulingService {
scheduleId,
originYardId,
builtTrainId,
+ pinnedToScheduleIds,
);
if (unpinnable.length) {
throw new BadRequestException({
@@ -3874,18 +3983,17 @@ export class TrainSchedulingService {
originYardId,
assignedPhysicalIds,
builtTrainId,
+ pinnedToScheduleIds,
);
if (!physical) continue;
+ // Pin lives ONLY on the schedule's own slot — the Wagon entity is never
+ // touched here, so the same physical wagon stays free for every other
+ // schedule (it gets stamped at dispatch, when it physically leaves).
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
physicalWagonId: physical.id,
status: 'RESERVED',
});
- await manager.getRepository(Wagon).update(physical.id, {
- trainSetWagonId: slot.trainSetWagonId,
- currentTrainScheduleId: scheduleId,
- status: WagonStatus.Assigned,
- });
assignedPhysicalIds.add(physical.id);
}
}
@@ -3898,9 +4006,10 @@ export class TrainSchedulingService {
): Promise {
if (!wagonPlan.length) return [];
- const [wagons, builtTrainId] = await Promise.all([
+ const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
+ this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]);
return this.findUnpinnableWagonSlots(
wagonPlan.map((slot) => ({
@@ -3913,6 +4022,7 @@ export class TrainSchedulingService {
targetScheduleId,
originYardId,
builtTrainId,
+ pinnedToScheduleIds,
);
}
@@ -3927,6 +4037,7 @@ export class TrainSchedulingService {
scheduleId: string | undefined,
originYardId: string,
builtTrainId: string | null = null,
+ pinnedToScheduleIds: Set = new Set(),
): string[] {
const violations: string[] = [];
const assignedPhysicalIds = new Set();
@@ -3939,6 +4050,7 @@ export class TrainSchedulingService {
originYardId,
assignedPhysicalIds,
builtTrainId,
+ pinnedToScheduleIds,
);
if (!physical) {
violations.push(
@@ -3964,14 +4076,22 @@ export class TrainSchedulingService {
originYardId: string,
assignedPhysicalIds: Set,
builtTrainId: string | null = null,
+ pinnedToScheduleIds: Set = new Set(),
): Wagon | undefined {
const usable = (wagon: Wagon): boolean => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
if (assignedPhysicalIds.has(wagon.id)) return false;
- const pinnedOnSchedule = scheduleId
- ? wagon.currentTrainScheduleId === scheduleId
- : false;
- return wagon.status === WagonStatus.Available || pinnedOnSchedule;
+ // Loose pool never lends a wagon coupled to a built train's consist.
+ if (wagon.trainId) return false;
+ // Out on a dispatched train right now — physically gone.
+ if (
+ wagon.currentTrainScheduleId &&
+ wagon.currentTrainScheduleId !== scheduleId
+ ) {
+ return false;
+ }
+ const pinnedOnSchedule = pinnedToScheduleIds.has(wagon.id);
+ return this.isWagonPhysicallyUsable(wagon) || pinnedOnSchedule;
};
// Train-bound schedule: ONLY the built train's own wagons may be pinned —
// wherever they currently sit (they travel with the train), never a loose
@@ -4746,6 +4866,7 @@ export class TrainSchedulingService {
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
+ const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
@@ -4802,8 +4923,8 @@ export class TrainSchedulingService {
wagons: wagons.map((wagon) => ({
...mapWagon(wagon),
loaded: loadedWagonIds.has(wagon.id),
- // Free = not pinned to any run; only free wagons can be trimmed.
- removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id),
+ // Free = not pinned to any live run's slot; only free wagons can be trimmed.
+ removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id),
})),
addableWagons: addableWagons.map(mapWagon),
adjustments: adjustments.map((log) => ({
@@ -4882,13 +5003,14 @@ export class TrainSchedulingService {
const consistById = new Map(consist.map((w) => [w.id, w]));
// --- validate removals: must be coupled and free (no cargo, no pin) ---
+ const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager);
const removed: Wagon[] = [];
for (const wagonId of removeWagonIds) {
const wagon = consistById.get(wagonId);
if (!wagon) {
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
}
- if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) {
+ if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
);
@@ -5369,10 +5491,10 @@ export class TrainSchedulingService {
/**
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
- * train capacity (not fully allocated). Wagon availability is deliberately NOT
- * checked here: whether a matching wagon currently sits in the right yard is an
- * operational question staff resolve when they approve or reject the booking,
- * not something the customer can act on while choosing a date. Same
+ * train capacity (not fully allocated) AND its wagon stock can physically carry
+ * the selected cargo/container type (wagon-TYPE gate). Quantity is deliberately
+ * NOT gated — a booking bigger than the free capacity is accepted and the batch
+ * engine offers a partial split later. No counts are exposed: same
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
* not a train.
*/
@@ -5380,9 +5502,11 @@ export class TrainSchedulingService {
originYardId?: string;
destinationYardId?: string;
freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
cargoTypeCode?: string | null;
totalWeightTons?: number;
containers?: Array<{ containerSize: string; quantity: number }>;
+ containerTypeIds?: string[];
}): Promise<{ days: string[] }> {
const schedules = await this.getBookableScheduleEntities(
input.originYardId,
@@ -5390,17 +5514,233 @@ export class TrainSchedulingService {
);
if (schedules.length === 0) return { days: [] };
+ const withCapacity = schedules.filter(
+ (s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0,
+ );
+ const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input);
+
const days = new Set();
- for (const s of schedules) {
- const hasCapacity =
- Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
- if (!hasCapacity) continue;
+ for (const s of compatible) {
if (s.scheduledDepartureDate)
days.add(eatDay(new Date(s.scheduledDepartureDate)));
}
return { days: [...days].sort() };
}
+ /**
+ * Wagon-TYPE compatibility gate (customer booking): keep only the schedules
+ * whose wagon stock can physically carry the selected cargo — every container
+ * line (or the bulk cargo type) must map to at least one wagon type the
+ * schedule's stock actually has. Stock = the built train's own consist, or the
+ * origin yard's loose pool for schedules assembled from loose locomotives.
+ * QUANTITY is deliberately ignored: an over-sized booking is allowed and gets
+ * a partial split offer from the batch engine later.
+ */
+ private async filterCargoCompatibleSchedules(
+ schedules: TrainSchedule[],
+ cargo: {
+ freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
+ cargoTypeCode?: string | null;
+ containers?: Array<{ containerSize: string; quantity: number }>;
+ containerTypeIds?: string[];
+ },
+ ): Promise {
+ if (!schedules.length) return schedules;
+ const required = await this.requiredWagonTypeSets(cargo);
+ // No cargo identity supplied — nothing to gate on (legacy callers).
+ if (required === null) return schedules;
+
+ const stockByScheduleId = await this.scheduleWagonTypeStock(schedules);
+ return schedules.filter((s) => {
+ const stock = stockByScheduleId.get(s.id) ?? new Set();
+ return required.every((set) => {
+ for (const typeId of set) if (stock.has(typeId)) return true;
+ return false;
+ });
+ });
+ }
+
+ /**
+ * One Set of allowed wagon-type ids per required cargo dimension: per
+ * container line's type (or per container size when only sizes are known),
+ * or a single set for the bulk cargo type. `null` = no cargo identity given,
+ * skip gating. An EMPTY set means "nothing can carry this" (no wagon types
+ * configured) — the gate then blocks every schedule, mirroring the hard
+ * config violation scheduling raises for the same state.
+ */
+ private async requiredWagonTypeSets(cargo: {
+ freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
+ cargoTypeCode?: string | null;
+ containers?: Array<{ containerSize: string; quantity: number }>;
+ containerTypeIds?: string[];
+ }): Promise[] | null> {
+ if (cargo.freightType === 'CONTAINER') {
+ const typeIds = [...new Set((cargo.containerTypeIds ?? []).filter(Boolean))];
+ if (typeIds.length) {
+ const rows: { container_type_id: string; wagon_type_id: string | null }[] =
+ await this.dataSource.query(
+ `SELECT ct.id AS container_type_id, wt.id AS wagon_type_id
+ FROM freight.container_types ct
+ LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
+ LEFT JOIN freight.wagon_types wt
+ ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
+ WHERE ct.id = ANY($1::uuid[]) AND ct.deleted_at IS NULL`,
+ [typeIds],
+ );
+ const byType = new Map>(typeIds.map((id) => [id, new Set()]));
+ for (const row of rows) {
+ if (row.wagon_type_id) byType.get(row.container_type_id)?.add(row.wagon_type_id);
+ }
+ return [...byType.values()];
+ }
+ // Legacy callers only know sizes ("20ft"/"40ft"): a size is carriable when
+ // ANY active container type of that size has a matching wagon type.
+ const sizes = [
+ ...new Set(
+ (cargo.containers ?? [])
+ .map((line) => parseInt(String(line.containerSize), 10))
+ .filter((n) => Number.isFinite(n) && n > 0),
+ ),
+ ];
+ if (!sizes.length) return null;
+ const rows: { size_ft: number; wagon_type_id: string | null }[] =
+ await this.dataSource.query(
+ `SELECT ct.size_ft, wt.id AS wagon_type_id
+ FROM freight.container_types ct
+ LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
+ LEFT JOIN freight.wagon_types wt
+ ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
+ WHERE ct.size_ft = ANY($1::int[]) AND ct.deleted_at IS NULL
+ AND (ct.is_active IS DISTINCT FROM false)`,
+ [sizes],
+ );
+ const bySize = new Map>(sizes.map((s) => [s, new Set()]));
+ for (const row of rows) {
+ if (row.wagon_type_id) bySize.get(Number(row.size_ft))?.add(row.wagon_type_id);
+ }
+ return [...bySize.values()];
+ }
+
+ if (!cargo.cargoTypeId && !cargo.cargoTypeCode) return null;
+ const rows: { wagon_type_id: string | null }[] = await this.dataSource.query(
+ `SELECT wt.id AS wagon_type_id
+ FROM freight.cargo_types c
+ LEFT JOIN freight.cargo_type_wagon_types ctwt ON ctwt.cargo_type_id = c.id
+ LEFT JOIN freight.wagon_types wt
+ ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
+ WHERE c.deleted_at IS NULL
+ AND (($1::uuid IS NOT NULL AND c.id = $1::uuid) OR ($1::uuid IS NULL AND c.code = $2))`,
+ [cargo.cargoTypeId ?? null, cargo.cargoTypeCode ?? null],
+ );
+ const set = new Set();
+ for (const row of rows) if (row.wagon_type_id) set.add(row.wagon_type_id);
+ return [set];
+ }
+
+ /**
+ * Wagon-type ids each schedule's stock can offer: the built train's own
+ * consist for train-bound schedules, the origin yard's loose usable pool
+ * otherwise. Batched — two queries for the whole schedule list.
+ */
+ private async scheduleWagonTypeStock(
+ schedules: TrainSchedule[],
+ ): Promise
+ ) : noWagonForSelectedDay ? (
+
+
+
+ No wagon available for the selected day
+
+
+ No train departing that day has a wagon type that can carry
+ your cargo. Go back to the route step and pick one of the
+ available days.
+
+
+
) : (
Ready to submit. You'll review the unit rates before final
@@ -567,7 +583,7 @@ export function Step8Review({
leftSection={}
onClick={onSubmit}
loading={submitPending}
- disabled={submitPending || hasOdd20ft}
+ disabled={submitPending || hasOdd20ft || noWagonForSelectedDay}
>
Submit
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index 6f4527c0b..a82945ecf 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -423,6 +423,12 @@ export const api = {
bookingsService.getAvailableDaysForCargo(input),
),
+ getAvailableDaysForBooking: endpoint<{ bookingId: string }, string[]>(
+ "train-scheduling",
+ "availableDaysForBooking",
+ ({ bookingId }) => bookingsService.getAvailableDaysForBooking(bookingId),
+ ),
+
getMyBookingWindows: endpoint(
"train-scheduling",
"myBookingWindows",
diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts
index 88113c451..910ba89a0 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -414,25 +414,38 @@ export const bookingsService = {
return (data.data as Freight.AvailableDaysResponse).days;
},
- // Cargo-aware day pool: only days where a train has remaining capacity AND
- // enough matching-type wagons for this cargo. `containers` is serialized as a
- // JSON string param (the server parses it).
+ // Cargo-aware day pool: only days where a train has remaining capacity AND a
+ // wagon TYPE that can carry this cargo. `containers`/`containerTypeIds` are
+ // serialized as JSON string params (the server parses them). Days only — no
+ // capacity counts are ever returned.
getAvailableDaysForCargo: async (
query: Freight.AvailableDaysForCargoQuery,
): Promise => {
- const { containers, ...rest } = query;
+ const { containers, containerTypeIds, ...rest } = query;
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
{
params: {
...rest,
...(containers ? { containers: JSON.stringify(containers) } : {}),
+ ...(containerTypeIds?.length
+ ? { containerTypeIds: JSON.stringify(containerTypeIds) }
+ : {}),
},
},
);
return (data.data as Freight.AvailableDaysResponse).days;
},
+ // Days bookable for an EXISTING booking (operation-request step): the server
+ // derives the cargo from the booking and applies the wagon-type gate.
+ getAvailableDaysForBooking: async (bookingId: string): Promise => {
+ const { data } = await client.get(
+ `/api/bookings/${bookingId}/available-days`,
+ );
+ return (data.data as Freight.AvailableDaysResponse).days;
+ },
+
/**
* Upcoming/open booking windows on the signed-in customer's active-contract
* lanes (import booking-day windows + export 24h pre-departure windows).
diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts
index 7d3962932..a0c51645f 100644
--- a/packages/types/src/freight/contracts.ts
+++ b/packages/types/src/freight/contracts.ts
@@ -182,6 +182,42 @@ export interface IContractSignature {
signedAt: string;
}
+// ── Per-contract document snapshot (staff-editable articles for one contract) ─
+
+export interface IContractDocumentArticle {
+ id: string;
+ title: string;
+ body: string;
+ order: number;
+}
+
+/**
+ * A per-contract frozen copy of the resolved document template. Staff may edit
+ * its articles for a single contract in the accept/edit dialog — this never
+ * writes back to the shared six contract templates. Null → the PDF renders from
+ * the live template.
+ */
+export interface IContractDocumentSnapshot {
+ code?: string | null;
+ name?: string | null;
+ documentTitle?: string | null;
+ whereasClauses: string[];
+ articles: IContractDocumentArticle[];
+}
+
+/** Editable document draft returned for the accept/edit editor. */
+export interface IContractDocumentDraft {
+ documentTitle: string | null;
+ whereasClauses: string[];
+ articles: IContractDocumentArticle[];
+ code: string | null;
+ name: string | null;
+ /** True once the document may no longer be edited/regenerated. */
+ locked: boolean;
+ generatedAt: string | null;
+ status: ContractStatus;
+}
+
export type ContractApprovalStepStatus =
| "PENDING"
| "APPROVED"
@@ -584,6 +620,8 @@ export interface IContract extends BaseEntity {
contractType?: string | null;
contractTemplateKey?: string | null;
contractGeneratedAt?: string | null;
+ /** Per-contract frozen document (articles + WHEREAS) captured at staff accept. */
+ documentSnapshot?: IContractDocumentSnapshot | null;
contractSummary?: string | null;
versionNumber: number;
financialTerms?: string | null;
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index bd6c8ffeb..9ed8b1a39 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -231,7 +231,8 @@ export enum WagonStatus {
ImportReady = "IMPORT_READY",
ExportReady = "EXPORT_READY",
Maintenance = "MAINTENANCE",
- Retired = "RETIRED",
+ /** Formerly RETIRED — wagons pulled from circulation. */
+ Detained = "DETAINED",
}
export enum WagonReadiness {
@@ -359,6 +360,8 @@ export interface IWagonTransferRequest extends BaseEntity {
requestedByUserId?: string | null;
fulfilledByUserId?: string | null;
fulfilledAt?: string | null;
+ /** Why the wagons are needed — required for new requests, shown on the OCC queue. */
+ reason?: string | null;
note?: string | null;
}
@@ -923,10 +926,14 @@ export interface AvailableDaysForCargoQuery {
freightType: "CONTAINER" | "BULK";
/** Bulk cargo type code (e.g. "COFFEE"); ignored for container freight. */
cargoTypeCode?: string;
+ /** Bulk cargo type id — preferred over code for the wagon-type gate. */
+ cargoTypeId?: string;
/** Total bulk weight in tons. */
totalWeightTons?: number;
/** Container lines (size + quantity) for container freight. */
containers?: { containerSize: string; quantity: number }[];
+ /** Container type ids — enables the exact wagon-type compatibility gate. */
+ containerTypeIds?: string[];
}
/**