mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
intercity fix
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consist adjustments can now happen mid-route (train standing at a stop), so
|
||||||
|
* each history row records WHERE it happened. Nullable — rows written before
|
||||||
|
* this column simply have no yard.
|
||||||
|
*/
|
||||||
|
export class AddYardToScheduleWagonAdjustmentLogs3110000000000 implements MigrationInterface {
|
||||||
|
name = "AddYardToScheduleWagonAdjustmentLogs3110000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.schedule_wagon_adjustment_logs
|
||||||
|
ADD COLUMN IF NOT EXISTS yard_id uuid`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.schedule_wagon_adjustment_logs
|
||||||
|
DROP COLUMN IF EXISTS yard_id`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index } from 'typeorm';
|
import { Column, Entity, Index } from 'typeorm';
|
||||||
|
|
||||||
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const;
|
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE', 'SWITCH'] as const;
|
||||||
export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
|
export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* History row for a consist adjustment made from a schedule: staff coupled a
|
* History row for a consist adjustment made from a schedule: staff coupled a
|
||||||
* wagon onto (ADD) or detached one from (REMOVE) the schedule's built train —
|
* wagon onto (ADD), detached one from (REMOVE), or swapped the physical wagon
|
||||||
* e.g. trimming free wagons whose tare pushed gross weight over the
|
* under a loaded slot (SWITCH — wagonNumber reads "OLD → NEW") on the
|
||||||
* locomotives' pull limit. Plain columns (no FK relations) so the history
|
* schedule's built train. `yardId` records WHERE it happened: the origin yard
|
||||||
* survives the wagon or train being deleted later.
|
* before departure, or the mid-route stop the train was standing at. Plain
|
||||||
|
* columns (no FK relations) so the history survives the wagon or train being
|
||||||
|
* deleted later.
|
||||||
*/
|
*/
|
||||||
@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
|
@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
|
||||||
@Index(['trainScheduleId'])
|
@Index(['trainScheduleId'])
|
||||||
@@ -33,6 +35,9 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
|||||||
@Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
|
@Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
|
||||||
adjustedByUserId!: string | null;
|
adjustedByUserId!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
|
||||||
|
yardId!: string | null;
|
||||||
|
|
||||||
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
|
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
|
||||||
occurredAt!: Date;
|
occurredAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -614,6 +614,26 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
const linked =
|
const linked =
|
||||||
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
||||||
|
// Intercity is allocated MANUALLY: payment secures the ride, staff then
|
||||||
|
// place it on whichever same-route train suits (intercity panel). Unpin
|
||||||
|
// from the train it reserved against — that train may be the wrong one by
|
||||||
|
// the time it departs — and return it to the waiting pool as PAID.
|
||||||
|
if (!linked && booking.tradeDirection === "DOMESTIC" && !booking.isGovernment) {
|
||||||
|
await this.dataSource.getRepository(Booking).update(bookingId, {
|
||||||
|
trainScheduleId: null,
|
||||||
|
schedulingStatus: "ELIGIBLE",
|
||||||
|
paymentDeadline: null,
|
||||||
|
} as never);
|
||||||
|
this.logger.log(
|
||||||
|
`[BATCH] intercity ${booking.reference ?? bookingId} PAID — awaiting manual placement by staff`,
|
||||||
|
);
|
||||||
|
void this.completeTrackingMilestones(bookingId, [
|
||||||
|
"FREIGHT_PAYMENT_PENDING",
|
||||||
|
"FREIGHT_PAYMENT_SETTLED",
|
||||||
|
]);
|
||||||
|
this.notifyBoardChanged(booking.trainScheduleId, "intercity_paid_unplaced");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!linked) {
|
if (!linked) {
|
||||||
if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
|
if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
|
||||||
await this.allocate(booking.trainScheduleId, booking, "paid");
|
await this.allocate(booking.trainScheduleId, booking, "paid");
|
||||||
@@ -3143,6 +3163,19 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Manual placement of an ALREADY-PAID intercity booking: payment landed
|
||||||
|
// earlier (and unpinned it back to the pool) — staff are now choosing its
|
||||||
|
// train, so link directly. No new pay window; wagon assignment stays with
|
||||||
|
// staff in the workspace.
|
||||||
|
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.update(booking.id, { trainScheduleId: scheduleId });
|
||||||
|
booking.trainScheduleId = scheduleId;
|
||||||
|
await this.allocate(scheduleId, booking, 'paid');
|
||||||
|
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.reserve(booking, scheduleId);
|
await this.reserve(booking, scheduleId);
|
||||||
this.armSettle(scheduleId);
|
this.armSettle(scheduleId);
|
||||||
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||||||
@@ -3363,7 +3396,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
|
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
|
||||||
);
|
);
|
||||||
this.notifier.secured(booking, reason, scheduleId);
|
this.notifier.secured(booking, reason, scheduleId);
|
||||||
void this.triggerWagonAllocation(scheduleId);
|
// Intercity rides are placed on wagons BY STAFF (workspace wizard) — auto
|
||||||
|
// wagon assignment is for the import/export batch flow only.
|
||||||
|
if (booking.tradeDirection !== 'DOMESTIC') {
|
||||||
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
|
}
|
||||||
void this.markWagonAllocatedMilestone(booking.id);
|
void this.markWagonAllocatedMilestone(booking.id);
|
||||||
// Customer tracking: freight payment settled (commercial pay-window path).
|
// Customer tracking: freight payment settled (commercial pay-window path).
|
||||||
// Government allocations don't pay upfront — theirs stay pending.
|
// Government allocations don't pay upfront — theirs stay pending.
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsArray, IsOptional, IsUUID } from 'class-validator';
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsArray, IsOptional, IsUUID, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
export class ConsistWagonSwitchDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'Coupled wagon being taken out of the consist.' })
|
||||||
|
@IsUUID()
|
||||||
|
fromWagonId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'AVAILABLE same-type wagon from the current yard that takes its place (and its slot, cargo included).' })
|
||||||
|
@IsUUID()
|
||||||
|
toWagonId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class AdjustScheduleConsistDto {
|
export class AdjustScheduleConsistDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
@@ -23,4 +34,15 @@ export class AdjustScheduleConsistDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
removeWagonIds?: string[];
|
removeWagonIds?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [ConsistWagonSwitchDto],
|
||||||
|
description:
|
||||||
|
"Wagon swaps: the replacement takes over the outgoing wagon's position AND its slot, so cargo allocations ride the new wagon. This is how a LOADED wagon leaves the train — removal is blocked for it, switching is not. Replacement must be the same wagon type, AVAILABLE, standing in the train's current yard.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ConsistWagonSwitchDto)
|
||||||
|
switches?: ConsistWagonSwitchDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,8 +330,10 @@ export class IntercityService {
|
|||||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||||
.andWhere('booking.train_schedule_id IS NULL')
|
.andWhere('booking.train_schedule_id IS NULL')
|
||||||
|
// PAID = customer paid but staff have not placed it on a train yet
|
||||||
|
// (intercity allocation is manual) — it stays in the pool until they do.
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
`((booking.is_government = false AND booking.status IN ('FULLY_EXECUTED', 'PAID'))
|
||||||
OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
|
OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
|
||||||
)
|
)
|
||||||
.orderBy('booking.is_government', 'DESC')
|
.orderBy('booking.is_government', 'DESC')
|
||||||
@@ -382,8 +384,12 @@ export class IntercityService {
|
|||||||
if (booking.trainScheduleId) {
|
if (booking.trainScheduleId) {
|
||||||
return 'Already assigned to a train';
|
return 'Already assigned to a train';
|
||||||
}
|
}
|
||||||
const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED';
|
// Commercial: FULLY_EXECUTED opens a pay window; PAID (payment landed,
|
||||||
if (booking.status !== readyStatus) {
|
// awaiting manual placement) links straight onto the chosen train.
|
||||||
|
const readyStatuses = booking.isGovernment
|
||||||
|
? ['APPROVED']
|
||||||
|
: ['FULLY_EXECUTED', 'PAID'];
|
||||||
|
if (!readyStatuses.includes(booking.status)) {
|
||||||
return `Not ready to board (status ${booking.status})`;
|
return `Not ready to board (status ${booking.status})`;
|
||||||
}
|
}
|
||||||
if (!this.corridorOnRoute(booking, milestoneSeq)) {
|
if (!this.corridorOnRoute(booking, milestoneSeq)) {
|
||||||
|
|||||||
@@ -195,6 +195,16 @@ export class TrainSchedulingController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("schedules/:id/history")
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first",
|
||||||
|
})
|
||||||
|
getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.trainSchedulingService.getScheduleHistory(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("bookable-schedules")
|
@Get("bookable-schedules")
|
||||||
// No staff guard: customers hit this while creating a booking to find OPEN
|
// No staff guard: customers hit this while creating a booking to find OPEN
|
||||||
// same-route schedules. Do not attach train_scheduling permissions here.
|
// same-route schedules. Do not attach train_scheduling permissions here.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
|||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
||||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
|
import { Contract } from '../contracts/entities/contract.entity';
|
||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||||
@@ -1249,8 +1250,31 @@ export class TrainSchedulingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limits for a preview aimed at an EXISTING schedule must be the schedule's
|
||||||
|
* own: its locomotive set and its built-consist wagon cap. Resolving from
|
||||||
|
* the dto alone re-derived the global wagon cap (53) and rejected a
|
||||||
|
* physically-coupled 54-wagon train the assign path would accept.
|
||||||
|
*/
|
||||||
|
private async resolvePreviewLimitConfig(dto: {
|
||||||
|
targetScheduleId?: string;
|
||||||
|
maxTrainWeightTons?: number;
|
||||||
|
maxTrainLengthMeters?: number;
|
||||||
|
maxWagonsPerTrain?: number;
|
||||||
|
}): Promise<Required<TrainLimitConfig>> {
|
||||||
|
const target = dto.targetScheduleId
|
||||||
|
? await this.trainSchedulesRepository.findByIdWithFullGraph(dto.targetScheduleId)
|
||||||
|
: null;
|
||||||
|
if (!target) return this.resolveTrainLimitConfig(dto);
|
||||||
|
return this.resolveTrainLimitConfig(
|
||||||
|
dto,
|
||||||
|
combinedLocomotiveLimits(this.locomotivesOfTrainSet(target.trainSet)),
|
||||||
|
target.maxWagons ?? undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async previewTrainSchedule(dto: PreviewTrainScheduleDto) {
|
async previewTrainSchedule(dto: PreviewTrainScheduleDto) {
|
||||||
const limits = await this.resolveTrainLimitConfig(dto);
|
const limits = await this.resolvePreviewLimitConfig(dto);
|
||||||
return this.buildPreviewResponse(
|
return this.buildPreviewResponse(
|
||||||
await this.validateBookingsForScheduling(
|
await this.validateBookingsForScheduling(
|
||||||
dto,
|
dto,
|
||||||
@@ -1265,7 +1289,7 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
|
async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
|
||||||
const limits = await this.resolveTrainLimitConfig(dto);
|
const limits = await this.resolvePreviewLimitConfig(dto);
|
||||||
return this.buildPreviewResponse(
|
return this.buildPreviewResponse(
|
||||||
await this.validateBookingsForScheduling(
|
await this.validateBookingsForScheduling(
|
||||||
dto,
|
dto,
|
||||||
@@ -1280,7 +1304,7 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) {
|
async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) {
|
||||||
const limits = await this.resolveTrainLimitConfig(dto);
|
const limits = await this.resolvePreviewLimitConfig(dto);
|
||||||
return this.buildPreviewResponse(
|
return this.buildPreviewResponse(
|
||||||
await this.validateBookingsForScheduling(
|
await this.validateBookingsForScheduling(
|
||||||
dto,
|
dto,
|
||||||
@@ -4657,16 +4681,29 @@ export class TrainSchedulingService {
|
|||||||
* schedule. Used to guard consist trims — the Wagon entity itself carries no
|
* schedule. Used to guard consist trims — the Wagon entity itself carries no
|
||||||
* schedule-occupancy state anymore.
|
* schedule-occupancy state anymore.
|
||||||
*/
|
*/
|
||||||
private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise<Set<string>> {
|
/**
|
||||||
|
* Physical wagons pinned to any live run's slot. `excludeTrainId` drops the
|
||||||
|
* pins of that BUILT TRAIN's own schedules (this run and its siblings — e.g.
|
||||||
|
* the paired return leg): a consist edit is an edit of the TRAIN, sibling
|
||||||
|
* runs ride whatever it is composed of and their pins are re-pointed by the
|
||||||
|
* edit itself. Only pins held by live schedules of OTHER trains block it.
|
||||||
|
*/
|
||||||
|
private async wagonIdsPinnedToLiveSchedules(
|
||||||
|
manager?: EntityManager,
|
||||||
|
excludeTrainId?: string,
|
||||||
|
): Promise<Set<string>> {
|
||||||
const runner = manager ?? this.dataSource;
|
const runner = manager ?? this.dataSource;
|
||||||
const rows: { physical_wagon_id: string }[] = await runner.query(
|
const rows: { physical_wagon_id: string }[] = await runner.query(
|
||||||
`SELECT DISTINCT tsw.physical_wagon_id
|
`SELECT DISTINCT tsw.physical_wagon_id
|
||||||
FROM freight.train_set_wagons tsw
|
FROM freight.train_set_wagons tsw
|
||||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||||
|
JOIN freight.train_sets tset ON tset.id = tsw.train_set_id
|
||||||
WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||||
AND ts.deleted_at IS NULL
|
AND ts.deleted_at IS NULL
|
||||||
AND tsw.deleted_at IS NULL
|
AND tsw.deleted_at IS NULL
|
||||||
AND tsw.physical_wagon_id IS NOT NULL`,
|
AND tsw.physical_wagon_id IS NOT NULL
|
||||||
|
AND ($1::uuid IS NULL OR tset.train_id IS NULL OR tset.train_id <> $1)`,
|
||||||
|
[excludeTrainId ?? null],
|
||||||
);
|
);
|
||||||
return new Set(rows.map((row) => row.physical_wagon_id));
|
return new Set(rows.map((row) => row.physical_wagon_id));
|
||||||
}
|
}
|
||||||
@@ -5711,6 +5748,70 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where consist work can physically happen right now. Before departure it is
|
||||||
|
* the built train's own yard. After dispatch it is the route stop the train
|
||||||
|
* is STANDING AT per its latest checkpoint — null while rolling between
|
||||||
|
* stops or when the last checkpoint is off-route, and consist work is closed
|
||||||
|
* there. Arrived/cancelled schedules always return null (history only).
|
||||||
|
*/
|
||||||
|
private async currentConsistYardId(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (
|
||||||
|
schedule.status === TrainScheduleStatusEnum.Draft ||
|
||||||
|
schedule.status === TrainScheduleStatusEnum.Scheduled
|
||||||
|
) {
|
||||||
|
return schedule.trainSet?.train?.currentYardId ?? schedule.originStationId;
|
||||||
|
}
|
||||||
|
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) return null;
|
||||||
|
const rows: Array<{ yard_id: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT yard_id
|
||||||
|
FROM freight.train_checkpoint_events
|
||||||
|
WHERE train_schedule_id = $1
|
||||||
|
ORDER BY occurred_at DESC, created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[schedule.id],
|
||||||
|
);
|
||||||
|
const yardId = rows[0]?.yard_id ?? null;
|
||||||
|
if (!yardId) return null;
|
||||||
|
return this.mapScheduleStops(schedule).some((s) => s.yardId === yardId)
|
||||||
|
? yardId
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Physical wagons whose cargo still RIDES beyond the given stop: any
|
||||||
|
* allocation whose booking alights strictly after it. Cargo whose
|
||||||
|
* destination is this stop (or an earlier one) has been offloaded here and
|
||||||
|
* no longer blocks its wagon — that wagon may be trimmed or switched away.
|
||||||
|
* Before departure the stop is the origin, so every allocated wagon counts
|
||||||
|
* as aboard — one rule covers both phases. Unknown destinations and
|
||||||
|
* off-route stops stay conservative (aboard).
|
||||||
|
*/
|
||||||
|
// ponytail: trusts booking.destinationYardId, not a physical unload
|
||||||
|
// confirmation — if staff trim before actually unloading, the cargo strands.
|
||||||
|
// Wire the journey unload flag in if that ever bites.
|
||||||
|
private wagonIdsWithCargoBeyond(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
atYardId: string | null,
|
||||||
|
): Set<string> {
|
||||||
|
const stops = this.mapScheduleStops(schedule).map((s) => s.yardId);
|
||||||
|
const atIdx = atYardId ? stops.indexOf(atYardId) : -1;
|
||||||
|
const aboard = new Set<string>();
|
||||||
|
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||||
|
if (!slot.physicalWagonId || !(slot.allocations?.length ?? 0)) continue;
|
||||||
|
const ridesOn = (slot.allocations ?? []).some((allocation) => {
|
||||||
|
const destination = allocation.booking?.destinationYardId;
|
||||||
|
const destIdx = destination ? stops.indexOf(destination) : -1;
|
||||||
|
if (destIdx < 0 || atIdx < 0) return true;
|
||||||
|
return destIdx > atIdx;
|
||||||
|
});
|
||||||
|
if (ridesOn) aboard.add(slot.physicalWagonId);
|
||||||
|
}
|
||||||
|
return aboard;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consist snapshot for the adjust-consist UI: the built train's wagons with
|
* Consist snapshot for the adjust-consist UI: the built train's wagons with
|
||||||
* loaded/removable flags, gross weight (cargo + FULL consist tare) and length
|
* loaded/removable flags, gross weight (cargo + FULL consist tare) and length
|
||||||
@@ -5727,32 +5828,41 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where the train stands right now — the origin yard before departure, the
|
||||||
|
// checkpoint stop after it. Null = rolling; the consist is view-only then.
|
||||||
|
const currentYardId = await this.currentConsistYardId(schedule);
|
||||||
|
|
||||||
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 },
|
relations: { wagonType: true },
|
||||||
order: { sequenceNumber: 'ASC' },
|
order: { sequenceNumber: 'ASC' },
|
||||||
});
|
});
|
||||||
const addableWagons = await this.dataSource.getRepository(Wagon).find({
|
const addableWagons = currentYardId
|
||||||
where: {
|
? await this.dataSource.getRepository(Wagon).find({
|
||||||
trainId: IsNull(),
|
where: {
|
||||||
status: WagonStatus.Available,
|
trainId: IsNull(),
|
||||||
currentYardId: builtTrain.currentYardId ?? undefined,
|
status: WagonStatus.Available,
|
||||||
},
|
currentYardId,
|
||||||
relations: { wagonType: true },
|
},
|
||||||
order: { wagonNumber: 'ASC' },
|
relations: { wagonType: true },
|
||||||
});
|
order: { wagonNumber: 'ASC' },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
const adjustments = await this.dataSource
|
const adjustments = await this.dataSource
|
||||||
.getRepository(ScheduleWagonAdjustmentLog)
|
.getRepository(ScheduleWagonAdjustmentLog)
|
||||||
.find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 });
|
.find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 });
|
||||||
|
|
||||||
// Slots with cargo aboard — their physical wagons are "loaded" and can
|
// Slots whose cargo still rides beyond the current stop — those wagons
|
||||||
// never be trimmed.
|
// cannot be trimmed, only switched. Cargo offloaded at this stop (or
|
||||||
const loadedWagonIds = new Set(
|
// earlier) has released its wagon.
|
||||||
(schedule.trainSet?.wagons ?? [])
|
const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId);
|
||||||
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
// Only OTHER trains' pins block edits here — this train's own schedules
|
||||||
.map((slot) => slot.physicalWagonId as string),
|
// (incl. the paired return run) have their pins managed by the edit itself
|
||||||
|
// (removal clears, switch re-points).
|
||||||
|
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(
|
||||||
|
undefined,
|
||||||
|
builtTrain.id,
|
||||||
);
|
);
|
||||||
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
|
|
||||||
|
|
||||||
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||||||
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
|
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
|
||||||
@@ -5817,12 +5927,23 @@ export class TrainSchedulingService {
|
|||||||
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
|
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
wagons: wagons.map((wagon) => ({
|
wagons: wagons.map((wagon) => {
|
||||||
...mapWagon(wagon),
|
const loaded = loadedWagonIds.has(wagon.id);
|
||||||
loaded: loadedWagonIds.has(wagon.id),
|
const pinnedElsewhere = pinnedToLiveIds.has(wagon.id);
|
||||||
// Free = not pinned to any live run's slot; only free wagons can be trimmed.
|
return {
|
||||||
removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id),
|
...mapWagon(wagon),
|
||||||
})),
|
loaded,
|
||||||
|
removable: !pinnedElsewhere && !loaded,
|
||||||
|
// A loaded wagon can't leave, but its SLOT can change wagon: switch
|
||||||
|
// moves the cargo allocations onto a same-type replacement.
|
||||||
|
switchable: !pinnedElsewhere,
|
||||||
|
blockReason: pinnedElsewhere
|
||||||
|
? 'Pinned by another live schedule'
|
||||||
|
: loaded
|
||||||
|
? 'Cargo aboard rides beyond this stop — switch it instead'
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
addableWagons: addableWagons.map(mapWagon),
|
addableWagons: addableWagons.map(mapWagon),
|
||||||
adjustments: adjustments.map((log) => ({
|
adjustments: adjustments.map((log) => ({
|
||||||
id: log.id,
|
id: log.id,
|
||||||
@@ -5830,9 +5951,26 @@ export class TrainSchedulingService {
|
|||||||
wagonId: log.wagonId,
|
wagonId: log.wagonId,
|
||||||
wagonNumber: log.wagonNumber,
|
wagonNumber: log.wagonNumber,
|
||||||
adjustedByUserId: log.adjustedByUserId,
|
adjustedByUserId: log.adjustedByUserId,
|
||||||
|
yardId: log.yardId ?? null,
|
||||||
occurredAt: log.occurredAt,
|
occurredAt: log.occurredAt,
|
||||||
})),
|
})),
|
||||||
editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status),
|
// Editable before departure, and after it whenever the train is standing
|
||||||
|
// at a route stop (mid-route wagon work at station B); frozen while
|
||||||
|
// rolling and once arrived/cancelled.
|
||||||
|
editable:
|
||||||
|
['DRAFT', 'SCHEDULED'].includes(schedule.status) ||
|
||||||
|
(schedule.status === TrainScheduleStatusEnum.Dispatched &&
|
||||||
|
currentYardId != null),
|
||||||
|
currentStop: currentYardId
|
||||||
|
? {
|
||||||
|
yardId: currentYardId,
|
||||||
|
label:
|
||||||
|
this.mapScheduleStops(schedule).find(
|
||||||
|
(s) => s.yardId === currentYardId,
|
||||||
|
)?.label ?? currentYardId,
|
||||||
|
isMidRoute: schedule.status === TrainScheduleStatusEnum.Dispatched,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5851,19 +5989,38 @@ export class TrainSchedulingService {
|
|||||||
) {
|
) {
|
||||||
const addWagonIds = [...new Set(dto.addWagonIds ?? [])];
|
const addWagonIds = [...new Set(dto.addWagonIds ?? [])];
|
||||||
const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])];
|
const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])];
|
||||||
if (!addWagonIds.length && !removeWagonIds.length) {
|
const switches = dto.switches ?? [];
|
||||||
throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove');
|
if (!addWagonIds.length && !removeWagonIds.length && !switches.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Nothing to adjust — pass wagons to add, remove and/or switch',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id));
|
const switchFromIds = switches.map((s) => s.fromWagonId);
|
||||||
if (overlap.length) {
|
const switchToIds = switches.map((s) => s.toWagonId);
|
||||||
throw new BadRequestException('A wagon cannot be added and removed in the same adjustment');
|
const touched = new Map<string, number>();
|
||||||
|
for (const id of [...addWagonIds, ...removeWagonIds, ...switchFromIds, ...switchToIds]) {
|
||||||
|
touched.set(id, (touched.get(id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
if ([...touched.values()].some((count) => count > 1)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Each wagon may appear once per adjustment — not in two lists or two switches',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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`);
|
||||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
// Consist edits are open before departure, and after it whenever the train
|
||||||
|
// is STANDING AT a route stop (checkpointed): that is exactly the "switch
|
||||||
|
// wagons at station B" window. Rolling between stops → frozen.
|
||||||
|
const currentYardId = await this.currentConsistYardId(schedule);
|
||||||
|
const editableStatus =
|
||||||
|
['DRAFT', 'SCHEDULED'].includes(schedule.status) ||
|
||||||
|
schedule.status === TrainScheduleStatusEnum.Dispatched;
|
||||||
|
if (!editableStatus || !currentYardId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'The consist is frozen once the train is dispatched — adjust before departure',
|
schedule.status === TrainScheduleStatusEnum.Dispatched
|
||||||
|
? 'The train is rolling — consist changes are only possible while it stands at a route stop (latest checkpoint)'
|
||||||
|
: 'The consist can no longer be adjusted — the run is over',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const builtTrainRef = schedule.trainSet?.train;
|
const builtTrainRef = schedule.trainSet?.train;
|
||||||
@@ -5872,11 +6029,9 @@ export class TrainSchedulingService {
|
|||||||
'This schedule was not created from a built train — its consist cannot be adjusted here',
|
'This schedule was not created from a built train — its consist cannot be adjusted here',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const loadedWagonIds = new Set(
|
// Wagons whose cargo still rides beyond the current stop: never removable,
|
||||||
(schedule.trainSet?.wagons ?? [])
|
// but switchable — the replacement inherits the slot, cargo included.
|
||||||
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId);
|
||||||
.map((slot) => slot.physicalWagonId as string),
|
|
||||||
);
|
|
||||||
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||||||
const pullCapTons = roundTons(
|
const pullCapTons = roundTons(
|
||||||
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
|
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
|
||||||
@@ -5899,25 +6054,41 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
const consistById = new Map(consist.map((w) => [w.id, w]));
|
const consistById = new Map(consist.map((w) => [w.id, w]));
|
||||||
|
|
||||||
// --- validate removals: must be coupled and free (no cargo, no pin) ---
|
// --- validate removals: coupled, cargo offloaded, no foreign pin ---
|
||||||
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager);
|
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(
|
||||||
|
manager,
|
||||||
|
train.id,
|
||||||
|
);
|
||||||
|
// Every live train set of THIS built train (this run + siblings, e.g.
|
||||||
|
// the paired return leg) — their pins follow the consist edit.
|
||||||
|
const ownSetIds = (
|
||||||
|
await manager.getRepository(TrainSet).find({
|
||||||
|
where: { trainId: train.id },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((set) => set.id);
|
||||||
const removed: Wagon[] = [];
|
const removed: Wagon[] = [];
|
||||||
for (const wagonId of removeWagonIds) {
|
for (const wagonId of removeWagonIds) {
|
||||||
const wagon = consistById.get(wagonId);
|
const wagon = consistById.get(wagonId);
|
||||||
if (!wagon) {
|
if (!wagon) {
|
||||||
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
|
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
|
||||||
}
|
}
|
||||||
if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) {
|
if (loadedWagonIds.has(wagon.id)) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
|
`Wagon ${wagon.wagonNumber} carries cargo riding beyond this stop — it cannot be trimmed, only switched`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pinnedToLiveIds.has(wagon.id)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${wagon.wagonNumber} is pinned by another live schedule and cannot be trimmed`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
removed.push(wagon);
|
removed.push(wagon);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- validate additions: AVAILABLE, loose, standing in the train's yard ---
|
// Shared gate for every incoming wagon (couple or switch replacement):
|
||||||
const added: Wagon[] = [];
|
// AVAILABLE, loose, and standing where the train stands right now.
|
||||||
for (const wagonId of addWagonIds) {
|
const lockIncomingWagon = async (wagonId: string): Promise<Wagon> => {
|
||||||
// No `relations` on this query: Postgres refuses FOR UPDATE through the
|
// No `relations` on this query: Postgres refuses FOR UPDATE through the
|
||||||
// nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be
|
// nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be
|
||||||
// applied to the nullable side of an outer join"). Lock the row alone,
|
// applied to the nullable side of an outer join"). Lock the row alone,
|
||||||
@@ -5935,21 +6106,57 @@ export class TrainSchedulingService {
|
|||||||
`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`,
|
`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (wagon.currentYardId !== train.currentYardId) {
|
if (wagon.currentYardId !== currentYardId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`,
|
`Wagon ${wagon.wagonNumber} is not at the train's current stop — only wagons standing there can be coupled`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
wagon.wagonType =
|
wagon.wagonType =
|
||||||
(await manager
|
(await manager
|
||||||
.getRepository(WagonType)
|
.getRepository(WagonType)
|
||||||
.findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined;
|
.findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined;
|
||||||
added.push(wagon);
|
return wagon;
|
||||||
|
};
|
||||||
|
|
||||||
|
const added: Wagon[] = [];
|
||||||
|
for (const wagonId of addWagonIds) {
|
||||||
|
added.push(await lockIncomingWagon(wagonId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- headroom check (only additions can push the train over a cap) ---
|
// --- validate switches: outgoing coupled + not foreign-pinned; the
|
||||||
|
// replacement passes the incoming gate AND matches the wagon type, so
|
||||||
|
// the slot's cargo (weight, TEU geometry) rides it unchanged ---
|
||||||
|
const switchPairs: Array<{ from: Wagon; to: Wagon }> = [];
|
||||||
|
for (const { fromWagonId, toWagonId } of switches) {
|
||||||
|
const from = consistById.get(fromWagonId);
|
||||||
|
if (!from) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Wagon ${fromWagonId} is not coupled to train ${train.code}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pinnedToLiveIds.has(from.id)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${from.wagonNumber} is pinned by another live schedule and cannot be switched`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const to = await lockIncomingWagon(toWagonId);
|
||||||
|
if (to.wagonTypeId !== from.wagonTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Wagon ${to.wagonNumber} (${to.wagonType?.code ?? 'unknown type'}) is not the same type as ${from.wagonNumber} (${from.wagonType?.code ?? 'unknown type'}) — a switch must not change what the slot can carry`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
switchPairs.push({ from, to });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- headroom check (only additions can push the train over a cap;
|
||||||
|
// switches are same-type and cancel out, but are computed honestly) ---
|
||||||
const removedIds = new Set(removed.map((w) => w.id));
|
const removedIds = new Set(removed.map((w) => w.id));
|
||||||
const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added];
|
const switchedFromIds = new Set(switchPairs.map((p) => p.from.id));
|
||||||
|
const finalConsist = [
|
||||||
|
...consist.filter((w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id)),
|
||||||
|
...added,
|
||||||
|
...switchPairs.map((p) => p.to),
|
||||||
|
];
|
||||||
const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0);
|
const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0);
|
||||||
const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0);
|
const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0);
|
||||||
const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0));
|
const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0));
|
||||||
@@ -5967,21 +6174,72 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- apply: detach trims, couple additions, compact the sequence ---
|
// --- apply: detach trims, couple additions, swap switches, compact ---
|
||||||
|
// A wagon leaving the train stands wherever the train stands — stamping
|
||||||
|
// the stop yard is what makes it findable (and re-couplable) at B.
|
||||||
|
const detachPatch = {
|
||||||
|
trainId: null,
|
||||||
|
sequenceNumber: null,
|
||||||
|
status: WagonStatus.Available,
|
||||||
|
trainSetWagonId: null,
|
||||||
|
currentTrainScheduleId: null,
|
||||||
|
currentYardId,
|
||||||
|
};
|
||||||
for (const wagon of removed) {
|
for (const wagon of removed) {
|
||||||
await manager.getRepository(Wagon).update(wagon.id, {
|
await manager.getRepository(Wagon).update(wagon.id, detachPatch);
|
||||||
trainId: null,
|
|
||||||
sequenceNumber: null,
|
|
||||||
status: WagonStatus.Available,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const remaining = consist.filter((w) => !removedIds.has(w.id));
|
if (removed.length && ownSetIds.length) {
|
||||||
for (let i = 0; i < remaining.length; i++) {
|
// This train's own pins (all its runs) on trimmed wagons are stale —
|
||||||
if (remaining[i].sequenceNumber !== i + 1) {
|
// clear them so the freed wagon isn't still claimed by slots it left.
|
||||||
await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 });
|
await manager
|
||||||
|
.getRepository(TrainSetWagon)
|
||||||
|
.update(
|
||||||
|
{ trainSetId: In(ownSetIds), physicalWagonId: In(removed.map((w) => w.id)) },
|
||||||
|
{ physicalWagonId: null },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switches: the replacement takes the outgoing wagon's position AND its
|
||||||
|
// slot pins, so every cargo allocation now rides the new wagon. The
|
||||||
|
// outgoing wagon is left standing at the stop.
|
||||||
|
for (const { from, to } of switchPairs) {
|
||||||
|
const slots = ownSetIds.length
|
||||||
|
? await manager.getRepository(TrainSetWagon).find({
|
||||||
|
where: { trainSetId: In(ownSetIds), physicalWagonId: from.id },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
for (const slot of slots) {
|
||||||
|
await manager
|
||||||
|
.getRepository(TrainSetWagon)
|
||||||
|
.update(slot.id, { physicalWagonId: to.id });
|
||||||
|
}
|
||||||
|
const ownSlot =
|
||||||
|
slots.find((slot) => slot.trainSetId === schedule.trainSetId) ?? slots[0];
|
||||||
|
await manager.getRepository(Wagon).update(to.id, {
|
||||||
|
trainId: train.id,
|
||||||
|
sequenceNumber: from.sequenceNumber,
|
||||||
|
status: WagonStatus.Assigned,
|
||||||
|
trainSetWagonId: ownSlot?.id ?? null,
|
||||||
|
currentTrainScheduleId: from.currentTrainScheduleId ?? null,
|
||||||
|
});
|
||||||
|
// Mirror on the in-memory row — the compaction below sorts by it.
|
||||||
|
to.sequenceNumber = from.sequenceNumber;
|
||||||
|
await manager.getRepository(Wagon).update(from.id, detachPatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = consist.filter(
|
||||||
|
(w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id),
|
||||||
|
);
|
||||||
|
const switchedIn = switchPairs.map((p) => p.to);
|
||||||
|
const compacted = [...remaining, ...switchedIn].sort(
|
||||||
|
(a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0),
|
||||||
|
);
|
||||||
|
for (let i = 0; i < compacted.length; i++) {
|
||||||
|
if (compacted[i].sequenceNumber !== i + 1) {
|
||||||
|
await manager.getRepository(Wagon).update(compacted[i].id, { sequenceNumber: i + 1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let sequence = remaining.length;
|
let sequence = compacted.length;
|
||||||
for (const wagon of added) {
|
for (const wagon of added) {
|
||||||
sequence += 1;
|
sequence += 1;
|
||||||
await manager.getRepository(Wagon).update(wagon.id, {
|
await manager.getRepository(Wagon).update(wagon.id, {
|
||||||
@@ -6000,16 +6258,31 @@ export class TrainSchedulingService {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
await logRepo.save(
|
await logRepo.save(
|
||||||
[
|
[
|
||||||
...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })),
|
...removed.map((wagon) => ({
|
||||||
...added.map((wagon) => ({ action: 'ADD' as const, wagon })),
|
action: 'REMOVE' as const,
|
||||||
].map(({ action, wagon }) =>
|
wagonId: wagon.id,
|
||||||
|
wagonNumber: wagon.wagonNumber,
|
||||||
|
})),
|
||||||
|
...added.map((wagon) => ({
|
||||||
|
action: 'ADD' as const,
|
||||||
|
wagonId: wagon.id,
|
||||||
|
wagonNumber: wagon.wagonNumber,
|
||||||
|
})),
|
||||||
|
...switchPairs.map(({ from, to }) => ({
|
||||||
|
action: 'SWITCH' as const,
|
||||||
|
wagonId: to.id,
|
||||||
|
// varchar(50) — two long wagon numbers could overflow the column.
|
||||||
|
wagonNumber: `${from.wagonNumber} → ${to.wagonNumber}`.slice(0, 50),
|
||||||
|
})),
|
||||||
|
].map((entry) =>
|
||||||
logRepo.create({
|
logRepo.create({
|
||||||
trainScheduleId: scheduleId,
|
trainScheduleId: scheduleId,
|
||||||
trainId: train.id,
|
trainId: train.id,
|
||||||
action,
|
action: entry.action,
|
||||||
wagonId: wagon.id,
|
wagonId: entry.wagonId,
|
||||||
wagonNumber: wagon.wagonNumber,
|
wagonNumber: entry.wagonNumber,
|
||||||
adjustedByUserId: userId ?? null,
|
adjustedByUserId: userId ?? null,
|
||||||
|
yardId: currentYardId,
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -6049,6 +6322,72 @@ export class TrainSchedulingService {
|
|||||||
return { ...(await this.getScheduleConsist(scheduleId)), warnings };
|
return { ...(await this.getScheduleConsist(scheduleId)), warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified change history for the schedule detail "History" tab: wagon
|
||||||
|
* consist adjustments (ADD / REMOVE / SWITCH, with the stop they happened
|
||||||
|
* at) merged with booking composition removals, newest first. Actor resolves
|
||||||
|
* through iam.users; rows survive wagon/train deletion (log tables carry
|
||||||
|
* plain columns, no FKs).
|
||||||
|
*/
|
||||||
|
async getScheduleHistory(scheduleId: string) {
|
||||||
|
type HistoryRow = {
|
||||||
|
id: string;
|
||||||
|
kind: 'WAGON' | 'BOOKING';
|
||||||
|
action: string;
|
||||||
|
subject: string | null;
|
||||||
|
yardLabel: string | null;
|
||||||
|
actor: string | null;
|
||||||
|
note: string | null;
|
||||||
|
occurredAt: Date;
|
||||||
|
};
|
||||||
|
const wagonRows: HistoryRow[] = (
|
||||||
|
await 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",
|
||||||
|
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
|
||||||
|
WHERE l.train_schedule_id = $1
|
||||||
|
AND l.deleted_at IS NULL
|
||||||
|
ORDER BY l.occurred_at DESC
|
||||||
|
LIMIT 200`,
|
||||||
|
[scheduleId],
|
||||||
|
)
|
||||||
|
).map((r: Omit<HistoryRow, 'kind' | 'note'>) => ({
|
||||||
|
...r,
|
||||||
|
kind: 'WAGON' as const,
|
||||||
|
note: null,
|
||||||
|
}));
|
||||||
|
const bookingRows: HistoryRow[] = (
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT r.id,
|
||||||
|
r.booking_reference AS "subject",
|
||||||
|
r.notes AS "note",
|
||||||
|
COALESCE(u.username, u.email) AS "actor",
|
||||||
|
r.removed_at AS "occurredAt"
|
||||||
|
FROM freight.train_composition_removal_logs r
|
||||||
|
LEFT JOIN iam.users u ON u.id = r.removed_by_user_id
|
||||||
|
WHERE r.schedule_id = $1
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
ORDER BY r.removed_at DESC
|
||||||
|
LIMIT 200`,
|
||||||
|
[scheduleId],
|
||||||
|
)
|
||||||
|
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'yardLabel'>) => ({
|
||||||
|
...r,
|
||||||
|
kind: 'BOOKING' as const,
|
||||||
|
action: 'BOOKING_REMOVED',
|
||||||
|
yardLabel: null,
|
||||||
|
}));
|
||||||
|
return [...wagonRows, ...bookingRows].sort(
|
||||||
|
(a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-derive a built train's lifecycle status from its schedules after one of
|
* Re-derive a built train's lifecycle status from its schedules after one of
|
||||||
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
|
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
|
||||||
@@ -6938,11 +7277,21 @@ export class TrainSchedulingService {
|
|||||||
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Booking has no ORM relation to Contract (FK only) — fetched separately
|
||||||
|
// by id so the "on this train" cards can show the contract reference.
|
||||||
|
const contractIds = [
|
||||||
|
...new Set(
|
||||||
|
(schedule.scheduleBookings ?? [])
|
||||||
|
.map((sb) => sb.booking?.contractId)
|
||||||
|
.filter((id): id is string => Boolean(id)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
// All independent lookups fired at once — they used to run one after
|
// All independent lookups fired at once — they used to run one after
|
||||||
// another, stacking round-trips onto every detail request.
|
// another, stacking round-trips onto every detail request.
|
||||||
// tareDims: booking weights are reported GROSS (cargo + wagon tare) — the
|
// tareDims: booking weights are reported GROSS (cargo + wagon tare) — the
|
||||||
// number the locomotive actually hauls against its pull limit.
|
// number the locomotive actually hauls against its pull limit.
|
||||||
const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] =
|
const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons, contracts] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.loadWagonTareDims(),
|
this.loadWagonTareDims(),
|
||||||
requiresLoadingConfirmation
|
requiresLoadingConfirmation
|
||||||
@@ -6972,7 +7321,13 @@ export class TrainSchedulingService {
|
|||||||
order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' },
|
order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' },
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
|
contractIds.length
|
||||||
|
? this.dataSource
|
||||||
|
.getRepository(Contract)
|
||||||
|
.find({ where: { id: In(contractIds) }, select: { id: true, reference: true } })
|
||||||
|
: [],
|
||||||
]);
|
]);
|
||||||
|
const contractReferenceById = new Map(contracts.map((c) => [c.id, c.reference]));
|
||||||
const loadingConfirmed = requiresLoadingConfirmation
|
const loadingConfirmed = requiresLoadingConfirmation
|
||||||
? Boolean(importOp?.loadedOnTrainAt)
|
? Boolean(importOp?.loadedOnTrainAt)
|
||||||
: true;
|
: true;
|
||||||
@@ -7315,6 +7670,10 @@ export class TrainSchedulingService {
|
|||||||
sb.booking?.destinationYard?.code ??
|
sb.booking?.destinationYard?.code ??
|
||||||
null,
|
null,
|
||||||
wagonsRequired: sb.booking ? this.effectiveWagonsRequired(sb.booking) : null,
|
wagonsRequired: sb.booking ? this.effectiveWagonsRequired(sb.booking) : null,
|
||||||
|
contractReference:
|
||||||
|
(sb.booking?.contractId
|
||||||
|
? contractReferenceById.get(sb.booking.contractId)
|
||||||
|
: null) ?? null,
|
||||||
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
|
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
|
||||||
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
|
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
|
||||||
// Loaded/unloaded is tracked on the schedule↔booking link, not the
|
// Loaded/unloaded is tracked on the schedule↔booking link, not the
|
||||||
|
|||||||
@@ -9,16 +9,18 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Progress,
|
Progress,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
import { AlertTriangle, History, Minus, Plus } from "lucide-react";
|
import { AlertTriangle, ArrowLeftRight, History, MapPin, Minus, Plus } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { ConsistWagonRef } from "@/services/trainBuilder.service";
|
import type { ConsistWagonRef, ScheduleConsist } from "@/services/trainBuilder.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const parseError = (error: unknown, fallback: string) => {
|
const parseError = (error: unknown, fallback: string) => {
|
||||||
@@ -34,11 +36,16 @@ const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0;
|
|||||||
const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0;
|
const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0;
|
||||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||||
|
|
||||||
|
type ConsistWagon = ScheduleConsist["wagons"][number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adjust the built train's consist from a schedule: trim free wagons (their
|
* Adjust the built train's consist from a schedule: trim free wagons (their
|
||||||
* tare no longer rides — the fix when gross weight beats the pull limit) or
|
* tare no longer rides — the fix when gross weight beats the pull limit),
|
||||||
* couple extra yard wagons while weight/length headroom remains. Changes are
|
* couple extra yard wagons while weight/length headroom remains, or SWITCH a
|
||||||
* permanent on the train and logged on the schedule.
|
* wagon for a same-type replacement — the replacement inherits the slot, cargo
|
||||||
|
* included, which is the only way a loaded wagon leaves the train. Works
|
||||||
|
* before departure and mid-route while the train stands at a checkpointed
|
||||||
|
* stop. Changes are permanent on the train and logged on the schedule.
|
||||||
*/
|
*/
|
||||||
export default function AdjustConsistModal({
|
export default function AdjustConsistModal({
|
||||||
scheduleId,
|
scheduleId,
|
||||||
@@ -48,6 +55,9 @@ export default function AdjustConsistModal({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||||
const [addIds, setAddIds] = useState<string[]>([]);
|
const [addIds, setAddIds] = useState<string[]>([]);
|
||||||
|
// fromWagonId → toWagonId. A switch is same-type, so it never moves the
|
||||||
|
// weight/length/slot projections — it only changes which steel rides.
|
||||||
|
const [switchMap, setSwitchMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const consistQuery = useQuery(
|
const consistQuery = useQuery(
|
||||||
api.trainScheduling.scheduleConsist.queryOptions({
|
api.trainScheduling.scheduleConsist.queryOptions({
|
||||||
@@ -62,13 +72,20 @@ export default function AdjustConsistModal({
|
|||||||
if (opened) {
|
if (opened) {
|
||||||
setRemoveIds([]);
|
setRemoveIds([]);
|
||||||
setAddIds([]);
|
setAddIds([]);
|
||||||
|
setSwitchMap({});
|
||||||
}
|
}
|
||||||
}, [opened]);
|
}, [opened]);
|
||||||
|
|
||||||
|
const switchCount = Object.keys(switchMap).length;
|
||||||
|
const usedReplacementIds = useMemo(
|
||||||
|
() => new Set(Object.values(switchMap)),
|
||||||
|
[switchMap],
|
||||||
|
);
|
||||||
|
|
||||||
// Live projection: gross = cargo + tare of (consist − trims + adds), plus
|
// Live projection: gross = cargo + tare of (consist − trims + adds), plus
|
||||||
// the schedule's wagon-slot picture — the consist IS the booking capacity
|
// the schedule's wagon-slot picture — the consist IS the booking capacity
|
||||||
// (weight/length only bind while assembling the consist), so trims/adds
|
// (weight/length only bind while assembling the consist), so trims/adds
|
||||||
// move the FULL line in real time.
|
// move the FULL line in real time. Switches are same-type and cancel out.
|
||||||
const projection = useMemo(() => {
|
const projection = useMemo(() => {
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
const removed = new Set(removeIds);
|
const removed = new Set(removeIds);
|
||||||
@@ -117,25 +134,58 @@ export default function AdjustConsistModal({
|
|||||||
};
|
};
|
||||||
}, [data, removeIds, addIds]);
|
}, [data, removeIds, addIds]);
|
||||||
|
|
||||||
const hasChanges = removeIds.length > 0 || addIds.length > 0;
|
const hasChanges = removeIds.length > 0 || addIds.length > 0 || switchCount > 0;
|
||||||
|
|
||||||
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
|
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
|
||||||
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
|
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
|
||||||
|
|
||||||
|
// Same-type replacements standing at the current stop, minus wagons already
|
||||||
|
// spoken for by another switch or a couple selection.
|
||||||
|
const switchOptionsFor = (wagon: ConsistWagon) =>
|
||||||
|
(data?.addableWagons ?? [])
|
||||||
|
.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.wagonType?.id === wagon.wagonType?.id &&
|
||||||
|
!addIds.includes(candidate.id) &&
|
||||||
|
(!usedReplacementIds.has(candidate.id) ||
|
||||||
|
switchMap[wagon.id] === candidate.id),
|
||||||
|
)
|
||||||
|
.map((candidate) => ({ value: candidate.id, label: candidate.wagonNumber }));
|
||||||
|
|
||||||
|
const setSwitch = (fromId: string, toId: string | null) =>
|
||||||
|
setSwitchMap((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (toId) next[fromId] = toId;
|
||||||
|
else delete next[fromId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!removeIds.length && !addIds.length) return;
|
if (!hasChanges) return;
|
||||||
try {
|
try {
|
||||||
const result = await adjust.mutateAsync({
|
const result = await adjust.mutateAsync({
|
||||||
scheduleId,
|
scheduleId,
|
||||||
payload: {
|
payload: {
|
||||||
...(addIds.length ? { addWagonIds: addIds } : {}),
|
...(addIds.length ? { addWagonIds: addIds } : {}),
|
||||||
...(removeIds.length ? { removeWagonIds: removeIds } : {}),
|
...(removeIds.length ? { removeWagonIds: removeIds } : {}),
|
||||||
|
...(switchCount
|
||||||
|
? {
|
||||||
|
switches: Object.entries(switchMap).map(([fromWagonId, toWagonId]) => ({
|
||||||
|
fromWagonId,
|
||||||
|
toWagonId,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${
|
title: `Consist updated — ${[
|
||||||
removeIds.length && addIds.length ? ", " : ""
|
removeIds.length ? `${removeIds.length} trimmed` : "",
|
||||||
}${addIds.length ? `${addIds.length} added` : ""}`,
|
addIds.length ? `${addIds.length} added` : "",
|
||||||
|
switchCount ? `${switchCount} switched` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}`,
|
||||||
});
|
});
|
||||||
// Schedule-impact warnings from the API: window reopened / now FULL /
|
// Schedule-impact warnings from the API: window reopened / now FULL /
|
||||||
// consist trimmed below what bookings already hold.
|
// consist trimmed below what bookings already hold.
|
||||||
@@ -151,6 +201,7 @@ export default function AdjustConsistModal({
|
|||||||
}
|
}
|
||||||
setRemoveIds([]);
|
setRemoveIds([]);
|
||||||
setAddIds([]);
|
setAddIds([]);
|
||||||
|
setSwitchMap({});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: "Adjustment failed",
|
title: "Adjustment failed",
|
||||||
@@ -170,7 +221,7 @@ export default function AdjustConsistModal({
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
size={860}
|
size={920}
|
||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
{consistQuery.isLoading || !data ? (
|
{consistQuery.isLoading || !data ? (
|
||||||
@@ -181,9 +232,19 @@ export default function AdjustConsistModal({
|
|||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{data.currentStop?.isMidRoute ? (
|
||||||
|
<Alert color="blue" icon={<MapPin size={16} />}>
|
||||||
|
Standing at <strong>{data.currentStop.label}</strong> — mid-route
|
||||||
|
consist work is open: couple or switch wagons standing at this
|
||||||
|
stop, trim wagons whose cargo was offloaded here. Detached wagons
|
||||||
|
stay at {data.currentStop.label}.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
{!data.editable ? (
|
{!data.editable ? (
|
||||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||||
The consist is frozen once the train is dispatched.
|
{data.schedule.status === "DISPATCHED"
|
||||||
|
? "The train is rolling — consist changes are only possible while it stands at a route stop."
|
||||||
|
: "The consist can no longer be adjusted — the run is over."}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -256,37 +317,39 @@ export default function AdjustConsistModal({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Grid gap="md">
|
<Grid gap="md">
|
||||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<Minus size={14} />
|
<Minus size={14} />
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
Trim coupled wagons ({data.totals.wagonCount})
|
Coupled wagons ({data.totals.wagonCount})
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Only free (unloaded, unpinned) wagons can be detached. Detaching is
|
Trim only wagons carrying nothing beyond this stop. A loaded
|
||||||
permanent — the wagon returns to the yard as available.
|
wagon can't leave — but it can be <strong>switched</strong>:
|
||||||
|
the same-type replacement takes its position and its cargo
|
||||||
|
slot. Detaching is permanent.
|
||||||
</Text>
|
</Text>
|
||||||
<ScrollArea.Autosize mah={260} type="auto">
|
<ScrollArea.Autosize mah={280} type="auto">
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
{data.wagons.map((wagon) => (
|
{data.wagons.map((wagon) => (
|
||||||
<WagonRow
|
<CoupledWagonRow
|
||||||
key={wagon.id}
|
key={wagon.id}
|
||||||
wagon={wagon}
|
wagon={wagon}
|
||||||
checked={removeIds.includes(wagon.id)}
|
checked={removeIds.includes(wagon.id)}
|
||||||
disabled={!data.editable || !wagon.removable}
|
editable={data.editable}
|
||||||
badge={
|
switchValue={switchMap[wagon.id] ?? null}
|
||||||
wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null
|
switchOptions={switchOptionsFor(wagon)}
|
||||||
}
|
onToggleRemove={toggle(setRemoveIds)}
|
||||||
onToggle={toggle(setRemoveIds)}
|
onSwitch={setSwitch}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</ScrollArea.Autosize>
|
</ScrollArea.Autosize>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
@@ -295,25 +358,30 @@ export default function AdjustConsistModal({
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
AVAILABLE wagons standing in the train's yard. Blocked when they push
|
AVAILABLE wagons standing at{" "}
|
||||||
gross weight or length past the locomotive limits incl. tolerance.
|
{data.currentStop?.label ?? "the train's yard"}. Blocked when
|
||||||
|
they push gross weight or length past the locomotive limits
|
||||||
|
incl. tolerance.
|
||||||
</Text>
|
</Text>
|
||||||
<ScrollArea.Autosize mah={260} type="auto">
|
<ScrollArea.Autosize mah={280} type="auto">
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
{data.addableWagons.length ? (
|
{data.addableWagons.length ? (
|
||||||
data.addableWagons.map((wagon) => (
|
data.addableWagons.map((wagon) => {
|
||||||
<WagonRow
|
const takenBySwitch = usedReplacementIds.has(wagon.id);
|
||||||
key={wagon.id}
|
return (
|
||||||
wagon={wagon}
|
<AddableWagonRow
|
||||||
checked={addIds.includes(wagon.id)}
|
key={wagon.id}
|
||||||
disabled={!data.editable}
|
wagon={wagon}
|
||||||
badge={null}
|
checked={addIds.includes(wagon.id)}
|
||||||
onToggle={toggle(setAddIds)}
|
disabled={!data.editable || takenBySwitch}
|
||||||
/>
|
badge={takenBySwitch ? "Switch target" : null}
|
||||||
))
|
onToggle={toggle(setAddIds)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" c="dimmed" py="sm" ta="center">
|
<Text size="sm" c="dimmed" py="sm" ta="center">
|
||||||
No available wagons in this yard
|
No available wagons at this stop
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -322,6 +390,19 @@ export default function AdjustConsistModal({
|
|||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{switchCount ? (
|
||||||
|
<Alert color="blue" icon={<ArrowLeftRight size={16} />} py={8}>
|
||||||
|
{Object.entries(switchMap)
|
||||||
|
.map(([fromId, toId]) => {
|
||||||
|
const from = data.wagons.find((w) => w.id === fromId);
|
||||||
|
const to = data.addableWagons.find((w) => w.id === toId);
|
||||||
|
return `${from?.wagonNumber ?? "?"} → ${to?.wagonNumber ?? "?"}`;
|
||||||
|
})
|
||||||
|
.join(" · ")}{" "}
|
||||||
|
— cargo allocations move to the replacement wagon(s).
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{data.adjustments.length ? (
|
{data.adjustments.length ? (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -339,9 +420,19 @@ export default function AdjustConsistModal({
|
|||||||
<Badge
|
<Badge
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color={log.action === "ADD" ? "edr-green" : "red"}
|
color={
|
||||||
|
log.action === "ADD"
|
||||||
|
? "edr-green"
|
||||||
|
: log.action === "SWITCH"
|
||||||
|
? "blue"
|
||||||
|
: "red"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{log.action === "ADD" ? "Added" : "Trimmed"}
|
{log.action === "ADD"
|
||||||
|
? "Added"
|
||||||
|
: log.action === "SWITCH"
|
||||||
|
? "Switched"
|
||||||
|
: "Trimmed"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Text size="xs" ff="monospace">
|
<Text size="xs" ff="monospace">
|
||||||
{log.wagonNumber}
|
{log.wagonNumber}
|
||||||
@@ -369,15 +460,19 @@ export default function AdjustConsistModal({
|
|||||||
loading={adjust.isPending}
|
loading={adjust.isPending}
|
||||||
disabled={
|
disabled={
|
||||||
!data.editable ||
|
!data.editable ||
|
||||||
(!removeIds.length && !addIds.length) ||
|
!hasChanges ||
|
||||||
(addIds.length > 0 && (projection?.overWeight || projection?.overLength))
|
(addIds.length > 0 && (projection?.overWeight || projection?.overLength))
|
||||||
}
|
}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
Apply{" "}
|
Apply{" "}
|
||||||
{removeIds.length ? `−${removeIds.length}` : ""}
|
{[
|
||||||
{removeIds.length && addIds.length ? " / " : ""}
|
removeIds.length ? `−${removeIds.length}` : "",
|
||||||
{addIds.length ? `+${addIds.length}` : ""}
|
addIds.length ? `+${addIds.length}` : "",
|
||||||
|
switchCount ? `⇄${switchCount}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -429,7 +524,89 @@ function LimitGauge({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WagonRow({
|
/** Coupled row: trim checkbox (reason-badged when blocked) + switch picker. */
|
||||||
|
function CoupledWagonRow({
|
||||||
|
wagon,
|
||||||
|
checked,
|
||||||
|
editable,
|
||||||
|
switchValue,
|
||||||
|
switchOptions,
|
||||||
|
onToggleRemove,
|
||||||
|
onSwitch,
|
||||||
|
}: {
|
||||||
|
wagon: ConsistWagon;
|
||||||
|
checked: boolean;
|
||||||
|
editable: boolean;
|
||||||
|
switchValue: string | null;
|
||||||
|
switchOptions: Array<{ value: string; label: string }>;
|
||||||
|
onToggleRemove: (id: string, checked: boolean) => void;
|
||||||
|
onSwitch: (fromId: string, toId: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const badge = wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null;
|
||||||
|
const checkbox = (
|
||||||
|
<Checkbox
|
||||||
|
size="sm"
|
||||||
|
checked={checked}
|
||||||
|
disabled={!editable || !wagon.removable || Boolean(switchValue)}
|
||||||
|
onChange={(e) => onToggleRemove(wagon.id, e.currentTarget.checked)}
|
||||||
|
aria-label={`Trim wagon ${wagon.wagonNumber}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap="sm"
|
||||||
|
wrap="nowrap"
|
||||||
|
p={6}
|
||||||
|
style={{
|
||||||
|
border: switchValue
|
||||||
|
? "1px solid var(--mantine-color-blue-4)"
|
||||||
|
: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
background: switchValue ? "var(--mantine-color-blue-0)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wagon.blockReason ? (
|
||||||
|
<Tooltip label={wagon.blockReason} withArrow>
|
||||||
|
<span>{checkbox}</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
checkbox
|
||||||
|
)}
|
||||||
|
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||||
|
{wagon.wagonNumber}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{wagon.wagonType
|
||||||
|
? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m`
|
||||||
|
: "Unknown type"}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{badge ? (
|
||||||
|
<Badge size="xs" variant="light" color={badge === "Loaded" ? "orange" : "gray"}>
|
||||||
|
{badge}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{editable && wagon.switchable && switchOptions.length ? (
|
||||||
|
<Select
|
||||||
|
size="xs"
|
||||||
|
w={148}
|
||||||
|
placeholder="Switch with…"
|
||||||
|
leftSection={<ArrowLeftRight size={12} />}
|
||||||
|
data={switchOptions}
|
||||||
|
value={switchValue}
|
||||||
|
onChange={(toId) => onSwitch(wagon.id, toId)}
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
disabled={checked}
|
||||||
|
aria-label={`Switch wagon ${wagon.wagonNumber}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddableWagonRow({
|
||||||
wagon,
|
wagon,
|
||||||
checked,
|
checked,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -471,7 +648,7 @@ function WagonRow({
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
{badge ? (
|
{badge ? (
|
||||||
<Badge size="xs" variant="light" color={badge === "Loaded" ? "orange" : "gray"}>
|
<Badge size="xs" variant="light" color="blue">
|
||||||
{badge}
|
{badge}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -574,6 +574,11 @@ export function AllocateBookingWizard({
|
|||||||
id: b.id,
|
id: b.id,
|
||||||
reference: b.reference ?? b.id.slice(0, 8),
|
reference: b.reference ?? b.id.slice(0, 8),
|
||||||
weightTons: b.weightTons,
|
weightTons: b.weightTons,
|
||||||
|
isGovernment: b.isGovernment,
|
||||||
|
wagonsRequired: b.wagonsRequired,
|
||||||
|
contractReference: b.contractReference,
|
||||||
|
origin: b.origin,
|
||||||
|
destination: b.destination,
|
||||||
}))}
|
}))}
|
||||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||||
eligibleLoading={eligibleQuery.isLoading}
|
eligibleLoading={eligibleQuery.isLoading}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useMemo } from "react";
|
import { Fragment, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
|
Collapse,
|
||||||
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
Progress,
|
Progress,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -10,7 +12,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Info } from "lucide-react";
|
import { ChevronDown, ChevronRight, Info, Train, Weight } from "lucide-react";
|
||||||
|
|
||||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||||
|
|
||||||
@@ -27,6 +29,13 @@ interface Stop {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LegBookingUsage {
|
||||||
|
bookingId: string;
|
||||||
|
reference: string;
|
||||||
|
wagons: number;
|
||||||
|
grossTons: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface EdgeUsage {
|
interface EdgeUsage {
|
||||||
edge: number;
|
edge: number;
|
||||||
from: Stop;
|
from: Stop;
|
||||||
@@ -35,6 +44,7 @@ interface EdgeUsage {
|
|||||||
grossTons: number;
|
grossTons: number;
|
||||||
lengthMeters: number;
|
lengthMeters: number;
|
||||||
bookingRefs: string[];
|
bookingRefs: string[];
|
||||||
|
bookings: LegBookingUsage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||||
@@ -90,6 +100,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
|||||||
const weightCap = schedule.maxGrossWeightTons ?? null;
|
const weightCap = schedule.maxGrossWeightTons ?? null;
|
||||||
const lengthCap = schedule.maxLengthMeters ?? null;
|
const lengthCap = schedule.maxLengthMeters ?? null;
|
||||||
const wagonCap = schedule.maxWagons ?? null;
|
const wagonCap = schedule.maxWagons ?? null;
|
||||||
|
const [expandedEdge, setExpandedEdge] = useState<number | null>(null);
|
||||||
|
|
||||||
const edges: EdgeUsage[] = useMemo(() => {
|
const edges: EdgeUsage[] = useMemo(() => {
|
||||||
if (stops.length < 2) return [];
|
if (stops.length < 2) return [];
|
||||||
@@ -105,13 +116,36 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
|||||||
const refs = new Set<string>();
|
const refs = new Set<string>();
|
||||||
let grossTons = 0;
|
let grossTons = 0;
|
||||||
let lengthMeters = 0;
|
let lengthMeters = 0;
|
||||||
|
// Per booking on this leg: wagon count (distinct wagons carrying at
|
||||||
|
// least one of its allocations — a shared wagon counts for each
|
||||||
|
// booking riding it, so per-booking wagon counts can sum to more than
|
||||||
|
// the leg's total) and its allocated weight share.
|
||||||
|
const byBooking = new Map<string, LegBookingUsage>();
|
||||||
for (const w of active) {
|
for (const w of active) {
|
||||||
grossTons += (Number(w.tareWeightTons) || 0) + (Number(w.assignedWeightTons) || 0);
|
grossTons += (Number(w.tareWeightTons) || 0) + (Number(w.assignedWeightTons) || 0);
|
||||||
lengthMeters += Number(w.lengthMeters) || 0;
|
lengthMeters += Number(w.lengthMeters) || 0;
|
||||||
|
const bookingIdsOnWagon = new Set<string>();
|
||||||
for (const a of w.allocations ?? []) {
|
for (const a of w.allocations ?? []) {
|
||||||
if (a.bookingReference) refs.add(a.bookingReference);
|
if (!a.bookingReference) continue;
|
||||||
|
refs.add(a.bookingReference);
|
||||||
|
const row = byBooking.get(a.bookingId) ?? {
|
||||||
|
bookingId: a.bookingId,
|
||||||
|
reference: a.bookingReference,
|
||||||
|
wagons: 0,
|
||||||
|
grossTons: 0,
|
||||||
|
};
|
||||||
|
row.grossTons += Number(a.allocatedWeightTons) || 0;
|
||||||
|
byBooking.set(a.bookingId, row);
|
||||||
|
bookingIdsOnWagon.add(a.bookingId);
|
||||||
|
}
|
||||||
|
for (const bookingId of bookingIdsOnWagon) {
|
||||||
|
const row = byBooking.get(bookingId);
|
||||||
|
if (row) row.wagons += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const bookings = [...byBooking.values()]
|
||||||
|
.map((b) => ({ ...b, grossTons: round1(b.grossTons) }))
|
||||||
|
.sort((a, b) => b.grossTons - a.grossTons);
|
||||||
return {
|
return {
|
||||||
edge,
|
edge,
|
||||||
from,
|
from,
|
||||||
@@ -120,6 +154,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
|||||||
grossTons: round1(grossTons),
|
grossTons: round1(grossTons),
|
||||||
lengthMeters: round1(lengthMeters),
|
lengthMeters: round1(lengthMeters),
|
||||||
bookingRefs: [...refs],
|
bookingRefs: [...refs],
|
||||||
|
bookings,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [stops, wagons]);
|
}, [stops, wagons]);
|
||||||
@@ -190,6 +225,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
|||||||
<Table verticalSpacing="sm" highlightOnHover>
|
<Table verticalSpacing="sm" highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
|
<Table.Th style={{ width: 28 }} />
|
||||||
<Table.Th>Leg</Table.Th>
|
<Table.Th>Leg</Table.Th>
|
||||||
<Table.Th>Wagons</Table.Th>
|
<Table.Th>Wagons</Table.Th>
|
||||||
<Table.Th>Gross weight</Table.Th>
|
<Table.Th>Gross weight</Table.Th>
|
||||||
@@ -199,43 +235,107 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{edges.map((e) => (
|
{edges.map((e) => {
|
||||||
<Table.Tr key={e.edge}>
|
const isOpen = expandedEdge === e.edge;
|
||||||
<Table.Td>
|
const hasBookings = e.bookings.length > 0;
|
||||||
<Text size="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
|
return (
|
||||||
{e.from.label} → {e.to.label}
|
<Fragment key={e.edge}>
|
||||||
</Text>
|
<Table.Tr
|
||||||
</Table.Td>
|
style={{ cursor: hasBookings ? "pointer" : undefined }}
|
||||||
<Table.Td>
|
onClick={
|
||||||
<UsageCell used={e.wagons} cap={wagonCap} unit="wagons" />
|
hasBookings
|
||||||
</Table.Td>
|
? () => setExpandedEdge(isOpen ? null : e.edge)
|
||||||
<Table.Td>
|
: undefined
|
||||||
<UsageCell used={e.grossTons} cap={weightCap} unit="T" />
|
}
|
||||||
</Table.Td>
|
>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<UsageCell used={e.lengthMeters} cap={lengthCap} unit="m" />
|
{hasBookings ? (
|
||||||
</Table.Td>
|
isOpen ? (
|
||||||
<Table.Td>
|
<ChevronDown size={14} />
|
||||||
{e.bookingRefs.length ? (
|
) : (
|
||||||
<Tooltip
|
<ChevronRight size={14} />
|
||||||
label={e.bookingRefs.join(", ")}
|
)
|
||||||
multiline
|
) : null}
|
||||||
maw={320}
|
</Table.Td>
|
||||||
withArrow
|
<Table.Td>
|
||||||
>
|
<Text size="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||||
<Text size="sm" style={{ cursor: "help" }}>
|
{e.from.label} → {e.to.label}
|
||||||
{e.bookingRefs.length}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Tooltip>
|
</Table.Td>
|
||||||
) : (
|
<Table.Td>
|
||||||
<Text size="sm" c="dimmed">
|
<UsageCell used={e.wagons} cap={wagonCap} unit="wagons" />
|
||||||
0
|
</Table.Td>
|
||||||
</Text>
|
<Table.Td>
|
||||||
)}
|
<UsageCell used={e.grossTons} cap={weightCap} unit="T" />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{legStatus(e)}</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<UsageCell used={e.lengthMeters} cap={lengthCap} unit="m" />
|
||||||
))}
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{hasBookings ? (
|
||||||
|
<Badge variant="light" color="gray" size="sm">
|
||||||
|
{e.bookings.length}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
0
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{legStatus(e)}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
{hasBookings ? (
|
||||||
|
<Table.Tr key={`${e.edge}-detail`}>
|
||||||
|
<Table.Td colSpan={7} p={0} style={{ border: 0 }}>
|
||||||
|
<Collapse expanded={isOpen}>
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="xs" fw={600} c="dimmed" mb={6} tt="uppercase">
|
||||||
|
Bookings riding {e.from.label} → {e.to.label}
|
||||||
|
</Text>
|
||||||
|
<Table verticalSpacing={4} withRowBorders={false}>
|
||||||
|
<Table.Tbody>
|
||||||
|
{e.bookings.map((b) => (
|
||||||
|
<Table.Tr key={b.bookingId}>
|
||||||
|
<Table.Td w="40%">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{b.reference}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td w="30%">
|
||||||
|
<Group gap={4}>
|
||||||
|
<Train size={12} />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{b.wagons} wagon{b.wagons === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td w="30%">
|
||||||
|
<Group gap={4}>
|
||||||
|
<Weight size={12} />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{b.grossTons}T
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ArrowRight, Landmark, Package, Train } from "lucide-react";
|
import { ArrowRight, FileText, Landmark, MapPin, Package, Train } from "lucide-react";
|
||||||
|
|
||||||
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
|
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
|
||||||
|
|
||||||
@@ -18,6 +18,10 @@ export type AssignedBookingRow = {
|
|||||||
reference: string;
|
reference: string;
|
||||||
weightTons?: number;
|
weightTons?: number;
|
||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
|
wagonsRequired?: number | null;
|
||||||
|
contractReference?: string | null;
|
||||||
|
origin?: string | null;
|
||||||
|
destination?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ScheduleBookingsStep({
|
export function ScheduleBookingsStep({
|
||||||
@@ -94,6 +98,16 @@ export function ScheduleBookingsStep({
|
|||||||
{booking.weightTons}T
|
{booking.weightTons}T
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{booking.wagonsRequired != null ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<Train size={10} />}
|
||||||
|
>
|
||||||
|
{booking.wagonsRequired} wagon{booking.wagonsRequired === 1 ? "" : "s"}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
{booking.isGovernment ? (
|
{booking.isGovernment ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -105,6 +119,26 @@ export function ScheduleBookingsStep({
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
|
{booking.contractReference || booking.origin || booking.destination ? (
|
||||||
|
<Group gap="xs">
|
||||||
|
{booking.contractReference ? (
|
||||||
|
<Group gap={4}>
|
||||||
|
<FileText size={12} />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{booking.contractReference}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
{booking.origin || booking.destination ? (
|
||||||
|
<Group gap={4}>
|
||||||
|
<MapPin size={12} />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{booking.origin ?? "?"} → {booking.destination ?? "?"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Assigned to this consist
|
Assigned to this consist
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Timeline,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ArrowLeftRight,
|
||||||
|
History,
|
||||||
|
MapPin,
|
||||||
|
Minus,
|
||||||
|
PackageMinus,
|
||||||
|
Plus,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { ScheduleHistoryEntry } from "@/services/trainBuilder.service";
|
||||||
|
|
||||||
|
const ACTION_META: Record<
|
||||||
|
ScheduleHistoryEntry["action"],
|
||||||
|
{ label: string; color: string; icon: typeof Plus }
|
||||||
|
> = {
|
||||||
|
ADD: { label: "Wagon coupled", color: "edr-green", icon: Plus },
|
||||||
|
REMOVE: { label: "Wagon trimmed", color: "red", icon: Minus },
|
||||||
|
SWITCH: { label: "Wagon switched", color: "blue", icon: ArrowLeftRight },
|
||||||
|
BOOKING_REMOVED: { label: "Booking removed", color: "orange", icon: PackageMinus },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "History" tab: every change made to the train after it was scheduled —
|
||||||
|
* wagons coupled/trimmed/switched (with the stop where it happened) and
|
||||||
|
* bookings removed from the composition — newest first.
|
||||||
|
*/
|
||||||
|
export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: string }) {
|
||||||
|
const historyQuery = useQuery(
|
||||||
|
api.trainScheduling.scheduleHistory.queryOptions({
|
||||||
|
input: { scheduleId },
|
||||||
|
enabled: Boolean(scheduleId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const entries = historyQuery.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper radius="xl" p="lg">
|
||||||
|
<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">
|
||||||
|
Change history
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Wagons coupled, trimmed or switched — and bookings removed — after
|
||||||
|
this train was scheduled, 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 changes recorded yet — the consist and composition are as
|
||||||
|
scheduled.
|
||||||
|
</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.kind}-${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}
|
||||||
|
</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>
|
||||||
|
{entry.note ? (
|
||||||
|
<Text size="xs" c="dimmed" mt={2} fs="italic">
|
||||||
|
{entry.note}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Timeline.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Timeline>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -87,22 +87,46 @@ function phaseCountdown(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GROSS weight already on this train (each booking's cargo + wagon tare) —
|
/**
|
||||||
* compared against the locomotive pull limit, which is a gross ceiling. */
|
* GROSS weight the locomotives actually haul: the HEAVIEST LEG, never the
|
||||||
|
* whole-route sum — disjoint legs (Mojo→Dire + Dire→Doraleh) are pulled one
|
||||||
|
* at a time, so summing every booking over-reports a multi-stop train.
|
||||||
|
* Prefers the API's consist-derived heaviestLeg; before allocation it falls
|
||||||
|
* back to a per-leg max over the bookings (same span math as the header strip).
|
||||||
|
*/
|
||||||
function usedWeight(schedule: TrainScheduleDetail): number {
|
function usedWeight(schedule: TrainScheduleDetail): number {
|
||||||
return (schedule.bookings ?? []).reduce(
|
const consist = schedule.trainSet?.heaviestLeg?.grossWeightTons;
|
||||||
(sum, b) => sum + (Number(b.weightTons) || 0),
|
if (consist != null) return Number(consist) || 0;
|
||||||
0,
|
|
||||||
);
|
const bookings = schedule.bookings ?? [];
|
||||||
|
const stops = schedule.stops ?? [];
|
||||||
|
if (stops.length <= 2) {
|
||||||
|
return bookings.reduce((sum, b) => sum + (Number(b.weightTons) || 0), 0);
|
||||||
|
}
|
||||||
|
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
|
||||||
|
const lastIdx = stops.length - 1;
|
||||||
|
let heaviest = 0;
|
||||||
|
for (let edge = 0; edge < lastIdx; edge += 1) {
|
||||||
|
let legTons = 0;
|
||||||
|
for (const b of bookings) {
|
||||||
|
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
||||||
|
const toRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx;
|
||||||
|
const to = toRaw != null && toRaw > from ? toRaw : lastIdx;
|
||||||
|
if (from <= edge && edge < to) legTons += Number(b.weightTons) || 0;
|
||||||
|
}
|
||||||
|
heaviest = Math.max(heaviest, legTons);
|
||||||
|
}
|
||||||
|
return heaviest;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when
|
* Pull capacity of the set. Locomotive pull weights ADD UP (they haul
|
||||||
* unknown). The API caps at the weakest loco, not the sum of all locos — a
|
* together), so prefer the API's maxGrossWeightTons — the combined set limit
|
||||||
* consist can only pull as hard as its weakest engine. Both sides of this meter
|
* incl. overage tolerance, the same ceiling the validator holds each leg to —
|
||||||
* are gross: `usedWeight` sums per-booking gross (cargo + wagon tare).
|
* and fall back to summing the locos' own limits.
|
||||||
*/
|
*/
|
||||||
function pullCapacity(schedule: TrainScheduleDetail): number {
|
function pullCapacity(schedule: TrainScheduleDetail): number {
|
||||||
|
if (schedule.maxGrossWeightTons != null) return Number(schedule.maxGrossWeightTons) || 0;
|
||||||
const set = schedule.trainSet;
|
const set = schedule.trainSet;
|
||||||
if (!set) return 0;
|
if (!set) return 0;
|
||||||
const locos =
|
const locos =
|
||||||
@@ -111,8 +135,7 @@ function pullCapacity(schedule: TrainScheduleDetail): number {
|
|||||||
: set.locomotive
|
: set.locomotive
|
||||||
? [set.locomotive]
|
? [set.locomotive]
|
||||||
: [];
|
: [];
|
||||||
if (locos.length === 0) return 0;
|
return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0);
|
||||||
return Math.min(...locos.map((l) => Number(l.maxPullWeightTons) || 0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduleWorkspacePanel({
|
export function ScheduleWorkspacePanel({
|
||||||
@@ -371,6 +394,7 @@ export function ScheduleWorkspacePanel({
|
|||||||
<Text size="xs" fw={600} c={over ? "red" : "dimmed"}>
|
<Text size="xs" fw={600} c={over ? "red" : "dimmed"}>
|
||||||
Load {used.toFixed(1)}T
|
Load {used.toFixed(1)}T
|
||||||
{capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""}
|
{capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""}
|
||||||
|
{(schedule.stops?.length ?? 0) > 2 ? " · heaviest leg" : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{over ? (
|
{over ? (
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
Container as ContainerIcon,
|
Container as ContainerIcon,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
|
History as HistoryIcon,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Navigation,
|
Navigation,
|
||||||
Package,
|
Package,
|
||||||
@@ -51,6 +52,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
|
|||||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||||
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
||||||
|
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
||||||
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
||||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||||
@@ -646,6 +648,10 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
reference: b.reference ?? b.id.slice(0, 8),
|
reference: b.reference ?? b.id.slice(0, 8),
|
||||||
weightTons: b.weightTons,
|
weightTons: b.weightTons,
|
||||||
isGovernment: b.isGovernment,
|
isGovernment: b.isGovernment,
|
||||||
|
wagonsRequired: b.wagonsRequired,
|
||||||
|
contractReference: b.contractReference,
|
||||||
|
origin: b.origin,
|
||||||
|
destination: b.destination,
|
||||||
}))}
|
}))}
|
||||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||||
eligibleLoading={eligibleQuery.isLoading}
|
eligibleLoading={eligibleQuery.isLoading}
|
||||||
@@ -1166,6 +1172,9 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
|
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
|
||||||
Leg capacity
|
Leg capacity
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
|
||||||
|
History
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="workflow">
|
<Tabs.Panel value="workflow">
|
||||||
@@ -1251,6 +1260,10 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
<Tabs.Panel value="legs">
|
<Tabs.Panel value="legs">
|
||||||
<LegCapacityPanel schedule={schedule} />
|
<LegCapacityPanel schedule={schedule} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="history">
|
||||||
|
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{scheduleId ? (
|
{scheduleId ? (
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ import {
|
|||||||
type BuiltTrainListFilters,
|
type BuiltTrainListFilters,
|
||||||
type BuiltTrainListResponse,
|
type BuiltTrainListResponse,
|
||||||
type ScheduleConsist,
|
type ScheduleConsist,
|
||||||
|
type ScheduleHistoryEntry,
|
||||||
type TrainComposition,
|
type TrainComposition,
|
||||||
type UpdateTrainDetailsPayload,
|
type UpdateTrainDetailsPayload,
|
||||||
type UsedTrainNumbers,
|
type UsedTrainNumbers,
|
||||||
@@ -362,6 +363,18 @@ export const api = {
|
|||||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
scheduleHistory: endpoint<{ scheduleId: string }, ScheduleHistoryEntry[]>(
|
||||||
|
"train-scheduling",
|
||||||
|
"schedule-history",
|
||||||
|
({ scheduleId }) =>
|
||||||
|
trainBuilderService.scheduleHistory(scheduleId).then((r) => r.data),
|
||||||
|
({ scheduleId }) => [
|
||||||
|
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||||
|
"history",
|
||||||
|
scheduleId,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
bookableSchedules: endpoint<
|
bookableSchedules: endpoint<
|
||||||
{ originYardId?: string | null; destinationYardId?: string | null },
|
{ originYardId?: string | null; destinationYardId?: string | null },
|
||||||
BookableSchedule[]
|
BookableSchedule[]
|
||||||
|
|||||||
@@ -231,17 +231,28 @@ export interface ScheduleConsist {
|
|||||||
grossTons: number;
|
grossTons: number;
|
||||||
consistLengthMeters: number;
|
consistLengthMeters: number;
|
||||||
};
|
};
|
||||||
wagons: Array<ConsistWagonRef & { loaded: boolean; removable: boolean }>;
|
wagons: Array<
|
||||||
|
ConsistWagonRef & {
|
||||||
|
loaded: boolean;
|
||||||
|
removable: boolean;
|
||||||
|
/** Loaded wagons can't leave, but their SLOT can change wagon. */
|
||||||
|
switchable: boolean;
|
||||||
|
blockReason: string | null;
|
||||||
|
}
|
||||||
|
>;
|
||||||
addableWagons: ConsistWagonRef[];
|
addableWagons: ConsistWagonRef[];
|
||||||
adjustments: Array<{
|
adjustments: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
action: "ADD" | "REMOVE";
|
action: "ADD" | "REMOVE" | "SWITCH";
|
||||||
wagonId: string;
|
wagonId: string;
|
||||||
wagonNumber: string;
|
wagonNumber: string;
|
||||||
adjustedByUserId: string | null;
|
adjustedByUserId: string | null;
|
||||||
|
yardId: string | null;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
}>;
|
}>;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
/** Where the train stands — mid-route this is the checkpointed stop. */
|
||||||
|
currentStop: { yardId: string; label: string; isMidRoute: boolean } | null;
|
||||||
/**
|
/**
|
||||||
* Wagon-slot picture of the schedule: the consist IS the booking capacity
|
* Wagon-slot picture of the schedule: the consist IS the booking capacity
|
||||||
* (weight/length only bind while building the consist), so the dialog can
|
* (weight/length only bind while building the consist), so the dialog can
|
||||||
@@ -259,6 +270,20 @@ export interface ScheduleConsist {
|
|||||||
export interface AdjustConsistPayload {
|
export interface AdjustConsistPayload {
|
||||||
addWagonIds?: string[];
|
addWagonIds?: string[];
|
||||||
removeWagonIds?: string[];
|
removeWagonIds?: string[];
|
||||||
|
/** Replacement takes the outgoing wagon's position and slot, cargo included. */
|
||||||
|
switches?: Array<{ fromWagonId: string; toWagonId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row of the schedule's unified change history (History tab). */
|
||||||
|
export interface ScheduleHistoryEntry {
|
||||||
|
id: string;
|
||||||
|
kind: "WAGON" | "BOOKING";
|
||||||
|
action: "ADD" | "REMOVE" | "SWITCH" | "BOOKING_REMOVED";
|
||||||
|
subject: string | null;
|
||||||
|
yardLabel: string | null;
|
||||||
|
actor: string | null;
|
||||||
|
note: string | null;
|
||||||
|
occurredAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
|
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
|
||||||
@@ -302,10 +327,15 @@ export const trainBuilderService = {
|
|||||||
/** Consist snapshot for a train-bound schedule (adjust-consist UI). */
|
/** Consist snapshot for a train-bound schedule (adjust-consist UI). */
|
||||||
scheduleConsist: (scheduleId: string) =>
|
scheduleConsist: (scheduleId: string) =>
|
||||||
apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`),
|
apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`),
|
||||||
/** Permanently trim/add wagons on the schedule's built train. */
|
/** Permanently trim/add/switch wagons on the schedule's built train. */
|
||||||
adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
|
adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
|
||||||
apiClient.post<AdjustConsistResult>(
|
apiClient.post<AdjustConsistResult>(
|
||||||
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
|
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
|
||||||
payload,
|
payload,
|
||||||
),
|
),
|
||||||
|
/** Unified wagon/booking change history for the schedule's History tab. */
|
||||||
|
scheduleHistory: (scheduleId: string) =>
|
||||||
|
apiClient.get<ScheduleHistoryEntry[]>(
|
||||||
|
`/train-scheduling/schedules/${scheduleId}/history`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -685,6 +685,7 @@ export interface TrainScheduleDetail {
|
|||||||
destinationYardId?: string | null;
|
destinationYardId?: string | null;
|
||||||
origin?: string | null;
|
origin?: string | null;
|
||||||
destination?: string | null;
|
destination?: string | null;
|
||||||
|
contractReference?: string | null;
|
||||||
wagonsRequired?: number | null;
|
wagonsRequired?: number | null;
|
||||||
loadedAt?: string | null;
|
loadedAt?: string | null;
|
||||||
arrivedAt?: string | null;
|
arrivedAt?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user