mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- planned couples: loose wagons join the train at a route stop, added from the schedule yards tab; capacity credits them per corridor edge and coupling validates locomotive weight/length caps per leg - real-cut toggle: a cut wagon permanently leaves the train build at its cut yard (soft cut still sits out one trip only) - fix heaviest-leg display counting a shared slot's full cargo on every spanned edge (phantom pull-weight overload on S-2026-00045) - confirmation dialogs for workspace add/load/unload/remove actions - train-builder History and Detached-wagons tabs, backed by paginated endpoints; builder detaches now always write adjustment-log rows Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and 3670 (adjustment log train_schedule_id nullable) — both applied to the dev DB by hand; watch mode does not run migrations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1506 lines
61 KiB
TypeScript
1506 lines
61 KiB
TypeScript
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
|
|
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
|
|
|
/** Schedule whose FULL flag must be re-derived once the consist edit has committed. */
|
|
type PendingWindowCheck = { scheduleId: string; wasFull: boolean };
|
|
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
|
|
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
|
import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util';
|
|
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
|
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
|
import { WagonStatusLog } from '../wagons/entities/wagon-status-log.entity';
|
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
|
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
|
import { BuildTrainDto } from './dto/build-train.dto';
|
|
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
|
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
|
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
|
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
|
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
|
import { Train } from './entities/train.entity';
|
|
import {
|
|
buildPaginationMeta,
|
|
normalizePagination,
|
|
} from '../../common/utils/pagination.util';
|
|
|
|
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;
|
|
status: string;
|
|
reference: string | null;
|
|
direction: string | null;
|
|
trainNumber: string | null;
|
|
}
|
|
|
|
/**
|
|
* Train Builder — assembles persistent fleet trains (code + 2+ locomotives +
|
|
* ordered wagons, all in one yard) that scheduling can later reference as a
|
|
* unit instead of hand-picking locomotives per departure.
|
|
*
|
|
* Resource rules:
|
|
* - Locomotive double-use is prevented through the `train_locomotives` link
|
|
* table (a locomotive rides at most one built train); its `status` column
|
|
* keeps its operational meaning (ASSIGNED = out on a dispatched train).
|
|
* - Wagons attached to a train are flipped to ASSIGNED (same semantic the
|
|
* legacy assign-train flow uses), so no other train or schedule grabs them.
|
|
*/
|
|
@Injectable()
|
|
export class TrainBuilderService {
|
|
private readonly logger = new Logger(TrainBuilderService.name);
|
|
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly bookingBatchService: BookingBatchService,
|
|
) {}
|
|
|
|
async buildTrain(dto: BuildTrainDto) {
|
|
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
|
if (locomotiveIds.length < 1) {
|
|
throw new BadRequestException('A train must be pulled by at least one locomotive');
|
|
}
|
|
|
|
const trainId = await this.dataSource.transaction(async (manager) => {
|
|
const code = await this.generateTrainCode(manager);
|
|
|
|
// Friendly 409 before the partial unique indexes (the race-proof backstop):
|
|
// the typed pair may not collide with any train's pair or legacy number.
|
|
const importTrainNumber = dto.importTrainNumber.trim();
|
|
const exportTrainNumber = dto.exportTrainNumber.trim();
|
|
const numberClash: { code: string }[] = await manager.query(
|
|
`SELECT code FROM freight.trains
|
|
WHERE deleted_at IS NULL
|
|
AND (import_train_number IN ($1, $2)
|
|
OR export_train_number IN ($1, $2)
|
|
OR train_number IN ($1, $2))
|
|
LIMIT 1`,
|
|
[importTrainNumber, exportTrainNumber],
|
|
);
|
|
if (numberClash.length) {
|
|
throw new ConflictException(
|
|
`Train number ${importTrainNumber}/${exportTrainNumber} is already used by train ${numberClash[0].code}`,
|
|
);
|
|
}
|
|
|
|
const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } });
|
|
if (!yard) throw new NotFoundException(`Yard ${dto.currentYardId} not found`);
|
|
|
|
const locomotives = await this.validateAndLockLocomotives(
|
|
manager,
|
|
locomotiveIds,
|
|
yard,
|
|
null,
|
|
);
|
|
|
|
// Effective haul capacity is capped by the weakest locomotive in the set.
|
|
const limits = combinedLocomotiveLimits(locomotives);
|
|
const train = await manager.getRepository(Train).save(
|
|
manager.getRepository(Train).create({
|
|
code,
|
|
currentYardId: yard.id,
|
|
capacityTons: round(limits?.maxPullWeightTons ?? 0),
|
|
status: Freight.TrainStatus.Available,
|
|
trainName: dto.trainName?.trim() || undefined,
|
|
notes: dto.notes?.trim() || undefined,
|
|
importTrainNumber,
|
|
exportTrainNumber,
|
|
}),
|
|
);
|
|
|
|
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
|
|
|
|
if (dto.wagonIds?.length) {
|
|
await this.attachWagons(manager, train, dto.wagonIds, 0);
|
|
}
|
|
return train.id;
|
|
});
|
|
|
|
return this.getComposition(trainId);
|
|
}
|
|
|
|
/** Paginated builder list with a composition summary per train. */
|
|
async listBuilt(query: ListBuiltTrainsQueryDto) {
|
|
const { page, pageSize, skip, take } = normalizePagination(query);
|
|
const search = query.search?.trim();
|
|
const filters = {
|
|
...(query.status ? { status: query.status } : {}),
|
|
...(query.currentYardId ? { currentYardId: query.currentYardId } : {}),
|
|
};
|
|
const where = search
|
|
? [
|
|
{ ...filters, code: ILike(`%${search}%`) },
|
|
{ ...filters, trainName: ILike(`%${search}%`) },
|
|
]
|
|
: filters;
|
|
|
|
const [trains, total] = await this.dataSource.getRepository(Train).findAndCount({
|
|
where,
|
|
relations: {
|
|
currentYard: true,
|
|
locomotives: { locomotive: true },
|
|
wagons: { wagonType: true },
|
|
},
|
|
order: { [query.sortBy ?? 'createdAt']: query.sortOrder ?? 'DESC' },
|
|
skip,
|
|
take,
|
|
});
|
|
|
|
const activeByTrain = await this.loadActiveScheduleByTrain(trains.map((t) => t.id));
|
|
|
|
return {
|
|
items: trains.map((train) => this.mapSummary(train, activeByTrain.get(train.id) ?? null)),
|
|
meta: buildPaginationMeta(total, page, pageSize),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run numbers already claimed by live (non-deleted) trains, split by
|
|
* direction. Legacy single `train_number` values are sorted into a side by
|
|
* parity (even = import, odd = export) so the pickers can grey them out too.
|
|
*/
|
|
async usedTrainNumbers() {
|
|
const rows: {
|
|
import_train_number: string | null;
|
|
export_train_number: string | null;
|
|
train_number: string | null;
|
|
}[] = await this.dataSource.query(
|
|
`SELECT import_train_number, export_train_number, train_number
|
|
FROM freight.trains
|
|
WHERE deleted_at IS NULL`,
|
|
);
|
|
|
|
const importTrainNumbers = new Set<string>();
|
|
const exportTrainNumbers = new Set<string>();
|
|
for (const row of rows) {
|
|
if (row.import_train_number) importTrainNumbers.add(row.import_train_number);
|
|
if (row.export_train_number) exportTrainNumbers.add(row.export_train_number);
|
|
const legacy = row.train_number?.trim();
|
|
if (legacy && /^\d+$/.test(legacy)) {
|
|
(Number(legacy) % 2 === 0 ? importTrainNumbers : exportTrainNumbers).add(legacy);
|
|
}
|
|
}
|
|
return {
|
|
importTrainNumbers: [...importTrainNumbers].sort(),
|
|
exportTrainNumbers: [...exportTrainNumbers].sort(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* One ACTIVE schedule per train for the page (prefer the DISPATCHED run,
|
|
* else the earliest upcoming departure) — feeds the list's direction tint
|
|
* and in-use train number.
|
|
*/
|
|
private async loadActiveScheduleByTrain(
|
|
trainIds: string[],
|
|
): Promise<Map<string, ActiveScheduleRef>> {
|
|
if (!trainIds.length) return new Map();
|
|
const rows: (ActiveScheduleRef & { trainId: string })[] = await this.dataSource.query(
|
|
`SELECT DISTINCT ON (tset.train_id)
|
|
tset.train_id AS "trainId",
|
|
ts.id,
|
|
ts.status,
|
|
ts.reference,
|
|
ts.direction,
|
|
ts.train_number AS "trainNumber"
|
|
FROM freight.train_schedules ts
|
|
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
|
WHERE tset.train_id = ANY($1)
|
|
AND ts.deleted_at IS NULL
|
|
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
|
ORDER BY tset.train_id, (ts.status = 'DISPATCHED') DESC, ts.scheduled_departure_date ASC`,
|
|
[trainIds],
|
|
);
|
|
return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule]));
|
|
}
|
|
|
|
/**
|
|
* Wagon adjustment history of one built train, newest first: builder
|
|
* attaches/detaches (no schedule) and trip events (real cuts, couples,
|
|
* consist adjustments — carrying their schedule reference) alike.
|
|
*/
|
|
async getTrainHistory(trainId: string, query: { page?: number; pageSize?: number } = {}) {
|
|
const { page, pageSize, skip, take } = normalizePagination(query);
|
|
const [countRows, rows]: [
|
|
Array<{ total: string }>,
|
|
Array<{
|
|
id: string;
|
|
action: string;
|
|
subject: string;
|
|
yardLabel: string | null;
|
|
actor: string | null;
|
|
scheduleReference: string | null;
|
|
occurredAt: Date;
|
|
}>,
|
|
] = await Promise.all([
|
|
this.dataSource.query(
|
|
`SELECT count(*) AS total
|
|
FROM freight.schedule_wagon_adjustment_logs l
|
|
WHERE l.train_id = $1
|
|
AND l.deleted_at IS NULL`,
|
|
[trainId],
|
|
),
|
|
this.dataSource.query(
|
|
`SELECT l.id,
|
|
l.action,
|
|
l.wagon_number AS "subject",
|
|
COALESCE(y.label, y.code) AS "yardLabel",
|
|
COALESCE(u.username, u.email) AS "actor",
|
|
ts.reference AS "scheduleReference",
|
|
l.occurred_at AS "occurredAt"
|
|
FROM freight.schedule_wagon_adjustment_logs l
|
|
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
|
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
|
|
LEFT JOIN freight.train_schedules ts ON ts.id = l.train_schedule_id
|
|
WHERE l.train_id = $1
|
|
AND l.deleted_at IS NULL
|
|
ORDER BY l.occurred_at DESC
|
|
LIMIT $2 OFFSET $3`,
|
|
[trainId, take, skip],
|
|
),
|
|
]);
|
|
const total = Number(countRows[0]?.total ?? 0);
|
|
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
|
|
}
|
|
|
|
/**
|
|
* Wagons last detached from THIS train that are still loose (no train,
|
|
* AVAILABLE) — the re-attach shortlist, with when/where/by whom each was
|
|
* last detached. Derived from the adjustment log, no denormalized column.
|
|
*/
|
|
async getDetachedWagons(trainId: string, query: { page?: number; pageSize?: number } = {}) {
|
|
const { page, pageSize, skip, take } = normalizePagination(query);
|
|
const lastRemovalSql = `
|
|
SELECT DISTINCT ON (l.wagon_id)
|
|
l.wagon_id AS "wagonId",
|
|
l.occurred_at AS "detachedAt",
|
|
COALESCE(y.label, y.code) AS "detachedYardLabel",
|
|
COALESCE(u.username, u.email) AS "detachedBy"
|
|
FROM freight.schedule_wagon_adjustment_logs l
|
|
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
|
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
|
|
WHERE l.train_id = $1
|
|
AND l.action = 'REMOVE'
|
|
AND l.deleted_at IS NULL
|
|
ORDER BY l.wagon_id, l.occurred_at DESC`;
|
|
const stillLoose = `w.deleted_at IS NULL AND w.train_id IS NULL AND w.status = 'AVAILABLE'`;
|
|
const [countRows, rows]: [
|
|
Array<{ total: string }>,
|
|
Array<{
|
|
wagonId: string;
|
|
wagonNumber: string;
|
|
wagonTypeCode: string | null;
|
|
currentYardLabel: string | null;
|
|
detachedAt: Date;
|
|
detachedYardLabel: string | null;
|
|
detachedBy: string | null;
|
|
}>,
|
|
] = await Promise.all([
|
|
this.dataSource.query(
|
|
`SELECT count(*) AS total
|
|
FROM (${lastRemovalSql}) last_removal
|
|
JOIN freight.wagons w ON w.id = last_removal."wagonId"
|
|
WHERE ${stillLoose}`,
|
|
[trainId],
|
|
),
|
|
this.dataSource.query(
|
|
`SELECT last_removal."wagonId",
|
|
w.wagon_number AS "wagonNumber",
|
|
wt.code AS "wagonTypeCode",
|
|
COALESCE(cy.label, cy.code) AS "currentYardLabel",
|
|
last_removal."detachedAt",
|
|
last_removal."detachedYardLabel",
|
|
last_removal."detachedBy"
|
|
FROM (${lastRemovalSql}) last_removal
|
|
JOIN freight.wagons w ON w.id = last_removal."wagonId"
|
|
LEFT JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
|
LEFT JOIN freight.yards cy ON cy.id = w.current_yard_id
|
|
WHERE ${stillLoose}
|
|
ORDER BY last_removal."detachedAt" DESC
|
|
LIMIT $2 OFFSET $3`,
|
|
[trainId, take, skip],
|
|
),
|
|
]);
|
|
const total = Number(countRows[0]?.total ?? 0);
|
|
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
|
|
}
|
|
|
|
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
|
|
async getComposition(id: string) {
|
|
const train = await this.dataSource.getRepository(Train).findOne({
|
|
where: { id },
|
|
relations: {
|
|
currentYard: true,
|
|
locomotives: { locomotive: { currentYard: true } },
|
|
wagons: { wagonType: true, currentYard: true },
|
|
},
|
|
order: {
|
|
locomotives: { sequenceNo: 'ASC' },
|
|
wagons: { sequenceNumber: 'ASC' },
|
|
},
|
|
});
|
|
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
|
|
|
const schedules: ActiveScheduleRef[] = await this.dataSource.query(
|
|
`SELECT ts.id, ts.status, ts.reference, ts.direction,
|
|
ts.train_number AS "trainNumber"
|
|
FROM freight.train_schedules ts
|
|
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
|
WHERE tset.train_id = $1
|
|
AND ts.deleted_at IS NULL
|
|
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
|
ORDER BY ts.scheduled_departure_date ASC`,
|
|
[id],
|
|
);
|
|
|
|
const locomotives = (train.locomotives ?? [])
|
|
.filter((link) => link.locomotive)
|
|
.map((link, index) => ({
|
|
id: link.locomotive!.id,
|
|
code: link.locomotive!.code,
|
|
name: link.locomotive!.name ?? null,
|
|
locomotiveType: link.locomotive!.locomotiveType,
|
|
status: link.locomotive!.status,
|
|
sequenceNo: link.sequenceNo,
|
|
role: index === 0 ? 'LEAD' : 'ASSIST',
|
|
currentYardId: link.locomotive!.currentYardId ?? null,
|
|
currentYard: link.locomotive!.currentYard
|
|
? {
|
|
id: link.locomotive!.currentYard.id,
|
|
code: link.locomotive!.currentYard.code,
|
|
label: link.locomotive!.currentYard.label,
|
|
}
|
|
: null,
|
|
maxPullWeightTons: round(link.locomotive!.maxPullWeightTons),
|
|
maxTrainLengthMeters: round(link.locomotive!.maxTrainLengthMeters),
|
|
}));
|
|
|
|
const wagons = (train.wagons ?? []).map((wagon) => ({
|
|
id: wagon.id,
|
|
wagonNumber: wagon.wagonNumber,
|
|
sequenceNumber: wagon.sequenceNumber,
|
|
status: wagon.status,
|
|
currentYardId: wagon.currentYardId ?? null,
|
|
currentYard: wagon.currentYard
|
|
? { id: wagon.currentYard.id, code: wagon.currentYard.code, label: wagon.currentYard.label }
|
|
: null,
|
|
wagonType: wagon.wagonType
|
|
? {
|
|
id: wagon.wagonType.id,
|
|
code: wagon.wagonType.code,
|
|
name: wagon.wagonType.name,
|
|
capacityTons: round(wagon.wagonType.capacityTons),
|
|
tareWeightTons: round(wagon.wagonType.tareWeightTons),
|
|
lengthMeters: round(wagon.wagonType.lengthMeters),
|
|
}
|
|
: null,
|
|
}));
|
|
|
|
const limits = combinedLocomotiveLimits(
|
|
(train.locomotives ?? [])
|
|
.map((link) => link.locomotive)
|
|
.filter((loco): loco is Locomotive => Boolean(loco)),
|
|
);
|
|
const totalTareTons = round(
|
|
wagons.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ?? 0), 0),
|
|
);
|
|
const totalCapacityTons = round(
|
|
wagons.reduce((sum, w) => sum + (w.wagonType?.capacityTons ?? 0), 0),
|
|
);
|
|
const totalLengthMeters = round(
|
|
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
|
|
);
|
|
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
|
|
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
|
|
|
|
return {
|
|
id: train.id,
|
|
code: train.code,
|
|
trainName: train.trainName ?? null,
|
|
status: train.status,
|
|
importTrainNumber: train.importTrainNumber ?? null,
|
|
exportTrainNumber: train.exportTrainNumber ?? null,
|
|
notes: train.notes ?? null,
|
|
createdAt: train.createdAt,
|
|
currentYard: train.currentYard
|
|
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }
|
|
: null,
|
|
locomotives,
|
|
wagons,
|
|
// Where the consist physically stands. A train built from several yards
|
|
// only picks a yard's wagons up when it reaches that yard, and a customer
|
|
// boarding there can only book the wagons standing there — the schedule
|
|
// route must therefore cover every one of these yards before its
|
|
// destination.
|
|
wagonYards: [
|
|
...wagons
|
|
.reduce((acc, wagon) => {
|
|
const id = wagon.currentYardId ?? 'UNASSIGNED';
|
|
const entry = acc.get(id) ?? {
|
|
yardId: wagon.currentYardId ?? null,
|
|
code: wagon.currentYard?.code ?? null,
|
|
label: wagon.currentYard?.label ?? null,
|
|
wagonCount: 0,
|
|
};
|
|
entry.wagonCount += 1;
|
|
acc.set(id, entry);
|
|
return acc;
|
|
}, new Map<string, { yardId: string | null; code: string | null; label: string | null; wagonCount: number }>())
|
|
.values(),
|
|
].sort((a, b) => b.wagonCount - a.wagonCount),
|
|
totals: {
|
|
wagonCount: wagons.length,
|
|
totalTareTons,
|
|
// Informational only — building never checks against full capacity;
|
|
// the real gross check (cargo + tare vs haul limit) runs at allocation.
|
|
totalCapacityTons,
|
|
totalLengthMeters,
|
|
maxPullWeightTons,
|
|
maxTrainLengthMeters,
|
|
// Cargo the locomotives can still haul once pulling the empty consist.
|
|
payloadCapacityTons: round(Math.max(0, maxPullWeightTons - totalTareTons)),
|
|
// Share of the haul limit consumed by the empty wagons alone.
|
|
tareUtilizationPct: maxPullWeightTons
|
|
? round((totalTareTons / maxPullWeightTons) * 100)
|
|
: null,
|
|
lengthUtilizationPct: maxTrainLengthMeters
|
|
? round((totalLengthMeters / maxTrainLengthMeters) * 100)
|
|
: null,
|
|
},
|
|
activeSchedules: schedules,
|
|
// Composition is frozen while the train is out on a dispatched run.
|
|
editable: !schedules.some((s) => s.status === 'DISPATCHED'),
|
|
};
|
|
}
|
|
|
|
/** Replace the locomotive set (minimum 1, same-yard rule applies). */
|
|
async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) {
|
|
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
|
if (locomotiveIds.length < 1) {
|
|
throw new BadRequestException('A train must be pulled by at least one locomotive');
|
|
}
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const train = await this.getEditableTrain(manager, id);
|
|
const yard = await manager
|
|
.getRepository(Yard)
|
|
.findOne({ where: { id: train.currentYardId ?? '' } });
|
|
if (!yard) {
|
|
throw new BadRequestException('Train has no yard; set the yard before changing locomotives');
|
|
}
|
|
const locomotives = await this.validateAndLockLocomotives(
|
|
manager,
|
|
locomotiveIds,
|
|
yard,
|
|
train.id,
|
|
);
|
|
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
|
|
const limits = combinedLocomotiveLimits(locomotives);
|
|
await manager
|
|
.getRepository(Train)
|
|
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
|
|
});
|
|
return this.getComposition(id);
|
|
}
|
|
|
|
/**
|
|
* Edit a built train's display identity: name and fixed import/export run
|
|
* numbers. Mirrors the build-time number rules — the pair may not collide
|
|
* with any other train's pair or legacy number (friendly 409 ahead of the
|
|
* partial unique indexes). Blocked while the train is out on a dispatched
|
|
* run, like every other composition edit.
|
|
*/
|
|
async updateDetails(id: string, dto: UpdateTrainDetailsDto) {
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const train = await this.getEditableTrain(manager, id);
|
|
|
|
const patch: QueryDeepPartialEntity<Train> = {};
|
|
if (dto.trainName !== undefined) {
|
|
patch.trainName = dto.trainName.trim() || null;
|
|
}
|
|
const importTrainNumber = dto.importTrainNumber?.trim();
|
|
const exportTrainNumber = dto.exportTrainNumber?.trim();
|
|
if (importTrainNumber) patch.importTrainNumber = importTrainNumber;
|
|
if (exportTrainNumber) patch.exportTrainNumber = exportTrainNumber;
|
|
|
|
if (importTrainNumber || exportTrainNumber) {
|
|
const nextImport = importTrainNumber ?? train.importTrainNumber ?? '';
|
|
const nextExport = exportTrainNumber ?? train.exportTrainNumber ?? '';
|
|
const numberClash: { code: string }[] = await manager.query(
|
|
`SELECT code FROM freight.trains
|
|
WHERE deleted_at IS NULL
|
|
AND id != $3
|
|
AND (import_train_number IN ($1, $2)
|
|
OR export_train_number IN ($1, $2)
|
|
OR train_number IN ($1, $2))
|
|
LIMIT 1`,
|
|
[nextImport, nextExport, train.id],
|
|
);
|
|
if (numberClash.length) {
|
|
throw new ConflictException(
|
|
`Train number ${nextImport}/${nextExport} is already used by train ${numberClash[0].code}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (Object.keys(patch).length) {
|
|
await manager.getRepository(Train).update(train.id, patch);
|
|
}
|
|
});
|
|
return this.getComposition(id);
|
|
}
|
|
|
|
/**
|
|
* Relocate the train to another yard. The locomotives always follow. Of the
|
|
* wagons, only those standing WITH the train move: on a consist spread
|
|
* across yards (20 in Dire, 33 waiting in Mojo), moving the train Dire→Mojo
|
|
* relocates the 20 it is actually pulling and leaves the Mojo wagons where
|
|
* they stand — the train collects those by arriving, not by this call.
|
|
* Each moved wagon gets a movement-ledger row. Blocked while the train is
|
|
* out on a dispatched run.
|
|
*/
|
|
async setYard(id: string, currentYardId: string) {
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const train = await this.getEditableTrain(manager, id);
|
|
if (train.currentYardId === currentYardId) return;
|
|
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
|
|
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
|
|
const previousYardId = train.currentYardId ?? null;
|
|
|
|
await manager.getRepository(Train).update(train.id, { currentYardId: yard.id });
|
|
|
|
const links = await manager
|
|
.getRepository(TrainLocomotive)
|
|
.find({ where: { trainId: train.id } });
|
|
if (links.length) {
|
|
await manager
|
|
.getRepository(Locomotive)
|
|
.update(
|
|
{ id: In(links.map((link) => link.locomotiveId)) },
|
|
{ currentYardId: yard.id },
|
|
);
|
|
}
|
|
|
|
const allWagons = await manager
|
|
.getRepository(Wagon)
|
|
.find({ where: { trainId: train.id } });
|
|
// Wagons travelling with the train = those at the yard it is leaving.
|
|
// A yard-less wagon has no standing position of its own, so it follows.
|
|
const wagons = allWagons.filter(
|
|
(wagon) =>
|
|
wagon.currentYardId == null ||
|
|
previousYardId == null ||
|
|
wagon.currentYardId === previousYardId,
|
|
);
|
|
const now = new Date();
|
|
for (const wagon of wagons) {
|
|
if (wagon.currentYardId === yard.id) continue;
|
|
await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
|
|
// Ledger row keeps the wagon's yard history auditable (mirrors the
|
|
// manual-relocation path in the wagons service).
|
|
await manager.getRepository(WagonMovement).save(
|
|
manager.getRepository(WagonMovement).create({
|
|
wagonId: wagon.id,
|
|
fromYardId: wagon.currentYardId ?? null,
|
|
toYardId: yard.id,
|
|
kind: WagonMovementKind.Manual,
|
|
occurredAt: now,
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
return this.getComposition(id);
|
|
}
|
|
|
|
/**
|
|
* Move ONE coupled wagon to another yard (the train and the rest of the
|
|
* consist stay put). Refused while any live (DRAFT/SCHEDULED/DISPATCHED)
|
|
* schedule has the wagon allocated to a slot — its standing yard is part of
|
|
* that schedule's route validation. Ledger row mirrors `setYard`.
|
|
*/
|
|
async setWagonYard(id: string, wagonId: string, currentYardId: string, userId?: string | null) {
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const train = await this.getEditableTrain(manager, id);
|
|
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
|
if (!wagon || wagon.trainId !== train.id) {
|
|
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
|
|
}
|
|
if (wagon.currentYardId === currentYardId) return;
|
|
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
|
|
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
|
|
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
|
|
throw new ConflictException(
|
|
`Wagon ${wagon.wagonNumber} is allocated to a scheduled or dispatched run; its yard cannot be changed`,
|
|
);
|
|
}
|
|
await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
|
|
await manager.getRepository(WagonMovement).save(
|
|
manager.getRepository(WagonMovement).create({
|
|
wagonId: wagon.id,
|
|
fromYardId: wagon.currentYardId ?? null,
|
|
toYardId: yard.id,
|
|
kind: WagonMovementKind.Manual,
|
|
movedByUserId: userId ?? null,
|
|
occurredAt: new Date(),
|
|
}),
|
|
);
|
|
});
|
|
return this.getComposition(id);
|
|
}
|
|
|
|
/**
|
|
* Move SEVERAL coupled wagons to another yard in one transaction (the train
|
|
* and the rest of the consist stay put). All-or-nothing: if any wagon is not
|
|
* coupled here, or is pinned to a live schedule, nothing moves — a partial
|
|
* relocation would leave the consist split across yards silently. Wagons
|
|
* already in the target yard are skipped, not an error.
|
|
*/
|
|
async setWagonsYard(
|
|
id: string,
|
|
wagonIds: string[],
|
|
currentYardId: string,
|
|
userId?: string | null,
|
|
) {
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const train = await this.getEditableTrain(manager, id);
|
|
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
|
|
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
|
|
|
|
const unique = [...new Set(wagonIds)];
|
|
const wagons = await manager.getRepository(Wagon).find({ where: unique.map((wid) => ({ id: wid })) });
|
|
const byId = new Map(wagons.map((w) => [w.id, w]));
|
|
const missing = unique.filter((wid) => byId.get(wid)?.trainId !== train.id);
|
|
if (missing.length) {
|
|
throw new NotFoundException(
|
|
`${missing.length} of ${unique.length} wagons are not coupled to train ${train.code}`,
|
|
);
|
|
}
|
|
|
|
// Check every wagon before moving any — the whole point of the bulk call.
|
|
const pinned: string[] = [];
|
|
for (const wagon of wagons) {
|
|
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
|
|
pinned.push(wagon.wagonNumber);
|
|
}
|
|
}
|
|
if (pinned.length) {
|
|
throw new ConflictException(
|
|
`${pinned.join(', ')} ${pinned.length === 1 ? 'is' : 'are'} allocated to a scheduled or dispatched run; ${
|
|
pinned.length === 1 ? 'its' : 'their'
|
|
} yard cannot be changed`,
|
|
);
|
|
}
|
|
|
|
const moving = wagons.filter((w) => w.currentYardId !== yard.id);
|
|
if (!moving.length) return;
|
|
await manager
|
|
.getRepository(Wagon)
|
|
.update(moving.map((w) => w.id), { currentYardId: yard.id });
|
|
await manager.getRepository(WagonMovement).save(
|
|
moving.map((w) =>
|
|
manager.getRepository(WagonMovement).create({
|
|
wagonId: w.id,
|
|
fromYardId: w.currentYardId ?? null,
|
|
toYardId: yard.id,
|
|
kind: WagonMovementKind.Manual,
|
|
movedByUserId: userId ?? null,
|
|
occurredAt: new Date(),
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
return this.getComposition(id);
|
|
}
|
|
|
|
/** Append AVAILABLE, unassigned wagons (any yard) to the consist. */
|
|
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
|
|
const pending = await this.dataSource.transaction(async (manager) => {
|
|
const train = await this.getEditableTrain(manager, id);
|
|
const currentCount = await manager
|
|
.getRepository(Wagon)
|
|
.count({ where: { trainId: train.id } });
|
|
const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount);
|
|
return this.syncLiveScheduleAfterConsistChange(
|
|
manager,
|
|
train.id,
|
|
attached.map((w) => ({ action: 'ADD' as const, wagonId: w.id, wagonNumber: w.wagonNumber })),
|
|
userId ?? null,
|
|
train.currentYardId ?? null,
|
|
);
|
|
});
|
|
await this.reconcileWindowAfterConsistChange(pending);
|
|
return this.getComposition(id);
|
|
}
|
|
|
|
/** Detach one wagon and close the sequence gap it leaves. */
|
|
async removeWagon(id: string, wagonId: string, userId?: string | null) {
|
|
const pending = await this.dataSource.transaction(async (manager) => {
|
|
const train = await this.getEditableTrain(manager, id);
|
|
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
|
if (!wagon || wagon.trainId !== train.id) {
|
|
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
|
}
|
|
await this.assertDetachableAndReleaseStaleSlots(manager, wagon);
|
|
await manager.getRepository(Wagon).update(wagon.id, {
|
|
trainId: null,
|
|
sequenceNumber: null,
|
|
status: WagonStatus.Available,
|
|
importTrainNumber: null,
|
|
exportTrainNumber: null,
|
|
});
|
|
await this.resequenceWagons(manager, train.id);
|
|
return this.syncLiveScheduleAfterConsistChange(
|
|
manager,
|
|
train.id,
|
|
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
|
userId ?? null,
|
|
wagon.currentYardId ?? train.currentYardId ?? null,
|
|
);
|
|
});
|
|
await this.reconcileWindowAfterConsistChange(pending);
|
|
return this.getComposition(id);
|
|
}
|
|
|
|
/**
|
|
* Detach one wagon AND flag it for maintenance: it leaves the consist and
|
|
* moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
|
|
* it clears maintenance. The freed sequence gap is closed.
|
|
*/
|
|
async sendWagonToMaintenance(
|
|
id: string,
|
|
wagonId: string,
|
|
userId?: string | null,
|
|
note?: string | null,
|
|
) {
|
|
const pending = await this.dataSource.transaction(async (manager) => {
|
|
const train = await this.getEditableTrain(manager, id);
|
|
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
|
if (!wagon || wagon.trainId !== train.id) {
|
|
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
|
}
|
|
await this.assertDetachableAndReleaseStaleSlots(manager, wagon);
|
|
const previousStatus = wagon.status;
|
|
const notes = buildMaintenanceNotes(formatTrainRunLabel(train), note);
|
|
await manager.getRepository(Wagon).update(wagon.id, {
|
|
trainId: null,
|
|
sequenceNumber: null,
|
|
status: WagonStatus.Maintenance,
|
|
importTrainNumber: null,
|
|
exportTrainNumber: null,
|
|
});
|
|
|
|
// Status-history row, same as the fleet desk's "Send to maintenance" —
|
|
// without it a maintenance detach made here is invisible in the wagon's
|
|
// status history. The train number is folded into the note so the history
|
|
// answers "which train did it come off, and why" in one line.
|
|
if (previousStatus !== WagonStatus.Maintenance) {
|
|
await manager.getRepository(WagonStatusLog).save(
|
|
manager.getRepository(WagonStatusLog).create({
|
|
wagonId: wagon.id,
|
|
fromStatus: previousStatus,
|
|
toStatus: WagonStatus.Maintenance,
|
|
changedByUserId: userId ?? null,
|
|
note: notes.statusLogNote,
|
|
}),
|
|
);
|
|
}
|
|
// Audit row: which train it came off and when. The wagon does not change
|
|
// yard here, so from/to are the same — the ledger is the wagon's history
|
|
// surface, and a maintenance detach has to be in it.
|
|
const yardId = wagon.currentYardId ?? train.currentYardId ?? null;
|
|
if (yardId) {
|
|
await manager.getRepository(WagonMovement).save(
|
|
manager.getRepository(WagonMovement).create({
|
|
wagonId: wagon.id,
|
|
fromYardId: yardId,
|
|
toYardId: yardId,
|
|
kind: WagonMovementKind.Maintenance,
|
|
note: notes.movementNote,
|
|
movedByUserId: userId,
|
|
occurredAt: new Date(),
|
|
}),
|
|
);
|
|
} else {
|
|
// to_yard_id is NOT NULL — a yard-less wagon still goes to maintenance,
|
|
// it just cannot carry a ledger row.
|
|
this.logger.warn(
|
|
`Wagon ${wagon.wagonNumber} sent to maintenance with no yard — ledger row skipped`,
|
|
);
|
|
}
|
|
await this.resequenceWagons(manager, train.id);
|
|
return this.syncLiveScheduleAfterConsistChange(
|
|
manager,
|
|
train.id,
|
|
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
|
userId ?? null,
|
|
yardId,
|
|
);
|
|
});
|
|
await this.reconcileWindowAfterConsistChange(pending);
|
|
return this.getComposition(id);
|
|
}
|
|
|
|
/**
|
|
* Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot),
|
|
* not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
|
|
* DISPATCHED) schedule has it pinned to one of its slots.
|
|
*/
|
|
private async isWagonPinnedToLiveSchedule(
|
|
manager: EntityManager,
|
|
wagonId: string,
|
|
): Promise<boolean> {
|
|
const rows: { exists: boolean }[] = await manager.query(
|
|
`SELECT TRUE AS exists
|
|
FROM freight.train_set_wagons tsw
|
|
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
|
WHERE tsw.physical_wagon_id = $1
|
|
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
|
AND ts.deleted_at IS NULL
|
|
AND tsw.deleted_at IS NULL
|
|
LIMIT 1`,
|
|
[wagonId],
|
|
);
|
|
return rows.length > 0;
|
|
}
|
|
|
|
/**
|
|
* Detach guard for removeWagon / sendWagonToMaintenance. A wagon is truly
|
|
* pinned only while a live schedule still NEEDS it: a slot carrying booking
|
|
* allocations, or any slot on a DISPATCHED run. An empty (allocation-free)
|
|
* slot on a DRAFT/SCHEDULED schedule is a stale reservation — its load was
|
|
* moved to another wagon (moveWagonLoad keeps the emptied slot) or its
|
|
* booking left through a path that didn't clean up — and used to pin the
|
|
* wagon forever. Release those slots here instead of blocking, with the
|
|
* same recount removeTrainSetWagonSlot does (wagonCount / totalLengthMeters
|
|
* feed the schedule capacity math).
|
|
*/
|
|
private async assertDetachableAndReleaseStaleSlots(
|
|
manager: EntityManager,
|
|
wagon: Wagon,
|
|
): Promise<void> {
|
|
const rows: { id: string; train_set_id: string; status: string; allocs: string }[] =
|
|
await manager.query(
|
|
`SELECT tsw.id, tsw.train_set_id, ts.status,
|
|
(SELECT count(*)
|
|
FROM freight.wagon_booking_allocations a
|
|
WHERE a.train_set_wagon_id = tsw.id
|
|
AND a.deleted_at IS NULL) AS allocs
|
|
FROM freight.train_set_wagons tsw
|
|
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
|
WHERE tsw.physical_wagon_id = $1
|
|
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
|
AND ts.deleted_at IS NULL
|
|
AND tsw.deleted_at IS NULL`,
|
|
[wagon.id],
|
|
);
|
|
if (!rows.length) return;
|
|
if (rows.some((r) => Number(r.allocs) > 0 || r.status === 'DISPATCHED')) {
|
|
throw new ConflictException(
|
|
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
|
);
|
|
}
|
|
await manager.getRepository(TrainSetWagon).delete(rows.map((r) => r.id));
|
|
for (const trainSetId of [...new Set(rows.map((r) => r.train_set_id))]) {
|
|
const remaining = await manager.getRepository(TrainSetWagon).find({
|
|
where: { trainSetId },
|
|
select: { id: true, lengthMeters: true },
|
|
});
|
|
await manager.getRepository(TrainSet).update(trainSetId, {
|
|
wagonCount: remaining.length,
|
|
totalLengthMeters: round(
|
|
remaining.reduce((sum, w) => sum + (Number(w.lengthMeters) || 0), 0),
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
|
|
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const train = await this.getEditableTrain(manager, id);
|
|
const wagons = await manager
|
|
.getRepository(Wagon)
|
|
.find({ where: { trainId: train.id } });
|
|
const current = new Set(wagons.map((w) => w.id));
|
|
const incoming = new Set(dto.wagonIds);
|
|
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
|
|
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
|
|
}
|
|
// Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder
|
|
// is allowed — the pinned schedules' consists are resequenced below so
|
|
// they can never desync from the built train's real order.
|
|
const dispatched: { exists: boolean }[] = await manager.query(
|
|
`SELECT TRUE AS exists
|
|
FROM freight.train_set_wagons tsw
|
|
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
|
WHERE tsw.physical_wagon_id = ANY($1::uuid[])
|
|
AND ts.status = 'DISPATCHED'
|
|
AND ts.deleted_at IS NULL
|
|
AND tsw.deleted_at IS NULL
|
|
LIMIT 1`,
|
|
[[...current]],
|
|
);
|
|
if (dispatched.length > 0) {
|
|
throw new ConflictException(
|
|
'This train is dispatched — wagons cannot be reordered while it is rolling.',
|
|
);
|
|
}
|
|
for (let i = 0; i < dto.wagonIds.length; i++) {
|
|
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
|
|
}
|
|
|
|
// Propagate the new order to every live (DRAFT/SCHEDULED) schedule of
|
|
// this train: slots pinned to a reordered wagon adopt the wagon's new
|
|
// position, unpinned slots trail in their old relative order. Allocations
|
|
// ride the slot row (by id), so cargo stays with its physical wagon.
|
|
const newSeq = new Map(dto.wagonIds.map((wid, i) => [wid, i + 1]));
|
|
const sets: { train_set_id: string }[] = await manager.query(
|
|
`SELECT DISTINCT ts.train_set_id
|
|
FROM freight.train_schedules ts
|
|
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
|
WHERE tset.train_id = $1
|
|
AND ts.deleted_at IS NULL
|
|
AND ts.status IN ('DRAFT', 'SCHEDULED')`,
|
|
[id],
|
|
);
|
|
for (const { train_set_id: trainSetId } of sets) {
|
|
const slots = await manager.getRepository(TrainSetWagon).find({
|
|
where: { trainSetId },
|
|
order: { sequenceNo: 'ASC' },
|
|
});
|
|
const sorted = orderSlotsByWagonSequence(slots, newSeq);
|
|
// (train_set_id, sequence_no) is unique — shift to a temp range first
|
|
// so the final renumbering can't collide mid-loop.
|
|
await manager.query(
|
|
`UPDATE freight.train_set_wagons
|
|
SET sequence_no = sequence_no + 100000
|
|
WHERE train_set_id = $1 AND deleted_at IS NULL`,
|
|
[trainSetId],
|
|
);
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
await manager
|
|
.getRepository(TrainSetWagon)
|
|
.update(sorted[i].id, { sequenceNo: i + 1 });
|
|
}
|
|
}
|
|
});
|
|
return this.getComposition(id);
|
|
}
|
|
|
|
/**
|
|
* Park the train indefinitely (status DEACTIVATED). Blocked while it still
|
|
* has a live (DRAFT/SCHEDULED/DISPATCHED) schedule. The consist stays
|
|
* coupled; like UNDER_MAINTENANCE / OUT_OF_SERVICE the flag is staff-owned —
|
|
* the scheduler never overwrites it and refuses the train for new schedules.
|
|
*/
|
|
async deactivate(id: string) {
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const train = await manager.getRepository(Train).findOne({ where: { id } });
|
|
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
|
if (train.status === Freight.TrainStatus.Deactivated) return;
|
|
const active: { count: string }[] = await manager.query(
|
|
`SELECT COUNT(*)::text AS count
|
|
FROM freight.train_schedules ts
|
|
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
|
WHERE tset.train_id = $1
|
|
AND ts.deleted_at IS NULL
|
|
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
|
|
[id],
|
|
);
|
|
if (Number(active[0]?.count ?? 0) > 0) {
|
|
throw new ConflictException(
|
|
'Train has active schedules; cancel them before deactivating the train',
|
|
);
|
|
}
|
|
await manager
|
|
.getRepository(Train)
|
|
.update(id, { status: Freight.TrainStatus.Deactivated });
|
|
});
|
|
return this.getComposition(id);
|
|
}
|
|
|
|
/**
|
|
* 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 });
|
|
}
|
|
return this.getComposition(id);
|
|
}
|
|
|
|
/** Disband the train: release wagons and locomotives, then delete it. */
|
|
async disband(id: string): Promise<void> {
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const train = await manager.getRepository(Train).findOne({ where: { id } });
|
|
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
|
const active: { count: string }[] = await manager.query(
|
|
`SELECT COUNT(*)::text AS count
|
|
FROM freight.train_schedules ts
|
|
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
|
WHERE tset.train_id = $1
|
|
AND ts.deleted_at IS NULL
|
|
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
|
|
[id],
|
|
);
|
|
if (Number(active[0]?.count ?? 0) > 0) {
|
|
throw new ConflictException(
|
|
'Train has active schedules; cancel them before disbanding the train',
|
|
);
|
|
}
|
|
await manager
|
|
.getRepository(Wagon)
|
|
.update(
|
|
{ trainId: train.id },
|
|
{
|
|
trainId: null,
|
|
sequenceNumber: null,
|
|
status: WagonStatus.Available,
|
|
importTrainNumber: null,
|
|
exportTrainNumber: null,
|
|
},
|
|
);
|
|
await manager.getRepository(TrainLocomotive).delete({ trainId: train.id });
|
|
await manager.getRepository(Train).remove(train);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------- internals
|
|
|
|
/**
|
|
* System-assigned train code `TR-NNNNN`. Draws the next number from the
|
|
* highest existing `TR-` code and probes past any manual collision so the
|
|
* unique constraint never rejects the build.
|
|
*/
|
|
private async generateTrainCode(manager: EntityManager): Promise<string> {
|
|
const [row]: { max_seq: string | null }[] = await manager.query(
|
|
`SELECT MAX(CAST(SUBSTRING(code FROM '^TR-([0-9]+)$') AS INTEGER)) AS max_seq
|
|
FROM freight.trains
|
|
WHERE code ~ '^TR-[0-9]+$'`,
|
|
);
|
|
let seq = Number(row?.max_seq ?? 0) + 1;
|
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
const code = `TR-${String(seq).padStart(5, '0')}`;
|
|
const exists = await manager
|
|
.getRepository(Train)
|
|
.findOne({ where: { code }, withDeleted: true });
|
|
if (!exists) return code;
|
|
seq += 1;
|
|
}
|
|
throw new ConflictException('Could not allocate a unique train code');
|
|
}
|
|
|
|
private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) {
|
|
const locomotives = [...(train.locomotives ?? [])]
|
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
|
.map((link) => link.locomotive)
|
|
.filter((loco): loco is Locomotive => Boolean(loco));
|
|
const wagons = train.wagons ?? [];
|
|
const totalTareTons = round(
|
|
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
|
|
);
|
|
return {
|
|
id: train.id,
|
|
code: train.code,
|
|
trainName: train.trainName ?? null,
|
|
status: train.status,
|
|
importTrainNumber: train.importTrainNumber ?? null,
|
|
exportTrainNumber: train.exportTrainNumber ?? null,
|
|
activeSchedule,
|
|
createdAt: train.createdAt,
|
|
currentYard: train.currentYard
|
|
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }
|
|
: null,
|
|
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
|
|
wagonCount: wagons.length,
|
|
totalTareTons,
|
|
totalLengthMeters: round(
|
|
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
|
),
|
|
// Derived live from the coupled set, NOT from the stored capacity_tons.
|
|
// That column is written at build/re-couple time, so every train built
|
|
// before pull weight became additive still holds the old single-locomotive
|
|
// figure. Computing it here keeps the board honest without a backfill;
|
|
// the column self-heals the next time the locomotive set is saved.
|
|
maxPullWeightTons: round(
|
|
combinedLocomotiveLimits(locomotives)?.maxPullWeightTons ??
|
|
Number(train.capacityTons) ??
|
|
0,
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Train Builder edits a train's physical consist directly on `Wagon.trainId`
|
|
* — it never touches `TrainSchedule.maxWagons` / `TrainSet.wagonCount`, so a
|
|
* wagon added/removed here (while the train already has a live DRAFT/
|
|
* SCHEDULED schedule) used to leave the schedule's capacity, history, and
|
|
* booking-window status silently stale. This mirrors what
|
|
* TrainSchedulingService.adjustScheduleConsist does when the SAME edit is
|
|
* made from the schedule's own consist editor, so both entry points agree.
|
|
*/
|
|
private async syncLiveScheduleAfterConsistChange(
|
|
manager: EntityManager,
|
|
trainId: string,
|
|
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
|
|
userId: string | null,
|
|
yardId: string | null,
|
|
): Promise<PendingWindowCheck | null> {
|
|
if (!changes.length) return null;
|
|
const trainSet = await manager
|
|
.getRepository(TrainSet)
|
|
.findOne({ where: { trainId }, order: { createdAt: 'DESC' } });
|
|
const schedule = trainSet
|
|
? await manager.getRepository(TrainSchedule).findOne({
|
|
where: { trainSetId: trainSet.id, status: In(['DRAFT', 'SCHEDULED']) },
|
|
})
|
|
: null;
|
|
|
|
const consist = await manager.getRepository(Wagon).find({
|
|
where: { trainId },
|
|
relations: { wagonType: true },
|
|
});
|
|
const wagonCount = consist.length;
|
|
const totalWeightTons = round(
|
|
consist.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ? Number(w.wagonType.tareWeightTons) : 0), 0),
|
|
);
|
|
const totalLengthMeters = round(
|
|
consist.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ? Number(w.wagonType.lengthMeters) : 0), 0),
|
|
);
|
|
if (trainSet) {
|
|
await manager
|
|
.getRepository(TrainSet)
|
|
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
|
|
}
|
|
// Log the consist change even when the train has no live schedule — the
|
|
// builder's own detach/attach is the train's history too (who removed
|
|
// which wagon, when, where), and the detached-wagons tab reads it back.
|
|
const now = new Date();
|
|
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
|
changes.map((c) =>
|
|
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
|
trainScheduleId: schedule?.id ?? null,
|
|
trainId,
|
|
action: c.action,
|
|
wagonId: c.wagonId,
|
|
wagonNumber: c.wagonNumber,
|
|
adjustedByUserId: userId,
|
|
yardId,
|
|
occurredAt: now,
|
|
}),
|
|
),
|
|
);
|
|
|
|
if (!schedule) return null;
|
|
|
|
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
|
|
|
|
// The FULL/reopen decision must run AFTER the transaction commits — see
|
|
// reconcileWindowAfterConsistChange.
|
|
return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' };
|
|
}
|
|
|
|
/**
|
|
* Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL
|
|
* schedule reopens its booking window; filling the last one closes it.
|
|
*
|
|
* Runs only once the consist transaction has COMMITTED. BookingBatchService
|
|
* reads through its own connection, so inside the transaction it still saw
|
|
* the old consist: a wagon coupled onto an empty (FULL) train counted as 0
|
|
* slots, `nowFull` stayed true and the window was never reopened.
|
|
*/
|
|
private async reconcileWindowAfterConsistChange(
|
|
pending: PendingWindowCheck | null,
|
|
): Promise<void> {
|
|
if (!pending) return;
|
|
const usage = await this.bookingBatchService.scheduleWagonUsage(pending.scheduleId);
|
|
if (!usage) return;
|
|
const nowFull = usage.remainingSlots <= 0;
|
|
if (pending.wasFull && !nowFull) {
|
|
await this.bookingBatchService.refreshWindowStatus(pending.scheduleId);
|
|
} else if (!pending.wasFull && nowFull) {
|
|
await this.bookingBatchService.setWindow(pending.scheduleId, 'FULL');
|
|
}
|
|
}
|
|
|
|
/** Load + freeze the train row for edit; block edits while it is out on a run. */
|
|
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
|
|
const train = await manager.getRepository(Train).findOne({
|
|
where: { id },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
|
if (train.status === Freight.TrainStatus.InService) {
|
|
throw new ConflictException(
|
|
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
|
|
);
|
|
}
|
|
return train;
|
|
}
|
|
|
|
/**
|
|
* Lock and validate the locomotives for a build/replace: each must exist, be
|
|
* serviceable, sit in the train's yard, and not ride another built train.
|
|
*/
|
|
private async validateAndLockLocomotives(
|
|
manager: EntityManager,
|
|
locomotiveIds: string[],
|
|
yard: Yard,
|
|
ownTrainId: string | null,
|
|
): Promise<Locomotive[]> {
|
|
const locomotives: Locomotive[] = [];
|
|
for (const locomotiveId of locomotiveIds) {
|
|
const locked = await manager.getRepository(Locomotive).findOne({
|
|
where: { id: locomotiveId },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
if (!locked) throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
|
if (locked.status === 'OUT_OF_SERVICE' || locked.status === 'MAINTENANCE') {
|
|
throw new ConflictException(`Locomotive ${locked.code} is ${locked.status.toLowerCase().replace('_', ' ')}`);
|
|
}
|
|
if (locked.currentYardId !== yard.id) {
|
|
throw new BadRequestException(
|
|
`Locomotive ${locked.code} is not in yard ${yard.label ?? yard.code}; a train can only be built from locomotives in its own yard`,
|
|
);
|
|
}
|
|
locomotives.push(locked);
|
|
}
|
|
|
|
const taken = await manager.getRepository(TrainLocomotive).find({
|
|
where: { locomotiveId: In(locomotiveIds) },
|
|
relations: { train: true },
|
|
});
|
|
const conflict = taken.find((link) => link.trainId !== ownTrainId);
|
|
if (conflict) {
|
|
const loco = locomotives.find((l) => l.id === conflict.locomotiveId);
|
|
throw new ConflictException(
|
|
`Locomotive ${loco?.code ?? conflict.locomotiveId} is already coupled to train ${conflict.train?.code ?? conflict.trainId}`,
|
|
);
|
|
}
|
|
return locomotives;
|
|
}
|
|
|
|
private async replaceLocomotiveLinks(
|
|
manager: EntityManager,
|
|
trainId: string,
|
|
locomotiveIds: string[],
|
|
): Promise<void> {
|
|
await manager.getRepository(TrainLocomotive).delete({ trainId });
|
|
await manager.getRepository(TrainLocomotive).save(
|
|
locomotiveIds.map((locomotiveId, index) =>
|
|
manager.getRepository(TrainLocomotive).create({ trainId, locomotiveId, sequenceNo: index }),
|
|
),
|
|
);
|
|
}
|
|
|
|
private async attachWagons(
|
|
manager: EntityManager,
|
|
train: Train,
|
|
wagonIds: string[],
|
|
startCount: number,
|
|
): Promise<Wagon[]> {
|
|
const uniqueIds = [...new Set(wagonIds)];
|
|
const wagonRepo = manager.getRepository(Wagon);
|
|
|
|
// First pass: lock + validate every wagon so the length gate below sees
|
|
// the full incoming set before any row is written.
|
|
const toAttach: Wagon[] = [];
|
|
for (const wagonId of uniqueIds) {
|
|
const wagon = await wagonRepo.findOne({
|
|
where: { id: wagonId },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
|
|
if (wagon.trainId === train.id) continue;
|
|
if (wagon.trainId) {
|
|
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`);
|
|
}
|
|
if (wagon.status !== WagonStatus.Available) {
|
|
throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
|
|
}
|
|
// Wagons may sit in any yard — the schedule's route must pass through
|
|
// every wagon yard before its destination (checked at scheduling time).
|
|
toAttach.push(wagon);
|
|
}
|
|
if (!toAttach.length) return [];
|
|
|
|
await this.assertConsistLengthWithinLimit(manager, train, toAttach);
|
|
|
|
let sequence = startCount;
|
|
for (const wagon of toAttach) {
|
|
sequence += 1;
|
|
await wagonRepo.update(wagon.id, {
|
|
trainId: train.id,
|
|
sequenceNumber: sequence,
|
|
status: WagonStatus.Assigned,
|
|
// Wagon inherits the train's run numbers on coupling — no per-wagon
|
|
// number entry, they ride whatever numbers the train was built with.
|
|
importTrainNumber: train.importTrainNumber,
|
|
exportTrainNumber: train.exportTrainNumber,
|
|
});
|
|
}
|
|
return toAttach;
|
|
}
|
|
|
|
/**
|
|
* The consist (already-attached wagons + the incoming ones) must fit the
|
|
* train's locomotive length limit — the weakest locomotive of the set caps
|
|
* the train, mirroring how scheduling derives capacity.
|
|
*/
|
|
private async assertConsistLengthWithinLimit(
|
|
manager: EntityManager,
|
|
train: Train,
|
|
incoming: Wagon[],
|
|
): Promise<void> {
|
|
const links = await manager.getRepository(TrainLocomotive).find({
|
|
where: { trainId: train.id },
|
|
relations: { locomotive: true },
|
|
});
|
|
const limits = combinedLocomotiveLimits(
|
|
links
|
|
.map((link) => link.locomotive)
|
|
.filter((loco): loco is Locomotive => Boolean(loco)),
|
|
);
|
|
const maxLengthMeters = Number(limits?.maxTrainLengthMeters ?? 0);
|
|
if (!Number.isFinite(maxLengthMeters) || maxLengthMeters <= 0) return;
|
|
|
|
const existing = await manager.getRepository(Wagon).find({
|
|
where: { trainId: train.id },
|
|
relations: { wagonType: true },
|
|
});
|
|
const currentLength = existing.reduce(
|
|
(sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0),
|
|
0,
|
|
);
|
|
|
|
const typeIds = [...new Set(incoming.map((w) => w.wagonTypeId).filter(Boolean))];
|
|
const types = typeIds.length
|
|
? await manager.getRepository(WagonType).find({ where: { id: In(typeIds) } })
|
|
: [];
|
|
const lengthByType = new Map(types.map((t) => [t.id, Number(t.lengthMeters) || 0]));
|
|
const addedLength = incoming.reduce(
|
|
(sum, w) => sum + (lengthByType.get(w.wagonTypeId) ?? 0),
|
|
0,
|
|
);
|
|
|
|
const totalLength = currentLength + addedLength;
|
|
if (totalLength > maxLengthMeters) {
|
|
throw new BadRequestException(
|
|
`Cannot attach wagons — train length would be ${round(totalLength)} m ` +
|
|
`(current ${round(currentLength)} m + ${round(addedLength)} m added), ` +
|
|
`over the locomotive limit of ${round(maxLengthMeters)} m. ` +
|
|
'Remove wagons from the consist or use locomotives with a higher length limit.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Compact wagon sequence numbers back to 1..n after a removal. */
|
|
private async resequenceWagons(manager: EntityManager, trainId: string): Promise<void> {
|
|
const wagons = await manager.getRepository(Wagon).find({
|
|
where: { trainId },
|
|
order: { sequenceNumber: 'ASC' },
|
|
});
|
|
for (let i = 0; i < wagons.length; i++) {
|
|
if (wagons[i].sequenceNumber !== i + 1) {
|
|
await manager.getRepository(Wagon).update(wagons[i].id, { sequenceNumber: i + 1 });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* New consist order for a schedule's slots after a built-train reorder: slots
|
|
* pinned to a reordered wagon adopt the wagon's new position; unpinned slots
|
|
* trail behind in their previous relative order.
|
|
*/
|
|
/**
|
|
* How a train is named in a wagon's history. Staff identify a train by its
|
|
* OPERATIONAL run numbers — the fixed export (odd) and import (even) numbers
|
|
* typed at build time — not by its internal code (`TRN-LEDGER-PW2`), which is a
|
|
* ledger key and means nothing on the ground. Both runs are shown when set,
|
|
* since one built train carries the pair. Falls back to the train number, then
|
|
* the code, only when no run number exists.
|
|
*/
|
|
export function formatTrainRunLabel(train: {
|
|
exportTrainNumber?: string | null;
|
|
importTrainNumber?: string | null;
|
|
trainNumber?: string | null;
|
|
code?: string | null;
|
|
}): string {
|
|
const exportNo = train.exportTrainNumber?.trim();
|
|
const importNo = train.importTrainNumber?.trim();
|
|
const runs = [
|
|
exportNo ? `export ${exportNo}` : null,
|
|
importNo ? `import ${importNo}` : null,
|
|
].filter(Boolean);
|
|
if (runs.length) return runs.join(' / ');
|
|
return train.trainNumber?.trim() || train.code?.trim() || 'unknown';
|
|
}
|
|
|
|
/**
|
|
* Notes for a maintenance detach. The train's run numbers are always recorded —
|
|
* staff need to know which consist a wagon came off — and the operator's reason
|
|
* is folded in when given, so the wagon's status history answers "which train,
|
|
* and why" in one line (matching the fleet desk's Send-to-maintenance note).
|
|
*/
|
|
export function buildMaintenanceNotes(trainLabel: string, note?: string | null) {
|
|
const reason = note?.trim();
|
|
return {
|
|
statusLogNote: reason
|
|
? `${reason} (detached from train ${trainLabel})`
|
|
: `Detached from train ${trainLabel}`,
|
|
movementNote: reason
|
|
? `Sent to maintenance from train ${trainLabel}: ${reason}`
|
|
: `Sent to maintenance from train ${trainLabel}`,
|
|
};
|
|
}
|
|
|
|
export function orderSlotsByWagonSequence<
|
|
T extends Pick<TrainSetWagon, 'sequenceNo' | 'physicalWagonId'>,
|
|
>(slots: T[], newSeq: Map<string, number>): T[] {
|
|
const key = (s: T): number =>
|
|
(s.physicalWagonId ? newSeq.get(s.physicalWagonId) : undefined) ?? Infinity;
|
|
return [...slots].sort((a, b) => {
|
|
const sa = key(a);
|
|
const sb = key(b);
|
|
return sa !== sb ? sa - sb : a.sequenceNo - b.sequenceNo;
|
|
});
|
|
}
|