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