Merge pull request #1399 from Tria-plc/freight_feature/usermanagement

feat(train-scheduling): mid-route consist changes, audit history, saf…
This commit is contained in:
marshal
2026-08-23 06:59:27 +03:00
committed by GitHub
34 changed files with 2532 additions and 202 deletions

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-schedule consist-change plan, executed automatically as the trip
* proceeds (dispatch / checkpoint logs):
*
* - `planned_wagon_couples` `{ wagonId: pickupYardId }` — LOOSE wagons this
* departure couples onto the train at a route stop. They join the built
* train permanently when the train reaches that stop.
* - `planned_wagon_real_cuts` `[wagonId, ...]` — cut wagons (see
* planned_wagon_cut_yards) flagged as REAL cuts: the built train
* permanently loses the wagon at its cut yard, instead of the default
* soft cut where it stays in the build and only sits out this trip.
*/
export class SchedulePlannedWagonCouples3660000000000 implements MigrationInterface {
name = 'SchedulePlannedWagonCouples3660000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS planned_wagon_couples jsonb
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS planned_wagon_real_cuts jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_couples
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_real_cuts
`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* A consist adjustment made from the TRAIN BUILDER on a train with no live
* schedule still belongs in the wagon adjustment history — it just has no
* schedule to point at. Relax the NOT NULL so builder detaches/attaches can
* be recorded; every existing reader filters BY train_schedule_id or
* train_id, so nullable rows are invisible to them.
*/
export class AdjustmentLogNullableSchedule3670000000000 implements MigrationInterface {
name = 'AdjustmentLogNullableSchedule3670000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.schedule_wagon_adjustment_logs
ALTER COLUMN train_schedule_id DROP NOT NULL
`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// No-op: restoring NOT NULL would fail on any builder-origin rows written
// while this migration was live, re-introducing the outage it fixed.
}
}

View File

@@ -698,8 +698,8 @@ export class BookingWagonCancellationService {
booking,
whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed',
whole
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`,
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`,
);
}
this.logger.log(
@@ -759,16 +759,6 @@ export class BookingWagonCancellationService {
if (!source.contractId) {
throw new BadRequestException('The original booking has no contract to rebook under.');
}
// Friendly pre-check; createUnderContract re-asserts inside its own guards.
if (
source.contractValidUntil &&
new Date(source.contractValidUntil).getTime() < Date.now()
) {
throw new BadRequestException(
'Contract validity has expired — ask EDR staff to extend the contract before rebooking.',
);
}
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
// Same currency as the source booking — the credit is in it.
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
@@ -779,6 +769,9 @@ export class BookingWagonCancellationService {
// System actor: carries the create-booking key so the GL gate passes on
// Path B (customs-clearance) contracts; harmless on Path A.
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
// The freight was paid while the contract was live — the credit stays
// redeemable even after the contract's validity lapses.
{ allowExpiredContract: true },
);
const newBookingId = created.booking.id;

View File

@@ -657,16 +657,18 @@ export class BookingsController {
}
@Post('wagon-cancellations/:cancellationId/withdraw')
@ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' })
@ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation — STAFF ONLY (void permission). A customer cancellation is final; only an admin can revert it.' })
async withdrawWagonCancellation(
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
);
// Customer cancellations are irreversible from the portal — no owner
// fallback here. Only staff holding the void permission can revert one.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationVoid)) {
throw new ForbiddenException(
'A cancellation request cannot be withdrawn from the portal — contact EDR staff.',
);
}
return this.wagonCancellationService.withdraw(cancellationId);
}

View File

@@ -140,6 +140,14 @@ export class ContractBookingService {
dto: CreateBookingUnderContractDto,
user?: { id?: string } | null,
actorPermissions?: unknown,
opts?: {
/**
* Wagon-cancellation credit rebook only: the freight was paid while the
* contract was live, so redeeming the credit is allowed even after the
* contract's validity lapsed. Never set for a genuinely new booking.
*/
allowExpiredContract?: boolean;
},
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
@@ -180,8 +188,13 @@ export class ContractBookingService {
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(contract, isGlActor);
if (!opts?.allowExpiredContract) await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(
contract,
isGlActor,
false,
opts?.allowExpiredContract,
);
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
// booking reached a terminal state (e.g. payment expired without shipping),
@@ -1203,6 +1216,7 @@ export class ContractBookingService {
contract: Contract,
isGlActor: boolean,
isInitiate = false,
allowExpired = false,
): Promise<string> {
// Suspended contracts are frozen for everyone, GL included — say so instead
// of letting the executed-status check below give a misleading reason.
@@ -1225,7 +1239,10 @@ export class ContractBookingService {
}
// No contract clearance cycle exists on either kind now — clearance runs
// on the booking, so an executed/active contract is the only gate here.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
if (
!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) &&
!(allowExpired && contract.status === 'EXPIRED')
) {
throw new BadRequestException(
'Contract must be fully executed before booking a shipment.',
);
@@ -1234,7 +1251,10 @@ export class ContractBookingService {
}
// Path A — customer (or staff) once the contract is executed.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
if (
!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) &&
!(allowExpired && contract.status === 'EXPIRED')
) {
throw new BadRequestException(
'Contract must be fully executed before booking a shipment.',
);

View File

@@ -0,0 +1,65 @@
import { ContractBookingService } from './contract-booking.service';
/**
* Wagon-cancellation credit rebook must work after the contract lapses (the
* freight was paid while it was live), while every other create path stays
* blocked. assertGate is the status gate createUnderContract runs; this pins
* the EXPIRED carve-out to the allowExpired flag.
*/
describe('ContractBookingService.assertGate expired-contract rebook carve-out', () => {
// assertGate only reads contract fields — no constructor deps needed.
const service = Object.create(
ContractBookingService.prototype,
) as ContractBookingService;
const gate = (
contract: Record<string, unknown>,
allowExpired: boolean,
): Promise<string> =>
(
service as unknown as {
assertGate: (
c: unknown,
gl: boolean,
init: boolean,
allowExpired: boolean,
) => Promise<string>;
}
).assertGate(contract, true, false, allowExpired);
it('refuses an EXPIRED contract on the normal create path', async () => {
await expect(
gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, false),
).rejects.toThrow(/fully executed/i);
});
it('lets a credit rebook through on an EXPIRED contract (Path A)', async () => {
await expect(
gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, true),
).resolves.toBe('STAFF');
});
it('lets a credit rebook through on an EXPIRED customs contract (Path B)', async () => {
await expect(
gate(
{
status: 'EXPIRED',
contractKind: 'GENERAL',
customsClearingEnabled: true,
},
true,
),
).resolves.toBe('GL_ET');
});
it('still refuses a SUSPENDED contract even for a rebook', async () => {
await expect(
gate({ status: 'SUSPENDED', contractKind: 'GENERAL' }, true),
).rejects.toThrow(/suspended/i);
});
it('does not open the gate for other non-executed statuses', async () => {
await expect(
gate({ status: 'DRAFT', contractKind: 'GENERAL' }, true),
).rejects.toThrow(/fully executed/i);
});
});

View File

@@ -17,8 +17,9 @@ export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
@Index(['trainScheduleId'])
@Index(['trainId'])
export class ScheduleWagonAdjustmentLog extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
/** Null when the change was made from the train builder with no live schedule. */
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId!: string | null;
@Column({ name: 'train_id', type: 'uuid' })
trainId!: string;

View File

@@ -139,6 +139,24 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'planned_wagon_cut_yards', type: 'jsonb', nullable: true })
plannedWagonCutYards?: Record<string, string> | null;
/**
* LOOSE wagons this departure plans to COUPLE onto the train at a route
* stop: `{ wagonId: pickupYardId }`. They join the built train permanently
* when the trip reaches that stop (dispatch for the origin, checkpoint log
* for mid-route stops).
*/
@Column({ name: 'planned_wagon_couples', type: 'jsonb', nullable: true })
plannedWagonCouples?: Record<string, string> | null;
/**
* Cut wagons (see plannedWagonCutYards) flagged as REAL cuts: the built
* train permanently loses the wagon at its cut yard. Absent from this list,
* a cut is soft — the wagon sits out the rest of this trip but stays in
* the build.
*/
@Column({ name: 'planned_wagon_real_cuts', type: 'jsonb', nullable: true })
plannedWagonRealCuts?: string[] | null;
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
bookingWindowStatus!: string;

