feat(train-scheduling): mid-route consist changes, audit history, safer workspace

- 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>
This commit is contained in:
Marshal
2026-08-23 03:55:54 +00:00
parent ba89b670c8
commit 8e6fc09aac
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

@@ -222,7 +222,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;
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,6 +4815,43 @@ export class TrainSchedulingService {
schedule.plannedWagonCutYards?.[wagon.id] ??
slot.alightYardId ??
schedule.destinationStationId;
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,
@@ -4665,6 +4860,7 @@ export class TrainSchedulingService {
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`,
);
}
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { plannedWagonYards: plan, plannedWagonCutYards: cutPlan });
realCuts.add(wagon.id);
}
}
// 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' };

View File

@@ -0,0 +1,194 @@
import {
Badge,
Button,
Checkbox,
Group,
Pagination,
Paper,
Stack,
Table,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Link2, MapPin, PackageOpen, User } from "lucide-react";
import { useState } from "react";
import { api } from "@/services/api";
interface Props {
trainId: string;
/** Staff may attach and the train is editable (not out on a run). */
canAttach: boolean;
attachPending: boolean;
onAttach: (wagonIds: string[]) => void;
}
/**
* "Detached wagons" tab: wagons last detached from THIS train that are still
* loose — with when, where and by whom they were detached — so staff can pick
* them straight back onto the consist without hunting through the global pool.
*/
export default function DetachedWagonsPanel({
trainId,
canAttach,
attachPending,
onAttach,
}: Props) {
const [page, setPage] = useState(1);
const query = useQuery(
api.trainBuilder.detachedWagons.queryOptions({
input: { id: trainId, page, pageSize: 20 },
enabled: Boolean(trainId),
// Keep the previous page on screen while the next one loads.
placeholderData: (prev) => prev,
}),
);
const rows = query.data?.items ?? [];
const totalPages = Math.max(1, query.data?.meta.totalPages ?? 1);
// Selection is page-scoped in the header checkbox but survives paging, so
// staff can gather wagons across pages into one attach.
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.wagonId));
const toggle = (wagonId: string, checked: boolean) =>
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(wagonId);
else next.delete(wagonId);
return next;
});
return (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="orange">
<PackageOpen size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={700} fz="lg">
Detached wagons
</Text>
<Text size="sm" c="dimmed">
Wagons that left this train and are still loose select and
attach them back in one click.
</Text>
</Stack>
</Group>
{canAttach ? (
<Button
leftSection={<Link2 size={16} />}
disabled={selected.size === 0}
loading={attachPending}
onClick={() => {
onAttach([...selected]);
setSelected(new Set());
}}
>
Attach {selected.size || ""} wagon{selected.size === 1 ? "" : "s"}
</Button>
) : null}
</Group>
{query.isLoading ? (
<Text size="sm" c="dimmed" ta="center" py="md">
Loading detached wagons
</Text>
) : rows.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No loose wagons were detached from this train detach history starts
being recorded from now on.
</Text>
) : (
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{canAttach ? (
<Table.Th w={36}>
<Checkbox
checked={allSelected}
indeterminate={selected.size > 0 && !allSelected}
onChange={(e) =>
setSelected(
e.currentTarget.checked
? new Set(rows.map((r) => r.wagonId))
: new Set(),
)
}
/>
</Table.Th>
) : null}
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Now standing at</Table.Th>
<Table.Th>Last detached</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.wagonId}>
{canAttach ? (
<Table.Td>
<Checkbox
checked={selected.has(r.wagonId)}
onChange={(e) => toggle(r.wagonId, e.currentTarget.checked)}
/>
</Table.Td>
) : null}
<Table.Td>
<Text fw={600} size="sm" ff="monospace">
{r.wagonNumber}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="gray">
{r.wagonTypeCode ?? "—"}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{r.currentYardLabel ?? "No yard"}</Text>
</Table.Td>
<Table.Td>
<Group gap="md" wrap="wrap">
<Tooltip label={new Date(r.detachedAt).toLocaleString()}>
<Text size="sm">{new Date(r.detachedAt).toLocaleDateString()}</Text>
</Tooltip>
{r.detachedYardLabel ? (
<Group gap={4} wrap="nowrap">
<MapPin size={12} />
<Text size="xs" c="dimmed">
at {r.detachedYardLabel}
</Text>
</Group>
) : null}
{r.detachedBy ? (
<Group gap={4} wrap="nowrap">
<User size={12} />
<Text size="xs" c="dimmed">
by {r.detachedBy}
</Text>
</Group>
) : null}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{totalPages > 1 ? (
<Group justify="space-between">
<Text size="xs" c="dimmed">
{query.data?.meta.total ?? 0} wagon(s) · selection carries across pages
</Text>
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
</Group>
) : null}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,140 @@
import { Badge, Group, Pagination, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeftRight, History, MapPin, Minus, Plus, TrainFront, User } from "lucide-react";
import { useState } from "react";
import { api } from "@/services/api";
import type { TrainHistoryEntry } from "@/services/trainBuilder.service";
const PAGE_SIZE = 20;
const ACTION_META: Record<
TrainHistoryEntry["action"],
{ label: string; color: string; icon: typeof Plus }
> = {
ADD: { label: "Wagon attached", color: "edr-green", icon: Plus },
REMOVE: { label: "Wagon detached", color: "red", icon: Minus },
SWITCH: { label: "Wagon switched", color: "blue", icon: ArrowLeftRight },
};
/**
* "History" tab of the train-builder detail page: every wagon ever attached,
* detached or switched on this built train — builder edits and trip events
* (real cuts, mid-route couples, consist adjustments) alike, newest first.
*/
export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
const [page, setPage] = useState(1);
const historyQuery = useQuery(
api.trainBuilder.history.queryOptions({
input: { id: trainId, page, pageSize: PAGE_SIZE },
enabled: Boolean(trainId),
// Keep the previous page on screen while the next one loads.
placeholderData: (prev) => prev,
}),
);
const entries = historyQuery.data?.items ?? [];
const totalPages = Math.max(1, historyQuery.data?.meta.totalPages ?? 1);
const total = historyQuery.data?.meta.total ?? 0;
return (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="lg">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
<History size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={700} fz="lg">
Wagon history
</Text>
<Text size="sm" c="dimmed">
Who attached, detached or switched which wagon on this train from
the builder and from its trips newest first.
</Text>
</Stack>
</Group>
{historyQuery.isLoading ? (
<Text size="sm" c="dimmed" ta="center" py="md">
Loading history
</Text>
) : entries.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No wagon changes recorded yet for this train.
</Text>
) : (
<Timeline bulletSize={26} lineWidth={2} color="edr-green">
{entries.map((entry) => {
const meta = ACTION_META[entry.action] ?? ACTION_META.ADD;
const Icon = meta.icon;
return (
<Timeline.Item
key={entry.id}
bullet={<Icon size={13} />}
color={meta.color}
title={
<Group gap="xs" wrap="nowrap">
<Badge size="sm" variant="light" color={meta.color}>
{meta.label}
</Badge>
{entry.subject ? (
<Text size="sm" fw={600} ff="monospace">
{entry.subject}
</Text>
) : null}
{entry.scheduleReference ? (
<Badge
size="sm"
variant="light"
color="blue"
leftSection={<TrainFront size={10} />}
>
{entry.scheduleReference}
</Badge>
) : (
<Badge size="sm" variant="light" color="gray">
Builder
</Badge>
)}
</Group>
}
>
<Group gap="md" mt={2}>
<Text size="xs" c="dimmed">
{new Date(entry.occurredAt).toLocaleString()}
</Text>
{entry.yardLabel ? (
<Group gap={4} wrap="nowrap">
<MapPin size={12} />
<Text size="xs" c="dimmed">
at {entry.yardLabel}
</Text>
</Group>
) : null}
{entry.actor ? (
<Group gap={4} wrap="nowrap">
<User size={12} />
<Text size="xs" c="dimmed">
{entry.actor}
</Text>
</Group>
) : null}
</Group>
</Timeline.Item>
);
})}
</Timeline>
)}
{totalPages > 1 ? (
<Group justify="space-between">
<Text size="xs" c="dimmed">
{total} change(s)
</Text>
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
</Group>
) : null}
</Stack>
</Paper>
);
}

View File

@@ -2,20 +2,27 @@ import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Modal,
NumberInput,
Pagination,
Paper,
ScrollArea,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Freight } from "@edr/types";
import { isAxiosError } from "axios";
import { AlertTriangle, Lock, MapPin } from "lucide-react";
import { AlertTriangle, Link2, Lock, MapPin, Plus, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { useToast } from "@/hooks/use-toast";
@@ -56,6 +63,21 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
const [pending, setPending] = useState<Record<string, string>>({});
/** wagonId → cut yard queued but not yet saved; null = queued clear (rides to destination). */
const [pendingCut, setPendingCut] = useState<Record<string, string | null>>({});
/** wagonId → real-cut flag queued but not yet saved. */
const [pendingRealCut, setPendingRealCut] = useState<Record<string, boolean>>({});
/** Loose wagons queued to couple: wagonId → couple stop + display data. */
const [pendingCouples, setPendingCouples] = useState<
Record<string, { yardId: string; wagonNumber: string; typeCode: string }>
>({});
/** Already-planned couples queued for removal. */
const [pendingUncouple, setPendingUncouple] = useState<string[]>([]);
// "Add wagon" modal + its filters.
const [coupleModalOpen, setCoupleModalOpen] = useState(false);
const [coupleYardFilter, setCoupleYardFilter] = useState<string | null>(null);
const [coupleType, setCoupleType] = useState<string | null>(null);
const [coupleSearch, setCoupleSearch] = useState("");
const [couplePage, setCouplePage] = useState(1);
const [debouncedCoupleSearch] = useDebouncedValue(coupleSearch, 300);
const [bulkType, setBulkType] = useState<string | null>(null);
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
const [bulkTo, setBulkTo] = useState<string | null>(null);
@@ -63,6 +85,39 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
const editable = Boolean(canEdit && data?.editable);
const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]);
/** Mid-route stops only — wagons are coupled between the origin and the destination. */
const intermediateStops = useMemo(() => {
const stops = data?.stops ?? [];
return stops.slice(1, -1).filter((s) => s.pickup);
}, [data]);
// Loose-wagon list for the "Add wagon" modal. A wagon can only be coupled
// where it physically stands, and only at a pickup stop of this route — the
// Add button carries that yard; off-route wagons render disabled.
const coupleListQuery = useQuery(
api.wagons.listPaged.queryOptions({
input: {
filters: {
status: Freight.WagonStatus.Available,
unassigned: true,
currentYardId: coupleYardFilter ?? undefined,
wagonTypeId: coupleType ?? undefined,
search: debouncedCoupleSearch || undefined,
page: couplePage,
pageSize: 8,
},
},
enabled: editable && coupleModalOpen,
placeholderData: (prev) => prev,
}),
);
const coupleCandidates = coupleListQuery.data?.items ?? [];
const coupleTotalPages = Math.max(1, coupleListQuery.data?.meta.totalPages ?? 1);
const yardsQuery = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: coupleModalOpen }),
);
const wagonTypesQuery = useQuery(
api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000, enabled: coupleModalOpen }),
);
const yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label }));
const yardLabel = (id: string | null) =>
(data?.stops ?? []).find((s) => s.yardId === id)?.label ??
@@ -74,6 +129,8 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId;
const effectiveCut = (w: ScheduleWagonYardRow) =>
w.id in pendingCut ? pendingCut[w.id] : w.cutYardId;
const effectiveRealCut = (w: ScheduleWagonYardRow) =>
(pendingRealCut[w.id] ?? w.realCut) && effectiveCut(w) != null;
const stopIndexOf = (yardId: string | null) =>
yardId == null ? -1 : (data?.stops ?? []).findIndex((s) => s.yardId === yardId);
/** Drop stops a wagon boarding at `boardYardId` can be cut at — strictly after
@@ -108,8 +165,13 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
cut: (data?.wagons ?? []).filter(
(w) => (w.id in pendingCut ? pendingCut[w.id] : w.cutYardId) === s.yardId,
).length,
coupled:
(data?.wagons ?? []).filter(
(w) => w.coupledYardId === s.yardId && !pendingUncouple.includes(w.id),
).length +
Object.values(pendingCouples).filter((c) => c.yardId === s.yardId).length,
})),
[data, pending, pendingCut],
[data, pending, pendingCut, pendingCouples, pendingUncouple],
);
const typeOptions = useMemo(() => {
const seen = new Map<string, string>();
@@ -117,7 +179,14 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
return [...seen].map(([value, label]) => ({ value, label }));
}, [data]);
const pendingCount = new Set([...Object.keys(pending), ...Object.keys(pendingCut)]).size;
const pendingCount =
new Set([
...Object.keys(pending),
...Object.keys(pendingCut),
...Object.keys(pendingRealCut),
]).size +
Object.keys(pendingCouples).length +
pendingUncouple.length;
const queueBulk = () => {
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
@@ -152,7 +221,13 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
const handleSave = async () => {
if (!pendingCount) return;
try {
const wagonIds = [...new Set([...Object.keys(pending), ...Object.keys(pendingCut)])];
const wagonIds = [
...new Set([
...Object.keys(pending),
...Object.keys(pendingCut),
...Object.keys(pendingRealCut),
]),
];
const result = await save.mutateAsync({
scheduleId,
payload: {
@@ -160,11 +235,24 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
wagonId,
...(wagonId in pending ? { yardId: pending[wagonId] } : {}),
...(wagonId in pendingCut ? { cutYardId: pendingCut[wagonId] } : {}),
...(wagonId in pendingRealCut ? { realCut: pendingRealCut[wagonId] } : {}),
})),
...(Object.keys(pendingCouples).length
? {
couple: Object.entries(pendingCouples).map(([wagonId, c]) => ({
wagonId,
yardId: c.yardId,
})),
}
: {}),
...(pendingUncouple.length ? { uncouple: pendingUncouple } : {}),
},
});
setPending({});
setPendingCut({});
setPendingRealCut({});
setPendingCouples({});
setPendingUncouple([]);
toast({
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
description: result.warnings.length ? result.warnings.join(" ") : undefined,
@@ -197,8 +285,11 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
<b>Planned</b> = where this departure boards the wagon (what customers can book per
origin). <b>Physical</b> = where the wagon stands now (train builder). <b>Cut at</b> ={" "}
where this departure detaches the wagon and leaves it blank means it rides to the
destination; booking capacity past the cut shrinks accordingly. Dispatch is blocked until
every wagon stands at its planned yard.
destination; booking capacity past the cut shrinks accordingly. Tick <b>Real cut</b> to
remove the wagon from the train build permanently at that yard (untick = it sits out this
trip only). <b>Coupled</b> wagons are loose wagons joining the train at a stop they
become part of the build for good. Dispatch is blocked until every wagon stands at its
planned yard.
{data.misaligned > 0 ? (
<Text component="span" c="orange" fw={600}>
{" "}
@@ -232,9 +323,19 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
Cut {s.cut}
</Badge>
) : null}
{s.coupled > 0 ? (
<Badge color="blue" variant="light">
+{s.coupled} coupled
</Badge>
) : null}
{!s.pickup ? (
<Badge color="blue" variant="light">
Through {data.wagons.length - perStop.reduce((sum, p) => sum + p.cut, 0)}
Through{" "}
{data.wagons.filter(
(w) => !w.coupledYardId || !pendingUncouple.includes(w.id),
).length +
Object.keys(pendingCouples).length -
perStop.reduce((sum, p) => sum + p.cut, 0)}
</Badge>
) : null}
</Group>
@@ -275,6 +376,214 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
</Paper>
) : null}
{editable ? (
<Group justify="space-between">
<Group gap={6}>
<Link2 size={16} />
<Text fw={600} size="sm">
Consist plan for this trip
</Text>
</Group>
<Button
leftSection={<Plus size={16} />}
variant="light"
onClick={() => setCoupleModalOpen(true)}
>
Add wagon
</Button>
</Group>
) : null}
<Modal
opened={coupleModalOpen}
onClose={() => setCoupleModalOpen(false)}
size="xl"
radius="md"
title={
<Group gap={8}>
<Link2 size={18} />
<Text fw={700}>Add wagons to this trip</Text>
</Group>
}
>
<Stack gap="sm">
<Alert color="blue" variant="light" p="xs">
A wagon is coupled where it physically stands, so it must be waiting at one of this
route&apos;s stops between the origin and the destination. Wagons elsewhere are listed
but cannot be added until they are moved.
</Alert>
<Group align="end" gap="sm" wrap="wrap">
<Select
label="Yard"
placeholder="All yards"
clearable
searchable
data={(yardsQuery.data ?? [])
.filter(
(y) =>
y.id !== data.stops[0]?.yardId &&
y.id !== data.stops[data.stops.length - 1]?.yardId,
)
.slice()
.sort((a, b) => a.label.localeCompare(b.label))
.map((y) => ({
value: y.id,
label: intermediateStops.some((s) => s.yardId === y.id)
? `${y.label} · route stop`
: y.label,
}))}
value={coupleYardFilter}
onChange={(v) => {
setCoupleYardFilter(v);
setCouplePage(1);
}}
w={220}
/>
<Select
label="Wagon type"
placeholder="Any type"
clearable
data={(wagonTypesQuery.data ?? []).map((t) => ({
value: t.id,
label: t.code ? `${t.name} (${t.code})` : t.name,
}))}
value={coupleType}
onChange={(v) => {
setCoupleType(v);
setCouplePage(1);
}}
w={200}
/>
<TextInput
label="Search"
placeholder="Wagon number…"
leftSection={<Search size={14} />}
value={coupleSearch}
onChange={(e) => {
setCoupleSearch(e.currentTarget.value);
setCouplePage(1);
}}
w={200}
/>
</Group>
{coupleListQuery.isLoading ? (
<Group justify="center" p="md">
<Loader size="sm" />
</Group>
) : (
<ScrollArea.Autosize mah={380}>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Standing at</Table.Th>
<Table.Th ta="right">Couple</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{coupleCandidates.map((w) => {
const onTrip = data.wagons.some((row) => row.id === w.id);
const queued = w.id in pendingCouples;
const stop = intermediateStops.find((s) => s.yardId === w.currentYardId);
return (
<Table.Tr key={w.id}>
<Table.Td>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="gray">
{w.wagonType?.code ?? w.wagonTypeId}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{w.currentYard?.label ?? "No yard"}</Text>
</Table.Td>
<Table.Td ta="right">
{onTrip ? (
<Badge size="sm" variant="light" color="gray">
On this trip
</Badge>
) : queued ? (
<Button
size="compact-xs"
variant="subtle"
color="red"
onClick={() =>
setPendingCouples((prev) => {
const next = { ...prev };
delete next[w.id];
return next;
})
}
>
Queued remove
</Button>
) : stop ? (
<Button
size="compact-xs"
variant="light"
leftSection={<Plus size={12} />}
onClick={() =>
setPendingCouples((prev) => ({
...prev,
[w.id]: {
yardId: stop.yardId,
wagonNumber: w.wagonNumber,
typeCode: w.wagonType?.code ?? w.wagonTypeId,
},
}))
}
>
Couple at {stop.label}
</Button>
) : (
<Tooltip label="Not standing at a mid-route stop of this schedule (origin and destination excluded)">
<Button size="compact-xs" variant="default" disabled>
Off route
</Button>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
);
})}
{coupleCandidates.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={4}>
<Text size="sm" c="dimmed" ta="center" py="sm">
No loose wagons match the filters.
</Text>
</Table.Td>
</Table.Tr>
) : null}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
)}
<Group justify="space-between">
{coupleTotalPages > 1 ? (
<Pagination
size="sm"
value={couplePage}
onChange={setCouplePage}
total={coupleTotalPages}
/>
) : (
<span />
)}
<Group gap="sm">
<Text size="sm" c="dimmed">
{Object.keys(pendingCouples).length} wagon(s) queued save the plan to apply
</Text>
<Button onClick={() => setCoupleModalOpen(false)}>Done</Button>
</Group>
</Group>
</Stack>
</Modal>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
@@ -288,10 +597,12 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.wagons.map((w) => {
{data.wagons
.filter((w) => !w.coupledYardId)
.map((w) => {
const planned = effectiveYard(w);
const cut = effectiveCut(w);
const changed = w.id in pending || w.id in pendingCut;
const changed = w.id in pending || w.id in pendingCut || w.id in pendingRealCut;
return (
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
@@ -336,24 +647,55 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
{editable ? (
// Locked wagons stay editable here — the server enforces the
// cargo-destination floor and the toast explains a 409.
<Stack gap={4}>
<Select
size="xs"
clearable
placeholder="Destination"
data={cutOptionsFor(planned)}
value={cut}
onChange={(v) =>
onChange={(v) => {
setPendingCut((prev) => {
const next = { ...prev };
if ((v ?? null) === w.cutYardId) delete next[w.id];
else next[w.id] = v ?? null;
return next;
})
});
if (!v) {
// No cut → no real-cut flag to keep.
setPendingRealCut((prev) => {
const next = { ...prev };
if (w.realCut) next[w.id] = false;
else delete next[w.id];
return next;
});
}
}}
w={180}
/>
{cut ? (
<Checkbox
size="xs"
label="Real cut (train loses wagon)"
checked={effectiveRealCut(w)}
onChange={(e) => {
const v = e.currentTarget.checked;
setPendingRealCut((prev) => {
const next = { ...prev };
if (v === w.realCut) delete next[w.id];
else next[w.id] = v;
return next;
});
}}
/>
) : null}
</Stack>
) : (
<Text size="sm">{cut ? yardLabel(cut) : "Destination"}</Text>
<Text size="sm">
{cut
? `${yardLabel(cut)}${effectiveRealCut(w) ? " (real cut)" : ""}`
: "Destination"}
</Text>
)}
</Table.Td>
<Table.Td>
@@ -370,6 +712,103 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
</Table.Tr>
);
})}
{data.wagons
.filter((w) => w.coupledYardId)
.map((w) => {
const queuedOff = pendingUncouple.includes(w.id);
return (
<Table.Tr
key={w.id}
bg={queuedOff ? "var(--mantine-color-yellow-light)" : undefined}
opacity={queuedOff ? 0.5 : undefined}
>
<Table.Td></Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Table.Td>
<Table.Td>{w.wagonType.code}</Table.Td>
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="blue" leftSection={<Link2 size={12} />}>
Coupled at {w.coupledYardLabel ?? w.coupledYardId}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">Destination</Text>
</Table.Td>
<Table.Td>
<Group gap={6}>
{w.aligned ? (
<Badge color="teal" variant="light" size="sm">
At couple yard
</Badge>
) : (
<Badge color="orange" variant="light" size="sm">
Not at couple yard
</Badge>
)}
{editable ? (
<Tooltip
label={w.locked ? w.lockReason ?? "Locked" : "Remove from couple plan"}
>
<Button
size="compact-xs"
variant="subtle"
color="red"
disabled={w.locked}
onClick={() =>
setPendingUncouple((prev) =>
queuedOff ? prev.filter((id) => id !== w.id) : [...prev, w.id],
)
}
>
{queuedOff ? "Keep" : "Uncouple"}
</Button>
</Tooltip>
) : null}
</Group>
</Table.Td>
</Table.Tr>
);
})}
{Object.entries(pendingCouples).map(([wagonId, c]) => (
<Table.Tr key={wagonId} bg="var(--mantine-color-yellow-light)">
<Table.Td></Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{c.wagonNumber}
</Text>
</Table.Td>
<Table.Td>{c.typeCode}</Table.Td>
<Table.Td>{yardLabel(c.yardId)}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="blue" leftSection={<Link2 size={12} />}>
Coupled at {yardLabel(c.yardId)} (pending)
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">Destination</Text>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="subtle"
color="red"
onClick={() =>
setPendingCouples((prev) => {
const next = { ...prev };
delete next[wagonId];
return next;
})
}
>
Remove
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
@@ -383,6 +822,9 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
onClick={() => {
setPending({});
setPendingCut({});
setPendingRealCut({});
setPendingCouples({});
setPendingUncouple([]);
}}
disabled={!pendingCount}
>

View File

@@ -347,6 +347,64 @@ export function ScheduleWorkspacePanel({
const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0;
const over = capacity > 0 && used > capacity;
// One confirmation dialog for every booking action; the action fires only
// after staff confirm, and the existing toasts report the outcome.
const [confirmAction, setConfirmAction] = useState<{
kind: "add" | "load" | "truckToTrain" | "unload" | "remove";
bookingId: string;
ref: string;
weightTons?: number;
} | null>(null);
const confirmMeta: Record<
NonNullable<typeof confirmAction>["kind"],
{ title: string; message: string; color: string; confirmLabel: string }
> = {
add: {
title: "Add booking to this train?",
message:
"The booking is assigned to this departure and wagons are auto-pinned. Adding past the pull-weight limit is allowed but flagged for review.",
color: "edr-green",
confirmLabel: "Add to train",
},
load: {
title: "Load cargo onto the train?",
message:
"Stamps the booking as loaded at this yard. The server checks the train is actually standing here.",
color: "edr-green",
confirmLabel: "Load",
},
truckToTrain: {
title: "Load as direct truck-to-train?",
message:
"Sets direct truck-to-train handover (no warehouse receipt, no GRN — the carriage acceptance sheet becomes the handover document) and loads the cargo.",
color: "blue",
confirmLabel: "Load direct",
},
unload: {
title: "Unload cargo at this yard?",
message: "Stamps the booking's arrival at this yard and frees its wagons for reuse.",
color: "orange",
confirmLabel: "Unload",
},
remove: {
title: "Remove booking from this train?",
message:
"Returns the booking to the unassigned pool, writes a removal log entry, and notifies the customer.",
color: "red",
confirmLabel: "Remove",
},
};
const runConfirmedAction = () => {
if (!confirmAction) return;
const { kind, bookingId, ref, weightTons } = confirmAction;
setConfirmAction(null);
if (kind === "add") forceAdd(bookingId, ref, weightTons ?? 0);
else if (kind === "load") doLoad(bookingId, ref);
else if (kind === "truckToTrain") doTruckToTrain(bookingId, ref);
else if (kind === "unload") doUnload(bookingId, ref);
else removeFromTrain(bookingId, ref);
};
const forceAdd = (bookingId: string, ref: string, weightTons: number) => {
const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity;
assign
@@ -649,7 +707,14 @@ export function ScheduleWorkspacePanel({
radius="md"
rightSection={<ArrowRight size={14} />}
loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
onClick={() =>
setConfirmAction({
kind: "add",
bookingId: b.id,
ref: b.reference,
weightTons: b.weightTons,
})
}
>
Add
</Button>
@@ -794,7 +859,9 @@ export function ScheduleWorkspacePanel({
loadJourney.isPending &&
loadJourney.variables?.bookingId === b.id
}
onClick={() => doLoad(b.id, ref)}
onClick={() =>
setConfirmAction({ kind: "load", bookingId: b.id, ref })
}
>
Load
</Button>
@@ -812,7 +879,13 @@ export function ScheduleWorkspacePanel({
radius="md"
leftSection={<Truck size={13} />}
loading={truckToTrainPending === b.id}
onClick={() => doTruckToTrain(b.id, ref)}
onClick={() =>
setConfirmAction({
kind: "truckToTrain",
bookingId: b.id,
ref,
})
}
>
Truck to Train
</Button>
@@ -838,7 +911,9 @@ export function ScheduleWorkspacePanel({
unloadJourney.isPending &&
unloadJourney.variables?.bookingId === b.id
}
onClick={() => doUnload(b.id, ref)}
onClick={() =>
setConfirmAction({ kind: "unload", bookingId: b.id, ref })
}
>
Unload
</Button>
@@ -857,7 +932,9 @@ export function ScheduleWorkspacePanel({
unassign.isPending &&
unassign.variables?.bookingId === b.id
}
onClick={() => removeFromTrain(b.id, ref)}
onClick={() =>
setConfirmAction({ kind: "remove", bookingId: b.id, ref })
}
>
Remove
</Button>
@@ -961,6 +1038,82 @@ export function ScheduleWorkspacePanel({
</Group>
</Stack>
</Modal>
{/* Confirm add / load / unload / remove */}
<Modal
opened={Boolean(confirmAction)}
onClose={() => setConfirmAction(null)}
centered
radius="lg"
size="md"
withCloseButton={false}
title={
confirmAction ? (
<Group gap={10} wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant="light"
color={confirmMeta[confirmAction.kind].color}
>
{confirmAction.kind === "remove" ? (
<X size={21} />
) : confirmAction.kind === "unload" ? (
<PackageOpen size={21} />
) : confirmAction.kind === "truckToTrain" ? (
<Truck size={21} />
) : (
<PackageCheck size={21} />
)}
</ThemeIcon>
<div>
<Text fw={800}>{confirmMeta[confirmAction.kind].title}</Text>
<Text size="xs" c="dimmed">
{confirmAction.ref}
</Text>
</div>
</Group>
) : null
}
>
{confirmAction ? (
<Stack gap="md">
<Text size="sm">{confirmMeta[confirmAction.kind].message}</Text>
{confirmAction.kind === "add" &&
capacity > 0 &&
used + (confirmAction.weightTons ?? 0) > capacity ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
style={{
borderRadius: 10,
background: "var(--mantine-color-red-0)",
border: "1px solid var(--mantine-color-red-2)",
}}
>
<AlertTriangle size={16} color="#B42318" />
<Text size="xs" c="red.8" fw={500}>
This add pushes the heaviest leg past the locomotive pull weight (
{(used + (confirmAction.weightTons ?? 0)).toFixed(1)}T / {capacity.toFixed(0)}T).
</Text>
</Group>
) : null}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setConfirmAction(null)}>
Cancel
</Button>
<Button
color={confirmMeta[confirmAction.kind].color}
radius="md"
onClick={runConfirmedAction}
>
{confirmMeta[confirmAction.kind].confirmLabel}
</Button>
</Group>
</Stack>
) : null}
</Modal>
</Paper>
);
}

View File

@@ -9,6 +9,7 @@ import {
Modal,
Progress,
Stack,
Tabs,
Text,
Textarea,
} from "@mantine/core";
@@ -17,8 +18,10 @@ import { isAxiosError } from "axios";
import {
AlertTriangle,
CalendarClock,
History,
MapPin,
MoreHorizontal,
PackageOpen,
Power,
PowerOff,
Replace,
@@ -36,6 +39,8 @@ import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import DetachedWagonsPanel from "@/components/trainBuilder/DetachedWagonsPanel";
import TrainHistoryPanel from "@/components/trainBuilder/TrainHistoryPanel";
import {
directionColor,
locomotiveStatusColor,
@@ -383,6 +388,21 @@ export default function TrainBuilderDetailPage() {
]}
/>
<Tabs defaultValue="build" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="build" leftSection={<TrainIcon size={14} />}>
Build
</Tabs.Tab>
<Tabs.Tab value="detached" leftSection={<PackageOpen size={14} />}>
Detached wagons
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="build" pt="md">
<Stack gap="lg">
{composition.wagonYards.length > 1 ? (
<Alert color="blue" icon={<MapPin size={16} />}>
<Stack gap={4}>
@@ -575,6 +595,22 @@ export default function TrainBuilderDetailPage() {
</Stack>
</Card>
) : null}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="detached" pt="md">
<DetachedWagonsPanel
trainId={composition.id}
canAttach={composition.editable && canAssign}
attachPending={assignWagons.isPending}
onAttach={handleAssign}
/>
</Tabs.Panel>
<Tabs.Panel value="history" pt="md">
<TrainHistoryPanel trainId={composition.id} />
</Tabs.Panel>
</Tabs>
<ChangeLocomotivesModal
composition={composition}

View File

@@ -231,6 +231,8 @@ import {
type BuildTrainPayload,
type BuiltTrainListFilters,
type BuiltTrainListResponse,
type DetachedWagonRow,
type TrainHistoryEntry,
type ScheduleConsist,
type ScheduleWagonYards,
type UpdateScheduleWagonYardsPayload,
@@ -2103,6 +2105,22 @@ export const api = {
({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id),
),
// Keys derive to ["train-builder", "history"|"detachedWagons", input] — the
// shared TRAIN_BUILDER.ROOT invalidation refreshes both after every edit.
history: endpoint<
{ id: string; page: number; pageSize: number },
PaginatedResponse<TrainHistoryEntry>
>("train-builder", "history", ({ id, page, pageSize }) =>
trainBuilderService.getHistory(id, page, pageSize).then((r) => r.data),
),
detachedWagons: endpoint<
{ id: string; page: number; pageSize: number },
PaginatedResponse<DetachedWagonRow>
>("train-builder", "detachedWagons", ({ id, page, pageSize }) =>
trainBuilderService.getDetachedWagons(id, page, pageSize).then((r) => r.data),
),
// Key derives to ["train-builder", "usedTrainNumbers"], so the shared
// TRAIN_BUILDER.ROOT invalidation refreshes it after every build/edit.
usedTrainNumbers: endpoint<void, UsedTrainNumbers>(

View File

@@ -1,3 +1,5 @@
import type { PaginatedResponse } from "@edr/types";
import { api as apiClient } from "../auth/http";
// ---------------------------------------------------------------------------
@@ -300,6 +302,29 @@ export interface ScheduleHistoryEntry {
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
/** One wagon adjustment on a built train (History tab): builder edits and trip events alike. */
export interface TrainHistoryEntry {
id: string;
action: "ADD" | "REMOVE" | "SWITCH";
subject: string | null;
yardLabel: string | null;
actor: string | null;
/** Set when the change came from a trip (schedule); null = train-builder edit. */
scheduleReference: string | null;
occurredAt: string;
}
/** Wagon last detached from this train and still loose — the re-attach shortlist. */
export interface DetachedWagonRow {
wagonId: string;
wagonNumber: string;
wagonTypeCode: string | null;
currentYardLabel: string | null;
detachedAt: string;
detachedYardLabel: string | null;
detachedBy: string | null;
}
/** One consist wagon in the schedule-yards tab: where this departure plans it vs where it stands. */
export interface ScheduleWagonYardRow {
id: string;
@@ -313,6 +338,11 @@ export interface ScheduleWagonYardRow {
/** Drop stop this departure cuts the wagon at; null = rides to the destination. */
cutYardId: string | null;
cutYardLabel: string | null;
/** true = REAL cut: the built train permanently loses the wagon at the cut yard. */
realCut: boolean;
/** Set on planned-couple rows: the pickup stop this loose wagon joins the train at. */
coupledYardId: string | null;
coupledYardLabel: string | null;
aligned: boolean;
locked: boolean;
lockReason: string | null;
@@ -327,6 +357,8 @@ export interface ScheduleWagonYardStop {
physical: number;
/** Wagons this departure cuts (detaches and leaves) at this stop. */
cut: number;
/** Loose wagons this departure couples onto the train at this stop. */
coupled: number;
}
export interface ScheduleWagonYards {
@@ -340,7 +372,16 @@ export interface ScheduleWagonYards {
export interface UpdateScheduleWagonYardsPayload {
/** Omit a field to leave it unchanged; cutYardId null clears the cut (rides to destination). */
moves: Array<{ wagonId: string; yardId?: string; cutYardId?: string | null }>;
moves?: Array<{
wagonId: string;
yardId?: string;
cutYardId?: string | null;
realCut?: boolean;
}>;
/** Loose wagons to plan-couple at a pickup stop (they must stand at that yard). */
couple?: Array<{ wagonId: string; yardId: string }>;
/** Wagon ids to drop from the couple plan. */
uncouple?: string[];
}
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
@@ -349,6 +390,14 @@ export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`),
getHistory: (id: string, page: number, pageSize: number) =>
apiClient.get<PaginatedResponse<TrainHistoryEntry>>(
`${BASE}/${id}/history?page=${page}&pageSize=${pageSize}`,
),
getDetachedWagons: (id: string, page: number, pageSize: number) =>
apiClient.get<PaginatedResponse<DetachedWagonRow>>(
`${BASE}/${id}/detached-wagons?page=${page}&pageSize=${pageSize}`,
),
/** Import/export run numbers already claimed by existing trains. */
usedTrainNumbers: () => apiClient.get<UsedTrainNumbers>(`${BASE}/used-train-numbers`),
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),

