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>> { + const builtTrainIds = [ + ...new Set( + schedules + .map((s) => s.trainSet?.trainId) + .filter((id): id is string => Boolean(id)), + ), + ]; + const looseOriginYardIds = [ + ...new Set( + schedules + .filter((s) => !s.trainSet?.trainId) + .map((s) => s.originStationId) + .filter(Boolean), + ), + ]; + + const [trainRows, yardRows] = await Promise.all([ + builtTrainIds.length + ? (this.dataSource.query( + `SELECT train_id, wagon_type_id + FROM freight.wagons + WHERE train_id = ANY($1::uuid[]) AND deleted_at IS NULL + GROUP BY train_id, wagon_type_id`, + [builtTrainIds], + ) as Promise<{ train_id: string; wagon_type_id: string }[]>) + : Promise.resolve([] as { train_id: string; wagon_type_id: string }[]), + looseOriginYardIds.length + ? (this.dataSource.query( + `SELECT current_yard_id, wagon_type_id + FROM freight.wagons + WHERE train_id IS NULL AND deleted_at IS NULL + AND status IN ('AVAILABLE', 'ASSIGNED') + AND current_yard_id = ANY($1::uuid[]) + GROUP BY current_yard_id, wagon_type_id`, + [looseOriginYardIds], + ) as Promise<{ current_yard_id: string; wagon_type_id: string }[]>) + : Promise.resolve([] as { current_yard_id: string; wagon_type_id: string }[]), + ]); + + const byTrain = new Map>(); + for (const row of trainRows) { + const set = byTrain.get(row.train_id) ?? new Set(); + set.add(row.wagon_type_id); + byTrain.set(row.train_id, set); + } + const byYard = new Map>(); + for (const row of yardRows) { + const set = byYard.get(row.current_yard_id) ?? new Set(); + set.add(row.wagon_type_id); + byYard.set(row.current_yard_id, set); + } + + const result = new Map>(); + for (const s of schedules) { + const trainId = s.trainSet?.trainId; + result.set( + s.id, + trainId + ? byTrain.get(trainId) ?? new Set() + : byYard.get(s.originStationId) ?? new Set(), + ); + } + return result; + } + + /** + * Booking-time gate for a chosen day: does the route have an OPEN departure + * that day at all, and can any of that day's departures physically carry the + * cargo (wagon-TYPE only — quantity never blocks, oversized bookings get a + * partial split offer instead). + */ + async checkDayCargoCompatibility( + originYardId: string, + destinationYardId: string, + day: string, + cargo: { + freightType: 'CONTAINER' | 'BULK'; + cargoTypeId?: string | null; + containerTypeIds?: string[]; + }, + ): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> { + const schedules = await this.getBookableScheduleEntities( + originYardId, + destinationYardId, + ); + const onDay = schedules.filter( + (s) => + s.scheduledDepartureDate && eatDay(new Date(s.scheduledDepartureDate)) === day, + ); + if (!onDay.length) return { hasDeparture: false, hasCompatible: false }; + const compatible = await this.filterCargoCompatibleSchedules(onDay, cargo); + return { hasDeparture: true, hasCompatible: compatible.length > 0 }; + } + /** * Ordered stop yards of a schedule's route: origin → milestones → destination, * de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule @@ -5553,6 +5893,7 @@ export class TrainSchedulingService { status: schedule.status, freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, + maxWagons: schedule.maxWagons ?? null, direction: schedule.direction ?? null, requiresLoadingConfirmation, loadingConfirmed, diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index c875433fe..3b5ad16cd 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -399,7 +399,7 @@ export class TrainBuilderService { if (!wagon || wagon.trainId !== train.id) { throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); } - if (wagon.currentTrainScheduleId) { + if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { throw new ConflictException( `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, ); @@ -426,7 +426,7 @@ export class TrainBuilderService { if (!wagon || wagon.trainId !== train.id) { throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); } - if (wagon.currentTrainScheduleId) { + if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { throw new ConflictException( `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, ); @@ -441,6 +441,29 @@ export class TrainBuilderService { return this.getComposition(id); } + /** + * Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot), + * not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/ + * DISPATCHED) schedule has it pinned to one of its slots. + */ + private async isWagonPinnedToLiveSchedule( + manager: EntityManager, + wagonId: string, + ): Promise { + const rows: { exists: boolean }[] = await manager.query( + `SELECT TRUE AS exists + FROM freight.train_set_wagons tsw + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + WHERE tsw.physical_wagon_id = $1 + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + AND ts.deleted_at IS NULL + AND tsw.deleted_at IS NULL + LIMIT 1`, + [wagonId], + ); + return rows.length > 0; + } + /** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */ async reorderWagons(id: string, dto: ReorderTrainWagonsDto) { await this.dataSource.transaction(async (manager) => { diff --git a/apps/edr-freight-api/src/modules/wagons/dto/bulk-fulfill-transfer-requests.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/bulk-fulfill-transfer-requests.dto.ts new file mode 100644 index 000000000..28d0ec418 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/bulk-fulfill-transfer-requests.dto.ts @@ -0,0 +1,13 @@ +import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator'; + +/** + * OCC bulk accept-and-execute: the subset of PENDING request ids to execute + * now. Requests not listed (or that cannot be executed) stay PENDING. + */ +export class BulkFulfillTransferRequestsDto { + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(200) + @IsUUID('all', { each: true }) + requestIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts index 4dd9f0f75..e747b69f2 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts @@ -1,10 +1,20 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, +} from 'class-validator'; /** * A count-only wagon-transfer request. The requester picks source yard, wagon * type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks - * those at fulfilment. + * those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that + * type currently in the source yard, and a reason is mandatory. */ export class CreateTransferRequestDto { @IsUUID() @@ -21,6 +31,12 @@ export class CreateTransferRequestDto { @Max(1000) quantity!: number; + @ApiProperty({ description: 'Why the wagons are needed — shown on the OCC queue' }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + reason!: string; + @ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts index 40005de26..c81b6c365 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts @@ -59,4 +59,11 @@ export class WagonTransferRequest extends BaseEntity { @Column({ name: 'note', type: 'text', nullable: true }) note?: string | null; + + /** + * Why the wagons are needed — required for every new request and shown on + * the OCC queue. Nullable only for rows that predate the requirement. + */ + @Column({ name: 'reason', type: 'text', nullable: true }) + reason?: string | null; } diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index 9f2d41416..b66fc3f9d 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -15,7 +15,7 @@ export const WAGON_STATUSES = [ WagonStatus.ImportReady, WagonStatus.ExportReady, WagonStatus.Maintenance, - WagonStatus.Retired, + WagonStatus.Detained, ] as const; export type WagonStatusType = (typeof WAGON_STATUSES)[number]; diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts index 12fdaf27c..d6925b71b 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts @@ -19,6 +19,7 @@ import { WagonTransferHistoryAll, WagonTransferRequest, } from '../../common/booking-guards'; +import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; @@ -51,6 +52,22 @@ export class WagonTransferRequestsController { return this.service.listRequests(status); } + // NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')` + // — Express matches in declaration order, so they would otherwise be captured + // by the `:id` param route (and rejected by ParseUUIDPipe). + @Post('bulk-fulfill') + @WagonTransferFulfill() + @ApiOperation({ + summary: + 'OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)', + }) + bulkFulfill( + @Body() dto: BulkFulfillTransferRequestsDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.bulkFulfill(dto.requestIds, user?.id); + } + // NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express // matches in declaration order, so `/history` would otherwise be captured by // the `:id` param route (and rejected by ParseUUIDPipe). diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index bf69d767d..068d4dc6d 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -1,4 +1,4 @@ -import { WagonTransferRequestStatus } from '@edr/types'; +import { WagonStatus, WagonTransferRequestStatus } from '@edr/types'; import { BadRequestException, ConflictException, @@ -48,7 +48,12 @@ export class WagonTransferRequestsService { private readonly wagonsService: WagonsService, ) {} - /** Record a PENDING request. Count-only — no wagons are picked here. */ + /** + * Record a PENDING request. Count-only — no wagons are picked here, but the + * count is capped at the AVAILABLE wagons of that type currently sitting in + * the source yard: staff may only ask for wagons that are actually there to + * give. A reason is mandatory and is shown on the OCC queue. + */ async createRequest( dto: CreateTransferRequestDto, userId?: string | null, @@ -58,6 +63,14 @@ export class WagonTransferRequestsService { 'Source and destination yard must be different', ); } + const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId); + if (available < dto.quantity) { + throw new BadRequestException( + available === 0 + ? 'No available wagons of this type in the source yard' + : `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`, + ); + } const request = this.requestRepo.create({ fromYardId: dto.fromYardId, toYardId: dto.toYardId, @@ -65,12 +78,24 @@ export class WagonTransferRequestsService { quantity: dto.quantity, status: WagonTransferRequestStatus.Pending, requestedByUserId: userId ?? null, + reason: dto.reason, note: dto.note ?? null, }); const saved = await this.requestRepo.save(request); return this.findById(saved.id); } + /** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */ + private countAvailable(yardId: string, wagonTypeId: string): Promise { + return this.wagonRepo.count({ + where: { + currentYardId: yardId, + wagonTypeId, + status: WagonStatus.Available, + }, + }); + } + /** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */ async listRequests( status?: WagonTransferRequestStatus, @@ -136,6 +161,14 @@ export class WagonTransferRequestsService { .join(', ')}`, ); } + const notAvailable = wagons.filter((w) => w.status !== WagonStatus.Available); + if (notAvailable.length) { + throw new BadRequestException( + `These wagons are not available: ${notAvailable + .map((w) => w.wagonNumber) + .join(', ')}`, + ); + } // Reuse the audited bulk-transfer path (writes wagon_movements ledger rows, // each stamped with this request's id so history can link them back). @@ -152,6 +185,71 @@ export class WagonTransferRequestsService { return this.findById(id); } + /** + * OCC accepts AND executes a subset of pending requests in one action. For + * each selected request the system auto-picks the required number of + * AVAILABLE wagons of the requested type from the source yard (lowest wagon + * number first) and runs the audited transfer. A request that cannot be + * executed — already decided, or not enough available wagons left after the + * ones processed before it — is SKIPPED and simply stays PENDING, visible to + * both teams; nothing is rolled back for the others. + */ + async bulkFulfill( + requestIds: string[], + userId?: string | null, + ): Promise<{ + fulfilled: WagonTransferRequest[]; + skipped: Array<{ id: string; reason: string }>; + }> { + const fulfilled: WagonTransferRequest[] = []; + const skipped: Array<{ id: string; reason: string }> = []; + + // Sequential on purpose: each executed transfer moves wagons out of the + // source yard, and the next request's auto-pick must see that new state. + for (const id of [...new Set(requestIds)]) { + const request = await this.requestRepo.findOne({ where: { id } }); + if (!request) { + skipped.push({ id, reason: 'Request not found' }); + continue; + } + if (request.status !== WagonTransferRequestStatus.Pending) { + skipped.push({ + id, + reason: `Already ${request.status.toLowerCase()}`, + }); + continue; + } + const wagons = await this.wagonRepo.find({ + where: { + currentYardId: request.fromYardId, + wagonTypeId: request.wagonTypeId, + status: WagonStatus.Available, + }, + order: { wagonNumber: 'ASC' }, + take: request.quantity, + }); + if (wagons.length < request.quantity) { + skipped.push({ + id, + reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`, + }); + continue; + } + await this.wagonsService.bulkTransfer( + { wagonIds: wagons.map((w) => w.id), toYardId: request.toYardId }, + userId, + { transferRequestId: request.id }, + ); + request.status = WagonTransferRequestStatus.Fulfilled; + request.fulfilledByUserId = userId ?? null; + request.fulfilledAt = new Date(); + await this.requestRepo.save(request); + fulfilled.push(await this.findById(id)); + } + + return { fulfilled, skipped }; + } + /** * Per-user transfer history: the requests a user filed OR fulfilled, plus the * individual wagons they physically moved (linked back to their request when diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index 41ca7facb..fd967cc8c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -144,7 +144,7 @@ export class SchedulingReadFacade { `SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId" FROM freight.wagons WHERE deleted_at IS NULL - AND UPPER(status) NOT IN ('RETIRED', 'MAINTENANCE') + AND UPPER(status) NOT IN ('DETAINED', 'MAINTENANCE') ORDER BY wagon_number ASC`, ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index 0e9ab450e..a7187638e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -1,19 +1,13 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { - Anchor, - Button, - Modal, - Select, - Stack, - Text, - Textarea, -} from "@mantine/core"; +import { Button, Modal, Stack, Text, Textarea } from "@mantine/core"; import { Check, + FilePen, FileSignature, MessageSquareWarning, + RefreshCw, ShieldCheck, Sparkles, XCircle, @@ -23,6 +17,7 @@ import type { Freight } from "@edr/types"; import { api } from "@/services/api"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { ContractDocumentEditorModal } from "@/components/contracts/ContractDocumentEditorModal"; import type { useContractMutations } from "@/hooks/contracts/useContracts"; /** Dropdown-settings code holding the admin-configured contract validity days. */ @@ -54,8 +49,8 @@ export function ContractActionsToolbar({ const navigate = useNavigate(); const { status } = contract; - const [acceptOpen, setAcceptOpen] = useState(false); - const [validityDays, setValidityDays] = useState(null); + const [editorOpen, setEditorOpen] = useState(false); + const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept"); const [changesOpen, setChangesOpen] = useState(false); const [changesNote, setChangesNote] = useState(""); const [rejectOpen, setRejectOpen] = useState(false); @@ -76,12 +71,6 @@ export function ContractActionsToolbar({ .map((o) => ({ value: String(o.value), label: o.label })), [validitySetting], ); - // Default the selection to the first configured option when the dialog opens. - useEffect(() => { - if (acceptOpen && !validityDays && validityOptions.length > 0) { - setValidityDays(validityOptions[0].value); - } - }, [acceptOpen, validityDays, validityOptions]); if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) { return null; @@ -98,10 +87,16 @@ export function ContractActionsToolbar({ } const canAccept = status === "SUBMITTED"; - // Generation only becomes available once EVERY approval step is complete and - // the contract reaches APPROVED. While any step is still pending the contract - // stays in PENDING_APPROVAL, so this button does not appear after only the - // first (line-staff) approval — the director step must land first. + // While the contract is PENDING_APPROVAL and NO approver has acted yet, staff + // can edit this contract's articles and (re)generate its PDF. The first + // approval action locks the document. + const docLocked = + status !== "PENDING_APPROVAL" || + (contract.approvalSteps ?? []).some((s) => s.status !== "PENDING"); + const canEditGenerate = status === "PENDING_APPROVAL" && !docLocked; + const documentGenerated = Boolean(contract.contractGeneratedAt); + // Legacy fallback: if a contract ever lands on APPROVED without a document + // (older flow), still offer a manual generate that moves it to CONTRACT_READY. const needsManualGenerate = status === "APPROVED" && !contract.contractGeneratedAt; // Signing now happens on the contract VIEW page (staff must open and read the @@ -131,7 +126,10 @@ export function ContractActionsToolbar({ fullWidth color="edr-green" leftSection={} - onClick={() => setAcceptOpen(true)} + onClick={() => { + setEditorMode("accept"); + setEditorOpen(true); + }} > Accept for approval @@ -156,6 +154,43 @@ export function ContractActionsToolbar({ )} + {canEditGenerate && ( + <> + + {documentGenerated + ? "Document generated. Approvers can now review it. You can still edit and regenerate until the first approval." + : "Review the contract document, edit its articles if needed, then generate it so approvers can review."} + + + + + )} + {needsManualGenerate && (