View File

@@ -100,6 +100,7 @@ import {
CorridorBudget,
CorridorLeg,
OverageTolerance,
addCoupledWagons,
stopYardsFor,
subtractCutWagons,
} from './corridor-capacity.util';
@@ -5064,6 +5065,8 @@ export class BookingBatchService implements OnModuleInit {
stock.byYardId,
budget.stops,
);
// Wagons staff cut mid-route are not stock past their cut stop.
ledger.debitCutWagons(stock.cutWagons ?? []);
// Debit what is already committed, per boarding yard and wagon type — the
// same bookings the corridor budget subtracted. A booking with no resolvable
// wagon type still occupies steel, so it drains any type at its yard.
@@ -5385,6 +5388,8 @@ export class BookingBatchService implements OnModuleInit {
// ponytail: the wagon-type stock ledger stays cut-blind; bucket
// builtTrainStock by (yard, reach) if mixed-type cut trains appear.
subtractCutWagons(budget, schedule.plannedWagonCutYards);
// Planned couples add a slot from their couple stop onward.
addCoupledWagons(budget, schedule.plannedWagonCouples);
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
budget.subtract(
this.needFor(b, wagonDims),

View File

@@ -224,7 +224,7 @@ export class TrainSchedulingController {
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleWagonYardsDto,
) {
return this.trainSchedulingService.updateScheduleWagonYards(id, dto.moves);
return this.trainSchedulingService.updateScheduleWagonYards(id, dto);
}
@Post("schedules/:id/adjust-consist")

View File

@@ -1,4 +1,5 @@
import {
addCoupledWagons,
Capacity,
CorridorBudget,
orientStopsToSchedule,
@@ -220,3 +221,39 @@ describe('corridor-capacity.util — stop orientation and fallback', () => {
expect(stopYardsFor(null, 'a', 'c')).toEqual(['a', 'c']);
});
});
describe('corridor-capacity.util — addCoupledWagons', () => {
const stops = ['a', 'b', 'c', 'd'];
const wagonsOnly: Capacity = {
wagons: 10,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
};
const remaining = (budget: CorridorBudget, from: string, to: string): number =>
budget.remainingFor(budget.legOf(from, to)!).wagons;
it('credits every edge at/after the couple stop', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
addCoupledWagons(budget, { 'w-1': 'a', 'w-2': 'c' });
expect(remaining(budget, 'a', 'b')).toBe(11); // origin couple rides everything
expect(remaining(budget, 'b', 'c')).toBe(11);
expect(remaining(budget, 'c', 'd')).toBe(12); // + the c-coupled wagon
});
it('nets against cuts on the same budget', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
subtractCutWagons(budget, { 'w-cut': 'c' });
addCoupledWagons(budget, { 'w-new': 'c' });
expect(remaining(budget, 'a', 'c')).toBe(10);
expect(remaining(budget, 'c', 'd')).toBe(10); // cut 1, couple +1
expect(remaining(budget, 'a', 'd')).toBe(10);
});
it('ignores off-corridor and destination couple yards, and a missing plan', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
addCoupledWagons(budget, { 'w-1': 'elsewhere', 'w-2': 'd' });
addCoupledWagons(budget, null);
addCoupledWagons(budget, undefined);
expect(remaining(budget, 'a', 'd')).toBe(10);
});
});

View File

@@ -124,6 +124,25 @@ export function subtractCutWagons(
}
}
/**
* Credit the corridor for LOOSE wagons the schedule plans to COUPLE onto the
* train mid-route: each coupled wagon adds a slot on every edge at/after its
* couple stop ([couple, destination)). A couple yard not on the corridor —
* or equal to the destination — is ignored; updateScheduleWagonYards owns
* rejecting it.
*/
export function addCoupledWagons(
budget: CorridorBudget,
couplePlan: Record<string, string> | null | undefined,
): void {
if (!couplePlan) return;
const destination = budget.stops[budget.stops.length - 1];
for (const coupleYardId of Object.values(couplePlan)) {
const leg = budget.legOf(coupleYardId, destination);
if (leg) budget.add({ wagons: 1, weightTons: 0, lengthMeters: 0 }, leg);
}
}
/** Overage a locomotive may absorb beyond its base caps. */
export interface OverageTolerance {
weightTons: number;

View File

@@ -1,6 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsOptional, IsUUID, ValidateIf, ValidateNested } from 'class-validator';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsOptional,
IsUUID,
ValidateIf,
ValidateNested,
} from 'class-validator';
export class ScheduleWagonYardMoveDto {
@ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." })
@@ -25,6 +33,27 @@ export class ScheduleWagonYardMoveDto {
@ValidateIf((o: ScheduleWagonYardMoveDto) => o.cutYardId !== null)
@IsUUID()
cutYardId?: string | null;
@ApiPropertyOptional({
description:
'true: REAL cut — the built train permanently loses the wagon at its cut yard. false: soft cut (default) — the wagon sits out this trip but stays in the build. Requires a cut yard.',
})
@IsOptional()
@IsBoolean()
realCut?: boolean;
}
export class ScheduleWagonCoupleDto {
@ApiProperty({ format: 'uuid', description: 'Loose wagon (no built train) to couple.' })
@IsUUID()
wagonId!: string;
@ApiProperty({
format: 'uuid',
description: 'Pickup stop the wagon joins the train at. It must physically stand there.',
})
@IsUUID()
yardId!: string;
}
export class UpdateScheduleWagonYardsDto {
@@ -33,9 +62,32 @@ export class UpdateScheduleWagonYardsDto {
description:
'Wagon → planned boarding yard for THIS schedule only. Physical wagon yards are untouched; dispatch requires both to agree.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => ScheduleWagonYardMoveDto)
moves!: ScheduleWagonYardMoveDto[];
moves?: ScheduleWagonYardMoveDto[];
@ApiPropertyOptional({
type: [ScheduleWagonCoupleDto],
description:
'Loose wagons to plan-couple onto the train at a pickup stop. They join the built train permanently when the trip reaches that stop.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => ScheduleWagonCoupleDto)
couple?: ScheduleWagonCoupleDto[];
@ApiPropertyOptional({
type: [String],
description: 'Wagon ids to remove from the couple plan (before execution).',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@IsUUID('all', { each: true })
uncouple?: string[];
}

View File

@@ -0,0 +1,59 @@
import { computeEdgeLoads } from './edge-load.util';
describe('edge-load.util — computeEdgeLoads', () => {
// gmp -> lebu -> mojo -> adama -> dct: 4 edges.
const EDGES = 4;
const wagon = (fromEdge: number, toEdge: number) => ({
fromEdge,
toEdge,
tareTons: 25,
lengthMeters: 17,
});
it('an uncut whole-route consist loads every edge flat', () => {
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(0, 4)], []);
for (const e of loads) {
expect(e.weightTons).toBe(50);
expect(e.lengthMeters).toBe(34);
}
});
it('a cut frees tare and length on the edges past the cut', () => {
// One wagon cut at mojo (edge index 2): rides edges 0-1 only.
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(0, 2)], []);
expect(loads[1]).toEqual({ weightTons: 50, lengthMeters: 34 });
expect(loads[2]).toEqual({ weightTons: 25, lengthMeters: 17 });
expect(loads[3]).toEqual({ weightTons: 25, lengthMeters: 17 });
});
it('a couple adds tare and length only from its couple stop', () => {
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(2, 4)], []);
expect(loads[1]).toEqual({ weightTons: 25, lengthMeters: 17 });
expect(loads[2]).toEqual({ weightTons: 50, lengthMeters: 34 });
});
it('cut-then-couple at the same stop nets to a flat load', () => {
const loads = computeEdgeLoads(EDGES, [wagon(0, 2), wagon(2, 4)], []);
for (const e of loads) {
expect(e.weightTons).toBe(25);
expect(e.lengthMeters).toBe(17);
}
});
it('cargo weighs only the edges of its own leg', () => {
const loads = computeEdgeLoads(
EDGES,
[wagon(0, 4)],
[{ fromEdge: 1, toEdge: 3, weightTons: 60 }],
);
expect(loads[0].weightTons).toBe(25);
expect(loads[1].weightTons).toBe(85);
expect(loads[2].weightTons).toBe(85);
expect(loads[3].weightTons).toBe(25);
});
it('clamps out-of-range spans instead of throwing', () => {
const loads = computeEdgeLoads(EDGES, [wagon(-2, 99)], []);
for (const e of loads) expect(e.weightTons).toBe(25);
});
});

