mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 02:30:55 +00:00
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.
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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<Contract> {
|
||||
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<Contract> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -368,6 +368,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<boolean> {
|
||||
const pending = await this.dataSource.getRepository(ContractApprovalStep).count({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Locomotive> {
|
||||
@@ -14,6 +15,47 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
|
||||
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<Locomotive[]> {
|
||||
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-
|
||||
|
||||
@@ -29,6 +29,17 @@ export class LocomotivesService {
|
||||
}
|
||||
|
||||
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
|
||||
// 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 } : {}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Map<string, number>> {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(YardDistance)
|
||||
.find({ where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }] });
|
||||
|
||||
const lookup = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
lookup.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm));
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateYardDistanceDto } from './create-yard-distance.dto';
|
||||
|
||||
export class UpdateYardDistanceDto extends PartialType(CreateYardDistanceDto) {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<YardDistance | null>;
|
||||
/** Exact or reverse pair — distances are symmetric (A→B serves B→A). */
|
||||
findBetween(fromYardId: string, toYardId: string): Promise<YardDistance | null>;
|
||||
/** All rows touching any of the given yards, for batch segment lookups. */
|
||||
findTouchingYards(yardIds: string[]): Promise<YardDistance[]>;
|
||||
findPaged(query: ListYardDistancesQueryDto): Promise<PaginatedResponse<YardDistance>>;
|
||||
create(data: Partial<YardDistance>): Promise<YardDistance>;
|
||||
update(id: string, data: Partial<YardDistance>): Promise<YardDistance | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const YARD_DISTANCES_REPOSITORY = Symbol('YARD_DISTANCES_REPOSITORY');
|
||||
@@ -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<YardDistance>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repo = this.dataSource.getRepository(YardDistance);
|
||||
}
|
||||
|
||||
findById(id: string): Promise<YardDistance | null> {
|
||||
return this.repo.findOne({
|
||||
where: { id },
|
||||
relations: { fromYard: true, toYard: true },
|
||||
});
|
||||
}
|
||||
|
||||
findBetween(fromYardId: string, toYardId: string): Promise<YardDistance | null> {
|
||||
return this.repo.findOne({
|
||||
where: [
|
||||
{ fromYardId, toYardId },
|
||||
{ fromYardId: toYardId, toYardId: fromYardId },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
findTouchingYards(yardIds: string[]): Promise<YardDistance[]> {
|
||||
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<PaginatedResponse<YardDistance>> {
|
||||
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<YardDistance>): Promise<YardDistance> {
|
||||
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<YardDistance>): Promise<YardDistance | null> {
|
||||
await this.repo.update(id, data);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async softDelete(id: string): Promise<void> {
|
||||
await this.repo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<PaginatedResponse<YardDistanceRow>> {
|
||||
const page = await this.repository.findPaged(query);
|
||||
return { ...page, items: page.items.map(toRow) };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<YardDistanceRow> {
|
||||
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<YardDistanceRow> {
|
||||
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<YardDistanceRow> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user