mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(train-scheduling): mid-route consist changes, audit history, safer workspace
- planned couples: loose wagons join the train at a route stop, added from the schedule yards tab; capacity credits them per corridor edge and coupling validates locomotive weight/length caps per leg - real-cut toggle: a cut wagon permanently leaves the train build at its cut yard (soft cut still sits out one trip only) - fix heaviest-leg display counting a shared slot's full cargo on every spanned edge (phantom pull-weight overload on S-2026-00045) - confirmation dialogs for workspace add/load/unload/remove actions - train-builder History and Detached-wagons tabs, backed by paginated endpoints; builder detaches now always write adjustment-log rows Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and 3670 (adjustment log train_schedule_id nullable) — both applied to the dev DB by hand; watch mode does not run migrations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
booking,
|
||||||
whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed',
|
whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed',
|
||||||
whole
|
whole
|
||||||
? `All wagons 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 any day while your contract is valid.`,
|
: `${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(
|
this.logger.log(
|
||||||
@@ -759,16 +759,6 @@ export class BookingWagonCancellationService {
|
|||||||
if (!source.contractId) {
|
if (!source.contractId) {
|
||||||
throw new BadRequestException('The original booking has no contract to rebook under.');
|
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);
|
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
|
||||||
// Same currency as the source booking — the credit is in it.
|
// Same currency as the source booking — the credit is in it.
|
||||||
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
|
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
|
// System actor: carries the create-booking key so the GL gate passes on
|
||||||
// Path B (customs-clearance) contracts; harmless on Path A.
|
// Path B (customs-clearance) contracts; harmless on Path A.
|
||||||
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
|
{ 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;
|
const newBookingId = created.booking.id;
|
||||||
|
|
||||||
|
|||||||
@@ -657,16 +657,18 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('wagon-cancellations/:cancellationId/withdraw')
|
@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(
|
async withdrawWagonCancellation(
|
||||||
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
|
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
await this.assertWagonCancellationActor(
|
// Customer cancellations are irreversible from the portal — no owner
|
||||||
cancellationId,
|
// fallback here. Only staff holding the void permission can revert one.
|
||||||
user,
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationVoid)) {
|
||||||
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
throw new ForbiddenException(
|
||||||
);
|
'A cancellation request cannot be withdrawn from the portal — contact EDR staff.',
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.wagonCancellationService.withdraw(cancellationId);
|
return this.wagonCancellationService.withdraw(cancellationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,14 @@ export class ContractBookingService {
|
|||||||
dto: CreateBookingUnderContractDto,
|
dto: CreateBookingUnderContractDto,
|
||||||
user?: { id?: string } | null,
|
user?: { id?: string } | null,
|
||||||
actorPermissions?: unknown,
|
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> {
|
): Promise<CreateBookingUnderContractResult> {
|
||||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||||
@@ -180,8 +188,13 @@ export class ContractBookingService {
|
|||||||
actorPermissions != null &&
|
actorPermissions != null &&
|
||||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||||
|
|
||||||
await this.assertNotExpired(contract);
|
if (!opts?.allowExpiredContract) await this.assertNotExpired(contract);
|
||||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
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
|
// 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),
|
// booking reached a terminal state (e.g. payment expired without shipping),
|
||||||
@@ -1203,6 +1216,7 @@ export class ContractBookingService {
|
|||||||
contract: Contract,
|
contract: Contract,
|
||||||
isGlActor: boolean,
|
isGlActor: boolean,
|
||||||
isInitiate = false,
|
isInitiate = false,
|
||||||
|
allowExpired = false,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Suspended contracts are frozen for everyone, GL included — say so instead
|
// Suspended contracts are frozen for everyone, GL included — say so instead
|
||||||
// of letting the executed-status check below give a misleading reason.
|
// 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
|
// No contract clearance cycle exists on either kind now — clearance runs
|
||||||
// on the booking, so an executed/active contract is the only gate here.
|
// 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(
|
throw new BadRequestException(
|
||||||
'Contract must be fully executed before booking a shipment.',
|
'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.
|
// 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(
|
throw new BadRequestException(
|
||||||
'Contract must be fully executed before booking a shipment.',
|
'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(['trainScheduleId'])
|
||||||
@Index(['trainId'])
|
@Index(['trainId'])
|
||||||
export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
||||||
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
/** Null when the change was made from the train builder with no live schedule. */
|
||||||
trainScheduleId!: string;
|
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||||
|
trainScheduleId!: string | null;
|
||||||
|
|
||||||
@Column({ name: 'train_id', type: 'uuid' })
|
@Column({ name: 'train_id', type: 'uuid' })
|
||||||
trainId!: string;
|
trainId!: string;
|
||||||
|
|||||||
@@ -139,6 +139,24 @@ export class TrainSchedule extends BaseEntity {
|
|||||||
@Column({ name: 'planned_wagon_cut_yards', type: 'jsonb', nullable: true })
|
@Column({ name: 'planned_wagon_cut_yards', type: 'jsonb', nullable: true })
|
||||||
plannedWagonCutYards?: Record<string, string> | null;
|
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`. */
|
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
|
||||||
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
||||||
bookingWindowStatus!: string;
|
bookingWindowStatus!: string;
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import {
|
|||||||
CorridorBudget,
|
CorridorBudget,
|
||||||
CorridorLeg,
|
CorridorLeg,
|
||||||
OverageTolerance,
|
OverageTolerance,
|
||||||
|
addCoupledWagons,
|
||||||
stopYardsFor,
|
stopYardsFor,
|
||||||
subtractCutWagons,
|
subtractCutWagons,
|
||||||
} from './corridor-capacity.util';
|
} from './corridor-capacity.util';
|
||||||
@@ -5064,6 +5065,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
stock.byYardId,
|
stock.byYardId,
|
||||||
budget.stops,
|
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
|
// Debit what is already committed, per boarding yard and wagon type — the
|
||||||
// same bookings the corridor budget subtracted. A booking with no resolvable
|
// same bookings the corridor budget subtracted. A booking with no resolvable
|
||||||
// wagon type still occupies steel, so it drains any type at its yard.
|
// 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
|
// ponytail: the wagon-type stock ledger stays cut-blind; bucket
|
||||||
// builtTrainStock by (yard, reach) if mixed-type cut trains appear.
|
// builtTrainStock by (yard, reach) if mixed-type cut trains appear.
|
||||||
subtractCutWagons(budget, schedule.plannedWagonCutYards);
|
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)) {
|
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
|
||||||
budget.subtract(
|
budget.subtract(
|
||||||
this.needFor(b, wagonDims),
|
this.needFor(b, wagonDims),
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export class TrainSchedulingController {
|
|||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateScheduleWagonYardsDto,
|
@Body() dto: UpdateScheduleWagonYardsDto,
|
||||||
) {
|
) {
|
||||||
return this.trainSchedulingService.updateScheduleWagonYards(id, dto.moves);
|
return this.trainSchedulingService.updateScheduleWagonYards(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/adjust-consist")
|
@Post("schedules/:id/adjust-consist")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
addCoupledWagons,
|
||||||
Capacity,
|
Capacity,
|
||||||
CorridorBudget,
|
CorridorBudget,
|
||||||
orientStopsToSchedule,
|
orientStopsToSchedule,
|
||||||
@@ -220,3 +221,39 @@ describe('corridor-capacity.util — stop orientation and fallback', () => {
|
|||||||
expect(stopYardsFor(null, 'a', 'c')).toEqual(['a', 'c']);
|
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. */
|
/** Overage a locomotive may absorb beyond its base caps. */
|
||||||
export interface OverageTolerance {
|
export interface OverageTolerance {
|
||||||
weightTons: number;
|
weightTons: number;
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
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 {
|
export class ScheduleWagonYardMoveDto {
|
||||||
@ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." })
|
@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)
|
@ValidateIf((o: ScheduleWagonYardMoveDto) => o.cutYardId !== null)
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
cutYardId?: string | null;
|
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 {
|
export class UpdateScheduleWagonYardsDto {
|
||||||
@@ -33,9 +62,32 @@ export class UpdateScheduleWagonYardsDto {
|
|||||||
description:
|
description:
|
||||||
'Wagon → planned boarding yard for THIS schedule only. Physical wagon yards are untouched; dispatch requires both to agree.',
|
'Wagon → planned boarding yard for THIS schedule only. Physical wagon yards are untouched; dispatch requires both to agree.',
|
||||||
})
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMaxSize(500)
|
@ArrayMaxSize(500)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => ScheduleWagonYardMoveDto)
|
@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,
|
type WagonPlanSlot,
|
||||||
} from '../utils/wagon-plan.util';
|
} from '../utils/wagon-plan.util';
|
||||||
import {
|
import {
|
||||||
|
addCoupledWagons,
|
||||||
CorridorBudget,
|
CorridorBudget,
|
||||||
orientStopsToSchedule,
|
orientStopsToSchedule,
|
||||||
subtractCutWagons,
|
subtractCutWagons,
|
||||||
} from '../corridor-capacity.util';
|
} from '../corridor-capacity.util';
|
||||||
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
|
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
|
||||||
|
import { computeEdgeLoads } from '../edge-load.util';
|
||||||
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
|
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
|
||||||
import {
|
import {
|
||||||
defaultPlannedWagonYards,
|
defaultPlannedWagonYards,
|
||||||
@@ -2199,7 +2201,16 @@ export class TrainSchedulingService {
|
|||||||
// so their tare rides on top of the binding edge.
|
// so their tare rides on top of the binding edge.
|
||||||
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
|
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
|
||||||
const scheduleStops = await this.stopYardsForSchedule(schedule);
|
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);
|
const stopLabels = await this.yardLabelMap(scheduleStops);
|
||||||
// Each edge is its own consist — name EVERY leg that breaks the limit,
|
// 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.
|
// 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 },
|
{ 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 ?? []) {
|
for (const sb of schedule.scheduleBookings ?? []) {
|
||||||
await this.bookingsRepository.updateSchedulingFields(
|
await this.bookingsRepository.updateSchedulingFields(
|
||||||
sb.bookingId,
|
sb.bookingId,
|
||||||
@@ -4407,19 +4468,57 @@ export class TrainSchedulingService {
|
|||||||
// bound to the schedule is riding empty. Matching against ALL passed
|
// bound to the schedule is riding empty. Matching against ALL passed
|
||||||
// yards, not just this one, self-heals skipped checkpoint logs.
|
// yards, not just this one, self-heals skipped checkpoint logs.
|
||||||
const cutPlan = schedule.plannedWagonCutYards ?? {};
|
const cutPlan = schedule.plannedWagonCutYards ?? {};
|
||||||
|
const realCutIds = new Set(schedule.plannedWagonRealCuts ?? []);
|
||||||
|
const builtTrainId = schedule.trainSet?.trainId ?? null;
|
||||||
const cutNow = Object.entries(cutPlan).filter(([, yardId]) =>
|
const cutNow = Object.entries(cutPlan).filter(([, yardId]) =>
|
||||||
passedYardIds.includes(yardId),
|
passedYardIds.includes(yardId),
|
||||||
);
|
);
|
||||||
|
let realCutHappened = false;
|
||||||
for (const [wagonId, cutYardId] of cutNow) {
|
for (const [wagonId, cutYardId] of cutNow) {
|
||||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||||
// Already settled earlier (or re-pinned elsewhere) — not ours to move.
|
// Already settled earlier (or re-pinned elsewhere) — not ours to move.
|
||||||
if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue;
|
if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue;
|
||||||
await manager.getRepository(Wagon).update(wagonId, {
|
if (realCutIds.has(wagonId) && builtTrainId) {
|
||||||
currentYardId: cutYardId,
|
// REAL cut: the built train permanently loses the wagon here.
|
||||||
currentTrainScheduleId: null,
|
await manager.getRepository(Wagon).update(wagonId, {
|
||||||
trainSetWagonId: null,
|
currentYardId: cutYardId,
|
||||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
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(
|
await manager.getRepository(WagonMovement).save(
|
||||||
manager.getRepository(WagonMovement).create({
|
manager.getRepository(WagonMovement).create({
|
||||||
wagonId,
|
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
|
await manager
|
||||||
.getRepository(Wagon)
|
.getRepository(Wagon)
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
@@ -4657,14 +4815,52 @@ export class TrainSchedulingService {
|
|||||||
schedule.plannedWagonCutYards?.[wagon.id] ??
|
schedule.plannedWagonCutYards?.[wagon.id] ??
|
||||||
slot.alightYardId ??
|
slot.alightYardId ??
|
||||||
schedule.destinationStationId;
|
schedule.destinationStationId;
|
||||||
await manager.getRepository(Wagon).update(wagon.id, {
|
const ownerTrainId = wagon.trainId;
|
||||||
currentTrainScheduleId: null,
|
const isRealCut =
|
||||||
trainSetWagonId: null,
|
(schedule.plannedWagonRealCuts ?? []).includes(wagon.id) &&
|
||||||
// A wagon that belongs to a built train stays coupled to it (ASSIGNED);
|
schedule.plannedWagonCutYards?.[wagon.id] != null;
|
||||||
// only loose wagons return to the open AVAILABLE pool.
|
if (isRealCut && ownerTrainId) {
|
||||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
// Arrival fallback for a journey logged without mid-route
|
||||||
currentYardId: settleYardId,
|
// 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.
|
// Ledger: the wagon rode this schedule to its settle yard.
|
||||||
const slotAllocations = slot.allocations ?? [];
|
const slotAllocations = slot.allocations ?? [];
|
||||||
await manager.getRepository(WagonMovement).save(
|
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.
|
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
|
||||||
const stations = await this.buildScheduleStations(schedule);
|
const stations = await this.buildScheduleStations(schedule);
|
||||||
const finalStation = stations[stations.length - 1];
|
const finalStation = stations[stations.length - 1];
|
||||||
@@ -5177,7 +5445,7 @@ export class TrainSchedulingService {
|
|||||||
// above — the whole-route totals here are informational (summary) only. The
|
// above — the whole-route totals here are informational (summary) only. The
|
||||||
// locomotive checks below also compare per edge: a train is never heavier
|
// locomotive checks below also compare per edge: a train is never heavier
|
||||||
// than its heaviest leg, so disjoint legs must not be summed.
|
// 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(
|
const maxEdgeGrossTons = roundTons(
|
||||||
Math.max(0, ...perEdgeUsage.map((e) => e.grossWeightTons)),
|
Math.max(0, ...perEdgeUsage.map((e) => e.grossWeightTons)),
|
||||||
);
|
);
|
||||||
@@ -5466,6 +5734,32 @@ export class TrainSchedulingService {
|
|||||||
return rows[0]?.planned_wagon_yards ?? {};
|
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(
|
private async countFleetAvailability(
|
||||||
originYardId: string,
|
originYardId: string,
|
||||||
targetScheduleId?: string,
|
targetScheduleId?: string,
|
||||||
@@ -5673,8 +5967,12 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
|
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
|
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 cutPlan = pinSchedule?.plannedWagonCutYards ?? {};
|
||||||
|
const coupleIds = new Set(Object.keys(couplePlan));
|
||||||
|
|
||||||
const unpinnable = this.findUnpinnableWagonSlots(
|
const unpinnable = this.findUnpinnableWagonSlots(
|
||||||
planSlots,
|
planSlots,
|
||||||
@@ -5686,6 +5984,7 @@ export class TrainSchedulingService {
|
|||||||
stops,
|
stops,
|
||||||
plannedYards,
|
plannedYards,
|
||||||
cutPlan,
|
cutPlan,
|
||||||
|
coupleIds,
|
||||||
);
|
);
|
||||||
if (unpinnable.length) {
|
if (unpinnable.length) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
@@ -5710,6 +6009,7 @@ export class TrainSchedulingService {
|
|||||||
plannedYards,
|
plannedYards,
|
||||||
cutPlan,
|
cutPlan,
|
||||||
stops,
|
stops,
|
||||||
|
coupleIds,
|
||||||
);
|
);
|
||||||
if (!physical) continue;
|
if (!physical) continue;
|
||||||
|
|
||||||
@@ -5759,8 +6059,13 @@ export class TrainSchedulingService {
|
|||||||
builtTrainId,
|
builtTrainId,
|
||||||
pinnedToScheduleIds,
|
pinnedToScheduleIds,
|
||||||
stops,
|
stops,
|
||||||
targetSchedule?.plannedWagonYards ?? {},
|
// Couples count as boarding entries at their couple yard.
|
||||||
|
{
|
||||||
|
...(targetSchedule?.plannedWagonYards ?? {}),
|
||||||
|
...(targetSchedule?.plannedWagonCouples ?? {}),
|
||||||
|
},
|
||||||
targetSchedule?.plannedWagonCutYards ?? {},
|
targetSchedule?.plannedWagonCutYards ?? {},
|
||||||
|
new Set(Object.keys(targetSchedule?.plannedWagonCouples ?? {})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5795,6 +6100,7 @@ export class TrainSchedulingService {
|
|||||||
stops: string[] = [],
|
stops: string[] = [],
|
||||||
plannedYards: PlannedWagonYards = {},
|
plannedYards: PlannedWagonYards = {},
|
||||||
cutPlan: Record<string, string> = {},
|
cutPlan: Record<string, string> = {},
|
||||||
|
coupleIds: Set<string> = new Set(),
|
||||||
): string[] {
|
): string[] {
|
||||||
const violations: string[] = [];
|
const violations: string[] = [];
|
||||||
// One physical wagon may serve several slots whose leg spans don't overlap
|
// One physical wagon may serve several slots whose leg spans don't overlap
|
||||||
@@ -5817,6 +6123,7 @@ export class TrainSchedulingService {
|
|||||||
plannedYards,
|
plannedYards,
|
||||||
cutPlan,
|
cutPlan,
|
||||||
stops,
|
stops,
|
||||||
|
coupleIds,
|
||||||
);
|
);
|
||||||
if (!physical) {
|
if (!physical) {
|
||||||
violations.push(
|
violations.push(
|
||||||
@@ -5850,6 +6157,7 @@ export class TrainSchedulingService {
|
|||||||
plannedYards: PlannedWagonYards = {},
|
plannedYards: PlannedWagonYards = {},
|
||||||
cutPlan: Record<string, string> = {},
|
cutPlan: Record<string, string> = {},
|
||||||
stops: string[] = [],
|
stops: string[] = [],
|
||||||
|
coupleIds: Set<string> = new Set(),
|
||||||
): Wagon | undefined {
|
): Wagon | undefined {
|
||||||
// How far down the route a wagon rides before this schedule cuts it:
|
// 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
|
// 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
|
// consist views draw the schedule exactly like the train builder; a schedule
|
||||||
// created with reverseWagonOrder pins back-to-front (physically-last wagon
|
// created with reverseWagonOrder pins back-to-front (physically-last wagon
|
||||||
// takes slot #1). Unsequenced wagons sort after every sequenced one.
|
// 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(
|
const consistYards = new Set(
|
||||||
wagons
|
wagons
|
||||||
.filter((w) => w.trainId === builtTrainId && scheduleYardOf(plannedYards, w))
|
.filter((w) => belongsToRun(w) && scheduleYardOf(plannedYards, w))
|
||||||
.map((w) => scheduleYardOf(plannedYards, w) as string),
|
.map((w) => scheduleYardOf(plannedYards, w) as string),
|
||||||
);
|
);
|
||||||
// Split consist: a slot boarding at a given yard must take a wagon that
|
// Split consist: a slot boarding at a given yard must take a wagon that
|
||||||
@@ -5901,7 +6215,7 @@ export class TrainSchedulingService {
|
|||||||
const candidates = wagons
|
const candidates = wagons
|
||||||
.filter(
|
.filter(
|
||||||
(w) =>
|
(w) =>
|
||||||
w.trainId === builtTrainId &&
|
belongsToRun(w) &&
|
||||||
w.wagonTypeId === slot.wagonTypeId &&
|
w.wagonTypeId === slot.wagonTypeId &&
|
||||||
spanFree(w.id) &&
|
spanFree(w.id) &&
|
||||||
// A wagon cut before the slot's alight stop cannot serve it.
|
// A wagon cut before the slot's alight stop cannot serve it.
|
||||||
@@ -6076,16 +6390,19 @@ export class TrainSchedulingService {
|
|||||||
builtTrainId: string,
|
builtTrainId: string,
|
||||||
scheduleId?: string,
|
scheduleId?: string,
|
||||||
): Promise<WagonStock> {
|
): Promise<WagonStock> {
|
||||||
const [wagons, plan] = await Promise.all([
|
const [wagons, plan, cutPlan, couplePlan] = await Promise.all([
|
||||||
this.dataSource.getRepository(Wagon).find({
|
this.dataSource.getRepository(Wagon).find({
|
||||||
where: { trainId: builtTrainId },
|
where: { trainId: builtTrainId },
|
||||||
relations: { wagonType: true },
|
relations: { wagonType: true },
|
||||||
}),
|
}),
|
||||||
this.plannedWagonYardsOf(scheduleId),
|
this.plannedWagonYardsOf(scheduleId),
|
||||||
|
this.plannedWagonCutYardsOf(scheduleId),
|
||||||
|
this.plannedWagonCouplesOf(scheduleId),
|
||||||
]);
|
]);
|
||||||
const remainingByTypeId = new Map<string, number>();
|
const remainingByTypeId = new Map<string, number>();
|
||||||
const codesByTypeId = new Map<string, string>();
|
const codesByTypeId = new Map<string, string>();
|
||||||
const byYardId = new Map<string, Map<string, number>>();
|
const byYardId = new Map<string, Map<string, number>>();
|
||||||
|
const cutWagons: NonNullable<WagonStock['cutWagons']> = [];
|
||||||
for (const wagon of wagons) {
|
for (const wagon of wagons) {
|
||||||
remainingByTypeId.set(
|
remainingByTypeId.set(
|
||||||
wagon.wagonTypeId,
|
wagon.wagonTypeId,
|
||||||
@@ -6099,6 +6416,38 @@ export class TrainSchedulingService {
|
|||||||
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
|
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
|
||||||
byYardId.set(yardId, perType);
|
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
|
// Single-yard consist (the overwhelming majority): the whole train is
|
||||||
// offered at every boarding yard exactly as before — the per-yard split is
|
// offered at every boarding yard exactly as before — the per-yard split is
|
||||||
@@ -6108,6 +6457,7 @@ export class TrainSchedulingService {
|
|||||||
remainingByTypeId,
|
remainingByTypeId,
|
||||||
codesByTypeId,
|
codesByTypeId,
|
||||||
...(byYardId.size > 1 ? { byYardId } : {}),
|
...(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 pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
|
||||||
const plan = schedule.plannedWagonYards ?? {};
|
const plan = schedule.plannedWagonYards ?? {};
|
||||||
const cutPlan = schedule.plannedWagonCutYards ?? {};
|
const cutPlan = schedule.plannedWagonCutYards ?? {};
|
||||||
|
const couplePlan = schedule.plannedWagonCouples ?? {};
|
||||||
|
const realCuts = new Set(schedule.plannedWagonRealCuts ?? []);
|
||||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||||
where: { trainId: builtTrain.id },
|
where: { trainId: builtTrain.id },
|
||||||
relations: { wagonType: true, currentYard: true },
|
relations: { wagonType: true, currentYard: true },
|
||||||
order: { sequenceNumber: 'ASC' },
|
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(
|
const lockedIds = new Set(
|
||||||
(schedule.trainSet?.wagons ?? [])
|
(schedule.trainSet?.wagons ?? [])
|
||||||
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
||||||
@@ -6580,7 +6939,7 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
const offRouteYardIds = [
|
const offRouteYardIds = [
|
||||||
...new Set(
|
...new Set(
|
||||||
wagons
|
[...wagons, ...coupleWagons]
|
||||||
.flatMap((w) => [scheduleYardOf(plan, w), w.currentYardId])
|
.flatMap((w) => [scheduleYardOf(plan, w), w.currentYardId])
|
||||||
.filter((y): y is string => !!y && !stops.some((s) => s.yardId === y)),
|
.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 rows = wagons.map((w) => {
|
||||||
const plannedYardId = scheduleYardOf(plan, 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);
|
const locked = lockedIds.has(w.id);
|
||||||
return {
|
return {
|
||||||
id: w.id,
|
id: w.id,
|
||||||
@@ -6608,13 +6967,42 @@ export class TrainSchedulingService {
|
|||||||
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
|
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
|
||||||
plannedYardId,
|
plannedYardId,
|
||||||
plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null,
|
plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null,
|
||||||
cutYardId,
|
cutYardId: cutYardId as string | null,
|
||||||
cutYardLabel: cutYardId ? labels.get(cutYardId) ?? cutYardId : 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,
|
aligned: plannedYardId === w.currentYardId,
|
||||||
locked,
|
locked,
|
||||||
lockReason: locked ? 'Carries cargo booked on this schedule' : null,
|
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) => ({
|
const perStop = stops.map((s) => ({
|
||||||
yardId: s.yardId,
|
yardId: s.yardId,
|
||||||
label: s.label,
|
label: s.label,
|
||||||
@@ -6622,6 +7010,7 @@ export class TrainSchedulingService {
|
|||||||
planned: rows.filter((r) => r.plannedYardId === s.yardId).length,
|
planned: rows.filter((r) => r.plannedYardId === s.yardId).length,
|
||||||
physical: rows.filter((r) => r.physicalYardId === s.yardId).length,
|
physical: rows.filter((r) => r.physicalYardId === s.yardId).length,
|
||||||
cut: rows.filter((r) => r.cutYardId === s.yardId).length,
|
cut: rows.filter((r) => r.cutYardId === s.yardId).length,
|
||||||
|
coupled: rows.filter((r) => r.coupledYardId === s.yardId).length,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
scheduleId,
|
scheduleId,
|
||||||
@@ -6634,17 +7023,29 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-plan which yard this departure boards wagons from (`yardId`) and/or
|
* Re-plan this departure's consist plan: boarding yard (`yardId`), cut yard
|
||||||
* where it cuts them mid-route (`cutYardId`; null clears — the wagon rides
|
* (`cutYardId`; null clears), the `realCut` flag (permanent removal from the
|
||||||
* to the destination). Only DRAFT/SCHEDULED schedules, only the train's own
|
* built train at the cut), and mid-route COUPLES of loose wagons
|
||||||
* wagons; boarding only at pickup stops, cutting only at drop stops after
|
* (`couple`/`uncouple`). Only DRAFT/SCHEDULED schedules; boarding/coupling
|
||||||
* the boarding yard and never before allocated cargo's destination.
|
* 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.
|
* Physical yards are untouched — the train builder owns those.
|
||||||
*/
|
*/
|
||||||
async updateScheduleWagonYards(
|
async updateScheduleWagonYards(
|
||||||
scheduleId: string,
|
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);
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
const builtTrain = schedule.trainSet?.train;
|
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 pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
|
||||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||||
where: { trainId: builtTrain.id },
|
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 wagonById = new Map(wagons.map((w) => [w.id, w]));
|
||||||
const lockedIds = new Set(
|
const lockedIds = new Set(
|
||||||
@@ -6693,6 +7094,8 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
const plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) };
|
const plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) };
|
||||||
const cutPlan: Record<string, string> = { ...(schedule.plannedWagonCutYards ?? {}) };
|
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) {
|
for (const move of moves) {
|
||||||
const wagon = wagonById.get(move.wagonId);
|
const wagon = wagonById.get(move.wagonId);
|
||||||
if (!wagon) {
|
if (!wagon) {
|
||||||
@@ -6713,6 +7116,7 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
if (move.cutYardId === null) {
|
if (move.cutYardId === null) {
|
||||||
delete cutPlan[wagon.id];
|
delete cutPlan[wagon.id];
|
||||||
|
realCuts.delete(wagon.id);
|
||||||
} else if (move.cutYardId !== undefined) {
|
} else if (move.cutYardId !== undefined) {
|
||||||
if (!dropYardIds.has(move.cutYardId)) {
|
if (!dropYardIds.has(move.cutYardId)) {
|
||||||
throw new BadRequestException(
|
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
|
// A flag whose cut disappeared (any path) must not survive.
|
||||||
.getRepository(TrainSchedule)
|
for (const id of [...realCuts]) if (!cutPlan[id]) realCuts.delete(id);
|
||||||
.update(scheduleId, { plannedWagonYards: plan, plannedWagonCutYards: cutPlan });
|
|
||||||
|
// ── 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
|
// ponytail: per-stop over-booking check counts bookings boarding at the
|
||||||
// stop against wagons planned there, ignoring leg sharing — a warning, not
|
// stop against wagons planned there, ignoring leg sharing — a warning, not
|
||||||
@@ -7535,6 +8078,10 @@ export class TrainSchedulingService {
|
|||||||
trainSetWagonId: null,
|
trainSetWagonId: null,
|
||||||
currentTrainScheduleId: null,
|
currentTrainScheduleId: null,
|
||||||
currentYardId,
|
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) {
|
for (const wagon of removed) {
|
||||||
await manager.getRepository(Wagon).update(wagon.id, detachPatch);
|
await manager.getRepository(Wagon).update(wagon.id, detachPatch);
|
||||||
@@ -8171,8 +8718,10 @@ export class TrainSchedulingService {
|
|||||||
weightTons: Number.POSITIVE_INFINITY,
|
weightTons: Number.POSITIVE_INFINITY,
|
||||||
lengthMeters: 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);
|
subtractCutWagons(budget, schedule.plannedWagonCutYards);
|
||||||
|
addCoupledWagons(budget, schedule.plannedWagonCouples);
|
||||||
for (const sb of schedule.scheduleBookings ?? []) {
|
for (const sb of schedule.scheduleBookings ?? []) {
|
||||||
if (!sb.booking) continue;
|
if (!sb.booking) continue;
|
||||||
budget.subtract(
|
budget.subtract(
|
||||||
@@ -8845,6 +9394,24 @@ export class TrainSchedulingService {
|
|||||||
// enforcement; coupled-but-empty consist wagons ride every edge.
|
// enforcement; coupled-but-empty consist wagons ride every edge.
|
||||||
const heaviestLeg = schedule.trainSet
|
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(
|
const usage = maxEdgeConsistUsage(
|
||||||
[
|
[
|
||||||
...(schedule.trainSet.wagons ?? []).map((w) => ({
|
...(schedule.trainSet.wagons ?? []).map((w) => ({
|
||||||
@@ -8864,7 +9431,8 @@ export class TrainSchedulingService {
|
|||||||
allocations: [],
|
allocations: [],
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
this.mapScheduleStops(schedule).map((s) => s.yardId),
|
legStops,
|
||||||
|
legByBookingId,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
grossWeightTons: roundTons(usage.grossWeightTons),
|
grossWeightTons: roundTons(usage.grossWeightTons),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
sumWagonsRequired,
|
sumWagonsRequired,
|
||||||
validate20ftContainerRules,
|
validate20ftContainerRules,
|
||||||
validateContainerPlacements,
|
validateContainerPlacements,
|
||||||
|
validateMixedTrainLimitsPerEdge,
|
||||||
validateWagonCargoExclusivity,
|
validateWagonCargoExclusivity,
|
||||||
} from './wagon-plan.util';
|
} from './wagon-plan.util';
|
||||||
|
|
||||||
@@ -412,4 +413,91 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', ()
|
|||||||
loadedWagonCount: 2,
|
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 label = (i: number) => stopLabels?.[i] ?? stops[i];
|
||||||
const violations = new Set<string>();
|
const violations = new Set<string>();
|
||||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||||||
const active = wagonPlan.filter(
|
// A shared slot rides the UNION of its cargo legs, but only carries each
|
||||||
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
// 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;
|
if (!active.length) continue;
|
||||||
for (const violation of validateMixedTrainLimits(
|
for (const violation of validateMixedTrainLimits(
|
||||||
active,
|
active,
|
||||||
@@ -684,6 +691,40 @@ export type EdgeUsageSlot = Pick<
|
|||||||
allocations?: unknown[];
|
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. */
|
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
|
||||||
function slotSpans(
|
function slotSpans(
|
||||||
wagonPlan: EdgeUsageSlot[],
|
wagonPlan: EdgeUsageSlot[],
|
||||||
@@ -708,8 +749,10 @@ function slotSpans(
|
|||||||
export function maxEdgeConsistUsage(
|
export function maxEdgeConsistUsage(
|
||||||
wagonPlan: EdgeUsageSlot[],
|
wagonPlan: EdgeUsageSlot[],
|
||||||
stops: string[],
|
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 } {
|
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
|
||||||
return perEdgeConsistUsage(wagonPlan, stops).reduce(
|
return perEdgeConsistUsage(wagonPlan, stops, legs).reduce(
|
||||||
(max, e) => ({
|
(max, e) => ({
|
||||||
grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons),
|
grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons),
|
||||||
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
|
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
|
||||||
@@ -737,12 +780,19 @@ export type EdgeConsistUsage = {
|
|||||||
export function perEdgeConsistUsage(
|
export function perEdgeConsistUsage(
|
||||||
wagonPlan: EdgeUsageSlot[],
|
wagonPlan: EdgeUsageSlot[],
|
||||||
stops: string[],
|
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[] {
|
): EdgeConsistUsage[] {
|
||||||
|
const edgeCount = Math.max(1, stops.length - 1);
|
||||||
const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({
|
const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({
|
||||||
edge,
|
edge,
|
||||||
grossWeightTons: slots.reduce(
|
grossWeightTons: slots.reduce(
|
||||||
(sum, w) =>
|
(sum, w) =>
|
||||||
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
|
sum + Number(w.tareWeightTons ?? 0) + slotCargoOnEdge(w, edge, edgeCount, legs),
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
|
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ export type WagonStock = {
|
|||||||
* math.
|
* math.
|
||||||
*/
|
*/
|
||||||
byYardId?: Map<string, Map<string, number>>;
|
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 = {
|
export type FlexPlanResult = {
|
||||||
@@ -324,6 +331,16 @@ export function planWagonsWithStock(params: {
|
|||||||
}
|
}
|
||||||
return row;
|
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 availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
|
||||||
const pool = poolOf(leg);
|
const pool = poolOf(leg);
|
||||||
const total = totalFor(wagonTypeId, pool);
|
const total = totalFor(wagonTypeId, pool);
|
||||||
|
|||||||
@@ -159,3 +159,52 @@ describe('WagonStockLedger — multi-yard consist', () => {
|
|||||||
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53);
|
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);
|
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
|
* 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.
|
* 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 { CurrentUser } from '@edr/api-common';
|
||||||
|
|
||||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
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 type { AuthUserPayload } from '../../common/resolve-auth-user-id';
|
||||||
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
@@ -78,6 +79,24 @@ export class TrainBuilderController {
|
|||||||
return this.trainBuilderService.getComposition(id);
|
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')
|
@Put(':id/locomotives')
|
||||||
@FleetManage(FREIGHT_PERMS.trains.changeLocomotives)
|
@FleetManage(FREIGHT_PERMS.trains.changeLocomotives)
|
||||||
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })
|
@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]));
|
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. */
|
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
|
||||||
async getComposition(id: string) {
|
async getComposition(id: string) {
|
||||||
const train = await this.dataSource.getRepository(Train).findOne({
|
const train = await this.dataSource.getRepository(Train).findOne({
|
||||||
@@ -721,6 +832,7 @@ export class TrainBuilderService {
|
|||||||
toYardId: yardId,
|
toYardId: yardId,
|
||||||
kind: WagonMovementKind.Maintenance,
|
kind: WagonMovementKind.Maintenance,
|
||||||
note: notes.movementNote,
|
note: notes.movementNote,
|
||||||
|
movedByUserId: userId,
|
||||||
occurredAt: new Date(),
|
occurredAt: new Date(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1097,15 +1209,14 @@ export class TrainBuilderService {
|
|||||||
.getRepository(TrainSet)
|
.getRepository(TrainSet)
|
||||||
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
|
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
|
||||||
}
|
}
|
||||||
if (!schedule) return null;
|
// 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
|
||||||
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
|
// which wagon, when, where), and the detached-wagons tab reads it back.
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||||
changes.map((c) =>
|
changes.map((c) =>
|
||||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||||
trainScheduleId: schedule.id,
|
trainScheduleId: schedule?.id ?? null,
|
||||||
trainId,
|
trainId,
|
||||||
action: c.action,
|
action: c.action,
|
||||||
wagonId: c.wagonId,
|
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
|
// The FULL/reopen decision must run AFTER the transaction commits — see
|
||||||
// reconcileWindowAfterConsistChange.
|
// reconcileWindowAfterConsistChange.
|
||||||
return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' };
|
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,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Modal,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
|
Pagination,
|
||||||
Paper,
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
import { isAxiosError } from "axios";
|
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 { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
@@ -56,6 +63,21 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
const [pending, setPending] = useState<Record<string, string>>({});
|
const [pending, setPending] = useState<Record<string, string>>({});
|
||||||
/** wagonId → cut yard queued but not yet saved; null = queued clear (rides to destination). */
|
/** wagonId → cut yard queued but not yet saved; null = queued clear (rides to destination). */
|
||||||
const [pendingCut, setPendingCut] = useState<Record<string, string | null>>({});
|
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 [bulkType, setBulkType] = useState<string | null>(null);
|
||||||
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
|
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
|
||||||
const [bulkTo, setBulkTo] = 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 editable = Boolean(canEdit && data?.editable);
|
||||||
const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]);
|
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 yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label }));
|
||||||
const yardLabel = (id: string | null) =>
|
const yardLabel = (id: string | null) =>
|
||||||
(data?.stops ?? []).find((s) => s.yardId === id)?.label ??
|
(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 effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId;
|
||||||
const effectiveCut = (w: ScheduleWagonYardRow) =>
|
const effectiveCut = (w: ScheduleWagonYardRow) =>
|
||||||
w.id in pendingCut ? pendingCut[w.id] : w.cutYardId;
|
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) =>
|
const stopIndexOf = (yardId: string | null) =>
|
||||||
yardId == null ? -1 : (data?.stops ?? []).findIndex((s) => s.yardId === yardId);
|
yardId == null ? -1 : (data?.stops ?? []).findIndex((s) => s.yardId === yardId);
|
||||||
/** Drop stops a wagon boarding at `boardYardId` can be cut at — strictly after
|
/** 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(
|
cut: (data?.wagons ?? []).filter(
|
||||||
(w) => (w.id in pendingCut ? pendingCut[w.id] : w.cutYardId) === s.yardId,
|
(w) => (w.id in pendingCut ? pendingCut[w.id] : w.cutYardId) === s.yardId,
|
||||||
).length,
|
).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 typeOptions = useMemo(() => {
|
||||||
const seen = new Map<string, string>();
|
const seen = new Map<string, string>();
|
||||||
@@ -117,7 +179,14 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
return [...seen].map(([value, label]) => ({ value, label }));
|
return [...seen].map(([value, label]) => ({ value, label }));
|
||||||
}, [data]);
|
}, [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 = () => {
|
const queueBulk = () => {
|
||||||
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
|
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
|
||||||
@@ -152,7 +221,13 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!pendingCount) return;
|
if (!pendingCount) return;
|
||||||
try {
|
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({
|
const result = await save.mutateAsync({
|
||||||
scheduleId,
|
scheduleId,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -160,11 +235,24 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
wagonId,
|
wagonId,
|
||||||
...(wagonId in pending ? { yardId: pending[wagonId] } : {}),
|
...(wagonId in pending ? { yardId: pending[wagonId] } : {}),
|
||||||
...(wagonId in pendingCut ? { cutYardId: pendingCut[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({});
|
setPending({});
|
||||||
setPendingCut({});
|
setPendingCut({});
|
||||||
|
setPendingRealCut({});
|
||||||
|
setPendingCouples({});
|
||||||
|
setPendingUncouple([]);
|
||||||
toast({
|
toast({
|
||||||
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
|
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
|
||||||
description: result.warnings.length ? result.warnings.join(" ") : undefined,
|
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
|
<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> ={" "}
|
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
|
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
|
destination; booking capacity past the cut shrinks accordingly. Tick <b>Real cut</b> to
|
||||||
every wagon stands at its planned yard.
|
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 ? (
|
{data.misaligned > 0 ? (
|
||||||
<Text component="span" c="orange" fw={600}>
|
<Text component="span" c="orange" fw={600}>
|
||||||
{" "}
|
{" "}
|
||||||
@@ -232,9 +323,19 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
Cut {s.cut}
|
Cut {s.cut}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{s.coupled > 0 ? (
|
||||||
|
<Badge color="blue" variant="light">
|
||||||
|
+{s.coupled} coupled
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
{!s.pickup ? (
|
{!s.pickup ? (
|
||||||
<Badge color="blue" variant="light">
|
<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>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -275,6 +376,214 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
</Paper>
|
</Paper>
|
||||||
) : null}
|
) : 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 striped highlightOnHover withTableBorder>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
@@ -288,88 +597,218 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data.wagons.map((w) => {
|
{data.wagons
|
||||||
const planned = effectiveYard(w);
|
.filter((w) => !w.coupledYardId)
|
||||||
const cut = effectiveCut(w);
|
.map((w) => {
|
||||||
const changed = w.id in pending || w.id in pendingCut;
|
const planned = effectiveYard(w);
|
||||||
return (
|
const cut = effectiveCut(w);
|
||||||
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
|
const changed = w.id in pending || w.id in pendingCut || w.id in pendingRealCut;
|
||||||
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
|
return (
|
||||||
<Table.Td>
|
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
|
||||||
<Text fw={600} size="sm">
|
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
|
||||||
{w.wagonNumber}
|
<Table.Td>
|
||||||
</Text>
|
<Text fw={600} size="sm">
|
||||||
</Table.Td>
|
{w.wagonNumber}
|
||||||
<Table.Td>{w.wagonType.code}</Table.Td>
|
</Text>
|
||||||
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>{w.wagonType.code}</Table.Td>
|
||||||
{editable && !w.locked ? (
|
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
|
||||||
<Select
|
<Table.Td>
|
||||||
size="xs"
|
{editable && !w.locked ? (
|
||||||
data={yardOptions}
|
<Select
|
||||||
value={planned}
|
size="xs"
|
||||||
onChange={(v) => {
|
data={yardOptions}
|
||||||
setPending((prev) => {
|
value={planned}
|
||||||
const next = { ...prev };
|
onChange={(v) => {
|
||||||
if (!v || v === w.plannedYardId) delete next[w.id];
|
setPending((prev) => {
|
||||||
else next[w.id] = v;
|
const next = { ...prev };
|
||||||
return next;
|
if (!v || v === w.plannedYardId) delete next[w.id];
|
||||||
});
|
else next[w.id] = v;
|
||||||
setPendingCut((prev) =>
|
return next;
|
||||||
clearInvalidCut({ ...prev }, w, v ?? w.plannedYardId),
|
});
|
||||||
);
|
setPendingCut((prev) =>
|
||||||
}}
|
clearInvalidCut({ ...prev }, w, v ?? w.plannedYardId),
|
||||||
w={180}
|
);
|
||||||
/>
|
}}
|
||||||
) : (
|
w={180}
|
||||||
<Group gap={4}>
|
/>
|
||||||
<Text size="sm">{yardLabel(planned)}</Text>
|
) : (
|
||||||
{w.locked ? (
|
<Group gap={4}>
|
||||||
<Tooltip label={w.lockReason ?? "Locked"}>
|
<Text size="sm">{yardLabel(planned)}</Text>
|
||||||
<Lock size={14} />
|
{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>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
</Table.Td>
|
||||||
</Table.Td>
|
</Table.Tr>
|
||||||
<Table.Td>
|
);
|
||||||
{editable ? (
|
})}
|
||||||
// Locked wagons stay editable here — the server enforces the
|
{Object.entries(pendingCouples).map(([wagonId, c]) => (
|
||||||
// cargo-destination floor and the toast explains a 409.
|
<Table.Tr key={wagonId} bg="var(--mantine-color-yellow-light)">
|
||||||
<Select
|
<Table.Td>—</Table.Td>
|
||||||
size="xs"
|
<Table.Td>
|
||||||
clearable
|
<Text fw={600} size="sm">
|
||||||
placeholder="Destination"
|
{c.wagonNumber}
|
||||||
data={cutOptionsFor(planned)}
|
</Text>
|
||||||
value={cut}
|
</Table.Td>
|
||||||
onChange={(v) =>
|
<Table.Td>{c.typeCode}</Table.Td>
|
||||||
setPendingCut((prev) => {
|
<Table.Td>{yardLabel(c.yardId)}</Table.Td>
|
||||||
const next = { ...prev };
|
<Table.Td>
|
||||||
if ((v ?? null) === w.cutYardId) delete next[w.id];
|
<Badge size="sm" variant="light" color="blue" leftSection={<Link2 size={12} />}>
|
||||||
else next[w.id] = v ?? null;
|
Coupled at {yardLabel(c.yardId)} (pending)
|
||||||
return next;
|
</Badge>
|
||||||
})
|
</Table.Td>
|
||||||
}
|
<Table.Td>
|
||||||
w={180}
|
<Text size="sm">Destination</Text>
|
||||||
/>
|
</Table.Td>
|
||||||
) : (
|
<Table.Td>
|
||||||
<Text size="sm">{cut ? yardLabel(cut) : "Destination"}</Text>
|
<Button
|
||||||
)}
|
size="compact-xs"
|
||||||
</Table.Td>
|
variant="subtle"
|
||||||
<Table.Td>
|
color="red"
|
||||||
{planned === w.physicalYardId ? (
|
onClick={() =>
|
||||||
<Badge color="teal" variant="light" size="sm">
|
setPendingCouples((prev) => {
|
||||||
Aligned
|
const next = { ...prev };
|
||||||
</Badge>
|
delete next[wagonId];
|
||||||
) : (
|
return next;
|
||||||
<Badge color="orange" variant="light" size="sm">
|
})
|
||||||
Needs move
|
}
|
||||||
</Badge>
|
>
|
||||||
)}
|
Remove
|
||||||
</Table.Td>
|
</Button>
|
||||||
</Table.Tr>
|
</Table.Td>
|
||||||
);
|
</Table.Tr>
|
||||||
})}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
@@ -383,6 +822,9 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPending({});
|
setPending({});
|
||||||
setPendingCut({});
|
setPendingCut({});
|
||||||
|
setPendingRealCut({});
|
||||||
|
setPendingCouples({});
|
||||||
|
setPendingUncouple([]);
|
||||||
}}
|
}}
|
||||||
disabled={!pendingCount}
|
disabled={!pendingCount}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -347,6 +347,64 @@ export function ScheduleWorkspacePanel({
|
|||||||
const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0;
|
const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0;
|
||||||
const over = capacity > 0 && used > capacity;
|
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 forceAdd = (bookingId: string, ref: string, weightTons: number) => {
|
||||||
const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity;
|
const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity;
|
||||||
assign
|
assign
|
||||||
@@ -649,7 +707,14 @@ export function ScheduleWorkspacePanel({
|
|||||||
radius="md"
|
radius="md"
|
||||||
rightSection={<ArrowRight size={14} />}
|
rightSection={<ArrowRight size={14} />}
|
||||||
loading={assign.isPending}
|
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
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
@@ -794,7 +859,9 @@ export function ScheduleWorkspacePanel({
|
|||||||
loadJourney.isPending &&
|
loadJourney.isPending &&
|
||||||
loadJourney.variables?.bookingId === b.id
|
loadJourney.variables?.bookingId === b.id
|
||||||
}
|
}
|
||||||
onClick={() => doLoad(b.id, ref)}
|
onClick={() =>
|
||||||
|
setConfirmAction({ kind: "load", bookingId: b.id, ref })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Load
|
Load
|
||||||
</Button>
|
</Button>
|
||||||
@@ -812,7 +879,13 @@ export function ScheduleWorkspacePanel({
|
|||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<Truck size={13} />}
|
leftSection={<Truck size={13} />}
|
||||||
loading={truckToTrainPending === b.id}
|
loading={truckToTrainPending === b.id}
|
||||||
onClick={() => doTruckToTrain(b.id, ref)}
|
onClick={() =>
|
||||||
|
setConfirmAction({
|
||||||
|
kind: "truckToTrain",
|
||||||
|
bookingId: b.id,
|
||||||
|
ref,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Truck to Train
|
Truck to Train
|
||||||
</Button>
|
</Button>
|
||||||
@@ -838,7 +911,9 @@ export function ScheduleWorkspacePanel({
|
|||||||
unloadJourney.isPending &&
|
unloadJourney.isPending &&
|
||||||
unloadJourney.variables?.bookingId === b.id
|
unloadJourney.variables?.bookingId === b.id
|
||||||
}
|
}
|
||||||
onClick={() => doUnload(b.id, ref)}
|
onClick={() =>
|
||||||
|
setConfirmAction({ kind: "unload", bookingId: b.id, ref })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Unload
|
Unload
|
||||||
</Button>
|
</Button>
|
||||||
@@ -857,7 +932,9 @@ export function ScheduleWorkspacePanel({
|
|||||||
unassign.isPending &&
|
unassign.isPending &&
|
||||||
unassign.variables?.bookingId === b.id
|
unassign.variables?.bookingId === b.id
|
||||||
}
|
}
|
||||||
onClick={() => removeFromTrain(b.id, ref)}
|
onClick={() =>
|
||||||
|
setConfirmAction({ kind: "remove", bookingId: b.id, ref })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
@@ -961,6 +1038,82 @@ export function ScheduleWorkspacePanel({
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</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>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Progress,
|
Progress,
|
||||||
Stack,
|
Stack,
|
||||||
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
@@ -17,8 +18,10 @@ import { isAxiosError } from "axios";
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
History,
|
||||||
MapPin,
|
MapPin,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
PackageOpen,
|
||||||
Power,
|
Power,
|
||||||
PowerOff,
|
PowerOff,
|
||||||
Replace,
|
Replace,
|
||||||
@@ -36,6 +39,8 @@ import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel
|
|||||||
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
|
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
|
||||||
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
|
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
|
||||||
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
|
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
|
||||||
|
import DetachedWagonsPanel from "@/components/trainBuilder/DetachedWagonsPanel";
|
||||||
|
import TrainHistoryPanel from "@/components/trainBuilder/TrainHistoryPanel";
|
||||||
import {
|
import {
|
||||||
directionColor,
|
directionColor,
|
||||||
locomotiveStatusColor,
|
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} />}>
|
<Alert color="blue" icon={<MapPin size={16} />}>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
@@ -575,6 +595,22 @@ export default function TrainBuilderDetailPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : 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
|
<ChangeLocomotivesModal
|
||||||
composition={composition}
|
composition={composition}
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ import {
|
|||||||
type BuildTrainPayload,
|
type BuildTrainPayload,
|
||||||
type BuiltTrainListFilters,
|
type BuiltTrainListFilters,
|
||||||
type BuiltTrainListResponse,
|
type BuiltTrainListResponse,
|
||||||
|
type DetachedWagonRow,
|
||||||
|
type TrainHistoryEntry,
|
||||||
type ScheduleConsist,
|
type ScheduleConsist,
|
||||||
type ScheduleWagonYards,
|
type ScheduleWagonYards,
|
||||||
type UpdateScheduleWagonYardsPayload,
|
type UpdateScheduleWagonYardsPayload,
|
||||||
@@ -2103,6 +2105,22 @@ export const api = {
|
|||||||
({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id),
|
({ 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
|
// Key derives to ["train-builder", "usedTrainNumbers"], so the shared
|
||||||
// TRAIN_BUILDER.ROOT invalidation refreshes it after every build/edit.
|
// TRAIN_BUILDER.ROOT invalidation refreshes it after every build/edit.
|
||||||
usedTrainNumbers: endpoint<void, UsedTrainNumbers>(
|
usedTrainNumbers: endpoint<void, UsedTrainNumbers>(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { PaginatedResponse } from "@edr/types";
|
||||||
|
|
||||||
import { api as apiClient } from "../auth/http";
|
import { api as apiClient } from "../auth/http";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -300,6 +302,29 @@ export interface ScheduleHistoryEntry {
|
|||||||
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
|
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
|
||||||
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
|
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. */
|
/** One consist wagon in the schedule-yards tab: where this departure plans it vs where it stands. */
|
||||||
export interface ScheduleWagonYardRow {
|
export interface ScheduleWagonYardRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -313,6 +338,11 @@ export interface ScheduleWagonYardRow {
|
|||||||
/** Drop stop this departure cuts the wagon at; null = rides to the destination. */
|
/** Drop stop this departure cuts the wagon at; null = rides to the destination. */
|
||||||
cutYardId: string | null;
|
cutYardId: string | null;
|
||||||
cutYardLabel: 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;
|
aligned: boolean;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
lockReason: string | null;
|
lockReason: string | null;
|
||||||
@@ -327,6 +357,8 @@ export interface ScheduleWagonYardStop {
|
|||||||
physical: number;
|
physical: number;
|
||||||
/** Wagons this departure cuts (detaches and leaves) at this stop. */
|
/** Wagons this departure cuts (detaches and leaves) at this stop. */
|
||||||
cut: number;
|
cut: number;
|
||||||
|
/** Loose wagons this departure couples onto the train at this stop. */
|
||||||
|
coupled: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleWagonYards {
|
export interface ScheduleWagonYards {
|
||||||
@@ -340,7 +372,16 @@ export interface ScheduleWagonYards {
|
|||||||
|
|
||||||
export interface UpdateScheduleWagonYardsPayload {
|
export interface UpdateScheduleWagonYardsPayload {
|
||||||
/** Omit a field to leave it unchanged; cutYardId null clears the cut (rides to destination). */
|
/** 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[] };
|
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
|
||||||
@@ -349,6 +390,14 @@ export const trainBuilderService = {
|
|||||||
list: (filters: BuiltTrainListFilters = {}) =>
|
list: (filters: BuiltTrainListFilters = {}) =>
|
||||||
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||||
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`),
|
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. */
|
/** Import/export run numbers already claimed by existing trains. */
|
||||||
usedTrainNumbers: () => apiClient.get<UsedTrainNumbers>(`${BASE}/used-train-numbers`),
|
usedTrainNumbers: () => apiClient.get<UsedTrainNumbers>(`${BASE}/used-train-numbers`),
|
||||||
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
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.
|
a credit you can rebook with once the fee is settled.
|
||||||
{booking.consolidationPartnerId
|
{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."
|
? " 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>
|
</Text>
|
||||||
{paidPreview.isLoading && <Skeleton height={64} radius={10} />}
|
{paidPreview.isLoading && <Skeleton height={64} radius={10} />}
|
||||||
{paidPreview.data && (
|
{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("");
|
const [rebookDate, setRebookDate] = useState("");
|
||||||
// Non-customs: container number / seal / VGM may change at rebook. Customs
|
// Non-customs: container number / seal / VGM may change at rebook. Customs
|
||||||
// (Path B) credits are rebooked by GL from the backoffice instead.
|
// (Path B) credits are rebooked by GL from the backoffice instead.
|
||||||
@@ -270,9 +257,8 @@ export function WagonCancellationCard({
|
|||||||
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
|
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
|
||||||
</Text>
|
</Text>
|
||||||
. The cancelled wagons have left the train. Pay the fee to unlock
|
. The cancelled wagons have left the train. Pay the fee to unlock
|
||||||
the rebooking credit, or withdraw the request to get the wagons
|
the rebooking credit. The request cannot be withdrawn from here —
|
||||||
back — withdrawing works only while the train still has free space
|
if it was a mistake, contact EDR staff.
|
||||||
for them.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
<Group gap={8}>
|
<Group gap={8}>
|
||||||
<Button
|
<Button
|
||||||
@@ -283,14 +269,6 @@ export function WagonCancellationCard({
|
|||||||
>
|
>
|
||||||
Pay cancellation fee
|
Pay cancellation fee
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
radius="md"
|
|
||||||
loading={withdrawMutation.isPending}
|
|
||||||
onClick={() => withdrawMutation.mutate()}
|
|
||||||
>
|
|
||||||
Withdraw request
|
|
||||||
</Button>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : creditRow ? (
|
) : creditRow ? (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
AlertCircle,
|
||||||
Container,
|
Container,
|
||||||
Gauge,
|
Gauge,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -717,8 +718,9 @@ export function WagonsTab({
|
|||||||
)}
|
)}
|
||||||
{cancellable && hasOpenCancellation && (
|
{cancellable && hasOpenCancellation && (
|
||||||
<Alert color="yellow" variant="light">
|
<Alert color="yellow" variant="light">
|
||||||
A wagon cancellation is already awaiting its fee — pay or withdraw it
|
A wagon cancellation is already awaiting its fee — pay it in the
|
||||||
in the wagon cancellation card before requesting another.
|
wagon cancellation card before requesting another. Withdrawing a
|
||||||
|
request is only possible through EDR staff.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -765,6 +767,13 @@ export function WagonsTab({
|
|||||||
paid freight for them becomes a credit you can rebook on another
|
paid freight for them becomes a credit you can rebook on another
|
||||||
day while your contract is valid.
|
day while your contract is valid.
|
||||||
</Text>
|
</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} />}
|
{previewMutation.isPending && <Skeleton height={64} radius={10} />}
|
||||||
{preview && (
|
{preview && (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -769,15 +769,8 @@ export const bookingsService = {
|
|||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Void a FEE_PENDING request — the fee invoice is cancelled, nothing was released. */
|
// Withdraw was removed from the portal on purpose: a customer's cancellation
|
||||||
withdrawWagonCancellation: async (
|
// request is final — only backoffice staff (void permission) can revert it.
|
||||||
cancellationId: string,
|
|
||||||
): Promise<WagonCancellation> => {
|
|
||||||
const { data } = await client.post(
|
|
||||||
`/api/bookings/wagon-cancellations/${cancellationId}/withdraw`,
|
|
||||||
);
|
|
||||||
return data.data ?? data;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Rebook a CREDIT_AVAILABLE cancellation onto a shipment day → new PAID booking. */
|
/** Rebook a CREDIT_AVAILABLE cancellation onto a shipment day → new PAID booking. */
|
||||||
rebookWagonCancellation: async (
|
rebookWagonCancellation: async (
|
||||||
|
|||||||
Reference in New Issue
Block a user