View File

@@ -0,0 +1,50 @@
/**
* Per-corridor-edge physical load of a train: tare + length of the wagons
* spanning each edge, plus the cargo weight riding it. Used to validate that
* a planned mid-route COUPLE keeps every leg within the locomotives' pull
* weight and train length limits — a wagon cut at Mojo frees its tare/length
* on the edges past Mojo, a wagon coupled there adds its own only from there.
*/
export interface EdgeLoad {
weightTons: number;
lengthMeters: number;
}
export interface EdgeWagonSpan {
/** Half-open edge span [fromEdge, toEdge) the wagon physically rides. */
fromEdge: number;
toEdge: number;
tareTons: number;
lengthMeters: number;
}
export interface EdgeCargoLeg {
fromEdge: number;
toEdge: number;
weightTons: number;
}
export function computeEdgeLoads(
edgeCount: number,
wagonSpans: readonly EdgeWagonSpan[],
cargoLegs: readonly EdgeCargoLeg[],
): EdgeLoad[] {
const loads: EdgeLoad[] = Array.from({ length: Math.max(1, edgeCount) }, () => ({
weightTons: 0,
lengthMeters: 0,
}));
const clamp = (edge: number) => Math.min(Math.max(edge, 0), loads.length);
for (const span of wagonSpans) {
for (let e = clamp(span.fromEdge); e < clamp(span.toEdge); e += 1) {
loads[e].weightTons += span.tareTons;
loads[e].lengthMeters += span.lengthMeters;
}
}
for (const cargo of cargoLegs) {
for (let e = clamp(cargo.fromEdge); e < clamp(cargo.toEdge); e += 1) {
loads[e].weightTons += cargo.weightTons;
}
}
return loads;
}

View File