View File

@@ -568,7 +568,11 @@ export function ReadonlyBookingView({
a credit you can rebook with once the fee is settled.
{booking.consolidationPartnerId
? " This booking shares a wagon with another customer — both bookings will be cancelled, and the shared wagon's fee is charged to you, not to them."
: ""}
: ""}{" "}
<Text span fw={700} c="#B3362C">
This cannot be undone from the portal only EDR staff can revert
a cancellation request.
</Text>
</Text>
{paidPreview.isLoading && <Skeleton height={64} radius={10} />}
{paidPreview.data && (

View File

@@ -205,19 +205,6 @@ export function WagonCancellationCard({
),
});
const withdrawMutation = useMutation({
mutationFn: () => bookingsService.withdrawWagonCancellation(openRow!.id),
onSuccess: () => {
toast.success("Cancellation withdrawn — the fee invoice was voided.");
void refetch();
onBookingUpdated?.();
},
onError: (e) =>
toast.error(
apiErrorMessage(e, "Could not withdraw the cancellation. Please try again."),
),
});
const [rebookDate, setRebookDate] = useState("");
// Non-customs: container number / seal / VGM may change at rebook. Customs
// (Path B) credits are rebooked by GL from the backoffice instead.
@@ -270,9 +257,8 @@ export function WagonCancellationCard({
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
</Text>
. The cancelled wagons have left the train. Pay the fee to unlock
the rebooking credit, or withdraw the request to get the wagons
back withdrawing works only while the train still has free space
for them.
the rebooking credit. The request cannot be withdrawn from here
if it was a mistake, contact EDR staff.
</Alert>
<Group gap={8}>
<Button
@@ -283,14 +269,6 @@ export function WagonCancellationCard({
>
Pay cancellation fee
</Button>
<Button
variant="default"
radius="md"
loading={withdrawMutation.isPending}
onClick={() => withdrawMutation.mutate()}
>
Withdraw request
</Button>
</Group>
</Stack>
) : creditRow ? (

View File

@@ -15,6 +15,7 @@ import {
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
Container,
Gauge,
MapPin,
@@ -717,8 +718,9 @@ export function WagonsTab({
)}
{cancellable && hasOpenCancellation && (
<Alert color="yellow" variant="light">
A wagon cancellation is already awaiting its fee pay or withdraw it
in the wagon cancellation card before requesting another.
A wagon cancellation is already awaiting its fee pay it in the
wagon cancellation card before requesting another. Withdrawing a
request is only possible through EDR staff.
</Alert>
)}
@@ -765,6 +767,13 @@ export function WagonsTab({
paid freight for them becomes a credit you can rebook on another
day while your contract is valid.
</Text>
<Alert color="red" variant="light" radius="md" icon={<AlertCircle size={16} />}>
<Text fz={13} fw={600}>
This cannot be undone from the portal. Once requested, the wagons
leave the train and only EDR staff can revert the cancellation
make sure before you confirm.
</Text>
</Alert>
{previewMutation.isPending && <Skeleton height={64} radius={10} />}
{preview && (
<Box

View File

@@ -769,15 +769,8 @@ export const bookingsService = {
return data.data ?? data;
},
/** Void a FEE_PENDING request — the fee invoice is cancelled, nothing was released. */
withdrawWagonCancellation: async (
cancellationId: string,
): Promise<WagonCancellation> => {
const { data } = await client.post(
`/api/bookings/wagon-cancellations/${cancellationId}/withdraw`,
);
return data.data ?? data;
},
// Withdraw was removed from the portal on purpose: a customer's cancellation
// request is final — only backoffice staff (void permission) can revert it.
/** Rebook a CREDIT_AVAILABLE cancellation onto a shipment day → new PAID booking. */
rebookWagonCancellation: async (