From 603537a20b65cd45e72c5c88585a7a1c6d519dcc Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 21 Jul 2026 08:50:50 +0000 Subject: [PATCH 1/2] add yard distances management to rule engine - Introduced new yard distances resource with CRUD operations. - Created migration for yard distances table with necessary constraints. - Implemented service and repository for yard distances handling. - Added controller for API endpoints to manage yard distances. - Updated rule engine configuration to include yard distances. - Enhanced rule engine resource page to support yard distance selection. - Updated contracts and train builder pages to handle new yard distance logic. - Added error handling utility for better error message extraction. --- .../2060000000000-CreateYardDistances.ts | 66 +++++++++ .../contracts/contract-notifier.service.ts | 13 ++ .../contracts/contract-transition.service.ts | 93 ++++++++++++- .../modules/contracts/contracts.controller.ts | 6 +- .../modules/contracts/contracts.repository.ts | 19 +++ .../modules/contracts/dto/approve-step.dto.ts | 18 ++- .../locomotives/dto/filter-locomotives.dto.ts | 28 +++- .../locomotives/locomotives.repository.ts | 44 +++++- .../locomotives/locomotives.service.ts | 11 ++ .../modules/routes/dto/create-route.dto.ts | 13 +- .../src/modules/routes/routes.service.ts | 79 +++++++---- .../controllers/yard-distances.controller.ts | 62 +++++++++ .../dto/create-yard-distance.dto.ts | 22 +++ .../dto/list-rule-engine-query.dto.ts | 12 ++ .../dto/update-yard-distance.dto.ts | 5 + .../entities/yard-distance.entity.ts | 35 +++++ .../yard-distances.repository.interface.ts | 17 +++ .../repositories/yard-distances.repository.ts | 87 ++++++++++++ .../modules/rule-engine/rule-engine.module.ts | 12 ++ .../services/yard-distances.service.ts | 119 +++++++++++++++++ .../modules/trains/train-builder.service.ts | 24 +++- .../src/seed/freight-permissions.registry.ts | 2 + .../backoffice/src/auth/http.ts | 15 ++- .../contracts/ContractApprovalStepsCard.tsx | 95 ++++++++++--- .../trainBuilder/BuildTrainModal.tsx | 12 +- .../trainBuilder/ChangeLocomotivesModal.tsx | 12 +- .../components/trainBuilder/trainStatus.ts | 28 ++++ .../backoffice/src/constants/URLS.ts | 3 + .../src/hooks/bookings/useBookings.ts | 30 ++--- .../src/hooks/contracts/useContracts.ts | 59 +++++--- .../src/hooks/rule-engine/useRuleEngine.ts | 16 ++- .../backoffice/src/lib/queryClient.ts | 18 +++ .../backoffice/src/pages/fleet/RoutesPage.tsx | 126 ++++++++++++------ .../ruleEngine/RuleEngineResourcePage.tsx | 22 ++- .../src/pages/ruleEngine/config/resources.ts | 29 ++++ .../trainBuilder/TrainBuilderDetailPage.tsx | 60 +++++++++ .../src/services/contracts.service.ts | 13 +- .../src/services/locomotives.service.ts | 6 + .../backoffice/src/services/routes.service.ts | 3 +- .../services/ruleEngine/ruleEngine.service.ts | 3 + .../backoffice/src/types/rule-engine/index.ts | 1 + .../backoffice/src/utils/errorExtractor.ts | 17 +++ .../src/pages/contracts/NewContractPage.tsx | 23 ++-- .../new-contract-form/step2-service-type.tsx | 22 +-- .../new-contract-form/step8-review.tsx | 102 +++++++------- 45 files changed, 1275 insertions(+), 227 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/utils/errorExtractor.ts diff --git a/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts b/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts new file mode 100644 index 000000000..42352237d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Configured rail distance between two yards (Configuration → Yard Distances). + * Route creation resolves each segment's km from here (symmetric lookup: + * one A↔B row serves both directions) instead of accepting free-text km, + * and snapshots the value onto route_milestones.distance_km. + * + * Uniqueness is a partial index (deleted_at IS NULL) so a soft-deleted pair + * can be re-created. + */ +export class CreateYardDistances2060000000000 implements MigrationInterface { + name = 'CreateYardDistances2060000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_distances ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + from_yard_id uuid NOT NULL REFERENCES freight.yards(id), + to_yard_id uuid NOT NULL REFERENCES freight.yards(id), + distance_km numeric(10,2) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_yard_distances_from_yard + ON freight.yard_distances (from_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_yard_distances_to_yard + ON freight.yard_distances (to_yard_id); + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_yard_distances_pair + ON freight.yard_distances (from_yard_id, to_yard_id) + WHERE deleted_at IS NULL; + `); + // Backfill from segments already stored on existing routes so editing them + // does not immediately fail the "pair not configured" check. One row per + // unordered pair; where routes disagree the longest segment wins. + await queryRunner.query(` + INSERT INTO freight.yard_distances (from_yard_id, to_yard_id, distance_km) + SELECT DISTINCT ON (LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id)) + prev_yard_id, yard_id, distance_km + FROM ( + SELECT + yard_id, + distance_km, + LAG(yard_id) OVER (PARTITION BY route_id ORDER BY sequence_no) AS prev_yard_id + FROM freight.route_milestones + WHERE deleted_at IS NULL + ) segments + WHERE prev_yard_id IS NOT NULL + AND distance_km IS NOT NULL + AND distance_km > 0 + ORDER BY LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id), distance_km DESC + ON CONFLICT DO NOTHING; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_distances;`); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index e35bd2bf5..ac4fe2a0f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -143,6 +143,19 @@ export class ContractNotifierService { this.inApp(c, 'Contract rejected', msg); } + /** + * A later approver sent the contract back to an earlier stage of the chain. + * Staff-only: the customer is not involved in an internal send-back — their + * contract simply stays "under approval". + */ + sentBackToStep(c: Contract, targetRole: string, reason: string): void { + this.inAppStaff( + c, + 'Contract returned in approval chain', + `Contract ${c.reference} was sent back to the ${targetRole} step. Reason: ${reason}`, + ); + } + /** Staff requested changes before approval. */ changesRequested(c: Contract, note: string): void { const msg = 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 2fd1e4cb4..107f2ab33 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 @@ -41,6 +41,7 @@ import { ContractDocumentSnapshotInput, } from './entities/contract.entity'; import { ContractSignerRole } from './entities/contract-signature.entity'; +import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { SignContractDto } from './dto/sign-contract.dto'; /** The editable contract-document draft returned for the accept/edit dialog. */ @@ -550,17 +551,24 @@ export class ContractTransitionService { /** * Reject one approval step (line staff / director / CEO). The rejecting - * approver must supply a reason. A rejection is terminal: the whole contract - * moves to REJECTED and the customer must create a new one — there is no - * resubmit of the same contract. The reason is recorded both on the step and - * as a REJECTION review note so it is visible to the customer and the rest of - * the approval chain. + * approver must supply a reason, and picks where the rejection lands: + * + * - **To the customer** (`returnToStepId` omitted — the only option for the + * first approver): terminal. The whole contract moves to REJECTED with a + * REJECTION review note visible to the customer, who must resubmit. + * - **To an earlier approver** (`returnToStepId` = an already-APPROVED + * earlier step): internal send-back. That step and everything after it + * reset to PENDING and the chain re-runs from there; the contract stays + * PENDING_APPROVAL and the customer never sees it. E.g. the director can + * return a contract to line staff, who fix it and approve again, after + * which every later stage re-approves in order. */ async rejectStep( contractId: string, stepId: string, actorId: string, reason: string, + returnToStepId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); @@ -568,6 +576,20 @@ export class ContractTransitionService { const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); if (!step) throw new BadRequestException('Approval step not found'); + // Only the approver whose turn it is may reject — same ordering rule as + // approveStep. Without this, an already-actioned or future step could be + // "rejected" and wipe chain state it never owned. + const next = await this.contractsRepository.findNextPendingApprovalStep(contractId); + if (!next || next.id !== step.id) { + throw new BadRequestException( + 'Only the current pending approval step can be rejected', + ); + } + + if (returnToStepId) { + return this.sendBackToStep(contract, step, actorId, reason, returnToStepId); + } + await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason); await this.contractsRepository.createReviewNote( @@ -590,6 +612,67 @@ export class ContractTransitionService { return updated; } + /** + * Internal send-back branch of rejectStep: return the contract to an earlier, + * already-approved stage of the chain instead of rejecting it outright. + * Deliberately NOT the terminal path: no clearance-fee expiry (the contract + * is still alive) and no customer-facing REJECTION note — the trail is a + * staff note plus a backoffice inbox ping. + */ + private async sendBackToStep( + contract: Contract, + rejectingStep: ContractApprovalStep, + actorId: string, + reason: string, + returnToStepId: string, + ): Promise { + const target = await this.contractsRepository.findApprovalStepById( + contract.id, + returnToStepId, + ); + if (!target) throw new BadRequestException('Return-to approval step not found'); + if (target.stepOrder >= rejectingStep.stepOrder) { + throw new BadRequestException( + 'A rejection can only be returned to an EARLIER step in the chain — to reject to the customer, omit returnToStepId', + ); + } + if (target.status !== 'APPROVED') { + throw new BadRequestException( + `Return-to step ${target.requiredRole} has not approved yet (status ${target.status})`, + ); + } + + // Staff-visible trail. Written before the reset so the reason survives the + // wipe of per-step notes. + await this.contractsRepository.createReviewNote( + contract.id, + `Returned to ${target.requiredRole} (step ${target.stepOrder}) by ${rejectingStep.requiredRole}: ${reason}`, + 'STAFF_NOTE', + actorId, + 'STAFF', + ); + + // Chain re-runs from the target stage: it and every later step (including + // the rejecting one) go back to PENDING. Legacy approved-by columns are + // left stale on purpose — approval steps are the source of truth and the + // columns get re-stamped on re-approval. + await this.contractsRepository.resetApprovalStepsFrom( + contract.id, + target.stepOrder, + ); + + // A send-back can only happen mid-chain, so the contract must remain (or + // return to) PENDING_APPROVAL — relevant when rejecting from + // APPROVED_PENDING_SIGNATURE. + await this.contractsRepository.update(contract.id, { + status: 'PENDING_APPROVAL', + } as never); + + const updated = await this.contractsService.findById(contract.id); + this.notifier.sentBackToStep(updated, target.requiredRole, reason); + return updated; + } + /** Approve one approval step in sequence; → APPROVED when all complete. */ async approveStep( contractId: string, 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 ca4814081..8b7a62536 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -441,7 +441,10 @@ export class ContractsController { FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.approveCeo, ]) - @ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' }) + @ApiOperation({ + summary: + 'Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)', + }) rejectStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, @@ -453,6 +456,7 @@ export class ContractsController { stepId, resolveAuthUserId(user), dto.reason, + dto.returnToStepId, ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 533b51b3b..e4d5810ea 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -368,6 +368,25 @@ export class ContractsRepository extends BaseRepository { }); } + /** + * Send-back reset: every step at or after `fromStepOrder` returns to PENDING + * with its actor/verdict cleared, so the chain re-runs from that stage. The + * send-back reason lives in the review-note trail, not on the wiped steps. + */ + async resetApprovalStepsFrom( + contractId: string, + fromStepOrder: number, + ): Promise { + await this.dataSource + .getRepository(ContractApprovalStep) + .createQueryBuilder() + .update() + .set({ status: 'PENDING', actedByStaffId: null, actedAt: null, note: null }) + .where('contract_id = :contractId', { contractId }) + .andWhere('step_order >= :fromStepOrder', { fromStepOrder }) + .execute(); + } + /** Check if all approval steps are approved. */ async allApprovalStepsComplete(contractId: string): Promise { const pending = await this.dataSource.getRepository(ContractApprovalStep).count({ diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts index 173857159..9a86a5b4e 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, MinLength } from 'class-validator'; +import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; export class ApproveStepDto { @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' }) @@ -26,6 +26,22 @@ export class RejectStepDto { @IsString() @MinLength(1) reason!: string; + + /** + * Where the rejection lands. Omitted → the customer: the contract goes to + * REJECTED and the customer must resubmit (unchanged legacy behaviour, and + * the only option for the first approver in the chain). Set to an EARLIER + * approved step's id → send-back: that step and everything after it reset to + * PENDING and the chain re-runs from there; the contract never leaves + * PENDING_APPROVAL and the customer is not involved. + */ + @ApiPropertyOptional({ + description: + 'Id of an earlier approval step to send the contract back to. Omit to reject to the customer.', + }) + @IsOptional() + @IsUUID() + returnToStepId?: string; } export class CancelContractDto { diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts index c634d5efb..a42dcd220 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -1,5 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsUUID } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator'; import { LOCOMOTIVE_STATUSES, @@ -21,4 +22,29 @@ export class FilterLocomotivesDto { @IsOptional() @IsUUID() currentYardId?: string; + + /** + * Drop locomotives already coupled to a built train — the train-builder + * "change locomotives" picker uses this so a loco that belongs to another + * train is never offered (the backend would 409 on save anyway). Combine with + * `excludeTrainId` to keep the CURRENT train's own locos in the list. + */ + @ApiPropertyOptional({ + description: 'Exclude locomotives already coupled to any built train', + }) + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + excludeCoupled?: boolean; + + /** + * When `excludeCoupled` is set, locos coupled to THIS train are still kept + * (they are valid picks — you are editing that train's consist). + */ + @ApiPropertyOptional({ + description: 'Train id whose own coupled locomotives are NOT excluded', + }) + @IsOptional() + @IsUUID() + excludeTrainId?: string; } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts index 18a42205e..3ad5b3650 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Locomotive } from './entities/locomotive.entity'; +import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity'; +import { TrainLocomotive } from '../trains/entities/train-locomotive.entity'; @Injectable() export class LocomotivesRepository extends BaseRepository { @@ -14,6 +15,47 @@ export class LocomotivesRepository extends BaseRepository { super(repository); } + /** + * List locomotives for the train-builder coupling picker: the usual + * status/type/yard filters, plus optional exclusion of any loco already + * coupled to a built train. `keepTrainId` spares that one train's own locos + * from the exclusion so they stay selectable while editing its consist. + */ + findForCoupling(opts: { + status?: LocomotiveStatus; + locomotiveType?: LocomotiveType; + currentYardId?: string; + excludeCoupled?: boolean; + keepTrainId?: string; + }): Promise { + const qb = this.repository + .createQueryBuilder('locomotive') + .leftJoinAndSelect('locomotive.currentYard', 'currentYard') + .orderBy('locomotive.code', 'ASC'); + + if (opts.status) qb.andWhere('locomotive.status = :status', { status: opts.status }); + if (opts.locomotiveType) + qb.andWhere('locomotive.locomotiveType = :type', { type: opts.locomotiveType }); + if (opts.currentYardId) + qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId }); + + if (opts.excludeCoupled) { + // NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the + // consist being edited still lists its current locomotives. + const sub = this.repository.manager + .getRepository(TrainLocomotive) + .createQueryBuilder('tl') + .select('1') + .where('tl.locomotiveId = locomotive.id'); + if (opts.keepTrainId) { + sub.andWhere('tl.trainId != :keepTrainId', { keepTrainId: opts.keepTrainId }); + } + qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters()); + } + + return qb.getMany(); + } + /** * A live locomotive already holding this name, compared the same way the * `UQ_locomotives_name_active` index compares: case- and whitespace- diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index cbf9dfc0c..46e0ae415 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -29,6 +29,17 @@ export class LocomotivesService { } findAll(filter: FilterLocomotivesDto): Promise { + // The coupling picker needs a NOT-EXISTS against the train link table, so it + // takes the query-builder path; the plain list keeps the simple where. + if (filter.excludeCoupled) { + return this.locomotivesRepository.findForCoupling({ + status: filter.status as LocomotiveStatus | undefined, + locomotiveType: filter.locomotiveType as LocomotiveType | undefined, + currentYardId: filter.currentYardId, + excludeCoupled: true, + keepTrainId: filter.excludeTrainId, + }); + } return this.locomotivesRepository.findAll({ where: { ...(filter.status ? { status: filter.status as LocomotiveStatus } : {}), diff --git a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts index 3f4d2e4ff..3de24835a 100644 --- a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts +++ b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts @@ -4,25 +4,22 @@ import { ArrayMinSize, IsArray, IsEnum, - IsNumber, IsOptional, IsUUID, - Min, ValidateNested, } from 'class-validator'; import { RouteStatus } from '../entities/route.entity'; +/** + * Segment distances are no longer part of the payload — they are resolved + * from the configured yard_distances table (Configuration → Yard Distances) + * and snapshotted onto route_milestones at create/update. + */ export class CreateRouteMilestoneDto { @ApiProperty({ format: 'uuid' }) @IsUUID() yardId!: string; - - @ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' }) - @IsOptional() - @IsNumber() - @Min(0) - distanceKm?: number; } export class CreateRouteDto { diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 4b1bfd08a..96e6c7fd1 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -9,6 +9,7 @@ import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { YardDistance } from '../rule-engine/entities/yard-distance.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; @@ -17,6 +18,9 @@ import { RouteMilestone } from './entities/route-milestone.entity'; import { formatRouteLabel, Route } from './entities/route.entity'; import { RoutesRepository } from './routes.repository'; +/** Order-insensitive key: distances are symmetric. */ +const pairKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`); + @Injectable() export class RoutesService { constructor( @@ -183,47 +187,63 @@ export class RoutesService { return this.findById(id); } - private async validateMilestones( - milestones: Array<{ yardId: string; distanceKm?: number }>, - ) { + private async validateMilestones(milestones: Array<{ yardId: string }>) { if (milestones.length < 2) { throw new BadRequestException('A route requires at least two yards'); } - const normalized = milestones.map((milestone, index) => { - const distanceKm = - index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null; - if (index > 0 && (distanceKm == null || distanceKm < 0)) { - throw new BadRequestException( - `Enter segment KM for stop ${index + 1} (from previous yard).`, - ); - } - return { - yardId: milestone.yardId, - sequenceNo: index + 1, - distanceKm, - }; - }); - - const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))]; + const uniqueYardIds = [...new Set(milestones.map((milestone) => milestone.yardId))]; const yards = await this.dataSource .getRepository(Yard) .find({ where: uniqueYardIds.map((id) => ({ id })) }); const yardIds = new Set(yards.map((yard) => yard.id)); - for (const milestone of normalized) { + for (const milestone of milestones) { if (!yardIds.has(milestone.yardId)) { throw new BadRequestException(`Yard ${milestone.yardId} does not exist`); } } - if (normalized[0].yardId === normalized[normalized.length - 1].yardId) { + if (milestones[0].yardId === milestones[milestones.length - 1].yardId) { throw new BadRequestException('Origin and destination yards must be different'); } - const originYardId = normalized[0].yardId; - const destinationYardId = normalized[normalized.length - 1].yardId; const yardById = new Map(yards.map((yard) => [yard.id, yard])); + const distanceByPair = await this.loadDistanceLookup(uniqueYardIds); + + // Segment km come from the configured yard-distance table, not the payload + // — a route can only be built over pairs an admin has entered. Distances + // are symmetric, so an A→B row also serves B→A. + const missingPairs: string[] = []; + const normalized = milestones.map((milestone, index) => { + if (index === 0) { + return { yardId: milestone.yardId, sequenceNo: 1, distanceKm: 0 }; + } + const previousYardId = milestones[index - 1].yardId; + const distanceKm = distanceByPair.get(pairKey(previousYardId, milestone.yardId)); + if (distanceKm == null) { + const from = yardById.get(previousYardId); + const to = yardById.get(milestone.yardId); + missingPairs.push( + `${from?.label ?? previousYardId} ↔ ${to?.label ?? milestone.yardId}`, + ); + } + return { + yardId: milestone.yardId, + sequenceNo: index + 1, + distanceKm: distanceKm ?? null, + }; + }); + + if (missingPairs.length > 0) { + throw new BadRequestException( + `No distance configured for: ${missingPairs.join(', ')}. ` + + 'Add the missing yard distances in Configuration → Yard Distances first.', + ); + } + + const originYardId = milestones[0].yardId; + const destinationYardId = milestones[milestones.length - 1].yardId; const direction = deriveTradeDirection( yardById.get(originYardId) ?? { country: null }, yardById.get(destinationYardId) ?? { country: null }, @@ -236,4 +256,17 @@ export class RoutesService { milestones: normalized, }; } + + /** Order-insensitive pair → km map over every configured distance touching the yards. */ + private async loadDistanceLookup(yardIds: string[]): Promise> { + const rows = await this.dataSource + .getRepository(YardDistance) + .find({ where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }] }); + + const lookup = new Map(); + for (const row of rows) { + lookup.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm)); + } + return lookup; + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts new file mode 100644 index 000000000..c43e7e4e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts @@ -0,0 +1,62 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto'; +import { YardDistancesService } from '../services/yard-distances.service'; + +@ApiTags('yard-distances') +@Controller('yard-distances') +@ApiBearerAuth() +export class YardDistancesController { + constructor(private readonly service: YardDistancesService) {} + + @Get() + @RuleEngineView('yard-distances') + @ApiOperation({ summary: 'List yard distances' }) + findAll(@Query() query: ListYardDistancesQueryDto) { + return this.service.findAll(query); + } + + @Get(':id') + @RuleEngineView('yard-distances') + @ApiOperation({ summary: 'Get a yard distance by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('yard-distances') + @ApiOperation({ summary: 'Create a yard distance' }) + create(@Body() dto: CreateYardDistanceDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('yard-distances') + @ApiOperation({ summary: 'Update a yard distance' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDistanceDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('yard-distances') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a yard distance' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts new file mode 100644 index 000000000..0615debdc --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsNumber, IsUUID, Min } from 'class-validator'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +export class CreateYardDistanceDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + fromYardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + toYardId!: string; + + @ApiProperty({ description: 'Rail distance between the two yards in kilometres', example: 445 }) + @Transform(toNumber) + @IsNumber() + @Min(0.01) + distanceKm!: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts index 5718b0531..30241ddaa 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts @@ -88,6 +88,18 @@ export class ListYardsQueryDto extends ListRuleEngineQueryDto { sortBy?: string; } +export class ListYardDistancesQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Return only distances touching this yard.' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ enum: ['createdAt', 'distanceKm'], default: 'createdAt' }) + @IsOptional() + @IsIn(['createdAt', 'distanceKm']) + sortBy?: string; +} + export class ListApprovalRulesQueryDto extends PaginationQueryDto { @ApiPropertyOptional({ description: 'Filter by approval chain (director vs standard).' }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts new file mode 100644 index 000000000..8c40876ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateYardDistanceDto } from './create-yard-distance.dto'; + +export class UpdateYardDistanceDto extends PartialType(CreateYardDistanceDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts new file mode 100644 index 000000000..982079f47 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts @@ -0,0 +1,35 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from './yard.entity'; + +/** + * Configured rail distance between two yards. Route creation reads segment + * kilometres from here (symmetric: A→B serves B→A too) instead of taking + * them as free-text input — see RoutesService.validateMilestones. + * + * Uniqueness on (from_yard_id, to_yard_id) is a partial index in the DB + * (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a + * soft-deleted pair can be re-created. + */ +@Entity({ schema: 'freight', name: 'yard_distances' }) +@Index(['fromYardId']) +@Index(['toYardId']) +export class YardDistance extends BaseEntity { + @Column({ name: 'from_yard_id', type: 'uuid' }) + fromYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'from_yard_id' }) + fromYard?: Yard; + + @Column({ name: 'to_yard_id', type: 'uuid' }) + toYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'to_yard_id' }) + toYard?: Yard; + + @Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2 }) + distanceKm!: string; // decimal columns come back as string in typeorm/pg — keep consistent with RouteMilestone.distanceKm +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts new file mode 100644 index 000000000..ca88ae048 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts @@ -0,0 +1,17 @@ +import { PaginatedResponse } from '@edr/types'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { YardDistance } from '../entities/yard-distance.entity'; + +export interface IYardDistancesRepository { + findById(id: string): Promise; + /** Exact or reverse pair — distances are symmetric (A→B serves B→A). */ + findBetween(fromYardId: string, toYardId: string): Promise; + /** All rows touching any of the given yards, for batch segment lookups. */ + findTouchingYards(yardIds: string[]): Promise; + findPaged(query: ListYardDistancesQueryDto): Promise>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const YARD_DISTANCES_REPOSITORY = Symbol('YARD_DISTANCES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts new file mode 100644 index 000000000..379c92c7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts @@ -0,0 +1,87 @@ +import { PaginatedResponse } from '@edr/types'; +import { Injectable } from '@nestjs/common'; +import { Brackets, DataSource, In, Repository } from 'typeorm'; +import { paginateQuery } from '../../../common/utils/pagination.util'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { YardDistance } from '../entities/yard-distance.entity'; +import { IYardDistancesRepository } from '../interfaces/yard-distances.repository.interface'; + +@Injectable() +export class YardDistancesRepository implements IYardDistancesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(YardDistance); + } + + findById(id: string): Promise { + return this.repo.findOne({ + where: { id }, + relations: { fromYard: true, toYard: true }, + }); + } + + findBetween(fromYardId: string, toYardId: string): Promise { + return this.repo.findOne({ + where: [ + { fromYardId, toYardId }, + { fromYardId: toYardId, toYardId: fromYardId }, + ], + }); + } + + findTouchingYards(yardIds: string[]): Promise { + if (!yardIds.length) return Promise.resolve([]); + return this.repo.find({ + where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }], + }); + } + + /** Paged list with server-side search on either yard's label/code. */ + findPaged(query: ListYardDistancesQueryDto): Promise> { + const qb = this.repo + .createQueryBuilder('yardDistance') + .leftJoinAndSelect('yardDistance.fromYard', 'fromYard') + .leftJoinAndSelect('yardDistance.toYard', 'toYard') + .orderBy(`yardDistance.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'ASC') + .addOrderBy('fromYard.label', 'ASC'); + + if (query.yardId) { + qb.andWhere( + new Brackets((w) => + w + .where('yardDistance.fromYardId = :yardId', { yardId: query.yardId }) + .orWhere('yardDistance.toYardId = :yardId', { yardId: query.yardId }), + ), + ); + } + if (query.search) { + qb.andWhere( + new Brackets((w) => + w + .where('fromYard.label ILIKE :search', { search: `%${query.search}%` }) + .orWhere('fromYard.code ILIKE :search', { search: `%${query.search}%` }) + .orWhere('toYard.label ILIKE :search', { search: `%${query.search}%` }) + .orWhere('toYard.code ILIKE :search', { search: `%${query.search}%` }), + ), + ); + } + + return paginateQuery(qb, query); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + const saved = await this.repo.save(entity); + return (await this.findById(saved.id)) ?? saved; + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} 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 95b5e381d..691992c54 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 @@ -11,6 +11,7 @@ import { RatesController } from './controllers/rates.controller'; import { ServiceTypesController } from './controllers/service-types.controller'; import { ShippingLinesController } from './controllers/shipping-lines.controller'; import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller'; +import { YardDistancesController } from './controllers/yard-distances.controller'; import { YardsController } from './controllers/yards.controller'; import { ApprovalRule } from './entities/approval-rule.entity'; @@ -24,6 +25,7 @@ import { ServiceType } from './entities/service-type.entity'; import { ShippingLine } from './entities/shipping-line.entity'; import { WeightLimitRule } from './entities/weight-limit-rule.entity'; import { Yard } from './entities/yard.entity'; +import { YardDistance } from './entities/yard-distance.entity'; import { YardFacility } from './entities/yard-facility.entity'; import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; @@ -34,6 +36,7 @@ import { RATES_REPOSITORY } from './interfaces/rates.repository.interface'; import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface'; import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface'; import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface'; +import { YARD_DISTANCES_REPOSITORY } from './interfaces/yard-distances.repository.interface'; import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface'; import { ApprovalRulesRepository } from './repositories/approval-rules.repository'; @@ -44,6 +47,7 @@ import { RatesRepository } from './repositories/rates.repository'; import { ServiceTypesRepository } from './repositories/service-types.repository'; import { ShippingLinesRepository } from './repositories/shipping-lines.repository'; import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository'; +import { YardDistancesRepository } from './repositories/yard-distances.repository'; import { YardsRepository } from './repositories/yards.repository'; import { ApprovalRulesService } from './services/approval-rules.service'; @@ -58,6 +62,7 @@ import { ServiceTypesService } from './services/service-types.service'; import { ShippingLinesService } from './services/shipping-lines.service'; import { WeightLimitRulesService } from './services/weight-limit-rules.service'; import { YardsService } from './services/yards.service'; +import { YardDistancesService } from './services/yard-distances.service'; import { YardFacilitiesService } from './services/yard-facilities.service'; import { RuleEngineService } from './rule-engine.service'; @@ -80,6 +85,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceType, WeightLimitRule, Yard, + YardDistance, YardFacility, ShippingLine, Rate, @@ -100,6 +106,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceTypesController, WeightLimitRulesController, YardsController, + YardDistancesController, ShippingLinesController, RatesController, ApprovalRulesController, @@ -117,6 +124,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. { provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository }, YardsRepository, { provide: YARDS_REPOSITORY, useExisting: YardsRepository }, + YardDistancesRepository, + { provide: YARD_DISTANCES_REPOSITORY, useExisting: YardDistancesRepository }, ShippingLinesRepository, { provide: SHIPPING_LINES_REPOSITORY, useExisting: ShippingLinesRepository }, RatesRepository, @@ -131,6 +140,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceTypesService, WeightLimitRulesService, YardsService, + YardDistancesService, YardFacilitiesService, ShippingLinesService, RatesService, @@ -146,6 +156,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. WeightLimitRulesService, PriorityConfigsService, YardsService, + YardDistancesService, YardFacilitiesService, ShippingLinesService, RatesService, @@ -155,6 +166,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. SERVICE_TYPES_REPOSITORY, SHIPPING_LINES_REPOSITORY, YARDS_REPOSITORY, + YARD_DISTANCES_REPOSITORY, ], }) export class RuleEngineModule {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts new file mode 100644 index 000000000..a41e593e2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts @@ -0,0 +1,119 @@ +import { PaginatedResponse } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto'; +import { YardDistance } from '../entities/yard-distance.entity'; +import { + IYardDistancesRepository, + YARD_DISTANCES_REPOSITORY, +} from '../interfaces/yard-distances.repository.interface'; +import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; + +/** + * Flat row shape for the backoffice config table: the yard relations stay for + * API consumers, plus label fields the generic rule-engine grid can render. + */ +export type YardDistanceRow = YardDistance & { + fromYardLabel: string; + toYardLabel: string; +}; + +const yardDisplay = (yard?: { label?: string; code?: string } | null): string => + yard?.label ?? yard?.code ?? '—'; + +const toRow = (entity: YardDistance): YardDistanceRow => + Object.assign(entity, { + fromYardLabel: yardDisplay(entity.fromYard), + toYardLabel: yardDisplay(entity.toYard), + }); + +@Injectable() +export class YardDistancesService { + constructor( + @Inject(YARD_DISTANCES_REPOSITORY) + private readonly repository: IYardDistancesRepository, + @Inject(YARDS_REPOSITORY) + private readonly yardsRepository: IYardsRepository, + ) {} + + async findAll(query: ListYardDistancesQueryDto): Promise> { + const page = await this.repository.findPaged(query); + return { ...page, items: page.items.map(toRow) }; + } + + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Yard distance ${id} not found`); + return toRow(entity); + } + + async create(dto: CreateYardDistanceDto): Promise { + await this.assertValidPair(dto.fromYardId, dto.toYardId); + + const created = await this.repository.create({ + fromYardId: dto.fromYardId, + toYardId: dto.toYardId, + distanceKm: dto.distanceKm.toFixed(2), + }); + return toRow(created); + } + + async update(id: string, dto: UpdateYardDistanceDto): Promise { + const existing = await this.findById(id); + + const fromYardId = dto.fromYardId ?? existing.fromYardId; + const toYardId = dto.toYardId ?? existing.toYardId; + if (fromYardId !== existing.fromYardId || toYardId !== existing.toYardId) { + await this.assertValidPair(fromYardId, toYardId, id); + } + + const updated = await this.repository.update(id, { + fromYardId, + toYardId, + ...(dto.distanceKm != null ? { distanceKm: dto.distanceKm.toFixed(2) } : {}), + }); + if (!updated) throw new NotFoundException(`Yard distance ${id} not found`); + return toRow(updated); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } + + /** + * Both yards must exist and differ, and the pair must not already be + * configured in either direction — distances are symmetric, so an A→B row + * already covers B→A. + */ + private async assertValidPair( + fromYardId: string, + toYardId: string, + ignoreId?: string, + ): Promise { + if (fromYardId === toYardId) { + throw new BadRequestException('From and to yards must be different'); + } + + const [fromYard, toYard] = await Promise.all([ + this.yardsRepository.findById(fromYardId), + this.yardsRepository.findById(toYardId), + ]); + if (!fromYard) throw new BadRequestException(`Yard ${fromYardId} does not exist`); + if (!toYard) throw new BadRequestException(`Yard ${toYardId} does not exist`); + + const existing = await this.repository.findBetween(fromYardId, toYardId); + if (existing && existing.id !== ignoreId) { + throw new ConflictException( + `A distance between ${fromYard.label} and ${toYard.label} is already configured`, + ); + } + } +} 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 3658f4f37..662b83534 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 @@ -29,6 +29,9 @@ import { const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100; +/** Locomotive statuses that block a train from reactivating. */ +const UNFIT_FOR_REACTIVATION = new Set(['MAINTENANCE', 'OUT_OF_SERVICE', 'UNAVAILABLE']); + /** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */ export interface ActiveScheduleRef { id: string; @@ -596,11 +599,30 @@ export class TrainBuilderService { return this.getComposition(id); } - /** Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled again. */ + /** + * Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled + * again. Blocked if any coupled locomotive is unfit for service — a + * deactivated train can sit parked for a while and its locomotives may have + * since been sent to maintenance independently; reactivating must not wave + * a down locomotive back onto the schedule board. + */ async activate(id: string) { const train = await this.dataSource.getRepository(Train).findOne({ where: { id } }); if (!train) throw new NotFoundException(`Train ${id} not found`); if (train.status === Freight.TrainStatus.Deactivated) { + const links = await this.dataSource + .getRepository(TrainLocomotive) + .find({ where: { trainId: id }, relations: { locomotive: true } }); + const unfit = links + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)) + .filter((loco) => UNFIT_FOR_REACTIVATION.has(loco.status)); + if (unfit.length) { + const names = unfit.map((l) => `${l.code} (${l.status})`).join(', '); + throw new ConflictException( + `Train cannot be reactivated: ${names} ${unfit.length > 1 ? 'are' : 'is'} not fit for service. Detach and replace before reactivating.`, + ); + } await this.dataSource .getRepository(Train) .update(id, { status: Freight.TrainStatus.Available }); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index d3b5bbb00..396ac95db 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -18,6 +18,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [ 'priority-configs', 'rates', 'approval-rules', + 'yard-distances', ] as const; export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; @@ -97,6 +98,7 @@ const RULE_ENGINE_PERMISSION_IDS: Record(null); const [rejectReason, setRejectReason] = useState(""); + // Where the rejection lands: "CUSTOMER" (terminal, resubmit) or the id of an + // earlier APPROVED step to send the chain back to. First approver has no + // choice — customer only. + const [rejectTarget, setRejectTarget] = useState("CUSTOMER"); const steps = useMemo( () => @@ -70,6 +75,7 @@ export function ContractApprovalStepsCard({ const openReject = (step: Freight.IContractApprovalStep) => { setRejectStepRow(step); setRejectReason(""); + setRejectTarget("CUSTOMER"); setRejectOpen(true); }; @@ -77,14 +83,34 @@ export function ContractApprovalStepsCard({ setRejectOpen(false); setRejectStepRow(null); setRejectReason(""); + setRejectTarget("CUSTOMER"); }; const trimmedReason = rejectReason.trim(); + // Earlier stages this rejection can be returned to — only stages that have + // already approved. Empty for the first approver, whose only target is the + // customer. + const returnableSteps = rejectStepRow + ? steps.filter( + (s) => + s.stepOrder < rejectStepRow.stepOrder && s.status === "APPROVED", + ) + : []; + + const sendBack = rejectTarget !== "CUSTOMER"; + const targetStep = sendBack + ? returnableSteps.find((s) => s.id === rejectTarget) + : undefined; + const runReject = () => { if (!rejectStepRow || !trimmedReason) return; mutations.rejectStep.mutate( - { stepId: rejectStepRow.id, reason: trimmedReason }, + { + stepId: rejectStepRow.id, + reason: trimmedReason, + returnToStepId: sendBack ? rejectTarget : undefined, + }, { onSuccess: () => closeReject() }, ); }; @@ -192,21 +218,56 @@ export function ContractApprovalStepsCard({ centered > - - Rejecting the{" "} - - {rejectStepRow?.requiredRole} - {" "} - step rejects contract{" "} - - {contract.reference} - {" "} - outright. The customer must create a new contract — this cannot be - undone. - + {returnableSteps.length > 0 && ( +