@@ -137,11 +137,13 @@ import {
type WagonPlanSlot,
} from '../utils/wagon-plan.util';
import {
addCoupledWagons,
CorridorBudget,
orientStopsToSchedule,
subtractCutWagons,
} from '../corridor-capacity.util';
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
import { computeEdgeLoads } from '../edge-load.util';
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
import {
defaultPlannedWagonYards,
@@ -2199,7 +2201,16 @@ export class TrainSchedulingService {
// so their tare rides on top of the binding edge.
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
const scheduleStops = await this.stopYardsForSchedule(schedule);
const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops);
// Same leg map the validator used: cargo weighs only the edges its booking
// rides, so a Dire Dawa boarder never inflates the Djibouti leg.
const commitLegByBookingId = new Map(
bookings.flatMap((b) => {
const from = scheduleStops.indexOf(b.originYardId);
const to = scheduleStops.indexOf(b.destinationYardId);
return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
}),
);
const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops, commitLegByBookingId);
const stopLabels = await this.yardLabelMap(scheduleStops);
// Each edge is its own consist — name EVERY leg that breaks the limit,
// not just the heaviest figure, so staff see where along A→…→E it fails.
@@ -2902,6 +2913,56 @@ export class TrainSchedulingService {
{ status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId },
);
}
// Planned couples boarding at the ORIGIN join the built train now — the
// departure is the moment they are physically hooked on. Mid-route
// couples join at their stop's checkpoint log instead.
const dispatchTrainId = schedule.trainSet?.trainId ?? null;
const originCouples = Object.entries(schedule.plannedWagonCouples ?? {}).filter(
([, yardId]) => yardId === schedule.originStationId,
);
if (originCouples.length && dispatchTrainId) {
const consist = await manager.getRepository(Wagon).find({
where: { trainId: dispatchTrainId },
select: { id: true, sequenceNumber: true },
});
let maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0);
for (const [coupleWagonId, coupleYardId] of originCouples) {
const wagon = await manager
.getRepository(Wagon)
.findOne({ where: { id: coupleWagonId }, lock: { mode: 'pessimistic_write' } });
if (!wagon) continue;
if (wagon.trainId === dispatchTrainId) continue; // already joined — self-heal
if (
wagon.trainId ||
wagon.status !== WagonStatus.Available ||
wagon.currentTrainScheduleId ||
wagon.currentYardId !== coupleYardId
) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is planned to couple at dispatch but is no longer free at the origin yard — remove the couple in the Schedule yards tab or free the wagon`,
);
}
maxSeq += 1;
await manager.getRepository(Wagon).update(wagon.id, {
trainId: dispatchTrainId,
sequenceNumber: maxSeq,
status: WagonStatus.Assigned,
currentTrainScheduleId: scheduleId,
});
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: scheduleId,
trainId: dispatchTrainId,
action: 'ADD',
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
adjustedByUserId: null,
yardId: coupleYardId,
occurredAt: now,
}),
);
}
}
for (const sb of schedule.scheduleBookings ?? []) {
await this.bookingsRepository.updateSchedulingFields(
sb.bookingId,
@@ -4407,19 +4468,57 @@ export class TrainSchedulingService {
// bound to the schedule is riding empty. Matching against ALL passed
// yards, not just this one, self-heals skipped checkpoint logs.
const cutPlan = schedule.plannedWagonCutYards ?? {};
const realCutIds = new Set(schedule.plannedWagonRealCuts ?? []);
const builtTrainId = schedule.trainSet?.trainId ?? null;
const cutNow = Object.entries(cutPlan).filter(([, yardId]) =>
passedYardIds.includes(yardId),
);
let realCutHappened = false;
for (const [wagonId, cutYardId] of cutNow) {
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
// Already settled earlier (or re-pinned elsewhere) — not ours to move.
if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue;
await manager.getRepository(Wagon).update(wagonId, {
currentYardId: cutYardId,
currentTrainScheduleId: null,
trainSetWagonId: null,
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
});
if (realCutIds.has(wagonId) && builtTrainId) {
// REAL cut: the built train permanently loses the wagon here.
await manager.getRepository(Wagon).update(wagonId, {
currentYardId: cutYardId,
currentTrainScheduleId: null,
trainSetWagonId: null,
trainId: null,
sequenceNumber: null,
importTrainNumber: null,
exportTrainNumber: null,
status: WagonStatus.Available,
});
// Any slot of this train's schedules still pinned to it is stale.
await manager.query(
`UPDATE freight.train_set_wagons SET physical_wagon_id = NULL
WHERE physical_wagon_id = $1
AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`,
[wagonId, builtTrainId],
);
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: scheduleId,
trainId: builtTrainId,
action: 'REMOVE',
wagonId,
wagonNumber: wagon.wagonNumber,
adjustedByUserId: null,
yardId: cutYardId,
occurredAt,
}),
);
realCutHappened = true;
} else {
// Soft cut: sits out the rest of this trip, stays in the build.
await manager.getRepository(Wagon).update(wagonId, {
currentYardId: cutYardId,
currentTrainScheduleId: null,
trainSetWagonId: null,
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
});
}
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId,
@@ -4431,6 +4530,65 @@ export class TrainSchedulingService {
}),
);
}
// Keep the coupling order gapless after permanent removals.
if (realCutHappened && builtTrainId) {
const remaining = await manager.getRepository(Wagon).find({
where: { trainId: builtTrainId },
order: { sequenceNumber: 'ASC' },
});
for (const [i, w] of remaining.entries()) {
if (w.sequenceNumber !== i + 1) {
await manager.getRepository(Wagon).update(w.id, { sequenceNumber: i + 1 });
}
}
}
// Planned COUPLES standing at a passed stop join the train here —
// before the position fix below, so they ride it from this checkpoint
// on. Unavailable wagons are skipped silently (a checkpoint log must
// never fail on a missing planned couple); passedYardIds self-heals
// skipped logs, and an already-joined wagon has trainId set.
const couplePlan = schedule.plannedWagonCouples ?? {};
const coupleNow = Object.entries(couplePlan).filter(([, yardId]) =>
passedYardIds.includes(yardId),
);
if (coupleNow.length && builtTrainId) {
const consist = await manager.getRepository(Wagon).find({
where: { trainId: builtTrainId },
select: { id: true, sequenceNumber: true },
});
let maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0);
for (const [wagonId, coupleYardId] of coupleNow) {
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (
!wagon ||
wagon.trainId ||
wagon.status !== WagonStatus.Available ||
wagon.currentTrainScheduleId ||
wagon.currentYardId !== coupleYardId
) {
continue;
}
maxSeq += 1;
await manager.getRepository(Wagon).update(wagonId, {
trainId: builtTrainId,
sequenceNumber: maxSeq,
status: WagonStatus.Assigned,
currentTrainScheduleId: scheduleId,
});
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: scheduleId,
trainId: builtTrainId,
action: 'ADD',
wagonId,
wagonNumber: wagon.wagonNumber,
adjustedByUserId: null,
yardId: coupleYardId,
occurredAt,
}),
);
}
}
await manager
.getRepository(Wagon)
.createQueryBuilder()
@@ -4657,14 +4815,52 @@ export class TrainSchedulingService {
schedule.plannedWagonCutYards?.[wagon.id] ??
slot.alightYardId ??
schedule.destinationStationId;
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
// A wagon that belongs to a built train stays coupled to it (ASSIGNED);
// only loose wagons return to the open AVAILABLE pool.
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
currentYardId: settleYardId,
});
const ownerTrainId = wagon.trainId;
const isRealCut =
(schedule.plannedWagonRealCuts ?? []).includes(wagon.id) &&
schedule.plannedWagonCutYards?.[wagon.id] != null;
if (isRealCut && ownerTrainId) {
// Arrival fallback for a journey logged without mid-route
// checkpoints: the REAL cut still permanently removes the wagon
// from the built train at its cut yard.
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
trainId: null,
sequenceNumber: null,
importTrainNumber: null,
exportTrainNumber: null,
status: WagonStatus.Available,
currentYardId: settleYardId,
});
await manager.query(
`UPDATE freight.train_set_wagons SET physical_wagon_id = NULL
WHERE physical_wagon_id = $1
AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`,
[wagon.id, ownerTrainId],
);
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: scheduleId,
trainId: ownerTrainId,
action: 'REMOVE',
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
adjustedByUserId: null,
yardId: settleYardId,
occurredAt: now,
}),
);
} else {
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
// A wagon that belongs to a built train stays coupled to it (ASSIGNED);
// only loose wagons return to the open AVAILABLE pool.
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
currentYardId: settleYardId,
});
}
// Ledger: the wagon rode this schedule to its settle yard.
const slotAllocations = slot.allocations ?? [];
await manager.getRepository(WagonMovement).save(
@@ -4682,6 +4878,78 @@ export class TrainSchedulingService {
);
}
// Planned couples: settle any that joined mid-route but have no pinned
// slot (the loop above never visits them), and — arrival fallback —
// join ones the checkpoint logs skipped: the train passed every stop,
// so a still-loose planned couple physically rode along.
const arrivalCouplePlan = schedule.plannedWagonCouples ?? {};
const arrivalTrainId = schedule.trainSet?.trainId ?? null;
for (const [coupleWagonId, coupleYardId] of Object.entries(arrivalCouplePlan)) {
const wagon = await manager
.getRepository(Wagon)
.findOne({ where: { id: coupleWagonId } });
if (!wagon) continue;
if (wagon.currentTrainScheduleId === scheduleId) {
// Joined during the trip, slot-less: settle at the destination.
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
currentYardId: schedule.destinationStationId,
});
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId: wagon.id,
fromYardId: coupleYardId,
toYardId: schedule.destinationStationId,
trainScheduleId: scheduleId,
kind: WagonMovementKind.EmptyReposition,
occurredAt: now,
}),
);
} else if (
arrivalTrainId &&
!wagon.trainId &&
wagon.status === WagonStatus.Available &&
!wagon.currentTrainScheduleId &&
wagon.currentYardId === coupleYardId
) {
const consist = await manager.getRepository(Wagon).find({
where: { trainId: arrivalTrainId },
select: { id: true, sequenceNumber: true },
});
const maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0);
await manager.getRepository(Wagon).update(wagon.id, {
trainId: arrivalTrainId,
sequenceNumber: maxSeq + 1,
status: WagonStatus.Assigned,
currentYardId: schedule.destinationStationId,
});
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: scheduleId,
trainId: arrivalTrainId,
action: 'ADD',
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
adjustedByUserId: null,
yardId: coupleYardId,
occurredAt: now,
}),
);
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId: wagon.id,
fromYardId: coupleYardId,
toYardId: schedule.destinationStationId,
trainScheduleId: scheduleId,
kind: WagonMovementKind.EmptyReposition,
occurredAt: now,
}),
);
}
}
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
const stations = await this.buildScheduleStations(schedule);
const finalStation = stations[stations.length - 1];
@@ -5177,7 +5445,7 @@ export class TrainSchedulingService {
// above — the whole-route totals here are informational (summary) only. The
// locomotive checks below also compare per edge: a train is never heavier
// than its heaviest leg, so disjoint legs must not be summed.
const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops);
const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops, legByBookingId);
const maxEdgeGrossTons = roundTons(
Math.max(0, ...perEdgeUsage.map((e) => e.grossWeightTons)),
);
@@ -5466,6 +5734,32 @@ export class TrainSchedulingService {
return rows[0]?.planned_wagon_yards ?? {};
}
/** `{ wagonId: yardId }` this schedule cuts each wagon at; `{}` when unset. */
private async plannedWagonCutYardsOf(
scheduleId: string | undefined,
): Promise<Record<string, string>> {
if (!scheduleId) return {};
const rows: { planned_wagon_cut_yards: Record<string, string> | null }[] =
await this.dataSource.query(
`SELECT planned_wagon_cut_yards FROM freight.train_schedules WHERE id = $1`,
[scheduleId],
);
return rows[0]?.planned_wagon_cut_yards ?? {};
}
/** `{ wagonId: pickupYardId }` of loose wagons this schedule plans to couple; `{}` when unset. */
private async plannedWagonCouplesOf(
scheduleId: string | undefined,
): Promise<Record<string, string>> {
if (!scheduleId) return {};
const rows: { planned_wagon_couples: Record<string, string> | null }[] =
await this.dataSource.query(
`SELECT planned_wagon_couples FROM freight.train_schedules WHERE id = $1`,
[scheduleId],
);
return rows[0]?.planned_wagon_couples ?? {};
}
private async countFleetAvailability(
originYardId: string,
targetScheduleId?: string,
@@ -5673,8 +5967,12 @@ export class TrainSchedulingService {
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
const plannedYards = pinSchedule?.plannedWagonYards ?? {};
const couplePlan = pinSchedule?.plannedWagonCouples ?? {};
// Couples ride into the yard plan as boarding entries: a slot boarding at
// the couple yard may pin the (still loose) planned couple wagon.
const plannedYards = { ...(pinSchedule?.plannedWagonYards ?? {}), ...couplePlan };
const cutPlan = pinSchedule?.plannedWagonCutYards ?? {};
const coupleIds = new Set(Object.keys(couplePlan));
const unpinnable = this.findUnpinnableWagonSlots(
planSlots,
@@ -5686,6 +5984,7 @@ export class TrainSchedulingService {
stops,
plannedYards,
cutPlan,
coupleIds,
);
if (unpinnable.length) {
throw new BadRequestException({
@@ -5710,6 +6009,7 @@ export class TrainSchedulingService {
plannedYards,
cutPlan,
stops,
coupleIds,
);
if (!physical) continue;
@@ -5759,8 +6059,13 @@ export class TrainSchedulingService {
builtTrainId,
pinnedToScheduleIds,
stops,
targetSchedule?.plannedWagonYards ?? {},
// Couples count as boarding entries at their couple yard.
{
...(targetSchedule?.plannedWagonYards ?? {}),
...(targetSchedule?.plannedWagonCouples ?? {}),
},
targetSchedule?.plannedWagonCutYards ?? {},
new Set(Object.keys(targetSchedule?.plannedWagonCouples ?? {})),
);
}
@@ -5795,6 +6100,7 @@ export class TrainSchedulingService {
stops: string[] = [],
plannedYards: PlannedWagonYards = {},
cutPlan: Record<string, string> = {},
coupleIds: Set<string> = new Set(),
): string[] {
const violations: string[] = [];
// One physical wagon may serve several slots whose leg spans don't overlap
@@ -5817,6 +6123,7 @@ export class TrainSchedulingService {
plannedYards,
cutPlan,
stops,
coupleIds,
);
if (!physical) {
violations.push(
@@ -5850,6 +6157,7 @@ export class TrainSchedulingService {
plannedYards: PlannedWagonYards = {},
cutPlan: Record<string, string> = {},
stops: string[] = [],
coupleIds: Set<string> = new Set(),
): Wagon | undefined {
// How far down the route a wagon rides before this schedule cuts it:
// stop index of its cut yard, or the last stop when uncut (also when the
@@ -5888,9 +6196,15 @@ export class TrainSchedulingService {
// consist views draw the schedule exactly like the train builder; a schedule
// created with reverseWagonOrder pins back-to-front (physically-last wagon
// takes slot #1). Unsequenced wagons sort after every sequenced one.
// A planned COUPLE (loose wagon joining at its couple yard) is pinnable
// alongside the train's own consist — its "boarding yard" is the couple
// yard, already merged into plannedYards by the callers.
const belongsToRun = (w: Wagon): boolean =>
w.trainId === builtTrainId ||
(coupleIds.has(w.id) && !w.trainId && w.status === WagonStatus.Available);
const consistYards = new Set(
wagons
.filter((w) => w.trainId === builtTrainId && scheduleYardOf(plannedYards, w))
.filter((w) => belongsToRun(w) && scheduleYardOf(plannedYards, w))
.map((w) => scheduleYardOf(plannedYards, w) as string),
);
// Split consist: a slot boarding at a given yard must take a wagon that
@@ -5901,7 +6215,7 @@ export class TrainSchedulingService {
const candidates = wagons
.filter(
(w) =>
w.trainId === builtTrainId &&
belongsToRun(w) &&
w.wagonTypeId === slot.wagonTypeId &&
spanFree(w.id) &&
// A wagon cut before the slot's alight stop cannot serve it.
@@ -6076,16 +6390,19 @@ export class TrainSchedulingService {
builtTrainId: string,
scheduleId?: string,
): Promise<WagonStock> {
const [wagons, plan] = await Promise.all([
const [wagons, plan, cutPlan, couplePlan] = await Promise.all([
this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
relations: { wagonType: true },
}),
this.plannedWagonYardsOf(scheduleId),
this.plannedWagonCutYardsOf(scheduleId),
this.plannedWagonCouplesOf(scheduleId),
]);
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
const byYardId = new Map<string, Map<string, number>>();
const cutWagons: NonNullable<WagonStock['cutWagons']> = [];
for (const wagon of wagons) {
remainingByTypeId.set(
wagon.wagonTypeId,
@@ -6099,6 +6416,38 @@ export class TrainSchedulingService {
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
byYardId.set(yardId, perType);
}
// A wagon cut mid-route is not stock past its cut stop — consumers debit
// it per edge so "2 NW5 free from gmp" reads 1 when one is cut at Lebu.
const cutYardId = cutPlan[wagon.id];
if (cutYardId) {
cutWagons.push({ wagonTypeId: wagon.wagonTypeId, poolYardId: yardId ?? '', cutYardId });
}
}
// Planned couples: loose wagons joining the train mid-route are stock too,
// pooled at their couple yard so they serve bookings boarding there.
// ponytail: in multi-yard mode a couple serves only bookings boarding
// exactly at its couple yard (existing split-consist semantics) —
// conservative; upgrade = pool lookup falling back to the nearest pool
// at/before the leg's boarding edge.
const coupleIds = Object.keys(couplePlan);
if (coupleIds.length) {
const coupleWagons = await this.dataSource.getRepository(Wagon).find({
where: { id: In(coupleIds) },
relations: { wagonType: true },
});
for (const wagon of coupleWagons) {
// Already joined (or grabbed by another train) — counted via trainId then.
if (wagon.trainId) continue;
remainingByTypeId.set(
wagon.wagonTypeId,
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
);
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
const coupleYardId = couplePlan[wagon.id];
const perType = byYardId.get(coupleYardId) ?? new Map<string, number>();
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
byYardId.set(coupleYardId, perType);
}
}
// Single-yard consist (the overwhelming majority): the whole train is
// offered at every boarding yard exactly as before — the per-yard split is
@@ -6108,6 +6457,7 @@ export class TrainSchedulingService {
remainingByTypeId,
codesByTypeId,
...(byYardId.size > 1 ? { byYardId } : {}),
...(cutWagons.length ? { cutWagons } : {}),
};
}
@@ -6568,11 +6918,20 @@ export class TrainSchedulingService {
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
const plan = schedule.plannedWagonYards ?? {};
const cutPlan = schedule.plannedWagonCutYards ?? {};
const couplePlan = schedule.plannedWagonCouples ?? {};
const realCuts = new Set(schedule.plannedWagonRealCuts ?? []);
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
relations: { wagonType: true, currentYard: true },
order: { sequenceNumber: 'ASC' },
});
const coupleIds = Object.keys(couplePlan);
const coupleWagons = coupleIds.length
? await this.dataSource.getRepository(Wagon).find({
where: { id: In(coupleIds) },
relations: { wagonType: true, currentYard: true },
})
: [];
const lockedIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
@@ -6580,7 +6939,7 @@ export class TrainSchedulingService {
);
const offRouteYardIds = [
...new Set(
wagons
[...wagons, ...coupleWagons]
.flatMap((w) => [scheduleYardOf(plan, w), w.currentYardId])
.filter((y): y is string => !!y && !stops.some((s) => s.yardId === y)),
),
@@ -6595,7 +6954,7 @@ export class TrainSchedulingService {
const rows = wagons.map((w) => {
const plannedYardId = scheduleYardOf(plan, w);
const cutYardId = cutPlan[w.id] ?? null;
const cutYardId: string | null = cutPlan[w.id] ?? null;
const locked = lockedIds.has(w.id);
return {
id: w.id,
@@ -6608,13 +6967,42 @@ export class TrainSchedulingService {
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
plannedYardId,
plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null,
cutYardId,
cutYardId: cutYardId as string | null,
cutYardLabel: cutYardId ? labels.get(cutYardId) ?? cutYardId : null,
realCut: realCuts.has(w.id),
coupledYardId: null as string | null,
coupledYardLabel: null as string | null,
aligned: plannedYardId === w.currentYardId,
locked,
lockReason: locked ? 'Carries cargo booked on this schedule' : null,
};
});
// Planned couples: loose wagons joining mid-route, appended after the
// consist so the table reads consist-first.
for (const w of coupleWagons) {
const coupledYardId = couplePlan[w.id];
const locked = lockedIds.has(w.id);
rows.push({
id: w.id,
wagonNumber: w.wagonNumber,
sequenceNumber: null,
wagonType: w.wagonType
? { id: w.wagonType.id, code: w.wagonType.code, name: w.wagonType.name }
: { id: w.wagonTypeId, code: w.wagonTypeId, name: w.wagonTypeId },
physicalYardId: w.currentYardId,
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
plannedYardId: null,
plannedYardLabel: null,
cutYardId: null,
cutYardLabel: null,
realCut: false,
coupledYardId,
coupledYardLabel: labels.get(coupledYardId) ?? coupledYardId,
aligned: w.currentYardId === coupledYardId,
locked,
lockReason: locked ? 'Carries cargo booked on this schedule' : null,
});
}
const perStop = stops.map((s) => ({
yardId: s.yardId,
label: s.label,
@@ -6622,6 +7010,7 @@ export class TrainSchedulingService {
planned: rows.filter((r) => r.plannedYardId === s.yardId).length,
physical: rows.filter((r) => r.physicalYardId === s.yardId).length,
cut: rows.filter((r) => r.cutYardId === s.yardId).length,
coupled: rows.filter((r) => r.coupledYardId === s.yardId).length,
}));
return {
scheduleId,
@@ -6634,17 +7023,29 @@ export class TrainSchedulingService {
}
/**
* Re-plan which yard this departure boards wagons from (`yardId`) and/or
* where it cuts them mid-route (`cutYardId`; null clears the wagon rides
* to the destination). Only DRAFT/SCHEDULED schedules, only the train's own
* wagons; boarding only at pickup stops, cutting only at drop stops after
* the boarding yard and never before allocated cargo's destination.
* Re-plan this departure's consist plan: boarding yard (`yardId`), cut yard
* (`cutYardId`; null clears), the `realCut` flag (permanent removal from the
* built train at the cut), and mid-route COUPLES of loose wagons
* (`couple`/`uncouple`). Only DRAFT/SCHEDULED schedules; boarding/coupling
* only at pickup stops, cutting only at drop stops after the boarding yard
* and never before allocated cargo's destination. Coupling validates every
* leg the new wagon rides against the locomotives' weight/length caps.
* Physical yards are untouched — the train builder owns those.
*/
async updateScheduleWagonYards(
scheduleId: string,
moves: Array<{ wagonId: string; yardId?: string; cutYardId?: string | null }>,
dto: {
moves?: Array<{
wagonId: string;
yardId?: string;
cutYardId?: string | null;
realCut?: boolean;
}>;
couple?: Array<{ wagonId: string; yardId: string }>;
uncouple?: string[];
},
) {
const moves = dto.moves ?? [];
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
const builtTrain = schedule.trainSet?.train;
@@ -6665,7 +7066,7 @@ export class TrainSchedulingService {
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
select: { id: true, currentYardId: true, wagonNumber: true },
relations: { wagonType: true },
});
const wagonById = new Map(wagons.map((w) => [w.id, w]));
const lockedIds = new Set(
@@ -6693,6 +7094,8 @@ export class TrainSchedulingService {
const plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) };
const cutPlan: Record<string, string> = { ...(schedule.plannedWagonCutYards ?? {}) };
const couplePlan: Record<string, string> = { ...(schedule.plannedWagonCouples ?? {}) };
const realCuts = new Set(schedule.plannedWagonRealCuts ?? []);
for (const move of moves) {
const wagon = wagonById.get(move.wagonId);
if (!wagon) {
@@ -6713,6 +7116,7 @@ export class TrainSchedulingService {
}
if (move.cutYardId === null) {
delete cutPlan[wagon.id];
realCuts.delete(wagon.id);
} else if (move.cutYardId !== undefined) {
if (!dropYardIds.has(move.cutYardId)) {
throw new BadRequestException(
@@ -6740,10 +7144,149 @@ export class TrainSchedulingService {
);
}
}
// Real-cut flag rides on the (now final) cut for this wagon.
if (move.realCut === false) {
realCuts.delete(wagon.id);
} else if (move.realCut === true) {
if (!cutPlan[wagon.id]) {
throw new BadRequestException(
`Wagon ${wagon.wagonNumber}: a real cut needs a cut yard — set where the wagon is cut first`,
);
}
realCuts.add(wagon.id);
}
}
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { plannedWagonYards: plan, plannedWagonCutYards: cutPlan });
// A flag whose cut disappeared (any path) must not survive.
for (const id of [...realCuts]) if (!cutPlan[id]) realCuts.delete(id);
// ── Couples: loose wagons planned to join the train at a pickup stop ──
const uncouple = new Set(dto.uncouple ?? []);
for (const id of uncouple) {
if (!couplePlan[id]) {
throw new BadRequestException(`Wagon ${id} is not in this schedule's couple plan`);
}
if (lockedIds.has(id)) {
throw new ConflictException(
'Coupled wagon carries cargo booked on this schedule — free the bookings first',
);
}
delete couplePlan[id];
}
const coupleEntries = dto.couple ?? [];
if (new Set(coupleEntries.map((c) => c.wagonId)).size !== coupleEntries.length) {
throw new BadRequestException('A wagon appears more than once in the couple list');
}
for (const c of coupleEntries) {
if (uncouple.has(c.wagonId)) {
throw new BadRequestException('A wagon cannot be both coupled and uncoupled in one save');
}
if (!pickupYardIds.has(c.yardId)) {
throw new BadRequestException(
`Yard ${c.yardId} is not a pickup stop of this schedule's route`,
);
}
if (wagonById.has(c.wagonId)) {
throw new BadRequestException(
`Wagon is already in train ${builtTrain.code}'s consist — use its yard/cut controls instead`,
);
}
couplePlan[c.wagonId] = c.yardId;
}
if (coupleEntries.length) {
const incoming = await this.dataSource.getRepository(Wagon).find({
where: { id: In(coupleEntries.map((c) => c.wagonId)) },
relations: { wagonType: true },
});
const incomingById = new Map(incoming.map((w) => [w.id, w]));
const pinnedElsewhere = await this.wagonIdsPinnedToLiveSchedules(undefined, builtTrain.id);
for (const c of coupleEntries) {
const w = incomingById.get(c.wagonId);
if (!w) throw new BadRequestException(`Wagon ${c.wagonId} not found`);
if (w.trainId) {
throw new ConflictException(
`Wagon ${w.wagonNumber} is already coupled to another built train`,
);
}
if (w.status !== WagonStatus.Available) {
throw new ConflictException(`Wagon ${w.wagonNumber} is not available (${w.status})`);
}
if (w.currentYardId !== c.yardId) {
throw new BadRequestException(
`Wagon ${w.wagonNumber} does not stand at the couple yard — it must physically wait where the train picks it up`,
);
}
if (pinnedElsewhere.has(w.id)) {
throw new ConflictException(
`Wagon ${w.wagonNumber} is reserved by another live schedule`,
);
}
}
}
// ── Per-leg weight/length guard: a couple must fit every edge it rides ──
// Cuts alone only shrink load; the guard runs whenever couples remain in
// the final plan, so cut-then-couple in one save passes on the freed edge.
const coupleIds = Object.keys(couplePlan);
if (coupleIds.length) {
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const pullCap =
(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0);
const lenCap =
(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0);
const edgeCount = Math.max(1, stops.length - 1);
const coupleWagons = await this.dataSource.getRepository(Wagon).find({
where: { id: In(coupleIds) },
relations: { wagonType: true },
});
const spanOfConsist = (w: Wagon) => ({
fromEdge: stopIdx.get(scheduleYardOf(plan, w) ?? '') ?? 0,
toEdge: cutPlan[w.id] ? stopIdx.get(cutPlan[w.id]) ?? edgeCount : edgeCount,
tareTons: Number(w.wagonType?.tareWeightTons ?? 0),
lengthMeters: Number(w.wagonType?.lengthMeters ?? 0),
});
const wagonSpans = [
...wagons.map(spanOfConsist),
...coupleWagons.map((w) => ({
fromEdge: stopIdx.get(couplePlan[w.id]) ?? 0,
toEdge: edgeCount,
tareTons: Number(w.wagonType?.tareWeightTons ?? 0),
lengthMeters: Number(w.wagonType?.lengthMeters ?? 0),
})),
];
const cargoLegs = (schedule.trainSet?.wagons ?? []).flatMap((slot) =>
(slot.allocations ?? []).flatMap((alloc) => {
if (!alloc.booking) return [];
return [
{
fromEdge: stopIdx.get(alloc.booking.originYardId) ?? 0,
toEdge: stopIdx.get(alloc.booking.destinationYardId) ?? edgeCount,
weightTons: Number(alloc.allocatedWeightTons ?? 0),
},
];
}),
);
const loads = computeEdgeLoads(edgeCount, wagonSpans, cargoLegs);
for (let e = 0; e < edgeCount; e += 1) {
const legLabel = `${stops[e].label}${stops[e + 1].label}`;
if (pullCap > 0 && loads[e].weightTons > pullCap) {
throw new BadRequestException(
`Leg ${legLabel}: coupling puts gross weight at ${Math.round(loads[e].weightTons)}T, over the locomotives' ${Math.round(pullCap)}T limit — cut a wagon riding this leg first (real cut frees the train permanently)`,
);
}
if (lenCap > 0 && loads[e].lengthMeters > lenCap) {
throw new BadRequestException(
`Leg ${legLabel}: coupling puts train length at ${Math.round(loads[e].lengthMeters)}m, over the ${Math.round(lenCap)}m limit — cut a wagon riding this leg first`,
);
}
}
}
await this.dataSource.getRepository(TrainSchedule).update(scheduleId, {
plannedWagonYards: plan,
plannedWagonCutYards: cutPlan,
plannedWagonCouples: couplePlan,
plannedWagonRealCuts: [...realCuts],
});
// ponytail: per-stop over-booking check counts bookings boarding at the
// stop against wagons planned there, ignoring leg sharing — a warning, not
@@ -7535,6 +8078,10 @@ export class TrainSchedulingService {
trainSetWagonId: null,
currentTrainScheduleId: null,
currentYardId,
// A wagon leaving the build sheds its run numbers, same as the train
// builder's removeWagon — they belong to the train, not the wagon.
importTrainNumber: null,
exportTrainNumber: null,
};
for (const wagon of removed) {
await manager.getRepository(Wagon).update(wagon.id, detachPatch);
@@ -8171,8 +8718,10 @@ export class TrainSchedulingService {
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
});
// Wagons staff plan to cut mid-route are gone from every edge past the cut.
// Wagons staff plan to cut mid-route are gone from every edge past the
// cut; planned couples add a slot from their couple stop onward.
subtractCutWagons(budget, schedule.plannedWagonCutYards);
addCoupledWagons(budget, schedule.plannedWagonCouples);
for (const sb of schedule.scheduleBookings ?? []) {
if (!sb.booking) continue;
budget.subtract(
@@ -8845,6 +9394,24 @@ export class TrainSchedulingService {
// enforcement; coupled-but-empty consist wagons ride every edge.
const heaviestLeg = schedule.trainSet
? (() => {
const legStops = this.mapScheduleStops(schedule).map((s) => s.yardId);
const legStopIdx = new Map(legStops.map((yardId, i) => [yardId, i]));
// Booking id → the stop-index span its cargo actually rides. Without
// this map a shared slot's FULL cargo counts on every edge the slot
// spans, over-reporting the heaviest leg (S-2026-00045 read 3703T on
// a leg that truly carried 2905T). Unknown yards fall back to the
// slot's whole span inside slotCargoOnEdge — conservative, as before.
const legByBookingId = new Map<string, { from: number; to: number }>();
for (const slot of schedule.trainSet.wagons ?? []) {
for (const alloc of slot.allocations ?? []) {
const booking = alloc.booking;
if (!booking || legByBookingId.has(alloc.bookingId)) continue;
legByBookingId.set(alloc.bookingId, {
from: legStopIdx.get(booking.originYardId) ?? -1,
to: legStopIdx.get(booking.destinationYardId) ?? -1,
});
}
}
const usage = maxEdgeConsistUsage(
[
...(schedule.trainSet.wagons ?? []).map((w) => ({
@@ -8864,7 +9431,8 @@ export class TrainSchedulingService {
allocations: [],
})),
],
this.mapScheduleStops(schedule).map((s) => s.yardId),
legStops,
legByBookingId,
);
return {
grossWeightTons: roundTons(usage.grossWeightTons),

View File

@@ -14,6 +14,7 @@ import {
sumWagonsRequired,
validate20ftContainerRules,
validateContainerPlacements,
validateMixedTrainLimitsPerEdge,
validateWagonCargoExclusivity,
} from './wagon-plan.util';
@@ -412,4 +413,91 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', ()
loadedWagonCount: 2,
});
});
it('with a legs map, shared-slot cargo weighs only its own edges (the S-2026-00045 shape)', () => {
// One wagon reused across legs: booking X rides a→b (40T), booking Y
// boards at b with 30T. The slot spans the whole route, but edge a→b
// must weigh 24 + 40 = 64T — not 24 + 70. Tare rides both edges.
const shared = {
tareWeightTons: 24,
assignedWeightTons: 70,
lengthMeters: 14,
boardYardId: null,
alightYardId: null,
allocations: [
{ bookingId: 'X', allocatedWeightTons: 40 },
{ bookingId: 'Y', allocatedWeightTons: 30 },
],
} as never;
const legs = new Map([
['X', { from: 0, to: 1 }],
['Y', { from: 1, to: 2 }],
]);
// Without legs: whole-span scalar on both edges (94T binding edge).
expect(maxEdgeConsistUsage([shared], stops).grossWeightTons).toBe(94);
// With legs: heaviest edge is a→b at 64T (b→c is 54T).
expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(64);
});
it('falls back to the whole-span scalar when an allocation has no readable weight', () => {
const shared = {
tareWeightTons: 24,
assignedWeightTons: 70,
lengthMeters: 14,
boardYardId: null,
alightYardId: null,
allocations: [{ bookingId: 'X' }],
} as never;
const legs = new Map([['X', { from: 0, to: 1 }]]);
expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(94);
});
});
describe('validateMixedTrainLimitsPerEdge — leg-aware cargo weighing', () => {
it('does not flag a leg whose overweight is only later-boarding cargo (S-2026-00045)', () => {
// 2 shared wagons, 100T cap. Booking X rides a→b with 30T/wagon, booking Y
// boards at b with 25T/wagon. Whole-span scalars read every edge as
// 2×(20 + 55) = 150T > 100T; the cargo actually aboard is 100T (a→b) and
// 90T (b→c) — both fit.
const slot = (seq: number) => ({
sequenceNo: seq,
wagonTypeId: 'wt-nw5',
wagonTypeCode: 'NW5',
capacityTons: 70,
lengthMeters: 14,
tareWeightTons: 20,
assignedWeightTons: 55,
boardYardId: null,
alightYardId: null,
allocations: [
{
bookingId: 'X',
bookingReference: 'X',
allocatedWeightTons: 30,
loadType: AllocationLoadType.Container,
},
{
bookingId: 'Y',
bookingReference: 'Y',
allocatedWeightTons: 25,
loadType: AllocationLoadType.Container,
},
],
});
const legs = new Map([
['X', { from: 0, to: 1 }],
['Y', { from: 1, to: 2 }],
]);
const run = (withLegs?: typeof legs) =>
validateMixedTrainLimitsPerEdge(
[slot(1), slot(2)] as never,
[{ lengthMeters: 14 }],
{ maxWeightTons: 100 },
['a', 'b', 'c'],
undefined,
withLegs,
);
expect(run()).toHaveLength(2); // both edges falsely overweight without legs
expect(run(legs)).toHaveLength(0);
});
});

View File

@@ -654,9 +654,16 @@ export function validateMixedTrainLimitsPerEdge(
const label = (i: number) => stopLabels?.[i] ?? stops[i];
const violations = new Set<string>();
for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = wagonPlan.filter(
(_, i) => spans[i].from <= edge && edge < spans[i].to,
);
// A shared slot rides the UNION of its cargo legs, but only carries each
// booking's cargo on that booking's own edges — weigh the edge with the
// cargo actually aboard there, not the slot's whole-route scalar, or a
// container boarding at Dire Dawa reads as hauled from Djibouti.
const active = wagonPlan
.filter((_, i) => spans[i].from <= edge && edge < spans[i].to)
.map((slot) => ({
...slot,
assignedWeightTons: slotCargoOnEdge(slot, edge, edges, legs),
}));
if (!active.length) continue;
for (const violation of validateMixedTrainLimits(
active,
@@ -684,6 +691,40 @@ export type EdgeUsageSlot = Pick<
allocations?: unknown[];
};
/**
* Cargo tons a slot actually carries on one edge. With a legs map and readable
* allocation records, each booking's cargo counts only on the edges that
* booking rides (an unmapped booking stays on the slot's whole span). Without
* either — or when any allocation lacks a numeric weight, e.g. persisted rows
* fed through {@link EdgeUsageSlot} — falls back to the slot's whole-span
* `assignedWeightTons`, the pre-existing reading.
*/
function slotCargoOnEdge(
slot: EdgeUsageSlot,
edge: number,
edgeCount: number,
legs?: Map<string, { from: number; to: number }>,
): number {
const wholeSpanCargo = Number(slot.assignedWeightTons ?? 0);
const allocations = (slot.allocations ?? []) as Array<{
bookingId?: string;
allocatedWeightTons?: number | string;
}>;
if (!legs?.size || !allocations.length) return wholeSpanCargo;
let cargo = 0;
for (const allocation of allocations) {
const weight = Number(allocation?.allocatedWeightTons);
if (!Number.isFinite(weight)) return wholeSpanCargo;
const leg = allocation.bookingId ? legs.get(allocation.bookingId) : undefined;
const rides =
!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to
? true
: leg.from <= edge && edge < leg.to;
if (rides) cargo += weight;
}
return cargo;
}
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
function slotSpans(
wagonPlan: EdgeUsageSlot[],
@@ -708,8 +749,10 @@ function slotSpans(
export function maxEdgeConsistUsage(
wagonPlan: EdgeUsageSlot[],
stops: string[],
/** Booking id → stop-index span; cargo then weighs only its own edges. */
legs?: Map<string, { from: number; to: number }>,
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
return perEdgeConsistUsage(wagonPlan, stops).reduce(
return perEdgeConsistUsage(wagonPlan, stops, legs).reduce(
(max, e) => ({
grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons),
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
@@ -737,12 +780,19 @@ export type EdgeConsistUsage = {
export function perEdgeConsistUsage(
wagonPlan: EdgeUsageSlot[],
stops: string[],
/**
* Booking id → stop-index span. When given, a shared slot's cargo weighs
* only the edges its booking rides (tare still rides the slot's whole
* span) — without it a slot's full cargo counts on every edge it spans.
*/
legs?: Map<string, { from: number; to: number }>,
): EdgeConsistUsage[] {
const edgeCount = Math.max(1, stops.length - 1);
const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({
edge,
grossWeightTons: slots.reduce(
(sum, w) =>
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
sum + Number(w.tareWeightTons ?? 0) + slotCargoOnEdge(w, edge, edgeCount, legs),
0,
),
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),

View File

@@ -54,6 +54,13 @@ export type WagonStock = {
* math.
*/
byYardId?: Map<string, Map<string, number>>;
/**
* Wagons the schedule CUTS mid-route (staff plan): each is stock only up to
* its cut stop. Consumers debit it from its pool on every edge at/after the
* cut, so a leg riding past the cut never counts it. Absent = no cuts.
* `poolYardId` is the wagon's boarding pool ('' on a single-yard consist).
*/
cutWagons?: Array<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>;
};
export type FlexPlanResult = {
@@ -324,6 +331,16 @@ export function planWagonsWithStock(params: {
}
return row;
};
// Cut wagons are pre-consumed on every edge at/after their cut stop: they
// are steel for gmp→lebu but not for gmp→dct. Unknown cut yard (no stops
// given / off-corridor) is skipped — conservative, same as before cuts.
for (const cut of stock.cutWagons ?? []) {
const fromEdge = stops.indexOf(cut.cutYardId);
if (fromEdge < 0) continue;
const pool = stock.byYardId ? cut.poolYardId : '';
const row = usedRow(rowKeyFor(cut.wagonTypeId, pool));
for (let e = fromEdge; e < edgeCount; e += 1) row[e] += 1;
}
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
const pool = poolOf(leg);
const total = totalFor(wagonTypeId, pool);

View File

@@ -159,3 +159,52 @@ describe('WagonStockLedger — multi-yard consist', () => {
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53);
});
});
describe('WagonStockLedger — cut wagons (S-2026-00050 shape)', () => {
// gmp -> lebu -> mojo -> adama -> dct. 3 NW5 + 2 PW2: two NW5 board at gmp
// (one cut at lebu), one NW5 boards at mojo; both PW2 board at gmp.
const stops = ['gmp', 'lebu', 'mojo', 'adama', 'dct'];
const makeLedger = () => {
const ledger = new WagonStockLedger(
new Map([
['nw5', 3],
['pw2', 2],
]),
stops.length - 1,
new Map([
['gmp', new Map([['nw5', 2], ['pw2', 2]])],
['mojo', new Map([['nw5', 1]])],
]),
stops,
);
ledger.debitCutWagons([{ wagonTypeId: 'nw5', poolYardId: 'gmp', cutYardId: 'lebu' }]);
return ledger;
};
const leg = (from: number, to: number) => ({ fromEdge: from, toEdge: to });
it('a leg past the cut sees only the wagons that reach it', () => {
const ledger = makeLedger();
// gmp -> dct: 2 NW5 stand at gmp but one is cut at lebu — only 1 rides through.
expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(1);
// gmp -> lebu: both gmp NW5 serve the short leg.
expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(2);
// PW2 uncut — both ride anywhere from gmp.
expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2);
// mojo -> dct: the mojo pool's own NW5, untouched by the gmp cut.
expect(ledger.availableFor(['nw5'], leg(2, 4))).toBe(1);
});
it('cut debit and booking consumption stack', () => {
const ledger = makeLedger();
expect(ledger.consume(['nw5'], 1, leg(0, 4))).toBe(1);
expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(0);
// Short leg still has the cut wagon (1 = 2 total 1 consumed through-rider).
expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(1);
});
it('ignores a cut yard that is not on the stops', () => {
const ledger = makeLedger();
ledger.debitCutWagons([{ wagonTypeId: 'pw2', poolYardId: 'gmp', cutYardId: 'elsewhere' }]);
expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2);
});
});

View File

@@ -75,6 +75,32 @@ export class WagonStockLedger {
return Math.max(0, total - busiest);
}
/**
* Pre-debit wagons the schedule CUTS mid-route: each cut wagon occupies its
* pool's stock on every edge at/after its cut stop, so a leg riding past the
* cut never counts it ("2 NW5 free from gmp" reads 1 when one cuts at Lebu).
* A cut yard not on this ledger's stops is skipped — conservative, matches
* the pre-cut behavior.
*/
debitCutWagons(
cuts: ReadonlyArray<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>,
): void {
for (const cut of cuts) {
const fromEdge = this.stops.indexOf(cut.cutYardId);
if (fromEdge < 0) continue;
const pool = this.byYardId ? cut.poolYardId : '';
const key = pool ? `${pool}\u0000${cut.wagonTypeId}` : cut.wagonTypeId;
let row = this.usedPerEdge.get(key);
if (!row) {
row = new Array<number>(this.edgeCount).fill(0);
this.usedPerEdge.set(key, row);
}
for (let edge = fromEdge; edge < this.edgeCount; edge += 1) {
row[edge] = (row[edge] ?? 0) + 1;
}
}
}
/**
* Free wagons across every type a booking may ride. A cargo/container type
* mapped to several wagon types can use any of them, so they add up.

View File

@@ -16,6 +16,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -78,6 +79,24 @@ export class TrainBuilderController {
return this.trainBuilderService.getComposition(id);
}
@Get(':id/history')
@ApiOperation({
summary:
"Wagon adjustment history of this built train: who attached/detached/switched which wagon, when and where — builder edits and trip events alike",
})
history(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) {
return this.trainBuilderService.getTrainHistory(id, query);
}
@Get(':id/detached-wagons')
@ApiOperation({
summary:
'Wagons previously detached from this train that are still loose — with when/where/by whom they were last detached, ready to re-attach',
})
detachedWagons(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) {
return this.trainBuilderService.getDetachedWagons(id, query);
}
@Put(':id/locomotives')
@FleetManage(FREIGHT_PERMS.trains.changeLocomotives)
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })

View File

@@ -231,6 +231,117 @@ export class TrainBuilderService {
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({
@@ -721,6 +832,7 @@ export class TrainBuilderService {
toYardId: yardId,
kind: WagonMovementKind.Maintenance,
note: notes.movementNote,
movedByUserId: userId,
occurredAt: new Date(),
}),
);
@@ -1097,15 +1209,14 @@ export class TrainBuilderService {
.getRepository(TrainSet)
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
}
if (!schedule) return null;
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
// 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,
trainScheduleId: schedule?.id ?? null,
trainId,
action: c.action,
wagonId: c.wagonId,
@@ -1117,6 +1228,10 @@ export class TrainBuilderService {
),
);
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' };