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,
|
||||
whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed',
|
||||
whole
|
||||
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`
|
||||
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`,
|
||||
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`
|
||||
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
@@ -759,16 +759,6 @@ export class BookingWagonCancellationService {
|
||||
if (!source.contractId) {
|
||||
throw new BadRequestException('The original booking has no contract to rebook under.');
|
||||
}
|
||||
// Friendly pre-check; createUnderContract re-asserts inside its own guards.
|
||||
if (
|
||||
source.contractValidUntil &&
|
||||
new Date(source.contractValidUntil).getTime() < Date.now()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Contract validity has expired — ask EDR staff to extend the contract before rebooking.',
|
||||
);
|
||||
}
|
||||
|
||||
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
|
||||
// Same currency as the source booking — the credit is in it.
|
||||
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
|
||||
@@ -779,6 +769,9 @@ export class BookingWagonCancellationService {
|
||||
// System actor: carries the create-booking key so the GL gate passes on
|
||||
// Path B (customs-clearance) contracts; harmless on Path A.
|
||||
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
|
||||
// The freight was paid while the contract was live — the credit stays
|
||||
// redeemable even after the contract's validity lapses.
|
||||
{ allowExpiredContract: true },
|
||||
);
|
||||
const newBookingId = created.booking.id;
|
||||
|
||||
|
||||
@@ -657,16 +657,18 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post('wagon-cancellations/:cancellationId/withdraw')
|
||||
@ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' })
|
||||
@ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation — STAFF ONLY (void permission). A customer cancellation is final; only an admin can revert it.' })
|
||||
async withdrawWagonCancellation(
|
||||
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertWagonCancellationActor(
|
||||
cancellationId,
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
||||
);
|
||||
// Customer cancellations are irreversible from the portal — no owner
|
||||
// fallback here. Only staff holding the void permission can revert one.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationVoid)) {
|
||||
throw new ForbiddenException(
|
||||
'A cancellation request cannot be withdrawn from the portal — contact EDR staff.',
|
||||
);
|
||||
}
|
||||
return this.wagonCancellationService.withdraw(cancellationId);
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,14 @@ export class ContractBookingService {
|
||||
dto: CreateBookingUnderContractDto,
|
||||
user?: { id?: string } | null,
|
||||
actorPermissions?: unknown,
|
||||
opts?: {
|
||||
/**
|
||||
* Wagon-cancellation credit rebook only: the freight was paid while the
|
||||
* contract was live, so redeeming the credit is allowed even after the
|
||||
* contract's validity lapsed. Never set for a genuinely new booking.
|
||||
*/
|
||||
allowExpiredContract?: boolean;
|
||||
},
|
||||
): Promise<CreateBookingUnderContractResult> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||
@@ -180,8 +188,13 @@ export class ContractBookingService {
|
||||
actorPermissions != null &&
|
||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||
|
||||
await this.assertNotExpired(contract);
|
||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
||||
if (!opts?.allowExpiredContract) await this.assertNotExpired(contract);
|
||||
const createdByRole = await this.assertGate(
|
||||
contract,
|
||||
isGlActor,
|
||||
false,
|
||||
opts?.allowExpiredContract,
|
||||
);
|
||||
|
||||
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
|
||||
// booking reached a terminal state (e.g. payment expired without shipping),
|
||||
@@ -1203,6 +1216,7 @@ export class ContractBookingService {
|
||||
contract: Contract,
|
||||
isGlActor: boolean,
|
||||
isInitiate = false,
|
||||
allowExpired = false,
|
||||
): Promise<string> {
|
||||
// Suspended contracts are frozen for everyone, GL included — say so instead
|
||||
// of letting the executed-status check below give a misleading reason.
|
||||
@@ -1225,7 +1239,10 @@ export class ContractBookingService {
|
||||
}
|
||||
// No contract clearance cycle exists on either kind now — clearance runs
|
||||
// on the booking, so an executed/active contract is the only gate here.
|
||||
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
|
||||
if (
|
||||
!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) &&
|
||||
!(allowExpired && contract.status === 'EXPIRED')
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Contract must be fully executed before booking a shipment.',
|
||||
);
|
||||
@@ -1234,7 +1251,10 @@ export class ContractBookingService {
|
||||
}
|
||||
|
||||
// Path A — customer (or staff) once the contract is executed.
|
||||
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
|
||||
if (
|
||||
!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) &&
|
||||
!(allowExpired && contract.status === 'EXPIRED')
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Contract must be fully executed before booking a shipment.',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
|
||||
/**
|
||||
* Wagon-cancellation credit rebook must work after the contract lapses (the
|
||||
* freight was paid while it was live), while every other create path stays
|
||||
* blocked. assertGate is the status gate createUnderContract runs; this pins
|
||||
* the EXPIRED carve-out to the allowExpired flag.
|
||||
*/
|
||||
describe('ContractBookingService.assertGate expired-contract rebook carve-out', () => {
|
||||
// assertGate only reads contract fields — no constructor deps needed.
|
||||
const service = Object.create(
|
||||
ContractBookingService.prototype,
|
||||
) as ContractBookingService;
|
||||
const gate = (
|
||||
contract: Record<string, unknown>,
|
||||
allowExpired: boolean,
|
||||
): Promise<string> =>
|
||||
(
|
||||
service as unknown as {
|
||||
assertGate: (
|
||||
c: unknown,
|
||||
gl: boolean,
|
||||
init: boolean,
|
||||
allowExpired: boolean,
|
||||
) => Promise<string>;
|
||||
}
|
||||
).assertGate(contract, true, false, allowExpired);
|
||||
|
||||
it('refuses an EXPIRED contract on the normal create path', async () => {
|
||||
await expect(
|
||||
gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, false),
|
||||
).rejects.toThrow(/fully executed/i);
|
||||
});
|
||||
|
||||
it('lets a credit rebook through on an EXPIRED contract (Path A)', async () => {
|
||||
await expect(
|
||||
gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, true),
|
||||
).resolves.toBe('STAFF');
|
||||
});
|
||||
|
||||
it('lets a credit rebook through on an EXPIRED customs contract (Path B)', async () => {
|
||||
await expect(
|
||||
gate(
|
||||
{
|
||||
status: 'EXPIRED',
|
||||
contractKind: 'GENERAL',
|
||||
customsClearingEnabled: true,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).resolves.toBe('GL_ET');
|
||||
});
|
||||
|
||||
it('still refuses a SUSPENDED contract even for a rebook', async () => {
|
||||
await expect(
|
||||
gate({ status: 'SUSPENDED', contractKind: 'GENERAL' }, true),
|
||||
).rejects.toThrow(/suspended/i);
|
||||
});
|
||||
|
||||
it('does not open the gate for other non-executed statuses', async () => {
|
||||
await expect(
|
||||
gate({ status: 'DRAFT', contractKind: 'GENERAL' }, true),
|
||||
).rejects.toThrow(/fully executed/i);
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,9 @@ export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
|
||||
@Index(['trainScheduleId'])
|
||||
@Index(['trainId'])
|
||||
export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||
trainScheduleId!: string;
|
||||
/** Null when the change was made from the train builder with no live schedule. */
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId!: string | null;
|
||||
|
||||
@Column({ name: 'train_id', type: 'uuid' })
|
||||
trainId!: string;
|
||||
|
||||
@@ -139,6 +139,24 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'planned_wagon_cut_yards', type: 'jsonb', nullable: true })
|
||||
plannedWagonCutYards?: Record<string, string> | null;
|
||||
|
||||
/**
|
||||
* LOOSE wagons this departure plans to COUPLE onto the train at a route
|
||||
* stop: `{ wagonId: pickupYardId }`. They join the built train permanently
|
||||
* when the trip reaches that stop (dispatch for the origin, checkpoint log
|
||||
* for mid-route stops).
|
||||
*/
|
||||
@Column({ name: 'planned_wagon_couples', type: 'jsonb', nullable: true })
|
||||
plannedWagonCouples?: Record<string, string> | null;
|
||||
|
||||
/**
|
||||
* Cut wagons (see plannedWagonCutYards) flagged as REAL cuts: the built
|
||||
* train permanently loses the wagon at its cut yard. Absent from this list,
|
||||
* a cut is soft — the wagon sits out the rest of this trip but stays in
|
||||
* the build.
|
||||
*/
|
||||
@Column({ name: 'planned_wagon_real_cuts', type: 'jsonb', nullable: true })
|
||||
plannedWagonRealCuts?: string[] | null;
|
||||
|
||||
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
|
||||
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
||||
bookingWindowStatus!: string;
|
||||
|
||||
@@ -100,6 +100,7 @@ import {
|
||||
CorridorBudget,
|
||||
CorridorLeg,
|
||||
OverageTolerance,
|
||||
addCoupledWagons,
|
||||
stopYardsFor,
|
||||
subtractCutWagons,
|
||||
} from './corridor-capacity.util';
|
||||
@@ -5064,6 +5065,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
stock.byYardId,
|
||||
budget.stops,
|
||||
);
|
||||
// Wagons staff cut mid-route are not stock past their cut stop.
|
||||
ledger.debitCutWagons(stock.cutWagons ?? []);
|
||||
// Debit what is already committed, per boarding yard and wagon type — the
|
||||
// same bookings the corridor budget subtracted. A booking with no resolvable
|
||||
// wagon type still occupies steel, so it drains any type at its yard.
|
||||
@@ -5385,6 +5388,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// ponytail: the wagon-type stock ledger stays cut-blind; bucket
|
||||
// builtTrainStock by (yard, reach) if mixed-type cut trains appear.
|
||||
subtractCutWagons(budget, schedule.plannedWagonCutYards);
|
||||
// Planned couples add a slot from their couple stop onward.
|
||||
addCoupledWagons(budget, schedule.plannedWagonCouples);
|
||||
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
|
||||
budget.subtract(
|
||||
this.needFor(b, wagonDims),
|
||||
|
||||
@@ -222,7 +222,7 @@ export class TrainSchedulingController {
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateScheduleWagonYardsDto,
|
||||
) {
|
||||
return this.trainSchedulingService.updateScheduleWagonYards(id, dto.moves);
|
||||
return this.trainSchedulingService.updateScheduleWagonYards(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/adjust-consist")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
addCoupledWagons,
|
||||
Capacity,
|
||||
CorridorBudget,
|
||||
orientStopsToSchedule,
|
||||
@@ -220,3 +221,39 @@ describe('corridor-capacity.util — stop orientation and fallback', () => {
|
||||
expect(stopYardsFor(null, 'a', 'c')).toEqual(['a', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('corridor-capacity.util — addCoupledWagons', () => {
|
||||
const stops = ['a', 'b', 'c', 'd'];
|
||||
const wagonsOnly: Capacity = {
|
||||
wagons: 10,
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
const remaining = (budget: CorridorBudget, from: string, to: string): number =>
|
||||
budget.remainingFor(budget.legOf(from, to)!).wagons;
|
||||
|
||||
it('credits every edge at/after the couple stop', () => {
|
||||
const budget = new CorridorBudget(stops, wagonsOnly);
|
||||
addCoupledWagons(budget, { 'w-1': 'a', 'w-2': 'c' });
|
||||
expect(remaining(budget, 'a', 'b')).toBe(11); // origin couple rides everything
|
||||
expect(remaining(budget, 'b', 'c')).toBe(11);
|
||||
expect(remaining(budget, 'c', 'd')).toBe(12); // + the c-coupled wagon
|
||||
});
|
||||
|
||||
it('nets against cuts on the same budget', () => {
|
||||
const budget = new CorridorBudget(stops, wagonsOnly);
|
||||
subtractCutWagons(budget, { 'w-cut': 'c' });
|
||||
addCoupledWagons(budget, { 'w-new': 'c' });
|
||||
expect(remaining(budget, 'a', 'c')).toBe(10);
|
||||
expect(remaining(budget, 'c', 'd')).toBe(10); // cut −1, couple +1
|
||||
expect(remaining(budget, 'a', 'd')).toBe(10);
|
||||
});
|
||||
|
||||
it('ignores off-corridor and destination couple yards, and a missing plan', () => {
|
||||
const budget = new CorridorBudget(stops, wagonsOnly);
|
||||
addCoupledWagons(budget, { 'w-1': 'elsewhere', 'w-2': 'd' });
|
||||
addCoupledWagons(budget, null);
|
||||
addCoupledWagons(budget, undefined);
|
||||
expect(remaining(budget, 'a', 'd')).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,25 @@ export function subtractCutWagons(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit the corridor for LOOSE wagons the schedule plans to COUPLE onto the
|
||||
* train mid-route: each coupled wagon adds a slot on every edge at/after its
|
||||
* couple stop ([couple, destination)). A couple yard not on the corridor —
|
||||
* or equal to the destination — is ignored; updateScheduleWagonYards owns
|
||||
* rejecting it.
|
||||
*/
|
||||
export function addCoupledWagons(
|
||||
budget: CorridorBudget,
|
||||
couplePlan: Record<string, string> | null | undefined,
|
||||
): void {
|
||||
if (!couplePlan) return;
|
||||
const destination = budget.stops[budget.stops.length - 1];
|
||||
for (const coupleYardId of Object.values(couplePlan)) {
|
||||
const leg = budget.legOf(coupleYardId, destination);
|
||||
if (leg) budget.add({ wagons: 1, weightTons: 0, lengthMeters: 0 }, leg);
|
||||
}
|
||||
}
|
||||
|
||||
/** Overage a locomotive may absorb beyond its base caps. */
|
||||
export interface OverageTolerance {
|
||||
weightTons: number;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMaxSize, IsArray, IsOptional, IsUUID, ValidateIf, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ScheduleWagonYardMoveDto {
|
||||
@ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." })
|
||||
@@ -25,6 +33,27 @@ export class ScheduleWagonYardMoveDto {
|
||||
@ValidateIf((o: ScheduleWagonYardMoveDto) => o.cutYardId !== null)
|
||||
@IsUUID()
|
||||
cutYardId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'true: REAL cut — the built train permanently loses the wagon at its cut yard. false: soft cut (default) — the wagon sits out this trip but stays in the build. Requires a cut yard.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
realCut?: boolean;
|
||||
}
|
||||
|
||||
export class ScheduleWagonCoupleDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'Loose wagon (no built train) to couple.' })
|
||||
@IsUUID()
|
||||
wagonId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
description: 'Pickup stop the wagon joins the train at. It must physically stand there.',
|
||||
})
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
}
|
||||
|
||||
export class UpdateScheduleWagonYardsDto {
|
||||
@@ -33,9 +62,32 @@ export class UpdateScheduleWagonYardsDto {
|
||||
description:
|
||||
'Wagon → planned boarding yard for THIS schedule only. Physical wagon yards are untouched; dispatch requires both to agree.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(500)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ScheduleWagonYardMoveDto)
|
||||
moves!: ScheduleWagonYardMoveDto[];
|
||||
moves?: ScheduleWagonYardMoveDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [ScheduleWagonCoupleDto],
|
||||
description:
|
||||
'Loose wagons to plan-couple onto the train at a pickup stop. They join the built train permanently when the trip reaches that stop.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ScheduleWagonCoupleDto)
|
||||
couple?: ScheduleWagonCoupleDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: 'Wagon ids to remove from the couple plan (before execution).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
@IsUUID('all', { each: true })
|
||||
uncouple?: string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { computeEdgeLoads } from './edge-load.util';
|
||||
|
||||
describe('edge-load.util — computeEdgeLoads', () => {
|
||||
// gmp -> lebu -> mojo -> adama -> dct: 4 edges.
|
||||
const EDGES = 4;
|
||||
const wagon = (fromEdge: number, toEdge: number) => ({
|
||||
fromEdge,
|
||||
toEdge,
|
||||
tareTons: 25,
|
||||
lengthMeters: 17,
|
||||
});
|
||||
|
||||
it('an uncut whole-route consist loads every edge flat', () => {
|
||||
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(0, 4)], []);
|
||||
for (const e of loads) {
|
||||
expect(e.weightTons).toBe(50);
|
||||
expect(e.lengthMeters).toBe(34);
|
||||
}
|
||||
});
|
||||
|
||||
it('a cut frees tare and length on the edges past the cut', () => {
|
||||
// One wagon cut at mojo (edge index 2): rides edges 0-1 only.
|
||||
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(0, 2)], []);
|
||||
expect(loads[1]).toEqual({ weightTons: 50, lengthMeters: 34 });
|
||||
expect(loads[2]).toEqual({ weightTons: 25, lengthMeters: 17 });
|
||||
expect(loads[3]).toEqual({ weightTons: 25, lengthMeters: 17 });
|
||||
});
|
||||
|
||||
it('a couple adds tare and length only from its couple stop', () => {
|
||||
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(2, 4)], []);
|
||||
expect(loads[1]).toEqual({ weightTons: 25, lengthMeters: 17 });
|
||||
expect(loads[2]).toEqual({ weightTons: 50, lengthMeters: 34 });
|
||||
});
|
||||
|
||||
it('cut-then-couple at the same stop nets to a flat load', () => {
|
||||
const loads = computeEdgeLoads(EDGES, [wagon(0, 2), wagon(2, 4)], []);
|
||||
for (const e of loads) {
|
||||
expect(e.weightTons).toBe(25);
|
||||
expect(e.lengthMeters).toBe(17);
|
||||
}
|
||||
});
|
||||
|
||||
it('cargo weighs only the edges of its own leg', () => {
|
||||
const loads = computeEdgeLoads(
|
||||
EDGES,
|
||||
[wagon(0, 4)],
|
||||
[{ fromEdge: 1, toEdge: 3, weightTons: 60 }],
|
||||
);
|
||||
expect(loads[0].weightTons).toBe(25);
|
||||
expect(loads[1].weightTons).toBe(85);
|
||||
expect(loads[2].weightTons).toBe(85);
|
||||
expect(loads[3].weightTons).toBe(25);
|
||||
});
|
||||
|
||||
it('clamps out-of-range spans instead of throwing', () => {
|
||||
const loads = computeEdgeLoads(EDGES, [wagon(-2, 99)], []);
|
||||
for (const e of loads) expect(e.weightTons).toBe(25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Per-corridor-edge physical load of a train: tare + length of the wagons
|
||||
* spanning each edge, plus the cargo weight riding it. Used to validate that
|
||||
* a planned mid-route COUPLE keeps every leg within the locomotives' pull
|
||||
* weight and train length limits — a wagon cut at Mojo frees its tare/length
|
||||
* on the edges past Mojo, a wagon coupled there adds its own only from there.
|
||||
*/
|
||||
|
||||
export interface EdgeLoad {
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
export interface EdgeWagonSpan {
|
||||
/** Half-open edge span [fromEdge, toEdge) the wagon physically rides. */
|
||||
fromEdge: number;
|
||||
toEdge: number;
|
||||
tareTons: number;
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
export interface EdgeCargoLeg {
|
||||
fromEdge: number;
|
||||
toEdge: number;
|
||||
weightTons: number;
|
||||
}
|
||||
|
||||
export function computeEdgeLoads(
|
||||
edgeCount: number,
|
||||
wagonSpans: readonly EdgeWagonSpan[],
|
||||
cargoLegs: readonly EdgeCargoLeg[],
|
||||
): EdgeLoad[] {
|
||||
const loads: EdgeLoad[] = Array.from({ length: Math.max(1, edgeCount) }, () => ({
|
||||
weightTons: 0,
|
||||
lengthMeters: 0,
|
||||
}));
|
||||
const clamp = (edge: number) => Math.min(Math.max(edge, 0), loads.length);
|
||||
for (const span of wagonSpans) {
|
||||
for (let e = clamp(span.fromEdge); e < clamp(span.toEdge); e += 1) {
|
||||
loads[e].weightTons += span.tareTons;
|
||||
loads[e].lengthMeters += span.lengthMeters;
|
||||
}
|
||||
}
|
||||
for (const cargo of cargoLegs) {
|
||||
for (let e = clamp(cargo.fromEdge); e < clamp(cargo.toEdge); e += 1) {
|
||||
loads[e].weightTons += cargo.weightTons;
|
||||
}
|
||||
}
|
||||
return loads;
|
||||
}
|
||||
@@ -137,11 +137,13 @@ import {
|
||||
type WagonPlanSlot,
|
||||
} from '../utils/wagon-plan.util';
|
||||
import {
|
||||
addCoupledWagons,
|
||||
CorridorBudget,
|
||||
orientStopsToSchedule,
|
||||
subtractCutWagons,
|
||||
} from '../corridor-capacity.util';
|
||||
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
|
||||
import { computeEdgeLoads } from '../edge-load.util';
|
||||
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
|
||||
import {
|
||||
defaultPlannedWagonYards,
|
||||
@@ -2199,7 +2201,16 @@ export class TrainSchedulingService {
|
||||
// so their tare rides on top of the binding edge.
|
||||
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
|
||||
const scheduleStops = await this.stopYardsForSchedule(schedule);
|
||||
const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops);
|
||||
// Same leg map the validator used: cargo weighs only the edges its booking
|
||||
// rides, so a Dire Dawa boarder never inflates the Djibouti leg.
|
||||
const commitLegByBookingId = new Map(
|
||||
bookings.flatMap((b) => {
|
||||
const from = scheduleStops.indexOf(b.originYardId);
|
||||
const to = scheduleStops.indexOf(b.destinationYardId);
|
||||
return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
|
||||
}),
|
||||
);
|
||||
const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops, commitLegByBookingId);
|
||||
const stopLabels = await this.yardLabelMap(scheduleStops);
|
||||
// Each edge is its own consist — name EVERY leg that breaks the limit,
|
||||
// not just the heaviest figure, so staff see where along A→…→E it fails.
|
||||
@@ -2902,6 +2913,56 @@ export class TrainSchedulingService {
|
||||
{ status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId },
|
||||
);
|
||||
}
|
||||
// Planned couples boarding at the ORIGIN join the built train now — the
|
||||
// departure is the moment they are physically hooked on. Mid-route
|
||||
// couples join at their stop's checkpoint log instead.
|
||||
const dispatchTrainId = schedule.trainSet?.trainId ?? null;
|
||||
const originCouples = Object.entries(schedule.plannedWagonCouples ?? {}).filter(
|
||||
([, yardId]) => yardId === schedule.originStationId,
|
||||
);
|
||||
if (originCouples.length && dispatchTrainId) {
|
||||
const consist = await manager.getRepository(Wagon).find({
|
||||
where: { trainId: dispatchTrainId },
|
||||
select: { id: true, sequenceNumber: true },
|
||||
});
|
||||
let maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0);
|
||||
for (const [coupleWagonId, coupleYardId] of originCouples) {
|
||||
const wagon = await manager
|
||||
.getRepository(Wagon)
|
||||
.findOne({ where: { id: coupleWagonId }, lock: { mode: 'pessimistic_write' } });
|
||||
if (!wagon) continue;
|
||||
if (wagon.trainId === dispatchTrainId) continue; // already joined — self-heal
|
||||
if (
|
||||
wagon.trainId ||
|
||||
wagon.status !== WagonStatus.Available ||
|
||||
wagon.currentTrainScheduleId ||
|
||||
wagon.currentYardId !== coupleYardId
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is planned to couple at dispatch but is no longer free at the origin yard — remove the couple in the Schedule yards tab or free the wagon`,
|
||||
);
|
||||
}
|
||||
maxSeq += 1;
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: dispatchTrainId,
|
||||
sequenceNumber: maxSeq,
|
||||
status: WagonStatus.Assigned,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
});
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: dispatchTrainId,
|
||||
action: 'ADD',
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
adjustedByUserId: null,
|
||||
yardId: coupleYardId,
|
||||
occurredAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const sb of schedule.scheduleBookings ?? []) {
|
||||
await this.bookingsRepository.updateSchedulingFields(
|
||||
sb.bookingId,
|
||||
@@ -4407,19 +4468,57 @@ export class TrainSchedulingService {
|
||||
// bound to the schedule is riding empty. Matching against ALL passed
|
||||
// yards, not just this one, self-heals skipped checkpoint logs.
|
||||
const cutPlan = schedule.plannedWagonCutYards ?? {};
|
||||
const realCutIds = new Set(schedule.plannedWagonRealCuts ?? []);
|
||||
const builtTrainId = schedule.trainSet?.trainId ?? null;
|
||||
const cutNow = Object.entries(cutPlan).filter(([, yardId]) =>
|
||||
passedYardIds.includes(yardId),
|
||||
);
|
||||
let realCutHappened = false;
|
||||
for (const [wagonId, cutYardId] of cutNow) {
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
// Already settled earlier (or re-pinned elsewhere) — not ours to move.
|
||||
if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue;
|
||||
await manager.getRepository(Wagon).update(wagonId, {
|
||||
currentYardId: cutYardId,
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
});
|
||||
if (realCutIds.has(wagonId) && builtTrainId) {
|
||||
// REAL cut: the built train permanently loses the wagon here.
|
||||
await manager.getRepository(Wagon).update(wagonId, {
|
||||
currentYardId: cutYardId,
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
importTrainNumber: null,
|
||||
exportTrainNumber: null,
|
||||
status: WagonStatus.Available,
|
||||
});
|
||||
// Any slot of this train's schedules still pinned to it is stale.
|
||||
await manager.query(
|
||||
`UPDATE freight.train_set_wagons SET physical_wagon_id = NULL
|
||||
WHERE physical_wagon_id = $1
|
||||
AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`,
|
||||
[wagonId, builtTrainId],
|
||||
);
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: builtTrainId,
|
||||
action: 'REMOVE',
|
||||
wagonId,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
adjustedByUserId: null,
|
||||
yardId: cutYardId,
|
||||
occurredAt,
|
||||
}),
|
||||
);
|
||||
realCutHappened = true;
|
||||
} else {
|
||||
// Soft cut: sits out the rest of this trip, stays in the build.
|
||||
await manager.getRepository(Wagon).update(wagonId, {
|
||||
currentYardId: cutYardId,
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
});
|
||||
}
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId,
|
||||
@@ -4431,6 +4530,65 @@ export class TrainSchedulingService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Keep the coupling order gapless after permanent removals.
|
||||
if (realCutHappened && builtTrainId) {
|
||||
const remaining = await manager.getRepository(Wagon).find({
|
||||
where: { trainId: builtTrainId },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
});
|
||||
for (const [i, w] of remaining.entries()) {
|
||||
if (w.sequenceNumber !== i + 1) {
|
||||
await manager.getRepository(Wagon).update(w.id, { sequenceNumber: i + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Planned COUPLES standing at a passed stop join the train here —
|
||||
// before the position fix below, so they ride it from this checkpoint
|
||||
// on. Unavailable wagons are skipped silently (a checkpoint log must
|
||||
// never fail on a missing planned couple); passedYardIds self-heals
|
||||
// skipped logs, and an already-joined wagon has trainId set.
|
||||
const couplePlan = schedule.plannedWagonCouples ?? {};
|
||||
const coupleNow = Object.entries(couplePlan).filter(([, yardId]) =>
|
||||
passedYardIds.includes(yardId),
|
||||
);
|
||||
if (coupleNow.length && builtTrainId) {
|
||||
const consist = await manager.getRepository(Wagon).find({
|
||||
where: { trainId: builtTrainId },
|
||||
select: { id: true, sequenceNumber: true },
|
||||
});
|
||||
let maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0);
|
||||
for (const [wagonId, coupleYardId] of coupleNow) {
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (
|
||||
!wagon ||
|
||||
wagon.trainId ||
|
||||
wagon.status !== WagonStatus.Available ||
|
||||
wagon.currentTrainScheduleId ||
|
||||
wagon.currentYardId !== coupleYardId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
maxSeq += 1;
|
||||
await manager.getRepository(Wagon).update(wagonId, {
|
||||
trainId: builtTrainId,
|
||||
sequenceNumber: maxSeq,
|
||||
status: WagonStatus.Assigned,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
});
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: builtTrainId,
|
||||
action: 'ADD',
|
||||
wagonId,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
adjustedByUserId: null,
|
||||
yardId: coupleYardId,
|
||||
occurredAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.createQueryBuilder()
|
||||
@@ -4657,14 +4815,52 @@ export class TrainSchedulingService {
|
||||
schedule.plannedWagonCutYards?.[wagon.id] ??
|
||||
slot.alightYardId ??
|
||||
schedule.destinationStationId;
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
// A wagon that belongs to a built train stays coupled to it (ASSIGNED);
|
||||
// only loose wagons return to the open AVAILABLE pool.
|
||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
currentYardId: settleYardId,
|
||||
});
|
||||
const ownerTrainId = wagon.trainId;
|
||||
const isRealCut =
|
||||
(schedule.plannedWagonRealCuts ?? []).includes(wagon.id) &&
|
||||
schedule.plannedWagonCutYards?.[wagon.id] != null;
|
||||
if (isRealCut && ownerTrainId) {
|
||||
// Arrival fallback for a journey logged without mid-route
|
||||
// checkpoints: the REAL cut still permanently removes the wagon
|
||||
// from the built train at its cut yard.
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
importTrainNumber: null,
|
||||
exportTrainNumber: null,
|
||||
status: WagonStatus.Available,
|
||||
currentYardId: settleYardId,
|
||||
});
|
||||
await manager.query(
|
||||
`UPDATE freight.train_set_wagons SET physical_wagon_id = NULL
|
||||
WHERE physical_wagon_id = $1
|
||||
AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`,
|
||||
[wagon.id, ownerTrainId],
|
||||
);
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: ownerTrainId,
|
||||
action: 'REMOVE',
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
adjustedByUserId: null,
|
||||
yardId: settleYardId,
|
||||
occurredAt: now,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
// A wagon that belongs to a built train stays coupled to it (ASSIGNED);
|
||||
// only loose wagons return to the open AVAILABLE pool.
|
||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
currentYardId: settleYardId,
|
||||
});
|
||||
}
|
||||
// Ledger: the wagon rode this schedule to its settle yard.
|
||||
const slotAllocations = slot.allocations ?? [];
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
@@ -4682,6 +4878,78 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
// Planned couples: settle any that joined mid-route but have no pinned
|
||||
// slot (the loop above never visits them), and — arrival fallback —
|
||||
// join ones the checkpoint logs skipped: the train passed every stop,
|
||||
// so a still-loose planned couple physically rode along.
|
||||
const arrivalCouplePlan = schedule.plannedWagonCouples ?? {};
|
||||
const arrivalTrainId = schedule.trainSet?.trainId ?? null;
|
||||
for (const [coupleWagonId, coupleYardId] of Object.entries(arrivalCouplePlan)) {
|
||||
const wagon = await manager
|
||||
.getRepository(Wagon)
|
||||
.findOne({ where: { id: coupleWagonId } });
|
||||
if (!wagon) continue;
|
||||
if (wagon.currentTrainScheduleId === scheduleId) {
|
||||
// Joined during the trip, slot-less: settle at the destination.
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
currentYardId: schedule.destinationStationId,
|
||||
});
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: wagon.id,
|
||||
fromYardId: coupleYardId,
|
||||
toYardId: schedule.destinationStationId,
|
||||
trainScheduleId: scheduleId,
|
||||
kind: WagonMovementKind.EmptyReposition,
|
||||
occurredAt: now,
|
||||
}),
|
||||
);
|
||||
} else if (
|
||||
arrivalTrainId &&
|
||||
!wagon.trainId &&
|
||||
wagon.status === WagonStatus.Available &&
|
||||
!wagon.currentTrainScheduleId &&
|
||||
wagon.currentYardId === coupleYardId
|
||||
) {
|
||||
const consist = await manager.getRepository(Wagon).find({
|
||||
where: { trainId: arrivalTrainId },
|
||||
select: { id: true, sequenceNumber: true },
|
||||
});
|
||||
const maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0);
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: arrivalTrainId,
|
||||
sequenceNumber: maxSeq + 1,
|
||||
status: WagonStatus.Assigned,
|
||||
currentYardId: schedule.destinationStationId,
|
||||
});
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: arrivalTrainId,
|
||||
action: 'ADD',
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
adjustedByUserId: null,
|
||||
yardId: coupleYardId,
|
||||
occurredAt: now,
|
||||
}),
|
||||
);
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: wagon.id,
|
||||
fromYardId: coupleYardId,
|
||||
toYardId: schedule.destinationStationId,
|
||||
trainScheduleId: scheduleId,
|
||||
kind: WagonMovementKind.EmptyReposition,
|
||||
occurredAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
|
||||
const stations = await this.buildScheduleStations(schedule);
|
||||
const finalStation = stations[stations.length - 1];
|
||||
@@ -5177,7 +5445,7 @@ export class TrainSchedulingService {
|
||||
// above — the whole-route totals here are informational (summary) only. The
|
||||
// locomotive checks below also compare per edge: a train is never heavier
|
||||
// than its heaviest leg, so disjoint legs must not be summed.
|
||||
const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops);
|
||||
const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops, legByBookingId);
|
||||
const maxEdgeGrossTons = roundTons(
|
||||
Math.max(0, ...perEdgeUsage.map((e) => e.grossWeightTons)),
|
||||
);
|
||||
@@ -5466,6 +5734,32 @@ export class TrainSchedulingService {
|
||||
return rows[0]?.planned_wagon_yards ?? {};
|
||||
}
|
||||
|
||||
/** `{ wagonId: yardId }` this schedule cuts each wagon at; `{}` when unset. */
|
||||
private async plannedWagonCutYardsOf(
|
||||
scheduleId: string | undefined,
|
||||
): Promise<Record<string, string>> {
|
||||
if (!scheduleId) return {};
|
||||
const rows: { planned_wagon_cut_yards: Record<string, string> | null }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT planned_wagon_cut_yards FROM freight.train_schedules WHERE id = $1`,
|
||||
[scheduleId],
|
||||
);
|
||||
return rows[0]?.planned_wagon_cut_yards ?? {};
|
||||
}
|
||||
|
||||
/** `{ wagonId: pickupYardId }` of loose wagons this schedule plans to couple; `{}` when unset. */
|
||||
private async plannedWagonCouplesOf(
|
||||
scheduleId: string | undefined,
|
||||
): Promise<Record<string, string>> {
|
||||
if (!scheduleId) return {};
|
||||
const rows: { planned_wagon_couples: Record<string, string> | null }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT planned_wagon_couples FROM freight.train_schedules WHERE id = $1`,
|
||||
[scheduleId],
|
||||
);
|
||||
return rows[0]?.planned_wagon_couples ?? {};
|
||||
}
|
||||
|
||||
private async countFleetAvailability(
|
||||
originYardId: string,
|
||||
targetScheduleId?: string,
|
||||
@@ -5673,8 +5967,12 @@ export class TrainSchedulingService {
|
||||
|
||||
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
|
||||
const plannedYards = pinSchedule?.plannedWagonYards ?? {};
|
||||
const couplePlan = pinSchedule?.plannedWagonCouples ?? {};
|
||||
// Couples ride into the yard plan as boarding entries: a slot boarding at
|
||||
// the couple yard may pin the (still loose) planned couple wagon.
|
||||
const plannedYards = { ...(pinSchedule?.plannedWagonYards ?? {}), ...couplePlan };
|
||||
const cutPlan = pinSchedule?.plannedWagonCutYards ?? {};
|
||||
const coupleIds = new Set(Object.keys(couplePlan));
|
||||
|
||||
const unpinnable = this.findUnpinnableWagonSlots(
|
||||
planSlots,
|
||||
@@ -5686,6 +5984,7 @@ export class TrainSchedulingService {
|
||||
stops,
|
||||
plannedYards,
|
||||
cutPlan,
|
||||
coupleIds,
|
||||
);
|
||||
if (unpinnable.length) {
|
||||
throw new BadRequestException({
|
||||
@@ -5710,6 +6009,7 @@ export class TrainSchedulingService {
|
||||
plannedYards,
|
||||
cutPlan,
|
||||
stops,
|
||||
coupleIds,
|
||||
);
|
||||
if (!physical) continue;
|
||||
|
||||
@@ -5759,8 +6059,13 @@ export class TrainSchedulingService {
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
stops,
|
||||
targetSchedule?.plannedWagonYards ?? {},
|
||||
// Couples count as boarding entries at their couple yard.
|
||||
{
|
||||
...(targetSchedule?.plannedWagonYards ?? {}),
|
||||
...(targetSchedule?.plannedWagonCouples ?? {}),
|
||||
},
|
||||
targetSchedule?.plannedWagonCutYards ?? {},
|
||||
new Set(Object.keys(targetSchedule?.plannedWagonCouples ?? {})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5795,6 +6100,7 @@ export class TrainSchedulingService {
|
||||
stops: string[] = [],
|
||||
plannedYards: PlannedWagonYards = {},
|
||||
cutPlan: Record<string, string> = {},
|
||||
coupleIds: Set<string> = new Set(),
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
// One physical wagon may serve several slots whose leg spans don't overlap
|
||||
@@ -5817,6 +6123,7 @@ export class TrainSchedulingService {
|
||||
plannedYards,
|
||||
cutPlan,
|
||||
stops,
|
||||
coupleIds,
|
||||
);
|
||||
if (!physical) {
|
||||
violations.push(
|
||||
@@ -5850,6 +6157,7 @@ export class TrainSchedulingService {
|
||||
plannedYards: PlannedWagonYards = {},
|
||||
cutPlan: Record<string, string> = {},
|
||||
stops: string[] = [],
|
||||
coupleIds: Set<string> = new Set(),
|
||||
): Wagon | undefined {
|
||||
// How far down the route a wagon rides before this schedule cuts it:
|
||||
// stop index of its cut yard, or the last stop when uncut (also when the
|
||||
@@ -5888,9 +6196,15 @@ export class TrainSchedulingService {
|
||||
// consist views draw the schedule exactly like the train builder; a schedule
|
||||
// created with reverseWagonOrder pins back-to-front (physically-last wagon
|
||||
// takes slot #1). Unsequenced wagons sort after every sequenced one.
|
||||
// A planned COUPLE (loose wagon joining at its couple yard) is pinnable
|
||||
// alongside the train's own consist — its "boarding yard" is the couple
|
||||
// yard, already merged into plannedYards by the callers.
|
||||
const belongsToRun = (w: Wagon): boolean =>
|
||||
w.trainId === builtTrainId ||
|
||||
(coupleIds.has(w.id) && !w.trainId && w.status === WagonStatus.Available);
|
||||
const consistYards = new Set(
|
||||
wagons
|
||||
.filter((w) => w.trainId === builtTrainId && scheduleYardOf(plannedYards, w))
|
||||
.filter((w) => belongsToRun(w) && scheduleYardOf(plannedYards, w))
|
||||
.map((w) => scheduleYardOf(plannedYards, w) as string),
|
||||
);
|
||||
// Split consist: a slot boarding at a given yard must take a wagon that
|
||||
@@ -5901,7 +6215,7 @@ export class TrainSchedulingService {
|
||||
const candidates = wagons
|
||||
.filter(
|
||||
(w) =>
|
||||
w.trainId === builtTrainId &&
|
||||
belongsToRun(w) &&
|
||||
w.wagonTypeId === slot.wagonTypeId &&
|
||||
spanFree(w.id) &&
|
||||
// A wagon cut before the slot's alight stop cannot serve it.
|
||||
@@ -6076,16 +6390,19 @@ export class TrainSchedulingService {
|
||||
builtTrainId: string,
|
||||
scheduleId?: string,
|
||||
): Promise<WagonStock> {
|
||||
const [wagons, plan] = await Promise.all([
|
||||
const [wagons, plan, cutPlan, couplePlan] = await Promise.all([
|
||||
this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: builtTrainId },
|
||||
relations: { wagonType: true },
|
||||
}),
|
||||
this.plannedWagonYardsOf(scheduleId),
|
||||
this.plannedWagonCutYardsOf(scheduleId),
|
||||
this.plannedWagonCouplesOf(scheduleId),
|
||||
]);
|
||||
const remainingByTypeId = new Map<string, number>();
|
||||
const codesByTypeId = new Map<string, string>();
|
||||
const byYardId = new Map<string, Map<string, number>>();
|
||||
const cutWagons: NonNullable<WagonStock['cutWagons']> = [];
|
||||
for (const wagon of wagons) {
|
||||
remainingByTypeId.set(
|
||||
wagon.wagonTypeId,
|
||||
@@ -6099,6 +6416,38 @@ export class TrainSchedulingService {
|
||||
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
|
||||
byYardId.set(yardId, perType);
|
||||
}
|
||||
// A wagon cut mid-route is not stock past its cut stop — consumers debit
|
||||
// it per edge so "2 NW5 free from gmp" reads 1 when one is cut at Lebu.
|
||||
const cutYardId = cutPlan[wagon.id];
|
||||
if (cutYardId) {
|
||||
cutWagons.push({ wagonTypeId: wagon.wagonTypeId, poolYardId: yardId ?? '', cutYardId });
|
||||
}
|
||||
}
|
||||
// Planned couples: loose wagons joining the train mid-route are stock too,
|
||||
// pooled at their couple yard so they serve bookings boarding there.
|
||||
// ponytail: in multi-yard mode a couple serves only bookings boarding
|
||||
// exactly at its couple yard (existing split-consist semantics) —
|
||||
// conservative; upgrade = pool lookup falling back to the nearest pool
|
||||
// at/before the leg's boarding edge.
|
||||
const coupleIds = Object.keys(couplePlan);
|
||||
if (coupleIds.length) {
|
||||
const coupleWagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { id: In(coupleIds) },
|
||||
relations: { wagonType: true },
|
||||
});
|
||||
for (const wagon of coupleWagons) {
|
||||
// Already joined (or grabbed by another train) — counted via trainId then.
|
||||
if (wagon.trainId) continue;
|
||||
remainingByTypeId.set(
|
||||
wagon.wagonTypeId,
|
||||
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
|
||||
);
|
||||
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
|
||||
const coupleYardId = couplePlan[wagon.id];
|
||||
const perType = byYardId.get(coupleYardId) ?? new Map<string, number>();
|
||||
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
|
||||
byYardId.set(coupleYardId, perType);
|
||||
}
|
||||
}
|
||||
// Single-yard consist (the overwhelming majority): the whole train is
|
||||
// offered at every boarding yard exactly as before — the per-yard split is
|
||||
@@ -6108,6 +6457,7 @@ export class TrainSchedulingService {
|
||||
remainingByTypeId,
|
||||
codesByTypeId,
|
||||
...(byYardId.size > 1 ? { byYardId } : {}),
|
||||
...(cutWagons.length ? { cutWagons } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6568,11 +6918,20 @@ export class TrainSchedulingService {
|
||||
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
|
||||
const plan = schedule.plannedWagonYards ?? {};
|
||||
const cutPlan = schedule.plannedWagonCutYards ?? {};
|
||||
const couplePlan = schedule.plannedWagonCouples ?? {};
|
||||
const realCuts = new Set(schedule.plannedWagonRealCuts ?? []);
|
||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: builtTrain.id },
|
||||
relations: { wagonType: true, currentYard: true },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
});
|
||||
const coupleIds = Object.keys(couplePlan);
|
||||
const coupleWagons = coupleIds.length
|
||||
? await this.dataSource.getRepository(Wagon).find({
|
||||
where: { id: In(coupleIds) },
|
||||
relations: { wagonType: true, currentYard: true },
|
||||
})
|
||||
: [];
|
||||
const lockedIds = new Set(
|
||||
(schedule.trainSet?.wagons ?? [])
|
||||
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
||||
@@ -6580,7 +6939,7 @@ export class TrainSchedulingService {
|
||||
);
|
||||
const offRouteYardIds = [
|
||||
...new Set(
|
||||
wagons
|
||||
[...wagons, ...coupleWagons]
|
||||
.flatMap((w) => [scheduleYardOf(plan, w), w.currentYardId])
|
||||
.filter((y): y is string => !!y && !stops.some((s) => s.yardId === y)),
|
||||
),
|
||||
@@ -6595,7 +6954,7 @@ export class TrainSchedulingService {
|
||||
|
||||
const rows = wagons.map((w) => {
|
||||
const plannedYardId = scheduleYardOf(plan, w);
|
||||
const cutYardId = cutPlan[w.id] ?? null;
|
||||
const cutYardId: string | null = cutPlan[w.id] ?? null;
|
||||
const locked = lockedIds.has(w.id);
|
||||
return {
|
||||
id: w.id,
|
||||
@@ -6608,13 +6967,42 @@ export class TrainSchedulingService {
|
||||
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
|
||||
plannedYardId,
|
||||
plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null,
|
||||
cutYardId,
|
||||
cutYardId: cutYardId as string | null,
|
||||
cutYardLabel: cutYardId ? labels.get(cutYardId) ?? cutYardId : null,
|
||||
realCut: realCuts.has(w.id),
|
||||
coupledYardId: null as string | null,
|
||||
coupledYardLabel: null as string | null,
|
||||
aligned: plannedYardId === w.currentYardId,
|
||||
locked,
|
||||
lockReason: locked ? 'Carries cargo booked on this schedule' : null,
|
||||
};
|
||||
});
|
||||
// Planned couples: loose wagons joining mid-route, appended after the
|
||||
// consist so the table reads consist-first.
|
||||
for (const w of coupleWagons) {
|
||||
const coupledYardId = couplePlan[w.id];
|
||||
const locked = lockedIds.has(w.id);
|
||||
rows.push({
|
||||
id: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
sequenceNumber: null,
|
||||
wagonType: w.wagonType
|
||||
? { id: w.wagonType.id, code: w.wagonType.code, name: w.wagonType.name }
|
||||
: { id: w.wagonTypeId, code: w.wagonTypeId, name: w.wagonTypeId },
|
||||
physicalYardId: w.currentYardId,
|
||||
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
|
||||
plannedYardId: null,
|
||||
plannedYardLabel: null,
|
||||
cutYardId: null,
|
||||
cutYardLabel: null,
|
||||
realCut: false,
|
||||
coupledYardId,
|
||||
coupledYardLabel: labels.get(coupledYardId) ?? coupledYardId,
|
||||
aligned: w.currentYardId === coupledYardId,
|
||||
locked,
|
||||
lockReason: locked ? 'Carries cargo booked on this schedule' : null,
|
||||
});
|
||||
}
|
||||
const perStop = stops.map((s) => ({
|
||||
yardId: s.yardId,
|
||||
label: s.label,
|
||||
@@ -6622,6 +7010,7 @@ export class TrainSchedulingService {
|
||||
planned: rows.filter((r) => r.plannedYardId === s.yardId).length,
|
||||
physical: rows.filter((r) => r.physicalYardId === s.yardId).length,
|
||||
cut: rows.filter((r) => r.cutYardId === s.yardId).length,
|
||||
coupled: rows.filter((r) => r.coupledYardId === s.yardId).length,
|
||||
}));
|
||||
return {
|
||||
scheduleId,
|
||||
@@ -6634,17 +7023,29 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-plan which yard this departure boards wagons from (`yardId`) and/or
|
||||
* where it cuts them mid-route (`cutYardId`; null clears — the wagon rides
|
||||
* to the destination). Only DRAFT/SCHEDULED schedules, only the train's own
|
||||
* wagons; boarding only at pickup stops, cutting only at drop stops after
|
||||
* the boarding yard and never before allocated cargo's destination.
|
||||
* Re-plan this departure's consist plan: boarding yard (`yardId`), cut yard
|
||||
* (`cutYardId`; null clears), the `realCut` flag (permanent removal from the
|
||||
* built train at the cut), and mid-route COUPLES of loose wagons
|
||||
* (`couple`/`uncouple`). Only DRAFT/SCHEDULED schedules; boarding/coupling
|
||||
* only at pickup stops, cutting only at drop stops after the boarding yard
|
||||
* and never before allocated cargo's destination. Coupling validates every
|
||||
* leg the new wagon rides against the locomotives' weight/length caps.
|
||||
* Physical yards are untouched — the train builder owns those.
|
||||
*/
|
||||
async updateScheduleWagonYards(
|
||||
scheduleId: string,
|
||||
moves: Array<{ wagonId: string; yardId?: string; cutYardId?: string | null }>,
|
||||
dto: {
|
||||
moves?: Array<{
|
||||
wagonId: string;
|
||||
yardId?: string;
|
||||
cutYardId?: string | null;
|
||||
realCut?: boolean;
|
||||
}>;
|
||||
couple?: Array<{ wagonId: string; yardId: string }>;
|
||||
uncouple?: string[];
|
||||
},
|
||||
) {
|
||||
const moves = dto.moves ?? [];
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
const builtTrain = schedule.trainSet?.train;
|
||||
@@ -6665,7 +7066,7 @@ export class TrainSchedulingService {
|
||||
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
|
||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: builtTrain.id },
|
||||
select: { id: true, currentYardId: true, wagonNumber: true },
|
||||
relations: { wagonType: true },
|
||||
});
|
||||
const wagonById = new Map(wagons.map((w) => [w.id, w]));
|
||||
const lockedIds = new Set(
|
||||
@@ -6693,6 +7094,8 @@ export class TrainSchedulingService {
|
||||
|
||||
const plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) };
|
||||
const cutPlan: Record<string, string> = { ...(schedule.plannedWagonCutYards ?? {}) };
|
||||
const couplePlan: Record<string, string> = { ...(schedule.plannedWagonCouples ?? {}) };
|
||||
const realCuts = new Set(schedule.plannedWagonRealCuts ?? []);
|
||||
for (const move of moves) {
|
||||
const wagon = wagonById.get(move.wagonId);
|
||||
if (!wagon) {
|
||||
@@ -6713,6 +7116,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
if (move.cutYardId === null) {
|
||||
delete cutPlan[wagon.id];
|
||||
realCuts.delete(wagon.id);
|
||||
} else if (move.cutYardId !== undefined) {
|
||||
if (!dropYardIds.has(move.cutYardId)) {
|
||||
throw new BadRequestException(
|
||||
@@ -6740,10 +7144,149 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Real-cut flag rides on the (now final) cut for this wagon.
|
||||
if (move.realCut === false) {
|
||||
realCuts.delete(wagon.id);
|
||||
} else if (move.realCut === true) {
|
||||
if (!cutPlan[wagon.id]) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${wagon.wagonNumber}: a real cut needs a cut yard — set where the wagon is cut first`,
|
||||
);
|
||||
}
|
||||
realCuts.add(wagon.id);
|
||||
}
|
||||
}
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.update(scheduleId, { plannedWagonYards: plan, plannedWagonCutYards: cutPlan });
|
||||
// A flag whose cut disappeared (any path) must not survive.
|
||||
for (const id of [...realCuts]) if (!cutPlan[id]) realCuts.delete(id);
|
||||
|
||||
// ── Couples: loose wagons planned to join the train at a pickup stop ──
|
||||
const uncouple = new Set(dto.uncouple ?? []);
|
||||
for (const id of uncouple) {
|
||||
if (!couplePlan[id]) {
|
||||
throw new BadRequestException(`Wagon ${id} is not in this schedule's couple plan`);
|
||||
}
|
||||
if (lockedIds.has(id)) {
|
||||
throw new ConflictException(
|
||||
'Coupled wagon carries cargo booked on this schedule — free the bookings first',
|
||||
);
|
||||
}
|
||||
delete couplePlan[id];
|
||||
}
|
||||
const coupleEntries = dto.couple ?? [];
|
||||
if (new Set(coupleEntries.map((c) => c.wagonId)).size !== coupleEntries.length) {
|
||||
throw new BadRequestException('A wagon appears more than once in the couple list');
|
||||
}
|
||||
for (const c of coupleEntries) {
|
||||
if (uncouple.has(c.wagonId)) {
|
||||
throw new BadRequestException('A wagon cannot be both coupled and uncoupled in one save');
|
||||
}
|
||||
if (!pickupYardIds.has(c.yardId)) {
|
||||
throw new BadRequestException(
|
||||
`Yard ${c.yardId} is not a pickup stop of this schedule's route`,
|
||||
);
|
||||
}
|
||||
if (wagonById.has(c.wagonId)) {
|
||||
throw new BadRequestException(
|
||||
`Wagon is already in train ${builtTrain.code}'s consist — use its yard/cut controls instead`,
|
||||
);
|
||||
}
|
||||
couplePlan[c.wagonId] = c.yardId;
|
||||
}
|
||||
if (coupleEntries.length) {
|
||||
const incoming = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { id: In(coupleEntries.map((c) => c.wagonId)) },
|
||||
relations: { wagonType: true },
|
||||
});
|
||||
const incomingById = new Map(incoming.map((w) => [w.id, w]));
|
||||
const pinnedElsewhere = await this.wagonIdsPinnedToLiveSchedules(undefined, builtTrain.id);
|
||||
for (const c of coupleEntries) {
|
||||
const w = incomingById.get(c.wagonId);
|
||||
if (!w) throw new BadRequestException(`Wagon ${c.wagonId} not found`);
|
||||
if (w.trainId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${w.wagonNumber} is already coupled to another built train`,
|
||||
);
|
||||
}
|
||||
if (w.status !== WagonStatus.Available) {
|
||||
throw new ConflictException(`Wagon ${w.wagonNumber} is not available (${w.status})`);
|
||||
}
|
||||
if (w.currentYardId !== c.yardId) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${w.wagonNumber} does not stand at the couple yard — it must physically wait where the train picks it up`,
|
||||
);
|
||||
}
|
||||
if (pinnedElsewhere.has(w.id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${w.wagonNumber} is reserved by another live schedule`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-leg weight/length guard: a couple must fit every edge it rides ──
|
||||
// Cuts alone only shrink load; the guard runs whenever couples remain in
|
||||
// the final plan, so cut-then-couple in one save passes on the freed edge.
|
||||
const coupleIds = Object.keys(couplePlan);
|
||||
if (coupleIds.length) {
|
||||
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||||
const pullCap =
|
||||
(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0);
|
||||
const lenCap =
|
||||
(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0);
|
||||
const edgeCount = Math.max(1, stops.length - 1);
|
||||
const coupleWagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { id: In(coupleIds) },
|
||||
relations: { wagonType: true },
|
||||
});
|
||||
const spanOfConsist = (w: Wagon) => ({
|
||||
fromEdge: stopIdx.get(scheduleYardOf(plan, w) ?? '') ?? 0,
|
||||
toEdge: cutPlan[w.id] ? stopIdx.get(cutPlan[w.id]) ?? edgeCount : edgeCount,
|
||||
tareTons: Number(w.wagonType?.tareWeightTons ?? 0),
|
||||
lengthMeters: Number(w.wagonType?.lengthMeters ?? 0),
|
||||
});
|
||||
const wagonSpans = [
|
||||
...wagons.map(spanOfConsist),
|
||||
...coupleWagons.map((w) => ({
|
||||
fromEdge: stopIdx.get(couplePlan[w.id]) ?? 0,
|
||||
toEdge: edgeCount,
|
||||
tareTons: Number(w.wagonType?.tareWeightTons ?? 0),
|
||||
lengthMeters: Number(w.wagonType?.lengthMeters ?? 0),
|
||||
})),
|
||||
];
|
||||
const cargoLegs = (schedule.trainSet?.wagons ?? []).flatMap((slot) =>
|
||||
(slot.allocations ?? []).flatMap((alloc) => {
|
||||
if (!alloc.booking) return [];
|
||||
return [
|
||||
{
|
||||
fromEdge: stopIdx.get(alloc.booking.originYardId) ?? 0,
|
||||
toEdge: stopIdx.get(alloc.booking.destinationYardId) ?? edgeCount,
|
||||
weightTons: Number(alloc.allocatedWeightTons ?? 0),
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
const loads = computeEdgeLoads(edgeCount, wagonSpans, cargoLegs);
|
||||
for (let e = 0; e < edgeCount; e += 1) {
|
||||
const legLabel = `${stops[e].label} → ${stops[e + 1].label}`;
|
||||
if (pullCap > 0 && loads[e].weightTons > pullCap) {
|
||||
throw new BadRequestException(
|
||||
`Leg ${legLabel}: coupling puts gross weight at ${Math.round(loads[e].weightTons)}T, over the locomotives' ${Math.round(pullCap)}T limit — cut a wagon riding this leg first (real cut frees the train permanently)`,
|
||||
);
|
||||
}
|
||||
if (lenCap > 0 && loads[e].lengthMeters > lenCap) {
|
||||
throw new BadRequestException(
|
||||
`Leg ${legLabel}: coupling puts train length at ${Math.round(loads[e].lengthMeters)}m, over the ${Math.round(lenCap)}m limit — cut a wagon riding this leg first`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.dataSource.getRepository(TrainSchedule).update(scheduleId, {
|
||||
plannedWagonYards: plan,
|
||||
plannedWagonCutYards: cutPlan,
|
||||
plannedWagonCouples: couplePlan,
|
||||
plannedWagonRealCuts: [...realCuts],
|
||||
});
|
||||
|
||||
// ponytail: per-stop over-booking check counts bookings boarding at the
|
||||
// stop against wagons planned there, ignoring leg sharing — a warning, not
|
||||
@@ -7535,6 +8078,10 @@ export class TrainSchedulingService {
|
||||
trainSetWagonId: null,
|
||||
currentTrainScheduleId: null,
|
||||
currentYardId,
|
||||
// A wagon leaving the build sheds its run numbers, same as the train
|
||||
// builder's removeWagon — they belong to the train, not the wagon.
|
||||
importTrainNumber: null,
|
||||
exportTrainNumber: null,
|
||||
};
|
||||
for (const wagon of removed) {
|
||||
await manager.getRepository(Wagon).update(wagon.id, detachPatch);
|
||||
@@ -8171,8 +8718,10 @@ export class TrainSchedulingService {
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
// Wagons staff plan to cut mid-route are gone from every edge past the cut.
|
||||
// Wagons staff plan to cut mid-route are gone from every edge past the
|
||||
// cut; planned couples add a slot from their couple stop onward.
|
||||
subtractCutWagons(budget, schedule.plannedWagonCutYards);
|
||||
addCoupledWagons(budget, schedule.plannedWagonCouples);
|
||||
for (const sb of schedule.scheduleBookings ?? []) {
|
||||
if (!sb.booking) continue;
|
||||
budget.subtract(
|
||||
@@ -8845,6 +9394,24 @@ export class TrainSchedulingService {
|
||||
// enforcement; coupled-but-empty consist wagons ride every edge.
|
||||
const heaviestLeg = schedule.trainSet
|
||||
? (() => {
|
||||
const legStops = this.mapScheduleStops(schedule).map((s) => s.yardId);
|
||||
const legStopIdx = new Map(legStops.map((yardId, i) => [yardId, i]));
|
||||
// Booking id → the stop-index span its cargo actually rides. Without
|
||||
// this map a shared slot's FULL cargo counts on every edge the slot
|
||||
// spans, over-reporting the heaviest leg (S-2026-00045 read 3703T on
|
||||
// a leg that truly carried 2905T). Unknown yards fall back to the
|
||||
// slot's whole span inside slotCargoOnEdge — conservative, as before.
|
||||
const legByBookingId = new Map<string, { from: number; to: number }>();
|
||||
for (const slot of schedule.trainSet.wagons ?? []) {
|
||||
for (const alloc of slot.allocations ?? []) {
|
||||
const booking = alloc.booking;
|
||||
if (!booking || legByBookingId.has(alloc.bookingId)) continue;
|
||||
legByBookingId.set(alloc.bookingId, {
|
||||
from: legStopIdx.get(booking.originYardId) ?? -1,
|
||||
to: legStopIdx.get(booking.destinationYardId) ?? -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
const usage = maxEdgeConsistUsage(
|
||||
[
|
||||
...(schedule.trainSet.wagons ?? []).map((w) => ({
|
||||
@@ -8864,7 +9431,8 @@ export class TrainSchedulingService {
|
||||
allocations: [],
|
||||
})),
|
||||
],
|
||||
this.mapScheduleStops(schedule).map((s) => s.yardId),
|
||||
legStops,
|
||||
legByBookingId,
|
||||
);
|
||||
return {
|
||||
grossWeightTons: roundTons(usage.grossWeightTons),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
sumWagonsRequired,
|
||||
validate20ftContainerRules,
|
||||
validateContainerPlacements,
|
||||
validateMixedTrainLimitsPerEdge,
|
||||
validateWagonCargoExclusivity,
|
||||
} from './wagon-plan.util';
|
||||
|
||||
@@ -412,4 +413,91 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', ()
|
||||
loadedWagonCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('with a legs map, shared-slot cargo weighs only its own edges (the S-2026-00045 shape)', () => {
|
||||
// One wagon reused across legs: booking X rides a→b (40T), booking Y
|
||||
// boards at b with 30T. The slot spans the whole route, but edge a→b
|
||||
// must weigh 24 + 40 = 64T — not 24 + 70. Tare rides both edges.
|
||||
const shared = {
|
||||
tareWeightTons: 24,
|
||||
assignedWeightTons: 70,
|
||||
lengthMeters: 14,
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
allocations: [
|
||||
{ bookingId: 'X', allocatedWeightTons: 40 },
|
||||
{ bookingId: 'Y', allocatedWeightTons: 30 },
|
||||
],
|
||||
} as never;
|
||||
const legs = new Map([
|
||||
['X', { from: 0, to: 1 }],
|
||||
['Y', { from: 1, to: 2 }],
|
||||
]);
|
||||
// Without legs: whole-span scalar on both edges (94T binding edge).
|
||||
expect(maxEdgeConsistUsage([shared], stops).grossWeightTons).toBe(94);
|
||||
// With legs: heaviest edge is a→b at 64T (b→c is 54T).
|
||||
expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(64);
|
||||
});
|
||||
|
||||
it('falls back to the whole-span scalar when an allocation has no readable weight', () => {
|
||||
const shared = {
|
||||
tareWeightTons: 24,
|
||||
assignedWeightTons: 70,
|
||||
lengthMeters: 14,
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
allocations: [{ bookingId: 'X' }],
|
||||
} as never;
|
||||
const legs = new Map([['X', { from: 0, to: 1 }]]);
|
||||
expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(94);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMixedTrainLimitsPerEdge — leg-aware cargo weighing', () => {
|
||||
it('does not flag a leg whose overweight is only later-boarding cargo (S-2026-00045)', () => {
|
||||
// 2 shared wagons, 100T cap. Booking X rides a→b with 30T/wagon, booking Y
|
||||
// boards at b with 25T/wagon. Whole-span scalars read every edge as
|
||||
// 2×(20 + 55) = 150T > 100T; the cargo actually aboard is 100T (a→b) and
|
||||
// 90T (b→c) — both fit.
|
||||
const slot = (seq: number) => ({
|
||||
sequenceNo: seq,
|
||||
wagonTypeId: 'wt-nw5',
|
||||
wagonTypeCode: 'NW5',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
tareWeightTons: 20,
|
||||
assignedWeightTons: 55,
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
allocations: [
|
||||
{
|
||||
bookingId: 'X',
|
||||
bookingReference: 'X',
|
||||
allocatedWeightTons: 30,
|
||||
loadType: AllocationLoadType.Container,
|
||||
},
|
||||
{
|
||||
bookingId: 'Y',
|
||||
bookingReference: 'Y',
|
||||
allocatedWeightTons: 25,
|
||||
loadType: AllocationLoadType.Container,
|
||||
},
|
||||
],
|
||||
});
|
||||
const legs = new Map([
|
||||
['X', { from: 0, to: 1 }],
|
||||
['Y', { from: 1, to: 2 }],
|
||||
]);
|
||||
const run = (withLegs?: typeof legs) =>
|
||||
validateMixedTrainLimitsPerEdge(
|
||||
[slot(1), slot(2)] as never,
|
||||
[{ lengthMeters: 14 }],
|
||||
{ maxWeightTons: 100 },
|
||||
['a', 'b', 'c'],
|
||||
undefined,
|
||||
withLegs,
|
||||
);
|
||||
expect(run()).toHaveLength(2); // both edges falsely overweight without legs
|
||||
expect(run(legs)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -654,9 +654,16 @@ export function validateMixedTrainLimitsPerEdge(
|
||||
const label = (i: number) => stopLabels?.[i] ?? stops[i];
|
||||
const violations = new Set<string>();
|
||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||||
const active = wagonPlan.filter(
|
||||
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
||||
);
|
||||
// A shared slot rides the UNION of its cargo legs, but only carries each
|
||||
// booking's cargo on that booking's own edges — weigh the edge with the
|
||||
// cargo actually aboard there, not the slot's whole-route scalar, or a
|
||||
// container boarding at Dire Dawa reads as hauled from Djibouti.
|
||||
const active = wagonPlan
|
||||
.filter((_, i) => spans[i].from <= edge && edge < spans[i].to)
|
||||
.map((slot) => ({
|
||||
...slot,
|
||||
assignedWeightTons: slotCargoOnEdge(slot, edge, edges, legs),
|
||||
}));
|
||||
if (!active.length) continue;
|
||||
for (const violation of validateMixedTrainLimits(
|
||||
active,
|
||||
@@ -684,6 +691,40 @@ export type EdgeUsageSlot = Pick<
|
||||
allocations?: unknown[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Cargo tons a slot actually carries on one edge. With a legs map and readable
|
||||
* allocation records, each booking's cargo counts only on the edges that
|
||||
* booking rides (an unmapped booking stays on the slot's whole span). Without
|
||||
* either — or when any allocation lacks a numeric weight, e.g. persisted rows
|
||||
* fed through {@link EdgeUsageSlot} — falls back to the slot's whole-span
|
||||
* `assignedWeightTons`, the pre-existing reading.
|
||||
*/
|
||||
function slotCargoOnEdge(
|
||||
slot: EdgeUsageSlot,
|
||||
edge: number,
|
||||
edgeCount: number,
|
||||
legs?: Map<string, { from: number; to: number }>,
|
||||
): number {
|
||||
const wholeSpanCargo = Number(slot.assignedWeightTons ?? 0);
|
||||
const allocations = (slot.allocations ?? []) as Array<{
|
||||
bookingId?: string;
|
||||
allocatedWeightTons?: number | string;
|
||||
}>;
|
||||
if (!legs?.size || !allocations.length) return wholeSpanCargo;
|
||||
let cargo = 0;
|
||||
for (const allocation of allocations) {
|
||||
const weight = Number(allocation?.allocatedWeightTons);
|
||||
if (!Number.isFinite(weight)) return wholeSpanCargo;
|
||||
const leg = allocation.bookingId ? legs.get(allocation.bookingId) : undefined;
|
||||
const rides =
|
||||
!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to
|
||||
? true
|
||||
: leg.from <= edge && edge < leg.to;
|
||||
if (rides) cargo += weight;
|
||||
}
|
||||
return cargo;
|
||||
}
|
||||
|
||||
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
|
||||
function slotSpans(
|
||||
wagonPlan: EdgeUsageSlot[],
|
||||
@@ -708,8 +749,10 @@ function slotSpans(
|
||||
export function maxEdgeConsistUsage(
|
||||
wagonPlan: EdgeUsageSlot[],
|
||||
stops: string[],
|
||||
/** Booking id → stop-index span; cargo then weighs only its own edges. */
|
||||
legs?: Map<string, { from: number; to: number }>,
|
||||
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
|
||||
return perEdgeConsistUsage(wagonPlan, stops).reduce(
|
||||
return perEdgeConsistUsage(wagonPlan, stops, legs).reduce(
|
||||
(max, e) => ({
|
||||
grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons),
|
||||
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
|
||||
@@ -737,12 +780,19 @@ export type EdgeConsistUsage = {
|
||||
export function perEdgeConsistUsage(
|
||||
wagonPlan: EdgeUsageSlot[],
|
||||
stops: string[],
|
||||
/**
|
||||
* Booking id → stop-index span. When given, a shared slot's cargo weighs
|
||||
* only the edges its booking rides (tare still rides the slot's whole
|
||||
* span) — without it a slot's full cargo counts on every edge it spans.
|
||||
*/
|
||||
legs?: Map<string, { from: number; to: number }>,
|
||||
): EdgeConsistUsage[] {
|
||||
const edgeCount = Math.max(1, stops.length - 1);
|
||||
const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({
|
||||
edge,
|
||||
grossWeightTons: slots.reduce(
|
||||
(sum, w) =>
|
||||
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
|
||||
sum + Number(w.tareWeightTons ?? 0) + slotCargoOnEdge(w, edge, edgeCount, legs),
|
||||
0,
|
||||
),
|
||||
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
|
||||
|
||||
@@ -54,6 +54,13 @@ export type WagonStock = {
|
||||
* math.
|
||||
*/
|
||||
byYardId?: Map<string, Map<string, number>>;
|
||||
/**
|
||||
* Wagons the schedule CUTS mid-route (staff plan): each is stock only up to
|
||||
* its cut stop. Consumers debit it from its pool on every edge at/after the
|
||||
* cut, so a leg riding past the cut never counts it. Absent = no cuts.
|
||||
* `poolYardId` is the wagon's boarding pool ('' on a single-yard consist).
|
||||
*/
|
||||
cutWagons?: Array<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>;
|
||||
};
|
||||
|
||||
export type FlexPlanResult = {
|
||||
@@ -324,6 +331,16 @@ export function planWagonsWithStock(params: {
|
||||
}
|
||||
return row;
|
||||
};
|
||||
// Cut wagons are pre-consumed on every edge at/after their cut stop: they
|
||||
// are steel for gmp→lebu but not for gmp→dct. Unknown cut yard (no stops
|
||||
// given / off-corridor) is skipped — conservative, same as before cuts.
|
||||
for (const cut of stock.cutWagons ?? []) {
|
||||
const fromEdge = stops.indexOf(cut.cutYardId);
|
||||
if (fromEdge < 0) continue;
|
||||
const pool = stock.byYardId ? cut.poolYardId : '';
|
||||
const row = usedRow(rowKeyFor(cut.wagonTypeId, pool));
|
||||
for (let e = fromEdge; e < edgeCount; e += 1) row[e] += 1;
|
||||
}
|
||||
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
|
||||
const pool = poolOf(leg);
|
||||
const total = totalFor(wagonTypeId, pool);
|
||||
|
||||
@@ -159,3 +159,52 @@ describe('WagonStockLedger — multi-yard consist', () => {
|
||||
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WagonStockLedger — cut wagons (S-2026-00050 shape)', () => {
|
||||
// gmp -> lebu -> mojo -> adama -> dct. 3 NW5 + 2 PW2: two NW5 board at gmp
|
||||
// (one cut at lebu), one NW5 boards at mojo; both PW2 board at gmp.
|
||||
const stops = ['gmp', 'lebu', 'mojo', 'adama', 'dct'];
|
||||
const makeLedger = () => {
|
||||
const ledger = new WagonStockLedger(
|
||||
new Map([
|
||||
['nw5', 3],
|
||||
['pw2', 2],
|
||||
]),
|
||||
stops.length - 1,
|
||||
new Map([
|
||||
['gmp', new Map([['nw5', 2], ['pw2', 2]])],
|
||||
['mojo', new Map([['nw5', 1]])],
|
||||
]),
|
||||
stops,
|
||||
);
|
||||
ledger.debitCutWagons([{ wagonTypeId: 'nw5', poolYardId: 'gmp', cutYardId: 'lebu' }]);
|
||||
return ledger;
|
||||
};
|
||||
const leg = (from: number, to: number) => ({ fromEdge: from, toEdge: to });
|
||||
|
||||
it('a leg past the cut sees only the wagons that reach it', () => {
|
||||
const ledger = makeLedger();
|
||||
// gmp -> dct: 2 NW5 stand at gmp but one is cut at lebu — only 1 rides through.
|
||||
expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(1);
|
||||
// gmp -> lebu: both gmp NW5 serve the short leg.
|
||||
expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(2);
|
||||
// PW2 uncut — both ride anywhere from gmp.
|
||||
expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2);
|
||||
// mojo -> dct: the mojo pool's own NW5, untouched by the gmp cut.
|
||||
expect(ledger.availableFor(['nw5'], leg(2, 4))).toBe(1);
|
||||
});
|
||||
|
||||
it('cut debit and booking consumption stack', () => {
|
||||
const ledger = makeLedger();
|
||||
expect(ledger.consume(['nw5'], 1, leg(0, 4))).toBe(1);
|
||||
expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(0);
|
||||
// Short leg still has the cut wagon (1 = 2 total − 1 consumed through-rider).
|
||||
expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores a cut yard that is not on the stops', () => {
|
||||
const ledger = makeLedger();
|
||||
ledger.debitCutWagons([{ wagonTypeId: 'pw2', poolYardId: 'gmp', cutYardId: 'elsewhere' }]);
|
||||
expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,32 @@ export class WagonStockLedger {
|
||||
return Math.max(0, total - busiest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-debit wagons the schedule CUTS mid-route: each cut wagon occupies its
|
||||
* pool's stock on every edge at/after its cut stop, so a leg riding past the
|
||||
* cut never counts it ("2 NW5 free from gmp" reads 1 when one cuts at Lebu).
|
||||
* A cut yard not on this ledger's stops is skipped — conservative, matches
|
||||
* the pre-cut behavior.
|
||||
*/
|
||||
debitCutWagons(
|
||||
cuts: ReadonlyArray<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>,
|
||||
): void {
|
||||
for (const cut of cuts) {
|
||||
const fromEdge = this.stops.indexOf(cut.cutYardId);
|
||||
if (fromEdge < 0) continue;
|
||||
const pool = this.byYardId ? cut.poolYardId : '';
|
||||
const key = pool ? `${pool}\u0000${cut.wagonTypeId}` : cut.wagonTypeId;
|
||||
let row = this.usedPerEdge.get(key);
|
||||
if (!row) {
|
||||
row = new Array<number>(this.edgeCount).fill(0);
|
||||
this.usedPerEdge.set(key, row);
|
||||
}
|
||||
for (let edge = fromEdge; edge < this.edgeCount; edge += 1) {
|
||||
row[edge] = (row[edge] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Free wagons across every type a booking may ride. A cargo/container type
|
||||
* mapped to several wagon types can use any of them, so they add up.
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
|
||||
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
|
||||
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
@@ -78,6 +79,24 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.getComposition(id);
|
||||
}
|
||||
|
||||
@Get(':id/history')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Wagon adjustment history of this built train: who attached/detached/switched which wagon, when and where — builder edits and trip events alike",
|
||||
})
|
||||
history(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) {
|
||||
return this.trainBuilderService.getTrainHistory(id, query);
|
||||
}
|
||||
|
||||
@Get(':id/detached-wagons')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Wagons previously detached from this train that are still loose — with when/where/by whom they were last detached, ready to re-attach',
|
||||
})
|
||||
detachedWagons(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) {
|
||||
return this.trainBuilderService.getDetachedWagons(id, query);
|
||||
}
|
||||
|
||||
@Put(':id/locomotives')
|
||||
@FleetManage(FREIGHT_PERMS.trains.changeLocomotives)
|
||||
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })
|
||||
|
||||
@@ -231,6 +231,117 @@ export class TrainBuilderService {
|
||||
return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon adjustment history of one built train, newest first: builder
|
||||
* attaches/detaches (no schedule) and trip events (real cuts, couples,
|
||||
* consist adjustments — carrying their schedule reference) alike.
|
||||
*/
|
||||
async getTrainHistory(trainId: string, query: { page?: number; pageSize?: number } = {}) {
|
||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||
const [countRows, rows]: [
|
||||
Array<{ total: string }>,
|
||||
Array<{
|
||||
id: string;
|
||||
action: string;
|
||||
subject: string;
|
||||
yardLabel: string | null;
|
||||
actor: string | null;
|
||||
scheduleReference: string | null;
|
||||
occurredAt: Date;
|
||||
}>,
|
||||
] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT count(*) AS total
|
||||
FROM freight.schedule_wagon_adjustment_logs l
|
||||
WHERE l.train_id = $1
|
||||
AND l.deleted_at IS NULL`,
|
||||
[trainId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT l.id,
|
||||
l.action,
|
||||
l.wagon_number AS "subject",
|
||||
COALESCE(y.label, y.code) AS "yardLabel",
|
||||
COALESCE(u.username, u.email) AS "actor",
|
||||
ts.reference AS "scheduleReference",
|
||||
l.occurred_at AS "occurredAt"
|
||||
FROM freight.schedule_wagon_adjustment_logs l
|
||||
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
||||
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
|
||||
LEFT JOIN freight.train_schedules ts ON ts.id = l.train_schedule_id
|
||||
WHERE l.train_id = $1
|
||||
AND l.deleted_at IS NULL
|
||||
ORDER BY l.occurred_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[trainId, take, skip],
|
||||
),
|
||||
]);
|
||||
const total = Number(countRows[0]?.total ?? 0);
|
||||
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons last detached from THIS train that are still loose (no train,
|
||||
* AVAILABLE) — the re-attach shortlist, with when/where/by whom each was
|
||||
* last detached. Derived from the adjustment log, no denormalized column.
|
||||
*/
|
||||
async getDetachedWagons(trainId: string, query: { page?: number; pageSize?: number } = {}) {
|
||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||
const lastRemovalSql = `
|
||||
SELECT DISTINCT ON (l.wagon_id)
|
||||
l.wagon_id AS "wagonId",
|
||||
l.occurred_at AS "detachedAt",
|
||||
COALESCE(y.label, y.code) AS "detachedYardLabel",
|
||||
COALESCE(u.username, u.email) AS "detachedBy"
|
||||
FROM freight.schedule_wagon_adjustment_logs l
|
||||
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
||||
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
|
||||
WHERE l.train_id = $1
|
||||
AND l.action = 'REMOVE'
|
||||
AND l.deleted_at IS NULL
|
||||
ORDER BY l.wagon_id, l.occurred_at DESC`;
|
||||
const stillLoose = `w.deleted_at IS NULL AND w.train_id IS NULL AND w.status = 'AVAILABLE'`;
|
||||
const [countRows, rows]: [
|
||||
Array<{ total: string }>,
|
||||
Array<{
|
||||
wagonId: string;
|
||||
wagonNumber: string;
|
||||
wagonTypeCode: string | null;
|
||||
currentYardLabel: string | null;
|
||||
detachedAt: Date;
|
||||
detachedYardLabel: string | null;
|
||||
detachedBy: string | null;
|
||||
}>,
|
||||
] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT count(*) AS total
|
||||
FROM (${lastRemovalSql}) last_removal
|
||||
JOIN freight.wagons w ON w.id = last_removal."wagonId"
|
||||
WHERE ${stillLoose}`,
|
||||
[trainId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT last_removal."wagonId",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
wt.code AS "wagonTypeCode",
|
||||
COALESCE(cy.label, cy.code) AS "currentYardLabel",
|
||||
last_removal."detachedAt",
|
||||
last_removal."detachedYardLabel",
|
||||
last_removal."detachedBy"
|
||||
FROM (${lastRemovalSql}) last_removal
|
||||
JOIN freight.wagons w ON w.id = last_removal."wagonId"
|
||||
LEFT JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||
LEFT JOIN freight.yards cy ON cy.id = w.current_yard_id
|
||||
WHERE ${stillLoose}
|
||||
ORDER BY last_removal."detachedAt" DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[trainId, take, skip],
|
||||
),
|
||||
]);
|
||||
const total = Number(countRows[0]?.total ?? 0);
|
||||
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
|
||||
}
|
||||
|
||||
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
|
||||
async getComposition(id: string) {
|
||||
const train = await this.dataSource.getRepository(Train).findOne({
|
||||
@@ -721,6 +832,7 @@ export class TrainBuilderService {
|
||||
toYardId: yardId,
|
||||
kind: WagonMovementKind.Maintenance,
|
||||
note: notes.movementNote,
|
||||
movedByUserId: userId,
|
||||
occurredAt: new Date(),
|
||||
}),
|
||||
);
|
||||
@@ -1097,15 +1209,14 @@ export class TrainBuilderService {
|
||||
.getRepository(TrainSet)
|
||||
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
|
||||
}
|
||||
if (!schedule) return null;
|
||||
|
||||
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
|
||||
|
||||
// Log the consist change even when the train has no live schedule — the
|
||||
// builder's own detach/attach is the train's history too (who removed
|
||||
// which wagon, when, where), and the detached-wagons tab reads it back.
|
||||
const now = new Date();
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
changes.map((c) =>
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: schedule.id,
|
||||
trainScheduleId: schedule?.id ?? null,
|
||||
trainId,
|
||||
action: c.action,
|
||||
wagonId: c.wagonId,
|
||||
@@ -1117,6 +1228,10 @@ export class TrainBuilderService {
|
||||
),
|
||||
);
|
||||
|
||||
if (!schedule) return null;
|
||||
|
||||
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
|
||||
|
||||
// The FULL/reopen decision must run AFTER the transaction commits — see
|
||||
// reconcileWindowAfterConsistChange.
|
||||
return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' };
|
||||
|
||||
Reference in New Issue
Block a user