mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consist adjustments from a schedule: staff can trim free wagons off a built
|
||||||
|
* train when their tare pushes gross weight over the locomotives' pull limit
|
||||||
|
* (incl. overage tolerance), or couple extra yard wagons on while weight and
|
||||||
|
* length headroom remain. Each add/remove is logged here so the schedule keeps
|
||||||
|
* an auditable history; the built train itself is updated in place.
|
||||||
|
*
|
||||||
|
* Plain columns (no FKs) so the history survives wagon/train deletion.
|
||||||
|
*
|
||||||
|
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||||
|
* hand-applied there. IF NOT EXISTS keeps that idempotent.
|
||||||
|
*/
|
||||||
|
export class ScheduleWagonAdjustmentLogs2170000000000 implements MigrationInterface {
|
||||||
|
name = 'ScheduleWagonAdjustmentLogs2170000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.schedule_wagon_adjustment_logs (
|
||||||
|
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
train_schedule_id uuid NOT NULL,
|
||||||
|
train_id uuid NOT NULL,
|
||||||
|
action varchar(10) NOT NULL,
|
||||||
|
wagon_id uuid NOT NULL,
|
||||||
|
wagon_number varchar(50) NOT NULL,
|
||||||
|
adjusted_by_user_id uuid,
|
||||||
|
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT "PK_schedule_wagon_adjustment_logs" PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_swal_train_schedule_id"
|
||||||
|
ON freight.schedule_wagon_adjustment_logs (train_schedule_id);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_swal_train_id"
|
||||||
|
ON freight.schedule_wagon_adjustment_logs (train_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_id";`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_schedule_id";`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.schedule_wagon_adjustment_logs;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index } from 'typeorm';
|
||||||
|
|
||||||
|
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const;
|
||||||
|
export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 —
|
||||||
|
* e.g. trimming free wagons whose tare pushed gross weight over the
|
||||||
|
* locomotives' pull limit. Plain columns (no FK relations) so the history
|
||||||
|
* survives the wagon or train being deleted later.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
|
||||||
|
@Index(['trainScheduleId'])
|
||||||
|
@Index(['trainId'])
|
||||||
|
export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
||||||
|
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||||
|
trainScheduleId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'train_id', type: 'uuid' })
|
||||||
|
trainId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'action', type: 'varchar', length: 10 })
|
||||||
|
action!: WagonAdjustmentAction;
|
||||||
|
|
||||||
|
@Column({ name: 'wagon_id', type: 'uuid' })
|
||||||
|
wagonId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'wagon_number', type: 'varchar', length: 50 })
|
||||||
|
wagonNumber!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
adjustedByUserId!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
|
||||||
|
occurredAt!: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsArray, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class AdjustScheduleConsistDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [String],
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
"AVAILABLE wagons from the train's current yard to couple onto the built train (blocked when they push gross weight or length past the locomotive limits incl. tolerance).",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
addWagonIds?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [String],
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Free (unloaded) wagons to detach permanently from the built train — e.g. trimming tare when gross weight exceeds the pull limit.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
removeWagonIds?: string[];
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
UploadImportDjiboutiDocumentDto,
|
UploadImportDjiboutiDocumentDto,
|
||||||
} from "./dto/import-djibouti-operation.dto";
|
} from "./dto/import-djibouti-operation.dto";
|
||||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||||
|
import { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.dto";
|
||||||
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
|
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
|
||||||
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
|
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
|
||||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||||
@@ -166,6 +167,34 @@ export class TrainSchedulingController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("schedules/:id/consist")
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Built-train consist snapshot for a schedule: gross weight/length vs locomotive limits (incl. tolerance), trimmable + addable wagons, adjustment history",
|
||||||
|
})
|
||||||
|
getScheduleConsist(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.trainSchedulingService.getScheduleConsist(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("schedules/:id/adjust-consist")
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)",
|
||||||
|
})
|
||||||
|
adjustScheduleConsist(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AdjustScheduleConsistDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.trainSchedulingService.adjustScheduleConsist(
|
||||||
|
id,
|
||||||
|
dto,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@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.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
FindOptionsWhere,
|
FindOptionsWhere,
|
||||||
ILike,
|
ILike,
|
||||||
In,
|
In,
|
||||||
|
IsNull,
|
||||||
Not,
|
Not,
|
||||||
QueryFailedError,
|
QueryFailedError,
|
||||||
Raw,
|
Raw,
|
||||||
@@ -48,6 +49,7 @@ import { Train } from '../trains/entities/train.entity';
|
|||||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||||
|
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||||
@@ -61,6 +63,7 @@ import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-book
|
|||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
|
import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto';
|
||||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
||||||
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
|
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
|
||||||
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
||||||
@@ -1331,11 +1334,25 @@ export class TrainSchedulingService {
|
|||||||
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
|
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
|
||||||
const lengthCapWithOverage =
|
const lengthCapWithOverage =
|
||||||
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
|
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
|
||||||
// The locomotives pull GROSS weight: the customers' cargo plus the empty
|
// The locomotives pull GROSS weight: the customers' cargo plus wagon tare.
|
||||||
// weight of every planned wagon — cargo-only comparison understates the load.
|
// A built train hauls EVERY coupled wagon's tare — empty ones included —
|
||||||
const planTareTons = roundTons(
|
// so train-bound schedules count the full consist, not just planned slots.
|
||||||
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
const consistWagons = schedule.trainSet?.trainId
|
||||||
);
|
? await this.dataSource.getRepository(Wagon).find({
|
||||||
|
where: { trainId: schedule.trainSet.trainId },
|
||||||
|
relations: { wagonType: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const planTareTons = consistWagons
|
||||||
|
? roundTons(
|
||||||
|
consistWagons.reduce(
|
||||||
|
(sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: roundTons(
|
||||||
|
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
||||||
|
);
|
||||||
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
|
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
|
||||||
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
|
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -4626,6 +4643,294 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consist snapshot for the adjust-consist UI: the built train's wagons with
|
||||||
|
* loaded/removable flags, gross weight (cargo + FULL consist tare) and length
|
||||||
|
* against the locomotive limits incl. overage tolerance, addable yard wagons,
|
||||||
|
* and the adjustment history.
|
||||||
|
*/
|
||||||
|
async getScheduleConsist(scheduleId: string) {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
|
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
|
const builtTrain = schedule.trainSet?.train;
|
||||||
|
if (!builtTrain) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This schedule was not created from a built train — its consist cannot be adjusted here',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||||
|
where: { trainId: builtTrain.id },
|
||||||
|
relations: { wagonType: true },
|
||||||
|
order: { sequenceNumber: 'ASC' },
|
||||||
|
});
|
||||||
|
const addableWagons = await this.dataSource.getRepository(Wagon).find({
|
||||||
|
where: {
|
||||||
|
trainId: IsNull(),
|
||||||
|
status: WagonStatus.Available,
|
||||||
|
currentYardId: builtTrain.currentYardId ?? undefined,
|
||||||
|
},
|
||||||
|
relations: { wagonType: true },
|
||||||
|
order: { wagonNumber: 'ASC' },
|
||||||
|
});
|
||||||
|
const adjustments = await this.dataSource
|
||||||
|
.getRepository(ScheduleWagonAdjustmentLog)
|
||||||
|
.find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 });
|
||||||
|
|
||||||
|
// Slots with cargo aboard — their physical wagons are "loaded" and can
|
||||||
|
// never be trimmed.
|
||||||
|
const loadedWagonIds = new Set(
|
||||||
|
(schedule.trainSet?.wagons ?? [])
|
||||||
|
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
||||||
|
.map((slot) => slot.physicalWagonId as string),
|
||||||
|
);
|
||||||
|
|
||||||
|
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||||||
|
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
|
||||||
|
const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
|
||||||
|
const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
|
||||||
|
const overageToleranceMeters = roundTons(Number(limits?.overageToleranceMeters) || 0);
|
||||||
|
|
||||||
|
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
|
||||||
|
const consistTareTons = roundTons(
|
||||||
|
wagons.reduce((sum, w) => sum + Number(w.wagonType?.tareWeightTons ?? 0), 0),
|
||||||
|
);
|
||||||
|
const consistLengthMeters = roundTons(
|
||||||
|
wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapWagon = (wagon: Wagon) => ({
|
||||||
|
id: wagon.id,
|
||||||
|
wagonNumber: wagon.wagonNumber,
|
||||||
|
sequenceNumber: wagon.sequenceNumber,
|
||||||
|
wagonType: wagon.wagonType
|
||||||
|
? {
|
||||||
|
id: wagon.wagonType.id,
|
||||||
|
code: wagon.wagonType.code,
|
||||||
|
tareWeightTons: roundTons(Number(wagon.wagonType.tareWeightTons ?? 0)),
|
||||||
|
capacityTons: roundTons(Number(wagon.wagonType.capacityTons ?? 0)),
|
||||||
|
lengthMeters: roundTons(Number(wagon.wagonType.lengthMeters ?? 0)),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
schedule: { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status },
|
||||||
|
train: {
|
||||||
|
id: builtTrain.id,
|
||||||
|
code: builtTrain.code,
|
||||||
|
trainName: builtTrain.trainName ?? null,
|
||||||
|
currentYardId: builtTrain.currentYardId ?? null,
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
maxPullWeightTons,
|
||||||
|
overageToleranceTons,
|
||||||
|
pullCapTons: roundTons(maxPullWeightTons + overageToleranceTons),
|
||||||
|
maxTrainLengthMeters,
|
||||||
|
overageToleranceMeters,
|
||||||
|
lengthCapMeters: roundTons(maxTrainLengthMeters + overageToleranceMeters),
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
wagonCount: wagons.length,
|
||||||
|
cargoTons,
|
||||||
|
consistTareTons,
|
||||||
|
grossTons: roundTons(cargoTons + consistTareTons),
|
||||||
|
consistLengthMeters,
|
||||||
|
},
|
||||||
|
wagons: wagons.map((wagon) => ({
|
||||||
|
...mapWagon(wagon),
|
||||||
|
loaded: loadedWagonIds.has(wagon.id),
|
||||||
|
// Free = not pinned to any run; only free wagons can be trimmed.
|
||||||
|
removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id),
|
||||||
|
})),
|
||||||
|
addableWagons: addableWagons.map(mapWagon),
|
||||||
|
adjustments: adjustments.map((log) => ({
|
||||||
|
id: log.id,
|
||||||
|
action: log.action,
|
||||||
|
wagonId: log.wagonId,
|
||||||
|
wagonNumber: log.wagonNumber,
|
||||||
|
adjustedByUserId: log.adjustedByUserId,
|
||||||
|
occurredAt: log.occurredAt,
|
||||||
|
})),
|
||||||
|
editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently adjust the built train's consist from a schedule: trim free
|
||||||
|
* wagons (their tare no longer rides — the usual fix when gross weight beats
|
||||||
|
* the pull limit) and/or couple extra AVAILABLE yard wagons while weight and
|
||||||
|
* length headroom remain (limits incl. overage tolerance). The built train
|
||||||
|
* updates in place, the schedule's wagon cap follows, and every change is
|
||||||
|
* logged for the schedule's history.
|
||||||
|
*/
|
||||||
|
async adjustScheduleConsist(
|
||||||
|
scheduleId: string,
|
||||||
|
dto: AdjustScheduleConsistDto,
|
||||||
|
userId?: string | null,
|
||||||
|
) {
|
||||||
|
const addWagonIds = [...new Set(dto.addWagonIds ?? [])];
|
||||||
|
const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])];
|
||||||
|
if (!addWagonIds.length && !removeWagonIds.length) {
|
||||||
|
throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove');
|
||||||
|
}
|
||||||
|
const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id));
|
||||||
|
if (overlap.length) {
|
||||||
|
throw new BadRequestException('A wagon cannot be added and removed in the same adjustment');
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
|
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
|
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'The consist is frozen once the train is dispatched — adjust before departure',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const builtTrainRef = schedule.trainSet?.train;
|
||||||
|
if (!builtTrainRef) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This schedule was not created from a built train — its consist cannot be adjusted here',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const loadedWagonIds = new Set(
|
||||||
|
(schedule.trainSet?.wagons ?? [])
|
||||||
|
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
||||||
|
.map((slot) => slot.physicalWagonId as string),
|
||||||
|
);
|
||||||
|
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||||||
|
const pullCapTons = roundTons(
|
||||||
|
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
|
||||||
|
);
|
||||||
|
const lengthCapMeters = roundTons(
|
||||||
|
Number(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const train = await manager.getRepository(Train).findOne({
|
||||||
|
where: { id: builtTrainRef.id },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (!train) throw new NotFoundException(`Train ${builtTrainRef.id} not found`);
|
||||||
|
|
||||||
|
const consist = await manager.getRepository(Wagon).find({
|
||||||
|
where: { trainId: train.id },
|
||||||
|
relations: { wagonType: true },
|
||||||
|
order: { sequenceNumber: 'ASC' },
|
||||||
|
});
|
||||||
|
const consistById = new Map(consist.map((w) => [w.id, w]));
|
||||||
|
|
||||||
|
// --- validate removals: must be coupled and free (no cargo, no pin) ---
|
||||||
|
const removed: Wagon[] = [];
|
||||||
|
for (const wagonId of removeWagonIds) {
|
||||||
|
const wagon = consistById.get(wagonId);
|
||||||
|
if (!wagon) {
|
||||||
|
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
|
||||||
|
}
|
||||||
|
if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
removed.push(wagon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- validate additions: AVAILABLE, loose, standing in the train's yard ---
|
||||||
|
const added: Wagon[] = [];
|
||||||
|
for (const wagonId of addWagonIds) {
|
||||||
|
const wagon = await manager.getRepository(Wagon).findOne({
|
||||||
|
where: { id: wagonId },
|
||||||
|
relations: { wagonType: true },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
|
||||||
|
if (wagon.trainId) {
|
||||||
|
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on a train`);
|
||||||
|
}
|
||||||
|
if (wagon.status !== WagonStatus.Available) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (wagon.currentYardId !== train.currentYardId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
added.push(wagon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- headroom check (only additions can push the train over a cap) ---
|
||||||
|
const removedIds = new Set(removed.map((w) => w.id));
|
||||||
|
const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added];
|
||||||
|
const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0);
|
||||||
|
const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0);
|
||||||
|
const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0));
|
||||||
|
const finalLengthMeters = roundTons(finalConsist.reduce((s, w) => s + lengthOf(w), 0));
|
||||||
|
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
|
||||||
|
const finalGrossTons = roundTons(cargoTons + finalTareTons);
|
||||||
|
if (added.length && pullCapTons > 0 && finalGrossTons > pullCapTons) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Adding these wagons puts gross weight at ${finalGrossTons}T (${cargoTons}T cargo + ${finalTareTons}T tare), over the locomotives' ${pullCapTons}T limit incl. tolerance`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (added.length && lengthCapMeters > 0 && finalLengthMeters > lengthCapMeters) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Adding these wagons puts consist length at ${finalLengthMeters}m, over the locomotives' ${lengthCapMeters}m limit incl. tolerance`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- apply: detach trims, couple additions, compact the sequence ---
|
||||||
|
for (const wagon of removed) {
|
||||||
|
await manager.getRepository(Wagon).update(wagon.id, {
|
||||||
|
trainId: null,
|
||||||
|
sequenceNumber: null,
|
||||||
|
status: WagonStatus.Available,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const remaining = consist.filter((w) => !removedIds.has(w.id));
|
||||||
|
for (let i = 0; i < remaining.length; i++) {
|
||||||
|
if (remaining[i].sequenceNumber !== i + 1) {
|
||||||
|
await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sequence = remaining.length;
|
||||||
|
for (const wagon of added) {
|
||||||
|
sequence += 1;
|
||||||
|
await manager.getRepository(Wagon).update(wagon.id, {
|
||||||
|
trainId: train.id,
|
||||||
|
sequenceNumber: sequence,
|
||||||
|
status: WagonStatus.Assigned,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The schedule is full when every consist wagon is allocated.
|
||||||
|
await manager
|
||||||
|
.getRepository(TrainSchedule)
|
||||||
|
.update(scheduleId, { maxWagons: finalConsist.length });
|
||||||
|
|
||||||
|
const logRepo = manager.getRepository(ScheduleWagonAdjustmentLog);
|
||||||
|
const now = new Date();
|
||||||
|
await logRepo.save(
|
||||||
|
[
|
||||||
|
...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })),
|
||||||
|
...added.map((wagon) => ({ action: 'ADD' as const, wagon })),
|
||||||
|
].map(({ action, wagon }) =>
|
||||||
|
logRepo.create({
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
trainId: train.id,
|
||||||
|
action,
|
||||||
|
wagonId: wagon.id,
|
||||||
|
wagonNumber: wagon.wagonNumber,
|
||||||
|
adjustedByUserId: userId ?? null,
|
||||||
|
occurredAt: now,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getScheduleConsist(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 →
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
|||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
|
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
|
||||||
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||||
@@ -511,9 +512,13 @@ export class TrainBuilderService {
|
|||||||
startCount: number,
|
startCount: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const uniqueIds = [...new Set(wagonIds)];
|
const uniqueIds = [...new Set(wagonIds)];
|
||||||
let sequence = startCount;
|
const wagonRepo = manager.getRepository(Wagon);
|
||||||
|
|
||||||
|
// First pass: lock + validate every wagon so the length gate below sees
|
||||||
|
// the full incoming set before any row is written.
|
||||||
|
const toAttach: Wagon[] = [];
|
||||||
for (const wagonId of uniqueIds) {
|
for (const wagonId of uniqueIds) {
|
||||||
const wagon = await manager.getRepository(Wagon).findOne({
|
const wagon = await wagonRepo.findOne({
|
||||||
where: { id: wagonId },
|
where: { id: wagonId },
|
||||||
lock: { mode: 'pessimistic_write' },
|
lock: { mode: 'pessimistic_write' },
|
||||||
});
|
});
|
||||||
@@ -530,8 +535,16 @@ export class TrainBuilderService {
|
|||||||
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
|
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
toAttach.push(wagon);
|
||||||
|
}
|
||||||
|
if (!toAttach.length) return;
|
||||||
|
|
||||||
|
await this.assertConsistLengthWithinLimit(manager, train, toAttach);
|
||||||
|
|
||||||
|
let sequence = startCount;
|
||||||
|
for (const wagon of toAttach) {
|
||||||
sequence += 1;
|
sequence += 1;
|
||||||
await manager.getRepository(Wagon).update(wagon.id, {
|
await wagonRepo.update(wagon.id, {
|
||||||
trainId: train.id,
|
trainId: train.id,
|
||||||
sequenceNumber: sequence,
|
sequenceNumber: sequence,
|
||||||
status: WagonStatus.Assigned,
|
status: WagonStatus.Assigned,
|
||||||
@@ -539,6 +552,58 @@ export class TrainBuilderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The consist (already-attached wagons + the incoming ones) must fit the
|
||||||
|
* train's locomotive length limit — the weakest locomotive of the set caps
|
||||||
|
* the train, mirroring how scheduling derives capacity.
|
||||||
|
*/
|
||||||
|
private async assertConsistLengthWithinLimit(
|
||||||
|
manager: EntityManager,
|
||||||
|
train: Train,
|
||||||
|
incoming: Wagon[],
|
||||||
|
): Promise<void> {
|
||||||
|
const links = await manager.getRepository(TrainLocomotive).find({
|
||||||
|
where: { trainId: train.id },
|
||||||
|
relations: { locomotive: true },
|
||||||
|
});
|
||||||
|
const limits = minLocomotiveLimits(
|
||||||
|
links
|
||||||
|
.map((link) => link.locomotive)
|
||||||
|
.filter((loco): loco is Locomotive => Boolean(loco)),
|
||||||
|
);
|
||||||
|
const maxLengthMeters = Number(limits?.maxTrainLengthMeters ?? 0);
|
||||||
|
if (!Number.isFinite(maxLengthMeters) || maxLengthMeters <= 0) return;
|
||||||
|
|
||||||
|
const existing = await manager.getRepository(Wagon).find({
|
||||||
|
where: { trainId: train.id },
|
||||||
|
relations: { wagonType: true },
|
||||||
|
});
|
||||||
|
const currentLength = existing.reduce(
|
||||||
|
(sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const typeIds = [...new Set(incoming.map((w) => w.wagonTypeId).filter(Boolean))];
|
||||||
|
const types = typeIds.length
|
||||||
|
? await manager.getRepository(WagonType).find({ where: { id: In(typeIds) } })
|
||||||
|
: [];
|
||||||
|
const lengthByType = new Map(types.map((t) => [t.id, Number(t.lengthMeters) || 0]));
|
||||||
|
const addedLength = incoming.reduce(
|
||||||
|
(sum, w) => sum + (lengthByType.get(w.wagonTypeId) ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalLength = currentLength + addedLength;
|
||||||
|
if (totalLength > maxLengthMeters) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot attach wagons — train length would be ${round(totalLength)} m ` +
|
||||||
|
`(current ${round(currentLength)} m + ${round(addedLength)} m added), ` +
|
||||||
|
`over the locomotive limit of ${round(maxLengthMeters)} m. ` +
|
||||||
|
'Remove wagons from the consist or use locomotives with a higher length limit.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Compact wagon sequence numbers back to 1..n after a removal. */
|
/** Compact wagon sequence numbers back to 1..n after a removal. */
|
||||||
private async resequenceWagons(manager: EntityManager, trainId: string): Promise<void> {
|
private async resequenceWagons(manager: EntityManager, trainId: string): Promise<void> {
|
||||||
const wagons = await manager.getRepository(Wagon).find({
|
const wagons = await manager.getRepository(Wagon).find({
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Progress,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
|
import { AlertTriangle, History, Minus, Plus } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { ConsistWagonRef } from "@/services/trainBuilder.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const parseError = (error: unknown, fallback: string) => {
|
||||||
|
if (isAxiosError(error)) {
|
||||||
|
const message = error.response?.data?.message;
|
||||||
|
if (Array.isArray(message)) return message.join(", ");
|
||||||
|
if (typeof message === "string") return message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0;
|
||||||
|
const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0;
|
||||||
|
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* couple extra yard wagons while weight/length headroom remains. Changes are
|
||||||
|
* permanent on the train and logged on the schedule.
|
||||||
|
*/
|
||||||
|
export default function AdjustConsistModal({
|
||||||
|
scheduleId,
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
}: AdjustConsistModalProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||||
|
const [addIds, setAddIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const consistQuery = useQuery(
|
||||||
|
api.trainScheduling.scheduleConsist.queryOptions({
|
||||||
|
input: { scheduleId },
|
||||||
|
enabled: opened && Boolean(scheduleId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const adjust = useMutation(api.trainScheduling.adjustConsist.mutationOptions());
|
||||||
|
const data = consistQuery.data;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (opened) {
|
||||||
|
setRemoveIds([]);
|
||||||
|
setAddIds([]);
|
||||||
|
}
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
// Live projection: gross = cargo + tare of (consist − trims + adds).
|
||||||
|
const projection = useMemo(() => {
|
||||||
|
if (!data) return null;
|
||||||
|
const removed = new Set(removeIds);
|
||||||
|
const keptTare = data.wagons
|
||||||
|
.filter((w) => !removed.has(w.id))
|
||||||
|
.reduce((s, w) => s + tareOf(w), 0);
|
||||||
|
const keptLength = data.wagons
|
||||||
|
.filter((w) => !removed.has(w.id))
|
||||||
|
.reduce((s, w) => s + lengthOf(w), 0);
|
||||||
|
const addedWagons = data.addableWagons.filter((w) => addIds.includes(w.id));
|
||||||
|
const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0);
|
||||||
|
const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0);
|
||||||
|
const gross = round2(data.totals.cargoTons + tare);
|
||||||
|
return {
|
||||||
|
wagonCount: data.totals.wagonCount - removeIds.length + addIds.length,
|
||||||
|
tare: round2(tare),
|
||||||
|
gross,
|
||||||
|
length: round2(length),
|
||||||
|
grossPct: data.limits.pullCapTons
|
||||||
|
? Math.round((gross / data.limits.pullCapTons) * 100)
|
||||||
|
: null,
|
||||||
|
lengthPct: data.limits.lengthCapMeters
|
||||||
|
? Math.round((length / data.limits.lengthCapMeters) * 100)
|
||||||
|
: null,
|
||||||
|
overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons,
|
||||||
|
overLength:
|
||||||
|
data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters,
|
||||||
|
};
|
||||||
|
}, [data, removeIds, addIds]);
|
||||||
|
|
||||||
|
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
|
||||||
|
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!removeIds.length && !addIds.length) return;
|
||||||
|
try {
|
||||||
|
await adjust.mutateAsync({
|
||||||
|
scheduleId,
|
||||||
|
payload: {
|
||||||
|
...(addIds.length ? { addWagonIds: addIds } : {}),
|
||||||
|
...(removeIds.length ? { removeWagonIds: removeIds } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${
|
||||||
|
removeIds.length && addIds.length ? ", " : ""
|
||||||
|
}${addIds.length ? `${addIds.length} added` : ""}`,
|
||||||
|
});
|
||||||
|
setRemoveIds([]);
|
||||||
|
setAddIds([]);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Adjustment failed",
|
||||||
|
description: parseError(err, "Could not adjust the consist"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={
|
||||||
|
<Text fw={600}>
|
||||||
|
Adjust consist{data ? ` — train ${data.train.code}` : ""}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
radius="lg"
|
||||||
|
size={860}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
{consistQuery.isLoading || !data ? (
|
||||||
|
<Text py="lg" ta="center" c="dimmed" size="sm">
|
||||||
|
{consistQuery.isError
|
||||||
|
? "This schedule has no built train to adjust."
|
||||||
|
: "Loading consist…"}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
{!data.editable ? (
|
||||||
|
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||||
|
The consist is frozen once the train is dispatched.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Grid gap="md">
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
|
<LimitGauge
|
||||||
|
label="Gross weight"
|
||||||
|
detail={`${data.totals.cargoTons}T cargo + ${projection?.tare}T tare = ${projection?.gross}T of ${data.limits.pullCapTons}T (limit ${data.limits.maxPullWeightTons}T + ${data.limits.overageToleranceTons}T tolerance)`}
|
||||||
|
pct={projection?.grossPct ?? null}
|
||||||
|
over={projection?.overWeight ?? false}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
|
<LimitGauge
|
||||||
|
label="Consist length"
|
||||||
|
detail={`${projection?.length}m of ${data.limits.lengthCapMeters}m (limit ${data.limits.maxTrainLengthMeters}m + ${data.limits.overageToleranceMeters}m tolerance)`}
|
||||||
|
pct={projection?.lengthPct ?? null}
|
||||||
|
over={projection?.overLength ?? false}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid gap="md">
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group gap={6}>
|
||||||
|
<Minus size={14} />
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Trim coupled wagons ({data.totals.wagonCount})
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Only free (unloaded, unpinned) wagons can be detached. Detaching is
|
||||||
|
permanent — the wagon returns to the yard as available.
|
||||||
|
</Text>
|
||||||
|
<ScrollArea.Autosize mah={260} type="auto">
|
||||||
|
<Stack gap={4}>
|
||||||
|
{data.wagons.map((wagon) => (
|
||||||
|
<WagonRow
|
||||||
|
key={wagon.id}
|
||||||
|
wagon={wagon}
|
||||||
|
checked={removeIds.includes(wagon.id)}
|
||||||
|
disabled={!data.editable || !wagon.removable}
|
||||||
|
badge={
|
||||||
|
wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null
|
||||||
|
}
|
||||||
|
onToggle={toggle(setRemoveIds)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group gap={6}>
|
||||||
|
<Plus size={14} />
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Couple yard wagons ({data.addableWagons.length} available)
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
AVAILABLE wagons standing in the train's yard. Blocked when they push
|
||||||
|
gross weight or length past the locomotive limits incl. tolerance.
|
||||||
|
</Text>
|
||||||
|
<ScrollArea.Autosize mah={260} type="auto">
|
||||||
|
<Stack gap={4}>
|
||||||
|
{data.addableWagons.length ? (
|
||||||
|
data.addableWagons.map((wagon) => (
|
||||||
|
<WagonRow
|
||||||
|
key={wagon.id}
|
||||||
|
wagon={wagon}
|
||||||
|
checked={addIds.includes(wagon.id)}
|
||||||
|
disabled={!data.editable}
|
||||||
|
badge={null}
|
||||||
|
onToggle={toggle(setAddIds)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed" py="sm" ta="center">
|
||||||
|
No available wagons in this yard
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{data.adjustments.length ? (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group gap={6}>
|
||||||
|
<History size={14} />
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Adjustment history
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<ScrollArea.Autosize mah={120} type="auto">
|
||||||
|
<Stack gap={2}>
|
||||||
|
{data.adjustments.map((log) => (
|
||||||
|
<Group key={log.id} gap="xs">
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color={log.action === "ADD" ? "edr-green" : "red"}
|
||||||
|
>
|
||||||
|
{log.action === "ADD" ? "Added" : "Trimmed"}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" ff="monospace">
|
||||||
|
{log.wagonNumber}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{new Date(log.occurredAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Projected consist: {projection?.wagonCount} wagons
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={adjust.isPending}
|
||||||
|
disabled={
|
||||||
|
!data.editable ||
|
||||||
|
(!removeIds.length && !addIds.length) ||
|
||||||
|
(addIds.length > 0 && (projection?.overWeight || projection?.overLength))
|
||||||
|
}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Apply{" "}
|
||||||
|
{removeIds.length ? `−${removeIds.length}` : ""}
|
||||||
|
{removeIds.length && addIds.length ? " / " : ""}
|
||||||
|
{addIds.length ? `+${addIds.length}` : ""}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdjustConsistModalProps {
|
||||||
|
scheduleId: string;
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LimitGauge({
|
||||||
|
label,
|
||||||
|
detail,
|
||||||
|
pct,
|
||||||
|
over,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
pct: number | null;
|
||||||
|
over: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" fw={600}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" fw={700} c={over ? "red.7" : "edr-green.7"}>
|
||||||
|
{pct != null ? `${pct}%` : "—"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress
|
||||||
|
value={Math.min(pct ?? 0, 100)}
|
||||||
|
size="md"
|
||||||
|
radius="xl"
|
||||||
|
color={over ? "red" : (pct ?? 0) > 85 ? "yellow" : "edr-green"}
|
||||||
|
striped={over}
|
||||||
|
animated={over}
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{detail}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WagonRow({
|
||||||
|
wagon,
|
||||||
|
checked,
|
||||||
|
disabled,
|
||||||
|
badge,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
wagon: ConsistWagonRef;
|
||||||
|
checked: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
badge: string | null;
|
||||||
|
onToggle: (id: string, checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap="sm"
|
||||||
|
wrap="nowrap"
|
||||||
|
p={6}
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
opacity: disabled && !badge ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
size="sm"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onToggle(wagon.id, e.currentTarget.checked)}
|
||||||
|
aria-label={`Select wagon ${wagon.wagonNumber}`}
|
||||||
|
/>
|
||||||
|
<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}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -324,6 +325,21 @@ export default function NewBookingPage() {
|
|||||||
const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0);
|
const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0);
|
||||||
const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight;
|
const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight;
|
||||||
|
|
||||||
|
// 20ft containers ride two per wagon, so a booking must hold an even number
|
||||||
|
// of them — odd counts would leave half a wagon waiting on a co-loader
|
||||||
|
// (cross-booking consolidation is disabled for now).
|
||||||
|
const twentyFtCount = useMemo(() => {
|
||||||
|
const sizeById = new Map<string, string>();
|
||||||
|
for (const group of refData?.containers ?? []) {
|
||||||
|
for (const type of group.types) sizeById.set(type.id, group.size);
|
||||||
|
}
|
||||||
|
return lines.reduce((sum, l) => {
|
||||||
|
const size = l.containerTypeId ? (sizeById.get(l.containerTypeId) ?? "") : "";
|
||||||
|
return String(size).includes("20") ? sum + (l.quantity || 0) : sum;
|
||||||
|
}, 0);
|
||||||
|
}, [refData?.containers, lines]);
|
||||||
|
const hasOdd20ft = freightType === "CONTAINER" && twentyFtCount % 2 === 1;
|
||||||
|
|
||||||
// ---- validation ----
|
// ---- validation ----
|
||||||
const lineValid = (l: ContainerLine) =>
|
const lineValid = (l: ContainerLine) =>
|
||||||
Boolean(l.containerTypeId) && l.quantity >= 1 && l.vgmPerUnitTons > 0;
|
Boolean(l.containerTypeId) && l.quantity >= 1 && l.vgmPerUnitTons > 0;
|
||||||
@@ -344,7 +360,7 @@ export default function NewBookingPage() {
|
|||||||
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
|
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
|
||||||
(freightType === "BULK"
|
(freightType === "BULK"
|
||||||
? Boolean(cargoTypeId) && bulkWeight > 0
|
? Boolean(cargoTypeId) && bulkWeight > 0
|
||||||
: allLinesValid);
|
: allLinesValid && !hasOdd20ft);
|
||||||
|
|
||||||
const updateLine = (key: string, patch: Partial<ContainerLine>) =>
|
const updateLine = (key: string, patch: Partial<ContainerLine>) =>
|
||||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
|
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
|
||||||
@@ -842,6 +858,20 @@ export default function NewBookingPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{hasOdd20ft ? (
|
||||||
|
<Alert color="red" icon={<AlertTriangle size={16} />} radius="md" mt="md">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Odd number of 20ft containers ({twentyFtCount})
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mt={4}>
|
||||||
|
20ft containers travel two per wagon, so they must be booked in
|
||||||
|
even numbers. Add one more 20ft container or remove one — e.g.
|
||||||
|
book {twentyFtCount + 1} or {twentyFtCount - 1} instead of{" "}
|
||||||
|
{twentyFtCount}.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Stack gap="sm" mt="lg">
|
<Stack gap="sm" mt="lg">
|
||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ export default function TrainBuilderDetailPage() {
|
|||||||
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
|
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
|
||||||
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
|
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
|
||||||
{
|
{
|
||||||
label: "Payload available",
|
label: "Tare weight / haul limit",
|
||||||
value: `${totals.payloadCapacityTons}T of ${totals.maxPullWeightTons}T`,
|
value: `${totals.totalTareTons}T of ${totals.maxPullWeightTons}T`,
|
||||||
icon: Weight,
|
icon: Weight,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { CountdownTimer, DataTable, type ColumnDef } from "@edr/ui-common";
|
|||||||
import { KpiStrip, PageContainer } from "@/components/page";
|
import { KpiStrip, PageContainer } from "@/components/page";
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
|
import AdjustConsistModal from "@/components/trainScheduling/AdjustConsistModal";
|
||||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||||
import {
|
import {
|
||||||
TrainConsistView,
|
TrainConsistView,
|
||||||
@@ -684,6 +685,7 @@ export default function BatchScheduleDetailPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||||||
|
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
|
||||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
|
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -870,6 +872,17 @@ export default function BatchScheduleDetailPage() {
|
|||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
{data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<TrainFront size={16} />}
|
||||||
|
onClick={() => setAdjustConsistOpen(true)}
|
||||||
|
>
|
||||||
|
Adjust consist
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{data.windowPhase === "DOC_REVIEW" ? (
|
{data.windowPhase === "DOC_REVIEW" ? (
|
||||||
<Button
|
<Button
|
||||||
color="yellow"
|
color="yellow"
|
||||||
@@ -1181,6 +1194,12 @@ export default function BatchScheduleDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<AdjustConsistModal
|
||||||
|
scheduleId={data.scheduleId}
|
||||||
|
opened={adjustConsistOpen}
|
||||||
|
onClose={() => setAdjustConsistOpen(false)}
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityR
|
|||||||
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";
|
||||||
|
import AdjustConsistModal from "@/components/trainScheduling/AdjustConsistModal";
|
||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||||
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
||||||
@@ -101,6 +102,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||||
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||||
|
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
|
||||||
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
||||||
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
||||||
const [gatepassReference, setGatepassReference] = useState("");
|
const [gatepassReference, setGatepassReference] = useState("");
|
||||||
@@ -949,6 +951,18 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
Reschedule train
|
Reschedule train
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{schedule.train && ["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="lg"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<Train size={16} />}
|
||||||
|
onClick={() => setAdjustConsistOpen(true)}
|
||||||
|
>
|
||||||
|
Adjust consist
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{gatepassApplies ? (
|
{gatepassApplies ? (
|
||||||
gatepassSecured ? (
|
gatepassSecured ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -1173,6 +1187,12 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<AdjustConsistModal
|
||||||
|
scheduleId={schedule.id}
|
||||||
|
opened={adjustConsistOpen}
|
||||||
|
onClose={() => setAdjustConsistOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<BookingWindowSettingsModal
|
<BookingWindowSettingsModal
|
||||||
scheduleId={scheduleId ?? null}
|
scheduleId={scheduleId ?? null}
|
||||||
opened={windowSettingsOpen}
|
opened={windowSettingsOpen}
|
||||||
|
|||||||
@@ -184,10 +184,12 @@ import {
|
|||||||
import { trainService, type Train } from "./trains.service";
|
import { trainService, type Train } from "./trains.service";
|
||||||
import {
|
import {
|
||||||
trainBuilderService,
|
trainBuilderService,
|
||||||
|
type AdjustConsistPayload,
|
||||||
type AvailableTrain,
|
type AvailableTrain,
|
||||||
type BuildTrainPayload,
|
type BuildTrainPayload,
|
||||||
type BuiltTrainListFilters,
|
type BuiltTrainListFilters,
|
||||||
type BuiltTrainListResponse,
|
type BuiltTrainListResponse,
|
||||||
|
type ScheduleConsist,
|
||||||
type TrainComposition,
|
type TrainComposition,
|
||||||
} from "./trainBuilder.service";
|
} from "./trainBuilder.service";
|
||||||
import { trainSchedulingService } from "./trainScheduling.service";
|
import { trainSchedulingService } from "./trainScheduling.service";
|
||||||
@@ -320,6 +322,30 @@ export const api = {
|
|||||||
({ routeId }) => QUERY_KEYS.TRAIN_SCHEDULING.availableTrains(routeId),
|
({ routeId }) => QUERY_KEYS.TRAIN_SCHEDULING.availableTrains(routeId),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
scheduleConsist: endpoint<{ scheduleId: string }, ScheduleConsist>(
|
||||||
|
"train-scheduling",
|
||||||
|
"schedule-consist",
|
||||||
|
({ scheduleId }) =>
|
||||||
|
trainBuilderService.scheduleConsist(scheduleId).then((r) => r.data),
|
||||||
|
({ scheduleId }) => [
|
||||||
|
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||||
|
"consist",
|
||||||
|
scheduleId,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
adjustConsist: endpoint<
|
||||||
|
{ scheduleId: string; payload: AdjustConsistPayload },
|
||||||
|
ScheduleConsist
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"adjust-consist",
|
||||||
|
({ scheduleId, payload }) =>
|
||||||
|
trainBuilderService.adjustConsist(scheduleId, payload).then((r) => r.data),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
bookableSchedules: endpoint<
|
bookableSchedules: endpoint<
|
||||||
{ originYardId?: string | null; destinationYardId?: string | null },
|
{ originYardId?: string | null; destinationYardId?: string | null },
|
||||||
BookableSchedule[]
|
BookableSchedule[]
|
||||||
|
|||||||
@@ -153,6 +153,64 @@ const toQuery = (filters: BuiltTrainListFilters = {}) => {
|
|||||||
return qs ? `?${qs}` : "";
|
return qs ? `?${qs}` : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schedule consist adjustment (train-bound schedules)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ConsistWagonRef {
|
||||||
|
id: string;
|
||||||
|
wagonNumber: string;
|
||||||
|
sequenceNumber: number | null;
|
||||||
|
wagonType: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
tareWeightTons: number;
|
||||||
|
capacityTons: number;
|
||||||
|
lengthMeters: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleConsist {
|
||||||
|
schedule: { id: string; reference: string | null; status: string };
|
||||||
|
train: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
trainName: string | null;
|
||||||
|
currentYardId: string | null;
|
||||||
|
};
|
||||||
|
limits: {
|
||||||
|
maxPullWeightTons: number;
|
||||||
|
overageToleranceTons: number;
|
||||||
|
pullCapTons: number;
|
||||||
|
maxTrainLengthMeters: number;
|
||||||
|
overageToleranceMeters: number;
|
||||||
|
lengthCapMeters: number;
|
||||||
|
};
|
||||||
|
totals: {
|
||||||
|
wagonCount: number;
|
||||||
|
cargoTons: number;
|
||||||
|
consistTareTons: number;
|
||||||
|
grossTons: number;
|
||||||
|
consistLengthMeters: number;
|
||||||
|
};
|
||||||
|
wagons: Array<ConsistWagonRef & { loaded: boolean; removable: boolean }>;
|
||||||
|
addableWagons: ConsistWagonRef[];
|
||||||
|
adjustments: Array<{
|
||||||
|
id: string;
|
||||||
|
action: "ADD" | "REMOVE";
|
||||||
|
wagonId: string;
|
||||||
|
wagonNumber: string;
|
||||||
|
adjustedByUserId: string | null;
|
||||||
|
occurredAt: string;
|
||||||
|
}>;
|
||||||
|
editable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdjustConsistPayload {
|
||||||
|
addWagonIds?: string[];
|
||||||
|
removeWagonIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export const trainBuilderService = {
|
export const trainBuilderService = {
|
||||||
list: (filters: BuiltTrainListFilters = {}) =>
|
list: (filters: BuiltTrainListFilters = {}) =>
|
||||||
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||||
@@ -175,4 +233,13 @@ export const trainBuilderService = {
|
|||||||
apiClient.get<AvailableTrain[]>(`/train-scheduling/available-trains`, {
|
apiClient.get<AvailableTrain[]>(`/train-scheduling/available-trains`, {
|
||||||
params: { routeId },
|
params: { routeId },
|
||||||
}),
|
}),
|
||||||
|
/** Consist snapshot for a train-bound schedule (adjust-consist UI). */
|
||||||
|
scheduleConsist: (scheduleId: string) =>
|
||||||
|
apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`),
|
||||||
|
/** Permanently trim/add wagons on the schedule's built train. */
|
||||||
|
adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
|
||||||
|
apiClient.post<ScheduleConsist>(
|
||||||
|
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
|
||||||
|
payload,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -787,13 +787,17 @@ export function Step5CargoDetails({
|
|||||||
const result = calcWagons(containers ?? []);
|
const result = calcWagons(containers ?? []);
|
||||||
if (result.hasOddUnit) {
|
if (result.hasOddUnit) {
|
||||||
return (
|
return (
|
||||||
<AlertBox tone="warning">
|
<AlertBox tone="error">
|
||||||
<p className="font-semibold">Unpaired 20ft Container</p>
|
<p className="font-semibold">
|
||||||
|
Odd number of 20ft containers ({result.ft20Wagons})
|
||||||
|
</p>
|
||||||
<p className="mt-1 text-xs">
|
<p className="mt-1 text-xs">
|
||||||
One 20ft container occupies only half a wagon. The wagon will
|
20ft containers travel two per wagon, so they must be booked in
|
||||||
depart once a co-loader is found to fill the remaining slot,
|
even numbers. Please <strong>add one more 20ft container</strong>{" "}
|
||||||
which <strong>may delay departure</strong> beyond the standard
|
or <strong>remove one</strong> (e.g. book{" "}
|
||||||
lead time.
|
{result.ft20Wagons + 1} or {result.ft20Wagons - 1} instead of{" "}
|
||||||
|
{result.ft20Wagons}) — the booking cannot be submitted with an
|
||||||
|
unpaired 20ft container.
|
||||||
</p>
|
</p>
|
||||||
</AlertBox>
|
</AlertBox>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@/types";
|
import type { Freight } from "@/types";
|
||||||
import {
|
import {
|
||||||
|
calcWagons,
|
||||||
type BookingFormInputValues,
|
type BookingFormInputValues,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { StepHeader } from "./shared";
|
import { AlertBox, StepHeader } from "./shared";
|
||||||
|
|
||||||
export const REVIEW_STEP_TARGETS = {
|
export const REVIEW_STEP_TARGETS = {
|
||||||
contract: 1,
|
contract: 1,
|
||||||
@@ -160,6 +161,12 @@ export function Step8Review({
|
|||||||
.join(", ")
|
.join(", ")
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
// 20ft containers must pair up (two per wagon) — an odd total blocks submit.
|
||||||
|
const { hasOddUnit: hasOdd20ft, ft20Wagons: twentyFtCount } =
|
||||||
|
values.cargoType === "container"
|
||||||
|
? calcWagons(values.containers ?? [])
|
||||||
|
: { hasOddUnit: false, ft20Wagons: 0 };
|
||||||
|
|
||||||
const isGeneralContract = values.bookingType === "general_contract";
|
const isGeneralContract = values.bookingType === "general_contract";
|
||||||
// Both one-time and general contracts take the bulk amount from the cargo step
|
// Both one-time and general contracts take the bulk amount from the cargo step
|
||||||
// (cargoWeight); general contracts no longer collect a per-route quantity.
|
// (cargoWeight); general contracts no longer collect a per-route quantity.
|
||||||
@@ -529,10 +536,27 @@ export function Step8Review({
|
|||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper radius={20} p="lg" withBorder bg="white">
|
<Paper radius={20} p="lg" withBorder bg="white">
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
{hasOdd20ft ? (
|
||||||
Ready to submit. You'll review the unit rates before final
|
<Box mb="md">
|
||||||
submission.
|
<AlertBox tone="error">
|
||||||
</Text>
|
<p className="font-semibold">
|
||||||
|
Odd number of 20ft containers ({twentyFtCount})
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs">
|
||||||
|
20ft containers travel two per wagon, so they must be booked
|
||||||
|
in even numbers. Go back to the cargo step and{" "}
|
||||||
|
<strong>add one more 20ft container</strong> or{" "}
|
||||||
|
<strong>remove one</strong> (e.g. {twentyFtCount + 1} or{" "}
|
||||||
|
{twentyFtCount - 1} instead of {twentyFtCount}).
|
||||||
|
</p>
|
||||||
|
</AlertBox>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
Ready to submit. You'll review the unit rates before final
|
||||||
|
submission.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -543,7 +567,7 @@ export function Step8Review({
|
|||||||
leftSection={<Send size={16} />}
|
leftSection={<Send size={16} />}
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
loading={submitPending}
|
loading={submitPending}
|
||||||
disabled={submitPending}
|
disabled={submitPending || hasOdd20ft}
|
||||||
>
|
>
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user